Skip to content

Commit c0d2840

Browse files
author
Fernando Pinho
committed
robustness: polish parsers, cap reads, hide $HOME (Phase 8.4)
Phase 8.4 of the post-firebaguette audit closes 9 of the 17 remaining LOW findings, plus adds a Trust model section to SECURITY.md. Why: the LOW tier doesn't include single-shot exploits but it is a steady trickle of bad-UX-becoming-bad-security if left untouched (cleartext HTTP clones, orphan post-push commits, $HOME paths in CI logs, unbounded SKILL.md reads). Knocking them out now keeps the audit closure rate ahead of the next audit cycle. Code changes: * config::slug_for_url rejects http:// with a clear "use HTTPS" message (L1). MITM-on-clone surface gone. * skill::parse_frontmatter strips a leading UTF-8 BOM before checking the opening --- fence; files with a BOM are no longer treated as "no frontmatter" (L2). * skill::clean_value only strips quotes when they balance ("foo' is left as-is rather than silently coerced to foo) (L4). * git::reset_hard_to_parent helper; push.rs and detect.rs roll back the orphan commit when git push fails after git commit succeeded (L7). * skill::read_skill_md_bounded caps SKILL.md reads at 1 MiB and surfaces a per-skill warning instead of slurping a 5 GiB file (L8). * git::clone passes --no-recurse-submodules; cargo-dist's release workflow now uses submodules: false on actions/checkout (L12 + L13). * add.rs apply loop wrapped in per-skill IIFE: a single failure logs + continues + saves partial state (L15). Matches pull.rs (v0.1.4) and push.rs (v0.1.5). * fs_util::display_path renders a $HOME-rooted path as ~/<rest>; applied at every "library cache not found" site + the init JSON's cache_path field so /Users/<operator>/ no longer leaks to CI logs or agent-mode JSON (L17). * list.rs replaces a bare eprintln! with ui::log_warning (which is JSON-aware) (L18). Docs: * SECURITY.md gains a "Trust model" section that explicitly names the three trust boundaries (Trusted / Semi-trusted / Adversarial) plus an Out-of-scope list (compromised git binary, side-channel attacks). External auditors can now know where to look without reverse-engineering the code. Deferred items (recorded with reasons in the audit notes): * L3 (homograph warning) — needs unicode-confusables dep * L5 (NFC normalisation) — needs unicode-normalization dep * L6 (APFS case-insensitive collision warn) — UX-focused release * L9 (Cargo.toml caret-semantics doc) — contributor-docs pass * L10 (SLSA provenance) — release-workflow PR with its own dry-run * L11 (cache-slug uniqueness) — pre-v1 single-library, revisit later * L14 (fork destination prompt) — UX question for a broader fork review 11 new unit tests; cargo test: 147 pass; clippy clean; cargo audit clean.
1 parent c0fcb22 commit c0d2840

16 files changed

Lines changed: 424 additions & 69 deletions

File tree

.github/workflows/release.yml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ jobs:
5959
- uses: actions/checkout@v6
6060
with:
6161
persist-credentials: false
62-
submodules: recursive
62+
submodules: false
6363
- name: Install dist
6464
# we specify bash to get pipefail; it guards against the `curl` command
6565
# failing. otherwise `sh` won't catch that `curl` returned non-0
@@ -119,7 +119,7 @@ jobs:
119119
- uses: actions/checkout@v6
120120
with:
121121
persist-credentials: false
122-
submodules: recursive
122+
submodules: false
123123
- name: Install Rust non-interactively if not already installed
124124
if: ${{ matrix.container }}
125125
run: |
@@ -178,7 +178,7 @@ jobs:
178178
- uses: actions/checkout@v6
179179
with:
180180
persist-credentials: false
181-
submodules: recursive
181+
submodules: false
182182
- name: Install cached dist
183183
uses: actions/download-artifact@v7
184184
with:
@@ -228,7 +228,7 @@ jobs:
228228
- uses: actions/checkout@v6
229229
with:
230230
persist-credentials: false
231-
submodules: recursive
231+
submodules: false
232232
- name: Install cached dist
233233
uses: actions/download-artifact@v7
234234
with:
@@ -340,4 +340,4 @@ jobs:
340340
- uses: actions/checkout@v6
341341
with:
342342
persist-credentials: false
343-
submodules: recursive
343+
submodules: false

CHANGELOG.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,35 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66

77
## [Unreleased]
88

9+
## [0.1.6] - 2026-05-25
10+
11+
### Robustness & hygiene
12+
13+
Close the audit's Phase 8.4 "low-impact polish" batch: 9 of the 17 remaining LOW findings, plus a new "Trust model" section in SECURITY.md that documents the boundaries underlying all the v0.1.2 → v0.1.6 hardening work. The 8 deferred LOW items either need a new runtime dependency (homograph detection, Unicode normalization), require a release-workflow change (SLSA provenance), or interact with pre-v1 design questions (slug-collision uniqueness, fork-destination UX).
14+
15+
- **Force HTTPS in library URLs** (L1). `skillctl init http://github.com/owner/repo` was previously accepted and silently downgraded to cleartext for the initial clone. A network attacker on the operator's link could MITM the response and serve modified content. Now `slug_for_url` rejects `http://` with a clear "use HTTPS instead" message. SSH (`git@host:`, `ssh://`) is unchanged.
16+
- **UTF-8 BOM stripped before frontmatter parse** (L2). Some editors (Notepad on Windows, occasionally VS Code) prepend a `\u{feff}` BOM to UTF-8 files. The frontmatter parser saw `\u{feff}---` instead of `---` and treated the whole SKILL.md as "no frontmatter." Now the parser strips a leading BOM before checking the opening fence.
17+
- **Balanced quotes enforced in `clean_value`** (L4). `clean_value` was using `trim_matches(|c| c == '"' || c == '\'')` which silently stripped mismatched quotes — `"foo'` became `foo`. Mismatched quotes now pass through unchanged so the operator sees the malformed value and can fix it.
18+
- **`git push` failure rolls back the just-created commit** (L7). When `git commit` succeeds but `git push` fails (network blip, auth expiry), the local commit sat orphaned in the cache, ahead of upstream. The next `fetch_and_fast_forward` would silently `reset --hard @{upstream}` it away — or, post-M10, refuse to refresh because the working tree happened to get dirty in between. New `git::reset_hard_to_parent` helper, wired into both `push` and `detect`, restores the cache to a clean state on push failure.
19+
- **SKILL.md read capped at 1 MiB** (L8). `std::fs::read_to_string` for SKILL.md was unbounded — a 5 GiB file would be slurped silently into RAM during `discover`. New `read_skill_md_bounded` helper refuses to load more than 1 MiB and surfaces a per-skill warning instead.
20+
- **Submodule recursion disabled** (L12 + L13). `git clone` now passes `--no-recurse-submodules` explicitly so a malicious library with a `.gitmodules` pointing at attacker-controlled repos cannot pull-through during `skillctl init`. The cargo-dist release workflow's `actions/checkout` steps switched from `submodules: recursive` to `submodules: false` (we have no submodules; this is defense-in-depth that survives the next `cargo dist init` regeneration). Skills do not use submodules; if a legitimate use case appears, it can be opt-in via an explicit flag later.
21+
- **`add` continues on per-skill failure** (L15). The apply loop in `add` used `?` for `fs::remove_dir_all`, `copy_dir_all`, and the `source_path` strip-prefix — a single per-skill failure aborted the whole batch, and `.skills.toml` was only saved at the end, so partial successes were untracked. Now each skill is wrapped in an IIFE that logs a warning + continues on failure, and `project_config::save` always runs (capturing partial state). Same pattern as `pull` (v0.1.4) and `push` (v0.1.5).
22+
- **`$HOME` rendered as `~/` in displayed paths** (L17). Absolute paths in error messages and JSON output (`library cache not found at /Users/<operator>/Library/Caches/...`) leaked the operator's Unix username into CI logs and agent-mode JSON. New `fs_util::display_path(&path)` swaps a leading `$HOME` with `~/` and is applied at every "library cache not found" / cache-path-display site.
23+
- **`list`'s `eprintln!` routed through `ui::log_warning`** (L18). A single bare `eprintln!("warning: could not refresh library cache (...)")` in `list` bypassed the `--json` gating, polluting JSON consumers' stderr with non-JSON text. Now routed through the shared `ui::log_warning` helper, which is JSON-aware.
24+
- **SECURITY.md trust-model section**. New section that explicitly names the three trust boundaries — Trusted (operator's machine, interactive flags, the binary itself), Semi-trusted (library URL and cache), Adversarial (frontmatter, `.skills.toml`, git working tree, non-interactive flag values) — plus an explicit Out-of-scope list (compromised git binary, side-channel attacks). External auditors and contributors can now know where to look without reverse-engineering the code.
25+
26+
11 new unit tests (1 HTTPS-required, 1 BOM strip, 4 balanced-quote, 2 SKILL.md size cap, 3 `$HOME` rendering). `cargo test`: 147 pass; clippy clean; `cargo audit` clean.
27+
28+
**Deferred to a future release** (with reasons, since v0.1.6 explicitly chose to keep the scope minimal):
29+
30+
- **L3** (homograph warning, e.g. Cyrillic `а` vs Latin `a` in skill names). Needs a `unicode-confusables` (or similar) dep; warrants its own decision before adding a runtime crate.
31+
- **L5** (NFC normalisation of paths/names). Needs `unicode-normalization`; same reasoning.
32+
- **L6** (case-insensitive FS collision warning on APFS-CI). No new dep but ~30 lines of runtime logic; deferred to a UX-focused release.
33+
- **L9** (Cargo.toml caret-semantics doc). Documentation-only; will land alongside a broader contributor-docs pass.
34+
- **L10** (SLSA provenance / cosign attestations on release binaries). Release-workflow change; deserves its own PR + dry-run on a tag.
35+
- **L11** (cache-slug collision via hash suffix). Pre-v1 with one-library-at-a-time, slug collisions are theoretical only; revisit if multi-library support lands.
36+
- **L14** (prompt operator on fork destination instead of inheriting the source's parent). UX question best decided alongside a broader `fork` flow review.
37+
938
## [0.1.5] - 2026-05-22
1039

1140
### Security & robustness

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "skillctl"
3-
version = "0.1.5"
3+
version = "0.1.6"
44
edition = "2024"
55
rust-version = "1.85"
66
description = "CLI to manage your personal agent skills library across projects"

SECURITY.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,35 @@
77
- It reads YAML-like frontmatter from `SKILL.md` files and writes TOML to `.skills.toml` and the global config file.
88
- There is no network surface beyond what `git` does, no privileged operations, and no telemetry.
99

10+
## Trust model
11+
12+
`skillctl`'s internal validation and sanitisation is shaped by the following trust boundaries. External auditors and contributors should focus probes on the **adversarial** category — anything in the **trusted** category is taken at face value.
13+
14+
**Trusted (taken at face value):**
15+
16+
- The operator's machine — filesystem, `$PATH`, environment variables, git binary, git's configured credentials.
17+
- Flags typed by the operator on an interactive TTY. When skillctl runs interactively, `--dest <absolute-path>` and other path-accepting flags are accepted as-is.
18+
- The skillctl binary itself, its dependencies (audited via `cargo audit` on every release), and the configuration written to the per-user config file (which only skillctl writes).
19+
20+
**Semi-trusted (the operator chose the source but its content is treated as adversarial):**
21+
22+
- The library repository URL passed to `skillctl init`. The host (GitHub) is trusted; the *content* it serves is not.
23+
- The library cache (`~/Library/Caches/dev.umanio-agency.skills-cli/<slug>/`). Skillctl owns the directory but treats every file under it as adversarial after the initial clone.
24+
25+
**Adversarial (treated as untrusted in every code path that touches them):**
26+
27+
- `SKILL.md` frontmatter (`name`, `description`, `tags`) from any source — library cache, project tree, fork-locally targets. Sanitised at the discovery boundary; control bytes / ANSI / NUL / CRLF are rejected; oversize files (> 1 MiB) are refused.
28+
- `.skills.toml` entries, especially those that arrive via PR. `name`, `source_path`, `source_sha`, `destination` are validated at load: identifier-class for `name`, hex regex for `source_sha`, lexical subpath check (no `..`, no absolute, no Windows prefix) for the two `PathBuf` fields. Duplicates and unknown fields are rejected.
29+
- The library cache's git working tree and submodules. `git clone --no-recurse-submodules` blocks submodule pull-through; every git invocation runs with `-c core.hooksPath=/dev/null` so a malicious library cannot ship hook scripts.
30+
- Skill folder contents: symlinks, hardlinks (Unix), FIFOs, devices, and sockets are refused at copy time. File modes are masked to `0o644 | (src_mode & 0o100)` on Unix — only the user-execute bit propagates.
31+
- Non-interactive flag values (`--dest <path>` in `--json` / `--no-interaction` mode, where the "operator" may be an LLM running on attacker-supplied input). Absolute paths are rejected; `..` is always rejected.
32+
33+
**Out of scope (not defended against):**
34+
35+
- A compromised git binary or local credential helper. Skillctl uses whatever git you point it at; if `which git` returns a trojan, all bets are off.
36+
- A compromised library repository owned by a third party that the operator explicitly trusts (e.g. corporate skills repo). Skillctl reduces blast radius via the controls above, but cannot make a malicious-but-trusted library safe.
37+
- Side-channel attacks via filesystem timing, memory analysis, or OS-level surveillance. The threat model assumes a normal single-user developer machine.
38+
1039
## Reporting a vulnerability
1140

1241
If you find a security issue (e.g. a way to make `skillctl` write outside the destinations it's supposed to, or to leak credentials in error messages), please report it privately:

src/commands/add.rs

Lines changed: 95 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ pub fn run(args: AddArgs, ctx: &Context) -> Result<()> {
5858
if !library_root.exists() {
5959
return Err(AppError::Config(format!(
6060
"library cache not found at {} — run `skillctl init{}` again",
61-
library_root.display(),
61+
fs_util::display_path(&library_root),
6262
library.url
6363
))
6464
.into());
@@ -104,20 +104,43 @@ pub fn run(args: AddArgs, ctx: &Context) -> Result<()> {
104104
let mut aborted = false;
105105

106106
for skill in selected {
107-
let folder_name = skill.path.file_name().ok_or_else(|| {
108-
AppError::Config(format!(
109-
"skill has no folder name: {}",
110-
skill.path.display()
111-
))
112-
})?;
113-
let dest = dest_root.join(folder_name);
107+
let folder_name = match skill.path.file_name() {
108+
Some(n) => n.to_os_string(),
109+
None => {
110+
let _ = ui::log_warning(
111+
ctx,
112+
format!(
113+
"skipping {}: source has no folder name ({})",
114+
skill.name,
115+
skill.path.display()
116+
),
117+
);
118+
results.push(json!({
119+
"name": skill.name,
120+
"status": "failed",
121+
"reason": "source has no folder name",
122+
}));
123+
continue;
124+
}
125+
};
126+
let dest = dest_root.join(&folder_name);
114127

115128
if dest.exists() {
116129
let action = resolve_conflict(ctx, &dest, conflict_policy.clone())?;
117130
match action {
118131
ConflictAction::Overwrite => {
119-
fs::remove_dir_all(&dest)
120-
.with_context(|| format!("removing {}", dest.display()))?;
132+
if let Err(e) = fs::remove_dir_all(&dest)
133+
.with_context(|| format!("removing {}", dest.display()))
134+
{
135+
let _ =
136+
ui::log_warning(ctx, format!("add failed for `{}`: {e}", skill.name));
137+
results.push(json!({
138+
"name": skill.name,
139+
"status": "failed",
140+
"reason": e.to_string(),
141+
}));
142+
continue;
143+
}
121144
}
122145
ConflictAction::Skip => {
123146
ui::log_info(ctx, format!("skipped {}", skill.name))?;
@@ -129,6 +152,9 @@ pub fn run(args: AddArgs, ctx: &Context) -> Result<()> {
129152
continue;
130153
}
131154
ConflictAction::Abort => {
155+
// Save what's been installed up to this point before
156+
// bailing — we may already have pushed entries for
157+
// earlier-loop-iteration skills.
132158
project_config::save(&cwd, &project_cfg)?;
133159
ui::outro_cancel(ctx, "aborted")?;
134160
results.push(json!({
@@ -142,35 +168,67 @@ pub fn run(args: AddArgs, ctx: &Context) -> Result<()> {
142168
}
143169
}
144170

145-
fs_util::copy_dir_all(&skill.path, &dest)?;
146-
let source_path = skill
147-
.path
148-
.strip_prefix(&library_root)
149-
.with_context(|| {
150-
format!(
151-
"computing path of {} relative to library at {}",
152-
skill.path.display(),
153-
library_root.display()
154-
)
155-
})?
156-
.to_path_buf();
157-
let destination_rel = fs_util::relative_to_or_self(&dest, &cwd);
158-
project_cfg.installed.push(InstalledSkill {
159-
name: skill.name.clone(),
160-
source_path,
161-
source_sha: source_sha.clone(),
162-
destination: destination_rel.clone(),
163-
installed_at: installed_at.clone(),
164-
});
165-
ui::log_success(ctx, format!("{} → {}", skill.name, dest.display()))?;
166-
results.push(json!({
167-
"name": skill.name,
168-
"status": "installed",
169-
"path": destination_rel.display().to_string(),
170-
"source_sha": source_sha,
171-
}));
171+
// Per-skill IIFE: a single skill's copy failure (symlink reject,
172+
// hardlink reject, FIFO inside, oversize SKILL.md, ...) logs and
173+
// continues with the next skill instead of aborting the batch and
174+
// orphaning partial work. The final `project_config::save` below
175+
// captures every successful entry, so a half-finished `--all` run
176+
// still produces a usable `.skills.toml`.
177+
let outcome: Result<InstalledSkill> = (|| {
178+
fs_util::copy_dir_all(&skill.path, &dest)?;
179+
let source_path = skill
180+
.path
181+
.strip_prefix(&library_root)
182+
.with_context(|| {
183+
format!(
184+
"computing path of {} relative to library at {}",
185+
skill.path.display(),
186+
library_root.display()
187+
)
188+
})?
189+
.to_path_buf();
190+
let destination_rel = fs_util::relative_to_or_self(&dest, &cwd);
191+
Ok(InstalledSkill {
192+
name: skill.name.clone(),
193+
source_path,
194+
source_sha: source_sha.clone(),
195+
destination: destination_rel,
196+
installed_at: installed_at.clone(),
197+
})
198+
})();
199+
match outcome {
200+
Ok(entry) => {
201+
let dest_display = entry.destination.display().to_string();
202+
let source_sha_for_json = entry.source_sha.clone();
203+
project_cfg.installed.push(entry);
204+
ui::log_success(ctx, format!("{} → {}", skill.name, dest.display()))?;
205+
results.push(json!({
206+
"name": skill.name,
207+
"status": "installed",
208+
"path": dest_display,
209+
"source_sha": source_sha_for_json,
210+
}));
211+
}
212+
Err(e) => {
213+
// Best-effort cleanup of any partial copy left behind by
214+
// copy_dir_all's tempdir-or-final-rename path. The atomic
215+
// swap pattern means dst is either old or new content, so
216+
// there's typically nothing to undo — but if the failure
217+
// happened during the staging copy we may still have a
218+
// `.skillctl-tmp.*` sibling around (it normally cleans
219+
// itself up; this is a paranoid sweep).
220+
let _ = ui::log_warning(ctx, format!("add failed for `{}`: {e}", skill.name));
221+
results.push(json!({
222+
"name": skill.name,
223+
"status": "failed",
224+
"reason": e.to_string(),
225+
}));
226+
}
227+
}
172228
}
173229

230+
// Always save: captures both fully-successful batches and partial
231+
// successes after one or more per-skill failures.
174232
project_config::save(&cwd, &project_cfg)?;
175233

176234
if !aborted {

src/commands/detect.rs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ pub fn run(args: DetectArgs, ctx: &Context) -> Result<()> {
4141
if !library_root.exists() {
4242
return Err(AppError::Config(format!(
4343
"library cache not found at {} — run `skillctl init{}` again",
44-
library_root.display(),
44+
fs_util::display_path(&library_root),
4545
library.url
4646
))
4747
.into());
@@ -169,7 +169,16 @@ pub fn run(args: DetectArgs, ctx: &Context) -> Result<()> {
169169
format!("add skills: {}", names.join(", "))
170170
};
171171
let new_sha = git::commit(&library_root, &message).map_err(|e| AppError::Git(e.to_string()))?;
172-
git::push(&library_root).map_err(|e| AppError::Git(e.to_string()))?;
172+
// Symmetric with push.rs: roll back the orphaned commit on push failure.
173+
if let Err(e) = git::push(&library_root) {
174+
if let Err(rollback_err) = git::reset_hard_to_parent(&library_root) {
175+
let _ = ui::log_warning(
176+
ctx,
177+
format!("could not roll back the local commit after push failure: {rollback_err}"),
178+
);
179+
}
180+
return Err(AppError::Git(e.to_string()).into());
181+
}
173182

174183
let installed_at = OffsetDateTime::now_utc()
175184
.format(&Rfc3339)

src/commands/diff.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ pub fn classify(
5959
None => {
6060
return Err(anyhow::anyhow!(
6161
"library HEAD doesn't resolve in the cache at {}",
62-
library_root.display()
62+
crate::fs_util::display_path(library_root)
6363
));
6464
}
6565
};

0 commit comments

Comments
 (0)