@odingard/guardian-hook 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,8 +1,5 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- // src/index.ts
4
- import { createGuardianClient } from "@odingard/guardian-sdk";
5
-
6
3
  // src/classify.ts
7
4
  import { createHash } from "node:crypto";
8
5
  var HOOK_TOOLS = [
@@ -26,6 +23,11 @@ var HOOK_TOOLS = [
26
23
  suggestedMode: "ask",
27
24
  description: "Pushing commits to a remote."
28
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
+ },
29
31
  {
30
32
  name: "file_write",
31
33
  suggestedMode: "allow",
@@ -69,10 +71,102 @@ var DESTRUCTIVE = [
69
71
  /\bfind\b.*\s-delete\b/
70
72
  ];
71
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
+ }
72
165
  function classifyCommand(command) {
73
166
  if (DESTRUCTIVE.some((re) => re.test(command))) return "shell_destructive";
74
167
  if (DEPLOY.some((re) => re.test(command))) return "shell_deploy";
75
168
  if (GIT_PUSH.test(command)) return "git_push";
169
+ if (startsInteractiveSession(command)) return "shell_session";
76
170
  return "shell";
77
171
  }
78
172
  function mcpToolName(server, tool) {
@@ -88,34 +182,119 @@ function digest(text) {
88
182
  function str(value) {
89
183
  return typeof value === "string" ? value : "";
90
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
+ }
91
237
  function governedCall(call) {
92
238
  const { tool, input } = call;
93
239
  const cwd = call.cwd ? { cwd: call.cwd } : {};
94
- if (tool === "Bash" || tool === "shell") {
95
- 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]);
96
243
  const toolName = classifyCommand(command);
97
244
  return {
98
245
  toolName,
99
- arguments: { command: bounded(command), ...cwd },
246
+ arguments: {
247
+ command: bounded(command),
248
+ ...tool === "Bash" || tool === "shell" ? {} : { agentTool: tool },
249
+ ...cwd
250
+ },
100
251
  summary: `Run: ${bounded(command, 160)}`
101
252
  };
102
253
  }
103
- if (["Write", "Edit", "MultiEdit", "NotebookEdit"].includes(tool)) {
254
+ if (FILE_TOOLS.has(tool)) {
104
255
  const path = str(input.file_path) || str(input.notebook_path);
105
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));
106
282
  return {
107
283
  toolName: "file_write",
108
284
  arguments: {
109
- operation: tool,
110
- path,
111
- contentBytes: Buffer.byteLength(content),
112
- 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),
113
292
  ...cwd
114
293
  },
115
- 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)}`
116
295
  };
117
296
  }
118
- if (tool === "WebFetch") {
297
+ if (tool === "WebFetch" || tool === "web_fetch") {
119
298
  const url = str(input.url);
120
299
  return {
121
300
  toolName: "web_fetch",
@@ -145,6 +324,9 @@ function tierOf(toolName) {
145
324
  return known ? known.suggestedMode : "ask";
146
325
  }
147
326
 
327
+ // src/run.ts
328
+ import { createGuardianClient } from "@odingard/guardian-sdk";
329
+
148
330
  // src/decide.ts
149
331
  var PLAIN = {
150
332
  NOT_AUTHORIZED: "this agent is not authorized for it",
@@ -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.`,
@@ -254,10 +443,44 @@ async function decide(call, options) {
254
443
  };
255
444
  }
256
445
 
257
- // src/index.ts
446
+ // src/run.ts
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,17 +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
- async function readStdin() {
374
- const chunks = [];
375
- for await (const chunk of process.stdin) chunks.push(chunk);
376
- return Buffer.concat(chunks).toString("utf8");
713
+
714
+ // src/index.ts
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);
377
763
  }
378
764
  async function main(argv) {
379
- const [command, ...rest] = argv;
765
+ const parsed = parseArgs(argv, process.env);
766
+ dialect = parsed.dialect;
767
+ const [command, ...rest] = parsed.rest;
380
768
  if (command === "setup") {
381
- process.stdout.write(`${setupText()}
769
+ process.stdout.write(`${setupText(dialect)}
382
770
  `);
383
771
  return;
384
772
  }
@@ -388,32 +776,42 @@ async function main(argv) {
388
776
  return;
389
777
  }
390
778
  if (command === "--help" || command === "-h") {
391
- process.stdout.write("guardian-hook [setup | classify <command>]\n");
779
+ process.stdout.write(
780
+ "guardian-hook [codex | dsh] [setup | classify <command>]\n"
781
+ );
392
782
  return;
393
783
  }
394
- const output = await runHook(await readStdin(), process.env, {
395
- log: (line) => process.stderr.write(`${line}
784
+ emit(
785
+ await runHookAs(dialect, readHookInput(), process.env, {
786
+ log: (line) => process.stderr.write(`${line}
396
787
  `)
397
- });
398
- if (output) process.stdout.write(`${output}
399
- `);
788
+ })
789
+ );
790
+ setTimeout(() => process.exit(process.exitCode ?? 0), 250).unref();
400
791
  }
401
792
  var invokedDirectly = process.argv[1] !== void 0 && /guardian-hook|hook[\\/]dist[\\/]index\.js$/.test(process.argv[1]);
402
793
  if (invokedDirectly) {
403
- main(process.argv.slice(2)).catch(() => {
404
- process.stdout.write(
405
- `${claudeCodeOutput({ decision: "deny", reason: "Guardian hook crashed. Nothing ran." })}
406
- `
407
- );
408
- });
794
+ process.on("uncaughtException", crashed);
795
+ process.on("unhandledRejection", crashed);
796
+ main(process.argv.slice(2)).catch(crashed);
409
797
  }
410
798
  export {
799
+ CODEX_HOOKS_SNIPPET,
800
+ CODEX_HOOK_COMMAND,
411
801
  DEFAULT_BASE_URL,
802
+ DEFAULT_DEADLINE_MS,
803
+ DIALECTS,
804
+ DSH_HOOKS_SNIPPET,
805
+ DSH_HOOK_COMMAND,
412
806
  MAX_WAIT_MS,
413
807
  REQUEST_TTL_MS,
414
808
  SETTINGS_SNIPPET,
415
809
  claudeCodeOutput,
416
810
  connectionFor,
811
+ formatVerdict,
812
+ parseArgs,
813
+ readHookInput,
417
814
  runHook,
815
+ runHookAs,
418
816
  setupText
419
817
  };