@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,131 @@
1
+ import { refusal } from '../core/refusal.js';
2
+ import { isContainedBy, isAbsolutePath, normalizePath } from '../core/paths.js';
3
+ /** The tool-input fields that carry a path, in precedence order. First readable one wins. */
4
+ const PATH_FIELDS = ['file_path', 'notebook_path', 'path'];
5
+ /**
6
+ * The path this tool call is about, or null when the input carries none.
7
+ *
8
+ * Null is a refusable state, not a missing value — see `checkPath`. A call whose target cannot be
9
+ * found is not a call that can be bounded, and guessing one would authorize something nobody named.
10
+ */
11
+ export function pathFromToolInput(toolInput) {
12
+ if (typeof toolInput !== 'object' || toolInput === null)
13
+ return null;
14
+ const record = toolInput;
15
+ for (const field of PATH_FIELDS) {
16
+ const value = record[field];
17
+ if (typeof value === 'string' && value.trim() !== '')
18
+ return value;
19
+ }
20
+ return null;
21
+ }
22
+ /** The command this shell call is about, or null when the input carries none. */
23
+ export function commandFromToolInput(toolInput) {
24
+ if (typeof toolInput !== 'object' || toolInput === null)
25
+ return null;
26
+ const value = toolInput['command'];
27
+ return typeof value === 'string' && value.trim() !== '' ? value : null;
28
+ }
29
+ /** Resolve, or say why it could not be done. Never throws — a throwing resolver becomes a refusal. */
30
+ function resolveOrRefuse(candidate, resolve) {
31
+ let resolved;
32
+ try {
33
+ resolved = resolve(candidate);
34
+ }
35
+ catch (error) {
36
+ return {
37
+ refusal: refusal('path-unresolvable', `${candidate} could not be normalized (${String(error)}), so it is not provably inside the workspace`),
38
+ };
39
+ }
40
+ if (typeof resolved !== 'string' || resolved === '') {
41
+ return { refusal: refusal('path-unresolvable', `normalizing ${candidate} produced no path at all`) };
42
+ }
43
+ if (!isAbsolutePath(resolved)) {
44
+ return {
45
+ refusal: refusal('path-not-absolute', `${candidate} does not resolve to an absolute path (${resolved})`),
46
+ };
47
+ }
48
+ return { resolved };
49
+ }
50
+ /**
51
+ * Is this resolved path at or beneath one of the protected paths?
52
+ *
53
+ * `isContainedBy` compares segment-wise after normalizing both sides, so a protected `C:\Users\x\.claude`
54
+ * does not also protect `C:\Users\x\.claude-notes`, and the protected path itself counts as protected.
55
+ */
56
+ function protectedPathCovering(resolved, protectedPaths) {
57
+ for (const candidate of protectedPaths) {
58
+ if (isContainedBy(resolved, candidate))
59
+ return candidate;
60
+ }
61
+ return null;
62
+ }
63
+ /**
64
+ * Check a path-taking tool call against the jail and the protected set.
65
+ *
66
+ * The credential check runs first and applies whatever the workspace root is: a token cache that
67
+ * happens to sit inside the workspace is still a token cache. Order matters for the message the
68
+ * reader gets, not for whether the call is refused — both answers block.
69
+ */
70
+ export function checkPath(candidate, options) {
71
+ if (candidate === null) {
72
+ return refusal('path-input-missing', 'this tool takes a path and the input carries none, so there is nothing to bound — refused rather than guessed');
73
+ }
74
+ const outcome = resolveOrRefuse(candidate, options.resolve);
75
+ if ('refusal' in outcome)
76
+ return outcome.refusal;
77
+ const { resolved } = outcome;
78
+ const covering = protectedPathCovering(resolved, options.protectedPaths);
79
+ if (covering !== null) {
80
+ return refusal('credential-path-denied', `${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`);
81
+ }
82
+ if (options.workspaceRoot === null || options.workspaceRoot.trim() === '') {
83
+ return refusal('path-escapes-root', 'no workspace root is declared, so no path can be shown to be inside one — a jail with no walls is not a jail');
84
+ }
85
+ const rootOutcome = resolveOrRefuse(options.workspaceRoot, options.resolve);
86
+ if ('refusal' in rootOutcome)
87
+ return rootOutcome.refusal;
88
+ if (!isContainedBy(resolved, rootOutcome.resolved)) {
89
+ return refusal('path-escapes-root', `${resolved} is outside the declared workspace root ${rootOutcome.resolved}`);
90
+ }
91
+ return null;
92
+ }
93
+ /**
94
+ * Check a shell command for credential material named anywhere in it.
95
+ *
96
+ * This one scans the whole command, and that is deliberately unlike the rest of the local gate.
97
+ * Everything else here parses precisely so that a mention of a boundary word does not refuse a
98
+ * benign call, so a reader who notices this function will reasonably wonder whether it was missed.
99
+ *
100
+ * It was not. The two cases are not the same shape. A boundary verb is only dangerous at a command
101
+ * position, so parsing tells you whether it is one. A credential path is dangerous wherever it
102
+ * appears: as an argument to any reader, inside a redirect, in a substitution, or handed to a program
103
+ * this parser has no model of. There is no position at which naming the host's token cache in a shell
104
+ * command is routine, so there is nothing to gain by locating it precisely — and every parser gap
105
+ * would become a way to read the credential. The cost of the choice is bounded and stated: a command
106
+ * that merely mentions the path — an `echo` of a diagnostic, say — is refused, which costs one human
107
+ * click. The alternative costs the credential.
108
+ *
109
+ * So do not narrow this to match the rest of the file. Both credential defences — this one and
110
+ * `checkPath`'s — are covered by tests that fail when either is removed. Reproduce that before
111
+ * changing anything here: make `protectedPathCovering` return null and make this function's
112
+ * haystack empty, then run the suite. The over-refusal is the price of those tests, not an
113
+ * oversight in them.
114
+ *
115
+ * The comparison is on the resolved protected paths and on the raw command text, case-insensitively,
116
+ * because Windows paths reach here in both slash styles and either case.
117
+ */
118
+ export function checkShellForProtectedPaths(command, options) {
119
+ const haystack = normalizePath(command).toLowerCase();
120
+ for (const candidate of options.protectedPaths) {
121
+ const outcome = resolveOrRefuse(candidate, options.resolve);
122
+ // A protected path this host cannot resolve is still protected — fall back to its literal form
123
+ // rather than dropping it from the set, which would silently shrink the protected surface.
124
+ const needle = normalizePath('refusal' in outcome ? candidate : outcome.resolved).toLowerCase();
125
+ if (needle !== '' && haystack.includes(needle)) {
126
+ return refusal('credential-path-denied', `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`);
127
+ }
128
+ }
129
+ return null;
130
+ }
131
+ //# sourceMappingURL=jail.js.map
@@ -0,0 +1,69 @@
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 type { DecisionRequest } from './decision.js';
27
+ import type { PathResolver } from './jail.js';
28
+ /**
29
+ * Decides one tool call, locally. Null means no local opinion, never "allowed".
30
+ *
31
+ * Synchronous and total by contract: this runs before anything is asked of anyone, and an
32
+ * asynchronous local policy would be a second place a decision can hang.
33
+ */
34
+ export type LocalGate = (request: DecisionRequest) => Refusal | null;
35
+ /** The tool families this module recognises. Data, so an embedder can state its own. */
36
+ export interface ToolFamilies {
37
+ /** Tools whose input names a path they will write. */
38
+ readonly write: readonly string[];
39
+ /** Tools whose input names a path they will read. Present because of the credential denial. */
40
+ readonly read: readonly string[];
41
+ /** Tools that run a command. */
42
+ readonly shell: readonly string[];
43
+ }
44
+ /**
45
+ * The SDK's own tool names.
46
+ *
47
+ * Read tools are jailed too. Reading source is benign; reading the host's token cache is not, and
48
+ * the two arrive through the same tool. The jail
49
+ * bounds where reads may go and the protected set names what is off-limits wherever it sits.
50
+ */
51
+ export declare const DEFAULT_TOOL_FAMILIES: ToolFamilies;
52
+ export interface LocalGateOptions {
53
+ /** The absolute root every path-taking call must resolve inside. */
54
+ readonly workspaceRoot: string | null;
55
+ /** The real resolver. `host/paths.ts` holds the one built on `node:path`. */
56
+ readonly resolve: PathResolver;
57
+ /** Absolute paths holding credential material. `host/paths.ts` computes the default set. */
58
+ readonly protectedPaths: readonly string[];
59
+ /** Defaults to `DEFAULT_TOOL_FAMILIES`. */
60
+ readonly toolFamilies?: ToolFamilies;
61
+ }
62
+ /**
63
+ * Build the local gate.
64
+ *
65
+ * The returned function is pure, synchronous and total — so it composes into whatever assembles a
66
+ * session later, and needs no composition root to exist first.
67
+ */
68
+ export declare function localGate(options: LocalGateOptions): LocalGate;
69
+ //# sourceMappingURL=local.d.ts.map
@@ -0,0 +1,51 @@
1
+ import { refusal } from '../core/refusal.js';
2
+ import { checkPath, checkShellForProtectedPaths, commandFromToolInput, pathFromToolInput } from './jail.js';
3
+ import { classifyShellCommand } from './shell.js';
4
+ import { parseCommand } from './command.js';
5
+ /**
6
+ * The SDK's own tool names.
7
+ *
8
+ * Read tools are jailed too. Reading source is benign; reading the host's token cache is not, and
9
+ * the two arrive through the same tool. The jail
10
+ * bounds where reads may go and the protected set names what is off-limits wherever it sits.
11
+ */
12
+ export const DEFAULT_TOOL_FAMILIES = {
13
+ write: ['Write', 'Edit', 'MultiEdit', 'NotebookEdit'],
14
+ read: ['Read', 'NotebookRead'],
15
+ shell: ['Bash', 'PowerShell'],
16
+ };
17
+ /**
18
+ * Build the local gate.
19
+ *
20
+ * The returned function is pure, synchronous and total — so it composes into whatever assembles a
21
+ * session later, and needs no composition root to exist first.
22
+ */
23
+ export function localGate(options) {
24
+ const families = options.toolFamilies ?? DEFAULT_TOOL_FAMILIES;
25
+ const jail = {
26
+ workspaceRoot: options.workspaceRoot,
27
+ resolve: options.resolve,
28
+ protectedPaths: options.protectedPaths,
29
+ };
30
+ const writeTools = new Set(families.write);
31
+ const readTools = new Set(families.read);
32
+ const shellTools = new Set(families.shell);
33
+ return (request) => {
34
+ if (shellTools.has(request.toolName)) {
35
+ const command = commandFromToolInput(request.toolInput);
36
+ if (command === null) {
37
+ return refusal('shell-command-missing', 'this tool runs a command and the input carries none, so there is nothing to classify — refused rather than guessed');
38
+ }
39
+ // One parse, both consumers. The credential check runs first because a command naming the
40
+ // token cache is the more serious of the two answers and should be the one the reader gets.
41
+ const parsed = parseCommand(command);
42
+ return checkShellForProtectedPaths(command, jail) ?? classifyShellCommand(command, parsed);
43
+ }
44
+ if (writeTools.has(request.toolName) || readTools.has(request.toolName)) {
45
+ return checkPath(pathFromToolInput(request.toolInput), jail);
46
+ }
47
+ // No opinion. The surrounding gate asks whoever it was going to ask.
48
+ return null;
49
+ };
50
+ }
51
+ //# sourceMappingURL=local.js.map
@@ -0,0 +1,72 @@
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 { SessionStateMachine, TransitionRequest } from '../state/machine.js';
21
+ import type { Result } from '../core/result.js';
22
+ import type { SessionTransition } from '../state/model.js';
23
+ import type { DecisionRequest } from './decision.js';
24
+ /**
25
+ * What happened to one tool call.
26
+ *
27
+ * `held` says whether a permission entry was opened for this call — i.e. whether the decision took
28
+ * long enough to be worth reporting as a session that is waiting. It is carried on every terminal
29
+ * outcome because the close is only correct when there was an open.
30
+ */
31
+ export type GateOutcome =
32
+ /** The decision is taking long enough to be a hold. Opens the permission entry. */
33
+ {
34
+ readonly kind: 'holding';
35
+ readonly request: DecisionRequest;
36
+ } | {
37
+ readonly kind: 'allow';
38
+ readonly request: DecisionRequest;
39
+ readonly updatedInput?: Record<string, unknown>;
40
+ readonly held: boolean;
41
+ } | {
42
+ readonly kind: 'deny';
43
+ readonly request: DecisionRequest;
44
+ readonly message: string;
45
+ readonly held: boolean;
46
+ }
47
+ /** Nobody decided: the decider threw, the escalation failed, or the answer was not understood. */
48
+ | {
49
+ readonly kind: 'refused';
50
+ readonly request: DecisionRequest;
51
+ readonly refusal: Refusal;
52
+ readonly held: boolean;
53
+ }
54
+ /** The host's own deadline passed before an answer arrived. */
55
+ | {
56
+ readonly kind: 'expired';
57
+ readonly request: DecisionRequest;
58
+ readonly detail: string;
59
+ readonly held: boolean;
60
+ };
61
+ /**
62
+ * The transitions one outcome produces. Pure, total, and often empty.
63
+ *
64
+ * An ordinary allow emits nothing, deliberately. The observer's own `PreToolUse` record already
65
+ * says the tool started, and a second transition saying it was permitted would be one fact recorded
66
+ * twice — which is how a trace stops being countable. A held allow is different: an entry was
67
+ * opened, so it has to be closed, and that close is a real event with a real duration behind it.
68
+ */
69
+ export declare function gateTransitions(outcome: GateOutcome): TransitionRequest[];
70
+ /** The impure half: put an outcome's transitions on a machine. Returns what it recorded. */
71
+ export declare function recordGateOutcome(machine: SessionStateMachine, outcome: GateOutcome): Result<SessionTransition>[];
72
+ //# sourceMappingURL=outcome.d.ts.map
@@ -0,0 +1,91 @@
1
+ import { HOOK_TIMEOUT_EVENT } from '../state/model.js';
2
+ const permissionActivity = (toolName) => ({ kind: 'permission', name: toolName });
3
+ /**
4
+ * How the gate keys its hold entry: by `tool_use_id`, which every `PreToolUse` carries.
5
+ *
6
+ * Deliberately not the observer's name-keyed `permission:<toolName>`. The observer's `PreToolUse`
7
+ * branch closes any open name-keyed permission entry — the allow-path exit for its own
8
+ * `PermissionRequest` lane — so a name-keyed hold could be closed by the next `PreToolUse` for the
9
+ * same tool while this call's decision is still outstanding, recording a resolution that never
10
+ * happened. Two writers, two key spaces: the observer owns `permission:<toolName>`, the gate owns
11
+ * `permission:<toolUseId>`, and two simultaneous holds for one tool are two entries. The
12
+ * `permission:` prefix is kept so this key can never collide with the observer's `tool` entry,
13
+ * which is the raw tool_use_id from the same event.
14
+ */
15
+ const holdEntryId = (toolUseId) => `permission:${toolUseId}`;
16
+ /**
17
+ * The transitions one outcome produces. Pure, total, and often empty.
18
+ *
19
+ * An ordinary allow emits nothing, deliberately. The observer's own `PreToolUse` record already
20
+ * says the tool started, and a second transition saying it was permitted would be one fact recorded
21
+ * twice — which is how a trace stops being countable. A held allow is different: an entry was
22
+ * opened, so it has to be closed, and that close is a real event with a real duration behind it.
23
+ */
24
+ export function gateTransitions(outcome) {
25
+ const { request } = outcome;
26
+ const entryId = holdEntryId(request.toolUseId);
27
+ // The event that fired is `PreToolUse`. Naming `PermissionRequest` or `PermissionDenied` here
28
+ // would be truer to the concept and false about the world — observed, neither of those hooks fires
29
+ // for a hook-authored decision — and a trace that names an event which never happened teaches its
30
+ // next reader something untrue.
31
+ const onTheHook = (detail) => ({ kind: 'hook', event: 'PreToolUse', detail });
32
+ if (outcome.kind === 'holding') {
33
+ return [
34
+ {
35
+ to: 'working',
36
+ entry: {
37
+ op: 'open',
38
+ entryId,
39
+ activity: permissionActivity(request.toolName),
40
+ agentId: request.agentId,
41
+ },
42
+ cause: onTheHook(`a permission decision for ${request.toolName} is outstanding`),
43
+ },
44
+ ];
45
+ }
46
+ const closeIfHeld = outcome.held ? { op: 'close', entryId } : null;
47
+ if (outcome.kind === 'allow') {
48
+ if (!outcome.held)
49
+ return [];
50
+ return [
51
+ {
52
+ to: 'working',
53
+ entry: closeIfHeld,
54
+ cause: onTheHook(`the permission for ${request.toolName} resolved: allowed`),
55
+ },
56
+ ];
57
+ }
58
+ if (outcome.kind === 'deny') {
59
+ return [
60
+ {
61
+ to: 'working',
62
+ entry: closeIfHeld,
63
+ // A call the host made on itself — which is what `control` means here, and what a
64
+ // hook-authored deny literally is. See CONTROL_EVENTS' own note: kind and event are
65
+ // validated independently and never as a pair, so this pairing is an authorial choice.
66
+ cause: { kind: 'control', event: 'permission_denied', detail: outcome.message },
67
+ },
68
+ ];
69
+ }
70
+ if (outcome.kind === 'refused') {
71
+ return [
72
+ {
73
+ to: 'working',
74
+ entry: closeIfHeld,
75
+ cause: { kind: 'refusal', event: outcome.refusal.reason, detail: outcome.refusal.detail },
76
+ },
77
+ ];
78
+ }
79
+ return [
80
+ {
81
+ to: 'working',
82
+ entry: closeIfHeld,
83
+ cause: { kind: 'timeout', event: HOOK_TIMEOUT_EVENT, detail: outcome.detail },
84
+ },
85
+ ];
86
+ }
87
+ /** The impure half: put an outcome's transitions on a machine. Returns what it recorded. */
88
+ export function recordGateOutcome(machine, outcome) {
89
+ return gateTransitions(outcome).map((request) => machine.record(request));
90
+ }
91
+ //# sourceMappingURL=outcome.js.map
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The shell backstop — two layers, and the second exists because the first was insufficient.
3
+ *
4
+ * A shell command cannot be proven safe by looking at a path, so this is the only control over what
5
+ * a shell tool call can reach outside the workspace. Both layers run; either one refusing is enough.
6
+ *
7
+ * Layer 1, the denylist. Named boundary operations — publishing, remote surgery, branch deletion,
8
+ * merging — matched over the whole scannable text with `[\s\S]`, so a newline cannot hide one
9
+ * inside a compound command.
10
+ *
11
+ * Layer 2, the git verb allowlist. This layer is not redundancy; it closes a real defect class.
12
+ * `git send-pack` — the plumbing that `git push` calls underneath — matches no denylist pattern,
13
+ * and a denylist-only classifier approved it as benign shell, which force-pushed a main branch
14
+ * under an automated actor. A denylist under-includes by construction: it can only refuse what
15
+ * somebody thought of. So a git invocation whose verb is not provably safe is refused without
16
+ * enumeration, which covers `send-pack`, `receive-pack`, aliases, and every verb git ships in a
17
+ * future release.
18
+ *
19
+ * The input is parsed, not raw — see `command.ts` for the defects a raw scan produces. Layer 1
20
+ * scans `ParsedCommand.scannable`, which is the raw command minus comments and provably-inert data
21
+ * payloads; layer 2 reads the parsed invocations. That narrows what is scanned and never what is
22
+ * denied.
23
+ *
24
+ * The rule that decides every ambiguous case: a false refusal costs one human click; a false allow
25
+ * costs the invariant. Every unknown shape in this file resolves toward refusing.
26
+ */
27
+ import type { Refusal } from '../core/refusal.js';
28
+ import type { ParsedCommand } from './command.js';
29
+ /**
30
+ * Classify a shell command. Returns the refusal that fired, or null when nothing did.
31
+ *
32
+ * `parsed` may be supplied by a caller that already parsed the command — the credential check does,
33
+ * and parsing twice would be work for nothing.
34
+ */
35
+ export declare function classifyShellCommand(command: string, parsed?: ParsedCommand): Refusal | null;
36
+ //# sourceMappingURL=shell.d.ts.map