@naswerks/periscope 1.0.0 → 1.0.1

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.
Files changed (187) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +2 -2
  3. package/contracts/wire-vectors/bulk_delivered.full.json +21 -0
  4. package/contracts/wire-vectors/bulk_delivered.minimal.json +21 -0
  5. package/contracts/wire-vectors/bulk_delivered.without-stat-pair.json +22 -0
  6. package/contracts/wire-vectors/bulk_failed.declared-reason.json +22 -0
  7. package/contracts/wire-vectors/bulk_failed.unknown-reason.json +23 -0
  8. package/contracts/wire-vectors/bulk_request.full.json +21 -0
  9. package/contracts/wire-vectors/host_configure.set-and-remove.json +28 -0
  10. package/contracts/wire-vectors/host_configure_result.applied.json +34 -0
  11. package/contracts/wire-vectors/host_configure_result.refused.json +33 -0
  12. package/contracts/wire-vectors/link_ack.cursors.json +25 -0
  13. package/contracts/wire-vectors/link_ack.empty.json +16 -0
  14. package/contracts/wire-vectors/link_bye.cause.json +16 -0
  15. package/contracts/wire-vectors/link_hello.empty.json +33 -0
  16. package/contracts/wire-vectors/link_hello.full.json +44 -0
  17. package/contracts/wire-vectors/link_ping.nonce.json +16 -0
  18. package/contracts/wire-vectors/link_ping.unknown-payload-key.json +18 -0
  19. package/contracts/wire-vectors/link_pong.nonce.json +16 -0
  20. package/contracts/wire-vectors/link_welcome.empty.json +18 -0
  21. package/contracts/wire-vectors/link_welcome.full.json +25 -0
  22. package/contracts/wire-vectors/refused.empty.json +10 -0
  23. package/contracts/wire-vectors/refused.encode.empty-at.json +18 -0
  24. package/contracts/wire-vectors/refused.encode.empty-session-id.json +18 -0
  25. package/contracts/wire-vectors/refused.encode.missing-member.json +22 -0
  26. package/contracts/wire-vectors/refused.encode.seq-zero.json +18 -0
  27. package/contracts/wire-vectors/refused.encode.undeclared-configure-refusal.json +34 -0
  28. package/contracts/wire-vectors/refused.encode.undeclared-wire-refusal.json +27 -0
  29. package/contracts/wire-vectors/refused.missing-member.json +10 -0
  30. package/contracts/wire-vectors/refused.not-json.json +10 -0
  31. package/contracts/wire-vectors/refused.over-limit.json +10 -0
  32. package/contracts/wire-vectors/refused.seq-zero.json +10 -0
  33. package/contracts/wire-vectors/refused.unknown-frame.json +10 -0
  34. package/contracts/wire-vectors/refused.unknown-kind.json +10 -0
  35. package/contracts/wire-vectors/repository_list.root.json +19 -0
  36. package/contracts/wire-vectors/repository_list.subdirectory.json +19 -0
  37. package/contracts/wire-vectors/repository_list_result.entries.json +34 -0
  38. package/contracts/wire-vectors/repository_list_result.refused.json +24 -0
  39. package/contracts/wire-vectors/repository_read.head.json +20 -0
  40. package/contracts/wire-vectors/repository_read_result.refused.json +25 -0
  41. package/contracts/wire-vectors/repository_read_result.truncated.json +22 -0
  42. package/contracts/wire-vectors/session_cancel.bare.json +17 -0
  43. package/contracts/wire-vectors/session_configure.full.json +22 -0
  44. package/contracts/wire-vectors/session_configure.minimal.json +20 -0
  45. package/contracts/wire-vectors/session_delta.agent-message.json +27 -0
  46. package/contracts/wire-vectors/session_list.request.json +18 -0
  47. package/contracts/wire-vectors/session_list_result.empty.json +21 -0
  48. package/contracts/wire-vectors/session_list_result.full.json +36 -0
  49. package/contracts/wire-vectors/session_new.full.json +72 -0
  50. package/contracts/wire-vectors/session_new.minimal.json +22 -0
  51. package/contracts/wire-vectors/session_new.request-all-null.json +41 -0
  52. package/contracts/wire-vectors/session_prompt.empty.json +18 -0
  53. package/contracts/wire-vectors/session_prompt.multibyte.json +18 -0
  54. package/contracts/wire-vectors/session_prompt.text.json +18 -0
  55. package/contracts/wire-vectors/session_prompt.unknown-frame-key.json +20 -0
  56. package/contracts/wire-vectors/session_prompt.unknown-payload-key.json +20 -0
  57. package/contracts/wire-vectors/session_update.agent-message.json +26 -0
  58. package/contracts/wire-vectors/session_update.state-transition-with-activity.json +44 -0
  59. package/contracts/wire-vectors/session_update.state-transition.json +41 -0
  60. package/contracts/wire-vectors/session_update.wire-refusal.json +26 -0
  61. package/contracts/wire-vectors/transcript_failed.declared-reason.json +22 -0
  62. package/contracts/wire-vectors/transcript_list.from-start.json +19 -0
  63. package/contracts/wire-vectors/transcript_list.paged.json +19 -0
  64. package/contracts/wire-vectors/transcript_list_result.entry-without-cwd.json +30 -0
  65. package/contracts/wire-vectors/transcript_list_result.full.json +29 -0
  66. package/contracts/wire-vectors/transcript_list_result.last-page.json +29 -0
  67. package/contracts/wire-vectors/transcript_tail.any-user-entry.json +22 -0
  68. package/contracts/wire-vectors/transcript_tail.with-needle.json +22 -0
  69. package/contracts/wire-vectors/transcript_tail_result.absent.json +23 -0
  70. package/contracts/wire-vectors/transcript_tail_result.found.json +23 -0
  71. package/contracts/wire-vectors/workspace_list.from-start.json +19 -0
  72. package/contracts/wire-vectors/workspace_list_result.page.json +48 -0
  73. package/contracts/wire-vectors/workspace_list_result.refused.json +26 -0
  74. package/contracts/wire-vectors/workspace_release.by-path-delete-branch.json +22 -0
  75. package/contracts/wire-vectors/workspace_release.request.json +22 -0
  76. package/contracts/wire-vectors/workspace_release_bulk.two-entries.json +32 -0
  77. package/contracts/wire-vectors/workspace_release_bulk_result.mixed.json +37 -0
  78. package/contracts/wire-vectors/workspace_release_result.partial.json +26 -0
  79. package/contracts/wire-vectors/workspace_release_result.refused.json +26 -0
  80. package/contracts/wire-vectors/workspace_release_result.released.json +23 -0
  81. package/package.json +6 -1
  82. package/src/bin/command.ts +198 -0
  83. package/src/bin/config.ts +104 -0
  84. package/src/bin/login.ts +149 -0
  85. package/src/bin/main.ts +204 -0
  86. package/src/bin/pair.ts +246 -0
  87. package/src/bin/periscope.ts +3 -0
  88. package/src/bin/posture.ts +232 -0
  89. package/src/bin/reconfigure.ts +266 -0
  90. package/src/bin/serve.ts +615 -0
  91. package/src/bin/status.ts +61 -0
  92. package/src/bin/workspaces.ts +280 -0
  93. package/src/control/backoff.ts +40 -0
  94. package/src/control/codec.ts +557 -0
  95. package/src/control/credential.ts +33 -0
  96. package/src/control/frames.ts +1586 -0
  97. package/src/control/link-state.ts +67 -0
  98. package/src/control/link.ts +894 -0
  99. package/src/control/queue.ts +248 -0
  100. package/src/control/seq.ts +76 -0
  101. package/src/control/stream-routing.ts +216 -0
  102. package/src/control/stream.ts +149 -0
  103. package/src/core/async-queue.ts +63 -0
  104. package/src/core/failure.ts +61 -0
  105. package/src/core/index.ts +22 -0
  106. package/src/core/keyed-turns.ts +47 -0
  107. package/src/core/paths.ts +84 -0
  108. package/src/core/refusal.ts +430 -0
  109. package/src/core/result.ts +27 -0
  110. package/src/core/time.ts +34 -0
  111. package/src/core/vocab.ts +44 -0
  112. package/src/core/workspace-id.ts +11 -0
  113. package/src/gate/command.ts +617 -0
  114. package/src/gate/decision.ts +173 -0
  115. package/src/gate/escalate.ts +164 -0
  116. package/src/gate/gate.ts +635 -0
  117. package/src/gate/index.ts +53 -0
  118. package/src/gate/jail.ts +210 -0
  119. package/src/gate/local.ts +115 -0
  120. package/src/gate/outcome.ts +164 -0
  121. package/src/gate/shell.ts +295 -0
  122. package/src/host/agent-process.ts +694 -0
  123. package/src/host/bulk-post.ts +166 -0
  124. package/src/host/claude-transcripts.ts +404 -0
  125. package/src/host/config-file.ts +246 -0
  126. package/src/host/git-facts.ts +119 -0
  127. package/src/host/hooks.ts +102 -0
  128. package/src/host/host.ts +1901 -0
  129. package/src/host/index.ts +92 -0
  130. package/src/host/link-state-file.ts +79 -0
  131. package/src/host/loopback.ts +177 -0
  132. package/src/host/machine.ts +27 -0
  133. package/src/host/mcp-server.ts +66 -0
  134. package/src/host/package-facts.ts +25 -0
  135. package/src/host/paired-credential-store.ts +140 -0
  136. package/src/host/paths.ts +175 -0
  137. package/src/host/repository-read.ts +245 -0
  138. package/src/host/session-store.ts +133 -0
  139. package/src/host/sign-in.ts +352 -0
  140. package/src/host/telemetry.ts +105 -0
  141. package/src/host/token-cache.ts +214 -0
  142. package/src/host/transcript-fs.ts +110 -0
  143. package/src/host/wire-request.ts +285 -0
  144. package/src/host/workspace-fs.ts +90 -0
  145. package/src/host/workspace-trust.ts +92 -0
  146. package/src/identity/authorize.ts +127 -0
  147. package/src/identity/config.ts +227 -0
  148. package/src/identity/credential.ts +176 -0
  149. package/src/identity/device-code.ts +217 -0
  150. package/src/identity/index.ts +84 -0
  151. package/src/identity/mode.ts +161 -0
  152. package/src/identity/paired-credential.ts +89 -0
  153. package/src/identity/pkce.ts +113 -0
  154. package/src/identity/store.ts +98 -0
  155. package/src/identity/token.ts +203 -0
  156. package/src/index.ts +140 -0
  157. package/src/mcp/descriptor.ts +75 -0
  158. package/src/mcp/index.ts +23 -0
  159. package/src/mcp/schema.ts +214 -0
  160. package/src/mcp/server.ts +180 -0
  161. package/src/persistence/entry.ts +142 -0
  162. package/src/persistence/index.ts +47 -0
  163. package/src/persistence/key.ts +77 -0
  164. package/src/persistence/mirror.ts +118 -0
  165. package/src/persistence/receipt.ts +182 -0
  166. package/src/persistence/retention.ts +118 -0
  167. package/src/persistence/store.ts +183 -0
  168. package/src/persistence/transition-log.ts +176 -0
  169. package/src/protocol.ts +215 -0
  170. package/src/sessions/index.ts +27 -0
  171. package/src/sessions/registry.ts +392 -0
  172. package/src/sessions/session.ts +440 -0
  173. package/src/sessions/spawn-env.ts +318 -0
  174. package/src/state/coverage.ts +360 -0
  175. package/src/state/index.ts +58 -0
  176. package/src/state/machine.ts +359 -0
  177. package/src/state/model.ts +417 -0
  178. package/src/state/observer.ts +499 -0
  179. package/src/state/reporter.ts +60 -0
  180. package/src/state/store.ts +157 -0
  181. package/src/telemetry/index.ts +10 -0
  182. package/src/telemetry/usage.ts +117 -0
  183. package/src/workspace/git-worktree.ts +551 -0
  184. package/src/workspace/index.ts +17 -0
  185. package/src/workspace/plain-dir.ts +132 -0
  186. package/src/workspace/provider.ts +159 -0
  187. package/src/workspace/worktree-porcelain.ts +118 -0
@@ -0,0 +1,635 @@
1
+ /**
2
+ * The gate: `options.hooks.PreToolUse` as the permission mechanism.
3
+ *
4
+ * Why the hook and not `canUseTool`, settled by execution rather than by reading. The hook fires
5
+ * for every tool call — MCP tools and calls inside spawned subagents included, carrying
6
+ * `agent_id`/`agent_type`. `canUseTool` is shadowed by a settings-file allow rule, by
7
+ * `allowedTools`, and by `bypassPermissions` (which additionally emits
8
+ * `CLAUDE_SDK_CAN_USE_TOOL_SHADOWED` on stderr and does not call it). Those are configurations an
9
+ * embedder chooses, so a gate built on `canUseTool` would silently not run on somebody else's setup
10
+ * — and a refusal that does not happen leaves no trace saying so.
11
+ *
12
+ * A hook that throws is fail-open. The CLI treats a throwing handler as absent rather than as a
13
+ * denial, so under `bypassPermissions` — where nothing else is left to say no — a bug in this file
14
+ * would be an open door. Every path here is inside a `try/catch` that returns an explicit
15
+ * `permissionDecision: 'deny'`. This is not defensive style; it is the difference between
16
+ * fail-closed and fail-open, and `gate.test.ts` pins it by throwing on purpose.
17
+ *
18
+ * The emission is unconditional and sits outside the deny path. A gate that fails closed must
19
+ * still say that it did — otherwise the invariant holds while the trace goes silent, and a denial
20
+ * becomes indistinguishable from an outage. So the outcome is computed on both paths, emitted once,
21
+ * and only then converted to a hook output; and the emission has its own guard, because a listener
22
+ * that throws must not be able to convert a deny back into an absent hook.
23
+ *
24
+ * An allow returns no opinion by default, never an explicit `permissionDecision: 'allow'`. This
25
+ * gate exists to add a refusal, never to remove one — two mechanisms, one invariant.
26
+ *
27
+ * What an explicit allow actually skips. The Claude Code permissions documentation states that a
28
+ * PreToolUse hook's decision does not bypass permission rules: deny and ask rules are evaluated
29
+ * whatever the hook returned. So an allow from this gate leaves the operator's deny and ask rules
30
+ * standing and skips only the permission mode, the allow rules and `canUseTool`. Source:
31
+ * https://code.claude.com/docs/en/permissions#extend-permissions-with-hooks
32
+ *
33
+ * That order is documented, not measured, and this package treats the two differently: the runtime
34
+ * beats the docs, and this module exists because executing something found what the documentation
35
+ * denied. The probe that would settle it is written in `gate.live.test.ts` ("does a hook allow
36
+ * override an operator deny rule?") and is not exercised: attempts to run it from inside an agent
37
+ * session were contaminated by the enclosing tool surface, so the denied tool was never the one
38
+ * called. Do not restate this paragraph as a measured claim without that receipt.
39
+ *
40
+ * Without `grantOnAllow` the gate is a veto rather than a gate, which was observed rather than
41
+ * inferred. Saying nothing leaves the agent's own permission mode as the decider, and an embedder
42
+ * who loads no settings files — the default, and the only posture under which this host can state
43
+ * what an agent's permissions are — has left nobody who can say yes. On a real session the gate
44
+ * allowed a `Write`, the tool did not run, and the result read "Claude requested permissions to
45
+ * write to …, but you haven't granted it yet" — in a host with no user to grant anything.
46
+ * `grantOnAllow` does not weaken this gate; it makes the decision this gate already made take
47
+ * effect, one call at a time, for exactly the calls it approved.
48
+ *
49
+ * Two deadlines, and the inner one belongs to this host. `HookCallbackMatcher.timeout` is
50
+ * per-matcher, in seconds, and expires fail-closed on CLI 2.1.210 and later — but the CLI enforces
51
+ * it, so this handler never learns it happened and the trace would show nothing at all. So the
52
+ * host runs its own shorter deadline: it fires first, blocks, and names the expiry; the matcher's
53
+ * remains as the backstop for the case where this code is the thing that hung.
54
+ */
55
+ import type {
56
+ HookCallbackMatcher,
57
+ HookInput,
58
+ HookJSONOutput,
59
+ HookRegistrations,
60
+ } from '../host/agent-process.js';
61
+ import type { Refusal, RefusalReason } from '../core/refusal.js';
62
+ import { refusal } from '../core/refusal.js';
63
+ import { HOOK_TIMEOUT_EVENT } from '../state/model.js';
64
+ import type { Decider, DecisionRequest } from './decision.js';
65
+ import { describeRaw, readDecision } from './decision.js';
66
+ import type { GateOutcome } from './outcome.js';
67
+ import { EscalationUnavailable } from './escalate.js';
68
+ import type { LocalGate } from './local.js';
69
+
70
+ /** Told about every outcome — allows, denials, outages, expiries. Never only the interesting ones. */
71
+ export type OutcomeListener = (outcome: GateOutcome) => void;
72
+
73
+ /**
74
+ * An outcome that ends a decision. A hold is not one: it is emitted while a decision is still
75
+ * outstanding, and something else terminates it later.
76
+ *
77
+ * Naming it rather than leaving it implied is what lets `outputFor` refuse the hold at compile time.
78
+ */
79
+ type TerminalOutcome = Exclude<GateOutcome, { kind: 'holding' }>;
80
+
81
+ export interface PermissionGateOptions {
82
+ readonly decide: Decider;
83
+ readonly onOutcome: OutcomeListener;
84
+ /**
85
+ * The controller's handle for this session — carried onto every decision request.
86
+ *
87
+ * Required rather than optional on purpose: a gate composed without it would send escalations a
88
+ * controller cannot route, and an optional field defaulting to `''` would make that failure
89
+ * silent. Every construction site already has the value. See `DecisionRequest.sessionKey`.
90
+ */
91
+ readonly sessionKey: string;
92
+ /**
93
+ * The host's own gate, consulted before anything is asked of anyone. Optional; absent means the
94
+ * gate behaves exactly as it did without it.
95
+ *
96
+ * The order is the point, not an optimisation. A local policy consulted after the decider would
97
+ * still block a boundary command, but only by waiting out a 50-second deadline and reporting
98
+ * `permission-decision-unavailable` — an outage, which is not what happened. Consulted first, an
99
+ * unreachable controller is never asked at all and the refusal is immediate, local and named.
100
+ * That is the difference between "refused" and "deferred, and eventually nothing", and it is the
101
+ * property somebody deciding whether to install this package actually checks.
102
+ *
103
+ * It returns a refusal rather than a decision, and that is the audit contract. A `deny` decision
104
+ * is recorded `control/permission_denied` — the same cause a controller's deny produces — so a
105
+ * locally-decided refusal expressed that way would be indistinguishable from a remote one except
106
+ * by free text nobody branches on. A `Refusal` becomes `refusal/<reason>` on the transition, which
107
+ * separates the three cases that must never blur: somebody decided, this host decided, nobody
108
+ * decided.
109
+ */
110
+ readonly localGate?: LocalGate;
111
+ /**
112
+ * How long the host waits for a decision before blocking and saying so. Milliseconds.
113
+ *
114
+ * Kept meaningfully below `matcherTimeoutSeconds` so this handler is the one that expires. If the
115
+ * matcher's timeout fired first the tool would still be blocked — the CLI is fail-closed — but
116
+ * nothing would be recorded, and a block nobody can explain reads as a hang. Enforced at
117
+ * construction: `permissionHooks` refuses a pair where this does not expire first.
118
+ */
119
+ readonly decisionTimeoutMs?: number;
120
+ /** After this long with no answer, the call is reported as held. Milliseconds. */
121
+ readonly holdAfterMs?: number;
122
+ /** Handed to the CLI as the matcher's own timeout. Seconds — the SDK's unit, not this package's. */
123
+ readonly matcherTimeoutSeconds?: number;
124
+ /**
125
+ * Make an allow effective, rather than silent. Defaults to false.
126
+ *
127
+ * Off by default, and on is a decision with a named cost. An effective allow skips the
128
+ * permission mode, the allow rules and `canUseTool`. With no settings
129
+ * files loaded, the only one of those with anything to say is the agent's own permission mode,
130
+ * which has nobody to answer it, so skipping it is the whole point.
131
+ *
132
+ * It does not skip operator deny or ask rules; the documentation states those are evaluated
133
+ * whatever a hook returns (see this module's header for the source and for why that is documented
134
+ * rather than measured). The `composeSession` refusal on this flag plus `settingSources` therefore
135
+ * guards two authorities with no stated precedence, not a bypass.
136
+ *
137
+ * An embedder who composes by hand and leaves this off gets a gate that cannot say yes: the tool
138
+ * simply does not run and the agent reports a permission it was never going to be granted. That
139
+ * case raises `gate-cannot-grant` through `onDegrade` on the first allow that does not take
140
+ * effect.
141
+ */
142
+ readonly grantOnAllow?: boolean;
143
+ /**
144
+ * A named degrade, raised at most once per gate.
145
+ *
146
+ * A degrade is a named outcome, not a comment. The `grantOnAllow`-off residual is raised where an
147
+ * embedder hits it — a running session in which every approved tool call silently fails to
148
+ * happen — rather than only documented where an installer reads. Optional: an embedder who does
149
+ * not pass it gets exactly the previous behaviour, so this adds an observation and never a
150
+ * requirement.
151
+ *
152
+ * `name` is a `RefusalReason`, not a free string — so a degrade cannot be invented at the call
153
+ * site. Adding one means declaring it in `core/refusal.ts` beside every other named outcome, which
154
+ * is what keeps the vocabulary a vocabulary.
155
+ */
156
+ readonly onDegrade?: (degrade: { readonly name: RefusalReason; readonly detail: string }) => void;
157
+ }
158
+
159
+ const DEFAULT_MATCHER_TIMEOUT_SECONDS = 60;
160
+ const DEFAULT_DECISION_TIMEOUT_MS = 50_000;
161
+
162
+ /**
163
+ * The two-deadline invariant, as one declaration with two consumers.
164
+ *
165
+ * It is a function rather than a repeated `if` because two enforcement points for one rule drift
166
+ * apart: `permissionHooks` throws on an inverted pair for an embedder, and `composeSession` must
167
+ * refuse on one for a controller. So the rule lives here and both read it.
168
+ *
169
+ * Returns the explanation when the pair is invalid, or null when it is fine. The caller decides
170
+ * whether that becomes a throw or a named refusal — which is the only thing the two sites disagree
171
+ * about, and it is a decision about audience rather than about the rule.
172
+ */
173
+ export function deadlineOrderRefusal(
174
+ decisionTimeoutMs: number | undefined,
175
+ matcherTimeoutSeconds: number | undefined,
176
+ ): string | null {
177
+ const decision = decisionTimeoutMs ?? DEFAULT_DECISION_TIMEOUT_MS;
178
+ const matcher = matcherTimeoutSeconds ?? DEFAULT_MATCHER_TIMEOUT_SECONDS;
179
+ if (decision < matcher * 1000) return null;
180
+ return (
181
+ `decisionTimeoutMs (${decision}ms) must be below matcherTimeoutSeconds ` +
182
+ `(${matcher}s = ${matcher * 1000}ms). The host's own deadline must expire first: if the matcher ` +
183
+ 'expires first the tool is still blocked, but nothing is recorded and the block reads as a hang — ' +
184
+ 'the exact failure this gate exists to avoid.'
185
+ );
186
+ }
187
+ /**
188
+ * 250ms. Below this, opening and closing a permission entry records a session as "waiting" for
189
+ * less time than it takes to read the record — noise in the one signal the entry exists to carry.
190
+ */
191
+ const DEFAULT_HOLD_AFTER_MS = 250;
192
+
193
+ /**
194
+ * The `PreToolUse` registration for a session's gate.
195
+ *
196
+ * Register this after `observationHooks()` — `mergeHooks(observationHooks(…), permissionHooks(…))`.
197
+ * Handlers on one event have their synchronous prologues run in array order and are then awaited
198
+ * concurrently (measured; `mergeHooks`'s own "earlier arguments run first" describes dispatch, not
199
+ * completion). The order is a convention, not a race guard: this gate opens its `permission` entry
200
+ * only from the hold timer (`holdAfterMs`, 250ms by default), after every same-event synchronous
201
+ * prologue has finished — so under either order the observer's `PreToolUse` check runs before any
202
+ * hold entry from this event exists, and cannot close one. Across events the guard is the key, not
203
+ * the timing: the hold entry is keyed by tool_use_id (`gate/outcome.ts`), so a later `PreToolUse`
204
+ * for the same tool — whose observer branch closes name-keyed permission entries — cannot close a
205
+ * hold whose decision is still outstanding.
206
+ *
207
+ * No `matcher` is set. A matcher filters by tool name, and every tool call must reach the gate.
208
+ */
209
+ export function permissionHooks(options: PermissionGateOptions): HookRegistrations {
210
+ const decisionTimeoutMs = options.decisionTimeoutMs ?? DEFAULT_DECISION_TIMEOUT_MS;
211
+ const holdAfterMs = options.holdAfterMs ?? DEFAULT_HOLD_AFTER_MS;
212
+ const matcherTimeoutSeconds = options.matcherTimeoutSeconds ?? DEFAULT_MATCHER_TIMEOUT_SECONDS;
213
+
214
+ // The two-deadline invariant, enforced where it still can be. A pair where the matcher expires
215
+ // first would still block the tool — the CLI is fail-closed — but this handler would never learn
216
+ // it happened: no outcome, no transition, and a trace showing a `tool` and a `permission` entry
217
+ // opened and never closed. Refused rather than clamped: a silent clamp would alter a value the
218
+ // embedder stated, and this happens before any session exists, so failing fast is safe.
219
+ const inverted = deadlineOrderRefusal(decisionTimeoutMs, matcherTimeoutSeconds);
220
+ if (inverted !== null) throw new Error(inverted);
221
+
222
+ // Per gate, not per call — see the degrade's own note on why it fires once.
223
+ let grantDegradeRaised = false;
224
+
225
+ const emit = (outcome: GateOutcome): void => {
226
+ try {
227
+ options.onOutcome(outcome);
228
+ } catch {
229
+ // A listener that throws must not reach the CLI as a thrown hook, because a thrown hook is an
230
+ // absent hook and the tool would run. Losing one record is bad; losing the refusal is worse.
231
+ }
232
+ };
233
+
234
+ const handler = async (
235
+ input: HookInput,
236
+ _toolUseId: string | undefined,
237
+ hookOptions: { signal: AbortSignal },
238
+ ) => {
239
+ // The hold flag lives here, outside decide(), so the recovery below can tell the truth about
240
+ // whether a hold entry was opened. A hardcoded `held: false` in the catch would leave any
241
+ // opened entry with no close — the invariant would hold while the trace showed a session
242
+ // eternally waiting on a decision that was in fact refused.
243
+ const held = { value: false };
244
+ let outcome: TerminalOutcome;
245
+ try {
246
+ if (input.hook_event_name !== 'PreToolUse') return {};
247
+ outcome = await decide(
248
+ input,
249
+ options.decide,
250
+ emit,
251
+ hookOptions.signal,
252
+ decisionTimeoutMs,
253
+ holdAfterMs,
254
+ held,
255
+ options.localGate,
256
+ options.sessionKey,
257
+ );
258
+ } catch (error) {
259
+ // The fail-open hole, closed. Anything at all that escaped above — a bug in this file, a
260
+ // decider that threw synchronously, a request that could not be read — becomes an explicit
261
+ // refusal rather than an absent hook.
262
+ outcome = {
263
+ kind: 'refused',
264
+ request: readRequest(input, options.sessionKey) ?? unreadableRequest(options.sessionKey),
265
+ held: held.value,
266
+ refusal: refusal('permission-decision-unavailable', `the gate itself failed: ${String(error)}`),
267
+ };
268
+ }
269
+
270
+ emit(outcome);
271
+
272
+ // The silent failure, made audible — once. An allow the CLI will not act on is the shape of
273
+ // this gate's worst outcome: the call is approved, the tool does not run, and the agent is told
274
+ // it lacks a permission nobody was ever going to grant. Raised on the first occurrence only,
275
+ // because a session that hits this hits it on every approved call and a degrade per call would
276
+ // bury the signal it exists to carry.
277
+ if (outcome.kind === 'allow' && !(options.grantOnAllow ?? false) && !grantDegradeRaised) {
278
+ grantDegradeRaised = true;
279
+ try {
280
+ options.onDegrade?.({
281
+ name: 'gate-cannot-grant',
282
+ detail:
283
+ `the gate ALLOWED ${outcome.request.toolName} but grantOnAllow is off, so the allow is silent and the ` +
284
+ `tool will not run — this gate can refuse a call and cannot let one through. Set grantOnAllow, or ` +
285
+ `expect every approved call to fail as an ungranted permission.`,
286
+ });
287
+ } catch {
288
+ // Same reason `emit` swallows: a listener that throws must not reach the CLI as a thrown
289
+ // hook, because a thrown hook is an absent hook and the tool would then run.
290
+ }
291
+ }
292
+
293
+ return outputFor(outcome, options.grantOnAllow ?? false);
294
+ };
295
+
296
+ const matcher: HookCallbackMatcher = {
297
+ hooks: [handler],
298
+ timeout: matcherTimeoutSeconds,
299
+ };
300
+
301
+ return { PreToolUse: [matcher] };
302
+ }
303
+
304
+ // ---------------------------------------------------------------------------
305
+
306
+ /**
307
+ * Ask, with a hold report and a deadline. Every return is a terminal outcome.
308
+ *
309
+ * The decider is started before either timer, so a fast answer never pays for the hold machinery.
310
+ *
311
+ * `held` is the caller's box, not a local: the handler's own catch reads it, so even a throw this
312
+ * function does not anticipate reports whether a hold entry was opened — and the terminal outcome
313
+ * then closes it instead of orphaning it.
314
+ */
315
+ async function decide(
316
+ input: Extract<HookInput, { hook_event_name: 'PreToolUse' }>,
317
+ ask: Decider,
318
+ emit: (outcome: GateOutcome) => void,
319
+ signal: AbortSignal,
320
+ decisionTimeoutMs: number,
321
+ holdAfterMs: number,
322
+ held: { value: boolean },
323
+ localGate: LocalGate | undefined,
324
+ sessionKey: string,
325
+ ): Promise<TerminalOutcome> {
326
+ const request = readRequest(input, sessionKey);
327
+ if (request === null) {
328
+ // The gate cannot say which tool this is, so it cannot authorize it. Blocking is the only
329
+ // answer that does not amount to permitting an unidentified call.
330
+ return {
331
+ kind: 'refused',
332
+ request: unreadableRequest(sessionKey),
333
+ held: false,
334
+ refusal: refusal(
335
+ 'permission-decision-unavailable',
336
+ 'the hook input could not be read, so the call it describes has no name to decide about',
337
+ ),
338
+ };
339
+ }
340
+
341
+ // The host's own gate, before anything is asked of anyone. It is consulted here rather than
342
+ // after the decider because a refusal that waits for an unreachable controller to time out is not
343
+ // a refusal, it is a `decisionTimeoutMs` silence that ends in an outage report — and the property
344
+ // this package states is that a locally refused call is refused immediately, by name.
345
+ const localRefusal = askLocalGate(localGate, request);
346
+ if (localRefusal !== null && localRefusal.reason !== 'shell-boundary-command') {
347
+ // `held: false` is a fact, not a default: nothing has been armed yet, so no entry was opened.
348
+ return { kind: 'refused', request, held: false, refusal: localRefusal };
349
+ }
350
+ // regression: a boundary-shaped shell command (publishing, remote surgery, branch deletion, a PR
351
+ // merge) was refused here, in-process, before the controller was asked, so a controller that
352
+ // holds such a call for a person could never deliver that answer and nothing on this host could
353
+ // publish. A boundary shape is escalated like any other call; when the controller cannot be
354
+ // reached the deadline below refuses it. Every other local refusal (the jail, the credential set,
355
+ // an unrecognised git verb) stays local and immediate.
356
+
357
+ // Both timers are armed inside the try whose finally disarms them. Between arming and the
358
+ // `finally` there is no window a throw can cross with a timer left live — that is a structural
359
+ // property of this block, not an audit of what happens to be between the lines.
360
+ let holdTimer: ReturnType<typeof setTimeout> | undefined;
361
+ let deadlineTimer: ReturnType<typeof setTimeout> | undefined;
362
+ let abortListener: (() => void) | undefined;
363
+
364
+ try {
365
+ const answer = (async (): Promise<unknown> => ask(request, signal))();
366
+ // The promise is consumed by the race below; this keeps a rejection from being unhandled in the
367
+ // window before the race attaches, which node reports as a process-level warning.
368
+ answer.catch(() => undefined);
369
+
370
+ holdTimer = setTimeout(() => {
371
+ held.value = true;
372
+ emit({ kind: 'holding', request });
373
+ }, holdAfterMs);
374
+ holdTimer.unref?.();
375
+
376
+ // The deadline timer is ref'd, and that is the fail-closed guarantee itself.
377
+ //
378
+ // An unref'd timer here is the same bug as a gate that cannot say no. An unref'd timer does
379
+ // not hold the event loop open, so a host with nothing else pending — exactly the state a host
380
+ // is in while it waits for a decision — drains the loop before the deadline fires. The decider
381
+ // never answers, the deadline never fires, and this handler never returns: the tool call has no
382
+ // fate at all, which is strictly worse than either answer.
383
+ //
384
+ // Observed on Linux: `gate.test.ts`'s "a decision that never arrives blocks" — the test that
385
+ // proves this exact property — reported "Promise resolution is still pending but the event
386
+ // loop has already resolved" and cancelled, taking its sibling tests with it. It passed on
387
+ // win32 only because something else in that process happened to keep the loop alive; the
388
+ // guarantee was never unconditional, and it is stated as unconditional.
389
+ //
390
+ // Holding the loop open is the correct cost. A tool call is in flight and undecided; a host
391
+ // that exits underneath it has answered nothing. The bound is `decisionTimeoutMs`, and the
392
+ // `finally` below clears the timer on every exit path, so nothing outlives the call. The
393
+ // hold timer stays unref'd — it only emits an observation, and this deadline keeps the loop
394
+ // alive for both.
395
+ const deadline = new Promise<'expired'>((resolve) => {
396
+ deadlineTimer = setTimeout(() => resolve('expired'), decisionTimeoutMs);
397
+ });
398
+
399
+ // A missing or hostile signal cannot escape: a throw inside a Promise executor rejects the
400
+ // promise, and the race below carries that rejection into this function's own catch.
401
+ const aborted = new Promise<'aborted'>((resolve) => {
402
+ if (signal.aborted) resolve('aborted');
403
+ else {
404
+ // Held so the `finally` can detach it. `{once: true}` self-removes only when the event
405
+ // fires, and the ordinary case is that it never does — so on a signal that outlives one tool
406
+ // call, every call would leave a listener behind on it.
407
+ abortListener = (): void => resolve('aborted');
408
+ signal.addEventListener('abort', abortListener, { once: true });
409
+ }
410
+ });
411
+
412
+ const settled = await Promise.race([
413
+ answer.then((value) => ({ answered: value }) as const),
414
+ deadline,
415
+ aborted,
416
+ ]);
417
+
418
+ if (settled === 'expired') {
419
+ return {
420
+ kind: 'expired',
421
+ request,
422
+ held: held.value,
423
+ detail: `no decision for ${request.toolName} within ${decisionTimeoutMs}ms; the tool did not run`,
424
+ };
425
+ }
426
+
427
+ if (settled === 'aborted') {
428
+ return {
429
+ kind: 'refused',
430
+ request,
431
+ held: held.value,
432
+ refusal: refusal(
433
+ 'permission-decision-unavailable',
434
+ `the turn was cancelled while a decision for ${request.toolName} was outstanding`,
435
+ ),
436
+ };
437
+ }
438
+
439
+ return terminal(request, held.value, settled.answered);
440
+ } catch (error) {
441
+ // The decider rejected. An outage — nobody decided — and never a denial, which is why it is a
442
+ // refusal reason rather than a deny with a borrowed message.
443
+ const detail =
444
+ error instanceof EscalationUnavailable
445
+ ? error.message
446
+ : `the decision for ${request.toolName} could not be obtained: ${String(error)}`;
447
+ return {
448
+ kind: 'refused',
449
+ request,
450
+ held: held.value,
451
+ refusal: refusal('permission-decision-unavailable', detail),
452
+ };
453
+ } finally {
454
+ // Both timers are cleared however this returned. A hook handler runs once per tool call, so a
455
+ // timer left armed here is a slow leak that only shows up on a long session. The abort listener
456
+ // is detached for the same reason and in the same place — one exit, three things released.
457
+ if (holdTimer !== undefined) clearTimeout(holdTimer);
458
+ if (deadlineTimer !== undefined) clearTimeout(deadlineTimer);
459
+ if (abortListener !== undefined) {
460
+ // A signal that does not implement removal must not turn a resolved decision into a thrown
461
+ // hook, which the CLI reads as an absent one.
462
+ try {
463
+ signal.removeEventListener('abort', abortListener);
464
+ } catch {
465
+ // Nothing to recover: the listener resolves a promise nobody is waiting on any more.
466
+ }
467
+ }
468
+ }
469
+ }
470
+
471
+ /**
472
+ * Ask the host's own gate, if there is one.
473
+ *
474
+ * A local gate that throws refuses. It must not fall through to the decider, and the wrong
475
+ * implementation here does not look wrong: falling through is not fail-open — the controller is
476
+ * still asked — so nothing would break in a test and nothing would show in a trace. What it would do
477
+ * is silently convert a local refusal into a remote question, which is the offline property
478
+ * evaporating at the exact moment the controller is unreachable. The invariant is absolute: any
479
+ * error, any outage, any timeout means the tool does not run.
480
+ */
481
+ function askLocalGate(localGate: LocalGate | undefined, request: DecisionRequest): Refusal | null {
482
+ if (localGate === undefined) return null;
483
+ try {
484
+ return localGate(request) ?? null;
485
+ } catch (error) {
486
+ return refusal(
487
+ 'permission-decision-unavailable',
488
+ `the host's own gate failed while deciding ${request.toolName}, so the call is refused rather than escalated: ${String(error)}`,
489
+ );
490
+ }
491
+ }
492
+
493
+ /** A settled answer, read. The unknown-decision rule lands here. */
494
+ function terminal(request: DecisionRequest, held: boolean, answered: unknown): TerminalOutcome {
495
+ const reading = readDecision(answered);
496
+
497
+ if (!reading.recognised) {
498
+ return {
499
+ kind: 'refused',
500
+ request,
501
+ held,
502
+ refusal: refusal(
503
+ 'permission-decision-unrecognised',
504
+ // The raw payload travels. A host that drops what it did not understand makes a
505
+ // controller-side bug invisible on the only side that could have seen it.
506
+ `the decision for ${request.toolName} was not one this build understands: ${reading.raw}`,
507
+ ),
508
+ };
509
+ }
510
+
511
+ if (reading.decision.behavior === 'deny') {
512
+ return { kind: 'deny', request, held, message: reading.decision.message };
513
+ }
514
+
515
+ const { updatedInput } = reading.decision;
516
+ return { kind: 'allow', request, held, ...(updatedInput === undefined ? {} : { updatedInput }) };
517
+ }
518
+
519
+ /** Everything unknown, for an input that could not be read at all. Never a thrown alternative. */
520
+ function unreadableRequest(sessionKey: string): DecisionRequest {
521
+ return {
522
+ toolName: '(unnamed tool)',
523
+ toolUseId: '(no tool_use_id)',
524
+ toolInput: null,
525
+ sessionId: '',
526
+ // The one field that is still true when nothing else is. Everything above is a placeholder for
527
+ // an input this code could not read; the controller handle comes from the host, not from that
528
+ // input, so an unreadable call is still attributable to the session it happened in. That is the
529
+ // difference between a refusal a controller can file and one it can only count.
530
+ sessionKey,
531
+ cwd: '',
532
+ agentId: null,
533
+ agentType: null,
534
+ };
535
+ }
536
+
537
+ /**
538
+ * The hook's input in this package's terms, or null if it could not be read at all.
539
+ *
540
+ * The recovery path must not use the thing that broke. This is called from the handler's own
541
+ * `catch`, so if reading the input is what threw, an unguarded read here throws a second time —
542
+ * inside the catch — and the whole handler escapes as a thrown hook, which the CLI treats as
543
+ * absent. A fail-closed wrapper whose recovery path can fail is fail-open, and it looks completely
544
+ * correct.
545
+ *
546
+ * Null rather than a filled-in default, because the two are not the same answer. An input this
547
+ * code cannot read names a tool it cannot name, and asking a decider to authorize "(unnamed tool)"
548
+ * would get a decision about nothing. An unnameable call is not an authorizable one.
549
+ */
550
+ function readRequest(input: HookInput, sessionKey: string): DecisionRequest | null {
551
+ try {
552
+ const anyInput = input as Partial<Record<string, unknown>>;
553
+ const text = (value: unknown): string | null => (typeof value === 'string' ? value : null);
554
+ return {
555
+ toolName: text(anyInput['tool_name']) ?? '(unnamed tool)',
556
+ toolUseId: text(anyInput['tool_use_id']) ?? '(no tool_use_id)',
557
+ toolInput: anyInput['tool_input'],
558
+ sessionId: text(anyInput['session_id']) ?? '',
559
+ // Never read from the input: the agent does not know what its controller calls this session.
560
+ sessionKey,
561
+ cwd: text(anyInput['cwd']) ?? '',
562
+ // `agent_id` is the discriminator, not `agent_type`: the type is also present on the main
563
+ // thread of a session started with --agent, so reading it alone calls a main-thread call a
564
+ // subagent one.
565
+ agentId: text(anyInput['agent_id']),
566
+ agentType: text(anyInput['agent_type']),
567
+ };
568
+ } catch {
569
+ return null;
570
+ }
571
+ }
572
+
573
+ /**
574
+ * What the CLI is told.
575
+ *
576
+ * Every blocking outcome carries a reason that names which rule fired — a degrade is a named
577
+ * outcome, and the model receives this string verbatim as an `is_error` tool result, so it is the
578
+ * only explanation anyone downstream gets.
579
+ *
580
+ * `holding` is excluded by the type, not by a branch. A hold is emitted while a decision is
581
+ * outstanding; it is never a terminal outcome, so it can never be what this function renders. A
582
+ * branch returning `{}` for it — no opinion, which the CLI reads as allow — would be the one branch
583
+ * that could open the door while looking most harmless. Narrowing the parameter makes that state
584
+ * unrepresentable: the compiler refuses it, where a safer return value would only have waited to
585
+ * be simplified back.
586
+ */
587
+ function outputFor(
588
+ outcome: Exclude<GateOutcome, { kind: 'holding' }>,
589
+ grantOnAllow: boolean,
590
+ ): HookJSONOutput {
591
+ if (outcome.kind === 'allow') {
592
+ if (grantOnAllow) {
593
+ // The gate's own decision, made effective. It grants this call and nothing else: the decision
594
+ // was already taken above, and this only stops it from being silently discarded.
595
+ return {
596
+ hookSpecificOutput: {
597
+ hookEventName: 'PreToolUse',
598
+ permissionDecision: 'allow',
599
+ permissionDecisionReason: `the host's gate allowed ${outcome.request.toolName}`,
600
+ ...(outcome.updatedInput === undefined ? {} : { updatedInput: outcome.updatedInput }),
601
+ },
602
+ };
603
+ }
604
+
605
+ if (outcome.updatedInput === undefined) return {};
606
+ // No `permissionDecision`. See this file's header: an explicit allow skips permission mode,
607
+ // allow rules and `canUseTool` (steps 4-6; operator deny and ask rules still run), so without
608
+ // `grantOnAllow` this gate only ever adds a refusal.
609
+ return {
610
+ hookSpecificOutput: {
611
+ hookEventName: 'PreToolUse',
612
+ updatedInput: outcome.updatedInput,
613
+ },
614
+ };
615
+ }
616
+
617
+ const reason =
618
+ outcome.kind === 'deny'
619
+ ? outcome.message
620
+ : outcome.kind === 'refused'
621
+ ? `${outcome.refusal.reason}: ${outcome.refusal.detail}`
622
+ : // One spelling for one concept: the same name the transition carries, so a reader who
623
+ // looks this prefix up finds it.
624
+ `${HOOK_TIMEOUT_EVENT}: ${outcome.detail}`;
625
+
626
+ return {
627
+ hookSpecificOutput: {
628
+ hookEventName: 'PreToolUse',
629
+ permissionDecision: 'deny',
630
+ permissionDecisionReason: reason,
631
+ },
632
+ };
633
+ }
634
+
635
+ export { describeRaw };