@odingard/guardian-hook 0.2.0 → 0.3.1
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 +156 -6
- package/dist/agent-sdk.js +1332 -28
- package/dist/index.js +1489 -48
- package/package.json +5 -4
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,16 +22,30 @@ 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
|
+
**How a command is routed.** The hook parses the command line and routes it by the riskiest command inside it. It removes quotes and escapes, splits on `;`, `&&`, `||`, `|`, newlines and subshells, and looks inside `$( )`, backticks, `<( )`, `sh -c` / `bash -c` strings, `eval`, `su -c`, `env -S` and `find -exec`. It looks past wrappers (`sudo`, `doas`, `env`, `xargs`, `nice`, `nohup`, `time`, `timeout`, `stdbuf`, `exec`, `command`, `watch`, `npx`), git global options (`-C`, `-c`, `--git-dir`, `--work-tree`) and git aliases set with `-c alias.*`. So `find . | xargs rm -rf`, `git -C repo push -f`, `"git" push --force` and `r\m -rf ~` are all `shell_destructive`. When the hook cannot follow a command, it sends it to the Ask tier: an unterminated quote, a command name known only at run time (`$CMD -rf x`), `eval` or `sh -c` of run-time text (`sh -c "$(echo … | base64 -d)"`, `eval "$(tool init)"`), or a `case` statement.
|
|
26
|
+
|
|
27
|
+
**It is still not a security boundary.** The hook cannot see inside scripts (`bash deploy.sh`, `make clean`), code run by other interpreters (`python -c`, `node -e`, PowerShell), shell functions and aliases, or git aliases and hooks already in the git config. 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.
|
|
28
|
+
|
|
25
29
|
| Guardian tool | Suggested mode | Covers |
|
|
26
30
|
| --------------------- | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
27
31
|
| `shell` | Allow | Ordinary commands (build, test, inspect) |
|
|
28
32
|
| `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
|
-
| `shell_destructive` | Ask me first |
|
|
33
|
+
| `shell_destructive` | Ask me first | See the list below |
|
|
30
34
|
| `git_push` | Ask me first | Pushing commits |
|
|
35
|
+
| `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
36
|
| `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
37
|
| `web_fetch` | Allow | Fetching a URL |
|
|
33
38
|
| `mcp.<server>.<tool>` | your choice | Any MCP tool, e.g. `mcp.github.create_pull_request` |
|
|
34
39
|
|
|
40
|
+
`shell_destructive` covers:
|
|
41
|
+
|
|
42
|
+
- `rm -r` / `rm -rf` (also with no operands, and through `xargs`), and any `rm` run by `find -exec`, `find -ok` or `xargs`; `find -delete`; `shred`, `mkfs`, `dd if=`, `wipefs`.
|
|
43
|
+
- `git reset --hard` or `--merge`, `git clean -f`, `git branch -D` (or `-d -f`), `git checkout -f` or `checkout -- <path>`, `git switch --discard-changes`, `git restore --source`, `git stash drop` / `clear`, `git update-ref -d`, `git reflog expire`, `git filter-branch`.
|
|
44
|
+
- Force pushes and remote ref deletes: `git push --force`, `-f`, `--force-with-lease`, `--force-if-includes`, `+refspec`, `--mirror`, `--prune`, `--delete`, `-d`, `origin :branch`, with or without git global options (`git -C dir push -f`).
|
|
45
|
+
- `DROP` / `TRUNCATE` / unqualified `DELETE FROM`, `chmod -R` to a world-writable mode, and downloaded code run by a shell or interpreter (`curl … | sh`, `bash <(curl …)`, `sh -c "$(curl …)"`).
|
|
46
|
+
- Changes to the agent's own MCP servers, hooks or permissions: `claude mcp add`, `add-json`, `add-from-claude-desktop`, `remove` and the other non-read `mcp`, `plugin` and `config` subcommands of `claude`, `codex` and `gemini`; a nested `claude` or `codex` given `--mcp-config`, `--settings`, `--dangerously-skip-permissions` or a wider `--permission-mode`; and writes to `.mcp.json`, `mcp.json`, `~/.claude.json`, `settings.local.json`, `.claude/settings*.json`, `.claude/hooks`, `agents`, `commands`, `skills` or `plugins`, `.codex/`, `.cursor/`, `.gemini/`, `.dsh/` or `guardian-hooks.json`. That includes writes by redirection (`> .mcp.json`), by commands such as `cp`, `mv`, `tee`, `sed -i` and `ln`, and by the file tools (`Write`, `Edit`, `MultiEdit`, `write`, `edit`, `str_replace_editor`, `apply_patch`), which then carry `touches: "agent-config"`. Reading those files stays `shell`.
|
|
47
|
+
- Commands the hook cannot parse (see above).
|
|
48
|
+
|
|
35
49
|
Read-only tools (Read, Grep, Glob, LS, WebSearch) are not routed through Guardian.
|
|
36
50
|
|
|
37
51
|
## Setup
|
|
@@ -40,7 +54,7 @@ Guardian sets modes **per connection**, not per tool. So the hook uses two recor
|
|
|
40
54
|
|
|
41
55
|
1. In the Guardian console, go to **Connections → Register a tool server → record-only** and create two:
|
|
42
56
|
- **"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.
|
|
57
|
+
- **"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
58
|
2. Under **Agents**, open your coding agent:
|
|
45
59
|
- set **Allow** on the first connection and **Ask me first** on the second;
|
|
46
60
|
- on each connection's card, type every tool name into **"Allow a tool by name"** and press **Allow tool**, once per tool.
|
|
@@ -103,14 +117,150 @@ It uses the same environment variables, the same two connections (the console's
|
|
|
103
117
|
|
|
104
118
|
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.
|
|
105
119
|
|
|
106
|
-
A runnable example is in `examples/claude-agent-sdk.mjs`.
|
|
120
|
+
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/`.
|
|
121
|
+
|
|
122
|
+
## OpenAI Codex CLI
|
|
123
|
+
|
|
124
|
+
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`:
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
npm i -g @odingard/guardian-hook # install it; do not rely on npx at hook time
|
|
128
|
+
guardian-hook codex setup # prints the tools to register and the config below
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
`~/.codex/hooks.json` (or `<repo>/.codex/hooks.json`):
|
|
132
|
+
|
|
133
|
+
```json
|
|
134
|
+
{
|
|
135
|
+
"hooks": {
|
|
136
|
+
"PreToolUse": [
|
|
137
|
+
{
|
|
138
|
+
"matcher": "^(Bash|apply_patch)$|^mcp__.*",
|
|
139
|
+
"hooks": [
|
|
140
|
+
{
|
|
141
|
+
"type": "command",
|
|
142
|
+
"command": "guardian-hook codex || { s=$?; [ $s -eq 2 ] || echo \"Guardian hook failed to run. Nothing ran.\" >&2; exit 2; }",
|
|
143
|
+
"timeout": 600,
|
|
144
|
+
"statusMessage": "Guardian is checking this action"
|
|
145
|
+
}
|
|
146
|
+
]
|
|
147
|
+
}
|
|
148
|
+
]
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Set the same `GUARDIAN_*` variables as for Claude Code in the environment Codex starts from. Approvers see the agent as "Codex".
|
|
154
|
+
|
|
155
|
+
**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.
|
|
156
|
+
|
|
157
|
+
| Codex tool | Guardian tool | What Guardian sees |
|
|
158
|
+
| ---------------------------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
159
|
+
| `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. |
|
|
160
|
+
| `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. |
|
|
161
|
+
| `mcp__<server>__<tool>` | `mcp.<server>.<tool>` | The MCP arguments (a digest above 2,000 characters). |
|
|
162
|
+
|
|
163
|
+
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.
|
|
164
|
+
|
|
165
|
+
### Hosted Codex agents (`codex exec`)
|
|
166
|
+
|
|
167
|
+
Some hosts run each chat message as `codex exec --dangerously-bypass-approvals-and-sandbox`, with config under `/data/.codex`. To govern it:
|
|
168
|
+
|
|
169
|
+
1. Install `@odingard/guardian-hook` in the image (a custom image) and write the `hooks.json` above to `/data/.codex/hooks.json` at boot.
|
|
170
|
+
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.
|
|
171
|
+
3. Set `GUARDIAN_AGENT_CREDENTIAL`, `GUARDIAN_CONNECTION_ID` and `GUARDIAN_HOOK_ASK_CONNECTION_ID` as agent environment variables (the credential as a secret).
|
|
172
|
+
|
|
173
|
+
`--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/`.
|
|
174
|
+
|
|
175
|
+
### Not governed under Codex
|
|
176
|
+
|
|
177
|
+
- **`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`:
|
|
178
|
+
|
|
179
|
+
```toml
|
|
180
|
+
[features]
|
|
181
|
+
unified_exec = false
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
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.
|
|
185
|
+
|
|
186
|
+
- **`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.
|
|
187
|
+
- **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.
|
|
188
|
+
- 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".
|
|
189
|
+
- 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.
|
|
190
|
+
- **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.
|
|
191
|
+
|
|
192
|
+
## DeepSeek Harness (dsh)
|
|
193
|
+
|
|
194
|
+
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.
|
|
195
|
+
|
|
196
|
+
```bash
|
|
197
|
+
npm i -g @odingard/guardian-hook # install it in the image or on the volume
|
|
198
|
+
guardian-hook dsh setup # prints the tools to register and the config below
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
The bridge's hooks file, e.g. `/data/.dsh/guardian-hooks.json`:
|
|
202
|
+
|
|
203
|
+
```json
|
|
204
|
+
{
|
|
205
|
+
"hooks": {
|
|
206
|
+
"PreToolUse": [
|
|
207
|
+
{
|
|
208
|
+
"matcher": "^(bash|pwsh|terminal_send|write|edit|apply_patch|str_replace_editor|web_fetch|Bash|Write|Edit|MultiEdit|NotebookEdit|WebFetch)$|^mcp__.*",
|
|
209
|
+
"hooks": [
|
|
210
|
+
{
|
|
211
|
+
"type": "command",
|
|
212
|
+
"command": "guardian-hook dsh || { s=$?; [ $s -eq 2 ] || echo \"Guardian hook failed to run. Nothing ran.\" >&2; exit 2; }",
|
|
213
|
+
"timeout": 600
|
|
214
|
+
}
|
|
215
|
+
]
|
|
216
|
+
}
|
|
217
|
+
]
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Mount the bridge in `$DSH_HOME/cordis.patch.yml` (for example `/data/.dsh/cordis.patch.yml`):
|
|
223
|
+
|
|
224
|
+
```yaml
|
|
225
|
+
- insert:
|
|
226
|
+
- id: guardian-hooks
|
|
227
|
+
name: "@deepseek-ai/dsh-hooks-claude-code"
|
|
228
|
+
config:
|
|
229
|
+
configPath: /data/.dsh/guardian-hooks.json
|
|
230
|
+
defaultTimeoutMs: 600000
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
The bridge reads its config once at startup, so restart the agent after changing it. Approvers see the agent as "DeepSeek Harness".
|
|
234
|
+
|
|
235
|
+
**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".
|
|
236
|
+
|
|
237
|
+
| dsh tool | Guardian tool | What Guardian sees |
|
|
238
|
+
| ------------------------------------------------------- | -------------------------------------------------------- | -------------------------------------------------------- |
|
|
239
|
+
| `bash`, `pwsh`, `terminal_send` (text typed into a PTY) | `shell`, `shell_deploy`, `shell_destructive`, `git_push` | The command or typed text. |
|
|
240
|
+
| `write`, `edit`, `str_replace_editor` (all but `view`) | `file_write` | The path, size and a SHA-256 digest; never the contents. |
|
|
241
|
+
| `apply_patch` (on by default in `dsh-base`) | `file_write` | Target paths and operations, size and digest. |
|
|
242
|
+
| `web_fetch` | `web_fetch` | The URL. |
|
|
243
|
+
| `mcp__<server>__<tool>` | `mcp.<server>.<tool>` | The MCP arguments. |
|
|
244
|
+
|
|
245
|
+
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.
|
|
246
|
+
|
|
247
|
+
### Not governed under DeepSeek Harness
|
|
248
|
+
|
|
249
|
+
- **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.
|
|
250
|
+
- **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.
|
|
251
|
+
- `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.
|
|
252
|
+
- **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.
|
|
253
|
+
- 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/`.
|
|
107
254
|
|
|
108
255
|
## Limits (read these)
|
|
109
256
|
|
|
110
257
|
- **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.
|
|
111
|
-
- **Routing is local.** The
|
|
258
|
+
- **Routing is local and evadable.** The parser and patterns run on your machine. They do not see inside scripts, other interpreters' code, shell functions or configured git aliases (see [Tools the hook proposes](#tools-the-hook-proposes)).
|
|
259
|
+
- **The agent can still edit the hook's own config in ways the hook does not recognise.** Writes to the known config files (`.codex/hooks.json`, `guardian-hooks.json`, `cordis.patch.yml`, `.claude/settings.json` and the others listed above) go to the Ask tier, but a script, an interpreter (`python script.py`) or a path built at run time can still rewrite them 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.
|
|
260
|
+
- **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.
|
|
112
261
|
- **Only calls through the hook are governed.** Commands you run yourself, or other credentials on the machine, are not.
|
|
113
262
|
- **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.
|
|
114
|
-
- **Claude Code
|
|
263
|
+
- **Claude Code, Codex and DeepSeek Harness.** A Cursor adapter will ship once its hook contract is verified against its documentation.
|
|
264
|
+
- **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.
|
|
115
265
|
|
|
116
266
|
Licensed Apache-2.0.
|