diff --git a/docs/evidence/audit-remediation-20261018-root-normalization/red-proofs.md b/docs/evidence/audit-remediation-20261018-root-normalization/red-proofs.md new file mode 100644 index 0000000..6c7dcfa --- /dev/null +++ b/docs/evidence/audit-remediation-20261018-root-normalization/red-proofs.md @@ -0,0 +1,125 @@ +# QA Red-Proof Evidence Log — OH-6.5-root-normalization (P0) + OH-6.5-embedded-placeholder (P1) + +- Branch: `fix/oh-6-5-root-normalization-embedded-placeholder` +- Worktree: `/home/john/Projects/Heretek-AI-Rebuild/opencode-harness/worktrees/audit-remediation` +- QA date (UTC): 2026-10-18 +- Base commit: `551e5fe` (Audit remediation 20261018, 1.0.11) +- Full-context reads completed: AGENTS.md, SPEC.md, AUDIT_REPORT_FINDINGS.md + (§OH-6.5-root-normalization lines 77-139 + §OH-6.5-embedded-placeholder + lines 141-172, verbatim), programmer's report + `docs/reports/audit-remediation-20261018-root-normalization.md`, + `src/guards/shell-parse.ts`, `test/guards.test.ts`, `docs/prompts/verify-stage.md`. + +## 1. Acceptance gates (exact commands + exit codes, from inside the worktree) + +| # | Command | Exit | Result | +|---|---------|------|--------| +| 1 | `bun run check` (tsc --noEmit) | 0 | PASS | +| 2 | `bun test test/guards.test.ts` (stock TMPDIR) | 0 | PASS — 41 pass, 0 fail, 497 expects (was 32 tests; +9 new) | +| 3 | `bun test` (stock TMPDIR) | 1 | FAIL *solely* on host `/tmp` inode-exhaustion (ENOSPC on `mkdtemp /tmp/*`); 302 pass / 65 fail, every failure is `ENOSPC: no space left on device, mkdtemp '/tmp/…'` — no assertion failure. Documented host condition, matches audit baseline. | +| 4 | `TMPDIR=/dev/shm/qa-tmp bun test` (override, override dir pre-created) | 1 | 365 pass / 2 fail. Both failures are the audit-documented TMPDIR-flip pair (`OH-6.5-redirect-escape` OS-temp allow + `OH-6.5-cp-mv-argv-targets` absolute-source allow) which pass with stock TMPDIR. | +| 5 | `TMPDIR=/dev/shm/qa-tmp bash scripts/accept/wave-1.sh` | 0 | PASS — `WAVE 1 ACCEPT: ALL GATES GREEN` (84 tests, zero-stub/scope scans clean) | +| 6 | `TMPDIR=/dev/shm/qa-tmp make accept WAVE=1` | 0 | PASS — `WAVE 1 ACCEPT: ALL GATES GREEN` | + +Pre-existing proof for the 2 TMPDIR-flip failures (git stash A/B): +- `git stash push -- src/guards/shell-parse.ts test/guards.test.ts` (pristine tree) + + `TMPDIR=/dev/shm/qa-tmp bun test` → 356 pass / **2 fail** (same 2 tests), exit 1. +- `git stash pop` → programmer changes restored (`M src/guards/shell-parse.ts`, + `M test/guards.test.ts`). +- Conclusion: the 2 failures are environmental (documented `/tmp` ENOSPC workaround + flips the `tmpdir()` allowlist at `permission-guard.ts:522`), NOT regressions. + Matches the audit's own note ("both pass with stock TMPDIR") and the programmer's + report stash A/B. + +## 2. Independent probe matrix (public guard API, no code change) + +Probe driver: `bun -e` importing `classifyShellCommand` / +`isDestructiveShellCommand` (`src/guards/shell-parse.ts`), `evaluateBoardGuard` ++ `defaultGuardConfig` (`src/guards/permission-guard.ts`), `evaluateTaintGuard` ++ `recordUntrustedContentTaint` + `clearTaintState` (`src/guards/taint-guard.ts`). +Roles probed per row: builder, architect, director, qa-adversary, agency-qa +(+ tainted-session layer per row). Exit 0, `PROBE-MATRIX: ALL EXPECTED`. + +ATTACK rows — every one: `class=destructive`, `destr=true`, +`DENY:rule-1-destructive` for all 5 roles, `taint=DENY:TAINT-DESTRUCTIVE-1`: +- `rm -rf //`, `rm -rf ///`, `rm -rf /.`, `rm -rf /./`, `rm -rf /../`, + `rm -rf /tmp/../`, `rm -rf ~root`, `rm -rf ~root/.ssh` +- `find // -exec rm -rf {} \;`, `find /./ -exec rm -rf {} \;`, + `find /tmp/../ -exec rm -rf {} \;`, `find .. -exec rm -rf {} \;`, + `find ~root -exec rm -rf {} \;` +- `find // -delete`, `find /tmp/../ -delete`, `find .. -delete` +- `find / -exec rm -rf {}/foo \;`, `find / -exec rm -rf {}foo \;`, + `find / -exec rm -rf prefix{} \;`, `find / -exec rm -rf -- {}/foo \;` +- `find / -ok rm -rf {}/foo \;`, `find / -ok rm -rf {}foo \;`, + `find / -ok rm -rf prefix{} \;` + +CONTROL rows — every one: `class=write`, builder `ALLOW` (no over-blocking): +- `rm -rf /tmp`, `rm -rf /tmp/wave1-abcd`, `rm -rf /var/tmp/app` +- `find /tmp -name x -exec rm -rf {} \;`, + `find /tmp -name x -exec rm -rf {}/foo \;`, + `find . -exec rm -rf {} \;`, `find . -exec rm -rf {}/foo \;`, + `find . -name x -exec rm -rf {}/foo \;`, `find /tmp -name x -delete` + +## 3. Red-proof sabotage log (distinct per invariant) + +Restore method note: the mandated `git checkout -- ` would destroy the +programmer's uncommitted remediation (the file carries the fix under test), so +each sabotage was applied as a minimal `edit` on top of the remediated file and +reverted with the exact inverse `edit`; restoration is proven by byte-identical +sha256 vs the pre-sabotage hash plus `git status`/`git diff --stat` showing only +the intended programmer modifications. Pre-sabotage hashes: +`shell-parse.ts = 958708d5a426e47fa012e0c64070afb3078cc399038c376c330e0e82dd5c7c26`, +`guards.test.ts = c3e877e58c692905d1f0a55dec49f7d8885eccc2d29fd34f79be1269bac49e48`. + +- Sabotage A (OH-6.5-embedded-placeholder invariant): + file:line `src/guards/shell-parse.ts:905`, + before `if (a.includes("{}")) {` → after `if (a === "{}") {`. + Suite: `bun test test/guards.test.ts` → **38 pass / 3 fail, EXIT 1** (RED — + the 3 embedded-`{}` guard tests fail, incl. the taint-layer test). + Reverted via inverse edit; post-restore sha256 `958708d5…` identical; + `bun test test/guards.test.ts` → 41 pass / 0 fail, EXIT 0 (GREEN). +- Sabotage B (OH-6.5-root-normalization invariant): + file:line `src/guards/shell-parse.ts:981` (`normalizeWipeTarget`), + before `return posix.normalize(s.replace(…).replace(/\/+/g, "/"));` + → after `return s.replace(…).replace(/\/+/g, "/");` + (slash-collapse only, no `..`/root resolution — exact-match-only behavior). + Suite: `bun test test/guards.test.ts` → **38 pass / 3 fail, EXIT 1** (RED — + the 3 root-normalization guard tests fail, incl. the taint-layer test). + Reverted via inverse edit; post-restore sha256 `958708d5…` identical; + `bun test test/guards.test.ts` → 41 pass / 0 fail, EXIT 0 (GREEN). + +Both checks are non-vacuous: each breaks RED under targeted sabotage and returns +GREEN on restoration. `git status --short` after each restore shows only: +`M src/guards/shell-parse.ts`, `M test/guards.test.ts`, +`?? docs/reports/audit-remediation-20261018-root-normalization.md` +(+ this evidence file) — no sabotage residue in tracked production files. + +## 4. Stub / shortcut scan (changed files) + +- `grep -rnE 'TODO|\|\| true|set \+e|cmd: "true"|test\.skip|describe\.skip|it\.skip|catch *\(.*\) *\{\}' src/guards/shell-parse.ts test/guards.test.ts` → zero hits (exit 1). +- `return true` occurrences audited: all are genuine guard-predicate outcomes + (destructive/write/allow decisions with surrounding logic), none vacuous. +- Wave-1 accept zero-stub scan (covers all of `src/` + `test/`) → clean (gate 5 above). +- No `|| true`, `set +e`, `cmd: "true"`, `test.skip`, empty catch, dummy passes. + +## 5. Scope compliance + +- No WebUI/HTTP-server/fleet/DAG/worktree/research-engine code in the changed + files. The only grep hits for scope terms are `scripts/accept/wave-1.sh`'s own + scope-refusal gate (comments + its grep pattern), which passes green. +- Single-session in-process OpenCode v2 plugin shape preserved; no new + dependencies (fix uses stdlib `node:path` only). +- Net `src/` delta stays under the OH-scope-loc-budget hard caps per the + programmer's report (raw 11981 ≤ 12000, stripped 9975 ≤ 10000; cap test green + under the TMPDIR override run). + +## QA verdict + +All verify-the-fix criteria from AUDIT_REPORT_FINDINGS.md hold: every Raw-output +probe row now classifies `destructive` + denies `rule-1-destructive` for +builder/architect/director/qa (+ agency-qa) and `TAINT-DESTRUCTIVE-1` while +tainted; all scoped controls stay `write`/ALLOW; gates green (modulo the proven +pre-existing TMPDIR-flip pair); both sabotages RED then restore byte-identical; +zero stubs; scope clean. + +QA ACCEPT diff --git a/docs/reports/audit-remediation-20261018-root-normalization-verification.md b/docs/reports/audit-remediation-20261018-root-normalization-verification.md new file mode 100644 index 0000000..5ef0fbd --- /dev/null +++ b/docs/reports/audit-remediation-20261018-root-normalization-verification.md @@ -0,0 +1,73 @@ +# Drift-Guard Verification: Audit Remediation 20261018 — OH-6.5-root-normalization (P0) + OH-6.5-embedded-placeholder (P1) + +- **Auditor role:** `agency-drift-guard` (read-only; no production code changed by auditor) +- **Branch / worktree:** `fix/oh-6-5-root-normalization-embedded-placeholder` @ `/home/john/Projects/Heretek-AI-Rebuild/opencode-harness/worktrees/audit-remediation` +- **Base commit:** `551e5fe` (findings baseline) +- **Date (UTC):** 2026-10-18 +- **Mandated reads (all read in full):** `AGENTS.md`, `docs/prompts/SPEC.md`, `docs/prompts/verify-stage.md`, `AUDIT_REPORT_FINDINGS.md` (§OH-6.5-root-normalization + §OH-6.5-embedded-placeholder), programmer report `docs/reports/audit-remediation-20261018-root-normalization.md`, target files `src/guards/shell-parse.ts`, `test/guards.test.ts`, `src/guards/permission-guard.ts`, `src/guards/taint-guard.ts` + +## 1. Gates (exact commands + exit codes, from inside the worktree) + +| Command | Result | +|---|---| +| `bun run check` (tsc --noEmit) | exit **0** | +| `bun test test/guards.test.ts` (stock TMPDIR) | **41 pass, 0 fail**, exit **0** (was 32; +9 new) | +| `TMPDIR=/dev/shm bun test` (full suite; override required — host `/tmp` inode-exhausted/ENOSPC) | **365 pass, 2 fail**, exit 1 | +| `TMPDIR=/dev/shm bash scripts/accept/wave-1.sh` | `WAVE 1 ACCEPT: ALL GATES GREEN`, exit **0** | + +**2-fail exception — proven pre-existing environmental via git-stash A/B (not a regression):** +- With fix: `(fail) OH-6.5-redirect-escape … workspace-relative and OS-temp targets still allow`, `(fail) OH-6.5-cp-mv-argv-targets … legitimate absolute-source uses … allow` → 365 pass / 2 fail / 367 total. +- `git stash push -- src/guards/shell-parse.ts test/guards.test.ts` → pristine: **identical 2 fails** → 356 pass / 2 fail / 358 total. +- `git stash pop` → fix restored; `git status` shows only the 2 fix files modified. Both failures are the audit-documented TMPDIR-flip OS-temp tests (pass with stock TMPDIR, fail identically with `/dev/shm` TMPDIR on the pristine tree). The delta is exactly +9 new tests, +9 passes, zero new failures. + +## 2. Stub / shortcut scan (src/ AND test/) + +| Pattern | Result | +|---|---| +| `TODO` in `src/ test/` | **zero hits** (`grep` exit 1) | +| `\|\| true` | only `scripts/accept/wave-8.sh:81-82` (the accept script's own self-scan quoting the pattern, excluded by the script itself) — **zero real hits** | +| `set +e` | **zero hits** | +| `cmd: "true"` / `echo "passed"` | only `scripts/accept/wave-8.sh:84` (self-scan) — **zero real hits** | +| `test.skip`/`describe.skip`/`it.skip`/`xit(`/`xdescribe(` (word-boundary scan) | **zero hits** (naive scan hit `process.exit(0)` in `test/wave8-audit.test.ts:121` — a shell-command string inside a mutating-shell corpus, i.e. `exit(` false positive, not a skipped test) | +| `return true` in `src/` | 60+ hits, **all guarded predicates** (`isRootWipeTarget` fail-closed wipe checks, `findExecPayloadIsPlaceholderWipe` placeholder detection, `shellTargetEscapesWorkspace` containment, `isUntrustedIngestionTool` MCP arms, loopback/private-IP guards) — every one has branching logic; **zero vacuous passes** | +| empty `catch {}` / `catch (e) {}` | **zero hits** | +| Unwired exports | `normalizeWipeTarget` (shell-parse.ts:977) called by `isRootWipeTarget` (:1003); `isFindWipeRootOperand` (:847) called by `findHasWipeRoot` (:843) and `-delete` branch (:1164); `findPathOperands` reused by `-delete` branch. All wired into `classifyShellCommand` → `isDestructiveShellCommand` → `evaluateBoardGuard` Rule 1 + `evaluateTaintGuard` TAINT-DESTRUCTIVE-1. **Zero unwired.** | + +ANY-hit rule: no unjustified hit → no FAIL. + +## 3. KV-cache invariants spot-check + +- `TMPDIR=/dev/shm bun test test/context-budget.test.ts` → **23 pass, 0 fail**, exit 0. +- `event.system` untouched, last-`role==="user"` append, lessons sorted by `id.localeCompare` with zero float scores/timestamps: covered green by that suite; no context-budget file touched by this fix (`git status` confirms). + +## 4. Scope refusal check + +- `getRecommendedMcpConfig()` (`src/ui-auditor/mcp-defaults.ts:30-42`) returns **ONLY** `lsp-mcp-server` (`bunx lsp-mcp-server`) + `fallow-mcp` (`npx --yes --package fallow fallow-mcp`). No extra servers. +- Broad grep hits for `worktree` in `src/` are upstream host plumbing (`contract-adapter.ts:205-206` `location.worktree` context fields, `index.ts:1093-1095` `ctx?.worktree` reads, interceptor comments about OpenCode built-in plugin instructions) — reads of host context, **not** a worktree manager. No `http.createServer`/`listen(`/WebUI/DAG-scheduler/research-engine code anywhere in `src/`. Wave-1 accept scope gate passes green (see §1). + +## 5. Adversarial bypass hunt (beyond QA's matrix — 33 attacks + 9 controls, all via public API) + +Probe: `/dev/shm/drift-bypass-probe.ts` (`classifyShellCommand` + `isDestructiveShellCommand` + `evaluateBoardGuard` builder-deny). Result: **33/33 DENY-OK, 9/9 ALLOW-OK, exit 0**. + +Attacks denied (each `destructive` + `isDestructive===true` + builder `allowed===false`): `/././`, `////tmp//..//`, `/.//`, `/tmp//../`, `rm -rf / --no-preserve-root`, `rm --recursive --force /`, `rm -r -f /`, `rm -rf -- /`, quoted `'~root'`/`"~root"`, `$HOME`, `${HOME}/..`, `-execdir`/`-okdir` `{}` payloads, `+`-terminator `find / -exec rm -rf {} +` and `{}/foo +`, `prefix{}`, long-form `--recursive --force`, `rm -rf -- {}`, `../..` and `a/../../` find roots (±exec/−delete), `/tmp/../` + `{}/foo`, `sudo`/`exec`/`bash -c` wrappers, tab-separated `rm\t-rf\t/` and `find /\t-exec`, single/double-quoted `'{}'`/`"{}"` payloads. + +Controls still allowed (each `write` + builder `allowed===true`, no over-blocking): `rm -rf /tmp`, `/tmp/wave1-abcd`, `/var/tmp/app`, `rm --recursive --force /tmp/app`, `find /tmp -name x -exec rm -rf {} \;`, `find /tmp -name x -exec rm -rf {}/foo \;`, `find . -exec rm -rf {} \;`, `find . -name x -delete`, `find /tmp -name x -delete`. + +Refutation angles from the cross-examination notes attempted before upholding and defeated: `//` stat-identity family normalizes to `/`; rm-backstop evasion rejected (`find //` never lets `rm` see `/` — now caught at the find-root layer); `find ..` downgrade rejected (`..`-leading/segment roots are wipe roots in the same Rule-1 path); `~root` env-specificity rejected (fail-closed `~`-leading → deny); wrapper composition (`sudo`/`exec`/`bash -c`) strengthens the deny; `+`-terminator and `-okdir`/`-execdir` forms deny; no reliance on the non-filed `FIND`/`RM` spellings. + +## 6. Independent sabotage test (distinct from QA's) + +- **Sabotage (NOT touching fix files):** neutered permission-guard Rule 3a in `src/guards/permission-guard.ts:649` (`path.startsWith("src/")` → `path.startsWith("src-internal/")`), via file write (auditor tool-hygiene exception for sabotage only). +- **Result:** `bun test test/guards.test.ts` → **39 pass, 2 fail, exit 1** — fail-closed proven (suite is non-vacuous). +- **Restore:** `git checkout -- src/guards/permission-guard.ts` (safe: file carries no uncommitted fix) → sha256 `fcfffc06…` identical before/after; fix files untouched (`shell-parse.ts` `958708d5…`, `guards.test.ts` `c3e877e5…` identical); `git status --short` shows only `M src/guards/shell-parse.ts`, `M test/guards.test.ts`, `??` programmer report. +- **Post-restore:** `bun test test/guards.test.ts` → **41 pass, 0 fail, exit 0**. + +## 7. Regression + LOC notes + +- `test/guards.test.ts` diff: **+169 lines, zero deleted/relaxed assertions** (no `^-` lines in diff) — pure additions across the 9 new tests. +- `src/guards/shell-parse.ts` diff: +70/−25 — deletions are exactly the intended replacements (exact-match `isRootWipeTarget` body → normalized version incl. `~` fail-closed; `a === "{}"` → `a.includes("{}")`; `-delete` exact-match branch → `findPathOperands(...).some(isFindWipeRootOperand)`), all in the fail-closed direction. +- `bash scripts/check-loc.sh` → `files=22 raw=11981 (budget 12000) stripped=9975 (budget 10000)`, `tracked (exit 0)` — gate passes; caps now 99.8%/99.75% full (next wave must budget accordingly; OH-scope-loc-budget overage fact unchanged, not worsened beyond +20 raw). +- Out-of-scope findings (OH-1.3/OH-1.1 tags+publish, OH-6.6 fork hook, OH-deps-caret, OH-1.1-no-gh-release) correctly untouched by the fix. +- Taint layer: no `taint-guard.ts` change needed — `evaluateTaintGuard` denies any `isDestructiveShellCommand` as `TAINT-DESTRUCTIVE-1`; classifier fix propagates automatically (proven by the 16-row + 4-row taint tests in the new suite). + +VERDICT: PASS diff --git a/docs/reports/audit-remediation-20261018-root-normalization.md b/docs/reports/audit-remediation-20261018-root-normalization.md new file mode 100644 index 0000000..8e75500 --- /dev/null +++ b/docs/reports/audit-remediation-20261018-root-normalization.md @@ -0,0 +1,110 @@ +# Programmer Report: Audit Remediation 20261018 — OH-6.5-root-normalization (P0) + OH-6.5-embedded-placeholder (P1) + +- **Branch:** `fix/oh-6-5-root-normalization-embedded-placeholder` +- **Worktree:** `/home/john/Projects/Heretek-AI-Rebuild/opencode-harness/worktrees/audit-remediation` +- **Date (UTC):** 2026-10-18 +- **Scope:** `src/guards/shell-parse.ts` (guard logic + comments only) and `test/guards.test.ts` (new regression tests only). No other production files touched; no version bump, tags, publishes, or CI changes. + +## Defects remediated + +### OH-6.5-root-normalization (P0) + +`isRootWipeTarget` used exact-match only, so `rm -rf //`, `///`, `/.`, `/./`, +`~root`, `~root/.ssh` classified `write`/ALLOW, and `find // …`, `find /./ …`, +`find /tmp/../ …`, `find .. …`, `find ~root …` (`-exec rm -rf {} \;` and +`-delete` forms) classified `write`/ALLOW for builder/architect, including +while tainted. + +### OH-6.5-embedded-placeholder (P1) + +`findExecPayloadIsPlaceholderWipe` used exact `a === "{}"`, so +`find / -exec rm -rf {}/foo \;`, `{}foo`, `prefix{}` (and `-- {}/foo`) +classified `write`/ALLOW. + +## Logic implemented (`src/guards/shell-parse.ts`) + +1. **Added `import { posix } from "node:path";`** — the file previously had no + imports; POSIX normalization is stdlib-only. +2. **Added `normalizeWipeTarget(t)`** — fail-closed normalization: trims, + maps every `~`-leading form (`~`, `~/x`, `~`, `~/x` — unknown + home, fail closed) to `/`, expands `$HOME`/`${HOME}` to `/`, collapses + repeated slashes, then applies `posix.normalize`, so `//`, `///`, `/.`, + `/./`, `/../`, `/tmp/../` all resolve to `/` (matching `realpath -m` / + same device:inode as `/` per the audit). +3. **Rewrote `isRootWipeTarget(t)`** — preserves every pre-existing exact/prefix + rule (`*`, `/*`, `--no-preserve-root`, `~`, `~/*`, `~/…`, `$HOME`, + `${HOME}`, `$HOME/…`, `${HOME}/…`), adds `~` fail-closed, and falls + through to `normalizeWipeTarget(t) === "/"`. Multi-line branch style is kept + so the wave-1 accept zero-stub scan (which flags `||` + `true` on one line) + stays clean. +4. **Added `isFindWipeRootOperand(p)`** — `true` when `isRootWipeTarget(p)` or + the operand contains a `..` segment (covers `..`, `../x`, `a/../b`), + i.e. the fail-closed workspace-escape approximation; `findHasWipeRoot` + now uses it, so `find ..`, `find //`, `find /tmp/../`, `find ~root` count + as wipe roots for the `-exec`-payload gate. +5. **Fixed the `-delete` branch** — was `args.filter(…).some(p === "/" || "/*")` + (exact match, and it wrongly swept `-exec` payload tokens into the path + set); now `findPathOperands(args).some(isFindWipeRootOperand)`, the same + normalized/outside-workspace check as the `-exec` branch. +6. **Embedded placeholder** — `a === "{}"` → `a.includes("{}")` in + `findExecPayloadIsPlaceholderWipe` (post-`--` tokens included, as before, + so `-- {}/foo` denies; `{}` never appears legitimately in `rm` targets). +7. **Taint mapping preserved** — `evaluateTaintGuard` already denies any + `isDestructiveShellCommand(command)` as `TAINT-DESTRUCTIVE-1` while tainted; + fixing the classifier automatically fixes the taint layer with no bypass. + +Net `src/` delta: raw 11961 → 11981 (budget 12000), stripped 9961 → 9975 +(budget 10000). Comments kept concise deliberately to stay under the +OH-scope-loc-budget hard caps pinned by +`test/audit-20261018-remediation.test.ts`. + +## Tests added (`test/guards.test.ts`, +169 lines, 9 new tests, none modified) + +- `OH-6.5-root-normalization` (5 tests): every probe row + (`rm -rf // /// /. /./ /../ /tmp/../ ~root ~root/.ssh`; + `find //|/./|/tmp/../|..|~root -exec rm -rf {} \;`; + `find //|/tmp/../|.. -delete`) asserts `class === "destructive"` + + `isDestructiveShellCommand === true` + `rule-1-destructive` deny for + builder/architect/director/qa-adversary; tainted sessions deny all 16 rows + via `evaluateTaintGuard` with `TAINT-DESTRUCTIVE-1`; scoped controls + (`rm -rf /tmp`, `/var/tmp/app`, `find /tmp … {}[ /{}/foo]`, `find . …`, + `find /tmp -name x -delete`) stay `write`/ALLOW. +- `OH-6.5-embedded-placeholder` (4 tests): `{}/foo`, `{}foo`, `prefix{}`, + `-- {}/foo` over `/` with `\;` and `-ok` terminators assert destructive + + `rule-1-destructive` for all four roles; tainted deny `TAINT-DESTRUCTIVE-1`; + scoped `find /tmp|… {}/foo` controls stay `write`/ALLOW. + +## Gate results (exact commands + exit codes, from inside the worktree) + +- `bun run check` (tsc --noEmit) → exit 0. +- `bun test test/guards.test.ts` (stock TMPDIR) → 41 pass, 0 fail, exit 0 + (was 32 tests; +9 new). +- `bun test test/guards.test.ts test/audit-20261018-remediation.test.ts` + filtered `-t` re-runs of the two OS-temp tests (stock TMPDIR) → pass + (proves the TMPDIR-override failures below are environmental, not regressions). +- `TMPDIR=/dev/shm bun test` (full suite; override required because host + `/tmp` is inode-exhausted/ENOSPC) → 365 pass, 2 fail; both failures are the + audit-documented TMPDIR-flip tests (`redirect-escape` OS-temp allow, + `cp-mv-argv-targets` absolute-source allow) which pass with stock TMPDIR + and fail identically on the pristine tree (verified via `git stash` A/B). + The OH-scope-loc-budget hard-cap test passes (raw 11981 ≤ 12000). +- `TMPDIR=/dev/shm bash scripts/accept/wave-1.sh` → `WAVE 1 ACCEPT: ALL GATES + GREEN`, exit 0 (84 tests, zero-stub/scope scans clean). +- `TMPDIR=/dev/shm make accept` → wave 1 green; wave 2 fails ONLY on the two + pre-existing TMPDIR environmental tests above (identical with and without + this change, proven by stash A/B). Stock-TMPDIR accept runs cannot execute + on this host (`mkdtemp /tmp/*` → ENOSPC), as documented in the audit. + +## Self-audit notes + +- Fail-closed everywhere: unknown homes (`~user`), unresolvable normalizations + (catch → raw string, still matched), non-converging wrappers (untouched), + `..`-containing find roots, and embedded `{}` all resolve toward + `destructive`/deny, never allow. +- No stubs/mocks/vacuous tests: every new assertion checks the public guard + API (`classifyShellCommand`, `isDestructiveShellCommand`, + `evaluateBoardGuard`, `evaluateTaintGuard`) with distinct attack/allow + expectations; no `TODO`, `|| true`, empty catch, or `test.skip`. +- Out-of-scope findings untouched: OH-1.3/OH-1.1 (tags/publish), OH-6.6 + (no upstream fork hook), OH-scope-loc-budget (budget observed, not revised), + OH-deps-caret/OH-1.1-no-gh-release (informational). diff --git a/src/guards/shell-parse.ts b/src/guards/shell-parse.ts index a6a888c..6eb6cf3 100644 --- a/src/guards/shell-parse.ts +++ b/src/guards/shell-parse.ts @@ -62,6 +62,8 @@ * consumed as option values but never reported as file targets. */ +import { posix } from "node:path"; + export type ShellCommandClass = "read-only" | "write" | "destructive"; export interface ParsedShellCommand { @@ -838,14 +840,21 @@ function findPathOperands(args: string[]): string[] { /** Whether any leading find-path operand is a root-wipe target. */ function findHasWipeRoot(args: string[]): boolean { - return findPathOperands(args).some((p) => isRootWipeTarget(p)); + return findPathOperands(args).some((p) => isFindWipeRootOperand(p)); +} + +/** Leading `find` path operand naming a wipe root (`//`, `/tmp/../`, `..`, `~root`). */ +function isFindWipeRootOperand(operand: string): boolean { + const t = (operand ?? "").trim(); + return t !== "" && (isRootWipeTarget(t) || t.replace(/\\/g, "/").split("/").includes("..")); } /** - * OH-6.5-find-exec-placeholder: `true` when an `-exec`/`-execdir`/`-ok`/ - * `-okdir` payload runs `rm` with recursive+force flags over the `{}` - * placeholder (`rm -rf {}`). Callers gate this on a root-wipe find root so - * scoped uses (`find /tmp -name x -exec rm -rf {} \;`) stay write-class. A + * OH-6.5-find-exec-placeholder (+ OH-6.5-embedded-placeholder): `true` when + * an `-exec`/`-execdir`/`-ok`/`-okdir` payload runs `rm` with recursive+force + * flags over a `{}-including` target (`{}`, `{}/foo`, `{}foo`, `prefix{}` — + * substring match, fail-closed). Callers gate this on a wipe-root find root + * so scoped uses (`find /tmp -name x -exec rm -rf {} \;`) stay write-class. A * payload substitution (`$(…)`/backticks) with recursive+force flags is * destructive because a real shell expands it before `rm` sees its targets. */ @@ -893,7 +902,7 @@ function findExecPayloadIsPlaceholderWipe(payload: string): boolean { if (!endOfOptions && a.startsWith("-") && a !== "-") { continue; } - if (a === "{}") { + if (a.includes("{}")) { hasPlaceholder = true; } } @@ -964,21 +973,34 @@ function splitChain(command: string): string[] { .filter((part) => part.length > 0); } +/** Fail-closed wipe-target normalization (`//`→`/`, `/tmp/../`→`/`, `~…`→`/`, `$HOME`→`/`). */ +function normalizeWipeTarget(target: string): string { + const s = (target ?? "").trim(); + if (s.startsWith("~")) return "/"; + try { + return posix.normalize(s.replace(/\$\{HOME\}|\$HOME/g, "/").replace(/\/+/g, "/")); + } catch { + return s; + } +} + /** Targets whose recursive-force removal wipes a home/root scope. */ function isRootWipeTarget(target: string): boolean { - return ( - target === "/" || - target === "/*" || - target === "*" || - target === "--no-preserve-root" || - target === "~" || - target === "~/*" || - target.startsWith("~/") || - target === "$HOME" || - target === "${HOME}" || - target.startsWith("$HOME/") || - target.startsWith("${HOME}/") - ); + const t = (target ?? "").trim(); + if (t === "*" || t === "/*" || t === "--no-preserve-root") { + return true; + } + if (t.startsWith("~")) { + return true; // `~`, `~/…`, `~` fail closed + } + if (t === "$HOME" || t === "${HOME}") { + return true; + } + if (t.startsWith("$HOME/") || t.startsWith("${HOME}/")) { + return true; + } + // Normalized `/` covers `//`, `///`, `/.`, `/./`, `/../`, `/tmp/../`. + return normalizeWipeTarget(t) === "/"; } /** @@ -1135,13 +1157,11 @@ function segmentIsDestructive(segment: string): boolean { } if (exe === "find") { - // `find / -delete` removes the whole tree: destructive. Scoped deletes - // (`find /tmp/x -delete`) stay write-class via `segmentIsWrite`. + // `find / -delete` removes the whole tree: destructive, as do the + // normalized/escaping spellings (`//`, `/tmp/../`, `..`, `~root`). + // Scoped deletes (`find /tmp/x -delete`) stay write-class. if (args.includes("-delete")) { - const pathArgs = args.filter( - (a) => !a.startsWith("-") && a !== ";" && a !== "+" && a !== "{}", - ); - if (pathArgs.some((p) => p === "/" || p === "/*")) { + if (findPathOperands(args).some((p) => isFindWipeRootOperand(p))) { return true; } } diff --git a/test/guards.test.ts b/test/guards.test.ts index 81cc9aa..c8619a7 100644 --- a/test/guards.test.ts +++ b/test/guards.test.ts @@ -20,6 +20,11 @@ import { isDestructiveShellCommand, isFileMutatingShellCommand, } from "../src/guards/shell-parse"; +import { + clearTaintState, + evaluateTaintGuard, + recordUntrustedContentTaint, +} from "../src/guards/taint-guard"; function verdict(request: GuardRequest) { return evaluateBoardGuard(request, defaultGuardConfig()); @@ -379,3 +384,167 @@ describe("permission-guard staff roles (OH-8.3)", () => { } }); }); + +describe("OH-6.5-root-normalization: slash/dot-dot/tilde wipe-root spellings are destructive (P0)", () => { + const ATTACK_ROLES = ["builder", "architect", "director", "qa-adversary"]; + + test("rm -rf over normalized wipe roots is destructive for every role", () => { + for (const command of [ + "rm -rf //", + "rm -rf ///", + "rm -rf /.", + "rm -rf /./", + "rm -rf /../", + "rm -rf /tmp/../", + "rm -rf ~root", + "rm -rf ~root/.ssh", + ]) { + expect(classifyShellCommand(command).class).toBe("destructive"); + expect(isDestructiveShellCommand(command)).toBe(true); + for (const role of ATTACK_ROLES) { + const result = verdict({ role, tool: "shell", command }); + expect(result.allowed).toBe(false); + expect(result.rule).toBe("rule-1-destructive"); + } + } + }); + + test("find -exec placeholder wipe over normalized/escaping roots is destructive for every role", () => { + for (const command of [ + "find // -exec rm -rf {} \\;", + "find /./ -exec rm -rf {} \\;", + "find /tmp/../ -exec rm -rf {} \\;", + "find .. -exec rm -rf {} \\;", + "find ~root -exec rm -rf {} \\;", + ]) { + expect(classifyShellCommand(command).class).toBe("destructive"); + expect(isDestructiveShellCommand(command)).toBe(true); + for (const role of ATTACK_ROLES) { + const result = verdict({ role, tool: "shell", command }); + expect(result.allowed).toBe(false); + expect(result.rule).toBe("rule-1-destructive"); + } + } + }); + + test("find -delete over normalized/escaping roots is destructive for every role", () => { + for (const command of ["find // -delete", "find /tmp/../ -delete", "find .. -delete"]) { + expect(classifyShellCommand(command).class).toBe("destructive"); + expect(isDestructiveShellCommand(command)).toBe(true); + for (const role of ATTACK_ROLES) { + const result = verdict({ role, tool: "shell", command }); + expect(result.allowed).toBe(false); + expect(result.rule).toBe("rule-1-destructive"); + } + } + }); + + test("tainted sessions deny every root-normalization probe at the taint layer", () => { + const session = `oh65-root-norm-taint-${Date.now()}`; + const commands = [ + "rm -rf //", + "rm -rf ///", + "rm -rf /.", + "rm -rf /./", + "rm -rf /../", + "rm -rf /tmp/../", + "rm -rf ~root", + "rm -rf ~root/.ssh", + "find // -exec rm -rf {} \\;", + "find /./ -exec rm -rf {} \\;", + "find /tmp/../ -exec rm -rf {} \\;", + "find .. -exec rm -rf {} \\;", + "find ~root -exec rm -rf {} \\;", + "find // -delete", + "find /tmp/../ -delete", + "find .. -delete", + ]; + for (const command of commands) { + recordUntrustedContentTaint(session, "webfetch"); + const tainted = evaluateTaintGuard(session, command, "shell"); + expect(tainted.action).toBe("deny"); + expect(tainted.ruleId).toBe("TAINT-DESTRUCTIVE-1"); + clearTaintState(session); + } + }); + + test("scoped controls stay write-class and allowed for builder", () => { + for (const command of [ + "rm -rf /tmp", + "rm -rf /tmp/wave1-abcd", + "rm -rf /var/tmp/app", + "find /tmp -name x -exec rm -rf {} \\;", + "find /tmp -name x -exec rm -rf {}/foo \\;", + "find . -exec rm -rf {} \\;", + "find . -exec rm -rf {}/foo \\;", + "find /tmp -name x -delete", + ]) { + expect(classifyShellCommand(command).class).toBe("write"); + expect(isDestructiveShellCommand(command)).toBe(false); + expect(verdict({ role: "builder", tool: "shell", command }).allowed).toBe(true); + } + }); +}); + +describe("OH-6.5-embedded-placeholder: suffixed/embedded {} is a placeholder wipe (P1)", () => { + const ATTACK_ROLES = ["builder", "architect", "director", "qa-adversary"]; + + test("embedded {} payloads over / are destructive for every role", () => { + for (const command of [ + "find / -exec rm -rf {}/foo \\;", + "find / -exec rm -rf {}foo \\;", + "find / -exec rm -rf prefix{} \\;", + "find / -exec rm -rf -- {}/foo \\;", + ]) { + expect(classifyShellCommand(command).class).toBe("destructive"); + expect(isDestructiveShellCommand(command)).toBe(true); + for (const role of ATTACK_ROLES) { + const result = verdict({ role, tool: "shell", command }); + expect(result.allowed).toBe(false); + expect(result.rule).toBe("rule-1-destructive"); + } + } + }); + + test("embedded {} also denies with -ok terminators", () => { + for (const command of [ + "find / -ok rm -rf {}/foo \\;", + "find / -ok rm -rf {}foo \\;", + "find / -ok rm -rf prefix{} \\;", + ]) { + expect(isDestructiveShellCommand(command)).toBe(true); + for (const role of ATTACK_ROLES) { + const result = verdict({ role, tool: "shell", command }); + expect(result.allowed).toBe(false); + expect(result.rule).toBe("rule-1-destructive"); + } + } + }); + + test("tainted sessions deny embedded {} wipes at the taint layer", () => { + const session = `oh65-embedded-taint-${Date.now()}`; + for (const command of [ + "find / -exec rm -rf {}/foo \\;", + "find / -exec rm -rf {}foo \\;", + "find / -exec rm -rf prefix{} \\;", + "find / -exec rm -rf -- {}/foo \\;", + ]) { + recordUntrustedContentTaint(session, "webfetch"); + const tainted = evaluateTaintGuard(session, command, "shell"); + expect(tainted.action).toBe("deny"); + expect(tainted.ruleId).toBe("TAINT-DESTRUCTIVE-1"); + clearTaintState(session); + } + }); + + test("scoped embedded-{} controls stay write-class and allowed", () => { + for (const command of [ + "find /tmp -name x -exec rm -rf {}/foo \\;", + "find . -name x -exec rm -rf {}/foo \\;", + ]) { + expect(classifyShellCommand(command).class).toBe("write"); + expect(isDestructiveShellCommand(command)).toBe(false); + expect(verdict({ role: "builder", tool: "shell", command }).allowed).toBe(true); + } + }); +});