@odingard/guardian-hook 0.2.0 → 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 +144 -5
- package/dist/agent-sdk.js +281 -22
- package/dist/index.js +438 -42
- package/package.json +4 -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,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.
|
|
@@ -103,14 +106,150 @@ It uses the same environment variables, the same two connections (the console's
|
|
|
103
106
|
|
|
104
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.
|
|
105
108
|
|
|
106
|
-
A runnable example is in `examples/claude-agent-sdk.mjs`.
|
|
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/`.
|
|
107
243
|
|
|
108
244
|
## Limits (read these)
|
|
109
245
|
|
|
110
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.
|
|
111
|
-
- **Routing is local.** The classification patterns run on your machine and
|
|
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.
|
|
112
250
|
- **Only calls through the hook are governed.** Commands you run yourself, or other credentials on the machine, are not.
|
|
113
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.
|
|
114
|
-
- **Claude Code
|
|
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.
|
|
115
254
|
|
|
116
255
|
Licensed Apache-2.0.
|
package/dist/agent-sdk.js
CHANGED
|
@@ -24,6 +24,11 @@ var HOOK_TOOLS = [
|
|
|
24
24
|
suggestedMode: "ask",
|
|
25
25
|
description: "Pushing commits to a remote."
|
|
26
26
|
},
|
|
27
|
+
{
|
|
28
|
+
name: "shell_session",
|
|
29
|
+
suggestedMode: "ask",
|
|
30
|
+
description: "Starting an interactive shell, interpreter or remote session (bash, python, ssh, docker exec -it). What is typed into it later is not seen by Guardian."
|
|
31
|
+
},
|
|
27
32
|
{
|
|
28
33
|
name: "file_write",
|
|
29
34
|
suggestedMode: "allow",
|
|
@@ -67,10 +72,102 @@ var DESTRUCTIVE = [
|
|
|
67
72
|
/\bfind\b.*\s-delete\b/
|
|
68
73
|
];
|
|
69
74
|
var GIT_PUSH = /\bgit\s+push\b/;
|
|
75
|
+
var INTERPRETERS = new Set(
|
|
76
|
+
"sh bash zsh dash ksh mksh csh tcsh fish ash pwsh powershell python python2 python3 ipython node nodejs deno bun ruby irb perl php lua luajit r julia ghci scala groovysh jshell tclsh wish expect su login tmux screen script".split(" ")
|
|
77
|
+
);
|
|
78
|
+
var DB_CLIENTS = new Set(
|
|
79
|
+
"psql mysql mariadb mongo mongosh cqlsh sqlplus clickhouse-client".split(" ")
|
|
80
|
+
);
|
|
81
|
+
var DB_INLINE = /^(-c|-e|-f|--command|--execute|--file|--eval|--query)(=|$)/;
|
|
82
|
+
var NET_PIPES = new Set("telnet nc ncat netcat socat openssl".split(" "));
|
|
83
|
+
var SSH_VALUE_OPTS = new Set(
|
|
84
|
+
"-B -b -c -D -E -e -F -I -i -J -L -l -m -O -o -p -Q -R -S -W -w".split(" ")
|
|
85
|
+
);
|
|
86
|
+
var SUDO_VALUE_OPTS = new Set("-u -g -U -C -D -h -p -r -t -T".split(" "));
|
|
87
|
+
var REDIS_VALUE_OPTS = new Set("-h -p -a -n -u --user --pass".split(" "));
|
|
88
|
+
var WRAPPERS = new Set(
|
|
89
|
+
"exec command builtin nohup time env stdbuf nice ionice".split(" ")
|
|
90
|
+
);
|
|
91
|
+
function positionals(args, valueOpts) {
|
|
92
|
+
const out = [];
|
|
93
|
+
for (let i = 0; i < args.length; i++) {
|
|
94
|
+
const a = args[i];
|
|
95
|
+
if (valueOpts.has(a)) i++;
|
|
96
|
+
else if (!a.startsWith("-")) out.push(a);
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
function startsSession(segment) {
|
|
101
|
+
const words = segment.trim().split(/\s+/).filter(Boolean);
|
|
102
|
+
let wrapped = false;
|
|
103
|
+
while (words.length > 0) {
|
|
104
|
+
const w = words[0];
|
|
105
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(w) || WRAPPERS.has(w) || wrapped && w.startsWith("-")) {
|
|
106
|
+
wrapped ||= WRAPPERS.has(w);
|
|
107
|
+
words.shift();
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
if (words.length === 0) return false;
|
|
113
|
+
const base = (words[0].split(/[\\/]/).pop() ?? "").replace(/^['"]+|['"]+$/g, "").toLowerCase().replace(/\.exe$/, "");
|
|
114
|
+
const args = words.slice(1);
|
|
115
|
+
if (args.some((a) => /^(--version|-V|--help)$/.test(a))) {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
if (base === "sudo" || base === "doas" || base === "busybox") {
|
|
119
|
+
if (base !== "busybox" && args.some((a) => /^-[a-zA-Z]*[is]$/.test(a))) {
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
const rest = positionals(args, SUDO_VALUE_OPTS);
|
|
123
|
+
if (rest.length === 0) return false;
|
|
124
|
+
return startsSession(args.slice(args.indexOf(rest[0])).join(" "));
|
|
125
|
+
}
|
|
126
|
+
if (base === "ssh" || base === "mosh") {
|
|
127
|
+
if (args.some((a) => /^-[a-zA-Z]*t/.test(a))) return true;
|
|
128
|
+
return positionals(args, SSH_VALUE_OPTS).length <= 1;
|
|
129
|
+
}
|
|
130
|
+
if (["docker", "podman", "nerdctl", "kubectl", "oc"].includes(base)) {
|
|
131
|
+
if (!args.some((a) => ["exec", "run", "attach", "debug"].includes(a))) {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
return args.includes("attach") || args.some(
|
|
135
|
+
(a) => /^(-[a-zA-Z]*i[a-zA-Z]*|--interactive|--stdin)(=true)?$/.test(a)
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
if (DB_CLIENTS.has(base)) {
|
|
139
|
+
return !args.some((a) => DB_INLINE.test(a)) && !segment.includes("<");
|
|
140
|
+
}
|
|
141
|
+
if (base === "sqlite3") {
|
|
142
|
+
return positionals(args, /* @__PURE__ */ new Set(["-cmd"])).length <= 1;
|
|
143
|
+
}
|
|
144
|
+
if (base === "redis-cli") {
|
|
145
|
+
return positionals(args, REDIS_VALUE_OPTS).length === 0;
|
|
146
|
+
}
|
|
147
|
+
if (NET_PIPES.has(base)) {
|
|
148
|
+
return base === "openssl" ? args[0] === "s_client" : !args.some((a) => /^-[a-zA-Z]*z/.test(a));
|
|
149
|
+
}
|
|
150
|
+
if (!INTERPRETERS.has(base) && !/^python[0-9.]+$/.test(base)) return false;
|
|
151
|
+
if ((base === "deno" || base === "bun") && args[0] === "repl") return true;
|
|
152
|
+
const code = args.findIndex((a) => /^-[a-zA-Z]*c[a-zA-Z]*$/.test(a));
|
|
153
|
+
if (code >= 0 && /sh$/.test(base)) {
|
|
154
|
+
const inner = args.slice(code + 1).join(" ").replace(/['"]/g, " ");
|
|
155
|
+
if (startsInteractiveSession(inner)) return true;
|
|
156
|
+
}
|
|
157
|
+
if (args.some((a) => /^(-[a-zA-Z]*[is][a-zA-Z]*|--interactive|-)$/.test(a))) {
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
return args.every((a) => a.startsWith("-") && !/^-[a-zA-Z]*[ce]/.test(a));
|
|
161
|
+
}
|
|
162
|
+
function startsInteractiveSession(command) {
|
|
163
|
+
if (/\bwhile\s+(?:IFS=\S*\s+)?read\b/.test(command)) return true;
|
|
164
|
+
return command.split(/&&|\|\||[;&|\n()]|\$\(|`/).some((segment) => startsSession(segment));
|
|
165
|
+
}
|
|
70
166
|
function classifyCommand(command) {
|
|
71
167
|
if (DESTRUCTIVE.some((re) => re.test(command))) return "shell_destructive";
|
|
72
168
|
if (DEPLOY.some((re) => re.test(command))) return "shell_deploy";
|
|
73
169
|
if (GIT_PUSH.test(command)) return "git_push";
|
|
170
|
+
if (startsInteractiveSession(command)) return "shell_session";
|
|
74
171
|
return "shell";
|
|
75
172
|
}
|
|
76
173
|
function mcpToolName(server, tool) {
|
|
@@ -86,34 +183,119 @@ function digest(text) {
|
|
|
86
183
|
function str(value) {
|
|
87
184
|
return typeof value === "string" ? value : "";
|
|
88
185
|
}
|
|
186
|
+
var SHELL_TOOLS = {
|
|
187
|
+
Bash: "command",
|
|
188
|
+
// Claude Code; Codex shell and unified exec
|
|
189
|
+
shell: "command",
|
|
190
|
+
bash: "command",
|
|
191
|
+
// DeepSeek Harness (plain and persistent)
|
|
192
|
+
pwsh: "command",
|
|
193
|
+
// DeepSeek Harness on Windows
|
|
194
|
+
terminal_send: "text"
|
|
195
|
+
// DeepSeek Harness PTY: typed input runs as a command
|
|
196
|
+
};
|
|
197
|
+
var FILE_TOOLS = /* @__PURE__ */ new Set([
|
|
198
|
+
"Write",
|
|
199
|
+
"Edit",
|
|
200
|
+
"MultiEdit",
|
|
201
|
+
"NotebookEdit",
|
|
202
|
+
"write",
|
|
203
|
+
// DeepSeek Harness
|
|
204
|
+
"edit"
|
|
205
|
+
// DeepSeek Harness
|
|
206
|
+
]);
|
|
207
|
+
function commandText(value) {
|
|
208
|
+
if (typeof value === "string") return value;
|
|
209
|
+
if (Array.isArray(value) && value.every((v) => typeof v === "string")) {
|
|
210
|
+
return value.join(" ");
|
|
211
|
+
}
|
|
212
|
+
return "";
|
|
213
|
+
}
|
|
214
|
+
var MAX_PATHS = 50;
|
|
215
|
+
function patchTargets(patch) {
|
|
216
|
+
const targets = [];
|
|
217
|
+
const re = /^[ \t]*\*\*\* (Add File|Update File|Delete File|Move to): (.+?)[ \t\r]*$/gm;
|
|
218
|
+
for (const m of patch.matchAll(re)) {
|
|
219
|
+
const kind = m[1];
|
|
220
|
+
const op = kind === "Add File" ? "add" : kind === "Update File" ? "update" : kind === "Delete File" ? "delete" : "move";
|
|
221
|
+
targets.push({ op, path: m[2] });
|
|
222
|
+
}
|
|
223
|
+
return targets;
|
|
224
|
+
}
|
|
225
|
+
function fileWrite(operation, path, content, cwd, summaryVerb) {
|
|
226
|
+
return {
|
|
227
|
+
toolName: "file_write",
|
|
228
|
+
arguments: {
|
|
229
|
+
operation,
|
|
230
|
+
path,
|
|
231
|
+
contentBytes: Buffer.byteLength(content),
|
|
232
|
+
contentDigest: digest(content),
|
|
233
|
+
...cwd
|
|
234
|
+
},
|
|
235
|
+
summary: `${summaryVerb} ${path}`
|
|
236
|
+
};
|
|
237
|
+
}
|
|
89
238
|
function governedCall(call) {
|
|
90
239
|
const { tool, input } = call;
|
|
91
240
|
const cwd = call.cwd ? { cwd: call.cwd } : {};
|
|
92
|
-
|
|
93
|
-
|
|
241
|
+
const shellField = Object.hasOwn(SHELL_TOOLS, tool) ? SHELL_TOOLS[tool] : void 0;
|
|
242
|
+
if (shellField) {
|
|
243
|
+
const command = commandText(input[shellField]);
|
|
94
244
|
const toolName = classifyCommand(command);
|
|
95
245
|
return {
|
|
96
246
|
toolName,
|
|
97
|
-
arguments: {
|
|
247
|
+
arguments: {
|
|
248
|
+
command: bounded(command),
|
|
249
|
+
...tool === "Bash" || tool === "shell" ? {} : { agentTool: tool },
|
|
250
|
+
...cwd
|
|
251
|
+
},
|
|
98
252
|
summary: `Run: ${bounded(command, 160)}`
|
|
99
253
|
};
|
|
100
254
|
}
|
|
101
|
-
if (
|
|
255
|
+
if (FILE_TOOLS.has(tool)) {
|
|
102
256
|
const path = str(input.file_path) || str(input.notebook_path);
|
|
103
257
|
const content = str(input.content) || str(input.new_string) || str(input.new_source) || (Array.isArray(input.edits) ? JSON.stringify(input.edits) : "");
|
|
258
|
+
return fileWrite(
|
|
259
|
+
tool,
|
|
260
|
+
path,
|
|
261
|
+
content,
|
|
262
|
+
cwd,
|
|
263
|
+
tool === "Write" || tool === "write" ? "Write" : "Edit"
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
if (tool === "str_replace_editor") {
|
|
267
|
+
if (input.command === "view") return null;
|
|
268
|
+
const sub = str(input.command) || "unknown";
|
|
269
|
+
const content = str(input.file_text) || str(input.new_str);
|
|
270
|
+
return fileWrite(
|
|
271
|
+
`str_replace_editor.${sub}`,
|
|
272
|
+
str(input.path),
|
|
273
|
+
content,
|
|
274
|
+
cwd,
|
|
275
|
+
sub === "create" ? "Write" : "Edit"
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
if (tool === "apply_patch") {
|
|
279
|
+
const patch = commandText(input.command) || str(input.patch) || str(input.input);
|
|
280
|
+
const targets = patchTargets(patch);
|
|
281
|
+
const paths = [...new Set(targets.map((t) => t.path))];
|
|
282
|
+
const shown = paths.slice(0, MAX_PATHS).map((p) => bounded(p, 500));
|
|
104
283
|
return {
|
|
105
284
|
toolName: "file_write",
|
|
106
285
|
arguments: {
|
|
107
|
-
operation:
|
|
108
|
-
path,
|
|
109
|
-
|
|
110
|
-
|
|
286
|
+
operation: "apply_patch",
|
|
287
|
+
path: shown[0] ?? "",
|
|
288
|
+
paths: shown,
|
|
289
|
+
...paths.length > MAX_PATHS ? { morePaths: paths.length - MAX_PATHS } : {},
|
|
290
|
+
operations: targets.slice(0, MAX_PATHS).map((t) => `${t.op} ${bounded(t.path, 500)}`),
|
|
291
|
+
contentBytes: Buffer.byteLength(patch),
|
|
292
|
+
contentDigest: digest(patch),
|
|
111
293
|
...cwd
|
|
112
294
|
},
|
|
113
|
-
summary:
|
|
295
|
+
summary: paths.length === 0 ? "Apply a patch (no file headers found)" : paths.length === 1 ? `Patch ${bounded(paths[0], 160)}` : `Patch ${paths.length} files: ${bounded(paths.join(", "), 160)}`
|
|
114
296
|
};
|
|
115
297
|
}
|
|
116
|
-
if (tool === "WebFetch") {
|
|
298
|
+
if (tool === "WebFetch" || tool === "web_fetch") {
|
|
117
299
|
const url = str(input.url);
|
|
118
300
|
return {
|
|
119
301
|
toolName: "web_fetch",
|
|
@@ -164,6 +346,7 @@ function denied(call, code, extra = {}) {
|
|
|
164
346
|
...extra
|
|
165
347
|
};
|
|
166
348
|
}
|
|
349
|
+
var FINAL_CHECK_LEAD_MS = 1500;
|
|
167
350
|
var isPending = (r) => !r.ok && (r.reasonCode === "APPROVAL_REQUIRED" || r.reasonCode === "APPROVAL_PENDING") || r.ok && r.decision === "ASK";
|
|
168
351
|
async function decide(call, options) {
|
|
169
352
|
const wait = options.wait ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
@@ -183,6 +366,7 @@ async function decide(call, options) {
|
|
|
183
366
|
} catch {
|
|
184
367
|
return denied(call, "SERVICE_UNAVAILABLE");
|
|
185
368
|
}
|
|
369
|
+
const answeredAt = now();
|
|
186
370
|
const requestId = result.requestId;
|
|
187
371
|
let approvedByHuman = false;
|
|
188
372
|
if (isPending(result)) {
|
|
@@ -190,14 +374,19 @@ async function decide(call, options) {
|
|
|
190
374
|
log(
|
|
191
375
|
`Guardian: "${call.summary}" is held for approval (request ${requestId}). Approve it on your phone or in the console.`
|
|
192
376
|
);
|
|
193
|
-
const
|
|
377
|
+
const window = Math.min(
|
|
378
|
+
options.approvalTimeoutMs,
|
|
379
|
+
options.requestTtlMs ?? options.approvalTimeoutMs
|
|
380
|
+
);
|
|
381
|
+
const lead = Math.min(FINAL_CHECK_LEAD_MS, Math.floor(window / 4));
|
|
382
|
+
const lastCheckAt = answeredAt + window - lead;
|
|
194
383
|
const maxInterval = Math.max(
|
|
195
384
|
options.maxPollIntervalMs ?? 1e4,
|
|
196
385
|
options.pollIntervalMs
|
|
197
386
|
);
|
|
198
387
|
let interval = options.pollIntervalMs;
|
|
199
388
|
for (; ; ) {
|
|
200
|
-
await wait(interval);
|
|
389
|
+
await wait(Math.max(0, Math.min(interval, lastCheckAt - now())));
|
|
201
390
|
interval = Math.min(Math.ceil(interval * 1.5), maxInterval);
|
|
202
391
|
let claimed;
|
|
203
392
|
try {
|
|
@@ -210,7 +399,7 @@ async function decide(call, options) {
|
|
|
210
399
|
approvedByHuman = claimed.ok && claimed.decision === "ALLOW";
|
|
211
400
|
break;
|
|
212
401
|
}
|
|
213
|
-
if (now() >=
|
|
402
|
+
if (now() >= lastCheckAt) {
|
|
214
403
|
return {
|
|
215
404
|
decision: "deny",
|
|
216
405
|
reason: `Guardian is still waiting for a human to approve ${call.toolName} (request ${requestId}). Nothing ran. Tell the developer it needs approval in the Guardian app, then try again.`,
|
|
@@ -256,6 +445,39 @@ async function decide(call, options) {
|
|
|
256
445
|
var DEFAULT_BASE_URL = "https://guardian-api.odingard.com";
|
|
257
446
|
var REQUEST_TTL_MS = 3e5;
|
|
258
447
|
var MAX_WAIT_MS = REQUEST_TTL_MS;
|
|
448
|
+
var DEFAULT_DEADLINE_MS = 54e4;
|
|
449
|
+
var AGENT_NAMES = {
|
|
450
|
+
"claude-code": "Claude Code",
|
|
451
|
+
codex: "Codex",
|
|
452
|
+
dsh: "DeepSeek Harness"
|
|
453
|
+
};
|
|
454
|
+
function formatVerdict(dialect, verdict) {
|
|
455
|
+
if (verdict === null) {
|
|
456
|
+
return { stdout: "", stderr: "", exitCode: 0, verdict };
|
|
457
|
+
}
|
|
458
|
+
if (dialect === "claude-code") {
|
|
459
|
+
return {
|
|
460
|
+
stdout: claudeCodeOutput(verdict),
|
|
461
|
+
stderr: "",
|
|
462
|
+
exitCode: 0,
|
|
463
|
+
verdict
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
if (verdict.decision === "allow") {
|
|
467
|
+
return {
|
|
468
|
+
stdout: dialect === "codex" ? "" : claudeCodeOutput(verdict),
|
|
469
|
+
stderr: "",
|
|
470
|
+
exitCode: 0,
|
|
471
|
+
verdict
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
return {
|
|
475
|
+
stdout: claudeCodeOutput(verdict),
|
|
476
|
+
stderr: verdict.reason,
|
|
477
|
+
exitCode: 2,
|
|
478
|
+
verdict
|
|
479
|
+
};
|
|
480
|
+
}
|
|
259
481
|
function claudeCodeOutput(verdict) {
|
|
260
482
|
return JSON.stringify({
|
|
261
483
|
hookSpecificOutput: {
|
|
@@ -271,16 +493,53 @@ function connectionFor(toolName, env) {
|
|
|
271
493
|
return env.GUARDIAN_HOOK_ASK_CONNECTION_ID?.trim() || allow;
|
|
272
494
|
}
|
|
273
495
|
async function runHook(stdin, env, deps = {}) {
|
|
274
|
-
|
|
496
|
+
return (await runHookAs("claude-code", stdin, env, deps)).stdout;
|
|
497
|
+
}
|
|
498
|
+
async function runHookAs(dialect, stdin, env, deps = {}) {
|
|
499
|
+
const requested = Number(
|
|
500
|
+
env.GUARDIAN_HOOK_DEADLINE_MS ?? DEFAULT_DEADLINE_MS
|
|
501
|
+
);
|
|
502
|
+
const deadlineMs = Number.isFinite(requested) && requested > 0 ? Math.min(requested, DEFAULT_DEADLINE_MS) : DEFAULT_DEADLINE_MS;
|
|
503
|
+
let timer;
|
|
504
|
+
const timedOut = new Promise((resolve) => {
|
|
505
|
+
timer = setTimeout(
|
|
506
|
+
() => resolve({
|
|
507
|
+
decision: "deny",
|
|
508
|
+
reason: "Guardian hook: no decision before the hook's deadline. Nothing ran."
|
|
509
|
+
}),
|
|
510
|
+
deadlineMs
|
|
511
|
+
);
|
|
512
|
+
timer.unref?.();
|
|
513
|
+
});
|
|
514
|
+
try {
|
|
515
|
+
const verdict = await Promise.race([
|
|
516
|
+
evaluate(dialect, stdin, env, deps, deadlineMs).catch(
|
|
517
|
+
() => ({
|
|
518
|
+
decision: "deny",
|
|
519
|
+
reason: "Guardian hook: crashed. Nothing ran."
|
|
520
|
+
})
|
|
521
|
+
),
|
|
522
|
+
timedOut
|
|
523
|
+
]);
|
|
524
|
+
return formatVerdict(dialect, verdict);
|
|
525
|
+
} finally {
|
|
526
|
+
clearTimeout(timer);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
async function evaluate(dialect, stdinText, env, deps, deadlineMs) {
|
|
530
|
+
const failClosed = (reason) => ({
|
|
531
|
+
decision: "deny",
|
|
532
|
+
reason: `Guardian hook: ${reason}`
|
|
533
|
+
});
|
|
275
534
|
let input;
|
|
276
535
|
try {
|
|
277
|
-
input = JSON.parse(
|
|
536
|
+
input = JSON.parse(await stdinText);
|
|
278
537
|
} catch {
|
|
279
538
|
return failClosed(
|
|
280
539
|
"could not read the tool call from the agent. Nothing ran."
|
|
281
540
|
);
|
|
282
541
|
}
|
|
283
|
-
if (typeof input.tool_name !== "string") {
|
|
542
|
+
if (!input || typeof input.tool_name !== "string") {
|
|
284
543
|
return failClosed("the tool call had no tool name. Nothing ran.");
|
|
285
544
|
}
|
|
286
545
|
const call = governedCall({
|
|
@@ -288,7 +547,7 @@ async function runHook(stdin, env, deps = {}) {
|
|
|
288
547
|
input: input.tool_input && typeof input.tool_input === "object" ? input.tool_input : {},
|
|
289
548
|
...typeof input.cwd === "string" ? { cwd: input.cwd } : {}
|
|
290
549
|
});
|
|
291
|
-
if (call === null) return
|
|
550
|
+
if (call === null) return null;
|
|
292
551
|
const connectionId = connectionFor(call.toolName, env);
|
|
293
552
|
const credential = env.GUARDIAN_AGENT_CREDENTIAL?.trim();
|
|
294
553
|
const baseUrl = (env.GUARDIAN_BASE_URL?.trim() || DEFAULT_BASE_URL).replace(
|
|
@@ -297,7 +556,7 @@ async function runHook(stdin, env, deps = {}) {
|
|
|
297
556
|
);
|
|
298
557
|
if (!deps.client && (!credential || !connectionId)) {
|
|
299
558
|
return failClosed(
|
|
300
|
-
|
|
559
|
+
`GUARDIAN_AGENT_CREDENTIAL and GUARDIAN_CONNECTION_ID must be set (run \`${dialect === "claude-code" ? "npx @odingard/guardian-hook" : `guardian-hook ${dialect}`} setup\`). Nothing ran.`
|
|
301
560
|
);
|
|
302
561
|
}
|
|
303
562
|
if (!deps.client && !baseUrl.startsWith("https://")) {
|
|
@@ -306,15 +565,16 @@ async function runHook(stdin, env, deps = {}) {
|
|
|
306
565
|
const requested = Number(
|
|
307
566
|
env.GUARDIAN_HOOK_APPROVAL_TIMEOUT_MS ?? MAX_WAIT_MS
|
|
308
567
|
);
|
|
309
|
-
const
|
|
310
|
-
const
|
|
568
|
+
const ceiling = Math.max(0, Math.min(MAX_WAIT_MS, deadlineMs - 3e4));
|
|
569
|
+
const approvalTimeoutMs = Number.isFinite(requested) ? Math.min(Math.max(requested, 0), ceiling) : ceiling;
|
|
570
|
+
return decide(call, {
|
|
311
571
|
client: deps.client ?? createGuardianClient({
|
|
312
572
|
baseUrl,
|
|
313
573
|
credential,
|
|
314
574
|
...env.GUARDIAN_ADAPTER_ID ? { adapterId: env.GUARDIAN_ADAPTER_ID } : {}
|
|
315
575
|
}),
|
|
316
576
|
connectionId: connectionId ?? "",
|
|
317
|
-
agentName: env.GUARDIAN_HOOK_AGENT_NAME?.trim() ||
|
|
577
|
+
agentName: env.GUARDIAN_HOOK_AGENT_NAME?.trim() || AGENT_NAMES[dialect],
|
|
318
578
|
approvalTimeoutMs,
|
|
319
579
|
requestTtlMs: REQUEST_TTL_MS,
|
|
320
580
|
pollIntervalMs: 2e3,
|
|
@@ -322,7 +582,6 @@ async function runHook(stdin, env, deps = {}) {
|
|
|
322
582
|
...deps.now ? { now: deps.now } : {},
|
|
323
583
|
...deps.log ? { log: deps.log } : {}
|
|
324
584
|
});
|
|
325
|
-
return claudeCodeOutput(verdict);
|
|
326
585
|
}
|
|
327
586
|
|
|
328
587
|
// src/agent-sdk.ts
|
package/dist/index.js
CHANGED
|
@@ -23,6 +23,11 @@ var HOOK_TOOLS = [
|
|
|
23
23
|
suggestedMode: "ask",
|
|
24
24
|
description: "Pushing commits to a remote."
|
|
25
25
|
},
|
|
26
|
+
{
|
|
27
|
+
name: "shell_session",
|
|
28
|
+
suggestedMode: "ask",
|
|
29
|
+
description: "Starting an interactive shell, interpreter or remote session (bash, python, ssh, docker exec -it). What is typed into it later is not seen by Guardian."
|
|
30
|
+
},
|
|
26
31
|
{
|
|
27
32
|
name: "file_write",
|
|
28
33
|
suggestedMode: "allow",
|
|
@@ -66,10 +71,102 @@ var DESTRUCTIVE = [
|
|
|
66
71
|
/\bfind\b.*\s-delete\b/
|
|
67
72
|
];
|
|
68
73
|
var GIT_PUSH = /\bgit\s+push\b/;
|
|
74
|
+
var INTERPRETERS = new Set(
|
|
75
|
+
"sh bash zsh dash ksh mksh csh tcsh fish ash pwsh powershell python python2 python3 ipython node nodejs deno bun ruby irb perl php lua luajit r julia ghci scala groovysh jshell tclsh wish expect su login tmux screen script".split(" ")
|
|
76
|
+
);
|
|
77
|
+
var DB_CLIENTS = new Set(
|
|
78
|
+
"psql mysql mariadb mongo mongosh cqlsh sqlplus clickhouse-client".split(" ")
|
|
79
|
+
);
|
|
80
|
+
var DB_INLINE = /^(-c|-e|-f|--command|--execute|--file|--eval|--query)(=|$)/;
|
|
81
|
+
var NET_PIPES = new Set("telnet nc ncat netcat socat openssl".split(" "));
|
|
82
|
+
var SSH_VALUE_OPTS = new Set(
|
|
83
|
+
"-B -b -c -D -E -e -F -I -i -J -L -l -m -O -o -p -Q -R -S -W -w".split(" ")
|
|
84
|
+
);
|
|
85
|
+
var SUDO_VALUE_OPTS = new Set("-u -g -U -C -D -h -p -r -t -T".split(" "));
|
|
86
|
+
var REDIS_VALUE_OPTS = new Set("-h -p -a -n -u --user --pass".split(" "));
|
|
87
|
+
var WRAPPERS = new Set(
|
|
88
|
+
"exec command builtin nohup time env stdbuf nice ionice".split(" ")
|
|
89
|
+
);
|
|
90
|
+
function positionals(args, valueOpts) {
|
|
91
|
+
const out = [];
|
|
92
|
+
for (let i = 0; i < args.length; i++) {
|
|
93
|
+
const a = args[i];
|
|
94
|
+
if (valueOpts.has(a)) i++;
|
|
95
|
+
else if (!a.startsWith("-")) out.push(a);
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
function startsSession(segment) {
|
|
100
|
+
const words = segment.trim().split(/\s+/).filter(Boolean);
|
|
101
|
+
let wrapped = false;
|
|
102
|
+
while (words.length > 0) {
|
|
103
|
+
const w = words[0];
|
|
104
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(w) || WRAPPERS.has(w) || wrapped && w.startsWith("-")) {
|
|
105
|
+
wrapped ||= WRAPPERS.has(w);
|
|
106
|
+
words.shift();
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
if (words.length === 0) return false;
|
|
112
|
+
const base = (words[0].split(/[\\/]/).pop() ?? "").replace(/^['"]+|['"]+$/g, "").toLowerCase().replace(/\.exe$/, "");
|
|
113
|
+
const args = words.slice(1);
|
|
114
|
+
if (args.some((a) => /^(--version|-V|--help)$/.test(a))) {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
if (base === "sudo" || base === "doas" || base === "busybox") {
|
|
118
|
+
if (base !== "busybox" && args.some((a) => /^-[a-zA-Z]*[is]$/.test(a))) {
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
const rest = positionals(args, SUDO_VALUE_OPTS);
|
|
122
|
+
if (rest.length === 0) return false;
|
|
123
|
+
return startsSession(args.slice(args.indexOf(rest[0])).join(" "));
|
|
124
|
+
}
|
|
125
|
+
if (base === "ssh" || base === "mosh") {
|
|
126
|
+
if (args.some((a) => /^-[a-zA-Z]*t/.test(a))) return true;
|
|
127
|
+
return positionals(args, SSH_VALUE_OPTS).length <= 1;
|
|
128
|
+
}
|
|
129
|
+
if (["docker", "podman", "nerdctl", "kubectl", "oc"].includes(base)) {
|
|
130
|
+
if (!args.some((a) => ["exec", "run", "attach", "debug"].includes(a))) {
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
return args.includes("attach") || args.some(
|
|
134
|
+
(a) => /^(-[a-zA-Z]*i[a-zA-Z]*|--interactive|--stdin)(=true)?$/.test(a)
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
if (DB_CLIENTS.has(base)) {
|
|
138
|
+
return !args.some((a) => DB_INLINE.test(a)) && !segment.includes("<");
|
|
139
|
+
}
|
|
140
|
+
if (base === "sqlite3") {
|
|
141
|
+
return positionals(args, /* @__PURE__ */ new Set(["-cmd"])).length <= 1;
|
|
142
|
+
}
|
|
143
|
+
if (base === "redis-cli") {
|
|
144
|
+
return positionals(args, REDIS_VALUE_OPTS).length === 0;
|
|
145
|
+
}
|
|
146
|
+
if (NET_PIPES.has(base)) {
|
|
147
|
+
return base === "openssl" ? args[0] === "s_client" : !args.some((a) => /^-[a-zA-Z]*z/.test(a));
|
|
148
|
+
}
|
|
149
|
+
if (!INTERPRETERS.has(base) && !/^python[0-9.]+$/.test(base)) return false;
|
|
150
|
+
if ((base === "deno" || base === "bun") && args[0] === "repl") return true;
|
|
151
|
+
const code = args.findIndex((a) => /^-[a-zA-Z]*c[a-zA-Z]*$/.test(a));
|
|
152
|
+
if (code >= 0 && /sh$/.test(base)) {
|
|
153
|
+
const inner = args.slice(code + 1).join(" ").replace(/['"]/g, " ");
|
|
154
|
+
if (startsInteractiveSession(inner)) return true;
|
|
155
|
+
}
|
|
156
|
+
if (args.some((a) => /^(-[a-zA-Z]*[is][a-zA-Z]*|--interactive|-)$/.test(a))) {
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
return args.every((a) => a.startsWith("-") && !/^-[a-zA-Z]*[ce]/.test(a));
|
|
160
|
+
}
|
|
161
|
+
function startsInteractiveSession(command) {
|
|
162
|
+
if (/\bwhile\s+(?:IFS=\S*\s+)?read\b/.test(command)) return true;
|
|
163
|
+
return command.split(/&&|\|\||[;&|\n()]|\$\(|`/).some((segment) => startsSession(segment));
|
|
164
|
+
}
|
|
69
165
|
function classifyCommand(command) {
|
|
70
166
|
if (DESTRUCTIVE.some((re) => re.test(command))) return "shell_destructive";
|
|
71
167
|
if (DEPLOY.some((re) => re.test(command))) return "shell_deploy";
|
|
72
168
|
if (GIT_PUSH.test(command)) return "git_push";
|
|
169
|
+
if (startsInteractiveSession(command)) return "shell_session";
|
|
73
170
|
return "shell";
|
|
74
171
|
}
|
|
75
172
|
function mcpToolName(server, tool) {
|
|
@@ -85,34 +182,119 @@ function digest(text) {
|
|
|
85
182
|
function str(value) {
|
|
86
183
|
return typeof value === "string" ? value : "";
|
|
87
184
|
}
|
|
185
|
+
var SHELL_TOOLS = {
|
|
186
|
+
Bash: "command",
|
|
187
|
+
// Claude Code; Codex shell and unified exec
|
|
188
|
+
shell: "command",
|
|
189
|
+
bash: "command",
|
|
190
|
+
// DeepSeek Harness (plain and persistent)
|
|
191
|
+
pwsh: "command",
|
|
192
|
+
// DeepSeek Harness on Windows
|
|
193
|
+
terminal_send: "text"
|
|
194
|
+
// DeepSeek Harness PTY: typed input runs as a command
|
|
195
|
+
};
|
|
196
|
+
var FILE_TOOLS = /* @__PURE__ */ new Set([
|
|
197
|
+
"Write",
|
|
198
|
+
"Edit",
|
|
199
|
+
"MultiEdit",
|
|
200
|
+
"NotebookEdit",
|
|
201
|
+
"write",
|
|
202
|
+
// DeepSeek Harness
|
|
203
|
+
"edit"
|
|
204
|
+
// DeepSeek Harness
|
|
205
|
+
]);
|
|
206
|
+
function commandText(value) {
|
|
207
|
+
if (typeof value === "string") return value;
|
|
208
|
+
if (Array.isArray(value) && value.every((v) => typeof v === "string")) {
|
|
209
|
+
return value.join(" ");
|
|
210
|
+
}
|
|
211
|
+
return "";
|
|
212
|
+
}
|
|
213
|
+
var MAX_PATHS = 50;
|
|
214
|
+
function patchTargets(patch) {
|
|
215
|
+
const targets = [];
|
|
216
|
+
const re = /^[ \t]*\*\*\* (Add File|Update File|Delete File|Move to): (.+?)[ \t\r]*$/gm;
|
|
217
|
+
for (const m of patch.matchAll(re)) {
|
|
218
|
+
const kind = m[1];
|
|
219
|
+
const op = kind === "Add File" ? "add" : kind === "Update File" ? "update" : kind === "Delete File" ? "delete" : "move";
|
|
220
|
+
targets.push({ op, path: m[2] });
|
|
221
|
+
}
|
|
222
|
+
return targets;
|
|
223
|
+
}
|
|
224
|
+
function fileWrite(operation, path, content, cwd, summaryVerb) {
|
|
225
|
+
return {
|
|
226
|
+
toolName: "file_write",
|
|
227
|
+
arguments: {
|
|
228
|
+
operation,
|
|
229
|
+
path,
|
|
230
|
+
contentBytes: Buffer.byteLength(content),
|
|
231
|
+
contentDigest: digest(content),
|
|
232
|
+
...cwd
|
|
233
|
+
},
|
|
234
|
+
summary: `${summaryVerb} ${path}`
|
|
235
|
+
};
|
|
236
|
+
}
|
|
88
237
|
function governedCall(call) {
|
|
89
238
|
const { tool, input } = call;
|
|
90
239
|
const cwd = call.cwd ? { cwd: call.cwd } : {};
|
|
91
|
-
|
|
92
|
-
|
|
240
|
+
const shellField = Object.hasOwn(SHELL_TOOLS, tool) ? SHELL_TOOLS[tool] : void 0;
|
|
241
|
+
if (shellField) {
|
|
242
|
+
const command = commandText(input[shellField]);
|
|
93
243
|
const toolName = classifyCommand(command);
|
|
94
244
|
return {
|
|
95
245
|
toolName,
|
|
96
|
-
arguments: {
|
|
246
|
+
arguments: {
|
|
247
|
+
command: bounded(command),
|
|
248
|
+
...tool === "Bash" || tool === "shell" ? {} : { agentTool: tool },
|
|
249
|
+
...cwd
|
|
250
|
+
},
|
|
97
251
|
summary: `Run: ${bounded(command, 160)}`
|
|
98
252
|
};
|
|
99
253
|
}
|
|
100
|
-
if (
|
|
254
|
+
if (FILE_TOOLS.has(tool)) {
|
|
101
255
|
const path = str(input.file_path) || str(input.notebook_path);
|
|
102
256
|
const content = str(input.content) || str(input.new_string) || str(input.new_source) || (Array.isArray(input.edits) ? JSON.stringify(input.edits) : "");
|
|
257
|
+
return fileWrite(
|
|
258
|
+
tool,
|
|
259
|
+
path,
|
|
260
|
+
content,
|
|
261
|
+
cwd,
|
|
262
|
+
tool === "Write" || tool === "write" ? "Write" : "Edit"
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
if (tool === "str_replace_editor") {
|
|
266
|
+
if (input.command === "view") return null;
|
|
267
|
+
const sub = str(input.command) || "unknown";
|
|
268
|
+
const content = str(input.file_text) || str(input.new_str);
|
|
269
|
+
return fileWrite(
|
|
270
|
+
`str_replace_editor.${sub}`,
|
|
271
|
+
str(input.path),
|
|
272
|
+
content,
|
|
273
|
+
cwd,
|
|
274
|
+
sub === "create" ? "Write" : "Edit"
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
if (tool === "apply_patch") {
|
|
278
|
+
const patch = commandText(input.command) || str(input.patch) || str(input.input);
|
|
279
|
+
const targets = patchTargets(patch);
|
|
280
|
+
const paths = [...new Set(targets.map((t) => t.path))];
|
|
281
|
+
const shown = paths.slice(0, MAX_PATHS).map((p) => bounded(p, 500));
|
|
103
282
|
return {
|
|
104
283
|
toolName: "file_write",
|
|
105
284
|
arguments: {
|
|
106
|
-
operation:
|
|
107
|
-
path,
|
|
108
|
-
|
|
109
|
-
|
|
285
|
+
operation: "apply_patch",
|
|
286
|
+
path: shown[0] ?? "",
|
|
287
|
+
paths: shown,
|
|
288
|
+
...paths.length > MAX_PATHS ? { morePaths: paths.length - MAX_PATHS } : {},
|
|
289
|
+
operations: targets.slice(0, MAX_PATHS).map((t) => `${t.op} ${bounded(t.path, 500)}`),
|
|
290
|
+
contentBytes: Buffer.byteLength(patch),
|
|
291
|
+
contentDigest: digest(patch),
|
|
110
292
|
...cwd
|
|
111
293
|
},
|
|
112
|
-
summary:
|
|
294
|
+
summary: paths.length === 0 ? "Apply a patch (no file headers found)" : paths.length === 1 ? `Patch ${bounded(paths[0], 160)}` : `Patch ${paths.length} files: ${bounded(paths.join(", "), 160)}`
|
|
113
295
|
};
|
|
114
296
|
}
|
|
115
|
-
if (tool === "WebFetch") {
|
|
297
|
+
if (tool === "WebFetch" || tool === "web_fetch") {
|
|
116
298
|
const url = str(input.url);
|
|
117
299
|
return {
|
|
118
300
|
toolName: "web_fetch",
|
|
@@ -166,6 +348,7 @@ function denied(call, code, extra = {}) {
|
|
|
166
348
|
...extra
|
|
167
349
|
};
|
|
168
350
|
}
|
|
351
|
+
var FINAL_CHECK_LEAD_MS = 1500;
|
|
169
352
|
var isPending = (r) => !r.ok && (r.reasonCode === "APPROVAL_REQUIRED" || r.reasonCode === "APPROVAL_PENDING") || r.ok && r.decision === "ASK";
|
|
170
353
|
async function decide(call, options) {
|
|
171
354
|
const wait = options.wait ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
@@ -185,6 +368,7 @@ async function decide(call, options) {
|
|
|
185
368
|
} catch {
|
|
186
369
|
return denied(call, "SERVICE_UNAVAILABLE");
|
|
187
370
|
}
|
|
371
|
+
const answeredAt = now();
|
|
188
372
|
const requestId = result.requestId;
|
|
189
373
|
let approvedByHuman = false;
|
|
190
374
|
if (isPending(result)) {
|
|
@@ -192,14 +376,19 @@ async function decide(call, options) {
|
|
|
192
376
|
log(
|
|
193
377
|
`Guardian: "${call.summary}" is held for approval (request ${requestId}). Approve it on your phone or in the console.`
|
|
194
378
|
);
|
|
195
|
-
const
|
|
379
|
+
const window = Math.min(
|
|
380
|
+
options.approvalTimeoutMs,
|
|
381
|
+
options.requestTtlMs ?? options.approvalTimeoutMs
|
|
382
|
+
);
|
|
383
|
+
const lead = Math.min(FINAL_CHECK_LEAD_MS, Math.floor(window / 4));
|
|
384
|
+
const lastCheckAt = answeredAt + window - lead;
|
|
196
385
|
const maxInterval = Math.max(
|
|
197
386
|
options.maxPollIntervalMs ?? 1e4,
|
|
198
387
|
options.pollIntervalMs
|
|
199
388
|
);
|
|
200
389
|
let interval = options.pollIntervalMs;
|
|
201
390
|
for (; ; ) {
|
|
202
|
-
await wait(interval);
|
|
391
|
+
await wait(Math.max(0, Math.min(interval, lastCheckAt - now())));
|
|
203
392
|
interval = Math.min(Math.ceil(interval * 1.5), maxInterval);
|
|
204
393
|
let claimed;
|
|
205
394
|
try {
|
|
@@ -212,7 +401,7 @@ async function decide(call, options) {
|
|
|
212
401
|
approvedByHuman = claimed.ok && claimed.decision === "ALLOW";
|
|
213
402
|
break;
|
|
214
403
|
}
|
|
215
|
-
if (now() >=
|
|
404
|
+
if (now() >= lastCheckAt) {
|
|
216
405
|
return {
|
|
217
406
|
decision: "deny",
|
|
218
407
|
reason: `Guardian is still waiting for a human to approve ${call.toolName} (request ${requestId}). Nothing ran. Tell the developer it needs approval in the Guardian app, then try again.`,
|
|
@@ -258,6 +447,40 @@ async function decide(call, options) {
|
|
|
258
447
|
var DEFAULT_BASE_URL = "https://guardian-api.odingard.com";
|
|
259
448
|
var REQUEST_TTL_MS = 3e5;
|
|
260
449
|
var MAX_WAIT_MS = REQUEST_TTL_MS;
|
|
450
|
+
var DEFAULT_DEADLINE_MS = 54e4;
|
|
451
|
+
var DIALECTS = ["claude-code", "codex", "dsh"];
|
|
452
|
+
var AGENT_NAMES = {
|
|
453
|
+
"claude-code": "Claude Code",
|
|
454
|
+
codex: "Codex",
|
|
455
|
+
dsh: "DeepSeek Harness"
|
|
456
|
+
};
|
|
457
|
+
function formatVerdict(dialect2, verdict) {
|
|
458
|
+
if (verdict === null) {
|
|
459
|
+
return { stdout: "", stderr: "", exitCode: 0, verdict };
|
|
460
|
+
}
|
|
461
|
+
if (dialect2 === "claude-code") {
|
|
462
|
+
return {
|
|
463
|
+
stdout: claudeCodeOutput(verdict),
|
|
464
|
+
stderr: "",
|
|
465
|
+
exitCode: 0,
|
|
466
|
+
verdict
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
if (verdict.decision === "allow") {
|
|
470
|
+
return {
|
|
471
|
+
stdout: dialect2 === "codex" ? "" : claudeCodeOutput(verdict),
|
|
472
|
+
stderr: "",
|
|
473
|
+
exitCode: 0,
|
|
474
|
+
verdict
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
return {
|
|
478
|
+
stdout: claudeCodeOutput(verdict),
|
|
479
|
+
stderr: verdict.reason,
|
|
480
|
+
exitCode: 2,
|
|
481
|
+
verdict
|
|
482
|
+
};
|
|
483
|
+
}
|
|
261
484
|
function claudeCodeOutput(verdict) {
|
|
262
485
|
return JSON.stringify({
|
|
263
486
|
hookSpecificOutput: {
|
|
@@ -283,12 +506,41 @@ var SETTINGS_SNIPPET = {
|
|
|
283
506
|
]
|
|
284
507
|
}
|
|
285
508
|
};
|
|
509
|
+
var CODEX_HOOK_COMMAND = 'guardian-hook codex || { s=$?; [ $s -eq 2 ] || echo "Guardian hook failed to run. Nothing ran." >&2; exit 2; }';
|
|
510
|
+
var CODEX_HOOKS_SNIPPET = {
|
|
511
|
+
hooks: {
|
|
512
|
+
PreToolUse: [
|
|
513
|
+
{
|
|
514
|
+
matcher: "^(Bash|apply_patch)$|^mcp__.*",
|
|
515
|
+
hooks: [
|
|
516
|
+
{
|
|
517
|
+
type: "command",
|
|
518
|
+
command: CODEX_HOOK_COMMAND,
|
|
519
|
+
timeout: 600,
|
|
520
|
+
statusMessage: "Guardian is checking this action"
|
|
521
|
+
}
|
|
522
|
+
]
|
|
523
|
+
}
|
|
524
|
+
]
|
|
525
|
+
}
|
|
526
|
+
};
|
|
527
|
+
var DSH_HOOK_COMMAND = 'guardian-hook dsh || { s=$?; [ $s -eq 2 ] || echo "Guardian hook failed to run. Nothing ran." >&2; exit 2; }';
|
|
528
|
+
var DSH_HOOKS_SNIPPET = {
|
|
529
|
+
hooks: {
|
|
530
|
+
PreToolUse: [
|
|
531
|
+
{
|
|
532
|
+
matcher: "^(bash|pwsh|terminal_send|write|edit|apply_patch|str_replace_editor|web_fetch|Bash|Write|Edit|MultiEdit|NotebookEdit|WebFetch)$|^mcp__.*",
|
|
533
|
+
hooks: [{ type: "command", command: DSH_HOOK_COMMAND, timeout: 600 }]
|
|
534
|
+
}
|
|
535
|
+
]
|
|
536
|
+
}
|
|
537
|
+
};
|
|
286
538
|
function connectionFor(toolName, env) {
|
|
287
539
|
const allow = env.GUARDIAN_CONNECTION_ID?.trim() || void 0;
|
|
288
540
|
if (tierOf(toolName) === "allow") return allow;
|
|
289
541
|
return env.GUARDIAN_HOOK_ASK_CONNECTION_ID?.trim() || allow;
|
|
290
542
|
}
|
|
291
|
-
function setupText() {
|
|
543
|
+
function setupText(dialect2 = "claude-code") {
|
|
292
544
|
const list = (mode) => HOOK_TOOLS.filter((t) => t.suggestedMode === mode).map((t) => ` ${t.name.padEnd(18)} ${t.description}`).join("\n");
|
|
293
545
|
return [
|
|
294
546
|
"Guardian modes are set per connection, so the hook uses two record-only tool servers.",
|
|
@@ -310,23 +562,111 @@ function setupText() {
|
|
|
310
562
|
"3. In the shell that starts the agent, export GUARDIAN_AGENT_CREDENTIAL,",
|
|
311
563
|
" GUARDIAN_CONNECTION_ID (a) and GUARDIAN_HOOK_ASK_CONNECTION_ID (b).",
|
|
312
564
|
"",
|
|
565
|
+
...agentSteps(dialect2)
|
|
566
|
+
].join("\n");
|
|
567
|
+
}
|
|
568
|
+
function agentSteps(dialect2) {
|
|
569
|
+
if (dialect2 === "codex") {
|
|
570
|
+
return [
|
|
571
|
+
"4. Install the hook where Codex runs (npm i -g @odingard/guardian-hook), then add",
|
|
572
|
+
" this to ~/.codex/hooks.json (or <repo>/.codex/hooks.json):",
|
|
573
|
+
JSON.stringify(CODEX_HOOKS_SNIPPET, null, 2),
|
|
574
|
+
"",
|
|
575
|
+
"5. Trust it once with /hooks in the Codex TUI, or run headless with",
|
|
576
|
+
" `codex exec --dangerously-bypass-hook-trust ...`. Untrusted hooks are skipped.",
|
|
577
|
+
"",
|
|
578
|
+
"Governed: Bash (shell and unified exec_command), apply_patch file edits, MCP tools.",
|
|
579
|
+
"NOT governed: write_stdin. Codex runs no hook when it types into an open",
|
|
580
|
+
"exec_command session, so anything typed there skips Guardian. Starting an",
|
|
581
|
+
"interactive shell, interpreter or ssh session is routed to shell_session (Ask);",
|
|
582
|
+
"to close the gap, turn unified exec off in a managed requirements.toml:",
|
|
583
|
+
" [features]",
|
|
584
|
+
" unified_exec = false",
|
|
585
|
+
"Also not governed: hosted tools such as WebSearch, and tool paths Codex runs",
|
|
586
|
+
"without hooks. A missing, untrusted or unparseable hooks.json governs nothing.",
|
|
587
|
+
"Codex lets a call run if the hook crashes or times out; the || above and the",
|
|
588
|
+
"hook's own 540 s deadline turn those cases into a deny."
|
|
589
|
+
];
|
|
590
|
+
}
|
|
591
|
+
if (dialect2 === "dsh") {
|
|
592
|
+
return [
|
|
593
|
+
"4. Install the hook in the image or on the volume (npm i -g @odingard/guardian-hook),",
|
|
594
|
+
" save this as /data/.dsh/guardian-hooks.json:",
|
|
595
|
+
JSON.stringify(DSH_HOOKS_SNIPPET, null, 2),
|
|
596
|
+
"",
|
|
597
|
+
"5. Mount the Claude Code hook bridge in $DSH_HOME/cordis.patch.yml:",
|
|
598
|
+
" - insert:",
|
|
599
|
+
" - id: guardian-hooks",
|
|
600
|
+
" name: '@deepseek-ai/dsh-hooks-claude-code'",
|
|
601
|
+
" config: { configPath: /data/.dsh/guardian-hooks.json, defaultTimeoutMs: 600000 }",
|
|
602
|
+
"",
|
|
603
|
+
"Governed: bash, pwsh, terminal_send, write, edit, apply_patch, str_replace_editor",
|
|
604
|
+
"(not view), web_fetch, MCP tools. The matcher decides whether the hook runs at",
|
|
605
|
+
"all: a tool it does not name is not governed. NOT governed: every other dsh tool,",
|
|
606
|
+
"including run_code itself, subagent, workflow, plugin_manager and",
|
|
607
|
+
"load_workspace_dependencies (install packages), stagehand_act/stagehand_navigate",
|
|
608
|
+
"(browser actions), send_message, spawn_teammate, team_task_*, job_*, schedule_*,",
|
|
609
|
+
"skill, lsp, propose_action, terminal_open, web_search, read/glob/grep.",
|
|
610
|
+
"A missing or unparseable hooks file: the bridge registers no hooks and nothing",
|
|
611
|
+
"is governed. The bridge lets a call run if the hook crashes; the || above and",
|
|
612
|
+
"the hook's own 540 s deadline turn those cases into a deny."
|
|
613
|
+
];
|
|
614
|
+
}
|
|
615
|
+
return [
|
|
313
616
|
"4. Add this to .claude/settings.json (project) or ~/.claude/settings.json (all projects):",
|
|
314
617
|
JSON.stringify(SETTINGS_SNIPPET, null, 2),
|
|
315
618
|
"",
|
|
316
619
|
"Read-only tools (Read, Grep, Glob, LS, WebSearch) are not routed through Guardian."
|
|
317
|
-
]
|
|
620
|
+
];
|
|
318
621
|
}
|
|
319
622
|
async function runHook(stdin, env, deps = {}) {
|
|
320
|
-
|
|
623
|
+
return (await runHookAs("claude-code", stdin, env, deps)).stdout;
|
|
624
|
+
}
|
|
625
|
+
async function runHookAs(dialect2, stdin, env, deps = {}) {
|
|
626
|
+
const requested = Number(
|
|
627
|
+
env.GUARDIAN_HOOK_DEADLINE_MS ?? DEFAULT_DEADLINE_MS
|
|
628
|
+
);
|
|
629
|
+
const deadlineMs = Number.isFinite(requested) && requested > 0 ? Math.min(requested, DEFAULT_DEADLINE_MS) : DEFAULT_DEADLINE_MS;
|
|
630
|
+
let timer;
|
|
631
|
+
const timedOut = new Promise((resolve) => {
|
|
632
|
+
timer = setTimeout(
|
|
633
|
+
() => resolve({
|
|
634
|
+
decision: "deny",
|
|
635
|
+
reason: "Guardian hook: no decision before the hook's deadline. Nothing ran."
|
|
636
|
+
}),
|
|
637
|
+
deadlineMs
|
|
638
|
+
);
|
|
639
|
+
timer.unref?.();
|
|
640
|
+
});
|
|
641
|
+
try {
|
|
642
|
+
const verdict = await Promise.race([
|
|
643
|
+
evaluate(dialect2, stdin, env, deps, deadlineMs).catch(
|
|
644
|
+
() => ({
|
|
645
|
+
decision: "deny",
|
|
646
|
+
reason: "Guardian hook: crashed. Nothing ran."
|
|
647
|
+
})
|
|
648
|
+
),
|
|
649
|
+
timedOut
|
|
650
|
+
]);
|
|
651
|
+
return formatVerdict(dialect2, verdict);
|
|
652
|
+
} finally {
|
|
653
|
+
clearTimeout(timer);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
async function evaluate(dialect2, stdinText, env, deps, deadlineMs) {
|
|
657
|
+
const failClosed = (reason) => ({
|
|
658
|
+
decision: "deny",
|
|
659
|
+
reason: `Guardian hook: ${reason}`
|
|
660
|
+
});
|
|
321
661
|
let input;
|
|
322
662
|
try {
|
|
323
|
-
input = JSON.parse(
|
|
663
|
+
input = JSON.parse(await stdinText);
|
|
324
664
|
} catch {
|
|
325
665
|
return failClosed(
|
|
326
666
|
"could not read the tool call from the agent. Nothing ran."
|
|
327
667
|
);
|
|
328
668
|
}
|
|
329
|
-
if (typeof input.tool_name !== "string") {
|
|
669
|
+
if (!input || typeof input.tool_name !== "string") {
|
|
330
670
|
return failClosed("the tool call had no tool name. Nothing ran.");
|
|
331
671
|
}
|
|
332
672
|
const call = governedCall({
|
|
@@ -334,7 +674,7 @@ async function runHook(stdin, env, deps = {}) {
|
|
|
334
674
|
input: input.tool_input && typeof input.tool_input === "object" ? input.tool_input : {},
|
|
335
675
|
...typeof input.cwd === "string" ? { cwd: input.cwd } : {}
|
|
336
676
|
});
|
|
337
|
-
if (call === null) return
|
|
677
|
+
if (call === null) return null;
|
|
338
678
|
const connectionId = connectionFor(call.toolName, env);
|
|
339
679
|
const credential = env.GUARDIAN_AGENT_CREDENTIAL?.trim();
|
|
340
680
|
const baseUrl = (env.GUARDIAN_BASE_URL?.trim() || DEFAULT_BASE_URL).replace(
|
|
@@ -343,7 +683,7 @@ async function runHook(stdin, env, deps = {}) {
|
|
|
343
683
|
);
|
|
344
684
|
if (!deps.client && (!credential || !connectionId)) {
|
|
345
685
|
return failClosed(
|
|
346
|
-
|
|
686
|
+
`GUARDIAN_AGENT_CREDENTIAL and GUARDIAN_CONNECTION_ID must be set (run \`${dialect2 === "claude-code" ? "npx @odingard/guardian-hook" : `guardian-hook ${dialect2}`} setup\`). Nothing ran.`
|
|
347
687
|
);
|
|
348
688
|
}
|
|
349
689
|
if (!deps.client && !baseUrl.startsWith("https://")) {
|
|
@@ -352,15 +692,16 @@ async function runHook(stdin, env, deps = {}) {
|
|
|
352
692
|
const requested = Number(
|
|
353
693
|
env.GUARDIAN_HOOK_APPROVAL_TIMEOUT_MS ?? MAX_WAIT_MS
|
|
354
694
|
);
|
|
355
|
-
const
|
|
356
|
-
const
|
|
695
|
+
const ceiling = Math.max(0, Math.min(MAX_WAIT_MS, deadlineMs - 3e4));
|
|
696
|
+
const approvalTimeoutMs = Number.isFinite(requested) ? Math.min(Math.max(requested, 0), ceiling) : ceiling;
|
|
697
|
+
return decide(call, {
|
|
357
698
|
client: deps.client ?? createGuardianClient({
|
|
358
699
|
baseUrl,
|
|
359
700
|
credential,
|
|
360
701
|
...env.GUARDIAN_ADAPTER_ID ? { adapterId: env.GUARDIAN_ADAPTER_ID } : {}
|
|
361
702
|
}),
|
|
362
703
|
connectionId: connectionId ?? "",
|
|
363
|
-
agentName: env.GUARDIAN_HOOK_AGENT_NAME?.trim() ||
|
|
704
|
+
agentName: env.GUARDIAN_HOOK_AGENT_NAME?.trim() || AGENT_NAMES[dialect2],
|
|
364
705
|
approvalTimeoutMs,
|
|
365
706
|
requestTtlMs: REQUEST_TTL_MS,
|
|
366
707
|
pollIntervalMs: 2e3,
|
|
@@ -368,19 +709,64 @@ async function runHook(stdin, env, deps = {}) {
|
|
|
368
709
|
...deps.now ? { now: deps.now } : {},
|
|
369
710
|
...deps.log ? { log: deps.log } : {}
|
|
370
711
|
});
|
|
371
|
-
return claudeCodeOutput(verdict);
|
|
372
712
|
}
|
|
373
713
|
|
|
374
714
|
// src/index.ts
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
715
|
+
function readHookInput(stream = process.stdin) {
|
|
716
|
+
return (async () => {
|
|
717
|
+
const chunks = [];
|
|
718
|
+
for await (const chunk of stream) {
|
|
719
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
720
|
+
const text = Buffer.concat(chunks).toString("utf8");
|
|
721
|
+
if (/\}\s*$/.test(text)) {
|
|
722
|
+
try {
|
|
723
|
+
const value = JSON.parse(text);
|
|
724
|
+
if (value && typeof value === "object") return text;
|
|
725
|
+
} catch {
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
730
|
+
})();
|
|
731
|
+
}
|
|
732
|
+
function isDialect(value) {
|
|
733
|
+
return DIALECTS.includes(value ?? "");
|
|
734
|
+
}
|
|
735
|
+
function parseArgs(argv, env) {
|
|
736
|
+
const [first, ...rest] = argv;
|
|
737
|
+
if (isDialect(first)) return { dialect: first, rest };
|
|
738
|
+
const fromEnv = env.GUARDIAN_HOOK_DIALECT?.trim();
|
|
739
|
+
return {
|
|
740
|
+
dialect: isDialect(fromEnv) ? fromEnv : "claude-code",
|
|
741
|
+
rest: [...argv]
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
var dialect = "claude-code";
|
|
745
|
+
var answered = false;
|
|
746
|
+
function emit(result) {
|
|
747
|
+
if (answered) return;
|
|
748
|
+
answered = true;
|
|
749
|
+
if (result.stdout) process.stdout.write(`${result.stdout}
|
|
750
|
+
`);
|
|
751
|
+
if (result.stderr) process.stderr.write(`${result.stderr}
|
|
752
|
+
`);
|
|
753
|
+
process.exitCode = result.exitCode;
|
|
754
|
+
}
|
|
755
|
+
function crashed() {
|
|
756
|
+
emit(
|
|
757
|
+
formatVerdict(dialect, {
|
|
758
|
+
decision: "deny",
|
|
759
|
+
reason: "Guardian hook crashed. Nothing ran."
|
|
760
|
+
})
|
|
761
|
+
);
|
|
762
|
+
process.exit(process.exitCode ?? 2);
|
|
379
763
|
}
|
|
380
764
|
async function main(argv) {
|
|
381
|
-
const
|
|
765
|
+
const parsed = parseArgs(argv, process.env);
|
|
766
|
+
dialect = parsed.dialect;
|
|
767
|
+
const [command, ...rest] = parsed.rest;
|
|
382
768
|
if (command === "setup") {
|
|
383
|
-
process.stdout.write(`${setupText()}
|
|
769
|
+
process.stdout.write(`${setupText(dialect)}
|
|
384
770
|
`);
|
|
385
771
|
return;
|
|
386
772
|
}
|
|
@@ -390,32 +776,42 @@ async function main(argv) {
|
|
|
390
776
|
return;
|
|
391
777
|
}
|
|
392
778
|
if (command === "--help" || command === "-h") {
|
|
393
|
-
process.stdout.write(
|
|
779
|
+
process.stdout.write(
|
|
780
|
+
"guardian-hook [codex | dsh] [setup | classify <command>]\n"
|
|
781
|
+
);
|
|
394
782
|
return;
|
|
395
783
|
}
|
|
396
|
-
|
|
397
|
-
|
|
784
|
+
emit(
|
|
785
|
+
await runHookAs(dialect, readHookInput(), process.env, {
|
|
786
|
+
log: (line) => process.stderr.write(`${line}
|
|
398
787
|
`)
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
788
|
+
})
|
|
789
|
+
);
|
|
790
|
+
setTimeout(() => process.exit(process.exitCode ?? 0), 250).unref();
|
|
402
791
|
}
|
|
403
792
|
var invokedDirectly = process.argv[1] !== void 0 && /guardian-hook|hook[\\/]dist[\\/]index\.js$/.test(process.argv[1]);
|
|
404
793
|
if (invokedDirectly) {
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
`
|
|
409
|
-
);
|
|
410
|
-
});
|
|
794
|
+
process.on("uncaughtException", crashed);
|
|
795
|
+
process.on("unhandledRejection", crashed);
|
|
796
|
+
main(process.argv.slice(2)).catch(crashed);
|
|
411
797
|
}
|
|
412
798
|
export {
|
|
799
|
+
CODEX_HOOKS_SNIPPET,
|
|
800
|
+
CODEX_HOOK_COMMAND,
|
|
413
801
|
DEFAULT_BASE_URL,
|
|
802
|
+
DEFAULT_DEADLINE_MS,
|
|
803
|
+
DIALECTS,
|
|
804
|
+
DSH_HOOKS_SNIPPET,
|
|
805
|
+
DSH_HOOK_COMMAND,
|
|
414
806
|
MAX_WAIT_MS,
|
|
415
807
|
REQUEST_TTL_MS,
|
|
416
808
|
SETTINGS_SNIPPET,
|
|
417
809
|
claudeCodeOutput,
|
|
418
810
|
connectionFor,
|
|
811
|
+
formatVerdict,
|
|
812
|
+
parseArgs,
|
|
813
|
+
readHookInput,
|
|
419
814
|
runHook,
|
|
815
|
+
runHookAs,
|
|
420
816
|
setupText
|
|
421
817
|
};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@odingard/guardian-hook",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Guardian for coding agents: a Claude Code PreToolUse hook (
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Guardian for coding agents: a Claude Code PreToolUse hook (also for OpenAI Codex CLI, DeepSeek Harness and Claude Agent SDK agents) that sends shell commands, file writes and MCP calls to Guardian for a deterministic ALLOW/ASK/DENY decision. Deploys and destructive commands wait for a human approval on your phone; every allowed call carries a verifiable receipt.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "dist/index.js",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"prepublishOnly": "npm run build && npm test"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@odingard/guardian-sdk": "^0.3.
|
|
34
|
+
"@odingard/guardian-sdk": "^0.3.8"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
37
|
"@types/node": "^22",
|
|
@@ -47,5 +47,5 @@
|
|
|
47
47
|
"url": "https://github.com/Odingard/guardian-sdk.git",
|
|
48
48
|
"directory": "packages/hook"
|
|
49
49
|
},
|
|
50
|
-
"homepage": "https://
|
|
50
|
+
"homepage": "https://odingard.com/guardian"
|
|
51
51
|
}
|