{
  "version": 1,
  "rationale": "Main-session destructive-command guard (#155). Consolidated blocklist for pre-bash hook + wave-executor wave-scope.",
  "rules": [
    {
      "id": "git-reset-hard",
      "pattern": "git reset --hard",
      "severity": "block",
      "allow-override-flag": null,
      "sources": ["PSA-003", "#155"],
      "rationale": "Hard reset permanently discards staged and committed work that may belong to another parallel session (#155 incident). Blocked unconditionally per PSA-003 to prevent cross-session data loss."
    },
    {
      "id": "git-reset-mixed",
      "pattern": "git reset --mixed",
      "severity": "warn",
      "allow-override-flag": null,
      "sources": ["PSA-003", "#155"],
      "rationale": "Mixed reset unstages changes silently and can discard parallel-session work from the index. Warn so the coordinator can confirm intent before proceeding."
    },
    {
      "id": "git-reset-soft",
      "pattern": "git reset --soft",
      "severity": "warn",
      "allow-override-flag": null,
      "sources": ["PSA-003", "#155"],
      "rationale": "Soft reset moves HEAD and may reorder commits from concurrent sessions. Warn to surface the operation to the coordinator for explicit approval."
    },
    {
      "id": "git-checkout-discard",
      "pattern": "git checkout --",
      "severity": "block",
      "allow-override-flag": null,
      "sources": ["PSA-003", "#155"],
      "rationale": "Discards uncommitted file changes that another session may be actively building. Blocked per PSA-003 to protect in-progress work."
    },
    {
      "id": "git-clean-force",
      "pattern": "git clean -f",
      "severity": "block",
      "allow-override-flag": null,
      "sources": ["PSA-003", "#155"],
      "rationale": "Force-clean deletes untracked files that may be unfinished output from a parallel session. Blocked unconditionally per PSA-003."
    },
    {
      "id": "git-clean-force-d",
      "pattern": "git clean -fd",
      "severity": "block",
      "allow-override-flag": null,
      "sources": ["PSA-003", "#155"],
      "rationale": "Force-clean including directories amplifies the blast radius of git-clean-force, removing entire untracked directory trees. Blocked per PSA-003."
    },
    {
      "id": "git-stash-any",
      "pattern": "git stash",
      "severity": "warn",
      "allow-override-flag": null,
      "sources": ["PSA-003", "#155"],
      "rationale": "Stashing captures another session's uncommitted changes into a stash they cannot find, effectively hiding their work. Warn to confirm the coordinator owns all affected changes."
    },
    {
      "id": "rm-rf-destructive",
      "pattern": "rm -rf",
      "severity": "block",
      "allow-override-flag": null,
      "path-allowlist": ["/tmp/", "/private/tmp/", "$TMPDIR"],
      "sources": ["PSA-003", "#155", "wave-executor-baseline", "#641"],
      "rationale": "Recursive force-removal can destroy project files or another session's work with no recovery path. Blocked except for safe paths (.orchestrator/tmp, node_modules, /tmp, $TMPDIR) which the pre-bash hook evaluates separately."
    },
    {
      "id": "git-push-force",
      "pattern": "git push --force",
      "severity": "block",
      "allow-override-flag": null,
      "sources": ["PSA-003", "#155", "#138-SECURITY-REQ-07"],
      "rationale": "Force-push rewrites shared remote history, permanently discarding commits from other sessions or collaborators. Blocked unconditionally per PSA-003 and SECURITY-REQ-07."
    },
    {
      "id": "git-push-force-short",
      "pattern": "git push -f",
      "severity": "block",
      "allow-override-flag": null,
      "sources": ["PSA-003", "#155", "#138-SECURITY-REQ-07"],
      "rationale": "Short-flag alias for git push --force; same destructive effect on remote history. Blocked for full parity with git-push-force per SECURITY-REQ-07."
    },
    {
      "id": "git-push-force-with-lease",
      "pattern": "git push --force-with-lease",
      "severity": "warn",
      "allow-override-flag": null,
      "sources": ["PSA-003", "#155"],
      "rationale": "Safer than bare force-push but still rewrites remote history if the lease condition is met. Warn so the coordinator confirms this is the intended recovery action."
    },
    {
      "id": "sql-drop-table-upper",
      "pattern": "DROP TABLE",
      "severity": "block",
      "allow-override-flag": null,
      "sources": ["wave-executor-baseline", "#155"],
      "rationale": "Uppercase DROP TABLE is an irreversible DDL operation that destroys database schema and data. Blocked as part of the wave-executor baseline destructive-command guard."
    },
    {
      "id": "sql-drop-table-lower",
      "pattern": "drop table",
      "severity": "block",
      "allow-override-flag": null,
      "sources": ["wave-executor-baseline", "#155"],
      "rationale": "Lowercase alias for DROP TABLE; same irreversible DDL destruction. Blocked to ensure case-insensitive SQL issued via shell (e.g., psql -c) is caught by commandMatchesBlocked()."
    },
    {
      "id": "redirect-truncate-protected",
      "type": "redirect-truncate",
      "pattern": ">",
      "severity": "block",
      "target-denylist": ["CLAUDE.md", "AGENTS.md", ".claude/rules/**", ".orchestrator/policy/**", ".orchestrator/metrics/*.jsonl*", ".git/**", "SECURITY.md"],
      "modes": ["truncate"],
      "rationale": "Truncating redirect (>, &>, N>) onto protected artefacts silently destroys them — #983. Append (>>) stays allowed. The metrics entry is `*.jsonl*`, not `*.jsonl` (#1401): `events-rotation.mjs` renames the live ledger to `events.jsonl.1`/`.2`/… (scripts/lib/events-rotation.mjs:74), and the narrow glob missed every one of those rotated generations — the same suffix blind spot the old .gitignore line had. Deliberately NOT widened to `.orchestrator/metrics/**`: the bootstrap templates legitimately create `README.md` and empty ledgers in that directory with truncating redirects (skills/bootstrap/{standard,deep}-template.md), and only the ledger family needs this rule. Deletion and renaming of ANY file under .orchestrator/metrics/ is covered by the separate `ledger-delete-protected` rule below, where no legitimate writer exists.",
      "sources": ["#983", "#1401"]
    },
    {
      "id": "redirect-harness-memory",
      "type": "redirect-truncate",
      "pattern": ">",
      "severity": "block",
      "target-denylist": ["~/.claude/projects/*/memory/**","~/.codex/projects/*/memory/**","~/.cursor/projects/*/memory/**"],
      "modes": ["truncate","append"],
      "rationale": "The harness auto-memory directory is injected as TRUSTED project context into every later session, yet lives outside git diff, CI review, check-owner-leakage (git ls-files only) and gitleaks. #1352 closed the Edit/Write lane for dispatched agents; this closes the shell-redirect lane, append included. NAMED CEILING (BV-004), every case measured 2026-09-13: (a) redirect-shaped by construction — `tee -a` and an in-process write (node -e fs.appendFileSync) are NOT covered; (b) NAMED CEILING: `eval` with a non-literal payload (`eval \"$CMD\"`, `eval $(…)`) is unreachable — the command text does not exist at hook time (`unresolved` class, #641). Literal `eval '… > target'`, `dd of=target` and `env -S <str> …` are covered since #1366; (c) a cwd-relative target after a directory change (`cd ~ && … > .claude/projects/*/memory/…`) is out of reach — the hook does not know the chain's cwd and a relative target is judged repo-root-relative, which is not lexically solvable; (d) `$HOME` / `${HOME}` / `$(…)` / backticks in the target fall into the pre-existing `unresolved` class (#641), where the hook warns and allows rather than guessing — for a home directory that is the most natural spelling. (e) the payload-evaluation budget is SHARED and finite (`MAX_PAYLOAD_EVALUATIONS` = 32 in `scripts/lib/command-blocker.mjs`): once it is spent, redirect-target collection returns `{unresolved, reason: 'budget-exhausted'}`, which this rule treats like every other unresolved target — warn and ALLOW. Measured 2026-09-16: 40 `eval 'echo fN'` fillers followed by `eval 'echo x > ~/.claude/…/MEMORY.md'` → ALLOW plus `⚠ unresolved redirect target (budget-exhausted)`. Not a regression of #1366 — `bash -c` fillers exhaust the same budget at HEAD (#988 T2) — but a standing ceiling of the same class as (d), and an attacker-controllable one: the filler count is free. Revisit together with (d) if the unresolved class is ever made blocking. Revisit if the policy grows a non-redirect target class or the payload traversal learns redirects. Home-anchored entries are matched against the expanded ABSOLUTE path (#1362) because relativizeAgainstRoot discards every out-of-repo target by design.",
      "sources": ["#1352","#1362"]
    },
    {
      "id": "ledger-delete-protected",
      "type": "path-delete",
      "pattern": "rm|mv|unlink .orchestrator (state dir) or .orchestrator/metrics/**",
      "severity": "block",
      "target-denylist": [".orchestrator", ".orchestrator/metrics", ".orchestrator/metrics/**"],
      "rationale": "Deleting or renaming anything under .orchestrator/metrics/ destroys append-only session telemetry with no recovery path — the ledger IS the history, and nothing re-derives it. Measured 2026-09-19 (#1401): a wave subagent ran `touch .orchestrator/metrics/events.jsonl.1` (which ADOPTED the existing rotated ledger instead of creating a probe file), then `rm -f` on the same path — 53,896 lines / 10 MB covering 2026-04-12 to 2026-09-18 were gone, and the guard said nothing, because `rm` was only ever matched as the literal pattern `rm -rf`. Severity is `block`, not `warn`, on a measured absence of legitimate callers: rotation and ledger repair run through Node fs, never through Bash (`renameSync`/`unlinkSync` at scripts/lib/events-rotation.mjs:16,61,69,74), and a census of every shell rm/mv/unlink in scripts/, hooks/, .husky/, .gitlab-ci.yml and the guard's own 60-command test corpus found ZERO targeting this directory. Intentional maintenance has two named bypasses: `allow-destructive-ops: true` in Session Config (per session), or SO_DISABLED_HOOKS=pre-bash-destructive-guard (per invocation). Covers `rm`, `mv` (both operand roles — the source vanishes, an existing destination is overwritten) and `unlink`, through transparent wrappers (sudo/env/timeout/nohup/nice/…), every chain position, and literal `bash -c` / `env -S` / `eval` payloads. NAMED CEILING (BV-004), each measured: `find … -delete`, `find … -exec rm`, `… | xargs rm`, `git rm <path>` and in-process `node -e fs.unlinkSync(…)` are NOT covered — in each the path is not lexically an operand of a resolvable destructive verb (see PATH_DESTRUCTIVE_VERBS in scripts/lib/command-blocker.mjs). REVISIT TRIGGER: any of those five shapes appearing in a real deletion incident or a transcript census. `.orchestrator/tmp/**` is outside this denylist by construction, not by an exemption clause, and the match is judged PER TARGET — a sibling tmp operand can never lift the verdict for a metrics operand. SPELLING CEILING (measured 2026-09-19 against this hook, one payload per row): the #1401 incident was an agent re-spelling a blocked command, so the four re-spellings were run back through the guard. Two were closed HERE by adding the exact entry `.orchestrator` to the denylist above — `rm -r .orchestrator` (ALLOW before: only the literal `rm -rf` pattern of `rm-rf-destructive` caught the -rf form, and `rm -r` is one character shorter) and `mv .orchestrator /tmp/x` (ALLOW before). That entry is EXACT, not a prefix: `.orchestrator/tmp/**` deletes stay ALLOW, re-measured after the change (`rm -rf .orchestrator/tmp/*`, `rm -rf .orchestrator/tmp`, `rm -f .orchestrator/tmp/scratch.json` → all ALLOW), so the widening adds no exemption clause that could be lifted. Three remain OPEN and are DECLARED, not papered over: (0) a TRAILING SLASH on the state dir — `rm -r .orchestrator/` → ALLOW (measured 2026-09-19, found while re-measuring this change): the operand keeps its slash and the exact entry `.orchestrator` does not match it, while a `.orchestrator/` entry would be read as a DIRECTORY-PREFIX glob (measured: `pathMatchesPattern('.orchestrator/tmp/x', '.orchestrator/') === true`) and would swallow the tmp exemption — so the fix belongs in operand normalisation in scripts/lib/command-blocker.mjs, never in this list; (1) a wildcard in a DIRECTORY segment — `rm -f .orchestrator/*/events.jsonl` — because the guard is lexical and never expands globs, so the operand it sees is the literal string `.orchestrator/*/events.jsonl`, which no concrete-path glob in this denylist matches; (2) a cwd-changing chain — `cd .orchestrator/metrics && rm -f events.jsonl` — because the guard does not track cwd across chain segments and the operand it sees is the bare `events.jsonl`. Closing either needs glob expansion or cwd tracking in scripts/lib/command-blocker.mjs, i.e. a matcher change, not a policy change. REVISIT TRIGGER for these two: either form appearing in a real deletion incident or a transcript census — or any widening of this denylist beyond exact paths, which would need a per-verb exemption for `.orchestrator/tmp/**` bound to the statement the matcher hit (widening without narrowing the bypass opens a hole the narrow form did not have, `.claude/rules/guard-design.md`).",
      "sources": ["#1401", "PSA-003"]
    }
  ]
}
