@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,318 @@
1
+ /**
2
+ * The environment a spawned agent process receives — an allow-list, not a strip-list.
3
+ *
4
+ * The Agent SDK's `env` option replaces the subprocess environment rather than merging it, and when
5
+ * omitted the subprocess inherits `process.env` whole. So the default posture is full inheritance,
6
+ * and the only way to hold a boundary is to state what crosses it. A strip-list can only ever remove
7
+ * what someone thought of; this one is "absent unless declared".
8
+ *
9
+ * The failures this prevents are all silent, which is why the set is pinned key-by-key rather than
10
+ * eyeballed. Three are recorded, and two have been observed in a live host:
11
+ * - `CLAUDE_CODE_CHILD_SESSION` inherited: the CLI treats the spawn as a nested session and never
12
+ * persists its transcript, while the Stop path still reports the path it never wrote.
13
+ * - `CLAUDE_EFFORT` inherited: the host's own effort silently becomes every spawn's effort.
14
+ * - a host secret inherited: every subprocess the agent runs authenticates as the host.
15
+ *
16
+ * How the declared set was derived — read this before adding or removing a key.
17
+ * Not copied from any single product's list. A list derived from a capture of one product's
18
+ * machines declares that product's own service-discovery variables; carrying those here is how a
19
+ * general-purpose package quietly becomes a single-product one. The test that gates this file runs
20
+ * a closed diff over two corpora — a live host capture and a production capture from a different
21
+ * machine — and every key in either is declared or carries a written reason for being dropped. Two
22
+ * corpora rather than one because a single capture under-includes whatever that box happens to
23
+ * lack: proxy variables, `XDG_`, `LC_`. Do not tidy them into one.
24
+ *
25
+ * Product-specific names are not declared here. They come back through `extraAllowedKeys`, which the
26
+ * embedder states for its own deployment — `USERPROFILE` is a primitive, a dashboard endpoint URL
27
+ * is somebody's product.
28
+ *
29
+ * The standing posture, because it decides every close call: over-inclusion is recoverable and
30
+ * visible; under-inclusion breaks agents in ways that read as model failures many sessions later.
31
+ * So a key is declared unless there is a positive reason to drop it — it carries a credential, it is
32
+ * another process's handle, it is one product's configuration, or it actively lies to the child
33
+ * about what it is.
34
+ */
35
+
36
+ /** Any environment-shaped map. Deliberately not `NodeJS.ProcessEnv` — nothing here needs a runtime. */
37
+ export type EnvSource = Readonly<Record<string, string | undefined>>;
38
+
39
+ export interface SpawnEnvPolicy {
40
+ /**
41
+ * Keys the embedder declares for its own deployment, on top of the general set.
42
+ *
43
+ * This is where a product's own variables belong. Matched case-insensitively like the rest, and
44
+ * it cannot override a denied key — see `DENIED_PATTERNS`.
45
+ */
46
+ readonly extraAllowedKeys?: readonly string[];
47
+ /**
48
+ * Keys the embedder refuses even though they are declared. For a host that knows a name in the
49
+ * general set is a secret on its machines.
50
+ */
51
+ readonly extraDeniedKeys?: readonly string[];
52
+ /**
53
+ * Literal values set on the spawn, applied after filtering.
54
+ *
55
+ * Setting a value is not the same act as inheriting one: a value stated here is a decision with an
56
+ * author, and it overrides whatever the host env happened to hold. That asymmetry is deliberate —
57
+ * a variable this package refuses to inherit can still be set, but only by naming it and its value.
58
+ */
59
+ readonly extraEnv?: Readonly<Record<string, string>>;
60
+ }
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // The declared set.
64
+ // ---------------------------------------------------------------------------
65
+
66
+ const ALLOWED_EXACT = [
67
+ // -- Home resolution. Load-bearing, and the group travels together or not at all.
68
+ // On Windows the key is USERPROFILE, not HOME: `os.homedir()` reads the USERPROFILE family,
69
+ // and `HOME` in a Windows child is a Git-Bash invention. Declaring HOME while omitting
70
+ // USERPROFILE leaves the CLI unable to find its ambient credentials file — every agent
71
+ // silently unauthenticated, presenting as a confused model rather than an error.
72
+ 'USERPROFILE',
73
+ 'HOMEDRIVE',
74
+ 'HOMEPATH',
75
+ 'HOME',
76
+ 'APPDATA',
77
+ 'LOCALAPPDATA',
78
+ 'ALLUSERSPROFILE',
79
+ 'OneDrive',
80
+ // Belongs to the group above, not to the CLI knobs below: it is another spelling of "where are
81
+ // my credentials", so dropping it breaks ambient auth in exactly the same silent way.
82
+ 'CLAUDE_CONFIG_DIR',
83
+
84
+ // -- Identity
85
+ 'USERNAME',
86
+ 'USERDOMAIN',
87
+ 'USERDOMAIN_ROAMINGPROFILE',
88
+ 'COMPUTERNAME',
89
+ 'USER',
90
+ 'LOGNAME',
91
+
92
+ // -- Windows OS substrate
93
+ 'PATH',
94
+ 'PATHEXT',
95
+ 'COMSPEC',
96
+ 'SYSTEMDRIVE',
97
+ 'SYSTEMROOT',
98
+ 'WINDIR',
99
+ 'TEMP',
100
+ 'TMP',
101
+ 'ProgramData',
102
+ 'PROGRAMFILES',
103
+ 'ProgramFiles(x86)',
104
+ 'ProgramW6432',
105
+ 'COMMONPROGRAMFILES',
106
+ 'CommonProgramFiles(x86)',
107
+ 'CommonProgramW6432',
108
+ 'PUBLIC',
109
+ 'DriverData',
110
+ 'OS',
111
+ 'NoDefaultCurrentDirectoryInExePath',
112
+ 'PROMPT',
113
+ 'PSModulePath',
114
+ 'SESSIONNAME',
115
+ 'LOGONSERVER',
116
+ 'NUMBER_OF_PROCESSORS',
117
+ 'ZES_ENABLE_SYSMAN',
118
+
119
+ // -- POSIX substrate. Neither corpus is a POSIX capture, so these are declared from the
120
+ // Windows keys' POSIX twins rather than observed. Stated openly because it is the one place
121
+ // this set is designed rather than derived — and under-inclusion on a platform no corpus
122
+ // covers is the invisible direction.
123
+ 'TMPDIR',
124
+
125
+ // -- Terminal + locale
126
+ 'TERM',
127
+ 'TERM_PROGRAM',
128
+ 'TERM_PROGRAM_VERSION',
129
+ 'COLORTERM',
130
+ 'COLOR',
131
+ 'LANG',
132
+ 'TZ',
133
+
134
+ // -- Node / npm toolchain. `npm_config_*` rides the prefix list; `npm_package_*`,
135
+ // `npm_lifecycle_*` and `INIT_CWD` deliberately do not — they describe the host's own npm
136
+ // invocation and would tell a child it is the host package.
137
+ 'NODE',
138
+ 'npm_command',
139
+ 'npm_execpath',
140
+ 'npm_node_execpath',
141
+ 'NVM_HOME',
142
+ 'NVM_SYMLINK',
143
+ 'COREPACK_ENABLE_AUTO_PIN',
144
+
145
+ // -- Shell, git and dev toolchain. EDITOR/GIT_EDITOR are declared because an agent running a git
146
+ // command that opens an editor with none configured hangs instead of failing.
147
+ 'SHELL',
148
+ 'MSYSTEM',
149
+ 'EXEPATH',
150
+ 'PLINK_PROTOCOL',
151
+ 'SSL_CERT_DIR',
152
+ 'PWD',
153
+ 'SHLVL',
154
+ 'EDITOR',
155
+ 'GIT_EDITOR',
156
+ 'DOTNET_NOLOGO',
157
+ 'MSBUILDTERMINALLOGGER',
158
+ 'ChocolateyInstall',
159
+ 'ChocolateyLastPathUpdate',
160
+
161
+ // -- Agent CLI knobs, exact names only. A `CLAUDE_` prefix would re-admit the stripped set below,
162
+ // above all the effort variable, and undo the whole point of this file.
163
+ 'CLAUDE_CODE_ENABLE_TELEMETRY',
164
+ ] as const;
165
+
166
+ const ALLOWED_PREFIXES = [
167
+ 'NODE_', // NODE_ENV / NODE_NO_WARNINGS / NODE_OPTIONS / NODE_EXTRA_CA_CERTS
168
+ 'npm_config_', // a child running npm needs the host's cache and prefix resolution
169
+ 'PROCESSOR_', // PROCESSOR_ARCHITECTURE / _IDENTIFIER / _LEVEL / _REVISION
170
+ 'XDG_', // POSIX base directories
171
+ 'LC_', // locale category overrides
172
+ ] as const;
173
+
174
+ // Matched as a suffix so one entry covers HTTP_PROXY / http_proxy / HTTPS_PROXY / ALL_PROXY /
175
+ // NO_PROXY, case-insensitively. A proxied host is exactly where under-inclusion is invisible on the
176
+ // machine that wrote the list and fatal on the machine that runs it.
177
+ const ALLOWED_SUFFIXES = ['_PROXY'] as const;
178
+
179
+ /**
180
+ * Denied whatever else matches. Runs before every allow, including `extraAllowedKeys`, because a
181
+ * key that is a credential does not stop being one because someone widened a prefix.
182
+ */
183
+ const DENIED_PATTERNS: readonly RegExp[] = [
184
+ // npm materialises every .npmrc key as an environment variable, so `npm_config_` genuinely
185
+ // matches shapes like `npm_config_//registry.example.com/:_authToken`. A targeted deny rather
186
+ // than a narrower prefix: a child running npm still needs the rest of its config resolution.
187
+ /^npm_config_.*(:_authtoken|:_auth|:_password|:username|:email|_auth)$/i,
188
+ // Disables certificate validation for every outbound request the child makes. A host may have a
189
+ // local reason for it; a spawned agent inherits the reason's absence along with the value, and
190
+ // the failure is a confidentiality one it cannot see. An embedder that means it states it as a
191
+ // literal through `extraEnv`, where it has an author.
192
+ /^NODE_TLS_REJECT_UNAUTHORIZED$/i,
193
+ ];
194
+
195
+ /**
196
+ * Stripped last and unconditionally — after the allow-list, after `extraEnv`, after everything.
197
+ *
198
+ * Deliberately redundant with "absent unless declared": this survives a widened declared set, a
199
+ * `CLAUDE_`-shaped prefix added in haste, and an embedder that sets one of these by hand. The class
200
+ * is the running host's own session fingerprint, and a spawned agent is a top-level session, not a
201
+ * continuation of whoever launched the host.
202
+ */
203
+ const HOST_SESSION_MARKERS = [
204
+ 'CLAUDECODE',
205
+ 'CLAUDE_CODE_CHILD_SESSION',
206
+ 'CLAUDE_CODE_ENTRYPOINT',
207
+ 'CLAUDE_CODE_SESSION_ID',
208
+ 'CLAUDE_CODE_EXECPATH',
209
+ 'CLAUDE_CODE_SSE_PORT',
210
+ 'CLAUDE_PID',
211
+ 'CLAUDE_EFFORT',
212
+ 'CLAUDE_AGENT_SDK_VERSION',
213
+ 'CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING',
214
+ 'CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING',
215
+ 'CLAUDE_CODE_ENABLE_TASKS',
216
+ 'MCP_CONNECTION_NONBLOCKING',
217
+ 'AI_AGENT',
218
+ // The host's own thinking budget, same class as the effort variable: an inherited value silently
219
+ // becomes every spawn's budget. A deliberate one arrives through the SDK options, which have an
220
+ // author and a type.
221
+ 'MAX_THINKING_TOKENS',
222
+ ] as const;
223
+
224
+ // ---------------------------------------------------------------------------
225
+ // Matching.
226
+ // ---------------------------------------------------------------------------
227
+
228
+ // Matching is case-insensitive. Windows environment names are case-insensitive and Node preserves
229
+ // the OS's own casing verbatim — `ProgramData`, `CommonProgramFiles(x86)`, `ChocolateyInstall` — so
230
+ // a case-sensitive set silently drops real keys. On POSIX this only over-includes (a lowercase twin
231
+ // of a declared name), which is the recoverable direction, and it is how `http_proxy` is covered by
232
+ // the same entry as `HTTPS_PROXY`.
233
+ const ALLOWED_EXACT_UPPER = new Set<string>(ALLOWED_EXACT.map((key) => key.toUpperCase()));
234
+ const ALLOWED_PREFIXES_UPPER = ALLOWED_PREFIXES.map((prefix) => prefix.toUpperCase());
235
+ const ALLOWED_SUFFIXES_UPPER = ALLOWED_SUFFIXES.map((suffix) => suffix.toUpperCase());
236
+ const HOST_SESSION_MARKERS_UPPER = new Set<string>(HOST_SESSION_MARKERS.map((key) => key.toUpperCase()));
237
+
238
+ /** Every key this package declares for a spawned agent, in declaration order. For the pin. */
239
+ export const DECLARED_EXACT_KEYS: readonly string[] = ALLOWED_EXACT;
240
+ export const DECLARED_PREFIXES: readonly string[] = ALLOWED_PREFIXES;
241
+ export const DECLARED_SUFFIXES: readonly string[] = ALLOWED_SUFFIXES;
242
+ export const STRIPPED_HOST_SESSION_KEYS: readonly string[] = HOST_SESSION_MARKERS;
243
+
244
+ function isDenied(key: string, policy: SpawnEnvPolicy): boolean {
245
+ if (DENIED_PATTERNS.some((pattern) => pattern.test(key))) return true;
246
+ const upper = key.toUpperCase();
247
+ return (policy.extraDeniedKeys ?? []).some((denied) => denied.toUpperCase() === upper);
248
+ }
249
+
250
+ /** Is this key declared for spawned agents? Exported so the gate test can drive it directly. */
251
+ export function isDeclaredSpawnEnvKey(key: string, policy: SpawnEnvPolicy = {}): boolean {
252
+ if (isDenied(key, policy)) return false;
253
+
254
+ const upper = key.toUpperCase();
255
+ if ((policy.extraAllowedKeys ?? []).some((extra) => extra.toUpperCase() === upper)) return true;
256
+ if (ALLOWED_EXACT_UPPER.has(upper)) return true;
257
+ if (ALLOWED_PREFIXES_UPPER.some((prefix) => upper.startsWith(prefix))) return true;
258
+ return ALLOWED_SUFFIXES_UPPER.some((suffix) => upper.endsWith(suffix));
259
+ }
260
+
261
+ const isProxyKey = (key: string): boolean =>
262
+ ALLOWED_SUFFIXES_UPPER.some((suffix) => key.toUpperCase().endsWith(suffix));
263
+
264
+ /**
265
+ * Strip `user:pass@` from a proxy URL, keeping the proxy reachable.
266
+ *
267
+ * The key stays declared and the credential leaves the value: proxy URLs routinely embed
268
+ * credentials, so declaring the key was declaring a secret — but dropping the key breaks every
269
+ * spawn behind a corporate proxy, which trades an availability failure for a confidentiality one.
270
+ * A value that does not parse as a URL (`NO_PROXY` is a comma-separated host list) is returned
271
+ * verbatim: a redaction must never corrupt a value it does not understand, because a mangled
272
+ * no-proxy list silently changes which hosts bypass the proxy.
273
+ */
274
+ export function redactProxyCredential(value: string): string {
275
+ if (!value.includes('@')) return value;
276
+ try {
277
+ const url = new URL(value);
278
+ if (url.username === '' && url.password === '') return value;
279
+ url.username = '';
280
+ url.password = '';
281
+ return url.toString();
282
+ } catch {
283
+ return value;
284
+ }
285
+ }
286
+
287
+ /**
288
+ * The environment for a spawned agent: the declared subset of `baseEnv`, then the embedder's
289
+ * literals, then the host-session strip.
290
+ *
291
+ * The strip runs last on purpose. It is the one rule that cannot be widened away, so the property
292
+ * "a spawned agent is never told it is a continuation of this process" holds no matter what a later
293
+ * change does to the declared set or what an embedder passes.
294
+ *
295
+ * Returns a fresh object every call and never mutates `baseEnv`.
296
+ */
297
+ export function composeSpawnEnv(baseEnv: EnvSource, policy: SpawnEnvPolicy = {}): Record<string, string> {
298
+ const env: Record<string, string> = {};
299
+
300
+ for (const key of Object.keys(baseEnv)) {
301
+ if (!isDeclaredSpawnEnvKey(key, policy)) continue;
302
+ const value = baseEnv[key];
303
+ // An undeclared value and an empty one are different things, but neither is worth passing: the
304
+ // SDK's env map allows `undefined` and the CLI would see the name with nothing behind it.
305
+ if (value === undefined) continue;
306
+ env[key] = isProxyKey(key) ? redactProxyCredential(value) : value;
307
+ }
308
+
309
+ for (const [key, value] of Object.entries(policy.extraEnv ?? {})) {
310
+ env[key] = value;
311
+ }
312
+
313
+ for (const key of Object.keys(env)) {
314
+ if (HOST_SESSION_MARKERS_UPPER.has(key.toUpperCase())) delete env[key];
315
+ }
316
+
317
+ return env;
318
+ }
@@ -0,0 +1,360 @@
1
+ /**
2
+ * The coverage table. Every hook the SDK exposes and every message it can send is wired or
3
+ * declined here, in writing.
4
+ *
5
+ * An absent row is a gap, not a default — which is the entire reason this is a table rather than
6
+ * a wiring pass. "Every hook is handled" is unfalsifiable; "every hook has a row, and here are the
7
+ * ones deliberately not used and why" can be audited by someone who was not here.
8
+ *
9
+ * It cannot rot silently, and that is enforced twice.
10
+ * 1. `satisfies Record<HookEvent, …>` and `satisfies Record<MessageDiscriminator, …>` — both keyed
11
+ * off the SDK's own unions, so an event added by an SDK upgrade breaks the build.
12
+ * 2. `pins/hook-coverage.test.ts` parses those unions straight out of the shipped `sdk.d.ts` and
13
+ * fails when a literal has no row — which catches the case (1) cannot: someone widening the
14
+ * local mirror instead of the table.
15
+ * A document that claims coverage is exactly the artifact that decays into a lie, so the claim is
16
+ * made checkable rather than merely careful.
17
+ *
18
+ * The rule for `wired`: an event is wired when it changes the session's state or activity, or
19
+ * when it names a why the trace would otherwise be missing. Everything else is declined — and the
20
+ * commonest honest reason is that the fact belongs to a neighbouring layer (content to streaming,
21
+ * cost to telemetry, transcripts to persistence), not that it is uninteresting.
22
+ */
23
+ import type { HookEvent, MessageDiscriminator } from '../host/agent-process.js';
24
+
25
+ export type CoverageHandling = 'wired' | 'declined';
26
+
27
+ export interface CoverageRow {
28
+ readonly handling: CoverageHandling;
29
+ /** What it produces when wired; why it is not consumed when declined. Required either way. */
30
+ readonly note: string;
31
+ }
32
+
33
+ /**
34
+ * All 31 hook events. 18 wired, 13 declined.
35
+ *
36
+ * `PreToolUse` appears here as an observation only. The permission decision is a separate
37
+ * concern with a separate handler; `HookCallbackMatcher.hooks` is an array and the SDK runs every
38
+ * entry, so a decision handler registers alongside this one without either editing the other.
39
+ */
40
+ export const HOOK_COVERAGE = {
41
+ // --- wired: the tool lane -------------------------------------------------
42
+ PreToolUse: {
43
+ handling: 'wired',
44
+ note: 'opens a `tool` entry keyed by tool_use_id; state -> working. Observation only — the permission decision is a separate handler on the same event.',
45
+ },
46
+ PostToolUse: {
47
+ handling: 'wired',
48
+ note: 'closes the `tool` entry for its tool_use_id. The ordinary exit.',
49
+ },
50
+ PostToolUseFailure: {
51
+ handling: 'wired',
52
+ note: 'closes the `tool` entry naming the failure — the failure exit, distinct from the success one because "it stopped" and "it broke" are different answers.',
53
+ },
54
+ PostToolBatch: {
55
+ handling: 'wired',
56
+ note: "fires exactly once after every call in a batch resolves, so it closes any tool entry still open. The backstop for a PostToolUse that never arrived — measured on two live sessions, including an all-denied batch: it fires on a hook-authored deny and its tool_calls includes the denied tool_use_id, so a denied call's entry does not leak. It closes the entry at end of batch and says only that; the denial itself is a separate transition the gate emits.",
57
+ },
58
+
59
+ // --- wired: the turn lane -------------------------------------------------
60
+ UserPromptSubmit: {
61
+ handling: 'wired',
62
+ note: 'state -> working. The turn boundary opening; the counterpart to Stop.',
63
+ },
64
+ Stop: {
65
+ handling: 'wired',
66
+ note: 'state -> idle. The clean turn end, recorded for every session with no exemption — a turn boundary that is missing for any class of session leaves every reader inferring.',
67
+ },
68
+ StopFailure: {
69
+ handling: 'wired',
70
+ note: 'state -> errored, carrying the SDKAssistantMessageError. Never collapsed into Stop: a turn that broke is not a turn that finished.',
71
+ },
72
+
73
+ // --- wired: session lifecycle --------------------------------------------
74
+ SessionStart: {
75
+ handling: 'wired',
76
+ note: 'records how the session began (startup | resume | clear | compact | fork). No state change — it names a why the trace would otherwise have to infer. Measured not to fire on an SDK-hosted start: a real session running every wired hook saw PreToolUse, PostToolUse, PostToolBatch, UserPromptSubmit and Stop, and no SessionStart. The likely reason is ordering — `options.hooks` reach the CLI after it has already started — but that was not isolated. Wired and not observed, which is a different row from declined.',
77
+ },
78
+ SessionEnd: {
79
+ handling: 'wired',
80
+ note: 'state -> ended, carrying the ExitReason. Never inferred from silence.',
81
+ },
82
+ CwdChanged: {
83
+ handling: 'wired',
84
+ note: 'updates `where.cwd` for every later transition. Declining it would leave every subsequent record carrying a stale directory — a trace that lies quietly.',
85
+ },
86
+
87
+ // --- wired: subagents -----------------------------------------------------
88
+ SubagentStart: {
89
+ handling: 'wired',
90
+ note: 'opens a `subagent` entry keyed by agent_id, carrying agent_type.',
91
+ },
92
+ SubagentStop: {
93
+ handling: 'wired',
94
+ note: 'closes the `subagent` entry for its agent_id.',
95
+ },
96
+
97
+ // --- wired: compaction ----------------------------------------------------
98
+ PreCompact: {
99
+ handling: 'wired',
100
+ note: 'opens a `compacting` entry, carrying trigger (manual | auto).',
101
+ },
102
+ PostCompact: {
103
+ handling: 'wired',
104
+ note: 'closes the `compacting` entry.',
105
+ },
106
+
107
+ // --- wired: the permission lane -------------------------------------------
108
+ PermissionRequest: {
109
+ handling: 'wired',
110
+ note: "opens a `permission` entry for the tool. Measured not to fire for a hook-authored decision — `gate/outcome.ts` records the measurement — so the held-visibility this model buys is delivered by the gate's own hold entry (keyed by tool_use_id), not by this hook. Do not wait on this hook for entries the gate opens elsewhere; re-measure before relying on it for any other decision path.",
111
+ },
112
+ PermissionDenied: {
113
+ handling: 'wired',
114
+ note: 'closes the `permission` entry, cause kind `hook`, carrying the deny reason. A denial and a permission-path outage must not read alike: an outage arrives as cause kind `refusal` with a refusal reason, so the two differ in kind and event. Measured not to fire for a hook-authored deny: a real session whose PreToolUse returned `permissionDecision: deny` blocked the tool (no PostToolUse) but emitted no PermissionDenied — so this hook appears to belong to a decision path other than the hook lane. Whoever builds the decision path must not rely on it as the deny receipt without re-measuring.',
115
+ },
116
+
117
+ // --- wired: elicitation ---------------------------------------------------
118
+ Elicitation: {
119
+ handling: 'wired',
120
+ note: 'opens an `elicitation` entry for the MCP server. The session is genuinely blocked on an answer.',
121
+ },
122
+ ElicitationResult: {
123
+ handling: 'wired',
124
+ note: 'closes the `elicitation` entry, carrying the action (accept | decline | cancel).',
125
+ },
126
+
127
+ // --- declined -------------------------------------------------------------
128
+ Notification: {
129
+ handling: 'declined',
130
+ note: 'a display notification (message, title, notification_type). Nothing about it moves the session; it is content, and content belongs to the streaming layer.',
131
+ },
132
+ UserPromptExpansion: {
133
+ handling: 'declined',
134
+ note: 'a slash-command or MCP-prompt expansion of a prompt already submitted. The turn boundary was UserPromptSubmit; recording this too would add a transition carrying no new fact.',
135
+ },
136
+ Setup: {
137
+ handling: 'declined',
138
+ note: 'environment housekeeping (trigger: init | maintenance). It runs beside the session rather than in it, and produces no condition the session can be blocked on.',
139
+ },
140
+ TeammateIdle: {
141
+ handling: 'declined',
142
+ note: "reports that another session is idle. Recording it here would attribute a different session's state to this one — precisely the cross-session aggregation that belongs to the controller, not a host.",
143
+ },
144
+ TaskCreated: {
145
+ handling: 'declined',
146
+ note: 'the task-list surface (task_subject, teammate_name). Its overlap with the task_started/task_updated message lane has not been resolved against a live session, and wiring both would double-count one entry. Declined on the unresolved overlap, not on irrelevance.',
147
+ },
148
+ TaskCompleted: {
149
+ handling: 'declined',
150
+ note: 'the closing half of TaskCreated, declined for the same unresolved overlap. system/task_notification is the completion signal that is wired.',
151
+ },
152
+ ConfigChange: {
153
+ handling: 'declined',
154
+ note: "settings or skills changed on disk. It changes what the session can do, not what it is doing — a capability fact, and capability is the workspace layer's.",
155
+ },
156
+ InstructionsLoaded: {
157
+ handling: 'declined',
158
+ note: 'memory/CLAUDE.md loading. Context composition, which is what a turn is made of rather than a condition it is in.',
159
+ },
160
+ WorktreeCreate: {
161
+ handling: 'declined',
162
+ note: 'the agent created a worktree. It does not move the session — `where` still describes where the session runs. Relevant to whichever layer provisions workspaces.',
163
+ },
164
+ WorktreeRemove: {
165
+ handling: 'declined',
166
+ note: 'as WorktreeCreate. Note that removing the worktree a session is in would be visible through CwdChanged, which is wired.',
167
+ },
168
+ DirectoryAdded: {
169
+ handling: 'declined',
170
+ note: 'widens the set of directories the session may touch. A permission-surface fact, not a state one; it belongs with whatever enforces the path boundary.',
171
+ },
172
+ FileChanged: {
173
+ handling: 'declined',
174
+ note: 'a watched file changed (change | add | unlink). High volume, driven by the filesystem rather than by the session, and it holds nothing.',
175
+ },
176
+ MessageDisplay: {
177
+ handling: 'declined',
178
+ note: "one flush of an assistant message, indexed per delta. The highest-volume event in the set and pure content — the streaming layer's lane, not the state model's.",
179
+ },
180
+ } as const satisfies Record<HookEvent, CoverageRow>;
181
+
182
+ /**
183
+ * All 39 members of the SDKMessage union, keyed by discriminator.
184
+ *
185
+ * Fewer keys than members: several shapes share one discriminator (`user` covers the ordinary and
186
+ * the replayed user message; `result` covers success and every error subtype). The pin walks the
187
+ * union member by member and checks each one's discriminator has a row, so the many-to-one is
188
+ * accounted for rather than hidden by the shorter key list.
189
+ */
190
+ export const MESSAGE_COVERAGE = {
191
+ // --- wired ----------------------------------------------------------------
192
+ 'system/init': {
193
+ handling: 'wired',
194
+ note: 'state -> ready, and the only place the session id, CLI version receipt, model, tool/skill/plugin inventory and apiKeySource arrive. Everything downstream keys off the id it carries.',
195
+ },
196
+ 'system/status': {
197
+ handling: 'wired',
198
+ note: "SDKStatus — sets activity `requesting` or `compacting`, and closes it on null. The SDK's own word for what a session is doing, adopted verbatim.",
199
+ },
200
+ 'system/session_state_changed': {
201
+ handling: 'wired',
202
+ note: 'documented as the authoritative turn-over signal — measured not to fire: never observed on any of the 3 real turns measured; `Stop` and `result` are the observed turn boundaries. When it does arrive: idle -> state idle, running -> state working. `requires_action` deliberately does not set state: what the session requires is already carried by the open permission or elicitation entry, and a second representation would be a second vocabulary.',
203
+ },
204
+ 'system/compact_boundary': {
205
+ handling: 'wired',
206
+ note: 'compaction actually happened, with trigger and token counts. Closes the compacting entry as a backstop for a PostCompact that never arrived.',
207
+ },
208
+ 'system/task_started': {
209
+ handling: 'wired',
210
+ note: 'no transition of its own — it supplies the task_id -> tool_use_id join, without which task_updated could not name the entry it backgrounds. Wired as correlation, and saying so is the point of this column.',
211
+ },
212
+ 'system/task_updated': {
213
+ handling: 'wired',
214
+ note: '`patch.is_backgrounded` is the caused moment a task stops holding the session — the entry moves to the background lane there, never on a timer and never by inference. `patch.status` in completed | failed | killed closes it.',
215
+ },
216
+ 'system/task_notification': {
217
+ handling: 'wired',
218
+ note: 'a background task finished (completed | failed | stopped) carrying tool_use_id. The exit for backgrounded work: backgrounding is not completion, so the entry stays open and ages until this arrives.',
219
+ },
220
+ 'system/worker_shutting_down': {
221
+ handling: 'wired',
222
+ note: 'a named reason for a teardown that would otherwise present as the process simply ending. Its own type warns that absence is not a dead-host signal — handoffs and fatal paths emit nothing — so it is a why when present, never a liveness check.',
223
+ },
224
+ 'system/model_refusal_no_fallback': {
225
+ handling: 'wired',
226
+ note: 'the turn ends with no retry. Records the why; the state change itself rides result/StopFailure, so the turn end is not recorded twice.',
227
+ },
228
+ result: {
229
+ handling: 'wired',
230
+ note: 'subtype success -> idle (the backstop for a missed Stop); every error subtype -> errored, carrying terminal_reason. This is where a turn that ended without any hook firing still gets a boundary.',
231
+ },
232
+
233
+ // --- declined -------------------------------------------------------------
234
+ assistant: {
235
+ handling: 'declined',
236
+ note: 'model output. Content, owned by the streaming layer; the state it implies is already carried by system/status and system/session_state_changed.',
237
+ },
238
+ user: {
239
+ handling: 'declined',
240
+ note: 'the turn input, including the replayed echo. UserPromptSubmit is the wired boundary; this is the payload that crossed it.',
241
+ },
242
+ stream_event: {
243
+ handling: 'declined',
244
+ note: "partial assistant deltas. The highest-volume message in the union and pure content — the streaming layer's.",
245
+ },
246
+ tool_progress: {
247
+ handling: 'declined',
248
+ note: "carries elapsed_time_seconds for a running tool. Declined deliberately: the open entry's age is computed from its own openedAt, and a second elapsed-time source is a number that can disagree with the trace.",
249
+ },
250
+ tool_use_summary: {
251
+ handling: 'declined',
252
+ note: 'a prose summary of preceding tool calls. Content.',
253
+ },
254
+ auth_status: {
255
+ handling: 'declined',
256
+ note: 'isAuthenticating plus provider output. An identity-plane fact; the session is not blocked on it in a way this model can name.',
257
+ },
258
+ rate_limit_event: {
259
+ handling: 'declined',
260
+ note: 'rate-limit windows. Usage and cost belong to the telemetry lane, which reads the same stream.',
261
+ },
262
+ prompt_suggestion: {
263
+ handling: 'declined',
264
+ note: 'a predicted next prompt. A suggestion for a human; nothing has happened.',
265
+ },
266
+ conversation_reset: {
267
+ handling: 'declined',
268
+ note: "/clear, plan-mode exit and fresh-session flows mint a new conversation id. The session continues — what changed is which transcript later reads attach to, which is the persistence layer's question.",
269
+ },
270
+ 'system/api_retry': {
271
+ handling: 'declined',
272
+ note: "a retryable API failure being retried. The session stays in `requesting`, and a session stuck in retries is already visible as that entry's growing age rather than needing a state of its own.",
273
+ },
274
+ 'system/control_request_progress': {
275
+ handling: 'declined',
276
+ note: 'progress for a client-originated control request. It belongs to whoever made that request, correlated by its own request_id.',
277
+ },
278
+ 'system/model_refusal_fallback': {
279
+ handling: 'declined',
280
+ note: 'the turn was retried on a fallback model and continues. Nothing ended; the no-fallback counterpart is the one that is wired.',
281
+ },
282
+ 'system/local_command_output': {
283
+ handling: 'declined',
284
+ note: 'output from a local slash command. Content.',
285
+ },
286
+ 'system/hook_started': {
287
+ handling: 'declined',
288
+ note: 'the lifecycle of a command hook (hook_id, hook_name, stdout/stderr). This host installs in-process callbacks and runs no command hooks, so these describe a mechanism it does not use.',
289
+ },
290
+ 'system/hook_progress': {
291
+ handling: 'declined',
292
+ note: 'as system/hook_started — the command-hook mechanism, unused here.',
293
+ },
294
+ 'system/hook_response': {
295
+ handling: 'declined',
296
+ note: 'as system/hook_started. Worth revisiting by whoever builds the permission decision path if command hooks are ever installed alongside the in-process ones.',
297
+ },
298
+ 'system/plugin_install': {
299
+ handling: 'declined',
300
+ note: 'headless plugin installation progress. A provisioning fact that precedes work rather than being work.',
301
+ },
302
+ 'system/task_progress': {
303
+ handling: 'declined',
304
+ note: "periodic progress for a running task. High volume, and the entry's own age is the number that matters.",
305
+ },
306
+ 'system/background_tasks_changed': {
307
+ handling: 'declined',
308
+ note: "declined on the type's own instruction. It is a level signal with replace semantics whose docs say the payload carries ids only and must not be correlated with the edge stream — so consuming it would mean rebuilding the entry set from ids, losing every entry's age and opening cause. The edges (task_started / task_updated / task_notification) are wired instead, and an edge that goes missing surfaces as an open entry with an age, which is the useful signal rather than the one a rebuild would erase.",
309
+ },
310
+ 'system/thinking_tokens': {
311
+ handling: 'declined',
312
+ note: "a live thinking-token estimate for spinners. Explicitly approximate in its own docs, and telemetry's if anyone wants it.",
313
+ },
314
+ 'system/commands_changed': {
315
+ handling: 'declined',
316
+ note: 'the slash-command list changed mid-session. A capability fact — what the session can do.',
317
+ },
318
+ 'system/notification': {
319
+ handling: 'declined',
320
+ note: 'a loop-side text notification with a priority. Display.',
321
+ },
322
+ 'system/files_persisted': {
323
+ handling: 'declined',
324
+ note: "file persistence results. The persistence layer's.",
325
+ },
326
+ 'system/memory_recall': {
327
+ handling: 'declined',
328
+ note: 'memories surfaced into the turn. Context composition.',
329
+ },
330
+ 'system/elicitation_complete': {
331
+ handling: 'declined',
332
+ note: 'a URL-mode elicitation confirmed complete by the MCP server. The ElicitationResult hook already closes the entry; consuming both would close it twice.',
333
+ },
334
+ 'system/permission_denied': {
335
+ handling: 'declined',
336
+ note: 'mirrors the PermissionDenied hook, which is wired and additionally carries the deny reason. Consuming both would record one denial as two.',
337
+ },
338
+ 'system/mirror_error': {
339
+ handling: 'declined',
340
+ note: "a real degrade — a transcript-mirror batch was dropped after retries. Declined here because it changes what can be read later, not what the session is doing; it is the persistence layer's to surface, and it must not be lost there.",
341
+ },
342
+ 'system/informational': {
343
+ handling: 'declined',
344
+ note: 'a generic text banner, including hook feedback. Content.',
345
+ },
346
+ } as const satisfies Record<MessageDiscriminator, CoverageRow>;
347
+
348
+ /** Counts for the table's own summary line, computed rather than typed in — one less thing to rot. */
349
+ export function coverageTally(table: Record<string, CoverageRow>): {
350
+ total: number;
351
+ wired: number;
352
+ declined: number;
353
+ } {
354
+ const rows = Object.values(table);
355
+ return {
356
+ total: rows.length,
357
+ wired: rows.filter((row) => row.handling === 'wired').length,
358
+ declined: rows.filter((row) => row.handling === 'declined').length,
359
+ };
360
+ }