@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 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`) | **allow** (fail-open — never bricks a repo) |
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`, `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`).
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
- if (!apiKey || !tenantId) return { permissionDecision: "allow" };
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/bin.ts
332
- async function main() {
333
- let event;
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
- event = JSON.parse(fs.readFileSync(0, "utf8"));
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
- process.exit(0);
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
- const pendingStore = new FilePendingStore(path.join(os.homedir(), ".intended", "pending.json"));
341
- const decision = await evaluateHookDecision({ event, env: process.env, pendingStore });
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
- const dir = path.join(os.homedir(), ".intended");
345
- fs.mkdirSync(dir, { recursive: true });
346
- fs.writeFileSync(path.join(dir, "last-decision.json"), JSON.stringify({ ...decision.record, at: (/* @__PURE__ */ new Date()).toISOString() }, null, 2));
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
- if (decision.permissionDecision === "allow") process.exit(0);
351
- process.stdout.write(
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
- void main();
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 { readFileSync, mkdirSync, writeFileSync, renameSync } from 'fs';
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
- if (!apiKey || !tenantId) return { permissionDecision: "allow" };
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/bin.ts
330
- async function main() {
331
- let event;
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
- event = JSON.parse(readFileSync(0, "utf8"));
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
- process.exit(0);
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
- const pendingStore = new FilePendingStore(join(homedir(), ".intended", "pending.json"));
339
- const decision = await evaluateHookDecision({ event, env: process.env, pendingStore });
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
- const dir = join(homedir(), ".intended");
343
- mkdirSync(dir, { recursive: true });
344
- writeFileSync(join(dir, "last-decision.json"), JSON.stringify({ ...decision.record, at: (/* @__PURE__ */ new Date()).toISOString() }, null, 2));
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
- if (decision.permissionDecision === "allow") process.exit(0);
349
- process.stdout.write(
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
- void main();
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
- export { DEFAULT_ESCALATION_WAIT_SECONDS, type FetchLike, FilePendingStore, type HookDecision, type HookEnv, type HookEvent, type PendingStore, describeToolCall, escalationWaitMs, evaluateHookDecision };
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
- export { DEFAULT_ESCALATION_WAIT_SECONDS, type FetchLike, FilePendingStore, type HookDecision, type HookEnv, type HookEvent, type PendingStore, describeToolCall, escalationWaitMs, evaluateHookDecision };
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
- if (!apiKey || !tenantId) return { permissionDecision: "allow" };
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
- if (!apiKey || !tenantId) return { permissionDecision: "allow" };
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.2.0",
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.",
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",