@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,295 @@
1
+ /**
2
+ * The shell backstop — two layers, and the second exists because the first was insufficient.
3
+ *
4
+ * A shell command cannot be proven safe by looking at a path, so this is the only control over what
5
+ * a shell tool call can reach outside the workspace. Both layers run; either one refusing is enough.
6
+ *
7
+ * Layer 1, the denylist. Named boundary operations — publishing, remote surgery, branch deletion,
8
+ * merging — matched over the whole scannable text with `[\s\S]`, so a newline cannot hide one
9
+ * inside a compound command.
10
+ *
11
+ * Layer 2, the git verb allowlist. This layer is not redundancy; it closes a real defect class.
12
+ * `git send-pack` — the plumbing that `git push` calls underneath — matches no denylist pattern,
13
+ * and a denylist-only classifier approved it as benign shell, which force-pushed a main branch
14
+ * under an automated actor. A denylist under-includes by construction: it can only refuse what
15
+ * somebody thought of. So a git invocation whose verb is not provably safe is refused without
16
+ * enumeration, which covers `send-pack`, `receive-pack`, aliases, and every verb git ships in a
17
+ * future release.
18
+ *
19
+ * The input is parsed, not raw — see `command.ts` for the defects a raw scan produces. Layer 1
20
+ * scans `ParsedCommand.scannable`, which is the raw command minus comments and provably-inert data
21
+ * payloads; layer 2 reads the parsed invocations. That narrows what is scanned and never what is
22
+ * denied.
23
+ *
24
+ * The rule that decides every ambiguous case: a false refusal costs one human click; a false allow
25
+ * costs the invariant. Every unknown shape in this file resolves toward refusing.
26
+ */
27
+ import type { Refusal } from '../core/refusal.js';
28
+ import { refusal } from '../core/refusal.js';
29
+ import type { ParsedCommand } from './command.js';
30
+ import { parseCommand } from './command.js';
31
+
32
+ /**
33
+ * A named boundary rule and the pattern that recognises it.
34
+ *
35
+ * The name reaches the model verbatim in the refusal detail, because a refusal that does not say
36
+ * which rule fired leaves the reader to guess, and a degrade is a named outcome.
37
+ */
38
+ interface BoundaryRule {
39
+ readonly name: string;
40
+ readonly pattern: RegExp;
41
+ readonly detail: string;
42
+ /**
43
+ * Evaluate the pattern per top-level command segment rather than over the whole string.
44
+ *
45
+ * The segmentation is the authority for what one command is; these patterns keep sole authority
46
+ * for which shapes of one command refuse. A whole-string scan lets a flag belonging to a piped
47
+ * command complete a pattern that began in an earlier one: `git branch -r --contains <sha> | tr
48
+ * -d ' '` refused as "branch deletion" because the `-d` came from `tr`. That is a read-only
49
+ * listing, and refusing it costs a human click for nothing.
50
+ *
51
+ * Narrows what is scanned, never what is denied. A match inside any single segment still
52
+ * refuses; only cross-segment stitches — which the segmentation rules to be two commands — are
53
+ * discarded, and layer 2 is untouched either way.
54
+ */
55
+ readonly segmentScoped?: boolean;
56
+ }
57
+
58
+ /**
59
+ * Separators that end one top-level command, mirroring `command.ts`'s own set.
60
+ *
61
+ * Quote spans are not tracked here on purpose: an unbalanced or quoted separator yields more
62
+ * segments, and more segments can only ever make a pattern harder to satisfy... which would be the
63
+ * wrong direction. So the split runs over `scannable`, whose inert payloads are already masked, and
64
+ * a segment that cannot be determined is scanned as part of a larger one — the refusing direction.
65
+ */
66
+ const SEGMENT_SPLIT = /[|&;\n\r()`]+/;
67
+
68
+ /** Does `pattern` match inside any single top-level segment of `text`? */
69
+ function matchesInAnySegment(pattern: RegExp, text: string): boolean {
70
+ return text.split(SEGMENT_SPLIT).some((segment) => pattern.test(segment));
71
+ }
72
+
73
+ /**
74
+ * The denylist, in match order. `[\s\S]` rather than `.` throughout: a compound command spanning a
75
+ * newline must not be able to hide a boundary operation behind the line break.
76
+ */
77
+ const BOUNDARY_RULES: readonly BoundaryRule[] = [
78
+ {
79
+ name: 'git-push',
80
+ // The lookbehind excises exactly one token pair: `stash push`. `git stash push -- <paths>` is the
81
+ // only canonical partial-stash form, and it parsed as a publish — so that operation was literally
82
+ // unperformable. A stash cannot publish anything, and layer 2 admits `stash` as a known-safe verb.
83
+ // The exclusion is whole-token (a bare `\b` matches inside a hyphenated token, so `my-stash push`
84
+ // had the suffix of `my-stash` read as the excluded token) and adjacency-strict, so
85
+ // `git stash push … ; git push origin main` still matches on the second occurrence.
86
+ pattern: /\bgit\b[\s\S]*?(?<!(?:^|[\s;&|(){}])stash\s{1,8})\bpush\b/i,
87
+ detail: 'publishing to a remote is a human decision, not an agent one',
88
+ },
89
+ {
90
+ name: 'git-remote-surgery',
91
+ // The lookbehind keeps `git ls-remote` — a remote query — out of this rule, whole-token for the
92
+ // same reason as above (a bare substring exclusion lets any token ending in `ls-` suppress it).
93
+ //
94
+ // The read-only forms are exempt, and the exemption is exactly these shapes: `-v`,
95
+ // `--verbose`, `show`, and the bare list. Everything mutating still refuses — `add`, `set-url`,
96
+ // `set-head`, `set-branches`, `rename`, `remove`, `prune`, `update`. `get-url` is read-only and
97
+ // still refuses on purpose: an exemption set that grows by inference is how a boundary erodes.
98
+ pattern: /\bgit\b[\s\S]*?(?<!(?:^|[\s;&|(){}])ls-)\bremote\b(?!\s+(?:-v|--verbose|show)\b)(?!\s*$)/i,
99
+ detail: 'changing where this repository publishes is a human decision',
100
+ },
101
+ {
102
+ name: 'git-branch-delete',
103
+ // Plain `git branch`, `--show-current` and `checkout -b` all flow; `git push --delete` is caught
104
+ // by the publish rule above.
105
+ //
106
+ // Segment-scoped. `[\s\S]*?` crosses pipes, so a read-only listing would borrow its `-d` from
107
+ // a downstream `tr -d ' '` and refuse as a deletion. The publish rule is deliberately not
108
+ // scoped: a push anywhere in a piped command is a push.
109
+ pattern: /\bgit\b[\s\S]*?\bbranch\b[\s\S]*?(\s-[dD]\b|--delete\b)/i,
110
+ detail: 'deleting a branch destroys history a human may still need',
111
+ segmentScoped: true,
112
+ },
113
+ {
114
+ name: 'gh-pr-merge',
115
+ pattern: /\bgh\b[\s\S]*?\bpr\b[\s\S]*?\bmerge\b/i,
116
+ detail: 'merging is a human boundary',
117
+ },
118
+ ];
119
+
120
+ /**
121
+ * Git verbs that are known-safe: reads, local-only writes, and download-only network.
122
+ *
123
+ * The organising idea is publish. None of these can send anything to a remote, which is why
124
+ * `fetch`, `pull` and `ls-remote` sit here despite touching the network, and why `commit` does too —
125
+ * a local commit publishes nothing.
126
+ *
127
+ * Exact-case: git subcommands are lowercase, so a cased oddity is not proven safe and refuses.
128
+ */
129
+ const ALLOWED_GIT_VERBS: ReadonlySet<string> = new Set([
130
+ // reads
131
+ 'status',
132
+ 'log',
133
+ 'diff',
134
+ 'show',
135
+ 'rev-parse',
136
+ 'describe',
137
+ 'blame',
138
+ 'grep',
139
+ 'shortlog',
140
+ 'reflog',
141
+ 'ls-files',
142
+ 'ls-tree',
143
+ 'cat-file',
144
+ 'merge-base',
145
+ 'rev-list',
146
+ 'check-ignore',
147
+ 'check-attr',
148
+ // read-only terminal flags, surfaced as the verb when nothing else is: git prints and exits.
149
+ '--version',
150
+ '--help',
151
+ // local-only writes. `branch -d` and friends are already refused by the denylist above.
152
+ 'add',
153
+ 'commit',
154
+ 'restore',
155
+ 'checkout',
156
+ 'switch',
157
+ 'branch',
158
+ 'stash',
159
+ 'init',
160
+ 'merge',
161
+ 'rebase',
162
+ 'cherry-pick',
163
+ 'revert',
164
+ 'reset',
165
+ 'rm',
166
+ 'mv',
167
+ 'tag',
168
+ 'clean',
169
+ 'apply',
170
+ // `hash-object` sits with the local writes rather than the reads, and the placement is the point.
171
+ // Its common form only prints a sha, but `-w` writes a loose object — and this allowlist is
172
+ // verb-level, so admitting the verb admits `-w`. It qualifies on the same ground `init` does: an
173
+ // unreferenced loose object cannot publish anything.
174
+ 'hash-object',
175
+ // download-only network — cannot publish.
176
+ 'fetch',
177
+ 'pull',
178
+ 'ls-remote',
179
+ ]);
180
+
181
+ /** `git config` flags that only read. Exact tokens, never prefixes. */
182
+ const CONFIG_READ_FLAGS: ReadonlySet<string> = new Set([
183
+ '--get',
184
+ '--get-all',
185
+ '--get-regexp',
186
+ '--get-urlmatch',
187
+ '--get-color',
188
+ '--get-colorbool',
189
+ '--list',
190
+ '-l',
191
+ ]);
192
+
193
+ /** `git config` flags that mutate. If present, refuse, even beside a read flag. */
194
+ const CONFIG_WRITE_FLAGS: ReadonlySet<string> = new Set([
195
+ '--add',
196
+ '--unset',
197
+ '--unset-all',
198
+ '--replace-all',
199
+ '--set-all',
200
+ '--edit',
201
+ '-e',
202
+ '--rename-section',
203
+ '--remove-section',
204
+ '--default',
205
+ ]);
206
+
207
+ /** The `git remote` shapes that only print. The same three the denylist above exempts. */
208
+ const REMOTE_READ_FLAGS: ReadonlySet<string> = new Set(['-v', '--verbose']);
209
+
210
+ /**
211
+ * `git config` is a read verb and a write verb wearing one name, so it cannot sit in the allowlist:
212
+ * a bare `git config user.email you@example` is a write and reads identically to a query without
213
+ * this rule. Only the provably-reading shapes are admitted.
214
+ */
215
+ function isReadOnlyConfig(args: readonly string[]): boolean {
216
+ let sawRead = false;
217
+ for (const argument of args) {
218
+ if (CONFIG_WRITE_FLAGS.has(argument)) return false;
219
+ if (CONFIG_READ_FLAGS.has(argument)) sawRead = true;
220
+ }
221
+ return sawRead;
222
+ }
223
+
224
+ /**
225
+ * `git remote`, same problem and same treatment. Admitted: the bare list, `-v`/`--verbose`, and
226
+ * `show <name>`. Anything else — a subcommand, an unknown flag — is not proven read-only and refuses.
227
+ *
228
+ * Both layers must agree before a remote command flows. The vocabulary is stated twice on purpose
229
+ * rather than shared through one looser predicate: two independent statements of the same three
230
+ * shapes cannot both be widened by accident.
231
+ */
232
+ function isReadOnlyRemote(args: readonly string[]): boolean {
233
+ for (const argument of args) {
234
+ if (argument === 'show') return true;
235
+ if (REMOTE_READ_FLAGS.has(argument)) continue;
236
+ return false;
237
+ }
238
+ return true;
239
+ }
240
+
241
+ function isAllowedGitInvocation(verb: string, args: readonly string[]): boolean {
242
+ if (ALLOWED_GIT_VERBS.has(verb)) return true;
243
+ if (verb === 'config') return isReadOnlyConfig(args);
244
+ if (verb === 'remote') return isReadOnlyRemote(args);
245
+ return false;
246
+ }
247
+
248
+ // ---------------------------------------------------------------------------
249
+
250
+ /**
251
+ * Classify a shell command. Returns the refusal that fired, or null when nothing did.
252
+ *
253
+ * `parsed` may be supplied by a caller that already parsed the command — the credential check does,
254
+ * and parsing twice would be work for nothing.
255
+ */
256
+ export function classifyShellCommand(
257
+ command: string,
258
+ parsed: ParsedCommand = parseCommand(command),
259
+ ): Refusal | null {
260
+ // Layer 1 — the denylist, over text the shell could actually execute.
261
+ for (const rule of BOUNDARY_RULES) {
262
+ const fired =
263
+ rule.segmentScoped === true
264
+ ? matchesInAnySegment(rule.pattern, parsed.scannable)
265
+ : rule.pattern.test(parsed.scannable);
266
+ if (fired) {
267
+ return refusal('shell-boundary-command', `${rule.name}: ${rule.detail}`);
268
+ }
269
+ }
270
+
271
+ // Layer 2 — the git verb allowlist. Non-git invocations are not this layer's business; shell
272
+ // commands as a whole are covered by the workspace jail, this file, and the audit trail together.
273
+ //
274
+ // It is deliberately git-only. Extending an allowlist posture to another program is a boundary
275
+ // decision that belongs to the embedder's policy (`ToolFamilies`, the local gate's options), not
276
+ // to this file.
277
+ for (const invocation of parsed.invocations) {
278
+ if (invocation.program !== 'git') continue;
279
+ if (invocation.verb === null) {
280
+ return refusal(
281
+ 'shell-verb-unrecognised',
282
+ 'a git invocation with no determinable verb cannot be shown to be safe, so it is refused',
283
+ );
284
+ }
285
+ if (!isAllowedGitInvocation(invocation.verb, invocation.args)) {
286
+ return refusal(
287
+ 'shell-verb-unrecognised',
288
+ `git ${invocation.verb} is not a verb this host can prove is safe; the allowlist refuses ` +
289
+ 'anything it cannot name, which is what covers the plumbing forms of publishing',
290
+ );
291
+ }
292
+ }
293
+
294
+ return null;
295
+ }