@naswerks/periscope 1.0.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.
Files changed (230) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/LICENSE +21 -0
  3. package/README.md +146 -0
  4. package/SECURITY.md +315 -0
  5. package/dist/bin/command.d.ts +97 -0
  6. package/dist/bin/command.js +141 -0
  7. package/dist/bin/config.d.ts +7 -0
  8. package/dist/bin/config.js +90 -0
  9. package/dist/bin/login.d.ts +48 -0
  10. package/dist/bin/login.js +82 -0
  11. package/dist/bin/main.d.ts +33 -0
  12. package/dist/bin/main.js +173 -0
  13. package/dist/bin/pair.d.ts +28 -0
  14. package/dist/bin/pair.js +198 -0
  15. package/dist/bin/periscope.d.ts +3 -0
  16. package/dist/bin/periscope.js +4 -0
  17. package/dist/bin/posture.d.ts +64 -0
  18. package/dist/bin/posture.js +157 -0
  19. package/dist/bin/reconfigure.d.ts +64 -0
  20. package/dist/bin/reconfigure.js +195 -0
  21. package/dist/bin/serve.d.ts +103 -0
  22. package/dist/bin/serve.js +441 -0
  23. package/dist/bin/status.d.ts +11 -0
  24. package/dist/bin/status.js +49 -0
  25. package/dist/bin/workspaces.d.ts +109 -0
  26. package/dist/bin/workspaces.js +215 -0
  27. package/dist/control/backoff.d.ts +25 -0
  28. package/dist/control/backoff.js +20 -0
  29. package/dist/control/codec.d.ts +15 -0
  30. package/dist/control/codec.js +501 -0
  31. package/dist/control/credential.d.ts +23 -0
  32. package/dist/control/credential.js +8 -0
  33. package/dist/control/frames.d.ts +1180 -0
  34. package/dist/control/frames.js +441 -0
  35. package/dist/control/link-state.d.ts +32 -0
  36. package/dist/control/link-state.js +39 -0
  37. package/dist/control/link.d.ts +109 -0
  38. package/dist/control/link.js +736 -0
  39. package/dist/control/queue.d.ts +110 -0
  40. package/dist/control/queue.js +174 -0
  41. package/dist/control/seq.d.ts +49 -0
  42. package/dist/control/seq.js +52 -0
  43. package/dist/control/stream-routing.d.ts +200 -0
  44. package/dist/control/stream-routing.js +167 -0
  45. package/dist/control/stream.d.ts +43 -0
  46. package/dist/control/stream.js +64 -0
  47. package/dist/core/async-queue.d.ts +26 -0
  48. package/dist/core/async-queue.js +63 -0
  49. package/dist/core/failure.d.ts +22 -0
  50. package/dist/core/failure.js +53 -0
  51. package/dist/core/index.d.ts +18 -0
  52. package/dist/core/index.js +7 -0
  53. package/dist/core/keyed-turns.d.ts +28 -0
  54. package/dist/core/keyed-turns.js +44 -0
  55. package/dist/core/paths.d.ts +25 -0
  56. package/dist/core/paths.js +77 -0
  57. package/dist/core/refusal.d.ts +43 -0
  58. package/dist/core/refusal.js +399 -0
  59. package/dist/core/result.d.ts +22 -0
  60. package/dist/core/result.js +16 -0
  61. package/dist/core/time.d.ts +19 -0
  62. package/dist/core/time.js +25 -0
  63. package/dist/core/vocab.d.ts +23 -0
  64. package/dist/core/vocab.js +37 -0
  65. package/dist/core/workspace-id.d.ts +12 -0
  66. package/dist/core/workspace-id.js +12 -0
  67. package/dist/gate/command.d.ts +136 -0
  68. package/dist/gate/command.js +551 -0
  69. package/dist/gate/decision.d.ts +111 -0
  70. package/dist/gate/decision.js +97 -0
  71. package/dist/gate/escalate.d.ts +82 -0
  72. package/dist/gate/escalate.js +92 -0
  73. package/dist/gate/gate.d.ts +173 -0
  74. package/dist/gate/gate.js +417 -0
  75. package/dist/gate/index.d.ts +39 -0
  76. package/dist/gate/index.js +31 -0
  77. package/dist/gate/jail.d.ts +92 -0
  78. package/dist/gate/jail.js +131 -0
  79. package/dist/gate/local.d.ts +69 -0
  80. package/dist/gate/local.js +51 -0
  81. package/dist/gate/outcome.d.ts +72 -0
  82. package/dist/gate/outcome.js +91 -0
  83. package/dist/gate/shell.d.ts +36 -0
  84. package/dist/gate/shell.js +226 -0
  85. package/dist/host/agent-process.d.ts +452 -0
  86. package/dist/host/agent-process.js +382 -0
  87. package/dist/host/bulk-post.d.ts +44 -0
  88. package/dist/host/bulk-post.js +114 -0
  89. package/dist/host/claude-transcripts.d.ts +84 -0
  90. package/dist/host/claude-transcripts.js +339 -0
  91. package/dist/host/config-file.d.ts +60 -0
  92. package/dist/host/config-file.js +216 -0
  93. package/dist/host/git-facts.d.ts +7 -0
  94. package/dist/host/git-facts.js +106 -0
  95. package/dist/host/hooks.d.ts +56 -0
  96. package/dist/host/hooks.js +75 -0
  97. package/dist/host/host.d.ts +356 -0
  98. package/dist/host/host.js +1294 -0
  99. package/dist/host/index.d.ts +35 -0
  100. package/dist/host/index.js +22 -0
  101. package/dist/host/link-state-file.d.ts +18 -0
  102. package/dist/host/link-state-file.js +66 -0
  103. package/dist/host/loopback.d.ts +38 -0
  104. package/dist/host/loopback.js +122 -0
  105. package/dist/host/machine.d.ts +9 -0
  106. package/dist/host/machine.js +19 -0
  107. package/dist/host/mcp-server.d.ts +11 -0
  108. package/dist/host/mcp-server.js +48 -0
  109. package/dist/host/package-facts.d.ts +3 -0
  110. package/dist/host/package-facts.js +26 -0
  111. package/dist/host/paired-credential-store.d.ts +32 -0
  112. package/dist/host/paired-credential-store.js +112 -0
  113. package/dist/host/paths.d.ts +80 -0
  114. package/dist/host/paths.js +165 -0
  115. package/dist/host/repository-read.d.ts +33 -0
  116. package/dist/host/repository-read.js +201 -0
  117. package/dist/host/session-store.d.ts +53 -0
  118. package/dist/host/session-store.js +100 -0
  119. package/dist/host/sign-in.d.ts +90 -0
  120. package/dist/host/sign-in.js +239 -0
  121. package/dist/host/telemetry.d.ts +42 -0
  122. package/dist/host/telemetry.js +74 -0
  123. package/dist/host/token-cache.d.ts +62 -0
  124. package/dist/host/token-cache.js +185 -0
  125. package/dist/host/transcript-fs.d.ts +4 -0
  126. package/dist/host/transcript-fs.js +104 -0
  127. package/dist/host/wire-request.d.ts +80 -0
  128. package/dist/host/wire-request.js +196 -0
  129. package/dist/host/workspace-fs.d.ts +14 -0
  130. package/dist/host/workspace-fs.js +84 -0
  131. package/dist/host/workspace-trust.d.ts +23 -0
  132. package/dist/host/workspace-trust.js +80 -0
  133. package/dist/identity/authorize.d.ts +59 -0
  134. package/dist/identity/authorize.js +72 -0
  135. package/dist/identity/config.d.ts +98 -0
  136. package/dist/identity/config.js +130 -0
  137. package/dist/identity/credential.d.ts +91 -0
  138. package/dist/identity/credential.js +76 -0
  139. package/dist/identity/device-code.d.ts +115 -0
  140. package/dist/identity/device-code.js +134 -0
  141. package/dist/identity/index.d.ts +33 -0
  142. package/dist/identity/index.js +10 -0
  143. package/dist/identity/mode.d.ts +106 -0
  144. package/dist/identity/mode.js +69 -0
  145. package/dist/identity/paired-credential.d.ts +52 -0
  146. package/dist/identity/paired-credential.js +43 -0
  147. package/dist/identity/pkce.d.ts +50 -0
  148. package/dist/identity/pkce.js +94 -0
  149. package/dist/identity/store.d.ts +43 -0
  150. package/dist/identity/store.js +43 -0
  151. package/dist/identity/token.d.ts +58 -0
  152. package/dist/identity/token.js +149 -0
  153. package/dist/index.d.ts +45 -0
  154. package/dist/index.js +53 -0
  155. package/dist/mcp/descriptor.d.ts +59 -0
  156. package/dist/mcp/descriptor.js +14 -0
  157. package/dist/mcp/index.d.ts +14 -0
  158. package/dist/mcp/index.js +4 -0
  159. package/dist/mcp/schema.d.ts +37 -0
  160. package/dist/mcp/schema.js +175 -0
  161. package/dist/mcp/server.d.ts +107 -0
  162. package/dist/mcp/server.js +66 -0
  163. package/dist/persistence/entry.d.ts +74 -0
  164. package/dist/persistence/entry.js +105 -0
  165. package/dist/persistence/index.d.ts +22 -0
  166. package/dist/persistence/index.js +8 -0
  167. package/dist/persistence/key.d.ts +46 -0
  168. package/dist/persistence/key.js +33 -0
  169. package/dist/persistence/mirror.d.ts +71 -0
  170. package/dist/persistence/mirror.js +57 -0
  171. package/dist/persistence/receipt.d.ts +111 -0
  172. package/dist/persistence/receipt.js +85 -0
  173. package/dist/persistence/retention.d.ts +68 -0
  174. package/dist/persistence/retention.js +68 -0
  175. package/dist/persistence/store.d.ts +106 -0
  176. package/dist/persistence/store.js +86 -0
  177. package/dist/persistence/transition-log.d.ts +73 -0
  178. package/dist/persistence/transition-log.js +133 -0
  179. package/dist/protocol.d.ts +27 -0
  180. package/dist/protocol.js +12 -0
  181. package/dist/sessions/index.d.ts +8 -0
  182. package/dist/sessions/index.js +4 -0
  183. package/dist/sessions/registry.d.ts +186 -0
  184. package/dist/sessions/registry.js +190 -0
  185. package/dist/sessions/session.d.ts +178 -0
  186. package/dist/sessions/session.js +288 -0
  187. package/dist/sessions/spawn-env.d.ts +88 -0
  188. package/dist/sessions/spawn-env.js +277 -0
  189. package/dist/state/coverage.d.ts +308 -0
  190. package/dist/state/coverage.js +315 -0
  191. package/dist/state/index.d.ts +12 -0
  192. package/dist/state/index.js +7 -0
  193. package/dist/state/machine.d.ts +112 -0
  194. package/dist/state/machine.js +237 -0
  195. package/dist/state/model.d.ts +233 -0
  196. package/dist/state/model.js +214 -0
  197. package/dist/state/observer.d.ts +55 -0
  198. package/dist/state/observer.js +413 -0
  199. package/dist/state/reporter.d.ts +45 -0
  200. package/dist/state/reporter.js +35 -0
  201. package/dist/state/store.d.ts +56 -0
  202. package/dist/state/store.js +120 -0
  203. package/dist/telemetry/index.d.ts +11 -0
  204. package/dist/telemetry/index.js +2 -0
  205. package/dist/telemetry/usage.d.ts +78 -0
  206. package/dist/telemetry/usage.js +69 -0
  207. package/dist/workspace/git-worktree.d.ts +150 -0
  208. package/dist/workspace/git-worktree.js +417 -0
  209. package/dist/workspace/index.d.ts +9 -0
  210. package/dist/workspace/index.js +4 -0
  211. package/dist/workspace/plain-dir.d.ts +34 -0
  212. package/dist/workspace/plain-dir.js +90 -0
  213. package/dist/workspace/provider.d.ts +152 -0
  214. package/dist/workspace/provider.js +2 -0
  215. package/dist/workspace/worktree-porcelain.d.ts +29 -0
  216. package/dist/workspace/worktree-porcelain.js +100 -0
  217. package/docs/architecture.md +277 -0
  218. package/docs/configuration.md +187 -0
  219. package/docs/gate.md +219 -0
  220. package/docs/identity.md +107 -0
  221. package/docs/protocol.md +501 -0
  222. package/docs/state-machine.md +160 -0
  223. package/examples/README.md +141 -0
  224. package/examples/minimal-controller/controller.ts +38 -0
  225. package/examples/parallel-run-proof/permission-mode-probe.ts +129 -0
  226. package/examples/parallel-run-proof/run.ts +850 -0
  227. package/examples/test-controller/controller.ts +655 -0
  228. package/examples/test-controller/serve.ts +29 -0
  229. package/examples/tsconfig.json +30 -0
  230. package/package.json +98 -0
@@ -0,0 +1,111 @@
1
+ /**
2
+ * What a decision about a tool call is, and how an unrecognised one is read.
3
+ *
4
+ * The shape is the SDK's `PermissionResult`, field for field. Not a shape of this package's own
5
+ * that happens to mean the same thing: the host's own local gate returns the same type, and a
6
+ * `canUseTool` implementation would return it directly, so a second spelling would need a
7
+ * translation in the one place a translation buys nothing. The SDK's word wins, exactly.
8
+ *
9
+ * The SDK documentation page names `PermissionResultDeny`, which the shipped `sdk.d.ts` does not
10
+ * declare (`grep -c PermissionResultDeny` returns 0). The docs page additionally states
11
+ * `{allow: false, reason}`, which does not exist either. Anything written that way fails to
12
+ * compile or, worse, type-widens.
13
+ *
14
+ * A decider returns `unknown`, deliberately. A decision arriving from a controller was never seen
15
+ * by this build's compiler — it is a value off a wire, from a peer that may be newer than this host.
16
+ * Typing the decider as returning a `Decision` would make the compiler assert a fact only the
17
+ * runtime can establish, and the unknown-decision rule below exists precisely because that fact is
18
+ * sometimes false. So the type says `unknown` and `readDecision` is the total function that narrows
19
+ * it — the honesty is in the signature, not in a comment.
20
+ */
21
+ /**
22
+ * What a decision about one tool call says.
23
+ *
24
+ * `allow` may rewrite the call's arguments through `updatedInput`. The model is never told that
25
+ * happened — observed on a real session — so a rewrite is invisible from the transcript's side.
26
+ */
27
+ export type Decision = {
28
+ readonly behavior: 'allow';
29
+ readonly updatedInput?: Record<string, unknown>;
30
+ } | {
31
+ readonly behavior: 'deny';
32
+ readonly message: string;
33
+ readonly interrupt?: boolean;
34
+ };
35
+ /** What a decider is asked about. Everything the hook knows, in this package's own terms. */
36
+ export interface DecisionRequest {
37
+ readonly toolName: string;
38
+ readonly toolUseId: string;
39
+ readonly toolInput: unknown;
40
+ /**
41
+ * The agent's own session id, read off the hook input. Not the controller's handle.
42
+ *
43
+ * Read `sessionKey` below before using this to correlate anything. This value is minted by the
44
+ * agent at `system/init`; every frame on the link is keyed by the controller's handle instead. So
45
+ * a decision request identified only by this arrives naming an id the controller may never have
46
+ * seen — a session that dies during start-up never mints one at all, and the first transitions of
47
+ * every session carry `null` here. Correlating on it is a correlation that silently fails exactly
48
+ * when the session is in trouble, which is when a decision matters most.
49
+ */
50
+ readonly sessionId: string;
51
+ /**
52
+ * The controller's handle — the same string every wire frame for this session is keyed by.
53
+ *
54
+ * This is the field to correlate on, and it exists because nothing else here could. The gate is
55
+ * the second transport: it is an ordinary HTTP POST to a URL nothing on the wire announces, so the
56
+ * body is the only place the two transports can be tied together. Without it a controller has to
57
+ * build an agent-id index and answer non-2xx for any id it has not seen yet.
58
+ *
59
+ * The host supplies it; it is never read from the agent's input, because the agent has no idea
60
+ * what its controller calls it.
61
+ */
62
+ readonly sessionKey: string;
63
+ readonly cwd: string;
64
+ /**
65
+ * The subagent this call came from, or null on the main thread.
66
+ *
67
+ * `agent_id` is the field that distinguishes a subagent call — `agent_type` is also present on the
68
+ * main thread of a session started with `--agent`, so branching on the type alone would read a
69
+ * main-thread call as a subagent one.
70
+ */
71
+ readonly agentId: string | null;
72
+ readonly agentType: string | null;
73
+ }
74
+ /**
75
+ * Answers one tool call. Returns `unknown` — see this file's header.
76
+ *
77
+ * The `signal` aborts when the surrounding turn is cancelled. A decider that reaches a controller
78
+ * passes it through so a cancelled turn does not leave a request in flight.
79
+ */
80
+ export type Decider = (request: DecisionRequest, signal: AbortSignal) => Promise<unknown>;
81
+ /**
82
+ * A decision read from an untrusted value: either one this build understands, or the raw payload.
83
+ *
84
+ * The raw payload is preserved rather than discarded. A host that silently drops what it did not
85
+ * understand makes a controller-side bug invisible on the only side that could have seen it — the
86
+ * controller believes it answered, the tool is blocked, and nothing anywhere says why.
87
+ */
88
+ export type DecisionReading = {
89
+ readonly recognised: true;
90
+ readonly decision: Decision;
91
+ } | {
92
+ readonly recognised: false;
93
+ readonly raw: string;
94
+ };
95
+ /** The raw value as one line, bounded and marked. Never throws — a cyclic value is still evidence. */
96
+ export declare function describeRaw(raw: unknown): string;
97
+ /**
98
+ * Read an untrusted value as a decision. Total: every input produces a reading.
99
+ *
100
+ * A value this build has never seen is never an allow. A controller running ahead of a host, a
101
+ * decision tier added later, a rolled-back deploy — each produces a `behavior` this code does not
102
+ * know, and the only safe reading of "I do not understand your answer" is that no answer was given.
103
+ * Borrowed from the Agent Client Protocol's `RequestPermissionOutcome::Other`, whose rule is that an
104
+ * agent which does not understand an outcome MUST NOT treat it as approval.
105
+ *
106
+ * It is refused here, by the gate, rather than at the transport. A strict codec could reject the
107
+ * frame instead — but then the gate never runs, and the tool's fate depends on a parse error rather
108
+ * than on a policy act with a receipt.
109
+ */
110
+ export declare function readDecision(raw: unknown): DecisionReading;
111
+ //# sourceMappingURL=decision.d.ts.map
@@ -0,0 +1,97 @@
1
+ /**
2
+ * What a decision about a tool call is, and how an unrecognised one is read.
3
+ *
4
+ * The shape is the SDK's `PermissionResult`, field for field. Not a shape of this package's own
5
+ * that happens to mean the same thing: the host's own local gate returns the same type, and a
6
+ * `canUseTool` implementation would return it directly, so a second spelling would need a
7
+ * translation in the one place a translation buys nothing. The SDK's word wins, exactly.
8
+ *
9
+ * The SDK documentation page names `PermissionResultDeny`, which the shipped `sdk.d.ts` does not
10
+ * declare (`grep -c PermissionResultDeny` returns 0). The docs page additionally states
11
+ * `{allow: false, reason}`, which does not exist either. Anything written that way fails to
12
+ * compile or, worse, type-widens.
13
+ *
14
+ * A decider returns `unknown`, deliberately. A decision arriving from a controller was never seen
15
+ * by this build's compiler — it is a value off a wire, from a peer that may be newer than this host.
16
+ * Typing the decider as returning a `Decision` would make the compiler assert a fact only the
17
+ * runtime can establish, and the unknown-decision rule below exists precisely because that fact is
18
+ * sometimes false. So the type says `unknown` and `readDecision` is the total function that narrows
19
+ * it — the honesty is in the signature, not in a comment.
20
+ */
21
+ /**
22
+ * How much of an unrecognised payload is carried into the trace.
23
+ *
24
+ * Bounded because the value goes into a transition that is retained and may cross the wire, and an
25
+ * unbounded field there is a way for a peer to fill this host's memory. Truncation is marked, so a
26
+ * reader can tell a short payload from a clipped one.
27
+ */
28
+ const RAW_PAYLOAD_LIMIT = 512;
29
+ /** The raw value as one line, bounded and marked. Never throws — a cyclic value is still evidence. */
30
+ export function describeRaw(raw) {
31
+ let text;
32
+ try {
33
+ text = raw === undefined ? 'undefined' : JSON.stringify(raw);
34
+ if (text === undefined)
35
+ text = String(raw);
36
+ }
37
+ catch {
38
+ // A cyclic or otherwise unserialisable payload still tells the reader its type, which is more
39
+ // than nothing and is the whole point of preserving it.
40
+ text = `[unserialisable ${typeof raw}]`;
41
+ }
42
+ return text.length > RAW_PAYLOAD_LIMIT ? `${text.slice(0, RAW_PAYLOAD_LIMIT)}…[truncated]` : text;
43
+ }
44
+ /**
45
+ * Read an untrusted value as a decision. Total: every input produces a reading.
46
+ *
47
+ * A value this build has never seen is never an allow. A controller running ahead of a host, a
48
+ * decision tier added later, a rolled-back deploy — each produces a `behavior` this code does not
49
+ * know, and the only safe reading of "I do not understand your answer" is that no answer was given.
50
+ * Borrowed from the Agent Client Protocol's `RequestPermissionOutcome::Other`, whose rule is that an
51
+ * agent which does not understand an outcome MUST NOT treat it as approval.
52
+ *
53
+ * It is refused here, by the gate, rather than at the transport. A strict codec could reject the
54
+ * frame instead — but then the gate never runs, and the tool's fate depends on a parse error rather
55
+ * than on a policy act with a receipt.
56
+ */
57
+ export function readDecision(raw) {
58
+ if (typeof raw !== 'object' || raw === null)
59
+ return { recognised: false, raw: describeRaw(raw) };
60
+ const candidate = raw;
61
+ if (candidate.behavior === 'allow') {
62
+ const updatedInput = candidate.updatedInput;
63
+ // An `allow` whose updatedInput is present but not an object is not an allow with the field
64
+ // dropped: it is a decision this build cannot carry out, and carrying out the half it
65
+ // understood would run the tool with arguments nobody approved.
66
+ if (updatedInput === undefined)
67
+ return { recognised: true, decision: { behavior: 'allow' } };
68
+ if (typeof updatedInput !== 'object' || updatedInput === null || Array.isArray(updatedInput)) {
69
+ return { recognised: false, raw: describeRaw(raw) };
70
+ }
71
+ return {
72
+ recognised: true,
73
+ decision: { behavior: 'allow', updatedInput: updatedInput },
74
+ };
75
+ }
76
+ if (candidate.behavior === 'deny') {
77
+ // `message` is required on a deny by the SDK's own type. A deny without one would reach the
78
+ // model as a blocked call with no stated reason, which is the silent refusal this package
79
+ // forbids everywhere else — so it is unrecognised rather than quietly given a default.
80
+ if (typeof candidate.message !== 'string' || candidate.message.length === 0) {
81
+ return { recognised: false, raw: describeRaw(raw) };
82
+ }
83
+ const interrupt = candidate.interrupt;
84
+ if (interrupt !== undefined && typeof interrupt !== 'boolean')
85
+ return { recognised: false, raw: describeRaw(raw) };
86
+ return {
87
+ recognised: true,
88
+ decision: {
89
+ behavior: 'deny',
90
+ message: candidate.message,
91
+ ...(interrupt === undefined ? {} : { interrupt }),
92
+ },
93
+ };
94
+ }
95
+ return { recognised: false, raw: describeRaw(raw) };
96
+ }
97
+ //# sourceMappingURL=decision.js.map
@@ -0,0 +1,82 @@
1
+ import type { ControllerCredential } from '../control/credential.js';
2
+ import type { Decider } from './decision.js';
3
+ /** Just enough of a response to decide on it. `fetch`'s own `Response` satisfies this. */
4
+ export interface EscalationResponse {
5
+ readonly status: number;
6
+ text(): Promise<string>;
7
+ }
8
+ export type EscalationTransport = (url: string, init: {
9
+ readonly method: string;
10
+ readonly headers: Record<string, string>;
11
+ readonly body: string;
12
+ readonly signal: AbortSignal;
13
+ }) => Promise<EscalationResponse>;
14
+ export interface EscalationOptions {
15
+ /** Where the controller answers. Supplied by the embedder; never derived here. */
16
+ readonly url: string;
17
+ readonly transport: EscalationTransport;
18
+ /** Sent verbatim, on every request. For a scheme whose value does not change. */
19
+ readonly headers?: Record<string, string>;
20
+ /**
21
+ * The credential this host presents on each decision request. Resolved per request.
22
+ *
23
+ * Why it is a credential and not a header. The static `headers` above cannot carry a bearer
24
+ * token: a token is refreshed on a schedule this module does not know, so a value captured once
25
+ * would be presented for the life of the host and start failing silently at the first expiry —
26
+ * as an outage, on every tool call, which is the failure shape this whole file is written against.
27
+ * The same seam the link uses is asked again here, and it hands back a live value.
28
+ *
29
+ * Its failure posture is the opposite of the link's, deliberately. The link connects with no
30
+ * headers when its credential refuses — a stated decision, on the ground that a version without
31
+ * identity is better than one pretending to have a scheme. That reasoning does not transfer here.
32
+ * The link carries observations; this endpoint decides whether a tool runs. An unauthenticated
33
+ * request to it is an unauthenticated permission decision, and anyone who can reach the URL can
34
+ * answer for the agent. So a credential that refuses is an outage and the tool does not run — the
35
+ * gate's own invariant, applied to the gate's own transport. Two transports, two postures, and the
36
+ * difference is what each one can be used to do.
37
+ *
38
+ * Absent means no credential is presented, which is the previous behaviour and stays available for
39
+ * an embedder whose endpoint is reachable only from inside its own network.
40
+ *
41
+ * The precondition: a credential that refuses is not a quieter credential — it is an outage on
42
+ * every tool call, so the option must be omitted rather than filled with a placeholder when there
43
+ * is no identity. `bin/periscope.ts` does exactly that, and the composition is the contract.
44
+ *
45
+ * What this package can present is narrower than what a controller may require. `identity/`
46
+ * implements the authorization-code, refresh-token and device-code grants — every one of which
47
+ * mints a delegated user token. There is no client-credentials grant here, so this host cannot
48
+ * obtain an app-only token at all, and `Authorization` carries one header, so it cannot present a
49
+ * second scheme selector alongside the bearer. A controller that admits only a machine app role
50
+ * therefore refuses both postures — a configured host with a user token and an unconfigured host
51
+ * with none — and it refuses them at the door, fail-closed. Nothing in this file grants this host
52
+ * a machine identity, and a reader should not infer from the presence of this option that one
53
+ * exists.
54
+ */
55
+ readonly credential?: ControllerCredential;
56
+ }
57
+ /**
58
+ * Thrown for every way the ask failed, as distinct from a decision that said no.
59
+ *
60
+ * It is an error rather than a returned value because the gate's contract is that the decider
61
+ * returns whatever the controller said; "the controller said nothing" is not something it said.
62
+ * The gate turns this into `permission-decision-unavailable` — an outage, never a denial.
63
+ */
64
+ export declare class EscalationUnavailable extends Error {
65
+ constructor(detail: string);
66
+ }
67
+ /**
68
+ * A decider that asks a controller over HTTP.
69
+ *
70
+ * Returns the parsed body as `unknown`: reading it as a decision is `readDecision`'s job, and a
71
+ * well-formed answer this build does not recognise must reach the gate to be refused by the gate.
72
+ *
73
+ * The two failure kinds are split on purpose, and the split is not obvious.
74
+ * - A non-2xx, a transport error, or a body that is not JSON is an outage. The controller did not
75
+ * answer, or answered something that is not an answer. A controller-side fix.
76
+ * - Valid JSON that is not a decision this build knows is not handled here. It is returned intact
77
+ * and the gate refuses it as unrecognised. A controller-version fix.
78
+ * Malformed JSON is an outage rather than an unrecognised decision because version skew produces a
79
+ * *different* decision, never a broken one.
80
+ */
81
+ export declare function escalatingDecider(options: EscalationOptions): Decider;
82
+ //# sourceMappingURL=escalate.d.ts.map
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Asking a controller. The one place this package makes an outbound decision request.
3
+ *
4
+ * The status is discriminated before the body is read, and that order is the whole file. A client
5
+ * that parses first and branches second turns a 500 carrying a perfectly valid problem-details body
6
+ * into an object with no `allow` field, whose falsy value renders as a refusal somebody made. That
7
+ * is a controller outage impersonating a human decision — nobody had said no, and the trace said
8
+ * somebody had. Reading `status` first makes that shape unwritable.
9
+ *
10
+ * The transport is injected and structural. A minimal `{status, text()}` shape rather than the DOM
11
+ * `Response` type: the global `fetch` satisfies it, a test double satisfies it without a server, and
12
+ * nothing here acquires a dependency on a browser lib for a package that runs on a server.
13
+ */
14
+ import { certificateRemedy, describeFailure, isCertificateRefusal } from '../core/failure.js';
15
+ /**
16
+ * Thrown for every way the ask failed, as distinct from a decision that said no.
17
+ *
18
+ * It is an error rather than a returned value because the gate's contract is that the decider
19
+ * returns whatever the controller said; "the controller said nothing" is not something it said.
20
+ * The gate turns this into `permission-decision-unavailable` — an outage, never a denial.
21
+ */
22
+ export class EscalationUnavailable extends Error {
23
+ constructor(detail) {
24
+ super(detail);
25
+ this.name = 'EscalationUnavailable';
26
+ }
27
+ }
28
+ /**
29
+ * A decider that asks a controller over HTTP.
30
+ *
31
+ * Returns the parsed body as `unknown`: reading it as a decision is `readDecision`'s job, and a
32
+ * well-formed answer this build does not recognise must reach the gate to be refused by the gate.
33
+ *
34
+ * The two failure kinds are split on purpose, and the split is not obvious.
35
+ * - A non-2xx, a transport error, or a body that is not JSON is an outage. The controller did not
36
+ * answer, or answered something that is not an answer. A controller-side fix.
37
+ * - Valid JSON that is not a decision this build knows is not handled here. It is returned intact
38
+ * and the gate refuses it as unrecognised. A controller-version fix.
39
+ * Malformed JSON is an outage rather than an unrecognised decision because version skew produces a
40
+ * *different* decision, never a broken one.
41
+ */
42
+ export function escalatingDecider(options) {
43
+ return async (request, signal) => {
44
+ // ---- The credential, before the request exists. A refusal here never becomes a request: an
45
+ // unauthenticated ask is not a quieter version of an authenticated one, it is a different act.
46
+ const authorization = {};
47
+ if (options.credential !== undefined) {
48
+ const authorized = await options.credential.authorize();
49
+ if (!authorized.ok) {
50
+ throw new EscalationUnavailable(`this host has no credential to present on a decision request for ${request.toolName} ` +
51
+ `(${authorized.refusal.reason}: ${authorized.refusal.detail}). The request was NOT sent: ` +
52
+ `an unauthenticated permission decision is one anybody who can reach the endpoint could ` +
53
+ `answer, so the gate treats this as an outage and the tool does not run`);
54
+ }
55
+ authorization[authorized.value.header] = authorized.value.value;
56
+ }
57
+ let response;
58
+ try {
59
+ response = await options.transport(options.url, {
60
+ method: 'POST',
61
+ headers: { 'content-type': 'application/json', ...(options.headers ?? {}), ...authorization },
62
+ body: JSON.stringify(request),
63
+ signal,
64
+ });
65
+ }
66
+ catch (error) {
67
+ const why = describeFailure(error);
68
+ throw new EscalationUnavailable(isCertificateRefusal(error)
69
+ ? `the decision request to ${options.url} was refused at TLS (${why}). ${certificateRemedy('the decision endpoint')}`
70
+ : `the decision request to ${options.url} did not complete: ${why}`);
71
+ }
72
+ // ---- Status first. Nothing below this line has read the body. ----
73
+ if (response.status < 200 || response.status >= 300) {
74
+ throw new EscalationUnavailable(`the controller answered ${response.status} to a decision request for ${request.toolName}; ` +
75
+ 'the body was not read, because a body that parses is not an answer that was given');
76
+ }
77
+ let body;
78
+ try {
79
+ body = await response.text();
80
+ }
81
+ catch (error) {
82
+ throw new EscalationUnavailable(`the decision response body could not be read: ${String(error)}`);
83
+ }
84
+ try {
85
+ return JSON.parse(body);
86
+ }
87
+ catch {
88
+ throw new EscalationUnavailable(`the controller answered ${response.status} with a body that is not JSON (${body.length} bytes)`);
89
+ }
90
+ };
91
+ }
92
+ //# sourceMappingURL=escalate.js.map
@@ -0,0 +1,173 @@
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 { HookRegistrations } from '../host/agent-process.js';
56
+ import type { RefusalReason } from '../core/refusal.js';
57
+ import type { Decider } from './decision.js';
58
+ import { describeRaw } from './decision.js';
59
+ import type { GateOutcome } from './outcome.js';
60
+ import type { LocalGate } from './local.js';
61
+ /** Told about every outcome — allows, denials, outages, expiries. Never only the interesting ones. */
62
+ export type OutcomeListener = (outcome: GateOutcome) => void;
63
+ export interface PermissionGateOptions {
64
+ readonly decide: Decider;
65
+ readonly onOutcome: OutcomeListener;
66
+ /**
67
+ * The controller's handle for this session — carried onto every decision request.
68
+ *
69
+ * Required rather than optional on purpose: a gate composed without it would send escalations a
70
+ * controller cannot route, and an optional field defaulting to `''` would make that failure
71
+ * silent. Every construction site already has the value. See `DecisionRequest.sessionKey`.
72
+ */
73
+ readonly sessionKey: string;
74
+ /**
75
+ * The host's own gate, consulted before anything is asked of anyone. Optional; absent means the
76
+ * gate behaves exactly as it did without it.
77
+ *
78
+ * The order is the point, not an optimisation. A local policy consulted after the decider would
79
+ * still block a boundary command, but only by waiting out a 50-second deadline and reporting
80
+ * `permission-decision-unavailable` — an outage, which is not what happened. Consulted first, an
81
+ * unreachable controller is never asked at all and the refusal is immediate, local and named.
82
+ * That is the difference between "refused" and "deferred, and eventually nothing", and it is the
83
+ * property somebody deciding whether to install this package actually checks.
84
+ *
85
+ * It returns a refusal rather than a decision, and that is the audit contract. A `deny` decision
86
+ * is recorded `control/permission_denied` — the same cause a controller's deny produces — so a
87
+ * locally-decided refusal expressed that way would be indistinguishable from a remote one except
88
+ * by free text nobody branches on. A `Refusal` becomes `refusal/<reason>` on the transition, which
89
+ * separates the three cases that must never blur: somebody decided, this host decided, nobody
90
+ * decided.
91
+ */
92
+ readonly localGate?: LocalGate;
93
+ /**
94
+ * How long the host waits for a decision before blocking and saying so. Milliseconds.
95
+ *
96
+ * Kept meaningfully below `matcherTimeoutSeconds` so this handler is the one that expires. If the
97
+ * matcher's timeout fired first the tool would still be blocked — the CLI is fail-closed — but
98
+ * nothing would be recorded, and a block nobody can explain reads as a hang. Enforced at
99
+ * construction: `permissionHooks` refuses a pair where this does not expire first.
100
+ */
101
+ readonly decisionTimeoutMs?: number;
102
+ /** After this long with no answer, the call is reported as held. Milliseconds. */
103
+ readonly holdAfterMs?: number;
104
+ /** Handed to the CLI as the matcher's own timeout. Seconds — the SDK's unit, not this package's. */
105
+ readonly matcherTimeoutSeconds?: number;
106
+ /**
107
+ * Make an allow effective, rather than silent. Defaults to false.
108
+ *
109
+ * Off by default, and on is a decision with a named cost. An effective allow skips the
110
+ * permission mode, the allow rules and `canUseTool`. With no settings
111
+ * files loaded, the only one of those with anything to say is the agent's own permission mode,
112
+ * which has nobody to answer it, so skipping it is the whole point.
113
+ *
114
+ * It does not skip operator deny or ask rules; the documentation states those are evaluated
115
+ * whatever a hook returns (see this module's header for the source and for why that is documented
116
+ * rather than measured). The `composeSession` refusal on this flag plus `settingSources` therefore
117
+ * guards two authorities with no stated precedence, not a bypass.
118
+ *
119
+ * An embedder who composes by hand and leaves this off gets a gate that cannot say yes: the tool
120
+ * simply does not run and the agent reports a permission it was never going to be granted. That
121
+ * case raises `gate-cannot-grant` through `onDegrade` on the first allow that does not take
122
+ * effect.
123
+ */
124
+ readonly grantOnAllow?: boolean;
125
+ /**
126
+ * A named degrade, raised at most once per gate.
127
+ *
128
+ * A degrade is a named outcome, not a comment. The `grantOnAllow`-off residual is raised where an
129
+ * embedder hits it — a running session in which every approved tool call silently fails to
130
+ * happen — rather than only documented where an installer reads. Optional: an embedder who does
131
+ * not pass it gets exactly the previous behaviour, so this adds an observation and never a
132
+ * requirement.
133
+ *
134
+ * `name` is a `RefusalReason`, not a free string — so a degrade cannot be invented at the call
135
+ * site. Adding one means declaring it in `core/refusal.ts` beside every other named outcome, which
136
+ * is what keeps the vocabulary a vocabulary.
137
+ */
138
+ readonly onDegrade?: (degrade: {
139
+ readonly name: RefusalReason;
140
+ readonly detail: string;
141
+ }) => void;
142
+ }
143
+ /**
144
+ * The two-deadline invariant, as one declaration with two consumers.
145
+ *
146
+ * It is a function rather than a repeated `if` because two enforcement points for one rule drift
147
+ * apart: `permissionHooks` throws on an inverted pair for an embedder, and `composeSession` must
148
+ * refuse on one for a controller. So the rule lives here and both read it.
149
+ *
150
+ * Returns the explanation when the pair is invalid, or null when it is fine. The caller decides
151
+ * whether that becomes a throw or a named refusal — which is the only thing the two sites disagree
152
+ * about, and it is a decision about audience rather than about the rule.
153
+ */
154
+ export declare function deadlineOrderRefusal(decisionTimeoutMs: number | undefined, matcherTimeoutSeconds: number | undefined): string | null;
155
+ /**
156
+ * The `PreToolUse` registration for a session's gate.
157
+ *
158
+ * Register this after `observationHooks()` — `mergeHooks(observationHooks(…), permissionHooks(…))`.
159
+ * Handlers on one event have their synchronous prologues run in array order and are then awaited
160
+ * concurrently (measured; `mergeHooks`'s own "earlier arguments run first" describes dispatch, not
161
+ * completion). The order is a convention, not a race guard: this gate opens its `permission` entry
162
+ * only from the hold timer (`holdAfterMs`, 250ms by default), after every same-event synchronous
163
+ * prologue has finished — so under either order the observer's `PreToolUse` check runs before any
164
+ * hold entry from this event exists, and cannot close one. Across events the guard is the key, not
165
+ * the timing: the hold entry is keyed by tool_use_id (`gate/outcome.ts`), so a later `PreToolUse`
166
+ * for the same tool — whose observer branch closes name-keyed permission entries — cannot close a
167
+ * hold whose decision is still outstanding.
168
+ *
169
+ * No `matcher` is set. A matcher filters by tool name, and every tool call must reach the gate.
170
+ */
171
+ export declare function permissionHooks(options: PermissionGateOptions): HookRegistrations;
172
+ export { describeRaw };
173
+ //# sourceMappingURL=gate.d.ts.map