@odingard/guardian-hook 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +170 -4
- package/agent-sdk.d.ts +39 -0
- package/dist/agent-sdk.js +628 -0
- package/dist/index.js +444 -46
- package/package.json +14 -5
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
|
-
|
|
95
|
-
|
|
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: {
|
|
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 (
|
|
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:
|
|
110
|
-
path,
|
|
111
|
-
|
|
112
|
-
|
|
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:
|
|
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
|
|
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.`,
|
|
@@ -254,10 +443,44 @@ async function decide(call, options) {
|
|
|
254
443
|
};
|
|
255
444
|
}
|
|
256
445
|
|
|
257
|
-
// src/
|
|
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
|
-
]
|
|
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,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
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
return
|
|
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
|
|
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(
|
|
779
|
+
process.stdout.write(
|
|
780
|
+
"guardian-hook [codex | dsh] [setup | classify <command>]\n"
|
|
781
|
+
);
|
|
392
782
|
return;
|
|
393
783
|
}
|
|
394
|
-
|
|
395
|
-
|
|
784
|
+
emit(
|
|
785
|
+
await runHookAs(dialect, readHookInput(), process.env, {
|
|
786
|
+
log: (line) => process.stderr.write(`${line}
|
|
396
787
|
`)
|
|
397
|
-
|
|
398
|
-
|
|
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
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
};
|