@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 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 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.
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 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.
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
- if (tool === "Bash" || tool === "shell") {
93
- const command = str(input.command);
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: { command: bounded(command), ...cwd },
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 (["Write", "Edit", "MultiEdit", "NotebookEdit"].includes(tool)) {
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: tool,
108
- path,
109
- contentBytes: Buffer.byteLength(content),
110
- contentDigest: digest(content),
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: `${tool === "Write" ? "Write" : "Edit"} ${path}`
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 deadline = now() + options.approvalTimeoutMs;
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() >= deadline) {
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
- const failClosed = (reason) => claudeCodeOutput({ decision: "deny", reason: `Guardian hook: ${reason}` });
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(stdin);
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
- "GUARDIAN_AGENT_CREDENTIAL and GUARDIAN_CONNECTION_ID must be set (run `npx @odingard/guardian-hook setup`). Nothing ran."
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 approvalTimeoutMs = Number.isFinite(requested) ? Math.min(Math.max(requested, 0), MAX_WAIT_MS) : MAX_WAIT_MS;
310
- const verdict = await decide(call, {
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() || "Claude Code",
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
- if (tool === "Bash" || tool === "shell") {
92
- const command = str(input.command);
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: { command: bounded(command), ...cwd },
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 (["Write", "Edit", "MultiEdit", "NotebookEdit"].includes(tool)) {
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: tool,
107
- path,
108
- contentBytes: Buffer.byteLength(content),
109
- contentDigest: digest(content),
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: `${tool === "Write" ? "Write" : "Edit"} ${path}`
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 deadline = now() + options.approvalTimeoutMs;
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() >= deadline) {
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
- ].join("\n");
620
+ ];
318
621
  }
319
622
  async function runHook(stdin, env, deps = {}) {
320
- const failClosed = (reason) => claudeCodeOutput({ decision: "deny", reason: `Guardian hook: ${reason}` });
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(stdin);
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
- "GUARDIAN_AGENT_CREDENTIAL and GUARDIAN_CONNECTION_ID must be set (run `npx @odingard/guardian-hook setup`). Nothing ran."
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 approvalTimeoutMs = Number.isFinite(requested) ? Math.min(Math.max(requested, 0), MAX_WAIT_MS) : MAX_WAIT_MS;
356
- const verdict = await decide(call, {
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() || "Claude Code",
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
- async function readStdin() {
376
- const chunks = [];
377
- for await (const chunk of process.stdin) chunks.push(chunk);
378
- return Buffer.concat(chunks).toString("utf8");
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 [command, ...rest] = argv;
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("guardian-hook [setup | classify <command>]\n");
779
+ process.stdout.write(
780
+ "guardian-hook [codex | dsh] [setup | classify <command>]\n"
781
+ );
394
782
  return;
395
783
  }
396
- const output = await runHook(await readStdin(), process.env, {
397
- log: (line) => process.stderr.write(`${line}
784
+ emit(
785
+ await runHookAs(dialect, readHookInput(), process.env, {
786
+ log: (line) => process.stderr.write(`${line}
398
787
  `)
399
- });
400
- if (output) process.stdout.write(`${output}
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
- main(process.argv.slice(2)).catch(() => {
406
- process.stdout.write(
407
- `${claudeCodeOutput({ decision: "deny", reason: "Guardian hook crashed. Nothing ran." })}
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.2.0",
4
- "description": "Guardian for coding agents: a Claude Code PreToolUse hook (and a PreToolUse hook for 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.",
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.5"
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://guardian.odingard.com"
50
+ "homepage": "https://odingard.com/guardian"
51
51
  }