@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
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
// src/run.ts
|
|
2
|
+
import { createGuardianClient } from "@odingard/guardian-sdk";
|
|
3
|
+
|
|
4
|
+
// src/classify.ts
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
var HOOK_TOOLS = [
|
|
7
|
+
{
|
|
8
|
+
name: "shell",
|
|
9
|
+
suggestedMode: "allow",
|
|
10
|
+
description: "Ordinary shell commands (build, test, read-only inspection)."
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
name: "shell_deploy",
|
|
14
|
+
suggestedMode: "ask",
|
|
15
|
+
description: "Infrastructure and deploy commands: terraform/tofu apply or destroy, pulumi up, kubectl apply/delete, helm install/upgrade, cdk/serverless/fly/vercel deploys, cloud deploy CLIs."
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
name: "shell_destructive",
|
|
19
|
+
suggestedMode: "ask",
|
|
20
|
+
description: "Destructive commands: rm -rf, git reset --hard, git clean, force pushes, dropping databases, disk formatting."
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
name: "git_push",
|
|
24
|
+
suggestedMode: "ask",
|
|
25
|
+
description: "Pushing commits to a remote."
|
|
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
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: "file_write",
|
|
34
|
+
suggestedMode: "allow",
|
|
35
|
+
description: "Creating or editing files in the workspace."
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
name: "web_fetch",
|
|
39
|
+
suggestedMode: "allow",
|
|
40
|
+
description: "Fetching a URL."
|
|
41
|
+
}
|
|
42
|
+
];
|
|
43
|
+
var MAX_COMMAND = 2e3;
|
|
44
|
+
var DEPLOY = [
|
|
45
|
+
/\b(terraform|tofu)\b.*\s(apply|destroy|import|state\s+(rm|mv|push))\b/,
|
|
46
|
+
/\bpulumi\s+(up|destroy|update)\b/,
|
|
47
|
+
/\bkubectl\b.*\s(apply|delete|create|replace|patch|scale|rollout|drain|cordon)\b/,
|
|
48
|
+
/\bhelm\s+(install|upgrade|uninstall|rollback|delete)\b/,
|
|
49
|
+
/\b(cdk|serverless|sls|sam)\s+(deploy|destroy|remove)\b/,
|
|
50
|
+
/\bfly(ctl)?\s+(deploy|destroy|apps\s+destroy|scale)\b/,
|
|
51
|
+
/\bvercel\b.*(--prod\b|\bdeploy\b|\bpromote\b|\bremove\b)/,
|
|
52
|
+
/\bnetlify\s+deploy\b.*--prod\b/,
|
|
53
|
+
/\bgcloud\s+.*\b(deploy|delete)\b/,
|
|
54
|
+
/\baws\s+.*\b(deploy|delete-stack|create-stack|update-stack|terminate-instances|delete-db-instance)\b/,
|
|
55
|
+
/\baz\s+.*\b(deploy|delete|create)\b/,
|
|
56
|
+
/\bwrangler\s+(deploy|publish|delete)\b/,
|
|
57
|
+
/\bdocker\s+(push|compose\s+.*\bup\b.*--remote)\b/,
|
|
58
|
+
/\b(npm|pnpm|yarn)\s+publish\b/,
|
|
59
|
+
/\btwine\s+upload\b/,
|
|
60
|
+
/\bgh\s+(release\s+create|workflow\s+run|pr\s+merge)\b/
|
|
61
|
+
];
|
|
62
|
+
var DESTRUCTIVE = [
|
|
63
|
+
/\brm\s+(?:-\S*[rR]\S*\s+(?:-\S+\s+)*|-\S*f\S*\s+-\S*[rR])/,
|
|
64
|
+
/\bgit\s+reset\s+.*--hard\b/,
|
|
65
|
+
/\bgit\s+clean\s+-\S*f/,
|
|
66
|
+
/\bgit\s+push\b.*(\s--force\b|\s-f\b|--force-with-lease|\s\+\S)/,
|
|
67
|
+
/\bgit\s+(branch\s+-D|checkout\s+--\s|restore\s+--source)\b/,
|
|
68
|
+
/\b(drop|truncate)\s+(table|database|schema)\b/i,
|
|
69
|
+
/\bdelete\s+from\s+\w+\s*(;|$)/i,
|
|
70
|
+
/\b(mkfs|dd\s+if=|shred|wipefs)\b/,
|
|
71
|
+
/\bchmod\s+-R\s+777\b/,
|
|
72
|
+
/\bfind\b.*\s-delete\b/
|
|
73
|
+
];
|
|
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
|
+
}
|
|
166
|
+
function classifyCommand(command) {
|
|
167
|
+
if (DESTRUCTIVE.some((re) => re.test(command))) return "shell_destructive";
|
|
168
|
+
if (DEPLOY.some((re) => re.test(command))) return "shell_deploy";
|
|
169
|
+
if (GIT_PUSH.test(command)) return "git_push";
|
|
170
|
+
if (startsInteractiveSession(command)) return "shell_session";
|
|
171
|
+
return "shell";
|
|
172
|
+
}
|
|
173
|
+
function mcpToolName(server, tool) {
|
|
174
|
+
const part = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "").replace(/^(\d)/, "x$1") || "x";
|
|
175
|
+
return `mcp.${part(server)}.${part(tool)}`.slice(0, 64).replace(/[._-]+$/, "");
|
|
176
|
+
}
|
|
177
|
+
function bounded(text, max = MAX_COMMAND) {
|
|
178
|
+
return text.length <= max ? text : `${text.slice(0, max)}\u2026 [${text.length - max} more chars]`;
|
|
179
|
+
}
|
|
180
|
+
function digest(text) {
|
|
181
|
+
return `sha256:${createHash("sha256").update(text).digest("hex")}`;
|
|
182
|
+
}
|
|
183
|
+
function str(value) {
|
|
184
|
+
return typeof value === "string" ? value : "";
|
|
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
|
+
}
|
|
238
|
+
function governedCall(call) {
|
|
239
|
+
const { tool, input } = call;
|
|
240
|
+
const cwd = call.cwd ? { cwd: call.cwd } : {};
|
|
241
|
+
const shellField = Object.hasOwn(SHELL_TOOLS, tool) ? SHELL_TOOLS[tool] : void 0;
|
|
242
|
+
if (shellField) {
|
|
243
|
+
const command = commandText(input[shellField]);
|
|
244
|
+
const toolName = classifyCommand(command);
|
|
245
|
+
return {
|
|
246
|
+
toolName,
|
|
247
|
+
arguments: {
|
|
248
|
+
command: bounded(command),
|
|
249
|
+
...tool === "Bash" || tool === "shell" ? {} : { agentTool: tool },
|
|
250
|
+
...cwd
|
|
251
|
+
},
|
|
252
|
+
summary: `Run: ${bounded(command, 160)}`
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
if (FILE_TOOLS.has(tool)) {
|
|
256
|
+
const path = str(input.file_path) || str(input.notebook_path);
|
|
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));
|
|
283
|
+
return {
|
|
284
|
+
toolName: "file_write",
|
|
285
|
+
arguments: {
|
|
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),
|
|
293
|
+
...cwd
|
|
294
|
+
},
|
|
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)}`
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
if (tool === "WebFetch" || tool === "web_fetch") {
|
|
299
|
+
const url = str(input.url);
|
|
300
|
+
return {
|
|
301
|
+
toolName: "web_fetch",
|
|
302
|
+
arguments: { url: bounded(url, 500) },
|
|
303
|
+
summary: `Fetch ${bounded(url, 160)}`
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
const mcp = /^mcp__(.+?)__(.+)$/.exec(tool);
|
|
307
|
+
if (mcp) {
|
|
308
|
+
const [, server, name] = mcp;
|
|
309
|
+
const json = JSON.stringify(input);
|
|
310
|
+
return {
|
|
311
|
+
toolName: mcpToolName(server, name),
|
|
312
|
+
arguments: json.length <= MAX_COMMAND ? { server, tool: name, input } : {
|
|
313
|
+
server,
|
|
314
|
+
tool: name,
|
|
315
|
+
inputBytes: json.length,
|
|
316
|
+
inputDigest: digest(json)
|
|
317
|
+
},
|
|
318
|
+
summary: `MCP ${server} \u2192 ${name}`
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
function tierOf(toolName) {
|
|
324
|
+
const known = HOOK_TOOLS.find((t) => t.name === toolName);
|
|
325
|
+
return known ? known.suggestedMode : "ask";
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// src/decide.ts
|
|
329
|
+
var PLAIN = {
|
|
330
|
+
NOT_AUTHORIZED: "this agent is not authorized for it",
|
|
331
|
+
APPROVAL_DENIED: "the approver declined it",
|
|
332
|
+
APPROVAL_NOT_SATISFIED: "the approver declined it",
|
|
333
|
+
APPROVAL_EXPIRED: "the approval expired",
|
|
334
|
+
UNAUTHENTICATED: "the Guardian agent credential was rejected",
|
|
335
|
+
INVALID_REQUEST: `this tool is not allowed for the agent yet. In the Guardian console: Agents \u2192 this agent \u2192 the connection's card \u2192 "Allow a tool by name"`,
|
|
336
|
+
SERVICE_UNAVAILABLE: "Guardian could not be reached",
|
|
337
|
+
REQUEST_CONFLICT: "Guardian saw a conflicting request"
|
|
338
|
+
};
|
|
339
|
+
function plain(code) {
|
|
340
|
+
return PLAIN[code] ?? code;
|
|
341
|
+
}
|
|
342
|
+
function denied(call, code, extra = {}) {
|
|
343
|
+
return {
|
|
344
|
+
decision: "deny",
|
|
345
|
+
reason: `Guardian blocked ${call.toolName} (${code}): ${plain(code)}. Do not retry or work around this; ask the developer to review it in the Guardian console.`,
|
|
346
|
+
...extra
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
var FINAL_CHECK_LEAD_MS = 1500;
|
|
350
|
+
var isPending = (r) => !r.ok && (r.reasonCode === "APPROVAL_REQUIRED" || r.reasonCode === "APPROVAL_PENDING") || r.ok && r.decision === "ASK";
|
|
351
|
+
async function decide(call, options) {
|
|
352
|
+
const wait = options.wait ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
353
|
+
const now = options.now ?? Date.now;
|
|
354
|
+
const log = options.log ?? (() => {
|
|
355
|
+
});
|
|
356
|
+
let result;
|
|
357
|
+
try {
|
|
358
|
+
result = await options.client.request({
|
|
359
|
+
action: "tool.invoke",
|
|
360
|
+
connectionId: options.connectionId,
|
|
361
|
+
toolName: call.toolName,
|
|
362
|
+
arguments: call.arguments,
|
|
363
|
+
purpose: `${options.agentName}: ${call.summary}`,
|
|
364
|
+
...options.requestTtlMs !== void 0 ? { ttlMs: options.requestTtlMs } : {}
|
|
365
|
+
});
|
|
366
|
+
} catch {
|
|
367
|
+
return denied(call, "SERVICE_UNAVAILABLE");
|
|
368
|
+
}
|
|
369
|
+
const answeredAt = now();
|
|
370
|
+
const requestId = result.requestId;
|
|
371
|
+
let approvedByHuman = false;
|
|
372
|
+
if (isPending(result)) {
|
|
373
|
+
if (!requestId) return denied(call, "MISSING_REQUEST_ID");
|
|
374
|
+
log(
|
|
375
|
+
`Guardian: "${call.summary}" is held for approval (request ${requestId}). Approve it on your phone or in the console.`
|
|
376
|
+
);
|
|
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;
|
|
383
|
+
const maxInterval = Math.max(
|
|
384
|
+
options.maxPollIntervalMs ?? 1e4,
|
|
385
|
+
options.pollIntervalMs
|
|
386
|
+
);
|
|
387
|
+
let interval = options.pollIntervalMs;
|
|
388
|
+
for (; ; ) {
|
|
389
|
+
await wait(Math.max(0, Math.min(interval, lastCheckAt - now())));
|
|
390
|
+
interval = Math.min(Math.ceil(interval * 1.5), maxInterval);
|
|
391
|
+
let claimed;
|
|
392
|
+
try {
|
|
393
|
+
claimed = await options.client.claim(requestId);
|
|
394
|
+
} catch {
|
|
395
|
+
claimed = { ok: false, reasonCode: "APPROVAL_REQUIRED", requestId };
|
|
396
|
+
}
|
|
397
|
+
if (!isPending(claimed)) {
|
|
398
|
+
result = claimed;
|
|
399
|
+
approvedByHuman = claimed.ok && claimed.decision === "ALLOW";
|
|
400
|
+
break;
|
|
401
|
+
}
|
|
402
|
+
if (now() >= lastCheckAt) {
|
|
403
|
+
return {
|
|
404
|
+
decision: "deny",
|
|
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.`,
|
|
406
|
+
requestId,
|
|
407
|
+
guardianDecision: "ASK"
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
if (!result.ok || result.decision !== "ALLOW" || !result.capabilityToken) {
|
|
413
|
+
const code = result.ok ? result.reasonCode ?? (result.decision === "DENY" ? "DENIED" : "MISSING_CAPABILITY") : result.reasonCode;
|
|
414
|
+
return denied(call, code, {
|
|
415
|
+
...requestId ? { requestId } : {},
|
|
416
|
+
guardianDecision: "DENY"
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
let executed;
|
|
420
|
+
try {
|
|
421
|
+
executed = await options.client.execute({
|
|
422
|
+
requestId: result.requestId,
|
|
423
|
+
capabilityToken: result.capabilityToken
|
|
424
|
+
});
|
|
425
|
+
} catch {
|
|
426
|
+
executed = { ok: false, reasonCode: "SERVICE_UNAVAILABLE" };
|
|
427
|
+
}
|
|
428
|
+
if (!executed.ok) {
|
|
429
|
+
return denied(call, executed.reasonCode, {
|
|
430
|
+
requestId: result.requestId,
|
|
431
|
+
guardianDecision: approvedByHuman ? "ASK" : "ALLOW",
|
|
432
|
+
approvedByHuman
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
return {
|
|
436
|
+
decision: "allow",
|
|
437
|
+
reason: approvedByHuman ? `Approved by a human in Guardian (request ${result.requestId}).` : `Allowed by Guardian policy (request ${result.requestId}).`,
|
|
438
|
+
requestId: result.requestId,
|
|
439
|
+
guardianDecision: approvedByHuman ? "ASK" : "ALLOW",
|
|
440
|
+
approvedByHuman
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// src/run.ts
|
|
445
|
+
var DEFAULT_BASE_URL = "https://guardian-api.odingard.com";
|
|
446
|
+
var REQUEST_TTL_MS = 3e5;
|
|
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
|
+
}
|
|
481
|
+
function claudeCodeOutput(verdict) {
|
|
482
|
+
return JSON.stringify({
|
|
483
|
+
hookSpecificOutput: {
|
|
484
|
+
hookEventName: "PreToolUse",
|
|
485
|
+
permissionDecision: verdict.decision,
|
|
486
|
+
permissionDecisionReason: verdict.reason
|
|
487
|
+
}
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
function connectionFor(toolName, env) {
|
|
491
|
+
const allow = env.GUARDIAN_CONNECTION_ID?.trim() || void 0;
|
|
492
|
+
if (tierOf(toolName) === "allow") return allow;
|
|
493
|
+
return env.GUARDIAN_HOOK_ASK_CONNECTION_ID?.trim() || allow;
|
|
494
|
+
}
|
|
495
|
+
async function runHook(stdin, env, deps = {}) {
|
|
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
|
+
});
|
|
534
|
+
let input;
|
|
535
|
+
try {
|
|
536
|
+
input = JSON.parse(await stdinText);
|
|
537
|
+
} catch {
|
|
538
|
+
return failClosed(
|
|
539
|
+
"could not read the tool call from the agent. Nothing ran."
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
if (!input || typeof input.tool_name !== "string") {
|
|
543
|
+
return failClosed("the tool call had no tool name. Nothing ran.");
|
|
544
|
+
}
|
|
545
|
+
const call = governedCall({
|
|
546
|
+
tool: input.tool_name,
|
|
547
|
+
input: input.tool_input && typeof input.tool_input === "object" ? input.tool_input : {},
|
|
548
|
+
...typeof input.cwd === "string" ? { cwd: input.cwd } : {}
|
|
549
|
+
});
|
|
550
|
+
if (call === null) return null;
|
|
551
|
+
const connectionId = connectionFor(call.toolName, env);
|
|
552
|
+
const credential = env.GUARDIAN_AGENT_CREDENTIAL?.trim();
|
|
553
|
+
const baseUrl = (env.GUARDIAN_BASE_URL?.trim() || DEFAULT_BASE_URL).replace(
|
|
554
|
+
/\/+$/,
|
|
555
|
+
""
|
|
556
|
+
);
|
|
557
|
+
if (!deps.client && (!credential || !connectionId)) {
|
|
558
|
+
return failClosed(
|
|
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.`
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
if (!deps.client && !baseUrl.startsWith("https://")) {
|
|
563
|
+
return failClosed("GUARDIAN_BASE_URL must be https. Nothing ran.");
|
|
564
|
+
}
|
|
565
|
+
const requested = Number(
|
|
566
|
+
env.GUARDIAN_HOOK_APPROVAL_TIMEOUT_MS ?? MAX_WAIT_MS
|
|
567
|
+
);
|
|
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, {
|
|
571
|
+
client: deps.client ?? createGuardianClient({
|
|
572
|
+
baseUrl,
|
|
573
|
+
credential,
|
|
574
|
+
...env.GUARDIAN_ADAPTER_ID ? { adapterId: env.GUARDIAN_ADAPTER_ID } : {}
|
|
575
|
+
}),
|
|
576
|
+
connectionId: connectionId ?? "",
|
|
577
|
+
agentName: env.GUARDIAN_HOOK_AGENT_NAME?.trim() || AGENT_NAMES[dialect],
|
|
578
|
+
approvalTimeoutMs,
|
|
579
|
+
requestTtlMs: REQUEST_TTL_MS,
|
|
580
|
+
pollIntervalMs: 2e3,
|
|
581
|
+
...deps.wait ? { wait: deps.wait } : {},
|
|
582
|
+
...deps.now ? { now: deps.now } : {},
|
|
583
|
+
...deps.log ? { log: deps.log } : {}
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// src/agent-sdk.ts
|
|
588
|
+
function guardianPreToolUse(options = {}) {
|
|
589
|
+
return async (input) => {
|
|
590
|
+
if (input.hook_event_name && input.hook_event_name !== "PreToolUse") {
|
|
591
|
+
return {};
|
|
592
|
+
}
|
|
593
|
+
const base = options.env ?? process.env;
|
|
594
|
+
const env = {
|
|
595
|
+
...base,
|
|
596
|
+
GUARDIAN_HOOK_AGENT_NAME: options.agentName ?? base.GUARDIAN_HOOK_AGENT_NAME ?? "Claude agent"
|
|
597
|
+
};
|
|
598
|
+
const output = await runHook(
|
|
599
|
+
JSON.stringify({
|
|
600
|
+
tool_name: input.tool_name,
|
|
601
|
+
tool_input: input.tool_input,
|
|
602
|
+
cwd: input.cwd
|
|
603
|
+
}),
|
|
604
|
+
env,
|
|
605
|
+
{
|
|
606
|
+
log: options.log ?? ((line) => process.stderr.write(`${line}
|
|
607
|
+
`)),
|
|
608
|
+
...options.client ? { client: options.client } : {},
|
|
609
|
+
...options.wait ? { wait: options.wait } : {},
|
|
610
|
+
...options.now ? { now: options.now } : {}
|
|
611
|
+
}
|
|
612
|
+
);
|
|
613
|
+
return output ? JSON.parse(output) : {};
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
var GUARDIAN_HOOK_TIMEOUT_SECONDS = Math.ceil(MAX_WAIT_MS / 1e3) + 30;
|
|
617
|
+
function guardianHookMatcher(options = {}) {
|
|
618
|
+
return {
|
|
619
|
+
hooks: [guardianPreToolUse(options)],
|
|
620
|
+
timeout: GUARDIAN_HOOK_TIMEOUT_SECONDS,
|
|
621
|
+
onFailure: "block"
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
export {
|
|
625
|
+
GUARDIAN_HOOK_TIMEOUT_SECONDS,
|
|
626
|
+
guardianHookMatcher,
|
|
627
|
+
guardianPreToolUse
|
|
628
|
+
};
|