{
  "$schema": "tools.json \u2014 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 \u2014 add rules as wrappers emerge. Escape any one command with a trailing '# raw-ok' comment.",
  "version": 1,
  "rules": [
    {
      "id": "ccr-start-raw",
      "shim_binary": "ccr",
      "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": "model-verification-purpose-raw",
      "argv_route_any_of": [
        [
          "(?:^|/)(?:opencode|codex|na)\\.sh$"
        ],
        [
          "^opencode$",
          "^run$"
        ],
        [
          "^cdx$",
          "^exec$"
        ],
        [
          "^codex$",
          "^(?:exec|e)$"
        ],
        [
          "^claude$",
          "^-p$"
        ]
      ],
      "argv_option_all_of": [
        {
          "name": "--purpose",
          "value": "verification"
        }
      ],
      "unless_contains": [
        "cdx exec",
        "cdx --profile"
      ],
      "wrapper": "model-test-fixture --purpose verification --backend <opencode|codex|north> --workspace <dir> --trust \"<prompt>\" --task-slug <slug>",
      "reason": "Model-backed tests, probes, smoke checks, canaries, and integration verification require the fixed free-route fixture. Raw wrappers and CLIs can select another engine, model, config, account, or fallback. EXEMPT: `cdx exec` \u2014 cdx is itself the sanctioned account-routing wrapper (see codex-auth-symlink-mutate), pins model/effort explicitly via `-m`/`-c model_reasoning_effort=`, and auto-logs every run, so it lacks the silent-engine/account-swap property this rule exists to catch. A probe whose SUBJECT is the cdx offload path itself (e.g. verifying the cdx sandbox can bootstrap a toolchain) can only be exercised by invoking cdx directly \u2014 routing it through model-test-fixture would pick a different engine and defeat the test."
    },
    {
      "id": "model-verification-probe-raw",
      "all_of": [
        "(?:^|[\\s\"'])(?:[^\\s\"']*/)?(?:opencode|codex|na)\\.sh\\b|\\bopencode\\s+run\\b|\\bcodex\\s+(?:exec|e)\\b|\\bclaude\\s+-p\\b",
        "(?:--task-slug(?:=|\\s+)[^\\s]*(?:test|probe|smoke|canary|integration)|\\b(?:AUTH-OK|[Rr]eply with exactly|[Rr]eply exactly|[Rr]espond with exactly|[Ss]ay exactly|[Rr]eply only with)\\b)"
      ],
      "wrapper": "model-test-fixture --purpose verification --backend <opencode|codex|north> --workspace <dir> --trust \"<prompt>\" --task-slug <slug>",
      "reason": "Model-backed tests, probes, smoke checks, canaries, and integration verification require the fixed free-route fixture. Raw wrappers and CLIs can select another engine, model, config, account, or fallback. EXEMPT: `cdx exec` \u2014 cdx is itself the sanctioned account-routing wrapper (see codex-auth-symlink-mutate), pins model/effort explicitly via `-m`/`-c model_reasoning_effort=`, and auto-logs every run, so it lacks the silent-engine/account-swap property this rule exists to catch. A probe whose SUBJECT is the cdx offload path itself can only be exercised by invoking cdx directly."
    },
    {
      "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 \u2014 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": "worktree-add-raw-suggest",
      "band": "suggest",
      "all_of": [
        "\\bgit\\b[^|;&]*\\bworktree\\s+add\\b"
      ],
      "unless_contains": [
        "od-worktree"
      ],
      "wrapper": "od-worktree add <name>   (same result, plus `od-worktree list`/`adopt` bookkeeping)",
      "reason": "A raw `git worktree add` inside the repo is not unsafe here, but od-worktree keeps the salvage/vault bookkeeping (od-wip) in sync \u2014 worth the one extra keystroke."
    },
    {
      "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>` \u2014 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 \u2014 the caller pins -m/-c explicitly. `codex exec` is auto-rewritten to `cdx exec`. (cx.sh was archived to workflows/.archive/ \u2014 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",
        "\\bbun\\s+(test|run\\s+(build|test)|x\\s+vitest)\\b",
        "\\bbunx\\s+(vitest|playwright)\\b",
        "\\byarn\\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 \u2014 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'   \u2014 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 \u2014 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 \u2014 especially `export`, which exempts every process the shell spawns for its whole lifetime \u2014 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` \u2014 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 \u2014 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 \u2014 append \"# raw-ok\"."
    },
    {
      "id": "raw-podman-in-sandbox",
      "shim_binary": "podman",
      "any_of": [
        "(^|[;&|]\\s*)(/usr/bin/|/bin/)?podman(-compose)?\\s"
      ],
      "unless_contains": [
        "deck-podman",
        "systemd-run"
      ],
      "wrapper": "deck-podman <podman-args...>   (passthrough when unconfined, so it is always safe to use)",
      "reason": "Agent Bash runs inside a bwrap user namespace whose uid_map maps a single uid, with no_new_privs set, so the setuid newuidmap helper cannot widen it. Rootless podman cannot build a pause namespace and dies with 'unable to create a new pause process: cannot re-exec process to join the existing user namespace'. Its own advice is wrong and destructive here: `podman system migrate` STOPS EVERY RUNNING CONTAINER (the owner's work), and a reboot fixes nothing because the sandbox is not machine state. Every raw attempt also leaks a podman-pause-*.scope and a catatonit that nothing reaps. deck-podman re-execs through the user manager where the uid range is intact, and fails closed on what it cannot forward faithfully (sandbox-private /tmp paths, interactive ttys)."
    },
    {
      "id": "cdx-account-steal",
      "any_of": [
        "\\bcdx\\s+(?:\\S+\\s+)*?--(?:account|profile)\\b"
      ],
      "wrapper": "cdx exec (no --account/--profile \u2014 default account only)",
      "reason": "Owner directive 2026-08-08: NEVER pass --account/--profile to cdx. zync/zync2 are other quotas \u2014 using them is theft. Default account only, unless the owner names one in the CURRENT conversation (then he types it himself)."
    },
    {
      "id": "cdx-probe-premium-model",
      "any_of": [
        "\\bcdx\\s+(?:\\S+\\s+)*?exec\\b[^\\n]*-m\\s+\"?gpt-5\\.6-(?:sol|terra|luna)\"?[^\\n]*(?:AUTH-OK|[Rr]eply with exactly|[Rr]espond with exactly|[Ss]ay exactly|[Rr]eply only with)"
      ],
      "wrapper": "cdx exec -m gpt-5.3-codex-spark (probes/smoke tests use spark ONLY)",
      "reason": "Owner directive 2026-08-08: probes/smoke tests NEVER on sol/terra/luna (paid). gpt-5.3-codex-spark only. Matches trivial-echo probe prompts, not domain words (probe/ping/smoke appear in real delegated work)."
    },
    {
      "id": "cpuquota-on-agent-slice",
      "shim_binary": "systemctl",
      "any_of": [
        "set-property[^;|&]*agent[^;|&]*CPUQuota=[0-9]"
      ],
      "wrapper": "systemctl --user set-property agent.slice CPUWeight=<n>   # weight yields; quota kills",
      "reason": "CPU is a rate, not a stock: CPUQuota on an agent slice starves every session shell to silent death under fleet load (2026-08-15: quota=150% killed 50 sessions' Bash children, nr_throttled 2.88M). Policy: CPUWeight only. Clearing an existing quota (CPUQuota=) is allowed."
    }
  ]
}