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