# coordinator-hook

`pa-coordinator-hook` — a Claude Code `PreToolUse` hook that mechanically
enforces a three-tier tool policy on interactive PA main threads: dispatch
primitives are always allowed, a small argument-gated set of Bash/Read/Write
calls are permitted for coordination-scale work, and everything else
(`Edit`, `Grep`, `WebFetch`, …) is blocked with a message pointing at the
dispatch alternative.

This exists because "handle it inline instead of dispatching" is a repeat
failure mode for interactive PAs — soft discipline in CLAUDE.md isn't
sufficient on its own. See `projects/agentic-harness/product-vision.md` §1
item 6 for the design rationale (operator repo, not this one).

## Status

Implemented: full three-tier decision engine, config + per-PA overlay
loading, `--help`. Not yet wired into any `~/.claude/settings.json` by this
package — that's an explicit boundary (see below), and the `pa-init`
scaffold does not yet emit the snippet either (tracked as a follow-up, not
done in the PR that introduced this doc).

## How it works

Claude Code's `PreToolUse` hook contract (verified against the current docs
at the time this was built —
[code.claude.com/docs/en/hooks](https://code.claude.com/docs/en/hooks)):
every `PreToolUse` invocation matching the configured `matcher` gets the
event as JSON on **stdin** (not an env var — the `TOOL_NAME` env var
mentioned in some older mental models of this contract does not exist in
the current shape):

```json
{
  "tool_name": "Bash",
  "tool_input": { "command": "npm test" },
  "cwd": "/home/user/my-project",
  "session_id": "...",
  "permission_mode": "default"
}
```

Exit codes: `0` = allow (falls through to Claude Code's normal permission
flow — the hook only ever *tightens*, it never auto-grants), `2` = block
(stderr becomes the message the model sees), anything else = non-blocking
error (tool call proceeds; stderr is logged to the transcript). `pa-
coordinator-hook` uses exit `1` for a malformed/empty stdin payload — it
fails OPEN on hook-input errors rather than blocking every tool call
estate-wide because of a hook bug.

## The three tiers

**Tier 1 — always allowed, never reaches the hook.** Dispatch + coordination
primitives (`Agent`, `SendMessage`, `TaskCreate`, `Skill`, …). The
settings.json `matcher` (below) simply never fires the hook for these tool
names, so there's no tier1 enforcement code — `config.tier1` in
`coordinator-allowlist.json` is documentation only.

**Tier 2 — argument-gated allowlist.** `Bash`, `Read`, `Write`. The hook
inspects `tool_input` against regex allow patterns:

- **Bash** — `command` must match one of `tier2.Bash.allowPatterns` (`pa `,
  `desk `, `git status`, `gh api `, `ls`, …), and must NOT match
  `hardDeny.bashPatterns` or contain a shell-chaining metacharacter
  (`;`, `&&`, `||`, `|`, `` ` ``, `$(`) — the chain-guard exists so a command
  like `pa knowledge search foo && rm -rf ~` can't slip through on the
  strength of its allowed prefix.
- **Read** — `file_path` must match `tier2.Read.allowPathPatterns` (repo-
  relative: `.session/`, `agents/<self>/`, `work/`, `projects/*/project.json`,
  …) or `allowAbsolutePathPatterns` (`/tmp/`, `/private/tmp/` — scratchpad),
  and must NOT match `blockPathPatterns` (`operator/knowledge/**` — direct
  reads bypass the knowledge graph; use `pa knowledge` verbs instead) or
  `hardDeny.pathPatterns` (`.ssh/`, `.vault/`, `secrets.enc.json`, `.env` —
  credential material, never overridable by an overlay).
- **Write** — same shape as Read, no `blockPathPatterns` (default-deny
  already covers "anything not explicitly staged/session/cache/scratch").

`<self>` in a path pattern is substituted at runtime: the resolved `paName`
(`PA_SELF_AGENT` env var, else `operator/config/preferences.json#paName`
read relative to the resolved repo root) when known, else `[^/]+` — the
documented fallback ("if unknown, allow any `agents/*/` path").

**Tier 3 — always blocked.** `Edit`, `NotebookEdit`, `Grep`, `Glob`,
`WebFetch`, `WebSearch`. Every invocation gets a tool-specific message
(`config.messages[toolName]`) pointing at the dispatch alternative — e.g.
Grep says "dispatch Explore for search", WebFetch says "dispatch
`/research`".

### Hard-deny (never overridable by an overlay)

`hardDeny` in `coordinator-allowlist.json` is checked before any allow
pattern, core or overlay-supplied, for both Bash commands and Read/Write
paths. It exists because the overlay mechanism (below) only *adds* allow
patterns — a careless or malicious overlay entry (e.g. an allow pattern that
accidentally matches `rm -rf ~` because it was written too loosely) must not
be able to defeat the policy's basic safety floor. Covers: `rm -rf`,
`curl | sh` / `wget | sh`, `git push --force`, `git reset --hard`,
`git clean -f`, `git branch -D`, `--no-verify`, `--no-gpg-sign`,
`gh repo delete` and other destructive `gh` verbs, `sudo`, `dd if=`,
`mkfs`, raw-disk redirects, and reads/writes of `.ssh/`, `.aws/`,
`.vault/`, `secrets.enc.json`, `.env`. Not a security sandbox — a
determined caller can still construct a bypass a naive prefix/negative-list
regex won't catch — just a mechanical backstop against the obviously
destructive cases.

## Toggle

```sh
PA_COORDINATOR_MODE=1   # enforce
# unset, or anything other than "1"                → transparent no-op, exits 0 for everything
```

Deliberately a different variable name from the unofficial internal
`CLAUDE_CODE_COORDINATOR_MODE` some environments may already use — this one
is owned by the pa-coordinator-hook contract specifically.

## Config

Core allowlist ships at `packages/cli/config/coordinator-allowlist.json`
(bundled into the published tarball at
`dist/engineering-pa-cli/config/coordinator-allowlist.json` — resolved via
`import.meta.url` at runtime, so it works identically from source and from
the bundled package). **This file is not the place to add a personal
exception** — it ships inside the package and is overwritten on every
`pnpm add`/upgrade.

### Per-PA overlay

Add allow-pattern exceptions at:

- `~/.claude/coordinator-overlay.json` (home — applies to every repo on the
  machine), and/or
- `<repo-root-or-cwd>/.claude/coordinator-overlay.json` (workspace —
  applies to just this operator repo; applied after the home overlay, so it
  can extend past whatever the home overlay already added)

```jsonc
{
  "tier2": {
    "Bash": { "allowPatterns": ["^npm ls\\s"] },
    "Read": { "allowPathPatterns": ["^docs/"] }
  }
}
```

**Contract (enforced by `mergeOverlay` in `config.mjs`):** an overlay may
ONLY extend `tier2.<Bash|Read|Write>.allowPatterns` /
`.allowPathPatterns`. It cannot move a tool between tiers, touch
`tier1`/`tier3`, or touch `hardDeny` — any other key is ignored and reported
as a stderr warning, never silently accepted. This is deliberate: silently
accepting an unrecognised overlay shape is exactly how a stray key would
quietly widen the policy.

## Settings snippet (documentation only — not applied by this package)

Wire the hook up in your OWN `~/.claude/settings.json` (per-machine operator
territory — this package never writes to it):

The command is the launcher the scaffold writes into a new instance's
`.claude/settings.json` (copy it from that file): it walks up from
`$CLAUDE_PROJECT_DIR` to the nearest installed
`node_modules/@stravica-ai/digital-operator-pa/bin/pa-coordinator-hook` and
needs only `node` on the hook PATH.

This launcher, like the other PreToolUse gate, has no stdout latch. The
scaffold's SessionStart launchers do: once their bin has written a byte to
fd 1 they print nothing of their own. That latch counts only
`process.stdout.write` and `fs.writeSync` to fd 1, so a SessionStart bin that
writes with async `fs.write`, `fs.writev` or `fs.writeFileSync(1)` bypasses
it. No shipped bin writes that way.

```jsonc
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "^(Bash|Read|Write|Edit|NotebookEdit|Grep|Glob|WebFetch|WebSearch)$",
        "hooks": [
          {
            "type": "command",
            "command": "node -e 'var P=require(\"path\"),d=process.env.CLAUDE_PROJECT_DIR,q=\"node_modules/@stravica-ai/digital-operator-pa/bin/pa-coordinator-hook\",y=e=>x(e?.message||e),x=r=>process.exit(console.error(\"pa-coordinator-hook could not run: \"+r)|1);for(var c=P.resolve(d||x(\"no CLAUDE_PROJECT_DIR\")),b,n;!require(\"fs\").existsSync(b=P.join(c,q));c=n)if(c==(n=P.dirname(c)))x(\"no \"+q+\" above \"+d);process.argv.splice(1,0,b);import(require(\"url\").pathToFileURL(b)).catch(e=>process.listenerCount(\"uncaughtException\")?process.nextTick(()=>{throw e}):y(e))'"
          }
        ]
      }
    ]
  }
}
```

Then, per-repo or per-session:

```sh
export PA_COORDINATOR_MODE=1
```

## Verifying an install

```sh
pa-coordinator-hook --help                # prints usage, exits 0

# allow (mode off — transparent no-op)
echo '{"tool_name":"Grep","tool_input":{}}' | pa-coordinator-hook; echo $?   # 0

# block (mode on, tier3)
echo '{"tool_name":"Grep","tool_input":{}}' \
  | PA_COORDINATOR_MODE=1 pa-coordinator-hook; echo $?                       # 2

# allow (mode on, tier2 Bash allow pattern)
echo '{"tool_name":"Bash","tool_input":{"command":"pa knowledge search foo"}}' \
  | PA_COORDINATOR_MODE=1 pa-coordinator-hook; echo $?                       # 0
```

## Known follow-ups (not done in this package)

- **`/research` light mode** (operator-side skill, not this package) runs
  inline `WebFetch`/`WebSearch` — under coordinator mode that path is
  blocked. The skill needs to detect `PA_COORDINATOR_MODE=1` and switch
  light-mode to a scoped dispatch. Tracked as a separate work item — not
  changed here.
- **`pa-init` scaffold** does not yet emit the settings.json snippet or a
  starter `.claude/coordinator-overlay.json` for new operator repos.
- **`gh` verb allowlist** is coarse (`gh api ` allows any subpath, `gh pr
  {view,list,merge,close}`) — if a narrower per-repo policy is needed later,
  extend via overlay rather than editing the core config.
