Skip to content

Commit c0fcb22

Browse files
author
Fernando Pinho
committed
robustness: harden parsers, mask modes, scrub credentials (Phase 8.3)
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.
1 parent 67d280f commit c0fcb22

19 files changed

Lines changed: 1012 additions & 122 deletions

CHANGELOG.md

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

77
## [Unreleased]
88

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.
27+
28+
13 new unit tests added (3 path_safety lexical normalisation, 3 sanitize fork-name hardening, 4 `.skills.toml` deny/dedup/cap, 3 discover gitignore/node_modules/include-vendored, 2 frontmatter bound, 7 git stderr scrub, 3 fs_util mode-mask + deep nesting). `cargo test`: 136 pass; clippy clean; `cargo audit` clean.
29+
930
## [0.1.4] - 2026-05-22
1031

1132
### 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.4"
3+
version = "0.1.5"
44
edition = "2024"
55
rust-version = "1.85"
66
description = "CLI to manage your personal agent skills library across projects"

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ Plus `init` (link a library). Every multi-skill flow supports `--tag` filtering
4343
brew install umanio-agency/homebrew-tap/skillctl
4444
```
4545

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.
47+
4648
### Cargo (any platform with Rust 1.85+)
4749

4850
```sh

SECURITY.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,9 @@ Please do not open a public issue for security reports. We aim to acknowledge wi
1919
## Supported versions
2020

2121
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.

src/cli.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,13 @@ pub struct DetectArgs {
190190
/// `skills`, or `.claude/skills`). Required in non-interactive mode.
191191
#[arg(long, value_name = "PATH")]
192192
pub target: Option<PathBuf>,
193+
194+
/// Also walk paths normally ignored by `.gitignore` (e.g.
195+
/// `node_modules/`, `vendor/`, `Pods/`). By default `detect` respects
196+
/// the project's `.gitignore` so a `SKILL.md` smuggled inside a
197+
/// third-party dependency cannot be silently shipped to the library.
198+
#[arg(long)]
199+
pub include_vendored: bool,
193200
}
194201

195202
#[derive(Clone, Copy, Debug, ValueEnum)]

src/commands/add.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ pub fn run(args: AddArgs, ctx: &Context) -> Result<()> {
7474
)?;
7575
}
7676

77-
let skills = skill::discover(&library_root)?;
77+
let skills = skill::discover(&library_root, false)?;
7878
if skills.is_empty() {
7979
ui::outro(ctx, format!("no skills found in {}", library.url))?;
8080
emit_json(ctx, None, &[]);

src/commands/detect.rs

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ use crate::error::AppError;
1717
use crate::fs_util;
1818
use crate::git;
1919
use crate::lock;
20-
use crate::path_safety::validate_relative_subpath;
20+
use crate::path_safety::{normalize_lexical, validate_relative_subpath};
2121
use crate::project_config::{self, InstalledSkill};
2222
use crate::skill::{self, Skill};
2323
use crate::ui;
@@ -59,18 +59,33 @@ pub fn run(args: DetectArgs, ctx: &Context) -> Result<()> {
5959
let _project_lock = lock::acquire_exclusive(&cwd, "project")?;
6060
let mut project_cfg = project_config::load(&cwd)?;
6161

62+
// Dedup keys for "already tracked in .skills.toml". Use BOTH the
63+
// canonical and the lexical path: canonical handles symlinks and
64+
// NFD/NFC variants on macOS when the destination exists; lexical
65+
// covers the case where a tracked destination was deleted from disk
66+
// (otherwise an attacker who removed `.claude/skills/foo/` and dropped
67+
// a malicious replacement at the same path would have it re-detected
68+
// as a new skill on the next `detect`).
6269
let installed_canonical: HashSet<PathBuf> = project_cfg
6370
.installed
6471
.iter()
6572
.filter_map(|i| std::fs::canonicalize(cwd.join(&i.destination)).ok())
6673
.collect();
74+
let installed_lexical: HashSet<PathBuf> = project_cfg
75+
.installed
76+
.iter()
77+
.map(|i| normalize_lexical(&cwd.join(&i.destination)))
78+
.collect();
6779

68-
let local_skills = skill::discover(&cwd)?;
80+
let local_skills = skill::discover(&cwd, args.include_vendored)?;
6981
let new_skills: Vec<Skill> = local_skills
7082
.into_iter()
71-
.filter(|s| match std::fs::canonicalize(&s.path) {
72-
Ok(c) => !installed_canonical.contains(&c),
73-
Err(_) => true,
83+
.filter(|s| {
84+
let canonical_match = std::fs::canonicalize(&s.path)
85+
.ok()
86+
.is_some_and(|c| installed_canonical.contains(&c));
87+
let lexical_match = installed_lexical.contains(&normalize_lexical(&s.path));
88+
!canonical_match && !lexical_match
7489
})
7590
.collect();
7691

src/commands/init.rs

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,14 @@ pub fn run(args: InitArgs, ctx: &Context) -> Result<()> {
1414

1515
git::ensure_available().map_err(|e| AppError::Git(e.to_string()))?;
1616

17-
let url = args.url;
18-
let dest = config::library_cache_path(&url).map_err(|e| AppError::Config(e.to_string()))?;
17+
// `args.url` may carry an embedded `user:token@` for the one-time clone;
18+
// we use it as-is to call `git clone`, but never persist or echo it.
19+
// `display_url` is the sanitised form that lands in `config.toml`,
20+
// logs, JSON output, and any later error chain referencing `library.url`.
21+
let clone_url = args.url;
22+
let display_url = config::sanitize_url_for_display(&clone_url);
23+
let dest =
24+
config::library_cache_path(&display_url).map_err(|e| AppError::Config(e.to_string()))?;
1925

2026
if dest.exists() {
2127
fs::remove_dir_all(&dest)
@@ -26,21 +32,26 @@ pub fn run(args: InitArgs, ctx: &Context) -> Result<()> {
2632
.with_context(|| format!("creating cache dir at {}", parent.display()))?;
2733
}
2834

29-
ui::log_info(ctx, format!("cloning {url} into {} ...", dest.display()))?;
30-
git::clone(&url, &dest).map_err(|e| AppError::Git(e.to_string()))?;
35+
ui::log_info(
36+
ctx,
37+
format!("cloning {display_url} into {} ...", dest.display()),
38+
)?;
39+
git::clone(&clone_url, &dest).map_err(|e| AppError::Git(e.to_string()))?;
3140

3241
let config = Config {
33-
library: Some(Library { url: url.clone() }),
42+
library: Some(Library {
43+
url: display_url.clone(),
44+
}),
3445
};
3546
config::save(&config).map_err(|e| AppError::Config(e.to_string()))?;
3647

37-
ui::log_success(ctx, format!("library configured: {url}"))?;
48+
ui::log_success(ctx, format!("library configured: {display_url}"))?;
3849

3950
if ctx.json {
4051
let out = serde_json::json!({
4152
"command": "init",
4253
"library": {
43-
"url": url,
54+
"url": display_url,
4455
"cache_path": dest.display().to_string(),
4556
}
4657
});

src/commands/list.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ pub fn run(args: ListArgs, ctx: &Context) -> Result<()> {
3737
eprintln!("warning: could not refresh library cache ({e}); using cached version");
3838
}
3939

40-
let skills = skill::discover(&repo)?;
40+
let skills = skill::discover(&repo, false)?;
4141
let filtered: Vec<_> = skills
4242
.into_iter()
4343
.filter(|s| matches_tags(&s.tags, &args.tags, args.all_tags))

0 commit comments

Comments
 (0)