{
  "$schema": "tools.json — wrapper registry read by deny-gate.mjs. GLOBAL scope.",
  "_doc": "Each rule denies a NAIVE-WRONG raw command (whose obvious form misbehaves) and points to the tested wrapper. Match on the ALWAYS-WRONG signature, never a full-cluster shape. Partial by design — add rules as wrappers emerge. Escape any one command with a trailing '# raw-ok' comment.",
  "version": 1,
  "rules": [
    {
      "id": "ccr-start-raw",
      "any_of": ["\\bccr\\s+start\\b"],
      "unless_contains": ["ccr-up.sh"],
      "wrapper": "bash ~/.claude/workflows/lib/ccr-up.sh <slug>",
      "reason": "`ccr start` is a long-lived daemon. Naive foreground (or `ccr start | tail`) HANGS the whole run ~46min. The one correct, non-blocking launch is the tested wrapper (detached + flock + readiness-poll, idempotent, cannot hang)."
    },
    {
      "id": "north-rig-raw",
      "all_of": ["ANTHROPIC_BASE_URL", "claude"],
      "any_of": ["127\\.0\\.0\\.1:3456", "ANTHROPIC_MODEL"],
      "unless_contains": ["na.sh"],
      "wrapper": "~/.claude/skills/north-orchestrator/na.sh --workspace <SHORT dir> --trust \"<prompt>\" --task-slug <slug>",
      "reason": "Hand-typed north rig leaks the OpenRouter env into your real-Anthropic shell and can hang on an unbounded claude -p. na.sh sources NORTH.env in its own subprocess (no leak), folds ccr-up (fail-closed), and bounds + stdin-closes the dispatch."
    },
    {
      "id": "worktree-outside-repo",
      "all_of": ["\\bgit\\b[^|;&]*\\bworktree\\s+add\\b"],
      "any_of": ["worktree\\s+add\\s+(?:-\\S+\\s+|\\S+\\s+)*?['\"]?(?:~|\\$HOME|/|\\.\\./)"],
      "unless_contains": ["od-worktree", "/.worktrees/", "/.claude/worktrees/"],
      "wrapper": "od-worktree add <name>   (creates <repo-root>/.worktrees/<name>; `od-worktree list` shows strays, `od-worktree adopt <path>` moves one back in)",
      "reason": "NEVER create a checkout outside the repo. Worktrees in $HOME, ~/Projects/<repo>-wt-*, or ~/.cache are exactly the laptop-wide sprawl overdeck exists to eliminate — the user has repeatedly said so, and no memory or prior agent's choice authorizes it. od-worktree can only write to <repo-root>/.worktrees/ and has no flag to escape it."
    },
    {
      "id": "codex-auth-symlink-mutate",
      "all_of": ["\\.codex/auth\\.json"],
      "any_of": ["\\bln\\s+-", "\\brm\\b", "\\bmv\\b", "\\bcp\\b", "\\bunlink\\b", "\\btee\\b", ">\\s*\\S*\\.codex/auth"],
      "wrapper": "cdx --profile <account> <codex-cmd>   (select the account per-invocation; NEVER repoint the symlink)",
      "reason": "NEVER touch the ~/.codex/auth.json symlink. Repointing it swaps the active codex account for EVERY session sharing ~/.claude (silent account leak) and can outlive your script if the restoring trap is killed. To run codex on another account, select it per-invocation with `cdx --profile <account>` — no global mutable state. Reads (readlink/ls) are fine; only mutation is blocked."
    },
    {
      "id": "codex-review-raw",
      "any_of": ["\\bcodex\\s+exec\\b", "\\bcodex\\s+review\\b", "\\bcodex\\s+e\\b"],
      "unless_contains": ["cdx"],
      "rewrite_from": "\\bcodex\\s+(exec|e)\\b",
      "rewrite_to": "cdx exec",
      "wrapper": "cdx exec --skip-git-repo-check -C <DIR> -m \"gpt-5.6-sol\" -c \"model_reasoning_effort=low\" 'PROMPT'",
      "reason": "Naive `codex` binds to whatever account the auth symlink points at and picks codex's config-default model/effort (not gpt-5.6-sol/low). cdx routes to a healthy account per-invocation (no global auth mutation) and passes exec args through — the caller pins -m/-c explicitly. `codex exec` is auto-rewritten to `cdx exec`. (cx.sh was archived to workflows/.archive/ — this rule previously pointed there.)"
    },
    {
      "id": "raw-heavy-build-local-gate",
      "any_of": [
        "\\bturbo\\s+run\\b",
        "\\bpnpm\\s+(build|test|exec\\s+vitest)\\b",
        "\\bnpm\\s+(run\\s+build|test)\\b",
        "\\bnpx\\s+vitest\\b",
        "(^|[;&|]\\s*)vitest\\b",
        "\\bastro\\s+build\\b",
        "\\bvite\\s+build\\b",
        "(^|[;&|]\\s*)tsc\\b",
        "\\bplaywright\\s+test\\b",
        "\\bnode\\s+[^|;&]*--test\\b",
        "\\bcargo\\s+(test|build)\\b",
        "\\bgo\\s+(test|build)\\b"
      ],
      "unless_contains": [
        "/home/user/.claude/bin/local-gate",
        "~/.claude/bin/local-gate",
        "/home/user/.claude/lib/cpu-guard.sh",
        "~/.claude/lib/cpu-guard.sh",
        "command -v",
        "which ",
        "type -a",
        "--version"
      ],
      "wrapper": "~/.claude/bin/local-gate --key <key> --mode full -- <command>",
      "reason": "Raw heavy build/test/gate commands can saturate the workstation or bypass adaptive slots. Run them through local-gate so current load, memory, swap, dedupe, and cpu-guard wrapping are enforced."
    },
    {
      "id": "gh-run-watch-unbounded",
      "any_of": ["\\bgh\\s+run\\s+watch\\b"],
      "unless_contains": ["timeout "],
      "rewrite_from": "\\bgh\\s+run\\s+watch\\b",
      "rewrite_to": "timeout -k 30 1500 gh run watch",
      "wrapper": "timeout -k 30 1500 gh run watch <run-id> --exit-status   (exit 124 = still running after 25min: check ONCE with `gh run view <run-id>`, then decide — never re-watch in a loop)",
      "reason": "`gh run watch` blocks the turn until the CI run ends and hangs indefinitely on network flaps; with the 24h Bash ceiling one naive watch has held a turn 3.5h (measured: 18 hangs / 1136 min in 3 days). Auto-rewritten to a 25min-capped watch."
    },
    {
      "id": "unbounded-wait-loop",
      "all_of": ["\\b(while|until)\\s", "\\bsleep\\s", "^(?![\\s\\S]*\\btimeout\\s+\\d+[smh]?\\s+bash\\s+-c\\b)"],
      "wrapper": "timeout <cap-sec> bash -c 'until <condition>; do sleep <n>; done'   — the timeout must wrap the WHOLE loop; a `timeout` on the command INSIDE the loop still retries forever",
      "reason": "An unbounded while/until+sleep poll loop never returns when its condition never flips — measured 12 hangs / 1142 min in 3 days, worst 8.6h (that one had `timeout 900` inside the loop body, bounding each attempt but not the loop). Cap the whole loop with `timeout N bash -c '...'` so the turn can lose minutes, not hours."
    },
    {
      "id": "follow-forever",
      "any_of": [
        "\\btail\\b(\\s+(-{1,2}[A-Za-z0-9=_-]+|\\d+))*\\s+-[A-Za-z]*[fF]\\b",
        "\\bjournalctl\\b[^|;&]*(\\s-f\\b|--follow)",
        "\\b(docker|kubectl)\\s+logs\\b[^|;&]*(\\s-f\\b|--follow)"
      ],
      "all_of": ["^(?![\\s\\S]*\\btimeout\\s+\\d)"],
      "wrapper": "timeout <cap-sec> tail -f <file>   (or take a snapshot: tail -n 200 <file>)",
      "reason": "A follow-mode reader (`tail -f`, `journalctl -f`, `docker/kubectl logs -f`) never exits on its own, so it blocks the turn until the 24h Bash ceiling. Bound it with `timeout`, or read a snapshot instead."
    },
    {
      "id": "raw-e2e-pair-local",
      "any_of": [
        "\\bnode\\s+[^|;&]*\\b(shoot|capture|screenshot|measure|visual)[A-Za-z0-9._-]*\\.(mjs|cjs|js|ts)\\b",
        "\\b(astro|vite|next)\\s+dev\\b[^|;]*&\\s*\\S",
        "\\bpnpm\\s+(exec\\s+|run\\s+)?(astro|vite|next|dev)\\b[^|;]*&\\s*\\S"
      ],
      "unless_contains": [
        "/home/user/.claude/bin/e2e-remote",
        "~/.claude/bin/e2e-remote",
        "e2e-remote",
        "command -v",
        "which ",
        "type -a",
        "--version",
        "--help"
      ],
      "wrapper": "e2e-remote --server \"<dev-server> --host 127.0.0.1 --port <P>\" --wait-port <P> --env PORT=<P> --mkdir <outdir> -- <browser-client>",
      "reason": "A browser client and its dev server are ONE coupled pair (the browser resolves the site to 127.0.0.1), so neither half offloads alone and cpu-guard cannot route them: `dev` is an explicit not-heavy early-return, and a bare `node <script>.mjs` matches no heavy token. Run raw, the pair pins ~400% CPU to the laptop and never reaches a build box. e2e-remote runs BOTH halves on debian2/debian1 (spilling debian2->debian1, never back to local), waits fail-closed for the port, tears the server down via trap, propagates the client's exit code, and rsyncs artifacts written inside the repo tree back."
    },
    {
      "id": "e2e-remote-ok-forgery",
      "any_of": ["E2E_REMOTE_OK", "PLAYWRIGHT_BROWSERS_PATH"],
      "unless_contains": ["install-headless-guard"],
      "wrapper": "e2e-remote --server \"<dev-server> --host 127.0.0.1 --port <P>\" --wait-port <P> --wait-sec 240 --env PORT=<P> --mkdir <outdir> -- <browser-client>",
      "reason": "E2E_REMOTE_OK=1 is the ONLY key that opens the fail-closed browser guard, and e2e-remote sets it inside its remote payload where a caller cannot forge or omit it. Setting it locally — especially `export`, which exempts every process the shell spawns for its whole lifetime — silently disables the guarantee that headless browsers cannot run on this laptop. Exit 97 means route the run through e2e-remote, never unlock the guard. PLAYWRIGHT_BROWSERS_PATH is blocked for the same reason: it points Playwright at a project-local cache the guard has never wrapped. To legitimately guard another cache dir, run `PLAYWRIGHT_BROWSERS_PATH=<dir> ~/.claude/bin/install-headless-guard` — naming the installer clears this rule. This rule is ordered BEFORE playwright-install-rewraps-guard: chaining the installer onto a redirected install would guard the DEFAULT cache, not the redirected one."
    },
    {
      "id": "playwright-install-rewraps-guard",
      "any_of": ["\\bplaywright\\s+install\\b"],
      "unless_contains": ["install-headless-guard"],
      "rewrite_from": "(\\bplaywright\\s+install\\b[^&|;]*)",
      "rewrite_to": "$1 && /home/user/.claude/bin/install-headless-guard",
      "wrapper": "<playwright install ...> && ~/.claude/bin/install-headless-guard",
      "reason": "`playwright install` drops a FRESH unwrapped browser at a new versioned path (chromium_headless_shell-<rev>), silently reopening the local-run hole that install-headless-guard closes. Chaining the installer keeps the guard total across version bumps; it is idempotent, so the chain is safe to run every time."
    },
    {
      "id": "raw-e2e-driver-local",
      "any_of": ["(^|[;&|]\\s*|\\s)(node|bun|tsx|ts-node|deno)\\s"],
      "script_content_all_of": ["(from\\s*['\"](playwright|puppeteer)|require\\(\\s*['\"](playwright|puppeteer)|\\b(chromium|firefox|webkit)\\s*\\.\\s*launch\\s*\\(|host-resolver-rules|chrome-headless)"],
      "script_content_any_of": [
        "\\blocalhost\\b",
        "127\\.0\\.0\\.1",
        "host-resolver-rules",
        "process\\.env\\.PORT",
        ":[34]\\d{3}\\b"
      ],
      "unless_contains": [
        "/home/user/.claude/bin/e2e-remote",
        "~/.claude/bin/e2e-remote",
        "e2e-remote",
        "command -v",
        "which ",
        "type -a",
        "--version",
        "--help"
      ],
      "wrapper": "e2e-remote --server \"<dev-server> --host 127.0.0.1 --port <P>\" --wait-port <P> --wait-sec 240 --env PORT=<P> --mkdir <outdir> -- <browser-client>",
      "reason": "This script drives a real browser against a LOCAL dev server (its source imports a browser automation lib and targets localhost/127.0.0.1/a dev port). Agents start the server in one Bash call and the driver in a later one, so no single command shows the pair — filename and `&`-shape rules cannot see it. Run raw, headless chromium alone measured ~250% CPU on the laptop. e2e-remote starts a matching server AND the driver together on debian2/debian1 and pulls artifacts back. If this driver targets a REMOTE/production URL (no local server involved), it is not a coupled pair — append \"# raw-ok\"."
    },
    {
      "id": "v1-harness-dead",
      "any_of": [
        "\\brunplan-legacy\\b",
        "engine/versions/[^ ]*/src/runner\\.js",
        "engine/versions/[^ ]*/v1-retired/",
        "mega-plan-harness/src/runner\\.js",
        "mega-plan-harness/src/control-api\\.js",
        "src/control-api\\.js --port"
      ],
      "wrapper": "runplan <slug>  (v2 engine — preset comes from the plan's meta.preset)",
      "reason": "The v1 harness plane is DEAD — user-ordered hard cutover 2026-07-29. Never launch the v1 runner, v1 control-api, or runplan-legacy; every runplan launcher on PATH routes to the released v2 bundle. Restart any old plan with plain `runplan <slug>` from inside its repo."
    }
  ]
}
