@odingard/guardian-hook 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @odingard/guardian-hook
2
2
 
3
- **Guardian for coding agents.** A [Claude Code](https://code.claude.com/docs/en/hooks) `PreToolUse` hook that sends every consequential tool call (shell commands, file writes, web fetches, MCP tools) to Guardian before it runs.
3
+ **Guardian for coding agents.** A [Claude Code](https://code.claude.com/docs/en/hooks) `PreToolUse` hook that sends every consequential tool call (shell commands, file writes, web fetches, MCP tools) to Guardian before it runs. The same hook runs under [OpenAI Codex CLI](#openai-codex-cli) (`guardian-hook codex`) and [DeepSeek Harness](#deepseek-harness-dsh) (`guardian-hook dsh`).
4
4
 
5
5
  - `npm test` runs under **Allow**.
6
6
  - `terraform apply` waits until **you approve it on your phone**.
@@ -22,12 +22,15 @@ There is no model in the decision: Guardian decides ALLOW / ASK / DENY determini
22
22
 
23
23
  Shell commands are routed by consequence. That routing is what lets you put deploys behind your phone while ordinary commands flow freely.
24
24
 
25
+ **The routing is a local pattern match, not a security boundary.** It catches the plain spellings of consequential commands. A model that obfuscates a command can move it into the Allow tier: quoting (`"git" push --force`), options before the subcommand (`git -C . push -f`), escapes (`r\m -rf ~`) and decoded payloads (`sh -c "$(echo … | base64 -d)"`) all classify as `shell`. Under prompt injection, treat the Ask tier as advisory. Where that matters, set the everyday connection to **Ask me first** as well, or deny `shell` outright.
26
+
25
27
  | Guardian tool | Suggested mode | Covers |
26
28
  | --------------------- | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
27
29
  | `shell` | Allow | Ordinary commands (build, test, inspect) |
28
30
  | `shell_deploy` | Ask me first | terraform/tofu apply and destroy, pulumi up, kubectl apply and delete, helm install and upgrade, cloud deploy CLIs, package publish, `gh pr merge` |
29
31
  | `shell_destructive` | Ask me first | rm -rf, git reset --hard, git clean, force push, DROP/TRUNCATE, disk tools |
30
32
  | `git_push` | Ask me first | Pushing commits |
33
+ | `shell_session` | Ask me first | Starting an interactive shell, interpreter, database client or remote session (`bash`, `python3`, `ssh host`, `docker exec -it`, `psql app`) |
31
34
  | `file_write` | Allow | Write and edit (the approver sees the path, size and a SHA-256 of the content; the content itself is not sent) |
32
35
  | `web_fetch` | Allow | Fetching a URL |
33
36
  | `mcp.<server>.<tool>` | your choice | Any MCP tool, e.g. `mcp.github.create_pull_request` |
@@ -40,7 +43,7 @@ Guardian sets modes **per connection**, not per tool. So the hook uses two recor
40
43
 
41
44
  1. In the Guardian console, go to **Connections → Register a tool server → record-only** and create two:
42
45
  - **"Coding agent — everyday"** with `shell`, `file_write` and `web_fetch`.
43
- - **"Coding agent — needs approval"** with `shell_deploy`, `shell_destructive`, `git_push`, and any MCP tools you use. `npx @odingard/guardian-mcp tools -- <server>` lists them.
46
+ - **"Coding agent — needs approval"** with `shell_deploy`, `shell_destructive`, `git_push`, `shell_session`, and any MCP tools you use. Until `shell_session` is allowed by name on that connection, starting an interactive session is denied (`INVALID_REQUEST`), which fails closed. `npx @odingard/guardian-mcp tools -- <server>` lists them.
44
47
  2. Under **Agents**, open your coding agent:
45
48
  - set **Allow** on the first connection and **Ask me first** on the second;
46
49
  - on each connection's card, type every tool name into **"Allow a tool by name"** and press **Allow tool**, once per tool.
@@ -78,12 +81,175 @@ Guardian sets modes **per connection**, not per tool. So the hook uses two recor
78
81
 
79
82
  `npx @odingard/guardian-hook setup` prints all of this. To see where a command would be routed, run `npx @odingard/guardian-hook classify "terraform apply"`, which prints `shell_deploy`.
80
83
 
84
+ ## Claude Agent SDK agents (0.2.0)
85
+
86
+ Agents built on Anthropic's Claude Agent SDK (`@anthropic-ai/claude-agent-sdk`) can be governed by Guardian in the same way:
87
+
88
+ ```ts
89
+ import { query } from "@anthropic-ai/claude-agent-sdk";
90
+ import { guardianHookMatcher } from "@odingard/guardian-hook/agent-sdk";
91
+
92
+ for await (const message of query({
93
+ prompt: "Run the tests, then deploy staging",
94
+ options: { hooks: { PreToolUse: [guardianHookMatcher()] } },
95
+ })) {
96
+ if (message.type === "result") console.log(message.subtype);
97
+ }
98
+ ```
99
+
100
+ It uses the same environment variables, the same two connections (the console's "Set up for Claude Code" creates them) and the same decisions as the Claude Code hook. Approvers see the agent as "Claude agent" unless you pass `agentName`.
101
+
102
+ `guardianHookMatcher()` registers the hook with:
103
+
104
+ - `onFailure: "block"`, so a crash blocks the call instead of letting it through (Agent SDK 0.3.317 or later);
105
+ - a timeout longer than Guardian's 5-minute approval window.
106
+
107
+ A `PreToolUse` hook runs for every tool call, even calls an allow rule or `bypassPermissions` would approve, and its deny holds in every permission mode.
108
+
109
+ A runnable example is in `examples/claude-agent-sdk.mjs`. For a hosted agent where Claude Code runs unattended in its own VM, see `examples/hosted-claude-code/`.
110
+
111
+ ## OpenAI Codex CLI
112
+
113
+ Codex runs `PreToolUse` command hooks with the same JSON as Claude Code ([Codex hooks](https://developers.openai.com/codex/hooks)). Run the hook as `guardian-hook codex`:
114
+
115
+ ```bash
116
+ npm i -g @odingard/guardian-hook # install it; do not rely on npx at hook time
117
+ guardian-hook codex setup # prints the tools to register and the config below
118
+ ```
119
+
120
+ `~/.codex/hooks.json` (or `<repo>/.codex/hooks.json`):
121
+
122
+ ```json
123
+ {
124
+ "hooks": {
125
+ "PreToolUse": [
126
+ {
127
+ "matcher": "^(Bash|apply_patch)$|^mcp__.*",
128
+ "hooks": [
129
+ {
130
+ "type": "command",
131
+ "command": "guardian-hook codex || { s=$?; [ $s -eq 2 ] || echo \"Guardian hook failed to run. Nothing ran.\" >&2; exit 2; }",
132
+ "timeout": 600,
133
+ "statusMessage": "Guardian is checking this action"
134
+ }
135
+ ]
136
+ }
137
+ ]
138
+ }
139
+ }
140
+ ```
141
+
142
+ Set the same `GUARDIAN_*` variables as for Claude Code in the environment Codex starts from. Approvers see the agent as "Codex".
143
+
144
+ **Hook trust.** Codex skips a non-managed hook until its exact definition is trusted. Trust it once with `/hooks` in the TUI, or for a headless run pass `codex exec --dangerously-bypass-hook-trust …`. To enforce it for every user, deliver it as a managed hook in `requirements.toml` with `allow_managed_hooks_only = true` and `[features].hooks = true`. A hook that is not trusted does not run, and nothing is governed.
145
+
146
+ | Codex tool | Guardian tool | What Guardian sees |
147
+ | ---------------------------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
148
+ | `Bash` (shell and unified exec `exec_command`) | `shell`, `shell_deploy`, `shell_destructive`, `git_push`, `shell_session` | The command that starts the process (bounded to 2,000 characters). **Not** what is later typed into it with `write_stdin`; see below. |
149
+ | `apply_patch` (every file edit Codex makes) | `file_write` | The target paths and operations from the patch headers (`add`, `update`, `delete`, `move`), its size and a SHA-256 digest. Never the patch text. |
150
+ | `mcp__<server>__<tool>` | `mcp.<server>.<tool>` | The MCP arguments (a digest above 2,000 characters). |
151
+
152
+ How a decision reaches Codex: an allow prints nothing and exits 0, which Codex treats as success (Codex documents `permissionDecision: "allow"` only together with `updatedInput`). A deny prints the deny JSON, writes the reason to stderr and exits 2; Codex blocks on either. The hook never returns `"ask"`, which Codex does not support and treats as a failed hook that lets the call run: on ASK the hook waits for the approval itself (up to 5 minutes) and then answers allow or deny.
153
+
154
+ ### Hosted Codex agents (`codex exec`)
155
+
156
+ Some hosts run each chat message as `codex exec --dangerously-bypass-approvals-and-sandbox`, with config under `/data/.codex`. To govern it:
157
+
158
+ 1. Install `@odingard/guardian-hook` in the image (a custom image) and write the `hooks.json` above to `/data/.codex/hooks.json` at boot.
159
+ 2. Add `--dangerously-bypass-hook-trust` to the host's `codex exec` command, or deliver the hook as a managed `requirements.toml` hook. **This is the one setting a headless host needs**; without it the hook is skipped.
160
+ 3. Set `GUARDIAN_AGENT_CREDENTIAL`, `GUARDIAN_CONNECTION_ID` and `GUARDIAN_HOOK_ASK_CONNECTION_ID` as agent environment variables (the credential as a secret).
161
+
162
+ `--dangerously-bypass-approvals-and-sandbox` turns off Codex's own approvals and sandbox; Codex documents hooks separately from approvals, but that hooks still fire under that flag is **not yet confirmed live**. See `examples/codex/`.
163
+
164
+ ### Not governed under Codex
165
+
166
+ - **`write_stdin`: input typed into an open unified-exec session.** Codex runs `PreToolUse` once, for the `exec_command` that starts the process, and never for `write_stdin` (Codex's hooks docs: "`write_stdin` is transport for an existing unified-exec session. It doesn't run `PreToolUse` again"; source: `write_stdin.rs` returns no pre-hook payload). So a model can start `bash` and then type `terraform apply` or `rm -rf` into it, and Guardian never sees those commands. The hook routes the start of an interactive shell, interpreter, database client, `ssh` or `docker exec -it` session to `shell_session` (Ask tier), so a person approves opening it, but once approved, what is typed is not governed, and a process that reads stdin in a way the patterns miss (for example a custom read-eval loop) is not caught. **To close the gap, turn unified exec off** in a managed `requirements.toml`:
167
+
168
+ ```toml
169
+ [features]
170
+ unified_exec = false
171
+ ```
172
+
173
+ With it off, Codex registers `exec_command` as one-shot (runs to completion, no `tty`, no session) and does not offer `write_stdin` (Codex `spec_plan.rs`, `add_shell_tools`; the source notes that only managed requirements can keep it off). Not yet confirmed live. `examples/codex/requirements.toml` includes it.
174
+
175
+ - **`apply_patch` deletes and moves are `file_write` (Allow tier).** A `*** Delete File:` patch runs without approval under the suggested modes; the approver sees `delete <path>` in the operations. That is a policy choice: set the everyday connection to Ask if deletes must wait for a person.
176
+ - **A missing, untrusted or unparseable `hooks.json` governs nothing.** Codex skips it and runs every call. Check with a deny (for example `rm -rf ./tmp-guardian-check` should be blocked or wait for approval) after every config change.
177
+ - Hosted tools such as `WebSearch` (they do not use Codex's hook path), and any "specialized tool path" Codex runs without hooks; Codex itself calls hooks "a useful guardrail, not a complete enforcement boundary".
178
+ - Other local tools (`update_plan`, `spawn_agent`, …) are left to Codex. Whether a subagent's own tool calls reach the hook is not yet confirmed.
179
+ - **Codex lets the call run if the hook process fails** (crash, timeout, malformed output). The hook makes every failure it can see a deny: it catches crashes, exits 2 on deny, and has its own 540-second deadline under Codex's 600-second timeout. The `|| … exit 2` in the command turns a hook that cannot start into a block. A hook killed from outside, or a `timeout` set below the hook's deadline, still fails open; keep `timeout` at 600.
180
+
181
+ ## DeepSeek Harness (dsh)
182
+
183
+ DeepSeek Harness runs Claude Code `PreToolUse` command hooks through its bridge plugin [`@deepseek-ai/dsh-hooks-claude-code`](https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/hooks/hooks-claude-code/README.md). Run the hook as `guardian-hook dsh`: it knows dsh's own tool names.
184
+
185
+ ```bash
186
+ npm i -g @odingard/guardian-hook # install it in the image or on the volume
187
+ guardian-hook dsh setup # prints the tools to register and the config below
188
+ ```
189
+
190
+ The bridge's hooks file, e.g. `/data/.dsh/guardian-hooks.json`:
191
+
192
+ ```json
193
+ {
194
+ "hooks": {
195
+ "PreToolUse": [
196
+ {
197
+ "matcher": "^(bash|pwsh|terminal_send|write|edit|apply_patch|str_replace_editor|web_fetch|Bash|Write|Edit|MultiEdit|NotebookEdit|WebFetch)$|^mcp__.*",
198
+ "hooks": [
199
+ {
200
+ "type": "command",
201
+ "command": "guardian-hook dsh || { s=$?; [ $s -eq 2 ] || echo \"Guardian hook failed to run. Nothing ran.\" >&2; exit 2; }",
202
+ "timeout": 600
203
+ }
204
+ ]
205
+ }
206
+ ]
207
+ }
208
+ }
209
+ ```
210
+
211
+ Mount the bridge in `$DSH_HOME/cordis.patch.yml` (for example `/data/.dsh/cordis.patch.yml`):
212
+
213
+ ```yaml
214
+ - insert:
215
+ - id: guardian-hooks
216
+ name: "@deepseek-ai/dsh-hooks-claude-code"
217
+ config:
218
+ configPath: /data/.dsh/guardian-hooks.json
219
+ defaultTimeoutMs: 600000
220
+ ```
221
+
222
+ The bridge reads its config once at startup, so restart the agent after changing it. Approvers see the agent as "DeepSeek Harness".
223
+
224
+ **The matcher decides whether the hook runs at all.** The bridge matches it against dsh's own tool name, passed unchanged (checked in the bridge source, `hooks-claude-code/src/index.ts`). A tool the matcher does not name never reaches the hook and is not governed. The matcher also lists Claude Code's names (`Bash`, `Write`, `Edit`, `MultiEdit`, `NotebookEdit`, `WebFetch`) in case a bridge version renames tools; it does not use a catch-all, because a tool the hook does not know would only get "no opinion".
225
+
226
+ | dsh tool | Guardian tool | What Guardian sees |
227
+ | ------------------------------------------------------- | -------------------------------------------------------- | -------------------------------------------------------- |
228
+ | `bash`, `pwsh`, `terminal_send` (text typed into a PTY) | `shell`, `shell_deploy`, `shell_destructive`, `git_push` | The command or typed text. |
229
+ | `write`, `edit`, `str_replace_editor` (all but `view`) | `file_write` | The path, size and a SHA-256 digest; never the contents. |
230
+ | `apply_patch` (on by default in `dsh-base`) | `file_write` | Target paths and operations, size and digest. |
231
+ | `web_fetch` | `web_fetch` | The URL. |
232
+ | `mcp__<server>__<tool>` | `mcp.<server>.<tool>` | The MCP arguments. |
233
+
234
+ A deny is the Claude Code deny JSON, plus the reason on stderr and exit 2, both of which the bridge treats as a block. An allow does not pre-approve the call in dsh: dsh's own approval policy still applies after Guardian allows it. The hook never returns `"ask"`; it waits for the approval itself, so it works in one-shot headless chats too.
235
+
236
+ ### Not governed under DeepSeek Harness
237
+
238
+ - **Every dsh tool not in the table**, including ones with real effects: `run_code` itself (its tool sub-calls go through the same pre-execute pipeline, per dsh's docs; not yet confirmed live), `subagent`, `workflow`, `ralph`; `plugin_manager` and `load_workspace_dependencies` (they install packages); the experimental browser tools `stagehand_act` and `stagehand_navigate` (they can click, submit forms and navigate); `send_message`, `spawn_teammate`, `team_task_*`; `job_*` (including `job_kill`); `schedule_*`; `skill`, `lsp`, `propose_action`, `terminal_open`, `web_search`; and read-only tools. Disable the ones you do not want with your dsh profile.
239
+ - **A missing or unparseable hooks file governs nothing.** The bridge logs "could not load hook config … no hooks registered" and runs every call. Check with a deny after every config change.
240
+ - `terminal_send` is classified per message, so a command split across several sends (e.g. `terraform ap` then `ply`) is seen in pieces, and text sent to a program already running in the PTY (a REPL, `ssh`) is classified as if it were a shell command. Prefer the plain `bash` tool and turn the terminal tools off where that matters.
241
+ - **The bridge lets the call run if the hook fails to run or crashes.** Only a deny or exit 2 blocks. As with Codex, the hook catches its own crashes, exits 2 on deny, and denies at its own 540-second deadline; the `|| … exit 2` covers a hook that cannot start. A hook killed from outside still fails open.
242
+ - Not yet confirmed live: the tool names the bridge passes in a hosted dsh image (the source passes dsh's names unchanged; the matcher lists Claude Code's too), whether a host's dsh image already includes the bridge plugin, whether the hook's environment keeps `GUARDIAN_AGENT_CREDENTIAL`, and whether a host's headless runs allow a 5-minute wait. See `examples/deepseek-harness/`.
243
+
81
244
  ## Limits (read these)
82
245
 
83
246
  - **Decision-gated.** The agent performs the effect after Guardian authorizes and redeems the call. The receipt proves Guardian's authorization of that exact call, not the command's own outcome.
84
- - **Routing is local.** The classification patterns run on your machine and err toward the Ask tools. An agent that can edit your settings or environment can remove the hook. Use managed settings (or `allow_managed_hooks_only`-style controls) where that matters.
247
+ - **Routing is local and evadable.** The classification patterns run on your machine and catch plain spellings only (see [Tools the hook proposes](#tools-the-hook-proposes)).
248
+ - **The agent can edit the hook's own config.** An Allow-tier file edit (`apply_patch`, dsh `write`/`edit`, Claude Code `Write`) can rewrite `.codex/hooks.json`, `/data/.dsh/guardian-hooks.json`, `cordis.patch.yml` or `.claude/settings.json` and remove the hook. On one-shot dsh runs the harness restarts on every message, so the edit takes effect on the next one. Deliver the hook as a managed hook (Codex `requirements.toml` with `allow_managed_hooks_only`, Claude Code managed settings), or mount the config read-only (owned by another user) where that matters.
249
+ - **Starting the hook.** It answers only when run as `guardian-hook` (or `…/hook/dist/index.js`). Installed under another file name it exits 0 with no output, which every agent treats as "no objection". Use the documented commands.
85
250
  - **Only calls through the hook are governed.** Commands you run yourself, or other credentials on the machine, are not.
86
251
  - **Arguments are bounded.** Commands over 2,000 characters are truncated in what the approver sees; MCP inputs over that size are sent as a digest.
87
- - **Claude Code only for now.** Cursor and Codex adapters will ship once their hook contracts are verified against their documentation.
252
+ - **Claude Code, Codex and DeepSeek Harness.** A Cursor adapter will ship once its hook contract is verified against its documentation.
253
+ - **Claude Code itself also runs a call when a hook times out or exits with an error other than 2.** The hook's own 540-second deadline and crash handling deny before that can happen; keep the hook `timeout` at 600.
88
254
 
89
255
  Licensed Apache-2.0.
package/agent-sdk.d.ts ADDED
@@ -0,0 +1,39 @@
1
+ // Types for "@odingard/guardian-hook/agent-sdk" (see src/agent-sdk.ts).
2
+ type Env = Readonly<Record<string, string | undefined>>;
3
+
4
+ export type GuardianHookInput = Readonly<{
5
+ hook_event_name?: string;
6
+ tool_name?: unknown;
7
+ tool_input?: unknown;
8
+ cwd?: unknown;
9
+ }>;
10
+
11
+ export type GuardianHookOutput =
12
+ | Record<string, never>
13
+ | {
14
+ hookSpecificOutput: {
15
+ hookEventName: "PreToolUse";
16
+ permissionDecision: "allow" | "deny";
17
+ permissionDecisionReason: string;
18
+ };
19
+ };
20
+
21
+ export type GuardianPreToolUseOptions = Readonly<{
22
+ env?: Env;
23
+ agentName?: string;
24
+ log?: (line: string) => void;
25
+ }>;
26
+
27
+ export declare function guardianPreToolUse(
28
+ options?: GuardianPreToolUseOptions,
29
+ ): (input: GuardianHookInput) => Promise<GuardianHookOutput>;
30
+
31
+ export declare const GUARDIAN_HOOK_TIMEOUT_SECONDS: number;
32
+
33
+ export declare function guardianHookMatcher(
34
+ options?: GuardianPreToolUseOptions,
35
+ ): {
36
+ hooks: ((input: GuardianHookInput) => Promise<GuardianHookOutput>)[];
37
+ timeout: number;
38
+ onFailure: "block";
39
+ };