@intended-inc/claude-code-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 +41 -3
- package/dist/bin.js +379 -26
- package/dist/bin.mjs +381 -28
- package/dist/index.d.mts +77 -13
- package/dist/index.d.ts +77 -13
- package/dist/index.js +389 -5
- package/dist/index.mjs +379 -6
- package/package.json +2 -2
package/dist/bin.mjs
CHANGED
|
@@ -1,10 +1,146 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
2
|
+
import { mkdirSync, writeFileSync, readFileSync, renameSync } from 'fs';
|
|
3
3
|
import { homedir } from 'os';
|
|
4
|
-
import { join } from 'path';
|
|
4
|
+
import { join, dirname } from 'path';
|
|
5
|
+
import { createHash } from 'crypto';
|
|
6
|
+
|
|
7
|
+
var FINAL = /* @__PURE__ */ new Set(["ALLOW", "DENY", "EXPIRED"]);
|
|
8
|
+
var MAX_POLL_SECONDS = 50;
|
|
9
|
+
var MAX_BACKOFF_MS = 1e4;
|
|
10
|
+
var MIN_REPOLL_MS = 1e3;
|
|
11
|
+
var defaultSleep = (ms) => new Promise((r) => setTimeout(r, Math.max(0, ms)));
|
|
12
|
+
function str(v) {
|
|
13
|
+
return typeof v === "string" && v.length > 0 ? v : null;
|
|
14
|
+
}
|
|
15
|
+
function base(intentId, status, reason) {
|
|
16
|
+
return {
|
|
17
|
+
intentId,
|
|
18
|
+
status,
|
|
19
|
+
reason,
|
|
20
|
+
token: null,
|
|
21
|
+
decidedBy: null,
|
|
22
|
+
decidedAt: null,
|
|
23
|
+
escalationId: null,
|
|
24
|
+
expiresAt: null,
|
|
25
|
+
receiptUrl: null
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
function parseBody(intentId, body, apiUrl) {
|
|
29
|
+
const status = body.status;
|
|
30
|
+
if (status !== "ALLOW" && status !== "DENY" && status !== "PENDING" && status !== "EXPIRED") return null;
|
|
31
|
+
const receipt = str(body.receiptUrl);
|
|
32
|
+
return {
|
|
33
|
+
intentId: str(body.intentId) ?? intentId,
|
|
34
|
+
status,
|
|
35
|
+
reason: str(body.reason) ?? `Decision: ${status}`,
|
|
36
|
+
token: status === "ALLOW" ? str(body.token) : null,
|
|
37
|
+
decidedBy: body.decidedBy && typeof body.decidedBy === "object" ? body.decidedBy : null,
|
|
38
|
+
decidedAt: str(body.decidedAt),
|
|
39
|
+
escalationId: str(body.escalationId),
|
|
40
|
+
expiresAt: str(body.expiresAt),
|
|
41
|
+
receiptUrl: receipt ? /^https?:\/\//.test(receipt) ? receipt : `${apiUrl}${receipt.startsWith("/") ? "" : "/"}${receipt}` : null
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function retryAfterHeaderMs(res) {
|
|
45
|
+
const raw = res.headers?.get?.("retry-after");
|
|
46
|
+
const n = raw == null ? NaN : Number(raw);
|
|
47
|
+
return Number.isFinite(n) && n > 0 ? n * 1e3 : 0;
|
|
48
|
+
}
|
|
49
|
+
async function waitForDecision(intentId, opts) {
|
|
50
|
+
const apiUrl = (opts.apiUrl ?? "https://api.intended.so").replace(/\/$/, "");
|
|
51
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
52
|
+
const now = opts.now ?? Date.now;
|
|
53
|
+
const sleep = opts.sleep ?? defaultSleep;
|
|
54
|
+
const deadline = now() + Math.max(0, opts.timeoutMs);
|
|
55
|
+
const headers = {
|
|
56
|
+
authorization: `Bearer ${opts.apiKey}`,
|
|
57
|
+
"x-tenant-id": opts.tenantId
|
|
58
|
+
};
|
|
59
|
+
let backoffMs = 1e3;
|
|
60
|
+
for (; ; ) {
|
|
61
|
+
const remaining = deadline - now();
|
|
62
|
+
const timeoutSec = remaining <= 0 ? 0 : Math.min(MAX_POLL_SECONDS, Math.ceil(remaining / 1e3));
|
|
63
|
+
const url = `${apiUrl}/v1/decisions/${encodeURIComponent(intentId)}/wait?timeout=${timeoutSec}&tenantId=${encodeURIComponent(opts.tenantId)}`;
|
|
64
|
+
const startedAt = now();
|
|
65
|
+
let res = null;
|
|
66
|
+
try {
|
|
67
|
+
res = await fetchImpl(url, {
|
|
68
|
+
headers,
|
|
69
|
+
// The server holds at most timeoutSec; give the network some slack beyond that.
|
|
70
|
+
signal: AbortSignal.timeout(timeoutSec * 1e3 + 15e3)
|
|
71
|
+
});
|
|
72
|
+
} catch {
|
|
73
|
+
res = null;
|
|
74
|
+
}
|
|
75
|
+
let transientWaitMs = 0;
|
|
76
|
+
if (res) {
|
|
77
|
+
if (res.status === 401) {
|
|
78
|
+
return { ...base(intentId, "ERROR", "The Intended API rejected the API key (401). The action is not approved."), error: "unauthorized" };
|
|
79
|
+
}
|
|
80
|
+
if (res.status === 403) {
|
|
81
|
+
return {
|
|
82
|
+
...base(
|
|
83
|
+
intentId,
|
|
84
|
+
"ERROR",
|
|
85
|
+
"This API key cannot read decisions: it needs the intent:read scope. The action is not approved."
|
|
86
|
+
),
|
|
87
|
+
error: "forbidden"
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
if (res.status === 404) {
|
|
91
|
+
return { ...base(intentId, "ERROR", `Intended has no decision for intent ${intentId}. The action is not approved.`), error: "not_found" };
|
|
92
|
+
}
|
|
93
|
+
if (res.status === 429 || res.status >= 500) {
|
|
94
|
+
transientWaitMs = Math.max(backoffMs, retryAfterHeaderMs(res));
|
|
95
|
+
} else {
|
|
96
|
+
const body = await res.json().catch(() => null);
|
|
97
|
+
const parsed = res.status === 200 && body ? parseBody(intentId, body, apiUrl) : null;
|
|
98
|
+
if (!parsed) {
|
|
99
|
+
return {
|
|
100
|
+
...base(intentId, "ERROR", `Unrecognised decision response (HTTP ${res.status}). The action is not approved.`),
|
|
101
|
+
error: "bad_response"
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
if (FINAL.has(parsed.status)) return parsed;
|
|
105
|
+
if (now() >= deadline) return { ...parsed, timedOut: true };
|
|
106
|
+
const retryAfterMs = typeof body?.retryAfterMs === "number" && body.retryAfterMs > 0 ? body.retryAfterMs : 0;
|
|
107
|
+
const answeredFast = now() - startedAt < Math.min(timeoutSec * 1e3, MIN_REPOLL_MS) / 2;
|
|
108
|
+
const pause = retryAfterMs > 0 ? retryAfterMs : answeredFast ? MIN_REPOLL_MS : 0;
|
|
109
|
+
if (pause > 0) await sleep(Math.min(pause, Math.max(0, deadline - now())));
|
|
110
|
+
backoffMs = 1e3;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
} else {
|
|
114
|
+
transientWaitMs = backoffMs;
|
|
115
|
+
}
|
|
116
|
+
if (now() >= deadline) {
|
|
117
|
+
return {
|
|
118
|
+
...base(
|
|
119
|
+
intentId,
|
|
120
|
+
"PENDING",
|
|
121
|
+
"The Intended decision service could not be reached before the wait ended. The action is still not approved."
|
|
122
|
+
),
|
|
123
|
+
timedOut: true
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
await sleep(Math.min(transientWaitMs, Math.max(0, deadline - now())));
|
|
127
|
+
backoffMs = Math.min(backoffMs * 2, MAX_BACKOFF_MS);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
function canonicalJson(value) {
|
|
131
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
|
|
132
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
133
|
+
const obj = value;
|
|
134
|
+
return `{${Object.keys(obj).sort().filter((k) => obj[k] !== void 0).map((k) => `${JSON.stringify(k)}:${canonicalJson(obj[k])}`).join(",")}}`;
|
|
135
|
+
}
|
|
136
|
+
function toolCallKey(toolName, args) {
|
|
137
|
+
return createHash("sha256").update(`${toolName}
|
|
138
|
+
${canonicalJson(args ?? {})}`).digest("hex");
|
|
139
|
+
}
|
|
5
140
|
|
|
6
141
|
// src/evaluate.ts
|
|
7
142
|
var AUTHORITY_STATUSES = /* @__PURE__ */ new Set([200, 202, 403]);
|
|
143
|
+
var DEFAULT_ESCALATION_WAIT_SECONDS = 45;
|
|
8
144
|
var MAX_DESCRIBED_KEYS = 20;
|
|
9
145
|
var SAFE_KEY = /^[A-Za-z_][A-Za-z0-9_.-]{0,63}$/;
|
|
10
146
|
function describeToolCall(name, args) {
|
|
@@ -15,15 +151,79 @@ function describeToolCall(name, args) {
|
|
|
15
151
|
const list = [...named, ...unnamed > 0 ? [`+${unnamed} more`] : []].join(", ");
|
|
16
152
|
return `Call tool "${name}" with args: ${list}`;
|
|
17
153
|
}
|
|
154
|
+
function escalationWaitMs(env) {
|
|
155
|
+
const raw = env.INTENDED_HOOK_ESCALATION_WAIT_SECONDS;
|
|
156
|
+
if (raw === void 0 || raw.trim() === "") return DEFAULT_ESCALATION_WAIT_SECONDS * 1e3;
|
|
157
|
+
const n = Number(raw);
|
|
158
|
+
return Number.isFinite(n) && n >= 0 ? Math.round(n * 1e3) : DEFAULT_ESCALATION_WAIT_SECONDS * 1e3;
|
|
159
|
+
}
|
|
160
|
+
function onPending(env) {
|
|
161
|
+
return env.INTENDED_HOOK_ON_PENDING?.trim().toLowerCase() === "ask" ? "ask" : "deny";
|
|
162
|
+
}
|
|
163
|
+
var UNCONFIGURED_OPT_OUT = "allow";
|
|
164
|
+
function unconfiguredOptOut(env) {
|
|
165
|
+
return env.INTENDED_HOOK_UNCONFIGURED?.trim().toLowerCase() === UNCONFIGURED_OPT_OUT;
|
|
166
|
+
}
|
|
167
|
+
function missingConfig(env) {
|
|
168
|
+
const missing = [];
|
|
169
|
+
if (!env.INTENDED_API_KEY?.trim()) missing.push("INTENDED_API_KEY");
|
|
170
|
+
if (!env.INTENDED_TENANT_ID?.trim()) missing.push("INTENDED_TENANT_ID");
|
|
171
|
+
return missing;
|
|
172
|
+
}
|
|
173
|
+
function unconfiguredDenyReason(missing, toolName) {
|
|
174
|
+
return `Intended: blocked "${toolName}" because the Intended Claude Code hook is not configured (missing ${missing.join(" and ")}). The hook fails closed: without a key it cannot get an authority decision, so it denies every tool call it governs. To configure it, set INTENDED_API_KEY (an Intended API key with the intent:write and intent:read scopes) and INTENDED_TENANT_ID (your workspace id) in the environment Claude Code runs in, for example in the "env" block of .claude/settings.json or your shell profile, then retry. For a local trial without Intended, set INTENDED_HOOK_UNCONFIGURED=allow: tool calls then run ungoverned, with a warning on every call. Setup: https://www.intended.so/developers/docs/developer/decision-outcomes#claude-code-hook`;
|
|
175
|
+
}
|
|
176
|
+
function unconfiguredAllowWarning(missing, toolName) {
|
|
177
|
+
return `WARNING: Intended is NOT governing tool calls. "${toolName}" is running without an authority decision because INTENDED_HOOK_UNCONFIGURED=allow is set and the hook is not configured (missing ${missing.join(" and ")}). Use this only for local trials. Set INTENDED_API_KEY and INTENDED_TENANT_ID and remove INTENDED_HOOK_UNCONFIGURED to govern tool calls.`;
|
|
178
|
+
}
|
|
18
179
|
async function evaluateHookDecision(input) {
|
|
19
|
-
const { event, env } = input;
|
|
180
|
+
const { event, env, pendingStore } = input;
|
|
181
|
+
if (event === null || typeof event !== "object" || Array.isArray(event) || event.tool_input !== void 0 && (event.tool_input === null || typeof event.tool_input !== "object" || Array.isArray(event.tool_input))) {
|
|
182
|
+
const reason2 = "Intended: the tool call from Claude Code is not in the expected format, so it is not authorized (fail-closed).";
|
|
183
|
+
return {
|
|
184
|
+
permissionDecision: "deny",
|
|
185
|
+
reason: reason2,
|
|
186
|
+
record: { tool: "unknown", verdict: "UNREADABLE", reason: reason2, intentId: null, tokenIssued: false }
|
|
187
|
+
};
|
|
188
|
+
}
|
|
20
189
|
const fetchImpl = input.fetchImpl ?? globalThis.fetch;
|
|
21
190
|
const toolName = event.tool_name ?? "unknown";
|
|
22
191
|
const args = { ...event.tool_input ?? {} };
|
|
23
|
-
const apiKey = env.INTENDED_API_KEY;
|
|
24
|
-
const tenantId = env.INTENDED_TENANT_ID;
|
|
25
192
|
const apiUrl = (env.INTENDED_API_URL ?? "https://api.intended.so").replace(/\/$/, "");
|
|
26
|
-
|
|
193
|
+
const missing = missingConfig(env);
|
|
194
|
+
if (missing.length > 0) {
|
|
195
|
+
if (unconfiguredOptOut(env)) {
|
|
196
|
+
const warning = unconfiguredAllowWarning(missing, toolName);
|
|
197
|
+
return {
|
|
198
|
+
permissionDecision: "allow",
|
|
199
|
+
warning,
|
|
200
|
+
record: { tool: toolName, verdict: "UNGOVERNED", reason: warning, intentId: null, tokenIssued: false }
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
const reason2 = unconfiguredDenyReason(missing, toolName);
|
|
204
|
+
return {
|
|
205
|
+
permissionDecision: "deny",
|
|
206
|
+
reason: reason2,
|
|
207
|
+
record: { tool: toolName, verdict: "UNCONFIGURED", reason: reason2, intentId: null, tokenIssued: false }
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
const apiKey = env.INTENDED_API_KEY.trim();
|
|
211
|
+
const tenantId = env.INTENDED_TENANT_ID.trim();
|
|
212
|
+
const key = toolCallKey(toolName, args);
|
|
213
|
+
const wait = (intentId) => waitForDecision(intentId, {
|
|
214
|
+
apiUrl,
|
|
215
|
+
apiKey,
|
|
216
|
+
tenantId,
|
|
217
|
+
fetchImpl,
|
|
218
|
+
timeoutMs: escalationWaitMs(env),
|
|
219
|
+
now: input.now,
|
|
220
|
+
sleep: input.sleep
|
|
221
|
+
});
|
|
222
|
+
const pendingIntentId = safe(() => pendingStore?.get(key));
|
|
223
|
+
if (pendingIntentId) {
|
|
224
|
+
const outcome = await wait(pendingIntentId);
|
|
225
|
+
return settle(outcome, pendingIntentId, toolName, key, env, pendingStore);
|
|
226
|
+
}
|
|
27
227
|
let res;
|
|
28
228
|
try {
|
|
29
229
|
res = await fetchImpl(`${apiUrl}/v1/agent-intent`, {
|
|
@@ -54,38 +254,191 @@ async function evaluateHookDecision(input) {
|
|
|
54
254
|
tokenIssued: Boolean(data.authorityDecisionToken)
|
|
55
255
|
};
|
|
56
256
|
if (verdict === "APPROVED") return { permissionDecision: "allow", record };
|
|
57
|
-
if (verdict === "ESCALATED")
|
|
257
|
+
if (verdict === "ESCALATED") {
|
|
258
|
+
if (!data.intentId) {
|
|
259
|
+
return { permissionDecision: "deny", reason: "Intended: escalated without an intent id to wait on \u2014 fail-closed.", record };
|
|
260
|
+
}
|
|
261
|
+
const outcome = await wait(data.intentId);
|
|
262
|
+
return settle(outcome, data.intentId, toolName, key, env, pendingStore, reason);
|
|
263
|
+
}
|
|
58
264
|
return { permissionDecision: "deny", reason: `Intended: denied \u2014 ${reason || "fail-closed (unrecognized decision)"}`, record };
|
|
59
265
|
}
|
|
266
|
+
function settle(outcome, intentId, toolName, key, env, store, escalationReason = "") {
|
|
267
|
+
const record = {
|
|
268
|
+
tool: toolName,
|
|
269
|
+
verdict: outcome.status === "ALLOW" ? "APPROVED_AFTER_ESCALATION" : outcome.status === "PENDING" ? "ESCALATED" : outcome.status,
|
|
270
|
+
reason: outcome.reason || escalationReason,
|
|
271
|
+
intentId,
|
|
272
|
+
tokenIssued: Boolean(outcome.token)
|
|
273
|
+
};
|
|
274
|
+
if (outcome.status === "ALLOW") {
|
|
275
|
+
safe(() => store?.delete(key));
|
|
276
|
+
return { permissionDecision: "allow", record };
|
|
277
|
+
}
|
|
278
|
+
if (outcome.status === "PENDING") {
|
|
279
|
+
safe(() => store?.set(key, intentId, outcome.expiresAt));
|
|
280
|
+
const message = `Intended: awaiting human approval (intent ${intentId}). Ask an approver to approve it in the Intended console, then retry the same tool call; it will be allowed then.` + (outcome.timedOut && /could not be reached/.test(outcome.reason) ? ` (${outcome.reason})` : "");
|
|
281
|
+
return { permissionDecision: onPending(env), reason: message, record };
|
|
282
|
+
}
|
|
283
|
+
safe(() => store?.delete(key));
|
|
284
|
+
if (outcome.status === "EXPIRED") {
|
|
285
|
+
return {
|
|
286
|
+
permissionDecision: "deny",
|
|
287
|
+
reason: `Intended: denied \u2014 no approver decided before the approval request expired (intent ${intentId}).`,
|
|
288
|
+
record
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
if (outcome.status === "ERROR") {
|
|
292
|
+
return { permissionDecision: "deny", reason: `Intended: could not get the approval decision \u2014 ${outcome.reason}`, record };
|
|
293
|
+
}
|
|
294
|
+
return { permissionDecision: "deny", reason: `Intended: denied \u2014 ${outcome.reason}`, record };
|
|
295
|
+
}
|
|
296
|
+
function hookOutput(decision) {
|
|
297
|
+
if (decision.permissionDecision === "allow") {
|
|
298
|
+
return decision.warning ? { systemMessage: decision.warning } : null;
|
|
299
|
+
}
|
|
300
|
+
return {
|
|
301
|
+
...decision.warning ? { systemMessage: decision.warning } : {},
|
|
302
|
+
hookSpecificOutput: {
|
|
303
|
+
hookEventName: "PreToolUse",
|
|
304
|
+
permissionDecision: decision.permissionDecision,
|
|
305
|
+
permissionDecisionReason: decision.reason
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
function safe(fn) {
|
|
310
|
+
try {
|
|
311
|
+
return fn();
|
|
312
|
+
} catch {
|
|
313
|
+
return void 0;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
var DEFAULT_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
317
|
+
var FilePendingStore = class {
|
|
318
|
+
constructor(path, maxEntries = 200, now = Date.now) {
|
|
319
|
+
this.path = path;
|
|
320
|
+
this.maxEntries = maxEntries;
|
|
321
|
+
this.now = now;
|
|
322
|
+
}
|
|
323
|
+
path;
|
|
324
|
+
maxEntries;
|
|
325
|
+
now;
|
|
326
|
+
get(key) {
|
|
327
|
+
const entries = this.load();
|
|
328
|
+
const e = entries[key];
|
|
329
|
+
if (!e) return void 0;
|
|
330
|
+
if (e.expiresAt <= this.now()) {
|
|
331
|
+
delete entries[key];
|
|
332
|
+
this.save(entries);
|
|
333
|
+
return void 0;
|
|
334
|
+
}
|
|
335
|
+
return e.intentId;
|
|
336
|
+
}
|
|
337
|
+
set(key, intentId, expiresAt) {
|
|
338
|
+
const entries = this.load();
|
|
339
|
+
const parsed = expiresAt ? Date.parse(expiresAt) : NaN;
|
|
340
|
+
entries[key] = {
|
|
341
|
+
intentId,
|
|
342
|
+
expiresAt: Number.isFinite(parsed) ? parsed : this.now() + DEFAULT_TTL_MS,
|
|
343
|
+
savedAt: this.now()
|
|
344
|
+
};
|
|
345
|
+
const keys = Object.keys(entries).sort((a, b) => entries[a].savedAt - entries[b].savedAt);
|
|
346
|
+
for (const k of keys.slice(0, Math.max(0, keys.length - this.maxEntries))) delete entries[k];
|
|
347
|
+
this.save(entries);
|
|
348
|
+
}
|
|
349
|
+
delete(key) {
|
|
350
|
+
const entries = this.load();
|
|
351
|
+
if (!(key in entries)) return;
|
|
352
|
+
delete entries[key];
|
|
353
|
+
this.save(entries);
|
|
354
|
+
}
|
|
355
|
+
/** Read the file and drop expired entries. */
|
|
356
|
+
load() {
|
|
357
|
+
let raw;
|
|
358
|
+
try {
|
|
359
|
+
raw = JSON.parse(readFileSync(this.path, "utf8"));
|
|
360
|
+
} catch {
|
|
361
|
+
return {};
|
|
362
|
+
}
|
|
363
|
+
const out = {};
|
|
364
|
+
if (!raw || typeof raw !== "object") return out;
|
|
365
|
+
const t = this.now();
|
|
366
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
367
|
+
const e = v;
|
|
368
|
+
if (e && typeof e.intentId === "string" && typeof e.expiresAt === "number" && typeof e.savedAt === "number" && e.expiresAt > t) {
|
|
369
|
+
out[k] = { intentId: e.intentId, expiresAt: e.expiresAt, savedAt: e.savedAt };
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return out;
|
|
373
|
+
}
|
|
374
|
+
save(entries) {
|
|
375
|
+
mkdirSync(dirname(this.path), { recursive: true });
|
|
376
|
+
const tmp = `${this.path}.${process.pid}.tmp`;
|
|
377
|
+
writeFileSync(tmp, JSON.stringify(entries, null, 2), { mode: 384 });
|
|
378
|
+
renameSync(tmp, this.path);
|
|
379
|
+
}
|
|
380
|
+
};
|
|
60
381
|
|
|
61
|
-
// src/
|
|
62
|
-
|
|
63
|
-
|
|
382
|
+
// src/run.ts
|
|
383
|
+
var UNREADABLE_INPUT_REASON = "Intended: could not read the tool call from Claude Code, so it is not authorized (fail-closed).";
|
|
384
|
+
var HOOK_ERROR_REASON = "Intended: the hook failed before reaching a decision, so the tool call is not authorized (fail-closed).";
|
|
385
|
+
function failClosedOutput(reason = HOOK_ERROR_REASON) {
|
|
386
|
+
return JSON.stringify(hookOutput({ permissionDecision: "deny", reason }));
|
|
387
|
+
}
|
|
388
|
+
function parseHookEvent(raw) {
|
|
389
|
+
try {
|
|
390
|
+
const value = JSON.parse(raw);
|
|
391
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return null;
|
|
392
|
+
return value;
|
|
393
|
+
} catch {
|
|
394
|
+
return null;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
async function runHook(io) {
|
|
398
|
+
let decision;
|
|
64
399
|
try {
|
|
65
|
-
event =
|
|
400
|
+
const event = parseHookEvent(io.readInput());
|
|
401
|
+
if (!event) {
|
|
402
|
+
io.write(failClosedOutput(UNREADABLE_INPUT_REASON));
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
const pendingStore = new FilePendingStore(join(io.stateDir, "pending.json"));
|
|
406
|
+
decision = await (io.evaluate ?? evaluateHookDecision)({ event, env: io.env, pendingStore });
|
|
66
407
|
} catch {
|
|
67
|
-
|
|
408
|
+
io.write(failClosedOutput());
|
|
68
409
|
return;
|
|
69
410
|
}
|
|
70
|
-
|
|
411
|
+
if (decision.warning) {
|
|
412
|
+
try {
|
|
413
|
+
io.writeError(`${decision.warning}
|
|
414
|
+
`);
|
|
415
|
+
} catch {
|
|
416
|
+
}
|
|
417
|
+
}
|
|
71
418
|
if (decision.record) {
|
|
72
419
|
try {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
420
|
+
mkdirSync(io.stateDir, { recursive: true });
|
|
421
|
+
writeFileSync(
|
|
422
|
+
join(io.stateDir, "last-decision.json"),
|
|
423
|
+
JSON.stringify({ ...decision.record, at: (/* @__PURE__ */ new Date()).toISOString() }, null, 2)
|
|
424
|
+
);
|
|
76
425
|
} catch {
|
|
77
426
|
}
|
|
78
427
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
JSON.stringify({
|
|
82
|
-
hookSpecificOutput: {
|
|
83
|
-
hookEventName: "PreToolUse",
|
|
84
|
-
permissionDecision: decision.permissionDecision,
|
|
85
|
-
permissionDecisionReason: decision.reason
|
|
86
|
-
}
|
|
87
|
-
})
|
|
88
|
-
);
|
|
89
|
-
process.exit(0);
|
|
428
|
+
const output = hookOutput(decision);
|
|
429
|
+
if (output) io.write(JSON.stringify(output));
|
|
90
430
|
}
|
|
91
|
-
|
|
431
|
+
|
|
432
|
+
// src/bin.ts
|
|
433
|
+
runHook({
|
|
434
|
+
readInput: () => readFileSync(0, "utf8"),
|
|
435
|
+
env: process.env,
|
|
436
|
+
write: (text) => process.stdout.write(text),
|
|
437
|
+
writeError: (text) => process.stderr.write(text),
|
|
438
|
+
stateDir: join(homedir(), ".intended")
|
|
439
|
+
}).catch(() => {
|
|
440
|
+
try {
|
|
441
|
+
process.stdout.write(failClosedOutput());
|
|
442
|
+
} catch {
|
|
443
|
+
}
|
|
444
|
+
}).finally(() => process.exit(0));
|
package/dist/index.d.mts
CHANGED
|
@@ -1,15 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Pure, testable core of the Intended Claude Code PreToolUse hook.
|
|
3
|
-
*
|
|
4
|
-
* Contract:
|
|
5
|
-
* - Unconfigured (no API key / tenant) → ALLOW (fail-open; never bricks a repo).
|
|
6
|
-
* - Configured:
|
|
7
|
-
* 200 APPROVED → allow
|
|
8
|
-
* 202 ESCALATED → ask (human)
|
|
9
|
-
* 403 DENIED → deny
|
|
10
|
-
* any other status (401/429/5xx/…), a network throw, or an unrecognized body
|
|
11
|
-
* → DENY (fail-closed). Never falls through to allow.
|
|
12
|
-
*/
|
|
13
1
|
interface HookEvent {
|
|
14
2
|
tool_name?: string;
|
|
15
3
|
tool_input?: Record<string, unknown>;
|
|
@@ -18,10 +6,21 @@ interface HookEnv {
|
|
|
18
6
|
INTENDED_API_KEY?: string;
|
|
19
7
|
INTENDED_TENANT_ID?: string;
|
|
20
8
|
INTENDED_API_URL?: string;
|
|
9
|
+
/** Seconds to wait for the approver on ESCALATED (default 45). */
|
|
10
|
+
INTENDED_HOOK_ESCALATION_WAIT_SECONDS?: string;
|
|
11
|
+
/** What to do when the decision is still pending after the wait: "deny" (default) or "ask". */
|
|
12
|
+
INTENDED_HOOK_ON_PENDING?: string;
|
|
13
|
+
/**
|
|
14
|
+
* What to do when the hook has no key or tenant: "deny" (default, fail-closed)
|
|
15
|
+
* or "allow" (local trials only; every call is ungoverned and warned about).
|
|
16
|
+
*/
|
|
17
|
+
INTENDED_HOOK_UNCONFIGURED?: string;
|
|
21
18
|
}
|
|
22
19
|
interface HookDecision {
|
|
23
20
|
permissionDecision: "allow" | "deny" | "ask";
|
|
24
21
|
reason?: string;
|
|
22
|
+
/** A warning to show the user (Claude Code `systemMessage`), e.g. when running ungoverned. */
|
|
23
|
+
warning?: string;
|
|
25
24
|
record?: {
|
|
26
25
|
tool: string;
|
|
27
26
|
verdict: string;
|
|
@@ -34,11 +33,76 @@ interface HookDecision {
|
|
|
34
33
|
type FetchLike = (url: string, init?: unknown) => Promise<{
|
|
35
34
|
status: number;
|
|
36
35
|
json: () => Promise<unknown>;
|
|
36
|
+
headers?: {
|
|
37
|
+
get(name: string): string | null;
|
|
38
|
+
};
|
|
37
39
|
}>;
|
|
40
|
+
/**
|
|
41
|
+
* Where escalations still waiting on an approver are remembered between hook
|
|
42
|
+
* runs (each run is a separate process). Keyed by toolCallKey(tool, args).
|
|
43
|
+
*/
|
|
44
|
+
interface PendingStore {
|
|
45
|
+
get(key: string): string | undefined;
|
|
46
|
+
set(key: string, intentId: string, expiresAt?: string | null): void;
|
|
47
|
+
delete(key: string): void;
|
|
48
|
+
}
|
|
49
|
+
/** Claude Code hooks time out at 60s by default; leave headroom. */
|
|
50
|
+
declare const DEFAULT_ESCALATION_WAIT_SECONDS = 45;
|
|
51
|
+
/**
|
|
52
|
+
* The action text Intended stores (and explain_decision returns): the tool name
|
|
53
|
+
* and argument KEYS, never values. Values (e.g. the Bash command) travel only in
|
|
54
|
+
* `arguments`, which the API evaluates in memory and stores as a sha256 digest.
|
|
55
|
+
*/
|
|
56
|
+
declare function describeToolCall(name: string, args: Record<string, unknown>): string;
|
|
57
|
+
/** INTENDED_HOOK_ESCALATION_WAIT_SECONDS → ms. Unset or invalid → 45s. */
|
|
58
|
+
declare function escalationWaitMs(env: HookEnv): number;
|
|
59
|
+
/** The documented opt-out value. Anything else (unset, "deny", a typo) keeps the hook fail-closed. */
|
|
60
|
+
declare const UNCONFIGURED_OPT_OUT = "allow";
|
|
61
|
+
/** True only when INTENDED_HOOK_UNCONFIGURED is exactly "allow" (case and surrounding space ignored). */
|
|
62
|
+
declare function unconfiguredOptOut(env: HookEnv): boolean;
|
|
63
|
+
/** Which required settings are missing ([] when configured). */
|
|
64
|
+
declare function missingConfig(env: HookEnv): string[];
|
|
38
65
|
declare function evaluateHookDecision(input: {
|
|
39
66
|
event: HookEvent;
|
|
40
67
|
env: HookEnv;
|
|
41
68
|
fetchImpl?: FetchLike;
|
|
69
|
+
/** Remembers pending escalations across hook runs. Optional: without it, every call is a fresh intent. */
|
|
70
|
+
pendingStore?: PendingStore;
|
|
71
|
+
/** Clock + sleep overrides (tests). */
|
|
72
|
+
now?: () => number;
|
|
73
|
+
sleep?: (ms: number) => Promise<void>;
|
|
42
74
|
}): Promise<HookDecision>;
|
|
75
|
+
/** The hook's stdout for a decision (Claude Code PreToolUse JSON), or null for a plain allow. */
|
|
76
|
+
declare function hookOutput(decision: HookDecision): Record<string, unknown> | null;
|
|
77
|
+
|
|
78
|
+
declare class FilePendingStore implements PendingStore {
|
|
79
|
+
private readonly path;
|
|
80
|
+
private readonly maxEntries;
|
|
81
|
+
private readonly now;
|
|
82
|
+
constructor(path: string, maxEntries?: number, now?: () => number);
|
|
83
|
+
get(key: string): string | undefined;
|
|
84
|
+
set(key: string, intentId: string, expiresAt?: string | null): void;
|
|
85
|
+
delete(key: string): void;
|
|
86
|
+
/** Read the file and drop expired entries. */
|
|
87
|
+
private load;
|
|
88
|
+
private save;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface HookIo {
|
|
92
|
+
/** Raw stdin text from Claude Code. */
|
|
93
|
+
readInput: () => string;
|
|
94
|
+
env: HookEnv;
|
|
95
|
+
write: (text: string) => void;
|
|
96
|
+
writeError: (text: string) => void;
|
|
97
|
+
/** Directory for pending.json and last-decision.json (default ~/.intended). */
|
|
98
|
+
stateDir: string;
|
|
99
|
+
/** Override the decision (tests). */
|
|
100
|
+
evaluate?: typeof evaluateHookDecision;
|
|
101
|
+
}
|
|
102
|
+
/** The deny Claude Code gets when the hook cannot decide. */
|
|
103
|
+
declare function failClosedOutput(reason?: string): string;
|
|
104
|
+
/** Parse the event; anything but a JSON object is unreadable. */
|
|
105
|
+
declare function parseHookEvent(raw: string): HookEvent | null;
|
|
106
|
+
declare function runHook(io: HookIo): Promise<void>;
|
|
43
107
|
|
|
44
|
-
export { type FetchLike, type HookDecision, type HookEnv, type HookEvent, evaluateHookDecision };
|
|
108
|
+
export { DEFAULT_ESCALATION_WAIT_SECONDS, type FetchLike, FilePendingStore, type HookDecision, type HookEnv, type HookEvent, type HookIo, type PendingStore, UNCONFIGURED_OPT_OUT, describeToolCall, escalationWaitMs, evaluateHookDecision, failClosedOutput, hookOutput, missingConfig, parseHookEvent, runHook, unconfiguredOptOut };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,15 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Pure, testable core of the Intended Claude Code PreToolUse hook.
|
|
3
|
-
*
|
|
4
|
-
* Contract:
|
|
5
|
-
* - Unconfigured (no API key / tenant) → ALLOW (fail-open; never bricks a repo).
|
|
6
|
-
* - Configured:
|
|
7
|
-
* 200 APPROVED → allow
|
|
8
|
-
* 202 ESCALATED → ask (human)
|
|
9
|
-
* 403 DENIED → deny
|
|
10
|
-
* any other status (401/429/5xx/…), a network throw, or an unrecognized body
|
|
11
|
-
* → DENY (fail-closed). Never falls through to allow.
|
|
12
|
-
*/
|
|
13
1
|
interface HookEvent {
|
|
14
2
|
tool_name?: string;
|
|
15
3
|
tool_input?: Record<string, unknown>;
|
|
@@ -18,10 +6,21 @@ interface HookEnv {
|
|
|
18
6
|
INTENDED_API_KEY?: string;
|
|
19
7
|
INTENDED_TENANT_ID?: string;
|
|
20
8
|
INTENDED_API_URL?: string;
|
|
9
|
+
/** Seconds to wait for the approver on ESCALATED (default 45). */
|
|
10
|
+
INTENDED_HOOK_ESCALATION_WAIT_SECONDS?: string;
|
|
11
|
+
/** What to do when the decision is still pending after the wait: "deny" (default) or "ask". */
|
|
12
|
+
INTENDED_HOOK_ON_PENDING?: string;
|
|
13
|
+
/**
|
|
14
|
+
* What to do when the hook has no key or tenant: "deny" (default, fail-closed)
|
|
15
|
+
* or "allow" (local trials only; every call is ungoverned and warned about).
|
|
16
|
+
*/
|
|
17
|
+
INTENDED_HOOK_UNCONFIGURED?: string;
|
|
21
18
|
}
|
|
22
19
|
interface HookDecision {
|
|
23
20
|
permissionDecision: "allow" | "deny" | "ask";
|
|
24
21
|
reason?: string;
|
|
22
|
+
/** A warning to show the user (Claude Code `systemMessage`), e.g. when running ungoverned. */
|
|
23
|
+
warning?: string;
|
|
25
24
|
record?: {
|
|
26
25
|
tool: string;
|
|
27
26
|
verdict: string;
|
|
@@ -34,11 +33,76 @@ interface HookDecision {
|
|
|
34
33
|
type FetchLike = (url: string, init?: unknown) => Promise<{
|
|
35
34
|
status: number;
|
|
36
35
|
json: () => Promise<unknown>;
|
|
36
|
+
headers?: {
|
|
37
|
+
get(name: string): string | null;
|
|
38
|
+
};
|
|
37
39
|
}>;
|
|
40
|
+
/**
|
|
41
|
+
* Where escalations still waiting on an approver are remembered between hook
|
|
42
|
+
* runs (each run is a separate process). Keyed by toolCallKey(tool, args).
|
|
43
|
+
*/
|
|
44
|
+
interface PendingStore {
|
|
45
|
+
get(key: string): string | undefined;
|
|
46
|
+
set(key: string, intentId: string, expiresAt?: string | null): void;
|
|
47
|
+
delete(key: string): void;
|
|
48
|
+
}
|
|
49
|
+
/** Claude Code hooks time out at 60s by default; leave headroom. */
|
|
50
|
+
declare const DEFAULT_ESCALATION_WAIT_SECONDS = 45;
|
|
51
|
+
/**
|
|
52
|
+
* The action text Intended stores (and explain_decision returns): the tool name
|
|
53
|
+
* and argument KEYS, never values. Values (e.g. the Bash command) travel only in
|
|
54
|
+
* `arguments`, which the API evaluates in memory and stores as a sha256 digest.
|
|
55
|
+
*/
|
|
56
|
+
declare function describeToolCall(name: string, args: Record<string, unknown>): string;
|
|
57
|
+
/** INTENDED_HOOK_ESCALATION_WAIT_SECONDS → ms. Unset or invalid → 45s. */
|
|
58
|
+
declare function escalationWaitMs(env: HookEnv): number;
|
|
59
|
+
/** The documented opt-out value. Anything else (unset, "deny", a typo) keeps the hook fail-closed. */
|
|
60
|
+
declare const UNCONFIGURED_OPT_OUT = "allow";
|
|
61
|
+
/** True only when INTENDED_HOOK_UNCONFIGURED is exactly "allow" (case and surrounding space ignored). */
|
|
62
|
+
declare function unconfiguredOptOut(env: HookEnv): boolean;
|
|
63
|
+
/** Which required settings are missing ([] when configured). */
|
|
64
|
+
declare function missingConfig(env: HookEnv): string[];
|
|
38
65
|
declare function evaluateHookDecision(input: {
|
|
39
66
|
event: HookEvent;
|
|
40
67
|
env: HookEnv;
|
|
41
68
|
fetchImpl?: FetchLike;
|
|
69
|
+
/** Remembers pending escalations across hook runs. Optional: without it, every call is a fresh intent. */
|
|
70
|
+
pendingStore?: PendingStore;
|
|
71
|
+
/** Clock + sleep overrides (tests). */
|
|
72
|
+
now?: () => number;
|
|
73
|
+
sleep?: (ms: number) => Promise<void>;
|
|
42
74
|
}): Promise<HookDecision>;
|
|
75
|
+
/** The hook's stdout for a decision (Claude Code PreToolUse JSON), or null for a plain allow. */
|
|
76
|
+
declare function hookOutput(decision: HookDecision): Record<string, unknown> | null;
|
|
77
|
+
|
|
78
|
+
declare class FilePendingStore implements PendingStore {
|
|
79
|
+
private readonly path;
|
|
80
|
+
private readonly maxEntries;
|
|
81
|
+
private readonly now;
|
|
82
|
+
constructor(path: string, maxEntries?: number, now?: () => number);
|
|
83
|
+
get(key: string): string | undefined;
|
|
84
|
+
set(key: string, intentId: string, expiresAt?: string | null): void;
|
|
85
|
+
delete(key: string): void;
|
|
86
|
+
/** Read the file and drop expired entries. */
|
|
87
|
+
private load;
|
|
88
|
+
private save;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface HookIo {
|
|
92
|
+
/** Raw stdin text from Claude Code. */
|
|
93
|
+
readInput: () => string;
|
|
94
|
+
env: HookEnv;
|
|
95
|
+
write: (text: string) => void;
|
|
96
|
+
writeError: (text: string) => void;
|
|
97
|
+
/** Directory for pending.json and last-decision.json (default ~/.intended). */
|
|
98
|
+
stateDir: string;
|
|
99
|
+
/** Override the decision (tests). */
|
|
100
|
+
evaluate?: typeof evaluateHookDecision;
|
|
101
|
+
}
|
|
102
|
+
/** The deny Claude Code gets when the hook cannot decide. */
|
|
103
|
+
declare function failClosedOutput(reason?: string): string;
|
|
104
|
+
/** Parse the event; anything but a JSON object is unreadable. */
|
|
105
|
+
declare function parseHookEvent(raw: string): HookEvent | null;
|
|
106
|
+
declare function runHook(io: HookIo): Promise<void>;
|
|
43
107
|
|
|
44
|
-
export { type FetchLike, type HookDecision, type HookEnv, type HookEvent, evaluateHookDecision };
|
|
108
|
+
export { DEFAULT_ESCALATION_WAIT_SECONDS, type FetchLike, FilePendingStore, type HookDecision, type HookEnv, type HookEvent, type HookIo, type PendingStore, UNCONFIGURED_OPT_OUT, describeToolCall, escalationWaitMs, evaluateHookDecision, failClosedOutput, hookOutput, missingConfig, parseHookEvent, runHook, unconfiguredOptOut };
|