@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,53 @@
1
+ /** The PreToolUse hook and the permission decision path. */
2
+ export type { Decider, Decision, DecisionReading, DecisionRequest } from './decision.js';
3
+ export { describeRaw, readDecision } from './decision.js';
4
+
5
+ export type { GateOutcome } from './outcome.js';
6
+ export { gateTransitions, recordGateOutcome } from './outcome.js';
7
+
8
+ export type { OutcomeListener, PermissionGateOptions } from './gate.js';
9
+ /**
10
+ * The PreToolUse gate itself.
11
+ *
12
+ * `composeSession` is a convenience that refuses, not a boundary. `composeSession` declines to
13
+ * assemble `grantOnAllow` together with a non-empty `settingSources`
14
+ * (`permission-grant-shadows-settings`), and it is the only place that can see both facts. But
15
+ * every part it composes is exported, including this one, so four lines reach the refused pair
16
+ * without going near it:
17
+ *
18
+ * ```ts
19
+ * const hooks = mergeHooks(observationHooks({ observer }), permissionHooks({ decide, onOutcome, grantOnAllow: true }));
20
+ * registry.create({ cwd, hooks, settingSources: ['project'] }); // no refusal fires
21
+ * ```
22
+ *
23
+ * This is stated rather than closed, deliberately. Hand-composition is a supported posture — the
24
+ * gate is usable without the host — and un-exporting it would remove a legitimate capability to
25
+ * discourage a combination that, per the SDK's documented evaluation order, does not actually
26
+ * override operator deny rules (see `gate.ts` for the order, and for why that is documented rather
27
+ * than measured). An embedder who assembles the pair on purpose should know they have done it;
28
+ * an embedder who expected `composeSession`'s refusal to be a package-wide guarantee should know
29
+ * it is not.
30
+ */
31
+ export { permissionHooks } from './gate.js';
32
+
33
+ export type { EscalationOptions, EscalationResponse, EscalationTransport } from './escalate.js';
34
+ export { EscalationUnavailable, escalatingDecider } from './escalate.js';
35
+
36
+ // The host's own gate — the offline-provable local refusal.
37
+ export type { LocalGate, LocalGateOptions, ToolFamilies } from './local.js';
38
+ export { DEFAULT_TOOL_FAMILIES, localGate } from './local.js';
39
+
40
+ export type { JailOptions, PathResolver } from './jail.js';
41
+ export { checkPath, checkShellForProtectedPaths, commandFromToolInput, pathFromToolInput } from './jail.js';
42
+
43
+ export { classifyShellCommand } from './shell.js';
44
+
45
+ export type { GlobalFlag, Invocation, ParsedCommand } from './command.js';
46
+ export {
47
+ INTERPRETER_NAMES,
48
+ isInertLiteral,
49
+ parseCommand,
50
+ programNameOf,
51
+ stripComments,
52
+ tokenize,
53
+ } from './command.js';
@@ -0,0 +1,210 @@
1
+ /**
2
+ * The path jail, and the credential denial.
3
+ *
4
+ * The jail bounds where an agent can write at all. It is self-contained: a call whose target
5
+ * resolves outside the declared workspace root is refused without asking anything, so it holds
6
+ * with the controller unreachable, unresponsive, or wrong.
7
+ *
8
+ * The credential denial covers what a default-open read policy misses. Default-open reads are
9
+ * correct for source files and wrong for the host's own token cache. The agent runs as the same
10
+ * OS user as the host, so file permissions are not a boundary against it: an 0600 credential is
11
+ * readable by the agent exactly as it is by the host. This denial is the local control, and it is
12
+ * scoped, not total: it refuses reads through the declared read tools (`Read`, `NotebookRead` by
13
+ * default), writes, and shell commands naming a protected path literally — each by absolute path,
14
+ * with a named refusal. Built-in tools outside the declared families (`Grep`, `Glob`), shell
15
+ * expansion forms (`~`, `$HOME`, `%USERPROFILE%`) and symlink indirection get no opinion here —
16
+ * those calls escalate to the controller, and with it unreachable they are refused as outages
17
+ * rather than by name. Widening the local denial to cover them is a known open question,
18
+ * deliberately not taken in passing: every widening is an over-refusal risk that deserves its own
19
+ * decision.
20
+ *
21
+ * Every unknown resolves toward refusing. No path in the input: refuse. A resolver that throws:
22
+ * refuse. Not absolute: refuse. No declared root: refuse, because a jail with no walls is not a
23
+ * jail. A false refusal costs one human click; a false allow costs the invariant.
24
+ */
25
+ import type { Refusal } from '../core/refusal.js';
26
+ import { refusal } from '../core/refusal.js';
27
+ import { isContainedBy, isAbsolutePath, normalizePath } from '../core/paths.js';
28
+
29
+ /**
30
+ * Turns a path into its canonical absolute form.
31
+ *
32
+ * Injected rather than imported, and `core/paths.ts` says why in its own header: its resolution is
33
+ * textual and deliberately never consults the filesystem, so a caller enforcing a real jail supplies
34
+ * a real resolver. `host/` holds the one built on `node:path`; a test supplies one that throws, which
35
+ * is the only way to exercise the normalization-failure path at all.
36
+ *
37
+ * It may throw. A throw is a refusal, never a fall-through.
38
+ */
39
+ export type PathResolver = (candidate: string) => string;
40
+
41
+ export interface JailOptions {
42
+ /** The absolute root every path-taking call must resolve inside. */
43
+ readonly workspaceRoot: string | null;
44
+ readonly resolve: PathResolver;
45
+ /**
46
+ * Absolute paths the agent may not read, write or name in a shell command.
47
+ *
48
+ * Supplied by the embedder at construction — `host/paths.ts` computes the default set. It is a
49
+ * list rather than a predicate so an embedder can read back exactly what is protected.
50
+ */
51
+ readonly protectedPaths: readonly string[];
52
+ }
53
+
54
+ /** The tool-input fields that carry a path, in precedence order. First readable one wins. */
55
+ const PATH_FIELDS = ['file_path', 'notebook_path', 'path'] as const;
56
+
57
+ /**
58
+ * The path this tool call is about, or null when the input carries none.
59
+ *
60
+ * Null is a refusable state, not a missing value — see `checkPath`. A call whose target cannot be
61
+ * found is not a call that can be bounded, and guessing one would authorize something nobody named.
62
+ */
63
+ export function pathFromToolInput(toolInput: unknown): string | null {
64
+ if (typeof toolInput !== 'object' || toolInput === null) return null;
65
+ const record = toolInput as Record<string, unknown>;
66
+ for (const field of PATH_FIELDS) {
67
+ const value = record[field];
68
+ if (typeof value === 'string' && value.trim() !== '') return value;
69
+ }
70
+ return null;
71
+ }
72
+
73
+ /** The command this shell call is about, or null when the input carries none. */
74
+ export function commandFromToolInput(toolInput: unknown): string | null {
75
+ if (typeof toolInput !== 'object' || toolInput === null) return null;
76
+ const value = (toolInput as Record<string, unknown>)['command'];
77
+ return typeof value === 'string' && value.trim() !== '' ? value : null;
78
+ }
79
+
80
+ /** Resolve, or say why it could not be done. Never throws — a throwing resolver becomes a refusal. */
81
+ function resolveOrRefuse(
82
+ candidate: string,
83
+ resolve: PathResolver,
84
+ ): { resolved: string } | { refusal: Refusal } {
85
+ let resolved: string;
86
+ try {
87
+ resolved = resolve(candidate);
88
+ } catch (error) {
89
+ return {
90
+ refusal: refusal(
91
+ 'path-unresolvable',
92
+ `${candidate} could not be normalized (${String(error)}), so it is not provably inside the workspace`,
93
+ ),
94
+ };
95
+ }
96
+ if (typeof resolved !== 'string' || resolved === '') {
97
+ return { refusal: refusal('path-unresolvable', `normalizing ${candidate} produced no path at all`) };
98
+ }
99
+ if (!isAbsolutePath(resolved)) {
100
+ return {
101
+ refusal: refusal(
102
+ 'path-not-absolute',
103
+ `${candidate} does not resolve to an absolute path (${resolved})`,
104
+ ),
105
+ };
106
+ }
107
+ return { resolved };
108
+ }
109
+
110
+ /**
111
+ * Is this resolved path at or beneath one of the protected paths?
112
+ *
113
+ * `isContainedBy` compares segment-wise after normalizing both sides, so a protected `C:\Users\x\.claude`
114
+ * does not also protect `C:\Users\x\.claude-notes`, and the protected path itself counts as protected.
115
+ */
116
+ function protectedPathCovering(resolved: string, protectedPaths: readonly string[]): string | null {
117
+ for (const candidate of protectedPaths) {
118
+ if (isContainedBy(resolved, candidate)) return candidate;
119
+ }
120
+ return null;
121
+ }
122
+
123
+ /**
124
+ * Check a path-taking tool call against the jail and the protected set.
125
+ *
126
+ * The credential check runs first and applies whatever the workspace root is: a token cache that
127
+ * happens to sit inside the workspace is still a token cache. Order matters for the message the
128
+ * reader gets, not for whether the call is refused — both answers block.
129
+ */
130
+ export function checkPath(candidate: string | null, options: JailOptions): Refusal | null {
131
+ if (candidate === null) {
132
+ return refusal(
133
+ 'path-input-missing',
134
+ 'this tool takes a path and the input carries none, so there is nothing to bound — refused rather than guessed',
135
+ );
136
+ }
137
+
138
+ const outcome = resolveOrRefuse(candidate, options.resolve);
139
+ if ('refusal' in outcome) return outcome.refusal;
140
+ const { resolved } = outcome;
141
+
142
+ const covering = protectedPathCovering(resolved, options.protectedPaths);
143
+ if (covering !== null) {
144
+ return refusal(
145
+ 'credential-path-denied',
146
+ `${resolved} is at or beneath ${covering}, which holds credential material; the agent shares the host's OS user, so this gate is the only control over it`,
147
+ );
148
+ }
149
+
150
+ if (options.workspaceRoot === null || options.workspaceRoot.trim() === '') {
151
+ return refusal(
152
+ 'path-escapes-root',
153
+ 'no workspace root is declared, so no path can be shown to be inside one — a jail with no walls is not a jail',
154
+ );
155
+ }
156
+
157
+ const rootOutcome = resolveOrRefuse(options.workspaceRoot, options.resolve);
158
+ if ('refusal' in rootOutcome) return rootOutcome.refusal;
159
+
160
+ if (!isContainedBy(resolved, rootOutcome.resolved)) {
161
+ return refusal(
162
+ 'path-escapes-root',
163
+ `${resolved} is outside the declared workspace root ${rootOutcome.resolved}`,
164
+ );
165
+ }
166
+
167
+ return null;
168
+ }
169
+
170
+ /**
171
+ * Check a shell command for credential material named anywhere in it.
172
+ *
173
+ * This one scans the whole command, and that is deliberately unlike the rest of the local gate.
174
+ * Everything else here parses precisely so that a mention of a boundary word does not refuse a
175
+ * benign call, so a reader who notices this function will reasonably wonder whether it was missed.
176
+ *
177
+ * It was not. The two cases are not the same shape. A boundary verb is only dangerous at a command
178
+ * position, so parsing tells you whether it is one. A credential path is dangerous wherever it
179
+ * appears: as an argument to any reader, inside a redirect, in a substitution, or handed to a program
180
+ * this parser has no model of. There is no position at which naming the host's token cache in a shell
181
+ * command is routine, so there is nothing to gain by locating it precisely — and every parser gap
182
+ * would become a way to read the credential. The cost of the choice is bounded and stated: a command
183
+ * that merely mentions the path — an `echo` of a diagnostic, say — is refused, which costs one human
184
+ * click. The alternative costs the credential.
185
+ *
186
+ * So do not narrow this to match the rest of the file. Both credential defences — this one and
187
+ * `checkPath`'s — are covered by tests that fail when either is removed. Reproduce that before
188
+ * changing anything here: make `protectedPathCovering` return null and make this function's
189
+ * haystack empty, then run the suite. The over-refusal is the price of those tests, not an
190
+ * oversight in them.
191
+ *
192
+ * The comparison is on the resolved protected paths and on the raw command text, case-insensitively,
193
+ * because Windows paths reach here in both slash styles and either case.
194
+ */
195
+ export function checkShellForProtectedPaths(command: string, options: JailOptions): Refusal | null {
196
+ const haystack = normalizePath(command).toLowerCase();
197
+ for (const candidate of options.protectedPaths) {
198
+ const outcome = resolveOrRefuse(candidate, options.resolve);
199
+ // A protected path this host cannot resolve is still protected — fall back to its literal form
200
+ // rather than dropping it from the set, which would silently shrink the protected surface.
201
+ const needle = normalizePath('refusal' in outcome ? candidate : outcome.resolved).toLowerCase();
202
+ if (needle !== '' && haystack.includes(needle)) {
203
+ return refusal(
204
+ 'credential-path-denied',
205
+ `the command names ${needle}, which holds credential material; the agent shares the host's OS user, so this gate is the only control over it`,
206
+ );
207
+ }
208
+ }
209
+ return null;
210
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * The host's own gate — a refusal that does not depend on the controller behaving.
3
+ *
4
+ * This is what makes the package installable by a stranger. A host that executes whatever a
5
+ * remote server sends is, mechanically, a code-execution service on somebody else's machine. An
6
+ * optional callback defaulting to escalate-everything is the opposite of an answer to that. This
7
+ * module refuses locally, offline, by rules that ship with the package — so the property can be
8
+ * demonstrated by somebody who does not trust the controller at all, which is the only audience the
9
+ * claim matters to.
10
+ *
11
+ * The vocabulary is owned locally, not received. A policy the controller can change remotely is a
12
+ * policy that depends on the controller behaving, which defeats the module. At the exact moment this
13
+ * is load-bearing — the controller unreachable — a cached remote policy is the only thing acting, so
14
+ * receiving-and-caching is local ownership plus a remote mutation path, for no benefit when it
15
+ * counts.
16
+ *
17
+ * Owned is not hardcoded, and that is what makes it survivable. The embedder chooses the tool
18
+ * families and the protected-path list at construction, in-process, before any session exists. The
19
+ * wire carries no policy in either direction.
20
+ *
21
+ * It adds refusals and never removes one. A tool this module has no opinion about returns null and
22
+ * the surrounding gate goes on to ask whoever it was going to ask. Two mechanisms, one invariant —
23
+ * the same posture the outer gate takes toward the operator's own settings.
24
+ */
25
+ import type { Refusal } from '../core/refusal.js';
26
+ import { refusal } from '../core/refusal.js';
27
+ import type { DecisionRequest } from './decision.js';
28
+ import type { JailOptions, PathResolver } from './jail.js';
29
+ import { checkPath, checkShellForProtectedPaths, commandFromToolInput, pathFromToolInput } from './jail.js';
30
+ import { classifyShellCommand } from './shell.js';
31
+ import { parseCommand } from './command.js';
32
+
33
+ /**
34
+ * Decides one tool call, locally. Null means no local opinion, never "allowed".
35
+ *
36
+ * Synchronous and total by contract: this runs before anything is asked of anyone, and an
37
+ * asynchronous local policy would be a second place a decision can hang.
38
+ */
39
+ export type LocalGate = (request: DecisionRequest) => Refusal | null;
40
+
41
+ /** The tool families this module recognises. Data, so an embedder can state its own. */
42
+ export interface ToolFamilies {
43
+ /** Tools whose input names a path they will write. */
44
+ readonly write: readonly string[];
45
+ /** Tools whose input names a path they will read. Present because of the credential denial. */
46
+ readonly read: readonly string[];
47
+ /** Tools that run a command. */
48
+ readonly shell: readonly string[];
49
+ }
50
+
51
+ /**
52
+ * The SDK's own tool names.
53
+ *
54
+ * Read tools are jailed too. Reading source is benign; reading the host's token cache is not, and
55
+ * the two arrive through the same tool. The jail
56
+ * bounds where reads may go and the protected set names what is off-limits wherever it sits.
57
+ */
58
+ export const DEFAULT_TOOL_FAMILIES: ToolFamilies = {
59
+ write: ['Write', 'Edit', 'MultiEdit', 'NotebookEdit'],
60
+ read: ['Read', 'NotebookRead'],
61
+ shell: ['Bash', 'PowerShell'],
62
+ };
63
+
64
+ export interface LocalGateOptions {
65
+ /** The absolute root every path-taking call must resolve inside. */
66
+ readonly workspaceRoot: string | null;
67
+ /** The real resolver. `host/paths.ts` holds the one built on `node:path`. */
68
+ readonly resolve: PathResolver;
69
+ /** Absolute paths holding credential material. `host/paths.ts` computes the default set. */
70
+ readonly protectedPaths: readonly string[];
71
+ /** Defaults to `DEFAULT_TOOL_FAMILIES`. */
72
+ readonly toolFamilies?: ToolFamilies;
73
+ }
74
+
75
+ /**
76
+ * Build the local gate.
77
+ *
78
+ * The returned function is pure, synchronous and total — so it composes into whatever assembles a
79
+ * session later, and needs no composition root to exist first.
80
+ */
81
+ export function localGate(options: LocalGateOptions): LocalGate {
82
+ const families = options.toolFamilies ?? DEFAULT_TOOL_FAMILIES;
83
+ const jail: JailOptions = {
84
+ workspaceRoot: options.workspaceRoot,
85
+ resolve: options.resolve,
86
+ protectedPaths: options.protectedPaths,
87
+ };
88
+
89
+ const writeTools = new Set(families.write);
90
+ const readTools = new Set(families.read);
91
+ const shellTools = new Set(families.shell);
92
+
93
+ return (request: DecisionRequest): Refusal | null => {
94
+ if (shellTools.has(request.toolName)) {
95
+ const command = commandFromToolInput(request.toolInput);
96
+ if (command === null) {
97
+ return refusal(
98
+ 'shell-command-missing',
99
+ 'this tool runs a command and the input carries none, so there is nothing to classify — refused rather than guessed',
100
+ );
101
+ }
102
+ // One parse, both consumers. The credential check runs first because a command naming the
103
+ // token cache is the more serious of the two answers and should be the one the reader gets.
104
+ const parsed = parseCommand(command);
105
+ return checkShellForProtectedPaths(command, jail) ?? classifyShellCommand(command, parsed);
106
+ }
107
+
108
+ if (writeTools.has(request.toolName) || readTools.has(request.toolName)) {
109
+ return checkPath(pathFromToolInput(request.toolInput), jail);
110
+ }
111
+
112
+ // No opinion. The surrounding gate asks whoever it was going to ask.
113
+ return null;
114
+ };
115
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * What the gate did, and the transitions that say so.
3
+ *
4
+ * This file is pure, and that is the point. Turning an outcome into transitions has no clock, no
5
+ * machine and no SDK in it, so the mapping every emitted trace depends on can be checked exhaustively
6
+ * without a session anywhere near it. `recordGateOutcome` below is the two-line impure half.
7
+ *
8
+ * The gate declares no status of its own. Every outcome here lands on the declared state model's
9
+ * existing states, activities and causes. A gate that found itself wanting a status word would have
10
+ * found a state missing from that model — a finding for it, never a column here.
11
+ *
12
+ * Five outcomes, three cause kinds, and the separation is the whole value. A denial is
13
+ * `control/permission_denied` — somebody decided. An outage and an unrecognised answer are
14
+ * `refusal/…` — nobody decided, and the two are different investigations. An expiry is
15
+ * `timeout/hook_timed_out`. Every one of them blocks the tool, so the invariant is identical and
16
+ * only the story differs; conflating an outage with a deliberate "no" is an expensive failure, and
17
+ * it is why the kinds are separate rather than one kind with a different string in `detail`.
18
+ */
19
+ import type { Refusal } from '../core/refusal.js';
20
+ import type { SessionActivity, TransitionCause } from '../state/model.js';
21
+ import { HOOK_TIMEOUT_EVENT } from '../state/model.js';
22
+ import type { SessionStateMachine, TransitionRequest } from '../state/machine.js';
23
+ import type { Result } from '../core/result.js';
24
+ import type { SessionTransition } from '../state/model.js';
25
+ import type { DecisionRequest } from './decision.js';
26
+
27
+ /**
28
+ * What happened to one tool call.
29
+ *
30
+ * `held` says whether a permission entry was opened for this call — i.e. whether the decision took
31
+ * long enough to be worth reporting as a session that is waiting. It is carried on every terminal
32
+ * outcome because the close is only correct when there was an open.
33
+ */
34
+ export type GateOutcome =
35
+ /** The decision is taking long enough to be a hold. Opens the permission entry. */
36
+ | { readonly kind: 'holding'; readonly request: DecisionRequest }
37
+ | {
38
+ readonly kind: 'allow';
39
+ readonly request: DecisionRequest;
40
+ readonly updatedInput?: Record<string, unknown>;
41
+ readonly held: boolean;
42
+ }
43
+ | {
44
+ readonly kind: 'deny';
45
+ readonly request: DecisionRequest;
46
+ readonly message: string;
47
+ readonly held: boolean;
48
+ }
49
+ /** Nobody decided: the decider threw, the escalation failed, or the answer was not understood. */
50
+ | {
51
+ readonly kind: 'refused';
52
+ readonly request: DecisionRequest;
53
+ readonly refusal: Refusal;
54
+ readonly held: boolean;
55
+ }
56
+ /** The host's own deadline passed before an answer arrived. */
57
+ | {
58
+ readonly kind: 'expired';
59
+ readonly request: DecisionRequest;
60
+ readonly detail: string;
61
+ readonly held: boolean;
62
+ };
63
+
64
+ const permissionActivity = (toolName: string): SessionActivity => ({ kind: 'permission', name: toolName });
65
+
66
+ /**
67
+ * How the gate keys its hold entry: by `tool_use_id`, which every `PreToolUse` carries.
68
+ *
69
+ * Deliberately not the observer's name-keyed `permission:<toolName>`. The observer's `PreToolUse`
70
+ * branch closes any open name-keyed permission entry — the allow-path exit for its own
71
+ * `PermissionRequest` lane — so a name-keyed hold could be closed by the next `PreToolUse` for the
72
+ * same tool while this call's decision is still outstanding, recording a resolution that never
73
+ * happened. Two writers, two key spaces: the observer owns `permission:<toolName>`, the gate owns
74
+ * `permission:<toolUseId>`, and two simultaneous holds for one tool are two entries. The
75
+ * `permission:` prefix is kept so this key can never collide with the observer's `tool` entry,
76
+ * which is the raw tool_use_id from the same event.
77
+ */
78
+ const holdEntryId = (toolUseId: string): string => `permission:${toolUseId}`;
79
+
80
+ /**
81
+ * The transitions one outcome produces. Pure, total, and often empty.
82
+ *
83
+ * An ordinary allow emits nothing, deliberately. The observer's own `PreToolUse` record already
84
+ * says the tool started, and a second transition saying it was permitted would be one fact recorded
85
+ * twice — which is how a trace stops being countable. A held allow is different: an entry was
86
+ * opened, so it has to be closed, and that close is a real event with a real duration behind it.
87
+ */
88
+ export function gateTransitions(outcome: GateOutcome): TransitionRequest[] {
89
+ const { request } = outcome;
90
+ const entryId = holdEntryId(request.toolUseId);
91
+
92
+ // The event that fired is `PreToolUse`. Naming `PermissionRequest` or `PermissionDenied` here
93
+ // would be truer to the concept and false about the world — observed, neither of those hooks fires
94
+ // for a hook-authored decision — and a trace that names an event which never happened teaches its
95
+ // next reader something untrue.
96
+ const onTheHook = (detail: string): TransitionCause => ({ kind: 'hook', event: 'PreToolUse', detail });
97
+
98
+ if (outcome.kind === 'holding') {
99
+ return [
100
+ {
101
+ to: 'working',
102
+ entry: {
103
+ op: 'open',
104
+ entryId,
105
+ activity: permissionActivity(request.toolName),
106
+ agentId: request.agentId,
107
+ },
108
+ cause: onTheHook(`a permission decision for ${request.toolName} is outstanding`),
109
+ },
110
+ ];
111
+ }
112
+
113
+ const closeIfHeld = outcome.held ? ({ op: 'close', entryId } as const) : null;
114
+
115
+ if (outcome.kind === 'allow') {
116
+ if (!outcome.held) return [];
117
+ return [
118
+ {
119
+ to: 'working',
120
+ entry: closeIfHeld,
121
+ cause: onTheHook(`the permission for ${request.toolName} resolved: allowed`),
122
+ },
123
+ ];
124
+ }
125
+
126
+ if (outcome.kind === 'deny') {
127
+ return [
128
+ {
129
+ to: 'working',
130
+ entry: closeIfHeld,
131
+ // A call the host made on itself — which is what `control` means here, and what a
132
+ // hook-authored deny literally is. See CONTROL_EVENTS' own note: kind and event are
133
+ // validated independently and never as a pair, so this pairing is an authorial choice.
134
+ cause: { kind: 'control', event: 'permission_denied', detail: outcome.message },
135
+ },
136
+ ];
137
+ }
138
+
139
+ if (outcome.kind === 'refused') {
140
+ return [
141
+ {
142
+ to: 'working',
143
+ entry: closeIfHeld,
144
+ cause: { kind: 'refusal', event: outcome.refusal.reason, detail: outcome.refusal.detail },
145
+ },
146
+ ];
147
+ }
148
+
149
+ return [
150
+ {
151
+ to: 'working',
152
+ entry: closeIfHeld,
153
+ cause: { kind: 'timeout', event: HOOK_TIMEOUT_EVENT, detail: outcome.detail },
154
+ },
155
+ ];
156
+ }
157
+
158
+ /** The impure half: put an outcome's transitions on a machine. Returns what it recorded. */
159
+ export function recordGateOutcome(
160
+ machine: SessionStateMachine,
161
+ outcome: GateOutcome,
162
+ ): Result<SessionTransition>[] {
163
+ return gateTransitions(outcome).map((request) => machine.record(request));
164
+ }