@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,80 @@
1
+ /**
2
+ * The real resolver.
3
+ *
4
+ * It is cwd-relative for a relative input, and that is exactly why the gate checks
5
+ * `isAbsolutePath` on the RESULT rather than trusting it. `node:path`'s `resolve` will happily turn
6
+ * `../../etc/passwd` into an absolute path against wherever this process happens to be — which is a
7
+ * different directory from the session's workspace. The jail's containment check is what catches
8
+ * that, and it catches it because the resolution happened first.
9
+ *
10
+ * It also handles what the textual resolver cannot: Windows drive-relative paths (`C:foo` means
11
+ * "foo relative to the current directory ON DRIVE C", not `C:\foo`), UNC roots, and mixed separators.
12
+ * Those are the shapes a hand-rolled normalizer gets subtly wrong, and each one is a way out of a
13
+ * jail built on string comparison.
14
+ */
15
+ export declare function nodePathResolver(candidate: string): string;
16
+ /**
17
+ * Where this host keeps its own credential material.
18
+ *
19
+ * This function is the single source, and that is the whole design. Both the path the host
20
+ * writes its token cache to and the set of paths the gate refuses derive from this one call, so
21
+ * they cannot disagree. The alternative (computing the cache path in one module and listing a
22
+ * protected path in another) is two facts that must be kept equal by hand, and nothing goes red
23
+ * when they drift apart: the cache tests still pass, the gate tests still pass, and the credential
24
+ * is simply unprotected.
25
+ *
26
+ * Why not just put the cache under `~/.claude`, which is already protected? Because that is the
27
+ * agent CLI's directory, and inheriting protection by squatting in somebody else's namespace is
28
+ * coincidence, not design. It would break the moment either side reorganised, and the breakage
29
+ * would be silent in exactly the same way.
30
+ *
31
+ * `PERISCOPE_CONFIG_DIR` overrides it, so an operator who keeps credentials on a separate volume
32
+ * gets the protection at the new location automatically rather than having to know to say so twice.
33
+ */
34
+ export declare function periscopeCredentialDir(env?: NodeJS.ProcessEnv): string | null;
35
+ /**
36
+ * The file the host's token cache lives in.
37
+ *
38
+ * Null when there is nowhere to put it — no home directory and no configured location. A caller
39
+ * that gets null has no cache, which is a refusable state and never a reason to fall back to a
40
+ * world-readable temp file.
41
+ */
42
+ export declare function tokenCachePath(env?: NodeJS.ProcessEnv): string | null;
43
+ /**
44
+ * The file this machine's paired credential lives in — beside the token cache, deliberately: the
45
+ * gate's protected set names the DIRECTORY, so living here is what protects it. Null when there is
46
+ * nowhere to put it, same contract as the cache.
47
+ */
48
+ export declare function pairedCredentialPath(env?: NodeJS.ProcessEnv): string | null;
49
+ /**
50
+ * Where credential material lives, as absolute paths.
51
+ *
52
+ * This exists because file permissions are not a boundary here. The agent runs as the same OS
53
+ * user as the host, so a 0600 token file is readable by the agent exactly as it is by the host. No
54
+ * mode, no owner and no ACL separates them. The gate refusing these paths is the only local
55
+ * control there is, and its refusal covers the declared tool families and shell commands naming a
56
+ * path literally; a call outside that scope (built-in `Grep`/`Glob`, an expansion form, a symlink)
57
+ * escalates to the controller instead, which offline means an outage refusal rather than a by-name
58
+ * one. Without this list "the agent holds no credential" is true only in the narrowest sense: it
59
+ * holds none of its own, and can read the host's.
60
+ *
61
+ * The set is returned, not applied. The embedder receives it, may add to it, and hands it to the
62
+ * gate at construction. A policy the caller cannot read back is a policy the caller cannot audit.
63
+ *
64
+ * Ambient cloud credentials are included deliberately. They are not this package's credentials,
65
+ * but they are reachable by the same user through the same tools, and a host that guards only its
66
+ * own secrets while the agent reads the machine's has guarded the cheaper half.
67
+ *
68
+ * `env` is passed in rather than read from the process so this is testable and so an embedder
69
+ * composing several sessions can state a different environment for each.
70
+ */
71
+ export declare function credentialPaths(env?: NodeJS.ProcessEnv, options?: {
72
+ readonly agentHome?: string | null;
73
+ }): string[];
74
+ /**
75
+ * `join` without importing it: these are all one-level appends and the resolver normalizes after.
76
+ * The separator follows the base, so a Windows home spelled `C:\Users\x` prints as one
77
+ * (`C:\Users\x\.periscope`) rather than as a mix the operator has to read twice.
78
+ */
79
+ export declare function join(base: string, ...segments: string[]): string;
80
+ //# sourceMappingURL=paths.d.ts.map
@@ -0,0 +1,165 @@
1
+ /**
2
+ * The two impure inputs the host's own gate needs: a real path resolver, and the set of absolute
3
+ * paths that hold credential material.
4
+ *
5
+ * Why it lives here: `core/paths.ts` resolves textually and never consults the filesystem, by
6
+ * contract, because the core must run anywhere — and its own header says a caller enforcing a real
7
+ * jail supplies a real resolver. Reading a home directory is reading the machine, and `src/host/` is
8
+ * where the boundary pin says that belongs. The gate takes both of these as INJECTED values, so the
9
+ * policy stays testable with a resolver that throws and a protected set nobody has to have on disk.
10
+ */
11
+ import { homedir } from 'node:os';
12
+ import { isAbsolute, resolve as resolvePath } from 'node:path';
13
+ /**
14
+ * The real resolver.
15
+ *
16
+ * It is cwd-relative for a relative input, and that is exactly why the gate checks
17
+ * `isAbsolutePath` on the RESULT rather than trusting it. `node:path`'s `resolve` will happily turn
18
+ * `../../etc/passwd` into an absolute path against wherever this process happens to be — which is a
19
+ * different directory from the session's workspace. The jail's containment check is what catches
20
+ * that, and it catches it because the resolution happened first.
21
+ *
22
+ * It also handles what the textual resolver cannot: Windows drive-relative paths (`C:foo` means
23
+ * "foo relative to the current directory ON DRIVE C", not `C:\foo`), UNC roots, and mixed separators.
24
+ * Those are the shapes a hand-rolled normalizer gets subtly wrong, and each one is a way out of a
25
+ * jail built on string comparison.
26
+ */
27
+ export function nodePathResolver(candidate) {
28
+ const resolved = resolvePath(candidate);
29
+ // `resolve` is total, but a caller reading this wants to know the post-condition is checked rather
30
+ // than assumed — an unrooted result would make every containment answer meaningless.
31
+ if (!isAbsolute(resolved))
32
+ throw new Error(`resolving ${candidate} produced a non-absolute path: ${resolved}`);
33
+ return resolved;
34
+ }
35
+ /**
36
+ * Where this host keeps its own credential material.
37
+ *
38
+ * This function is the single source, and that is the whole design. Both the path the host
39
+ * writes its token cache to and the set of paths the gate refuses derive from this one call, so
40
+ * they cannot disagree. The alternative (computing the cache path in one module and listing a
41
+ * protected path in another) is two facts that must be kept equal by hand, and nothing goes red
42
+ * when they drift apart: the cache tests still pass, the gate tests still pass, and the credential
43
+ * is simply unprotected.
44
+ *
45
+ * Why not just put the cache under `~/.claude`, which is already protected? Because that is the
46
+ * agent CLI's directory, and inheriting protection by squatting in somebody else's namespace is
47
+ * coincidence, not design. It would break the moment either side reorganised, and the breakage
48
+ * would be silent in exactly the same way.
49
+ *
50
+ * `PERISCOPE_CONFIG_DIR` overrides it, so an operator who keeps credentials on a separate volume
51
+ * gets the protection at the new location automatically rather than having to know to say so twice.
52
+ */
53
+ export function periscopeCredentialDir(env = process.env) {
54
+ const configured = env['PERISCOPE_CONFIG_DIR'];
55
+ if (typeof configured === 'string' && configured.trim() !== '')
56
+ return configured.trim();
57
+ const home = env['USERPROFILE'] ?? env['HOME'] ?? safeHomedir();
58
+ if (home === null || home === '')
59
+ return null;
60
+ return join(home, '.periscope');
61
+ }
62
+ /**
63
+ * The file the host's token cache lives in.
64
+ *
65
+ * Null when there is nowhere to put it — no home directory and no configured location. A caller
66
+ * that gets null has no cache, which is a refusable state and never a reason to fall back to a
67
+ * world-readable temp file.
68
+ */
69
+ export function tokenCachePath(env = process.env) {
70
+ const dir = periscopeCredentialDir(env);
71
+ return dir === null ? null : join(dir, 'token-cache.json');
72
+ }
73
+ /**
74
+ * The file this machine's paired credential lives in — beside the token cache, deliberately: the
75
+ * gate's protected set names the DIRECTORY, so living here is what protects it. Null when there is
76
+ * nowhere to put it, same contract as the cache.
77
+ */
78
+ export function pairedCredentialPath(env = process.env) {
79
+ const dir = periscopeCredentialDir(env);
80
+ return dir === null ? null : join(dir, 'paired-credential.json');
81
+ }
82
+ /**
83
+ * Where credential material lives, as absolute paths.
84
+ *
85
+ * This exists because file permissions are not a boundary here. The agent runs as the same OS
86
+ * user as the host, so a 0600 token file is readable by the agent exactly as it is by the host. No
87
+ * mode, no owner and no ACL separates them. The gate refusing these paths is the only local
88
+ * control there is, and its refusal covers the declared tool families and shell commands naming a
89
+ * path literally; a call outside that scope (built-in `Grep`/`Glob`, an expansion form, a symlink)
90
+ * escalates to the controller instead, which offline means an outage refusal rather than a by-name
91
+ * one. Without this list "the agent holds no credential" is true only in the narrowest sense: it
92
+ * holds none of its own, and can read the host's.
93
+ *
94
+ * The set is returned, not applied. The embedder receives it, may add to it, and hands it to the
95
+ * gate at construction. A policy the caller cannot read back is a policy the caller cannot audit.
96
+ *
97
+ * Ambient cloud credentials are included deliberately. They are not this package's credentials,
98
+ * but they are reachable by the same user through the same tools, and a host that guards only its
99
+ * own secrets while the agent reads the machine's has guarded the cheaper half.
100
+ *
101
+ * `env` is passed in rather than read from the process so this is testable and so an embedder
102
+ * composing several sessions can state a different environment for each.
103
+ */
104
+ export function credentialPaths(env = process.env, options = {}) {
105
+ const home = env['USERPROFILE'] ?? env['HOME'] ?? safeHomedir();
106
+ const paths = [];
107
+ // The host's own credential directory, first, and derived rather than spelled out. The
108
+ // directory is named rather than the file, so anything the host later keeps beside the token
109
+ // cache is protected by having been put there, not by somebody remembering to add a line.
110
+ //
111
+ // Removing these two lines takes tests red across three lanes at once (read, write and shell)
112
+ // because all three read this one list. That is the point of deriving it here rather than
113
+ // restating the path where the cache is written.
114
+ const own = periscopeCredentialDir(env);
115
+ if (own !== null)
116
+ paths.push(own);
117
+ if (home !== null && home !== '') {
118
+ // The agent CLI's own token cache and its per-project state file. `.claude.json` is a FILE and
119
+ // `.claude` a DIRECTORY; both are named because the containment check treats a protected path as
120
+ // protected along with everything beneath it, and a file simply has nothing beneath it.
121
+ paths.push(join(home, '.claude'));
122
+ paths.push(join(home, '.claude.json'));
123
+ // Ambient cloud credentials reachable by the same user.
124
+ paths.push(join(home, '.aws'));
125
+ paths.push(join(home, '.config', 'gcloud'));
126
+ paths.push(join(home, '.azure'));
127
+ // SSH keys: not a token cache, but the same class — material that authenticates this user.
128
+ paths.push(join(home, '.ssh'));
129
+ }
130
+ // An explicitly configured credential location wins over the derived ones and is added as well.
131
+ const configured = env['CLAUDE_CONFIG_DIR'];
132
+ if (typeof configured === 'string' && configured.trim() !== '')
133
+ paths.push(configured);
134
+ // The effective agent home, when it is not the default under the home directory: the agent CLI
135
+ // keeps its token cache and per-project state there, so protecting only `~/.claude` would leave a
136
+ // host configured with `PERISCOPE_AGENT_HOME` guarding the wrong directory. The composition root
137
+ // passes the value it resolved (environment or config file); the environment key alone is read
138
+ // here for a caller that has nothing else.
139
+ const agentHome = options.agentHome ?? env['PERISCOPE_AGENT_HOME'] ?? null;
140
+ if (typeof agentHome === 'string' && agentHome.trim() !== '' && !paths.includes(agentHome)) {
141
+ paths.push(agentHome);
142
+ }
143
+ return paths;
144
+ }
145
+ /**
146
+ * `join` without importing it: these are all one-level appends and the resolver normalizes after.
147
+ * The separator follows the base, so a Windows home spelled `C:\Users\x` prints as one
148
+ * (`C:\Users\x\.periscope`) rather than as a mix the operator has to read twice.
149
+ */
150
+ export function join(base, ...segments) {
151
+ const trimmed = base.replace(/[\\/]+$/, '');
152
+ const separator = trimmed.includes('\\') && !trimmed.includes('/') ? '\\' : '/';
153
+ return [trimmed, ...segments].join(separator);
154
+ }
155
+ /** `homedir()` throws on a system with no resolvable home; a missing home is not a reason to fail. */
156
+ function safeHomedir() {
157
+ try {
158
+ const home = homedir();
159
+ return home === '' ? null : home;
160
+ }
161
+ catch {
162
+ return null;
163
+ }
164
+ }
165
+ //# sourceMappingURL=paths.js.map
@@ -0,0 +1,33 @@
1
+ import type { RepositoryEntry } from '../control/frames.js';
2
+ import type { Result } from '../core/result.js';
3
+ /** How many bytes of a file's head are checked for a NUL byte before it is served as text. */
4
+ export declare const BINARY_PROBE_BYTES: number;
5
+ /** One directory's children, names only, sorted; `truncated` when the cap stopped the listing. */
6
+ export interface RepositoryListing {
7
+ readonly entries: readonly RepositoryEntry[];
8
+ readonly truncated: boolean;
9
+ }
10
+ /** One file's head as text, with the whole size so a caller can see what `truncated` left behind. */
11
+ export interface RepositoryText {
12
+ readonly text: string;
13
+ readonly sizeBytes: number;
14
+ readonly truncated: boolean;
15
+ }
16
+ /**
17
+ * The lexical half of the jail: join `relative` under `root`, resolve, and refuse anything that
18
+ * resolves outside. `''` is the root itself. Pure over the resolver; the physical half needs the
19
+ * filesystem and lives in the two readers.
20
+ */
21
+ export declare function resolveRepositoryPath(root: string, relative: string): Result<string>;
22
+ /**
23
+ * List one directory under the root: files and directories by name, sorted, at most `cap` of
24
+ * them. Anything that is neither (a link, a socket) is left out rather than followed.
25
+ */
26
+ export declare function listRepositoryDirectory(root: string, relative: string, cap?: number, protectedPaths?: readonly string[]): Promise<Result<RepositoryListing>>;
27
+ /**
28
+ * Read the head of one text file under the root: at most `maxBytes` (capped at the wire's bound),
29
+ * cut back to a UTF-8 boundary so the text never ends mid-character. Binary is refused by the NUL
30
+ * probe; a directory or an absent path is refused by name.
31
+ */
32
+ export declare function readRepositoryFile(root: string, relative: string, maxBytes?: number, protectedPaths?: readonly string[]): Promise<Result<RepositoryText>>;
33
+ //# sourceMappingURL=repository-read.d.ts.map
@@ -0,0 +1,201 @@
1
+ /**
2
+ * The repository read: a controller listing one directory or reading one text file of the
3
+ * operator's checkout through this host, jailed to the repository root.
4
+ *
5
+ * The posture is the discovery door's, over a different root. The transcripts door reads the agent
6
+ * CLI's own directory; this reads the repository the host provisions workspaces from, so a
7
+ * controller can show a checkout's shape and the text of a file in it without a session, a clone or
8
+ * a bulk lane. Read-only is a property here too: the filesystem surface is `createReadStream`,
9
+ * `readdir`, `stat` and `realpath`, and `src/pins/transcript-readonly.test.ts` holds this module's
10
+ * closure to that allowlist beside the transcripts door.
11
+ *
12
+ * The jail, for every controller-supplied path:
13
+ * 1. a NUL byte in the text is refused before anything is resolved (it would end the path early
14
+ * for the filesystem and late for the check);
15
+ * 2. resolve-then-containment, lexically: the path is joined under the root, resolved by the real
16
+ * resolver, and must stay inside the root, so `..` and an absolute path cannot leave it;
17
+ * 3. resolve-then-containment, physically: the real path (links followed) must stay inside the
18
+ * root's real path, so a link planted inside the checkout cannot point the read outside it;
19
+ * 4. the protected set: a path at or beneath one of the host's protected paths (the credential
20
+ * directories the gate keeps from the agent) is refused whatever the root is, on the lexical
21
+ * resolution and again on the real path.
22
+ * A jail violation refuses `repository-path-escape`; a protected path refuses
23
+ * `credential-path-denied`. A path that is inside the root but is not what was asked for (a file
24
+ * where a directory was asked, nothing at all) refuses `repository-read-failed`; absence is a
25
+ * refusal here, not a value, because the ask named one thing and it is not there.
26
+ *
27
+ * Bounded twice: a listing carries at most `MAX_REPOSITORY_ENTRIES` names and says when it stopped;
28
+ * a read carries at most `maxBytes` (itself capped at `MAX_REPOSITORY_READ_BYTES`), cut on a UTF-8
29
+ * boundary, and says the file's whole size. A file with a NUL byte in its first
30
+ * `BINARY_PROBE_BYTES` is refused as binary: the answer is a string and a string cannot carry it.
31
+ */
32
+ import { createReadStream } from 'node:fs';
33
+ import { readdir, realpath, stat } from 'node:fs/promises';
34
+ import { MAX_REPOSITORY_ENTRIES, MAX_REPOSITORY_READ_BYTES } from '../control/frames.js';
35
+ import { isContainedBy, normalizePath } from '../core/paths.js';
36
+ import { ok, refuse } from '../core/result.js';
37
+ import { nodePathResolver } from './paths.js';
38
+ /** How many bytes of a file's head are checked for a NUL byte before it is served as text. */
39
+ export const BINARY_PROBE_BYTES = 8 * 1024;
40
+ /**
41
+ * The lexical half of the jail: join `relative` under `root`, resolve, and refuse anything that
42
+ * resolves outside. `''` is the root itself. Pure over the resolver; the physical half needs the
43
+ * filesystem and lives in the two readers.
44
+ */
45
+ export function resolveRepositoryPath(root, relative) {
46
+ if (relative.includes('\0')) {
47
+ return refuse('repository-path-escape', 'the path holds a NUL byte, refused before it is resolved');
48
+ }
49
+ const resolvedRoot = normalizePath(nodePathResolver(root));
50
+ const candidate = normalizePath(nodePathResolver(`${resolvedRoot}/${relative}`));
51
+ if (!isContainedBy(candidate, resolvedRoot)) {
52
+ return refuse('repository-path-escape', 'the path resolves outside the repository root, refused by the containment layer');
53
+ }
54
+ return ok(candidate);
55
+ }
56
+ /** The physical half: the real path of both, links followed, and the same containment rule. */
57
+ async function containedRealPath(root, candidate) {
58
+ let realRoot;
59
+ try {
60
+ realRoot = normalizePath(await realpath(root));
61
+ }
62
+ catch (error) {
63
+ return refuse('repository-read-failed', `the repository root could not be resolved: ${describe(error)}`);
64
+ }
65
+ let real;
66
+ try {
67
+ real = normalizePath(await realpath(candidate));
68
+ }
69
+ catch (error) {
70
+ return refuse('repository-read-failed', `nothing is at that path: ${describe(error)}`);
71
+ }
72
+ if (!isContainedBy(real, realRoot)) {
73
+ return refuse('repository-path-escape', 'the path leads outside the repository root through a link, refused by the containment layer');
74
+ }
75
+ return ok(real);
76
+ }
77
+ /**
78
+ * The protected set applies to this door as it applies to the agent: a path at or beneath one of
79
+ * the host's protected paths is refused whatever the repository root is, checked on the lexical
80
+ * resolution and again on the real path, so neither a root pointed at the credential directory nor
81
+ * a link into it serves credential material to a controller.
82
+ */
83
+ function protectedPathRefusal(resolved, protectedPaths) {
84
+ for (const candidate of protectedPaths) {
85
+ if (isContainedBy(resolved, candidate)) {
86
+ return refuse('credential-path-denied', `the path is at or beneath ${candidate}, which holds credential material; the repository doors honour the same protected set as the gate`);
87
+ }
88
+ }
89
+ return ok(null);
90
+ }
91
+ /** The jail and the protected set together; the real path of the target on success. */
92
+ async function admittedRealPath(root, relative, protectedPaths) {
93
+ const resolved = resolveRepositoryPath(root, relative);
94
+ if (!resolved.ok)
95
+ return resolved;
96
+ const lexical = protectedPathRefusal(resolved.value, protectedPaths);
97
+ if (!lexical.ok)
98
+ return lexical;
99
+ const real = await containedRealPath(root, resolved.value);
100
+ if (!real.ok)
101
+ return real;
102
+ // The real path is compared against the protected set's real paths, not its spellings: a
103
+ // component `realpath` rewrites (a short name, a linked directory) would otherwise make the
104
+ // resolved target and the protected directory disagree about a prefix that names one place. A
105
+ // protected path that does not exist is compared as spelled; absence protects nothing.
106
+ const realProtected = await Promise.all(protectedPaths.map(async (candidate) => {
107
+ try {
108
+ return normalizePath(await realpath(candidate));
109
+ }
110
+ catch {
111
+ return candidate;
112
+ }
113
+ }));
114
+ const physical = protectedPathRefusal(real.value, realProtected);
115
+ if (!physical.ok)
116
+ return physical;
117
+ return real;
118
+ }
119
+ /**
120
+ * List one directory under the root: files and directories by name, sorted, at most `cap` of
121
+ * them. Anything that is neither (a link, a socket) is left out rather than followed.
122
+ */
123
+ export async function listRepositoryDirectory(root, relative, cap = MAX_REPOSITORY_ENTRIES, protectedPaths = []) {
124
+ const real = await admittedRealPath(root, relative, protectedPaths);
125
+ if (!real.ok)
126
+ return real;
127
+ let children;
128
+ try {
129
+ children = await readdir(real.value, { withFileTypes: true });
130
+ }
131
+ catch (error) {
132
+ return refuse('repository-read-failed', `the path could not be listed as a directory: ${describe(error)}`);
133
+ }
134
+ const named = children
135
+ .filter((child) => child.isDirectory() || child.isFile())
136
+ .map((child) => ({ name: child.name, directory: child.isDirectory() }))
137
+ .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
138
+ const limit = Math.max(1, cap);
139
+ const entries = [];
140
+ for (const child of named.slice(0, limit)) {
141
+ try {
142
+ const stats = await stat(`${real.value}/${child.name}`);
143
+ entries.push({
144
+ name: child.name,
145
+ directory: child.directory,
146
+ sizeBytes: child.directory ? 0 : stats.size,
147
+ mtimeMs: Math.floor(stats.mtimeMs),
148
+ });
149
+ }
150
+ catch {
151
+ // Removed between readdir and stat: the listing describes what exists, not what did.
152
+ }
153
+ }
154
+ return ok({ entries, truncated: named.length > limit });
155
+ }
156
+ /**
157
+ * Read the head of one text file under the root: at most `maxBytes` (capped at the wire's bound),
158
+ * cut back to a UTF-8 boundary so the text never ends mid-character. Binary is refused by the NUL
159
+ * probe; a directory or an absent path is refused by name.
160
+ */
161
+ export async function readRepositoryFile(root, relative, maxBytes = MAX_REPOSITORY_READ_BYTES, protectedPaths = []) {
162
+ const real = await admittedRealPath(root, relative, protectedPaths);
163
+ if (!real.ok)
164
+ return real;
165
+ let stats;
166
+ try {
167
+ stats = await stat(real.value);
168
+ }
169
+ catch (error) {
170
+ return refuse('repository-read-failed', `the file could not be read: ${describe(error)}`);
171
+ }
172
+ if (!stats.isFile())
173
+ return refuse('repository-read-failed', 'the path is not a file');
174
+ const wanted = Math.min(Math.max(1, Math.floor(maxBytes)), MAX_REPOSITORY_READ_BYTES);
175
+ const probe = Math.max(wanted, BINARY_PROBE_BYTES);
176
+ const chunks = [];
177
+ try {
178
+ if (stats.size > 0) {
179
+ for await (const chunk of createReadStream(real.value, { start: 0, end: probe - 1 })) {
180
+ chunks.push(chunk);
181
+ }
182
+ }
183
+ }
184
+ catch (error) {
185
+ return refuse('repository-read-failed', `the file could not be read: ${describe(error)}`);
186
+ }
187
+ const head = Buffer.concat(chunks);
188
+ if (head.subarray(0, BINARY_PROBE_BYTES).includes(0)) {
189
+ return refuse('repository-read-failed', 'the file holds a NUL byte in its head and is not served as text');
190
+ }
191
+ let cut = Math.min(head.length, wanted);
192
+ // A continuation byte at the cut means a character straddles it: step back to its first byte.
193
+ while (cut > 0 && cut < head.length && (head[cut] & 0xc0) === 0x80)
194
+ cut -= 1;
195
+ const text = head.subarray(0, cut).toString('utf8');
196
+ return ok({ text, sizeBytes: stats.size, truncated: cut < stats.size });
197
+ }
198
+ function describe(error) {
199
+ return error instanceof Error ? error.message : String(error);
200
+ }
201
+ //# sourceMappingURL=repository-read.js.map
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The store adapter seam: where this package's transcript store becomes the SDK's `SessionStore`.
3
+ *
4
+ * It lives in `src/host/` because it names `@anthropic-ai/claude-agent-sdk`, which is confined here
5
+ * exactly like `node:fs` is. Everything above works in the package's own vocabulary; the bridge is
6
+ * a field-for-field copy in one file, so a shape change on either side is a compile error here
7
+ * rather than a silent mismatch anywhere else.
8
+ *
9
+ * The adapter is an interface this package implements, not an API it calls, and that is why
10
+ * standing on an `@alpha` surface is acceptable here. If the shape moves, `satisfies` stops
11
+ * holding and the build breaks, loudly, before anything ships. An alpha surface that is called
12
+ * fails the other way: it keeps compiling and behaves differently in production. The version is
13
+ * pinned exactly, and pins/sdk-confinement.test.ts asserts that the pin has no range.
14
+ *
15
+ * The SDK's errors are exceptions and this package's are refusals, so the bridge converts. The
16
+ * adapter contract says a rejection is retried and a timeout is not, which means throwing is how an
17
+ * adapter reports a failure it wants retried; returning normally would tell the SDK the batch
18
+ * landed. So a refusal from the store is re-thrown here deliberately: it is the only way to refuse
19
+ * in the adapter's own vocabulary, and swallowing it would silently accept data loss.
20
+ */
21
+ import type { SDKMessage, SessionKey, SessionStore, SessionStoreEntry } from '@anthropic-ai/claude-agent-sdk';
22
+ import type { TranscriptKey } from '../persistence/key.js';
23
+ import type { MirrorDrop } from '../persistence/mirror.js';
24
+ import type { TranscriptStore } from '../persistence/store.js';
25
+ export type { SessionKey, SessionStore, SessionStoreEntry };
26
+ /** The SDK's key, in this package's vocabulary. A copy: the two types may not meet above this file. */
27
+ export declare function toTranscriptKey(key: SessionKey): TranscriptKey;
28
+ /** This package's key, as the SDK's. */
29
+ export declare function toSessionKey(key: TranscriptKey): SessionKey;
30
+ /**
31
+ * Present a `TranscriptStore` to the SDK as a `SessionStore`.
32
+ *
33
+ * `listSessionSummaries` is not implemented, deliberately; see persistence/store.ts for the three
34
+ * reasons. The adapter contract marks it optional and states the fallback in as many words, so its
35
+ * absence is a supported posture rather than a missing feature.
36
+ */
37
+ export declare function asSessionStore(store: TranscriptStore): SessionStore;
38
+ /**
39
+ * A dropped mirror batch, lifted off the message stream: the one failure that is otherwise
40
+ * indistinguishable from nothing having happened.
41
+ *
42
+ * The store simply ends up with fewer entries than local disk: no error at the read, no gap anything
43
+ * can compute, and the durable copy silently behind local truth. The SDK reports it as a system
44
+ * message and this is what turns that into a named degrade a caller must handle. A host that only
45
+ * forwarded the message lane would drop it on the floor.
46
+ *
47
+ * The message does not carry the lost entries or how many attempts ran, so those are what this
48
+ * host knows rather than what it was told: attempts come from the contract's own stated policy, and
49
+ * the uuids are empty unless a caller correlates the batch itself. Reporting an attempt count the
50
+ * message did not carry would be inventing detail, so the kind is what decides it.
51
+ */
52
+ export declare function readMirrorDrop(message: SDKMessage): MirrorDrop | null;
53
+ //# sourceMappingURL=session-store.d.ts.map
@@ -0,0 +1,100 @@
1
+ /** The SDK's key, in this package's vocabulary. A copy: the two types may not meet above this file. */
2
+ export function toTranscriptKey(key) {
3
+ return key.subpath === undefined
4
+ ? { projectKey: key.projectKey, sessionId: key.sessionId }
5
+ : { projectKey: key.projectKey, sessionId: key.sessionId, subpath: key.subpath };
6
+ }
7
+ /** This package's key, as the SDK's. */
8
+ export function toSessionKey(key) {
9
+ return key.subpath === undefined
10
+ ? { projectKey: key.projectKey, sessionId: key.sessionId }
11
+ : { projectKey: key.projectKey, sessionId: key.sessionId, subpath: key.subpath };
12
+ }
13
+ /**
14
+ * Present a `TranscriptStore` to the SDK as a `SessionStore`.
15
+ *
16
+ * `listSessionSummaries` is not implemented, deliberately; see persistence/store.ts for the three
17
+ * reasons. The adapter contract marks it optional and states the fallback in as many words, so its
18
+ * absence is a supported posture rather than a missing feature.
19
+ */
20
+ export function asSessionStore(store) {
21
+ const adapter = {
22
+ async append(key, entries) {
23
+ const result = await store.append(toTranscriptKey(key), entries);
24
+ if (!result.ok) {
25
+ // Thrown, not swallowed: a normal return tells the SDK the batch is durable. See the header.
26
+ throw new Error(`${result.refusal.reason}: ${result.refusal.detail}`);
27
+ }
28
+ },
29
+ async load(key) {
30
+ const result = await store.load(toTranscriptKey(key));
31
+ if (!result.ok)
32
+ throw new Error(`${result.refusal.reason}: ${result.refusal.detail}`);
33
+ return result.value === null ? null : result.value;
34
+ },
35
+ };
36
+ if (store.listSessions !== undefined) {
37
+ adapter.listSessions = async (projectKey) => {
38
+ const result = await store.listSessions?.(projectKey);
39
+ if (result === undefined || !result.ok) {
40
+ throw new Error(result === undefined ? 'listSessions is unavailable' : result.refusal.detail);
41
+ }
42
+ return result.value.map((one) => ({ sessionId: one.sessionId, mtime: one.mtime }));
43
+ };
44
+ }
45
+ if (store.delete !== undefined) {
46
+ adapter.delete = async (key) => {
47
+ const result = await store.delete?.(toTranscriptKey(key));
48
+ if (result !== undefined && !result.ok)
49
+ throw new Error(result.refusal.detail);
50
+ };
51
+ }
52
+ if (store.listSubkeys !== undefined) {
53
+ adapter.listSubkeys = async (key) => {
54
+ const result = await store.listSubkeys?.({ projectKey: key.projectKey, sessionId: key.sessionId });
55
+ if (result === undefined || !result.ok) {
56
+ throw new Error(result === undefined ? 'listSubkeys is unavailable' : result.refusal.detail);
57
+ }
58
+ return result.value;
59
+ };
60
+ }
61
+ return adapter;
62
+ }
63
+ /**
64
+ * A dropped mirror batch, lifted off the message stream: the one failure that is otherwise
65
+ * indistinguishable from nothing having happened.
66
+ *
67
+ * The store simply ends up with fewer entries than local disk: no error at the read, no gap anything
68
+ * can compute, and the durable copy silently behind local truth. The SDK reports it as a system
69
+ * message and this is what turns that into a named degrade a caller must handle. A host that only
70
+ * forwarded the message lane would drop it on the floor.
71
+ *
72
+ * The message does not carry the lost entries or how many attempts ran, so those are what this
73
+ * host knows rather than what it was told: attempts come from the contract's own stated policy, and
74
+ * the uuids are empty unless a caller correlates the batch itself. Reporting an attempt count the
75
+ * message did not carry would be inventing detail, so the kind is what decides it.
76
+ */
77
+ export function readMirrorDrop(message) {
78
+ if (message.type !== 'system' || message.subtype !== 'mirror_error')
79
+ return null;
80
+ const kind = looksLikeTimeout(message.error) ? 'timed-out' : 'rejected';
81
+ return {
82
+ key: toTranscriptKey(message.key),
83
+ kind,
84
+ attempts: kind === 'timed-out' ? 1 : 3,
85
+ error: message.error,
86
+ entryUuids: [],
87
+ };
88
+ }
89
+ /**
90
+ * Whether the store's error text describes a timeout.
91
+ *
92
+ * A text match, and said out loud because it is the weak part. The message carries the failure as
93
+ * a string with no discriminant, so the two drop paths (retried three times, versus not retried at
94
+ * all) cannot be told apart structurally. Guessing wrong misreports the attempt count and nothing
95
+ * else; the drop itself is reported either way, which is the property that matters.
96
+ */
97
+ function looksLikeTimeout(error) {
98
+ return /timed?[\s-]?out|timeout|etimedout/i.test(error);
99
+ }
100
+ //# sourceMappingURL=session-store.js.map