@intended-inc/claude-code-hook 0.2.0 → 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 +11 -4
- package/dist/bin.js +109 -25
- package/dist/bin.mjs +110 -26
- package/dist/index.d.mts +33 -1
- package/dist/index.d.ts +33 -1
- package/dist/index.js +111 -3
- package/dist/index.mjs +106 -5
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -24,7 +24,9 @@ authority layer — "no token, no action" before execution.
|
|
|
24
24
|
|
|
25
25
|
| State | Result |
|
|
26
26
|
|---|---|
|
|
27
|
-
| Unconfigured (no `INTENDED_API_KEY` / `INTENDED_TENANT_ID
|
|
27
|
+
| Unconfigured (no `INTENDED_API_KEY` / `INTENDED_TENANT_ID`, or either is blank) | **deny** every governed tool call, with a message naming what is missing and how to fix it |
|
|
28
|
+
| Unconfigured with `INTENDED_HOOK_UNCONFIGURED=allow` | allow, **ungoverned**, with a warning on every call (for local trials only) |
|
|
29
|
+
| Hook input that is not a JSON object, or any error inside the hook | **deny** (the hook always exits 0 with an answer, so a crash never lets a tool run) |
|
|
28
30
|
| `200 APPROVED` | allow |
|
|
29
31
|
| `202 ESCALATED` | **wait** for the approver in Intended (see below) |
|
|
30
32
|
| `403 DENIED` | **deny** (with reason) |
|
|
@@ -59,9 +61,14 @@ seconds, raise the hook's `timeout` (in seconds) above the wait:
|
|
|
59
61
|
|
|
60
62
|
and set `INTENDED_HOOK_ESCALATION_WAIT_SECONDS` below it (for example `280`).
|
|
61
63
|
|
|
62
|
-
Config (env): `INTENDED_API_KEY
|
|
63
|
-
`INTENDED_HOOK_ESCALATION_WAIT_SECONDS` (default `45`),
|
|
64
|
-
(`deny` or `ask`, default `deny`)
|
|
64
|
+
Config (env): `INTENDED_API_KEY` (scopes `intent:write` and `intent:read`), `INTENDED_TENANT_ID`,
|
|
65
|
+
`INTENDED_API_URL` (optional), `INTENDED_HOOK_ESCALATION_WAIT_SECONDS` (default `45`),
|
|
66
|
+
`INTENDED_HOOK_ON_PENDING` (`deny` or `ask`, default `deny`), `INTENDED_HOOK_UNCONFIGURED`
|
|
67
|
+
(set to `allow` to let tool calls through ungoverned while the hook is unconfigured; anything
|
|
68
|
+
else, or unset, denies).
|
|
69
|
+
|
|
70
|
+
Upgrading from 0.2: an unconfigured hook used to allow every call silently. It now denies.
|
|
71
|
+
Set the key and tenant, or set `INTENDED_HOOK_UNCONFIGURED=allow` while you try it out.
|
|
65
72
|
Attribution: actor = `claude-code:<tool>`. Last decision → `~/.intended/last-decision.json`.
|
|
66
73
|
|
|
67
74
|
It's a CLI (the SDK is the library). Apache-2.0.
|
package/dist/bin.js
CHANGED
|
@@ -162,15 +162,55 @@ function escalationWaitMs(env) {
|
|
|
162
162
|
function onPending(env) {
|
|
163
163
|
return env.INTENDED_HOOK_ON_PENDING?.trim().toLowerCase() === "ask" ? "ask" : "deny";
|
|
164
164
|
}
|
|
165
|
+
var UNCONFIGURED_OPT_OUT = "allow";
|
|
166
|
+
function unconfiguredOptOut(env) {
|
|
167
|
+
return env.INTENDED_HOOK_UNCONFIGURED?.trim().toLowerCase() === UNCONFIGURED_OPT_OUT;
|
|
168
|
+
}
|
|
169
|
+
function missingConfig(env) {
|
|
170
|
+
const missing = [];
|
|
171
|
+
if (!env.INTENDED_API_KEY?.trim()) missing.push("INTENDED_API_KEY");
|
|
172
|
+
if (!env.INTENDED_TENANT_ID?.trim()) missing.push("INTENDED_TENANT_ID");
|
|
173
|
+
return missing;
|
|
174
|
+
}
|
|
175
|
+
function unconfiguredDenyReason(missing, toolName) {
|
|
176
|
+
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`;
|
|
177
|
+
}
|
|
178
|
+
function unconfiguredAllowWarning(missing, toolName) {
|
|
179
|
+
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.`;
|
|
180
|
+
}
|
|
165
181
|
async function evaluateHookDecision(input) {
|
|
166
182
|
const { event, env, pendingStore } = input;
|
|
183
|
+
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))) {
|
|
184
|
+
const reason2 = "Intended: the tool call from Claude Code is not in the expected format, so it is not authorized (fail-closed).";
|
|
185
|
+
return {
|
|
186
|
+
permissionDecision: "deny",
|
|
187
|
+
reason: reason2,
|
|
188
|
+
record: { tool: "unknown", verdict: "UNREADABLE", reason: reason2, intentId: null, tokenIssued: false }
|
|
189
|
+
};
|
|
190
|
+
}
|
|
167
191
|
const fetchImpl = input.fetchImpl ?? globalThis.fetch;
|
|
168
192
|
const toolName = event.tool_name ?? "unknown";
|
|
169
193
|
const args = { ...event.tool_input ?? {} };
|
|
170
|
-
const apiKey = env.INTENDED_API_KEY;
|
|
171
|
-
const tenantId = env.INTENDED_TENANT_ID;
|
|
172
194
|
const apiUrl = (env.INTENDED_API_URL ?? "https://api.intended.so").replace(/\/$/, "");
|
|
173
|
-
|
|
195
|
+
const missing = missingConfig(env);
|
|
196
|
+
if (missing.length > 0) {
|
|
197
|
+
if (unconfiguredOptOut(env)) {
|
|
198
|
+
const warning = unconfiguredAllowWarning(missing, toolName);
|
|
199
|
+
return {
|
|
200
|
+
permissionDecision: "allow",
|
|
201
|
+
warning,
|
|
202
|
+
record: { tool: toolName, verdict: "UNGOVERNED", reason: warning, intentId: null, tokenIssued: false }
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
const reason2 = unconfiguredDenyReason(missing, toolName);
|
|
206
|
+
return {
|
|
207
|
+
permissionDecision: "deny",
|
|
208
|
+
reason: reason2,
|
|
209
|
+
record: { tool: toolName, verdict: "UNCONFIGURED", reason: reason2, intentId: null, tokenIssued: false }
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
const apiKey = env.INTENDED_API_KEY.trim();
|
|
213
|
+
const tenantId = env.INTENDED_TENANT_ID.trim();
|
|
174
214
|
const key = toolCallKey(toolName, args);
|
|
175
215
|
const wait = (intentId) => waitForDecision(intentId, {
|
|
176
216
|
apiUrl,
|
|
@@ -255,6 +295,19 @@ function settle(outcome, intentId, toolName, key, env, store, escalationReason =
|
|
|
255
295
|
}
|
|
256
296
|
return { permissionDecision: "deny", reason: `Intended: denied \u2014 ${outcome.reason}`, record };
|
|
257
297
|
}
|
|
298
|
+
function hookOutput(decision) {
|
|
299
|
+
if (decision.permissionDecision === "allow") {
|
|
300
|
+
return decision.warning ? { systemMessage: decision.warning } : null;
|
|
301
|
+
}
|
|
302
|
+
return {
|
|
303
|
+
...decision.warning ? { systemMessage: decision.warning } : {},
|
|
304
|
+
hookSpecificOutput: {
|
|
305
|
+
hookEventName: "PreToolUse",
|
|
306
|
+
permissionDecision: decision.permissionDecision,
|
|
307
|
+
permissionDecisionReason: decision.reason
|
|
308
|
+
}
|
|
309
|
+
};
|
|
310
|
+
}
|
|
258
311
|
function safe(fn) {
|
|
259
312
|
try {
|
|
260
313
|
return fn();
|
|
@@ -328,35 +381,66 @@ var FilePendingStore = class {
|
|
|
328
381
|
}
|
|
329
382
|
};
|
|
330
383
|
|
|
331
|
-
// src/
|
|
332
|
-
|
|
333
|
-
|
|
384
|
+
// src/run.ts
|
|
385
|
+
var UNREADABLE_INPUT_REASON = "Intended: could not read the tool call from Claude Code, so it is not authorized (fail-closed).";
|
|
386
|
+
var HOOK_ERROR_REASON = "Intended: the hook failed before reaching a decision, so the tool call is not authorized (fail-closed).";
|
|
387
|
+
function failClosedOutput(reason = HOOK_ERROR_REASON) {
|
|
388
|
+
return JSON.stringify(hookOutput({ permissionDecision: "deny", reason }));
|
|
389
|
+
}
|
|
390
|
+
function parseHookEvent(raw) {
|
|
334
391
|
try {
|
|
335
|
-
|
|
392
|
+
const value = JSON.parse(raw);
|
|
393
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return null;
|
|
394
|
+
return value;
|
|
336
395
|
} catch {
|
|
337
|
-
|
|
396
|
+
return null;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
async function runHook(io) {
|
|
400
|
+
let decision;
|
|
401
|
+
try {
|
|
402
|
+
const event = parseHookEvent(io.readInput());
|
|
403
|
+
if (!event) {
|
|
404
|
+
io.write(failClosedOutput(UNREADABLE_INPUT_REASON));
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
const pendingStore = new FilePendingStore(path.join(io.stateDir, "pending.json"));
|
|
408
|
+
decision = await (io.evaluate ?? evaluateHookDecision)({ event, env: io.env, pendingStore });
|
|
409
|
+
} catch {
|
|
410
|
+
io.write(failClosedOutput());
|
|
338
411
|
return;
|
|
339
412
|
}
|
|
340
|
-
|
|
341
|
-
|
|
413
|
+
if (decision.warning) {
|
|
414
|
+
try {
|
|
415
|
+
io.writeError(`${decision.warning}
|
|
416
|
+
`);
|
|
417
|
+
} catch {
|
|
418
|
+
}
|
|
419
|
+
}
|
|
342
420
|
if (decision.record) {
|
|
343
421
|
try {
|
|
344
|
-
|
|
345
|
-
fs.
|
|
346
|
-
|
|
422
|
+
fs.mkdirSync(io.stateDir, { recursive: true });
|
|
423
|
+
fs.writeFileSync(
|
|
424
|
+
path.join(io.stateDir, "last-decision.json"),
|
|
425
|
+
JSON.stringify({ ...decision.record, at: (/* @__PURE__ */ new Date()).toISOString() }, null, 2)
|
|
426
|
+
);
|
|
347
427
|
} catch {
|
|
348
428
|
}
|
|
349
429
|
}
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
JSON.stringify({
|
|
353
|
-
hookSpecificOutput: {
|
|
354
|
-
hookEventName: "PreToolUse",
|
|
355
|
-
permissionDecision: decision.permissionDecision,
|
|
356
|
-
permissionDecisionReason: decision.reason
|
|
357
|
-
}
|
|
358
|
-
})
|
|
359
|
-
);
|
|
360
|
-
process.exit(0);
|
|
430
|
+
const output = hookOutput(decision);
|
|
431
|
+
if (output) io.write(JSON.stringify(output));
|
|
361
432
|
}
|
|
362
|
-
|
|
433
|
+
|
|
434
|
+
// src/bin.ts
|
|
435
|
+
runHook({
|
|
436
|
+
readInput: () => fs.readFileSync(0, "utf8"),
|
|
437
|
+
env: process.env,
|
|
438
|
+
write: (text) => process.stdout.write(text),
|
|
439
|
+
writeError: (text) => process.stderr.write(text),
|
|
440
|
+
stateDir: path.join(os.homedir(), ".intended")
|
|
441
|
+
}).catch(() => {
|
|
442
|
+
try {
|
|
443
|
+
process.stdout.write(failClosedOutput());
|
|
444
|
+
} catch {
|
|
445
|
+
}
|
|
446
|
+
}).finally(() => process.exit(0));
|
package/dist/bin.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
2
|
+
import { mkdirSync, writeFileSync, readFileSync, renameSync } from 'fs';
|
|
3
3
|
import { homedir } from 'os';
|
|
4
4
|
import { join, dirname } from 'path';
|
|
5
5
|
import { createHash } from 'crypto';
|
|
@@ -160,15 +160,55 @@ function escalationWaitMs(env) {
|
|
|
160
160
|
function onPending(env) {
|
|
161
161
|
return env.INTENDED_HOOK_ON_PENDING?.trim().toLowerCase() === "ask" ? "ask" : "deny";
|
|
162
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
|
+
}
|
|
163
179
|
async function evaluateHookDecision(input) {
|
|
164
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
|
+
}
|
|
165
189
|
const fetchImpl = input.fetchImpl ?? globalThis.fetch;
|
|
166
190
|
const toolName = event.tool_name ?? "unknown";
|
|
167
191
|
const args = { ...event.tool_input ?? {} };
|
|
168
|
-
const apiKey = env.INTENDED_API_KEY;
|
|
169
|
-
const tenantId = env.INTENDED_TENANT_ID;
|
|
170
192
|
const apiUrl = (env.INTENDED_API_URL ?? "https://api.intended.so").replace(/\/$/, "");
|
|
171
|
-
|
|
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();
|
|
172
212
|
const key = toolCallKey(toolName, args);
|
|
173
213
|
const wait = (intentId) => waitForDecision(intentId, {
|
|
174
214
|
apiUrl,
|
|
@@ -253,6 +293,19 @@ function settle(outcome, intentId, toolName, key, env, store, escalationReason =
|
|
|
253
293
|
}
|
|
254
294
|
return { permissionDecision: "deny", reason: `Intended: denied \u2014 ${outcome.reason}`, record };
|
|
255
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
|
+
}
|
|
256
309
|
function safe(fn) {
|
|
257
310
|
try {
|
|
258
311
|
return fn();
|
|
@@ -326,35 +379,66 @@ var FilePendingStore = class {
|
|
|
326
379
|
}
|
|
327
380
|
};
|
|
328
381
|
|
|
329
|
-
// src/
|
|
330
|
-
|
|
331
|
-
|
|
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) {
|
|
332
389
|
try {
|
|
333
|
-
|
|
390
|
+
const value = JSON.parse(raw);
|
|
391
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return null;
|
|
392
|
+
return value;
|
|
334
393
|
} catch {
|
|
335
|
-
|
|
394
|
+
return null;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
async function runHook(io) {
|
|
398
|
+
let decision;
|
|
399
|
+
try {
|
|
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 });
|
|
407
|
+
} catch {
|
|
408
|
+
io.write(failClosedOutput());
|
|
336
409
|
return;
|
|
337
410
|
}
|
|
338
|
-
|
|
339
|
-
|
|
411
|
+
if (decision.warning) {
|
|
412
|
+
try {
|
|
413
|
+
io.writeError(`${decision.warning}
|
|
414
|
+
`);
|
|
415
|
+
} catch {
|
|
416
|
+
}
|
|
417
|
+
}
|
|
340
418
|
if (decision.record) {
|
|
341
419
|
try {
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
+
);
|
|
345
425
|
} catch {
|
|
346
426
|
}
|
|
347
427
|
}
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
JSON.stringify({
|
|
351
|
-
hookSpecificOutput: {
|
|
352
|
-
hookEventName: "PreToolUse",
|
|
353
|
-
permissionDecision: decision.permissionDecision,
|
|
354
|
-
permissionDecisionReason: decision.reason
|
|
355
|
-
}
|
|
356
|
-
})
|
|
357
|
-
);
|
|
358
|
-
process.exit(0);
|
|
428
|
+
const output = hookOutput(decision);
|
|
429
|
+
if (output) io.write(JSON.stringify(output));
|
|
359
430
|
}
|
|
360
|
-
|
|
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
|
@@ -10,10 +10,17 @@ interface HookEnv {
|
|
|
10
10
|
INTENDED_HOOK_ESCALATION_WAIT_SECONDS?: string;
|
|
11
11
|
/** What to do when the decision is still pending after the wait: "deny" (default) or "ask". */
|
|
12
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;
|
|
13
18
|
}
|
|
14
19
|
interface HookDecision {
|
|
15
20
|
permissionDecision: "allow" | "deny" | "ask";
|
|
16
21
|
reason?: string;
|
|
22
|
+
/** A warning to show the user (Claude Code `systemMessage`), e.g. when running ungoverned. */
|
|
23
|
+
warning?: string;
|
|
17
24
|
record?: {
|
|
18
25
|
tool: string;
|
|
19
26
|
verdict: string;
|
|
@@ -49,6 +56,12 @@ declare const DEFAULT_ESCALATION_WAIT_SECONDS = 45;
|
|
|
49
56
|
declare function describeToolCall(name: string, args: Record<string, unknown>): string;
|
|
50
57
|
/** INTENDED_HOOK_ESCALATION_WAIT_SECONDS → ms. Unset or invalid → 45s. */
|
|
51
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[];
|
|
52
65
|
declare function evaluateHookDecision(input: {
|
|
53
66
|
event: HookEvent;
|
|
54
67
|
env: HookEnv;
|
|
@@ -59,6 +72,8 @@ declare function evaluateHookDecision(input: {
|
|
|
59
72
|
now?: () => number;
|
|
60
73
|
sleep?: (ms: number) => Promise<void>;
|
|
61
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;
|
|
62
77
|
|
|
63
78
|
declare class FilePendingStore implements PendingStore {
|
|
64
79
|
private readonly path;
|
|
@@ -73,4 +88,21 @@ declare class FilePendingStore implements PendingStore {
|
|
|
73
88
|
private save;
|
|
74
89
|
}
|
|
75
90
|
|
|
76
|
-
|
|
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>;
|
|
107
|
+
|
|
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
|
@@ -10,10 +10,17 @@ interface HookEnv {
|
|
|
10
10
|
INTENDED_HOOK_ESCALATION_WAIT_SECONDS?: string;
|
|
11
11
|
/** What to do when the decision is still pending after the wait: "deny" (default) or "ask". */
|
|
12
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;
|
|
13
18
|
}
|
|
14
19
|
interface HookDecision {
|
|
15
20
|
permissionDecision: "allow" | "deny" | "ask";
|
|
16
21
|
reason?: string;
|
|
22
|
+
/** A warning to show the user (Claude Code `systemMessage`), e.g. when running ungoverned. */
|
|
23
|
+
warning?: string;
|
|
17
24
|
record?: {
|
|
18
25
|
tool: string;
|
|
19
26
|
verdict: string;
|
|
@@ -49,6 +56,12 @@ declare const DEFAULT_ESCALATION_WAIT_SECONDS = 45;
|
|
|
49
56
|
declare function describeToolCall(name: string, args: Record<string, unknown>): string;
|
|
50
57
|
/** INTENDED_HOOK_ESCALATION_WAIT_SECONDS → ms. Unset or invalid → 45s. */
|
|
51
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[];
|
|
52
65
|
declare function evaluateHookDecision(input: {
|
|
53
66
|
event: HookEvent;
|
|
54
67
|
env: HookEnv;
|
|
@@ -59,6 +72,8 @@ declare function evaluateHookDecision(input: {
|
|
|
59
72
|
now?: () => number;
|
|
60
73
|
sleep?: (ms: number) => Promise<void>;
|
|
61
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;
|
|
62
77
|
|
|
63
78
|
declare class FilePendingStore implements PendingStore {
|
|
64
79
|
private readonly path;
|
|
@@ -73,4 +88,21 @@ declare class FilePendingStore implements PendingStore {
|
|
|
73
88
|
private save;
|
|
74
89
|
}
|
|
75
90
|
|
|
76
|
-
|
|
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>;
|
|
107
|
+
|
|
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.js
CHANGED
|
@@ -161,15 +161,55 @@ function escalationWaitMs(env) {
|
|
|
161
161
|
function onPending(env) {
|
|
162
162
|
return env.INTENDED_HOOK_ON_PENDING?.trim().toLowerCase() === "ask" ? "ask" : "deny";
|
|
163
163
|
}
|
|
164
|
+
var UNCONFIGURED_OPT_OUT = "allow";
|
|
165
|
+
function unconfiguredOptOut(env) {
|
|
166
|
+
return env.INTENDED_HOOK_UNCONFIGURED?.trim().toLowerCase() === UNCONFIGURED_OPT_OUT;
|
|
167
|
+
}
|
|
168
|
+
function missingConfig(env) {
|
|
169
|
+
const missing = [];
|
|
170
|
+
if (!env.INTENDED_API_KEY?.trim()) missing.push("INTENDED_API_KEY");
|
|
171
|
+
if (!env.INTENDED_TENANT_ID?.trim()) missing.push("INTENDED_TENANT_ID");
|
|
172
|
+
return missing;
|
|
173
|
+
}
|
|
174
|
+
function unconfiguredDenyReason(missing, toolName) {
|
|
175
|
+
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`;
|
|
176
|
+
}
|
|
177
|
+
function unconfiguredAllowWarning(missing, toolName) {
|
|
178
|
+
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.`;
|
|
179
|
+
}
|
|
164
180
|
async function evaluateHookDecision(input) {
|
|
165
181
|
const { event, env, pendingStore } = input;
|
|
182
|
+
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))) {
|
|
183
|
+
const reason2 = "Intended: the tool call from Claude Code is not in the expected format, so it is not authorized (fail-closed).";
|
|
184
|
+
return {
|
|
185
|
+
permissionDecision: "deny",
|
|
186
|
+
reason: reason2,
|
|
187
|
+
record: { tool: "unknown", verdict: "UNREADABLE", reason: reason2, intentId: null, tokenIssued: false }
|
|
188
|
+
};
|
|
189
|
+
}
|
|
166
190
|
const fetchImpl = input.fetchImpl ?? globalThis.fetch;
|
|
167
191
|
const toolName = event.tool_name ?? "unknown";
|
|
168
192
|
const args = { ...event.tool_input ?? {} };
|
|
169
|
-
const apiKey = env.INTENDED_API_KEY;
|
|
170
|
-
const tenantId = env.INTENDED_TENANT_ID;
|
|
171
193
|
const apiUrl = (env.INTENDED_API_URL ?? "https://api.intended.so").replace(/\/$/, "");
|
|
172
|
-
|
|
194
|
+
const missing = missingConfig(env);
|
|
195
|
+
if (missing.length > 0) {
|
|
196
|
+
if (unconfiguredOptOut(env)) {
|
|
197
|
+
const warning = unconfiguredAllowWarning(missing, toolName);
|
|
198
|
+
return {
|
|
199
|
+
permissionDecision: "allow",
|
|
200
|
+
warning,
|
|
201
|
+
record: { tool: toolName, verdict: "UNGOVERNED", reason: warning, intentId: null, tokenIssued: false }
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
const reason2 = unconfiguredDenyReason(missing, toolName);
|
|
205
|
+
return {
|
|
206
|
+
permissionDecision: "deny",
|
|
207
|
+
reason: reason2,
|
|
208
|
+
record: { tool: toolName, verdict: "UNCONFIGURED", reason: reason2, intentId: null, tokenIssued: false }
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
const apiKey = env.INTENDED_API_KEY.trim();
|
|
212
|
+
const tenantId = env.INTENDED_TENANT_ID.trim();
|
|
173
213
|
const key = toolCallKey(toolName, args);
|
|
174
214
|
const wait = (intentId) => waitForDecision(intentId, {
|
|
175
215
|
apiUrl,
|
|
@@ -254,6 +294,19 @@ function settle(outcome, intentId, toolName, key, env, store, escalationReason =
|
|
|
254
294
|
}
|
|
255
295
|
return { permissionDecision: "deny", reason: `Intended: denied \u2014 ${outcome.reason}`, record };
|
|
256
296
|
}
|
|
297
|
+
function hookOutput(decision) {
|
|
298
|
+
if (decision.permissionDecision === "allow") {
|
|
299
|
+
return decision.warning ? { systemMessage: decision.warning } : null;
|
|
300
|
+
}
|
|
301
|
+
return {
|
|
302
|
+
...decision.warning ? { systemMessage: decision.warning } : {},
|
|
303
|
+
hookSpecificOutput: {
|
|
304
|
+
hookEventName: "PreToolUse",
|
|
305
|
+
permissionDecision: decision.permissionDecision,
|
|
306
|
+
permissionDecisionReason: decision.reason
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
}
|
|
257
310
|
function safe(fn) {
|
|
258
311
|
try {
|
|
259
312
|
return fn();
|
|
@@ -326,9 +379,64 @@ var FilePendingStore = class {
|
|
|
326
379
|
fs.renameSync(tmp, this.path);
|
|
327
380
|
}
|
|
328
381
|
};
|
|
382
|
+
var UNREADABLE_INPUT_REASON = "Intended: could not read the tool call from Claude Code, so it is not authorized (fail-closed).";
|
|
383
|
+
var HOOK_ERROR_REASON = "Intended: the hook failed before reaching a decision, so the tool call is not authorized (fail-closed).";
|
|
384
|
+
function failClosedOutput(reason = HOOK_ERROR_REASON) {
|
|
385
|
+
return JSON.stringify(hookOutput({ permissionDecision: "deny", reason }));
|
|
386
|
+
}
|
|
387
|
+
function parseHookEvent(raw) {
|
|
388
|
+
try {
|
|
389
|
+
const value = JSON.parse(raw);
|
|
390
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return null;
|
|
391
|
+
return value;
|
|
392
|
+
} catch {
|
|
393
|
+
return null;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
async function runHook(io) {
|
|
397
|
+
let decision;
|
|
398
|
+
try {
|
|
399
|
+
const event = parseHookEvent(io.readInput());
|
|
400
|
+
if (!event) {
|
|
401
|
+
io.write(failClosedOutput(UNREADABLE_INPUT_REASON));
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
const pendingStore = new FilePendingStore(path.join(io.stateDir, "pending.json"));
|
|
405
|
+
decision = await (io.evaluate ?? evaluateHookDecision)({ event, env: io.env, pendingStore });
|
|
406
|
+
} catch {
|
|
407
|
+
io.write(failClosedOutput());
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
if (decision.warning) {
|
|
411
|
+
try {
|
|
412
|
+
io.writeError(`${decision.warning}
|
|
413
|
+
`);
|
|
414
|
+
} catch {
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
if (decision.record) {
|
|
418
|
+
try {
|
|
419
|
+
fs.mkdirSync(io.stateDir, { recursive: true });
|
|
420
|
+
fs.writeFileSync(
|
|
421
|
+
path.join(io.stateDir, "last-decision.json"),
|
|
422
|
+
JSON.stringify({ ...decision.record, at: (/* @__PURE__ */ new Date()).toISOString() }, null, 2)
|
|
423
|
+
);
|
|
424
|
+
} catch {
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
const output = hookOutput(decision);
|
|
428
|
+
if (output) io.write(JSON.stringify(output));
|
|
429
|
+
}
|
|
329
430
|
|
|
330
431
|
exports.DEFAULT_ESCALATION_WAIT_SECONDS = DEFAULT_ESCALATION_WAIT_SECONDS;
|
|
331
432
|
exports.FilePendingStore = FilePendingStore;
|
|
433
|
+
exports.UNCONFIGURED_OPT_OUT = UNCONFIGURED_OPT_OUT;
|
|
332
434
|
exports.describeToolCall = describeToolCall;
|
|
333
435
|
exports.escalationWaitMs = escalationWaitMs;
|
|
334
436
|
exports.evaluateHookDecision = evaluateHookDecision;
|
|
437
|
+
exports.failClosedOutput = failClosedOutput;
|
|
438
|
+
exports.hookOutput = hookOutput;
|
|
439
|
+
exports.missingConfig = missingConfig;
|
|
440
|
+
exports.parseHookEvent = parseHookEvent;
|
|
441
|
+
exports.runHook = runHook;
|
|
442
|
+
exports.unconfiguredOptOut = unconfiguredOptOut;
|
package/dist/index.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash } from 'crypto';
|
|
2
2
|
import { readFileSync, mkdirSync, writeFileSync, renameSync } from 'fs';
|
|
3
|
-
import { dirname } from 'path';
|
|
3
|
+
import { dirname, join } from 'path';
|
|
4
4
|
|
|
5
5
|
// src/wait.ts
|
|
6
6
|
var FINAL = /* @__PURE__ */ new Set(["ALLOW", "DENY", "EXPIRED"]);
|
|
@@ -159,15 +159,55 @@ function escalationWaitMs(env) {
|
|
|
159
159
|
function onPending(env) {
|
|
160
160
|
return env.INTENDED_HOOK_ON_PENDING?.trim().toLowerCase() === "ask" ? "ask" : "deny";
|
|
161
161
|
}
|
|
162
|
+
var UNCONFIGURED_OPT_OUT = "allow";
|
|
163
|
+
function unconfiguredOptOut(env) {
|
|
164
|
+
return env.INTENDED_HOOK_UNCONFIGURED?.trim().toLowerCase() === UNCONFIGURED_OPT_OUT;
|
|
165
|
+
}
|
|
166
|
+
function missingConfig(env) {
|
|
167
|
+
const missing = [];
|
|
168
|
+
if (!env.INTENDED_API_KEY?.trim()) missing.push("INTENDED_API_KEY");
|
|
169
|
+
if (!env.INTENDED_TENANT_ID?.trim()) missing.push("INTENDED_TENANT_ID");
|
|
170
|
+
return missing;
|
|
171
|
+
}
|
|
172
|
+
function unconfiguredDenyReason(missing, toolName) {
|
|
173
|
+
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`;
|
|
174
|
+
}
|
|
175
|
+
function unconfiguredAllowWarning(missing, toolName) {
|
|
176
|
+
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.`;
|
|
177
|
+
}
|
|
162
178
|
async function evaluateHookDecision(input) {
|
|
163
179
|
const { event, env, pendingStore } = input;
|
|
180
|
+
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))) {
|
|
181
|
+
const reason2 = "Intended: the tool call from Claude Code is not in the expected format, so it is not authorized (fail-closed).";
|
|
182
|
+
return {
|
|
183
|
+
permissionDecision: "deny",
|
|
184
|
+
reason: reason2,
|
|
185
|
+
record: { tool: "unknown", verdict: "UNREADABLE", reason: reason2, intentId: null, tokenIssued: false }
|
|
186
|
+
};
|
|
187
|
+
}
|
|
164
188
|
const fetchImpl = input.fetchImpl ?? globalThis.fetch;
|
|
165
189
|
const toolName = event.tool_name ?? "unknown";
|
|
166
190
|
const args = { ...event.tool_input ?? {} };
|
|
167
|
-
const apiKey = env.INTENDED_API_KEY;
|
|
168
|
-
const tenantId = env.INTENDED_TENANT_ID;
|
|
169
191
|
const apiUrl = (env.INTENDED_API_URL ?? "https://api.intended.so").replace(/\/$/, "");
|
|
170
|
-
|
|
192
|
+
const missing = missingConfig(env);
|
|
193
|
+
if (missing.length > 0) {
|
|
194
|
+
if (unconfiguredOptOut(env)) {
|
|
195
|
+
const warning = unconfiguredAllowWarning(missing, toolName);
|
|
196
|
+
return {
|
|
197
|
+
permissionDecision: "allow",
|
|
198
|
+
warning,
|
|
199
|
+
record: { tool: toolName, verdict: "UNGOVERNED", reason: warning, intentId: null, tokenIssued: false }
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
const reason2 = unconfiguredDenyReason(missing, toolName);
|
|
203
|
+
return {
|
|
204
|
+
permissionDecision: "deny",
|
|
205
|
+
reason: reason2,
|
|
206
|
+
record: { tool: toolName, verdict: "UNCONFIGURED", reason: reason2, intentId: null, tokenIssued: false }
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
const apiKey = env.INTENDED_API_KEY.trim();
|
|
210
|
+
const tenantId = env.INTENDED_TENANT_ID.trim();
|
|
171
211
|
const key = toolCallKey(toolName, args);
|
|
172
212
|
const wait = (intentId) => waitForDecision(intentId, {
|
|
173
213
|
apiUrl,
|
|
@@ -252,6 +292,19 @@ function settle(outcome, intentId, toolName, key, env, store, escalationReason =
|
|
|
252
292
|
}
|
|
253
293
|
return { permissionDecision: "deny", reason: `Intended: denied \u2014 ${outcome.reason}`, record };
|
|
254
294
|
}
|
|
295
|
+
function hookOutput(decision) {
|
|
296
|
+
if (decision.permissionDecision === "allow") {
|
|
297
|
+
return decision.warning ? { systemMessage: decision.warning } : null;
|
|
298
|
+
}
|
|
299
|
+
return {
|
|
300
|
+
...decision.warning ? { systemMessage: decision.warning } : {},
|
|
301
|
+
hookSpecificOutput: {
|
|
302
|
+
hookEventName: "PreToolUse",
|
|
303
|
+
permissionDecision: decision.permissionDecision,
|
|
304
|
+
permissionDecisionReason: decision.reason
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
}
|
|
255
308
|
function safe(fn) {
|
|
256
309
|
try {
|
|
257
310
|
return fn();
|
|
@@ -324,5 +377,53 @@ var FilePendingStore = class {
|
|
|
324
377
|
renameSync(tmp, this.path);
|
|
325
378
|
}
|
|
326
379
|
};
|
|
380
|
+
var UNREADABLE_INPUT_REASON = "Intended: could not read the tool call from Claude Code, so it is not authorized (fail-closed).";
|
|
381
|
+
var HOOK_ERROR_REASON = "Intended: the hook failed before reaching a decision, so the tool call is not authorized (fail-closed).";
|
|
382
|
+
function failClosedOutput(reason = HOOK_ERROR_REASON) {
|
|
383
|
+
return JSON.stringify(hookOutput({ permissionDecision: "deny", reason }));
|
|
384
|
+
}
|
|
385
|
+
function parseHookEvent(raw) {
|
|
386
|
+
try {
|
|
387
|
+
const value = JSON.parse(raw);
|
|
388
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return null;
|
|
389
|
+
return value;
|
|
390
|
+
} catch {
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
async function runHook(io) {
|
|
395
|
+
let decision;
|
|
396
|
+
try {
|
|
397
|
+
const event = parseHookEvent(io.readInput());
|
|
398
|
+
if (!event) {
|
|
399
|
+
io.write(failClosedOutput(UNREADABLE_INPUT_REASON));
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
const pendingStore = new FilePendingStore(join(io.stateDir, "pending.json"));
|
|
403
|
+
decision = await (io.evaluate ?? evaluateHookDecision)({ event, env: io.env, pendingStore });
|
|
404
|
+
} catch {
|
|
405
|
+
io.write(failClosedOutput());
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
if (decision.warning) {
|
|
409
|
+
try {
|
|
410
|
+
io.writeError(`${decision.warning}
|
|
411
|
+
`);
|
|
412
|
+
} catch {
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
if (decision.record) {
|
|
416
|
+
try {
|
|
417
|
+
mkdirSync(io.stateDir, { recursive: true });
|
|
418
|
+
writeFileSync(
|
|
419
|
+
join(io.stateDir, "last-decision.json"),
|
|
420
|
+
JSON.stringify({ ...decision.record, at: (/* @__PURE__ */ new Date()).toISOString() }, null, 2)
|
|
421
|
+
);
|
|
422
|
+
} catch {
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
const output = hookOutput(decision);
|
|
426
|
+
if (output) io.write(JSON.stringify(output));
|
|
427
|
+
}
|
|
327
428
|
|
|
328
|
-
export { DEFAULT_ESCALATION_WAIT_SECONDS, FilePendingStore, describeToolCall, escalationWaitMs, evaluateHookDecision };
|
|
429
|
+
export { DEFAULT_ESCALATION_WAIT_SECONDS, FilePendingStore, UNCONFIGURED_OPT_OUT, describeToolCall, escalationWaitMs, evaluateHookDecision, failClosedOutput, hookOutput, missingConfig, parseHookEvent, runHook, unconfiguredOptOut };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intended-inc/claude-code-hook",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Claude Code PreToolUse hook — govern every tool call through Intended's authority layer. Fail-
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Claude Code PreToolUse hook — govern every tool call through Intended's authority layer. Fail-closed when unconfigured (opt out with INTENDED_HOOK_UNCONFIGURED=allow) and on DENY / unreachable / non-2xx. npx-invokable from .claude/settings.json.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|