You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Phase 8.3 of the post-firebaguette audit closes 13 MEDIUM findings plus the
deferred push-side half of H8.
Why: the audit's M-tier surface (credentials in logs, recursive walkers,
unbounded parsers, hooks-via-shared-cache, silent state destruction)
doesn't include single-shot exploits but is a steady leak of trust if
left in. Closing it now keeps the threat model coherent before MEDIUM
items grow into HIGH ones at scale.
Headline primitives this ship adds:
* git_cmd() — every git invocation prepends `-c core.hooksPath=/dev/null`
so a malicious library cannot weaponise the operator's global hooks
config (M12).
* scrub_stderr() — first line, no control bytes, redact ghp_/gho_/ghs_/
ghu_/github_pat_/x-access-token: tokens (M3).
* fetch_and_fast_forward now refuses to reset --hard when porcelain is
non-empty — prevents silent destruction of state from a previously
crashed run (M10).
* config::sanitize_url_for_display — userinfo stripped from stored URL
so PATs never land in config.toml / JSON / error chains (M1).
* skill::discover takes include_vendored: bool — defaults to false
(respect .gitignore + skip node_modules/target). New --include-vendored
flag on detect (M11).
* parse_frontmatter capped at 200 lines; unterminated frontmatter
yields no metadata instead of scanning the whole body (M4).
* sanitize::validate_fork_name consolidated and hardened (control char
reject + 64-byte cap) (M5).
* ProjectConfig + InstalledSkill get deny_unknown_fields; dedup by name
and destination at load; entry count capped at 256 (M6).
* copy_dir_all rewritten as an explicit work-stack loop (M7); on Unix,
copied file modes masked to 0o644|(src_mode & 0o100) so setuid/setgid/
sticky/group-write/world-write are stripped (M8).
* detect's "already installed" dedup unions canonical-path AND
lexical-path comparison so a deleted-then-replaced destination
can't be silently re-detected as new (M9, via new
path_safety::normalize_lexical).
* push apply loop is now continue-on-error per skill, with
git::checkout_paths to roll back the cache working tree for a failed
skill before continuing — closes the deferred push-side of H8.
* README + SECURITY.md document the canonical Homebrew tap install and
the typo-squat risk (M13).
13 new unit tests; cargo test: 136 pass; clippy clean; cargo audit clean.
Copy file name to clipboardExpand all lines: CHANGELOG.md
+21Lines changed: 21 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -6,6 +6,27 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
7
7
## [Unreleased]
8
8
9
+
## [0.1.5] - 2026-05-22
10
+
11
+
### Security & robustness
12
+
13
+
Close the comprehensive audit's Phase 8.3: 13 MEDIUM findings plus the deferred push-side half of H8. No item here is single-shot exploitable, but each closes a credibility-eroding leak (credentials in logs), DoS vector (unbounded parsers, recursive walkers), or footgun (silently-discarded state, hook execution via shared cache).
14
+
15
+
-**Credentials stripped from stored `library.url`** (M1). `skillctl init https://x-access-token:<PAT>@github.com/...` would store the full URL — token and all — in `config.toml`, then echo it back in JSON output, error chains, and CI logs. `init` now sanitises the URL (strips `user[:password]@` from `https://`/`http://` authority sections) before persisting; the one-time `git clone` still uses the original URL for authentication, but the token never lands on disk or in any later command's output. SSH forms (`git@host:...`, `ssh://git@host/...`) are unchanged.
16
+
-**Git stderr scrubbed in every error chain** (M3). Each `git`-shell-out site used `String::from_utf8_lossy(&stderr).trim()` — which would faithfully echo credential-helper banners, proxy URLs containing PATs, ANSI control sequences, and stack traces past the first line. The new `git::scrub_stderr` helper takes the first non-empty line, strips C0/C1/DEL/ESC control bytes, and redacts known token prefixes (`ghp_*`, `gho_*`, `ghs_*`, `ghu_*`, `github_pat_*`, `x-access-token:*`) to `<prefix>***`. Applied uniformly across every git invocation.
17
+
-**`core.hooksPath` neutralised on every git call** (M12). The library cache is a git repo whose `.git/config` is reachable from inside skill content. A malicious library that dropped a script at the operator's globally-configured `core.hooksPath` would have it executed by any `git commit` in the cache. Every `Command::new("git")` now goes through a `git_cmd()` helper that prepends `-c core.hooksPath=/dev/null`, so hook execution is impossible regardless of global or in-cache git config.
18
+
-**`git status --porcelain` check before `reset --hard @{upstream}`** (M10). `fetch_and_fast_forward` used to unconditionally `git reset --hard @{upstream}`, silently destroying any uncommitted state left over from a previous skillctl run that crashed mid-commit (e.g. `replace_folder_contents` succeeded but `git push` failed). Now refuses to refresh when the cache reports any porcelain output, surfacing a clear "uncommitted changes — inspect with `git -C <cache> status`" message so the operator can investigate before any destruction happens.
19
+
-**Frontmatter parser bounded at 200 lines** (M4). A SKILL.md with an opening `---` but no closing fence would force the parser to scan the entire (potentially multi-GiB) body — a cheap DoS reachable on every `skill::discover` call. Capped to `MAX_FRONTMATTER_LINES = 200`; unterminated frontmatter is now treated as "no frontmatter" (the skill is dropped from discovery rather than half-parsed).
20
+
-**`validate_fork_name` rejects control characters and caps length** (M5). The previous fork-name validator only rejected empty / `.` / `..` / path separators — a name like `foo\0bar` would panic inside `CString::new` when later passed to `Command`. Now rejects any control char (NUL, ESC, ANSI, DEL, newline, CR, tab) and caps at 64 bytes. Consolidated as `sanitize::validate_fork_name` (was duplicated between `push.rs` and `pull.rs`).
21
+
-**`.skills.toml` rejects unknown fields, duplicates, and overflow** (M6). Added `#[serde(deny_unknown_fields)]` on `ProjectConfig` and `InstalledSkill`, so a malicious PR can no longer smuggle unknown keys (which might later be load-bearing for an unreleased feature) into the deserialiser. Duplicate `name` or `destination` entries are rejected at load — silent dedup would make every command ambiguous about which entry wins. Capped at 256 entries to bound the diff-classifier work.
22
+
-**`copy_dir_all` is iterative and masks mode bits** (M7 + M8). Converted from recursion to an explicit `Vec<(PathBuf, PathBuf)>` work stack, so an adversarial skill with 10k-deep nesting can no longer blow Rust's default 8 MiB thread stack. On Unix, copied file modes are now masked to `0o644 | (src_mode & 0o100)` — only the user-execute bit propagates; setuid, setgid, sticky, group-write, world-write, group-execute and world-execute are stripped. A library that drop-ins a setuid binary cannot weaponise the round-trip into elevated privileges on the destination.
23
+
-**`detect` dedup unions canonical AND lexical comparison** (M9). The "already installed" set was built from `fs::canonicalize` only — silently dropping entries whose destination had been deleted from disk. An attacker who removed `.claude/skills/foo/` and dropped a replacement at the same path would have it re-detected as a new skill on the next `detect`. Now compares by canonical path (when both ends exist) AND lexical path (covers the deleted-destination case via the new `path_safety::normalize_lexical` helper).
24
+
-**`detect` walker respects `.gitignore` and skips vendor dirs by default** (M11). A malicious npm package shipping its own `SKILL.md` under `node_modules/...` could be picked up by `skillctl detect --all` running in CI and uploaded to the library. `skill::discover` now takes an `include_vendored` parameter; the default (false) leans on `ignore::WalkBuilder`'s `.gitignore`/`.ignore` respect plus a hard-skip on `node_modules`/`target`. New CLI flag `skillctl detect --include-vendored` for the explicit opt-in.
25
+
-**Homebrew tap typo-squat documented** (M13). Both README and SECURITY.md now prominently call out the canonical fully-qualified install (`brew install umanio-agency/homebrew-tap/skillctl`) and explain that anyone can ship a `skillctl.rb` formula under their own `homebrew-tap` repo. Pinning the owner avoids the typo-squat risk.
26
+
-**`push --all` continues on per-skill failure** (H8 push-side). The pre-v0.1.5 apply loop used `?` inside the per-skill body, so one failing skill aborted the entire batch and orphaned the cache's working tree for the successful early skills (commit + push never happened, cache stayed dirty until the next `fetch_and_fast_forward` reset it). Now each apply is wrapped in an IIFE: on per-skill failure, the change is rolled back with `git checkout HEAD -- <library_relative>`, a warning is logged, and the loop continues. If all skills fail, the command exits cleanly with "nothing pushed". This closes the half of H8 deferred from v0.1.4.
Copy file name to clipboardExpand all lines: README.md
+2Lines changed: 2 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -43,6 +43,8 @@ Plus `init` (link a library). Every multi-skill flow supports `--tag` filtering
43
43
brew install umanio-agency/homebrew-tap/skillctl
44
44
```
45
45
46
+
Always use the **fully-qualified** form above — `<tap-owner>/<tap-repo>/skillctl`. The unqualified `brew install skillctl` would resolve to whichever tap is currently active in your Homebrew installation, and anyone can create a `homebrew-tap` repo under their own GitHub user and ship a `skillctl.rb` formula. Pinning the tap owner (`umanio-agency`) avoids that typo-squat risk.
Copy file name to clipboardExpand all lines: SECURITY.md
+6Lines changed: 6 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -19,3 +19,9 @@ Please do not open a public issue for security reports. We aim to acknowledge wi
19
19
## Supported versions
20
20
21
21
The project is pre-v1; only the `main` branch is supported and security fixes land there. Once v1 ships, this section will document the supported release range.
22
+
23
+
## Install channel hygiene
24
+
25
+
-**Homebrew:** always use the fully-qualified tap name — `brew install umanio-agency/homebrew-tap/skillctl`. Homebrew taps are namespaced by their GitHub owner, and anyone can create a `homebrew-tap` repo and publish a `skillctl.rb` formula. Pinning the owner (`umanio-agency`) prevents typo-squat attacks where a malicious tap is added before the official one.
26
+
-**crates.io:** the published crate is `skillctl` (owner: `pinho.dcj@gmail.com`). The historical name `skills-cli` is owned by an unrelated third party and is **not** affiliated with this project.
27
+
-**Direct binaries:** the `curl | sh` and PowerShell installers serve assets from `github.com/umanio-agency/skillctl/releases/latest/download/…` and verify SHA-256 sums published alongside each release.
0 commit comments