@odingard/guardian-hook 0.1.0 → 0.2.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 +28 -1
- package/agent-sdk.d.ts +39 -0
- package/dist/agent-sdk.js +369 -0
- package/dist/index.js +16 -8
- package/package.json +14 -5
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@ There is no model in the decision: Guardian decides ALLOW / ASK / DENY determini
|
|
|
16
16
|
| **ASK** | The agent waits. You get the request on your phone or in the console. On approval the hook claims and redeems the capability, then the call runs. It is never auto-approved. |
|
|
17
17
|
| **DENY** | The agent is told no, with the reason. Nothing runs. |
|
|
18
18
|
|
|
19
|
-
**Fail closed.** If Guardian can't be reached, the configuration is wrong, approval times out or redemption fails, the call is denied.
|
|
19
|
+
**Fail closed.** If Guardian can't be reached, the configuration is wrong, approval times out or redemption fails, the call is denied. Each request stays open for 5 minutes (Guardian's limit), and the hook waits that long for an approval, under Claude Code's 600-second hook timeout, because a hook that times out does not block. An ASK not approved within 5 minutes expires; the call is denied and the agent can ask again.
|
|
20
20
|
|
|
21
21
|
## Tools the hook proposes
|
|
22
22
|
|
|
@@ -78,6 +78,33 @@ Guardian sets modes **per connection**, not per tool. So the hook uses two recor
|
|
|
78
78
|
|
|
79
79
|
`npx @odingard/guardian-hook setup` prints all of this. To see where a command would be routed, run `npx @odingard/guardian-hook classify "terraform apply"`, which prints `shell_deploy`.
|
|
80
80
|
|
|
81
|
+
## Claude Agent SDK agents (0.2.0)
|
|
82
|
+
|
|
83
|
+
Agents built on Anthropic's Claude Agent SDK (`@anthropic-ai/claude-agent-sdk`) can be governed by Guardian in the same way:
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
87
|
+
import { guardianHookMatcher } from "@odingard/guardian-hook/agent-sdk";
|
|
88
|
+
|
|
89
|
+
for await (const message of query({
|
|
90
|
+
prompt: "Run the tests, then deploy staging",
|
|
91
|
+
options: { hooks: { PreToolUse: [guardianHookMatcher()] } },
|
|
92
|
+
})) {
|
|
93
|
+
if (message.type === "result") console.log(message.subtype);
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
It uses the same environment variables, the same two connections (the console's "Set up for Claude Code" creates them) and the same decisions as the Claude Code hook. Approvers see the agent as "Claude agent" unless you pass `agentName`.
|
|
98
|
+
|
|
99
|
+
`guardianHookMatcher()` registers the hook with:
|
|
100
|
+
|
|
101
|
+
- `onFailure: "block"`, so a crash blocks the call instead of letting it through (Agent SDK 0.3.317 or later);
|
|
102
|
+
- a timeout longer than Guardian's 5-minute approval window.
|
|
103
|
+
|
|
104
|
+
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
|
+
|
|
106
|
+
A runnable example is in `examples/claude-agent-sdk.mjs`.
|
|
107
|
+
|
|
81
108
|
## Limits (read these)
|
|
82
109
|
|
|
83
110
|
- **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.
|
package/agent-sdk.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Types for "@odingard/guardian-hook/agent-sdk" (see src/agent-sdk.ts).
|
|
2
|
+
type Env = Readonly<Record<string, string | undefined>>;
|
|
3
|
+
|
|
4
|
+
export type GuardianHookInput = Readonly<{
|
|
5
|
+
hook_event_name?: string;
|
|
6
|
+
tool_name?: unknown;
|
|
7
|
+
tool_input?: unknown;
|
|
8
|
+
cwd?: unknown;
|
|
9
|
+
}>;
|
|
10
|
+
|
|
11
|
+
export type GuardianHookOutput =
|
|
12
|
+
| Record<string, never>
|
|
13
|
+
| {
|
|
14
|
+
hookSpecificOutput: {
|
|
15
|
+
hookEventName: "PreToolUse";
|
|
16
|
+
permissionDecision: "allow" | "deny";
|
|
17
|
+
permissionDecisionReason: string;
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type GuardianPreToolUseOptions = Readonly<{
|
|
22
|
+
env?: Env;
|
|
23
|
+
agentName?: string;
|
|
24
|
+
log?: (line: string) => void;
|
|
25
|
+
}>;
|
|
26
|
+
|
|
27
|
+
export declare function guardianPreToolUse(
|
|
28
|
+
options?: GuardianPreToolUseOptions,
|
|
29
|
+
): (input: GuardianHookInput) => Promise<GuardianHookOutput>;
|
|
30
|
+
|
|
31
|
+
export declare const GUARDIAN_HOOK_TIMEOUT_SECONDS: number;
|
|
32
|
+
|
|
33
|
+
export declare function guardianHookMatcher(
|
|
34
|
+
options?: GuardianPreToolUseOptions,
|
|
35
|
+
): {
|
|
36
|
+
hooks: ((input: GuardianHookInput) => Promise<GuardianHookOutput>)[];
|
|
37
|
+
timeout: number;
|
|
38
|
+
onFailure: "block";
|
|
39
|
+
};
|
|
@@ -0,0 +1,369 @@
|
|
|
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: "file_write",
|
|
29
|
+
suggestedMode: "allow",
|
|
30
|
+
description: "Creating or editing files in the workspace."
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: "web_fetch",
|
|
34
|
+
suggestedMode: "allow",
|
|
35
|
+
description: "Fetching a URL."
|
|
36
|
+
}
|
|
37
|
+
];
|
|
38
|
+
var MAX_COMMAND = 2e3;
|
|
39
|
+
var DEPLOY = [
|
|
40
|
+
/\b(terraform|tofu)\b.*\s(apply|destroy|import|state\s+(rm|mv|push))\b/,
|
|
41
|
+
/\bpulumi\s+(up|destroy|update)\b/,
|
|
42
|
+
/\bkubectl\b.*\s(apply|delete|create|replace|patch|scale|rollout|drain|cordon)\b/,
|
|
43
|
+
/\bhelm\s+(install|upgrade|uninstall|rollback|delete)\b/,
|
|
44
|
+
/\b(cdk|serverless|sls|sam)\s+(deploy|destroy|remove)\b/,
|
|
45
|
+
/\bfly(ctl)?\s+(deploy|destroy|apps\s+destroy|scale)\b/,
|
|
46
|
+
/\bvercel\b.*(--prod\b|\bdeploy\b|\bpromote\b|\bremove\b)/,
|
|
47
|
+
/\bnetlify\s+deploy\b.*--prod\b/,
|
|
48
|
+
/\bgcloud\s+.*\b(deploy|delete)\b/,
|
|
49
|
+
/\baws\s+.*\b(deploy|delete-stack|create-stack|update-stack|terminate-instances|delete-db-instance)\b/,
|
|
50
|
+
/\baz\s+.*\b(deploy|delete|create)\b/,
|
|
51
|
+
/\bwrangler\s+(deploy|publish|delete)\b/,
|
|
52
|
+
/\bdocker\s+(push|compose\s+.*\bup\b.*--remote)\b/,
|
|
53
|
+
/\b(npm|pnpm|yarn)\s+publish\b/,
|
|
54
|
+
/\btwine\s+upload\b/,
|
|
55
|
+
/\bgh\s+(release\s+create|workflow\s+run|pr\s+merge)\b/
|
|
56
|
+
];
|
|
57
|
+
var DESTRUCTIVE = [
|
|
58
|
+
/\brm\s+(?:-\S*[rR]\S*\s+(?:-\S+\s+)*|-\S*f\S*\s+-\S*[rR])/,
|
|
59
|
+
/\bgit\s+reset\s+.*--hard\b/,
|
|
60
|
+
/\bgit\s+clean\s+-\S*f/,
|
|
61
|
+
/\bgit\s+push\b.*(\s--force\b|\s-f\b|--force-with-lease|\s\+\S)/,
|
|
62
|
+
/\bgit\s+(branch\s+-D|checkout\s+--\s|restore\s+--source)\b/,
|
|
63
|
+
/\b(drop|truncate)\s+(table|database|schema)\b/i,
|
|
64
|
+
/\bdelete\s+from\s+\w+\s*(;|$)/i,
|
|
65
|
+
/\b(mkfs|dd\s+if=|shred|wipefs)\b/,
|
|
66
|
+
/\bchmod\s+-R\s+777\b/,
|
|
67
|
+
/\bfind\b.*\s-delete\b/
|
|
68
|
+
];
|
|
69
|
+
var GIT_PUSH = /\bgit\s+push\b/;
|
|
70
|
+
function classifyCommand(command) {
|
|
71
|
+
if (DESTRUCTIVE.some((re) => re.test(command))) return "shell_destructive";
|
|
72
|
+
if (DEPLOY.some((re) => re.test(command))) return "shell_deploy";
|
|
73
|
+
if (GIT_PUSH.test(command)) return "git_push";
|
|
74
|
+
return "shell";
|
|
75
|
+
}
|
|
76
|
+
function mcpToolName(server, tool) {
|
|
77
|
+
const part = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "").replace(/^(\d)/, "x$1") || "x";
|
|
78
|
+
return `mcp.${part(server)}.${part(tool)}`.slice(0, 64).replace(/[._-]+$/, "");
|
|
79
|
+
}
|
|
80
|
+
function bounded(text, max = MAX_COMMAND) {
|
|
81
|
+
return text.length <= max ? text : `${text.slice(0, max)}\u2026 [${text.length - max} more chars]`;
|
|
82
|
+
}
|
|
83
|
+
function digest(text) {
|
|
84
|
+
return `sha256:${createHash("sha256").update(text).digest("hex")}`;
|
|
85
|
+
}
|
|
86
|
+
function str(value) {
|
|
87
|
+
return typeof value === "string" ? value : "";
|
|
88
|
+
}
|
|
89
|
+
function governedCall(call) {
|
|
90
|
+
const { tool, input } = call;
|
|
91
|
+
const cwd = call.cwd ? { cwd: call.cwd } : {};
|
|
92
|
+
if (tool === "Bash" || tool === "shell") {
|
|
93
|
+
const command = str(input.command);
|
|
94
|
+
const toolName = classifyCommand(command);
|
|
95
|
+
return {
|
|
96
|
+
toolName,
|
|
97
|
+
arguments: { command: bounded(command), ...cwd },
|
|
98
|
+
summary: `Run: ${bounded(command, 160)}`
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
if (["Write", "Edit", "MultiEdit", "NotebookEdit"].includes(tool)) {
|
|
102
|
+
const path = str(input.file_path) || str(input.notebook_path);
|
|
103
|
+
const content = str(input.content) || str(input.new_string) || str(input.new_source) || (Array.isArray(input.edits) ? JSON.stringify(input.edits) : "");
|
|
104
|
+
return {
|
|
105
|
+
toolName: "file_write",
|
|
106
|
+
arguments: {
|
|
107
|
+
operation: tool,
|
|
108
|
+
path,
|
|
109
|
+
contentBytes: Buffer.byteLength(content),
|
|
110
|
+
contentDigest: digest(content),
|
|
111
|
+
...cwd
|
|
112
|
+
},
|
|
113
|
+
summary: `${tool === "Write" ? "Write" : "Edit"} ${path}`
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
if (tool === "WebFetch") {
|
|
117
|
+
const url = str(input.url);
|
|
118
|
+
return {
|
|
119
|
+
toolName: "web_fetch",
|
|
120
|
+
arguments: { url: bounded(url, 500) },
|
|
121
|
+
summary: `Fetch ${bounded(url, 160)}`
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
const mcp = /^mcp__(.+?)__(.+)$/.exec(tool);
|
|
125
|
+
if (mcp) {
|
|
126
|
+
const [, server, name] = mcp;
|
|
127
|
+
const json = JSON.stringify(input);
|
|
128
|
+
return {
|
|
129
|
+
toolName: mcpToolName(server, name),
|
|
130
|
+
arguments: json.length <= MAX_COMMAND ? { server, tool: name, input } : {
|
|
131
|
+
server,
|
|
132
|
+
tool: name,
|
|
133
|
+
inputBytes: json.length,
|
|
134
|
+
inputDigest: digest(json)
|
|
135
|
+
},
|
|
136
|
+
summary: `MCP ${server} \u2192 ${name}`
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
function tierOf(toolName) {
|
|
142
|
+
const known = HOOK_TOOLS.find((t) => t.name === toolName);
|
|
143
|
+
return known ? known.suggestedMode : "ask";
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// src/decide.ts
|
|
147
|
+
var PLAIN = {
|
|
148
|
+
NOT_AUTHORIZED: "this agent is not authorized for it",
|
|
149
|
+
APPROVAL_DENIED: "the approver declined it",
|
|
150
|
+
APPROVAL_NOT_SATISFIED: "the approver declined it",
|
|
151
|
+
APPROVAL_EXPIRED: "the approval expired",
|
|
152
|
+
UNAUTHENTICATED: "the Guardian agent credential was rejected",
|
|
153
|
+
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"`,
|
|
154
|
+
SERVICE_UNAVAILABLE: "Guardian could not be reached",
|
|
155
|
+
REQUEST_CONFLICT: "Guardian saw a conflicting request"
|
|
156
|
+
};
|
|
157
|
+
function plain(code) {
|
|
158
|
+
return PLAIN[code] ?? code;
|
|
159
|
+
}
|
|
160
|
+
function denied(call, code, extra = {}) {
|
|
161
|
+
return {
|
|
162
|
+
decision: "deny",
|
|
163
|
+
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.`,
|
|
164
|
+
...extra
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
var isPending = (r) => !r.ok && (r.reasonCode === "APPROVAL_REQUIRED" || r.reasonCode === "APPROVAL_PENDING") || r.ok && r.decision === "ASK";
|
|
168
|
+
async function decide(call, options) {
|
|
169
|
+
const wait = options.wait ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
170
|
+
const now = options.now ?? Date.now;
|
|
171
|
+
const log = options.log ?? (() => {
|
|
172
|
+
});
|
|
173
|
+
let result;
|
|
174
|
+
try {
|
|
175
|
+
result = await options.client.request({
|
|
176
|
+
action: "tool.invoke",
|
|
177
|
+
connectionId: options.connectionId,
|
|
178
|
+
toolName: call.toolName,
|
|
179
|
+
arguments: call.arguments,
|
|
180
|
+
purpose: `${options.agentName}: ${call.summary}`,
|
|
181
|
+
...options.requestTtlMs !== void 0 ? { ttlMs: options.requestTtlMs } : {}
|
|
182
|
+
});
|
|
183
|
+
} catch {
|
|
184
|
+
return denied(call, "SERVICE_UNAVAILABLE");
|
|
185
|
+
}
|
|
186
|
+
const requestId = result.requestId;
|
|
187
|
+
let approvedByHuman = false;
|
|
188
|
+
if (isPending(result)) {
|
|
189
|
+
if (!requestId) return denied(call, "MISSING_REQUEST_ID");
|
|
190
|
+
log(
|
|
191
|
+
`Guardian: "${call.summary}" is held for approval (request ${requestId}). Approve it on your phone or in the console.`
|
|
192
|
+
);
|
|
193
|
+
const deadline = now() + options.approvalTimeoutMs;
|
|
194
|
+
const maxInterval = Math.max(
|
|
195
|
+
options.maxPollIntervalMs ?? 1e4,
|
|
196
|
+
options.pollIntervalMs
|
|
197
|
+
);
|
|
198
|
+
let interval = options.pollIntervalMs;
|
|
199
|
+
for (; ; ) {
|
|
200
|
+
await wait(interval);
|
|
201
|
+
interval = Math.min(Math.ceil(interval * 1.5), maxInterval);
|
|
202
|
+
let claimed;
|
|
203
|
+
try {
|
|
204
|
+
claimed = await options.client.claim(requestId);
|
|
205
|
+
} catch {
|
|
206
|
+
claimed = { ok: false, reasonCode: "APPROVAL_REQUIRED", requestId };
|
|
207
|
+
}
|
|
208
|
+
if (!isPending(claimed)) {
|
|
209
|
+
result = claimed;
|
|
210
|
+
approvedByHuman = claimed.ok && claimed.decision === "ALLOW";
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
if (now() >= deadline) {
|
|
214
|
+
return {
|
|
215
|
+
decision: "deny",
|
|
216
|
+
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.`,
|
|
217
|
+
requestId,
|
|
218
|
+
guardianDecision: "ASK"
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (!result.ok || result.decision !== "ALLOW" || !result.capabilityToken) {
|
|
224
|
+
const code = result.ok ? result.reasonCode ?? (result.decision === "DENY" ? "DENIED" : "MISSING_CAPABILITY") : result.reasonCode;
|
|
225
|
+
return denied(call, code, {
|
|
226
|
+
...requestId ? { requestId } : {},
|
|
227
|
+
guardianDecision: "DENY"
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
let executed;
|
|
231
|
+
try {
|
|
232
|
+
executed = await options.client.execute({
|
|
233
|
+
requestId: result.requestId,
|
|
234
|
+
capabilityToken: result.capabilityToken
|
|
235
|
+
});
|
|
236
|
+
} catch {
|
|
237
|
+
executed = { ok: false, reasonCode: "SERVICE_UNAVAILABLE" };
|
|
238
|
+
}
|
|
239
|
+
if (!executed.ok) {
|
|
240
|
+
return denied(call, executed.reasonCode, {
|
|
241
|
+
requestId: result.requestId,
|
|
242
|
+
guardianDecision: approvedByHuman ? "ASK" : "ALLOW",
|
|
243
|
+
approvedByHuman
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
return {
|
|
247
|
+
decision: "allow",
|
|
248
|
+
reason: approvedByHuman ? `Approved by a human in Guardian (request ${result.requestId}).` : `Allowed by Guardian policy (request ${result.requestId}).`,
|
|
249
|
+
requestId: result.requestId,
|
|
250
|
+
guardianDecision: approvedByHuman ? "ASK" : "ALLOW",
|
|
251
|
+
approvedByHuman
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// src/run.ts
|
|
256
|
+
var DEFAULT_BASE_URL = "https://guardian-api.odingard.com";
|
|
257
|
+
var REQUEST_TTL_MS = 3e5;
|
|
258
|
+
var MAX_WAIT_MS = REQUEST_TTL_MS;
|
|
259
|
+
function claudeCodeOutput(verdict) {
|
|
260
|
+
return JSON.stringify({
|
|
261
|
+
hookSpecificOutput: {
|
|
262
|
+
hookEventName: "PreToolUse",
|
|
263
|
+
permissionDecision: verdict.decision,
|
|
264
|
+
permissionDecisionReason: verdict.reason
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
function connectionFor(toolName, env) {
|
|
269
|
+
const allow = env.GUARDIAN_CONNECTION_ID?.trim() || void 0;
|
|
270
|
+
if (tierOf(toolName) === "allow") return allow;
|
|
271
|
+
return env.GUARDIAN_HOOK_ASK_CONNECTION_ID?.trim() || allow;
|
|
272
|
+
}
|
|
273
|
+
async function runHook(stdin, env, deps = {}) {
|
|
274
|
+
const failClosed = (reason) => claudeCodeOutput({ decision: "deny", reason: `Guardian hook: ${reason}` });
|
|
275
|
+
let input;
|
|
276
|
+
try {
|
|
277
|
+
input = JSON.parse(stdin);
|
|
278
|
+
} catch {
|
|
279
|
+
return failClosed(
|
|
280
|
+
"could not read the tool call from the agent. Nothing ran."
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
if (typeof input.tool_name !== "string") {
|
|
284
|
+
return failClosed("the tool call had no tool name. Nothing ran.");
|
|
285
|
+
}
|
|
286
|
+
const call = governedCall({
|
|
287
|
+
tool: input.tool_name,
|
|
288
|
+
input: input.tool_input && typeof input.tool_input === "object" ? input.tool_input : {},
|
|
289
|
+
...typeof input.cwd === "string" ? { cwd: input.cwd } : {}
|
|
290
|
+
});
|
|
291
|
+
if (call === null) return "";
|
|
292
|
+
const connectionId = connectionFor(call.toolName, env);
|
|
293
|
+
const credential = env.GUARDIAN_AGENT_CREDENTIAL?.trim();
|
|
294
|
+
const baseUrl = (env.GUARDIAN_BASE_URL?.trim() || DEFAULT_BASE_URL).replace(
|
|
295
|
+
/\/+$/,
|
|
296
|
+
""
|
|
297
|
+
);
|
|
298
|
+
if (!deps.client && (!credential || !connectionId)) {
|
|
299
|
+
return failClosed(
|
|
300
|
+
"GUARDIAN_AGENT_CREDENTIAL and GUARDIAN_CONNECTION_ID must be set (run `npx @odingard/guardian-hook setup`). Nothing ran."
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
if (!deps.client && !baseUrl.startsWith("https://")) {
|
|
304
|
+
return failClosed("GUARDIAN_BASE_URL must be https. Nothing ran.");
|
|
305
|
+
}
|
|
306
|
+
const requested = Number(
|
|
307
|
+
env.GUARDIAN_HOOK_APPROVAL_TIMEOUT_MS ?? MAX_WAIT_MS
|
|
308
|
+
);
|
|
309
|
+
const approvalTimeoutMs = Number.isFinite(requested) ? Math.min(Math.max(requested, 0), MAX_WAIT_MS) : MAX_WAIT_MS;
|
|
310
|
+
const verdict = await decide(call, {
|
|
311
|
+
client: deps.client ?? createGuardianClient({
|
|
312
|
+
baseUrl,
|
|
313
|
+
credential,
|
|
314
|
+
...env.GUARDIAN_ADAPTER_ID ? { adapterId: env.GUARDIAN_ADAPTER_ID } : {}
|
|
315
|
+
}),
|
|
316
|
+
connectionId: connectionId ?? "",
|
|
317
|
+
agentName: env.GUARDIAN_HOOK_AGENT_NAME?.trim() || "Claude Code",
|
|
318
|
+
approvalTimeoutMs,
|
|
319
|
+
requestTtlMs: REQUEST_TTL_MS,
|
|
320
|
+
pollIntervalMs: 2e3,
|
|
321
|
+
...deps.wait ? { wait: deps.wait } : {},
|
|
322
|
+
...deps.now ? { now: deps.now } : {},
|
|
323
|
+
...deps.log ? { log: deps.log } : {}
|
|
324
|
+
});
|
|
325
|
+
return claudeCodeOutput(verdict);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// src/agent-sdk.ts
|
|
329
|
+
function guardianPreToolUse(options = {}) {
|
|
330
|
+
return async (input) => {
|
|
331
|
+
if (input.hook_event_name && input.hook_event_name !== "PreToolUse") {
|
|
332
|
+
return {};
|
|
333
|
+
}
|
|
334
|
+
const base = options.env ?? process.env;
|
|
335
|
+
const env = {
|
|
336
|
+
...base,
|
|
337
|
+
GUARDIAN_HOOK_AGENT_NAME: options.agentName ?? base.GUARDIAN_HOOK_AGENT_NAME ?? "Claude agent"
|
|
338
|
+
};
|
|
339
|
+
const output = await runHook(
|
|
340
|
+
JSON.stringify({
|
|
341
|
+
tool_name: input.tool_name,
|
|
342
|
+
tool_input: input.tool_input,
|
|
343
|
+
cwd: input.cwd
|
|
344
|
+
}),
|
|
345
|
+
env,
|
|
346
|
+
{
|
|
347
|
+
log: options.log ?? ((line) => process.stderr.write(`${line}
|
|
348
|
+
`)),
|
|
349
|
+
...options.client ? { client: options.client } : {},
|
|
350
|
+
...options.wait ? { wait: options.wait } : {},
|
|
351
|
+
...options.now ? { now: options.now } : {}
|
|
352
|
+
}
|
|
353
|
+
);
|
|
354
|
+
return output ? JSON.parse(output) : {};
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
var GUARDIAN_HOOK_TIMEOUT_SECONDS = Math.ceil(MAX_WAIT_MS / 1e3) + 30;
|
|
358
|
+
function guardianHookMatcher(options = {}) {
|
|
359
|
+
return {
|
|
360
|
+
hooks: [guardianPreToolUse(options)],
|
|
361
|
+
timeout: GUARDIAN_HOOK_TIMEOUT_SECONDS,
|
|
362
|
+
onFailure: "block"
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
export {
|
|
366
|
+
GUARDIAN_HOOK_TIMEOUT_SECONDS,
|
|
367
|
+
guardianHookMatcher,
|
|
368
|
+
guardianPreToolUse
|
|
369
|
+
};
|
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 = [
|
|
@@ -145,6 +142,9 @@ function tierOf(toolName) {
|
|
|
145
142
|
return known ? known.suggestedMode : "ask";
|
|
146
143
|
}
|
|
147
144
|
|
|
145
|
+
// src/run.ts
|
|
146
|
+
import { createGuardianClient } from "@odingard/guardian-sdk";
|
|
147
|
+
|
|
148
148
|
// src/decide.ts
|
|
149
149
|
var PLAIN = {
|
|
150
150
|
NOT_AUTHORIZED: "this agent is not authorized for it",
|
|
@@ -179,7 +179,8 @@ async function decide(call, options) {
|
|
|
179
179
|
connectionId: options.connectionId,
|
|
180
180
|
toolName: call.toolName,
|
|
181
181
|
arguments: call.arguments,
|
|
182
|
-
purpose: `${options.agentName}: ${call.summary}
|
|
182
|
+
purpose: `${options.agentName}: ${call.summary}`,
|
|
183
|
+
...options.requestTtlMs !== void 0 ? { ttlMs: options.requestTtlMs } : {}
|
|
183
184
|
});
|
|
184
185
|
} catch {
|
|
185
186
|
return denied(call, "SERVICE_UNAVAILABLE");
|
|
@@ -253,9 +254,10 @@ async function decide(call, options) {
|
|
|
253
254
|
};
|
|
254
255
|
}
|
|
255
256
|
|
|
256
|
-
// src/
|
|
257
|
+
// src/run.ts
|
|
257
258
|
var DEFAULT_BASE_URL = "https://guardian-api.odingard.com";
|
|
258
|
-
var
|
|
259
|
+
var REQUEST_TTL_MS = 3e5;
|
|
260
|
+
var MAX_WAIT_MS = REQUEST_TTL_MS;
|
|
259
261
|
function claudeCodeOutput(verdict) {
|
|
260
262
|
return JSON.stringify({
|
|
261
263
|
hookSpecificOutput: {
|
|
@@ -347,8 +349,10 @@ async function runHook(stdin, env, deps = {}) {
|
|
|
347
349
|
if (!deps.client && !baseUrl.startsWith("https://")) {
|
|
348
350
|
return failClosed("GUARDIAN_BASE_URL must be https. Nothing ran.");
|
|
349
351
|
}
|
|
350
|
-
const requested = Number(
|
|
351
|
-
|
|
352
|
+
const requested = Number(
|
|
353
|
+
env.GUARDIAN_HOOK_APPROVAL_TIMEOUT_MS ?? MAX_WAIT_MS
|
|
354
|
+
);
|
|
355
|
+
const approvalTimeoutMs = Number.isFinite(requested) ? Math.min(Math.max(requested, 0), MAX_WAIT_MS) : MAX_WAIT_MS;
|
|
352
356
|
const verdict = await decide(call, {
|
|
353
357
|
client: deps.client ?? createGuardianClient({
|
|
354
358
|
baseUrl,
|
|
@@ -358,6 +362,7 @@ async function runHook(stdin, env, deps = {}) {
|
|
|
358
362
|
connectionId: connectionId ?? "",
|
|
359
363
|
agentName: env.GUARDIAN_HOOK_AGENT_NAME?.trim() || "Claude Code",
|
|
360
364
|
approvalTimeoutMs,
|
|
365
|
+
requestTtlMs: REQUEST_TTL_MS,
|
|
361
366
|
pollIntervalMs: 2e3,
|
|
362
367
|
...deps.wait ? { wait: deps.wait } : {},
|
|
363
368
|
...deps.now ? { now: deps.now } : {},
|
|
@@ -365,6 +370,8 @@ async function runHook(stdin, env, deps = {}) {
|
|
|
365
370
|
});
|
|
366
371
|
return claudeCodeOutput(verdict);
|
|
367
372
|
}
|
|
373
|
+
|
|
374
|
+
// src/index.ts
|
|
368
375
|
async function readStdin() {
|
|
369
376
|
const chunks = [];
|
|
370
377
|
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
@@ -405,6 +412,7 @@ if (invokedDirectly) {
|
|
|
405
412
|
export {
|
|
406
413
|
DEFAULT_BASE_URL,
|
|
407
414
|
MAX_WAIT_MS,
|
|
415
|
+
REQUEST_TTL_MS,
|
|
408
416
|
SETTINGS_SNIPPET,
|
|
409
417
|
claudeCodeOutput,
|
|
410
418
|
connectionFor,
|
package/package.json
CHANGED
|
@@ -1,34 +1,43 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@odingard/guardian-hook",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Guardian for coding agents: a Claude Code PreToolUse hook 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.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.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "dist/index.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./dist/index.js",
|
|
10
|
+
"./agent-sdk": {
|
|
11
|
+
"types": "./agent-sdk.d.ts",
|
|
12
|
+
"default": "./dist/agent-sdk.js"
|
|
13
|
+
},
|
|
14
|
+
"./package.json": "./package.json"
|
|
15
|
+
},
|
|
8
16
|
"bin": {
|
|
9
17
|
"guardian-hook": "dist/index.js"
|
|
10
18
|
},
|
|
11
19
|
"files": [
|
|
12
20
|
"dist",
|
|
21
|
+
"agent-sdk.d.ts",
|
|
13
22
|
"README.md"
|
|
14
23
|
],
|
|
15
24
|
"engines": {
|
|
16
25
|
"node": ">=22"
|
|
17
26
|
},
|
|
18
27
|
"scripts": {
|
|
19
|
-
"build": "esbuild src/index.ts --bundle --platform=node --format=esm --target=node22 --external:@odingard/guardian-sdk --outfile=dist/index.js",
|
|
28
|
+
"build": "esbuild src/index.ts --bundle --platform=node --format=esm --target=node22 --external:@odingard/guardian-sdk --outfile=dist/index.js && esbuild src/agent-sdk.ts --bundle --platform=node --format=esm --target=node22 --external:@odingard/guardian-sdk --outfile=dist/agent-sdk.js",
|
|
20
29
|
"test": "vitest run",
|
|
21
30
|
"typecheck": "tsc -p tsconfig.json",
|
|
22
31
|
"prepublishOnly": "npm run build && npm test"
|
|
23
32
|
},
|
|
24
33
|
"dependencies": {
|
|
25
|
-
"@odingard/guardian-sdk": "^0.3.
|
|
34
|
+
"@odingard/guardian-sdk": "^0.3.5"
|
|
26
35
|
},
|
|
27
36
|
"devDependencies": {
|
|
28
37
|
"@types/node": "^22",
|
|
29
38
|
"esbuild": "^0.28.2",
|
|
30
39
|
"typescript": "^5.9.3",
|
|
31
|
-
"vitest": "^
|
|
40
|
+
"vitest": "^5.1.0"
|
|
32
41
|
},
|
|
33
42
|
"publishConfig": {
|
|
34
43
|
"access": "public"
|