@intended-inc/claude-code-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/dist/index.js CHANGED
@@ -1,13 +1,168 @@
1
1
  'use strict';
2
2
 
3
+ var crypto = require('crypto');
4
+ var fs = require('fs');
5
+ var path = require('path');
6
+
7
+ // src/wait.ts
8
+ var FINAL = /* @__PURE__ */ new Set(["ALLOW", "DENY", "EXPIRED"]);
9
+ var MAX_POLL_SECONDS = 50;
10
+ var MAX_BACKOFF_MS = 1e4;
11
+ var MIN_REPOLL_MS = 1e3;
12
+ var defaultSleep = (ms) => new Promise((r) => setTimeout(r, Math.max(0, ms)));
13
+ function str(v) {
14
+ return typeof v === "string" && v.length > 0 ? v : null;
15
+ }
16
+ function base(intentId, status, reason) {
17
+ return {
18
+ intentId,
19
+ status,
20
+ reason,
21
+ token: null,
22
+ decidedBy: null,
23
+ decidedAt: null,
24
+ escalationId: null,
25
+ expiresAt: null,
26
+ receiptUrl: null
27
+ };
28
+ }
29
+ function parseBody(intentId, body, apiUrl) {
30
+ const status = body.status;
31
+ if (status !== "ALLOW" && status !== "DENY" && status !== "PENDING" && status !== "EXPIRED") return null;
32
+ const receipt = str(body.receiptUrl);
33
+ return {
34
+ intentId: str(body.intentId) ?? intentId,
35
+ status,
36
+ reason: str(body.reason) ?? `Decision: ${status}`,
37
+ token: status === "ALLOW" ? str(body.token) : null,
38
+ decidedBy: body.decidedBy && typeof body.decidedBy === "object" ? body.decidedBy : null,
39
+ decidedAt: str(body.decidedAt),
40
+ escalationId: str(body.escalationId),
41
+ expiresAt: str(body.expiresAt),
42
+ receiptUrl: receipt ? /^https?:\/\//.test(receipt) ? receipt : `${apiUrl}${receipt.startsWith("/") ? "" : "/"}${receipt}` : null
43
+ };
44
+ }
45
+ function retryAfterHeaderMs(res) {
46
+ const raw = res.headers?.get?.("retry-after");
47
+ const n = raw == null ? NaN : Number(raw);
48
+ return Number.isFinite(n) && n > 0 ? n * 1e3 : 0;
49
+ }
50
+ async function waitForDecision(intentId, opts) {
51
+ const apiUrl = (opts.apiUrl ?? "https://api.intended.so").replace(/\/$/, "");
52
+ const fetchImpl = opts.fetchImpl ?? fetch;
53
+ const now = opts.now ?? Date.now;
54
+ const sleep = opts.sleep ?? defaultSleep;
55
+ const deadline = now() + Math.max(0, opts.timeoutMs);
56
+ const headers = {
57
+ authorization: `Bearer ${opts.apiKey}`,
58
+ "x-tenant-id": opts.tenantId
59
+ };
60
+ let backoffMs = 1e3;
61
+ for (; ; ) {
62
+ const remaining = deadline - now();
63
+ const timeoutSec = remaining <= 0 ? 0 : Math.min(MAX_POLL_SECONDS, Math.ceil(remaining / 1e3));
64
+ const url = `${apiUrl}/v1/decisions/${encodeURIComponent(intentId)}/wait?timeout=${timeoutSec}&tenantId=${encodeURIComponent(opts.tenantId)}`;
65
+ const startedAt = now();
66
+ let res = null;
67
+ try {
68
+ res = await fetchImpl(url, {
69
+ headers,
70
+ // The server holds at most timeoutSec; give the network some slack beyond that.
71
+ signal: AbortSignal.timeout(timeoutSec * 1e3 + 15e3)
72
+ });
73
+ } catch {
74
+ res = null;
75
+ }
76
+ let transientWaitMs = 0;
77
+ if (res) {
78
+ if (res.status === 401) {
79
+ return { ...base(intentId, "ERROR", "The Intended API rejected the API key (401). The action is not approved."), error: "unauthorized" };
80
+ }
81
+ if (res.status === 403) {
82
+ return {
83
+ ...base(
84
+ intentId,
85
+ "ERROR",
86
+ "This API key cannot read decisions: it needs the intent:read scope. The action is not approved."
87
+ ),
88
+ error: "forbidden"
89
+ };
90
+ }
91
+ if (res.status === 404) {
92
+ return { ...base(intentId, "ERROR", `Intended has no decision for intent ${intentId}. The action is not approved.`), error: "not_found" };
93
+ }
94
+ if (res.status === 429 || res.status >= 500) {
95
+ transientWaitMs = Math.max(backoffMs, retryAfterHeaderMs(res));
96
+ } else {
97
+ const body = await res.json().catch(() => null);
98
+ const parsed = res.status === 200 && body ? parseBody(intentId, body, apiUrl) : null;
99
+ if (!parsed) {
100
+ return {
101
+ ...base(intentId, "ERROR", `Unrecognised decision response (HTTP ${res.status}). The action is not approved.`),
102
+ error: "bad_response"
103
+ };
104
+ }
105
+ if (FINAL.has(parsed.status)) return parsed;
106
+ if (now() >= deadline) return { ...parsed, timedOut: true };
107
+ const retryAfterMs = typeof body?.retryAfterMs === "number" && body.retryAfterMs > 0 ? body.retryAfterMs : 0;
108
+ const answeredFast = now() - startedAt < Math.min(timeoutSec * 1e3, MIN_REPOLL_MS) / 2;
109
+ const pause = retryAfterMs > 0 ? retryAfterMs : answeredFast ? MIN_REPOLL_MS : 0;
110
+ if (pause > 0) await sleep(Math.min(pause, Math.max(0, deadline - now())));
111
+ backoffMs = 1e3;
112
+ continue;
113
+ }
114
+ } else {
115
+ transientWaitMs = backoffMs;
116
+ }
117
+ if (now() >= deadline) {
118
+ return {
119
+ ...base(
120
+ intentId,
121
+ "PENDING",
122
+ "The Intended decision service could not be reached before the wait ended. The action is still not approved."
123
+ ),
124
+ timedOut: true
125
+ };
126
+ }
127
+ await sleep(Math.min(transientWaitMs, Math.max(0, deadline - now())));
128
+ backoffMs = Math.min(backoffMs * 2, MAX_BACKOFF_MS);
129
+ }
130
+ }
131
+ function canonicalJson(value) {
132
+ if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
133
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
134
+ const obj = value;
135
+ return `{${Object.keys(obj).sort().filter((k) => obj[k] !== void 0).map((k) => `${JSON.stringify(k)}:${canonicalJson(obj[k])}`).join(",")}}`;
136
+ }
137
+ function toolCallKey(toolName, args) {
138
+ return crypto.createHash("sha256").update(`${toolName}
139
+ ${canonicalJson(args ?? {})}`).digest("hex");
140
+ }
141
+
3
142
  // src/evaluate.ts
4
143
  var AUTHORITY_STATUSES = /* @__PURE__ */ new Set([200, 202, 403]);
5
- function describe(name, args) {
6
- const rendered = Object.entries(args).map(([k, v]) => `${k}=${typeof v === "string" ? v : JSON.stringify(v)}`).join(", ");
7
- return rendered ? `Call tool "${name}" with ${rendered}` : `Call tool "${name}"`;
144
+ var DEFAULT_ESCALATION_WAIT_SECONDS = 45;
145
+ var MAX_DESCRIBED_KEYS = 20;
146
+ var SAFE_KEY = /^[A-Za-z_][A-Za-z0-9_.-]{0,63}$/;
147
+ function describeToolCall(name, args) {
148
+ const keys = Object.keys(args);
149
+ if (keys.length === 0) return `Call tool "${name}"`;
150
+ const named = keys.filter((k) => SAFE_KEY.test(k)).slice(0, MAX_DESCRIBED_KEYS);
151
+ const unnamed = keys.length - named.length;
152
+ const list = [...named, ...unnamed > 0 ? [`+${unnamed} more`] : []].join(", ");
153
+ return `Call tool "${name}" with args: ${list}`;
154
+ }
155
+ function escalationWaitMs(env) {
156
+ const raw = env.INTENDED_HOOK_ESCALATION_WAIT_SECONDS;
157
+ if (raw === void 0 || raw.trim() === "") return DEFAULT_ESCALATION_WAIT_SECONDS * 1e3;
158
+ const n = Number(raw);
159
+ return Number.isFinite(n) && n >= 0 ? Math.round(n * 1e3) : DEFAULT_ESCALATION_WAIT_SECONDS * 1e3;
160
+ }
161
+ function onPending(env) {
162
+ return env.INTENDED_HOOK_ON_PENDING?.trim().toLowerCase() === "ask" ? "ask" : "deny";
8
163
  }
9
164
  async function evaluateHookDecision(input) {
10
- const { event, env } = input;
165
+ const { event, env, pendingStore } = input;
11
166
  const fetchImpl = input.fetchImpl ?? globalThis.fetch;
12
167
  const toolName = event.tool_name ?? "unknown";
13
168
  const args = { ...event.tool_input ?? {} };
@@ -15,6 +170,21 @@ async function evaluateHookDecision(input) {
15
170
  const tenantId = env.INTENDED_TENANT_ID;
16
171
  const apiUrl = (env.INTENDED_API_URL ?? "https://api.intended.so").replace(/\/$/, "");
17
172
  if (!apiKey || !tenantId) return { permissionDecision: "allow" };
173
+ const key = toolCallKey(toolName, args);
174
+ const wait = (intentId) => waitForDecision(intentId, {
175
+ apiUrl,
176
+ apiKey,
177
+ tenantId,
178
+ fetchImpl,
179
+ timeoutMs: escalationWaitMs(env),
180
+ now: input.now,
181
+ sleep: input.sleep
182
+ });
183
+ const pendingIntentId = safe(() => pendingStore?.get(key));
184
+ if (pendingIntentId) {
185
+ const outcome = await wait(pendingIntentId);
186
+ return settle(outcome, pendingIntentId, toolName, key, env, pendingStore);
187
+ }
18
188
  let res;
19
189
  try {
20
190
  res = await fetchImpl(`${apiUrl}/v1/agent-intent`, {
@@ -24,7 +194,7 @@ async function evaluateHookDecision(input) {
24
194
  tenantId,
25
195
  actor: { id: `claude-code:${toolName}`, type: "agent" },
26
196
  source: { connector: "claude-code", harness: "claude-code", tool: toolName },
27
- proposedAction: describe(toolName, args),
197
+ proposedAction: describeToolCall(toolName, args),
28
198
  arguments: args
29
199
  })
30
200
  });
@@ -45,8 +215,120 @@ async function evaluateHookDecision(input) {
45
215
  tokenIssued: Boolean(data.authorityDecisionToken)
46
216
  };
47
217
  if (verdict === "APPROVED") return { permissionDecision: "allow", record };
48
- if (verdict === "ESCALATED") return { permissionDecision: "ask", reason: `Intended: requires human approval \u2014 ${reason}`, record };
218
+ if (verdict === "ESCALATED") {
219
+ if (!data.intentId) {
220
+ return { permissionDecision: "deny", reason: "Intended: escalated without an intent id to wait on \u2014 fail-closed.", record };
221
+ }
222
+ const outcome = await wait(data.intentId);
223
+ return settle(outcome, data.intentId, toolName, key, env, pendingStore, reason);
224
+ }
49
225
  return { permissionDecision: "deny", reason: `Intended: denied \u2014 ${reason || "fail-closed (unrecognized decision)"}`, record };
50
226
  }
227
+ function settle(outcome, intentId, toolName, key, env, store, escalationReason = "") {
228
+ const record = {
229
+ tool: toolName,
230
+ verdict: outcome.status === "ALLOW" ? "APPROVED_AFTER_ESCALATION" : outcome.status === "PENDING" ? "ESCALATED" : outcome.status,
231
+ reason: outcome.reason || escalationReason,
232
+ intentId,
233
+ tokenIssued: Boolean(outcome.token)
234
+ };
235
+ if (outcome.status === "ALLOW") {
236
+ safe(() => store?.delete(key));
237
+ return { permissionDecision: "allow", record };
238
+ }
239
+ if (outcome.status === "PENDING") {
240
+ safe(() => store?.set(key, intentId, outcome.expiresAt));
241
+ 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})` : "");
242
+ return { permissionDecision: onPending(env), reason: message, record };
243
+ }
244
+ safe(() => store?.delete(key));
245
+ if (outcome.status === "EXPIRED") {
246
+ return {
247
+ permissionDecision: "deny",
248
+ reason: `Intended: denied \u2014 no approver decided before the approval request expired (intent ${intentId}).`,
249
+ record
250
+ };
251
+ }
252
+ if (outcome.status === "ERROR") {
253
+ return { permissionDecision: "deny", reason: `Intended: could not get the approval decision \u2014 ${outcome.reason}`, record };
254
+ }
255
+ return { permissionDecision: "deny", reason: `Intended: denied \u2014 ${outcome.reason}`, record };
256
+ }
257
+ function safe(fn) {
258
+ try {
259
+ return fn();
260
+ } catch {
261
+ return void 0;
262
+ }
263
+ }
264
+ var DEFAULT_TTL_MS = 24 * 60 * 60 * 1e3;
265
+ var FilePendingStore = class {
266
+ constructor(path, maxEntries = 200, now = Date.now) {
267
+ this.path = path;
268
+ this.maxEntries = maxEntries;
269
+ this.now = now;
270
+ }
271
+ path;
272
+ maxEntries;
273
+ now;
274
+ get(key) {
275
+ const entries = this.load();
276
+ const e = entries[key];
277
+ if (!e) return void 0;
278
+ if (e.expiresAt <= this.now()) {
279
+ delete entries[key];
280
+ this.save(entries);
281
+ return void 0;
282
+ }
283
+ return e.intentId;
284
+ }
285
+ set(key, intentId, expiresAt) {
286
+ const entries = this.load();
287
+ const parsed = expiresAt ? Date.parse(expiresAt) : NaN;
288
+ entries[key] = {
289
+ intentId,
290
+ expiresAt: Number.isFinite(parsed) ? parsed : this.now() + DEFAULT_TTL_MS,
291
+ savedAt: this.now()
292
+ };
293
+ const keys = Object.keys(entries).sort((a, b) => entries[a].savedAt - entries[b].savedAt);
294
+ for (const k of keys.slice(0, Math.max(0, keys.length - this.maxEntries))) delete entries[k];
295
+ this.save(entries);
296
+ }
297
+ delete(key) {
298
+ const entries = this.load();
299
+ if (!(key in entries)) return;
300
+ delete entries[key];
301
+ this.save(entries);
302
+ }
303
+ /** Read the file and drop expired entries. */
304
+ load() {
305
+ let raw;
306
+ try {
307
+ raw = JSON.parse(fs.readFileSync(this.path, "utf8"));
308
+ } catch {
309
+ return {};
310
+ }
311
+ const out = {};
312
+ if (!raw || typeof raw !== "object") return out;
313
+ const t = this.now();
314
+ for (const [k, v] of Object.entries(raw)) {
315
+ const e = v;
316
+ if (e && typeof e.intentId === "string" && typeof e.expiresAt === "number" && typeof e.savedAt === "number" && e.expiresAt > t) {
317
+ out[k] = { intentId: e.intentId, expiresAt: e.expiresAt, savedAt: e.savedAt };
318
+ }
319
+ }
320
+ return out;
321
+ }
322
+ save(entries) {
323
+ fs.mkdirSync(path.dirname(this.path), { recursive: true });
324
+ const tmp = `${this.path}.${process.pid}.tmp`;
325
+ fs.writeFileSync(tmp, JSON.stringify(entries, null, 2), { mode: 384 });
326
+ fs.renameSync(tmp, this.path);
327
+ }
328
+ };
51
329
 
330
+ exports.DEFAULT_ESCALATION_WAIT_SECONDS = DEFAULT_ESCALATION_WAIT_SECONDS;
331
+ exports.FilePendingStore = FilePendingStore;
332
+ exports.describeToolCall = describeToolCall;
333
+ exports.escalationWaitMs = escalationWaitMs;
52
334
  exports.evaluateHookDecision = evaluateHookDecision;
package/dist/index.mjs CHANGED
@@ -1,11 +1,166 @@
1
+ import { createHash } from 'crypto';
2
+ import { readFileSync, mkdirSync, writeFileSync, renameSync } from 'fs';
3
+ import { dirname } from 'path';
4
+
5
+ // src/wait.ts
6
+ var FINAL = /* @__PURE__ */ new Set(["ALLOW", "DENY", "EXPIRED"]);
7
+ var MAX_POLL_SECONDS = 50;
8
+ var MAX_BACKOFF_MS = 1e4;
9
+ var MIN_REPOLL_MS = 1e3;
10
+ var defaultSleep = (ms) => new Promise((r) => setTimeout(r, Math.max(0, ms)));
11
+ function str(v) {
12
+ return typeof v === "string" && v.length > 0 ? v : null;
13
+ }
14
+ function base(intentId, status, reason) {
15
+ return {
16
+ intentId,
17
+ status,
18
+ reason,
19
+ token: null,
20
+ decidedBy: null,
21
+ decidedAt: null,
22
+ escalationId: null,
23
+ expiresAt: null,
24
+ receiptUrl: null
25
+ };
26
+ }
27
+ function parseBody(intentId, body, apiUrl) {
28
+ const status = body.status;
29
+ if (status !== "ALLOW" && status !== "DENY" && status !== "PENDING" && status !== "EXPIRED") return null;
30
+ const receipt = str(body.receiptUrl);
31
+ return {
32
+ intentId: str(body.intentId) ?? intentId,
33
+ status,
34
+ reason: str(body.reason) ?? `Decision: ${status}`,
35
+ token: status === "ALLOW" ? str(body.token) : null,
36
+ decidedBy: body.decidedBy && typeof body.decidedBy === "object" ? body.decidedBy : null,
37
+ decidedAt: str(body.decidedAt),
38
+ escalationId: str(body.escalationId),
39
+ expiresAt: str(body.expiresAt),
40
+ receiptUrl: receipt ? /^https?:\/\//.test(receipt) ? receipt : `${apiUrl}${receipt.startsWith("/") ? "" : "/"}${receipt}` : null
41
+ };
42
+ }
43
+ function retryAfterHeaderMs(res) {
44
+ const raw = res.headers?.get?.("retry-after");
45
+ const n = raw == null ? NaN : Number(raw);
46
+ return Number.isFinite(n) && n > 0 ? n * 1e3 : 0;
47
+ }
48
+ async function waitForDecision(intentId, opts) {
49
+ const apiUrl = (opts.apiUrl ?? "https://api.intended.so").replace(/\/$/, "");
50
+ const fetchImpl = opts.fetchImpl ?? fetch;
51
+ const now = opts.now ?? Date.now;
52
+ const sleep = opts.sleep ?? defaultSleep;
53
+ const deadline = now() + Math.max(0, opts.timeoutMs);
54
+ const headers = {
55
+ authorization: `Bearer ${opts.apiKey}`,
56
+ "x-tenant-id": opts.tenantId
57
+ };
58
+ let backoffMs = 1e3;
59
+ for (; ; ) {
60
+ const remaining = deadline - now();
61
+ const timeoutSec = remaining <= 0 ? 0 : Math.min(MAX_POLL_SECONDS, Math.ceil(remaining / 1e3));
62
+ const url = `${apiUrl}/v1/decisions/${encodeURIComponent(intentId)}/wait?timeout=${timeoutSec}&tenantId=${encodeURIComponent(opts.tenantId)}`;
63
+ const startedAt = now();
64
+ let res = null;
65
+ try {
66
+ res = await fetchImpl(url, {
67
+ headers,
68
+ // The server holds at most timeoutSec; give the network some slack beyond that.
69
+ signal: AbortSignal.timeout(timeoutSec * 1e3 + 15e3)
70
+ });
71
+ } catch {
72
+ res = null;
73
+ }
74
+ let transientWaitMs = 0;
75
+ if (res) {
76
+ if (res.status === 401) {
77
+ return { ...base(intentId, "ERROR", "The Intended API rejected the API key (401). The action is not approved."), error: "unauthorized" };
78
+ }
79
+ if (res.status === 403) {
80
+ return {
81
+ ...base(
82
+ intentId,
83
+ "ERROR",
84
+ "This API key cannot read decisions: it needs the intent:read scope. The action is not approved."
85
+ ),
86
+ error: "forbidden"
87
+ };
88
+ }
89
+ if (res.status === 404) {
90
+ return { ...base(intentId, "ERROR", `Intended has no decision for intent ${intentId}. The action is not approved.`), error: "not_found" };
91
+ }
92
+ if (res.status === 429 || res.status >= 500) {
93
+ transientWaitMs = Math.max(backoffMs, retryAfterHeaderMs(res));
94
+ } else {
95
+ const body = await res.json().catch(() => null);
96
+ const parsed = res.status === 200 && body ? parseBody(intentId, body, apiUrl) : null;
97
+ if (!parsed) {
98
+ return {
99
+ ...base(intentId, "ERROR", `Unrecognised decision response (HTTP ${res.status}). The action is not approved.`),
100
+ error: "bad_response"
101
+ };
102
+ }
103
+ if (FINAL.has(parsed.status)) return parsed;
104
+ if (now() >= deadline) return { ...parsed, timedOut: true };
105
+ const retryAfterMs = typeof body?.retryAfterMs === "number" && body.retryAfterMs > 0 ? body.retryAfterMs : 0;
106
+ const answeredFast = now() - startedAt < Math.min(timeoutSec * 1e3, MIN_REPOLL_MS) / 2;
107
+ const pause = retryAfterMs > 0 ? retryAfterMs : answeredFast ? MIN_REPOLL_MS : 0;
108
+ if (pause > 0) await sleep(Math.min(pause, Math.max(0, deadline - now())));
109
+ backoffMs = 1e3;
110
+ continue;
111
+ }
112
+ } else {
113
+ transientWaitMs = backoffMs;
114
+ }
115
+ if (now() >= deadline) {
116
+ return {
117
+ ...base(
118
+ intentId,
119
+ "PENDING",
120
+ "The Intended decision service could not be reached before the wait ended. The action is still not approved."
121
+ ),
122
+ timedOut: true
123
+ };
124
+ }
125
+ await sleep(Math.min(transientWaitMs, Math.max(0, deadline - now())));
126
+ backoffMs = Math.min(backoffMs * 2, MAX_BACKOFF_MS);
127
+ }
128
+ }
129
+ function canonicalJson(value) {
130
+ if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
131
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
132
+ const obj = value;
133
+ return `{${Object.keys(obj).sort().filter((k) => obj[k] !== void 0).map((k) => `${JSON.stringify(k)}:${canonicalJson(obj[k])}`).join(",")}}`;
134
+ }
135
+ function toolCallKey(toolName, args) {
136
+ return createHash("sha256").update(`${toolName}
137
+ ${canonicalJson(args ?? {})}`).digest("hex");
138
+ }
139
+
1
140
  // src/evaluate.ts
2
141
  var AUTHORITY_STATUSES = /* @__PURE__ */ new Set([200, 202, 403]);
3
- function describe(name, args) {
4
- const rendered = Object.entries(args).map(([k, v]) => `${k}=${typeof v === "string" ? v : JSON.stringify(v)}`).join(", ");
5
- return rendered ? `Call tool "${name}" with ${rendered}` : `Call tool "${name}"`;
142
+ var DEFAULT_ESCALATION_WAIT_SECONDS = 45;
143
+ var MAX_DESCRIBED_KEYS = 20;
144
+ var SAFE_KEY = /^[A-Za-z_][A-Za-z0-9_.-]{0,63}$/;
145
+ function describeToolCall(name, args) {
146
+ const keys = Object.keys(args);
147
+ if (keys.length === 0) return `Call tool "${name}"`;
148
+ const named = keys.filter((k) => SAFE_KEY.test(k)).slice(0, MAX_DESCRIBED_KEYS);
149
+ const unnamed = keys.length - named.length;
150
+ const list = [...named, ...unnamed > 0 ? [`+${unnamed} more`] : []].join(", ");
151
+ return `Call tool "${name}" with args: ${list}`;
152
+ }
153
+ function escalationWaitMs(env) {
154
+ const raw = env.INTENDED_HOOK_ESCALATION_WAIT_SECONDS;
155
+ if (raw === void 0 || raw.trim() === "") return DEFAULT_ESCALATION_WAIT_SECONDS * 1e3;
156
+ const n = Number(raw);
157
+ return Number.isFinite(n) && n >= 0 ? Math.round(n * 1e3) : DEFAULT_ESCALATION_WAIT_SECONDS * 1e3;
158
+ }
159
+ function onPending(env) {
160
+ return env.INTENDED_HOOK_ON_PENDING?.trim().toLowerCase() === "ask" ? "ask" : "deny";
6
161
  }
7
162
  async function evaluateHookDecision(input) {
8
- const { event, env } = input;
163
+ const { event, env, pendingStore } = input;
9
164
  const fetchImpl = input.fetchImpl ?? globalThis.fetch;
10
165
  const toolName = event.tool_name ?? "unknown";
11
166
  const args = { ...event.tool_input ?? {} };
@@ -13,6 +168,21 @@ async function evaluateHookDecision(input) {
13
168
  const tenantId = env.INTENDED_TENANT_ID;
14
169
  const apiUrl = (env.INTENDED_API_URL ?? "https://api.intended.so").replace(/\/$/, "");
15
170
  if (!apiKey || !tenantId) return { permissionDecision: "allow" };
171
+ const key = toolCallKey(toolName, args);
172
+ const wait = (intentId) => waitForDecision(intentId, {
173
+ apiUrl,
174
+ apiKey,
175
+ tenantId,
176
+ fetchImpl,
177
+ timeoutMs: escalationWaitMs(env),
178
+ now: input.now,
179
+ sleep: input.sleep
180
+ });
181
+ const pendingIntentId = safe(() => pendingStore?.get(key));
182
+ if (pendingIntentId) {
183
+ const outcome = await wait(pendingIntentId);
184
+ return settle(outcome, pendingIntentId, toolName, key, env, pendingStore);
185
+ }
16
186
  let res;
17
187
  try {
18
188
  res = await fetchImpl(`${apiUrl}/v1/agent-intent`, {
@@ -22,7 +192,7 @@ async function evaluateHookDecision(input) {
22
192
  tenantId,
23
193
  actor: { id: `claude-code:${toolName}`, type: "agent" },
24
194
  source: { connector: "claude-code", harness: "claude-code", tool: toolName },
25
- proposedAction: describe(toolName, args),
195
+ proposedAction: describeToolCall(toolName, args),
26
196
  arguments: args
27
197
  })
28
198
  });
@@ -43,8 +213,116 @@ async function evaluateHookDecision(input) {
43
213
  tokenIssued: Boolean(data.authorityDecisionToken)
44
214
  };
45
215
  if (verdict === "APPROVED") return { permissionDecision: "allow", record };
46
- if (verdict === "ESCALATED") return { permissionDecision: "ask", reason: `Intended: requires human approval \u2014 ${reason}`, record };
216
+ if (verdict === "ESCALATED") {
217
+ if (!data.intentId) {
218
+ return { permissionDecision: "deny", reason: "Intended: escalated without an intent id to wait on \u2014 fail-closed.", record };
219
+ }
220
+ const outcome = await wait(data.intentId);
221
+ return settle(outcome, data.intentId, toolName, key, env, pendingStore, reason);
222
+ }
47
223
  return { permissionDecision: "deny", reason: `Intended: denied \u2014 ${reason || "fail-closed (unrecognized decision)"}`, record };
48
224
  }
225
+ function settle(outcome, intentId, toolName, key, env, store, escalationReason = "") {
226
+ const record = {
227
+ tool: toolName,
228
+ verdict: outcome.status === "ALLOW" ? "APPROVED_AFTER_ESCALATION" : outcome.status === "PENDING" ? "ESCALATED" : outcome.status,
229
+ reason: outcome.reason || escalationReason,
230
+ intentId,
231
+ tokenIssued: Boolean(outcome.token)
232
+ };
233
+ if (outcome.status === "ALLOW") {
234
+ safe(() => store?.delete(key));
235
+ return { permissionDecision: "allow", record };
236
+ }
237
+ if (outcome.status === "PENDING") {
238
+ safe(() => store?.set(key, intentId, outcome.expiresAt));
239
+ 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})` : "");
240
+ return { permissionDecision: onPending(env), reason: message, record };
241
+ }
242
+ safe(() => store?.delete(key));
243
+ if (outcome.status === "EXPIRED") {
244
+ return {
245
+ permissionDecision: "deny",
246
+ reason: `Intended: denied \u2014 no approver decided before the approval request expired (intent ${intentId}).`,
247
+ record
248
+ };
249
+ }
250
+ if (outcome.status === "ERROR") {
251
+ return { permissionDecision: "deny", reason: `Intended: could not get the approval decision \u2014 ${outcome.reason}`, record };
252
+ }
253
+ return { permissionDecision: "deny", reason: `Intended: denied \u2014 ${outcome.reason}`, record };
254
+ }
255
+ function safe(fn) {
256
+ try {
257
+ return fn();
258
+ } catch {
259
+ return void 0;
260
+ }
261
+ }
262
+ var DEFAULT_TTL_MS = 24 * 60 * 60 * 1e3;
263
+ var FilePendingStore = class {
264
+ constructor(path, maxEntries = 200, now = Date.now) {
265
+ this.path = path;
266
+ this.maxEntries = maxEntries;
267
+ this.now = now;
268
+ }
269
+ path;
270
+ maxEntries;
271
+ now;
272
+ get(key) {
273
+ const entries = this.load();
274
+ const e = entries[key];
275
+ if (!e) return void 0;
276
+ if (e.expiresAt <= this.now()) {
277
+ delete entries[key];
278
+ this.save(entries);
279
+ return void 0;
280
+ }
281
+ return e.intentId;
282
+ }
283
+ set(key, intentId, expiresAt) {
284
+ const entries = this.load();
285
+ const parsed = expiresAt ? Date.parse(expiresAt) : NaN;
286
+ entries[key] = {
287
+ intentId,
288
+ expiresAt: Number.isFinite(parsed) ? parsed : this.now() + DEFAULT_TTL_MS,
289
+ savedAt: this.now()
290
+ };
291
+ const keys = Object.keys(entries).sort((a, b) => entries[a].savedAt - entries[b].savedAt);
292
+ for (const k of keys.slice(0, Math.max(0, keys.length - this.maxEntries))) delete entries[k];
293
+ this.save(entries);
294
+ }
295
+ delete(key) {
296
+ const entries = this.load();
297
+ if (!(key in entries)) return;
298
+ delete entries[key];
299
+ this.save(entries);
300
+ }
301
+ /** Read the file and drop expired entries. */
302
+ load() {
303
+ let raw;
304
+ try {
305
+ raw = JSON.parse(readFileSync(this.path, "utf8"));
306
+ } catch {
307
+ return {};
308
+ }
309
+ const out = {};
310
+ if (!raw || typeof raw !== "object") return out;
311
+ const t = this.now();
312
+ for (const [k, v] of Object.entries(raw)) {
313
+ const e = v;
314
+ if (e && typeof e.intentId === "string" && typeof e.expiresAt === "number" && typeof e.savedAt === "number" && e.expiresAt > t) {
315
+ out[k] = { intentId: e.intentId, expiresAt: e.expiresAt, savedAt: e.savedAt };
316
+ }
317
+ }
318
+ return out;
319
+ }
320
+ save(entries) {
321
+ mkdirSync(dirname(this.path), { recursive: true });
322
+ const tmp = `${this.path}.${process.pid}.tmp`;
323
+ writeFileSync(tmp, JSON.stringify(entries, null, 2), { mode: 384 });
324
+ renameSync(tmp, this.path);
325
+ }
326
+ };
49
327
 
50
- export { evaluateHookDecision };
328
+ export { DEFAULT_ESCALATION_WAIT_SECONDS, FilePendingStore, describeToolCall, escalationWaitMs, evaluateHookDecision };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intended-inc/claude-code-hook",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Claude Code PreToolUse hook — govern every tool call through Intended's authority layer. Fail-open when unconfigured, fail-closed on DENY / unreachable / non-2xx. npx-invokable from .claude/settings.json.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -44,6 +44,6 @@
44
44
  "@types/node": "^22.0.0",
45
45
  "tsup": "^8.5.0",
46
46
  "typescript": "^5.5.0",
47
- "vitest": "^4.1.0"
47
+ "vitest": "^5.0.5"
48
48
  }
49
49
  }