opencode-turnstile 0.2.1 → 0.2.5

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.
@@ -0,0 +1,683 @@
1
+ /**
2
+ * @fileoverview Tool-call policy boundary: stateless matching for the tools
3
+ * that flow through `execute.before` — bash/shell commands (`guardBash`,
4
+ * `guardCommitMessage`, `observeLanding`, `guardLanding`) and write/edit target
5
+ * paths (`guardPath`) — plus non-blocking `session_move` telemetry
6
+ * (`observeSessionMove`). The `bashDeny`/`pathDeny` denylist sources and the
7
+ * landing/commit-format regexes compile once at creation; the hot path is a
8
+ * cached `RegExp.test`. A match throws the same `BlockError` the gate layer
9
+ * throws (the hook rethrows it), and every internal failure fails open
10
+ * (journaled, never thrown).
11
+ *
12
+ * First-match wins: the lowest-indexed matching rule is the one recorded and
13
+ * blocked. A source that fails to compile is dropped (defensively re-checked
14
+ * here even though `mergeConfig` drops it first) and journaled as a persistent
15
+ * `{type:"error"}`, so a config typo that weakens the denylist is never silent.
16
+ * See the `src/types.ts` `TurnstileToolPolicy` contract for the config shape.
17
+ */
18
+
19
+ import { DEFAULT_COMMIT_FORMAT } from "./config.ts"
20
+ import { BlockError } from "./gates.ts"
21
+ import type { Journaler } from "./journal.ts"
22
+ import type { TurnstileConfig } from "./types.ts"
23
+
24
+ /** One compiled denylist rule, paired with its source index for the journal. */
25
+ interface CompiledRule {
26
+ re: RegExp
27
+ index: number
28
+ source: string
29
+ }
30
+
31
+ // Splits a command into simple commands on top-level shell separators
32
+ // (`;` `|` `&` `\r` `\n`), quote- and backslash-aware: a backslash escapes the
33
+ // next character (`\;` inside quotes is not a separator, `\"` does not close a
34
+ // quote early), so a quoted `echo "git commit"` stays on one side of a
35
+ // separator (never parsed as an invocation). Shared with the commit-lint guard
36
+ // below so landing detection and commit linting parse the same shape.
37
+ export function splitCommands(command: string): string[] {
38
+ const out: string[] = []
39
+ let cur = ""
40
+ let quote = ""
41
+ let escaped = false
42
+ for (const ch of command) {
43
+ if (escaped) {
44
+ // The previous backslash consumed this character: append it verbatim.
45
+ cur += ch
46
+ escaped = false
47
+ continue
48
+ }
49
+ if (quote) {
50
+ if (ch === "\\") {
51
+ escaped = true
52
+ cur += ch
53
+ continue
54
+ }
55
+ if (ch === quote) quote = ""
56
+ cur += ch
57
+ continue
58
+ }
59
+ if (ch === "\\") {
60
+ escaped = true
61
+ cur += ch
62
+ continue
63
+ }
64
+ if (ch === '"' || ch === "'") {
65
+ quote = ch
66
+ cur += ch
67
+ continue
68
+ }
69
+ if (ch === ";" || ch === "|" || ch === "&" || ch === "\r" || ch === "\n") {
70
+ out.push(cur)
71
+ cur = ""
72
+ continue
73
+ }
74
+ cur += ch
75
+ }
76
+ out.push(cur)
77
+ return out
78
+ }
79
+
80
+ // Tokenizes one simple command into quoted-aware words, stripping the quotes so
81
+ // a `-m "feat: x"` word yields the bare message `feat: x`. Honors the same
82
+ // backslash escape so a `\"` inside a double-quoted word is a literal quote,
83
+ // not a word break.
84
+ export function tokenizeWords(input: string): string[] {
85
+ const words: string[] = []
86
+ let cur = ""
87
+ let quote = ""
88
+ let escaped = false
89
+ for (const ch of input) {
90
+ if (escaped) {
91
+ cur += ch
92
+ escaped = false
93
+ continue
94
+ }
95
+ if (quote) {
96
+ if (ch === "\\") {
97
+ escaped = true
98
+ cur += ch
99
+ continue
100
+ }
101
+ if (ch === quote) quote = ""
102
+ else cur += ch
103
+ continue
104
+ }
105
+ if (ch === "\\") {
106
+ escaped = true
107
+ cur += ch
108
+ continue
109
+ }
110
+ if (ch === '"' || ch === "'") {
111
+ quote = ch
112
+ continue
113
+ }
114
+ if (/\s/.test(ch)) {
115
+ if (cur) words.push(cur)
116
+ cur = ""
117
+ continue
118
+ }
119
+ cur += ch
120
+ }
121
+ if (cur) words.push(cur)
122
+ return words
123
+ }
124
+
125
+ // Returns the git subcommand of one simple command's words (skipping leading
126
+ // git global options such as `-C dir` / `--git-dir=dir`, each of which may take
127
+ // a value in the following token), or null when `words` is not a `git`
128
+ // invocation. A landing candidate is `git` followed by the `push` subcommand.
129
+ function gitVerb(words: string[]): string | null {
130
+ if (words.length === 0 || words[0] !== "git") return null
131
+ let i = 1
132
+ while (i < words.length && words[i]!.startsWith("-")) {
133
+ i++ // skip the option token itself
134
+ // A value held in the following token (e.g. `-C dir`) is a global option
135
+ // value and is skipped; an inline value (`--opt=val`) has none.
136
+ if (i < words.length && !words[i]!.startsWith("-")) i++
137
+ }
138
+ return i < words.length ? words[i]! : null
139
+ }
140
+
141
+ /**
142
+ * True when `command` invokes a `git push` — the landing gate's enforcement
143
+ * target. Detection routes through the shell-aware tokenizer above (shared with
144
+ * commit linting) so the candidate set has no false positives: `git push`
145
+ * matches (tolerating global options like `-C dir` before the subcommand), but
146
+ * `echo remember to git push`, `git commit -m "docs: git push"`, and
147
+ * `git log --grep push` do not (their subcommand is `echo`/`commit`/`log`).
148
+ * Exported so `setup.ts` arms the evaluate-handler ask against this exact
149
+ * candidate set (single source of truth; no duplicate-regex drift).
150
+ * @param command The raw bash/shell command string.
151
+ * @returns True for a `git push` candidate.
152
+ */
153
+ export function isPushLanding(command: string): boolean {
154
+ if (typeof command !== "string" || command.trim() === "") return false
155
+ return splitCommands(command).some((simple) => gitVerb(tokenizeWords(simple)) === "push")
156
+ }
157
+
158
+ /**
159
+ * Core landing-gate text shared by the enforce-path throw and the human ask
160
+ * message, so the two never drift. Stamped after `BLOCKED by turnstile:` (the
161
+ * ask message is `turnstile: ${LANDING_REQUIREMENT}`, prefixed so the stream
162
+ * consumer can recognize a landing ask by its `turnstile: push/PR …` prefix).
163
+ */
164
+ export const LANDING_REQUIREMENT =
165
+ "push/PR requires a reviewer VERDICT: APPROVE before landing — wait for the reviewer or escalate to the user"
166
+
167
+ /**
168
+ * Suffix added to the invalid-commit-block ask, describing the accepted shape.
169
+ * @returns The "expected …" suffix.
170
+ */
171
+ const COMMIT_EXPECTED = "is not a conventional commit — expected type(scope)!: subject"
172
+
173
+ /**
174
+ * The stateful landing-gate capability `setup.ts` hands to {@link ToolPolicy.guardLanding}
175
+ * so the stateful ledger never crosses into the pure tool-policy boundary. The
176
+ * stateful side (`landingBlocks`, `hasReviewApprove`) lives only in `setup.ts`;
177
+ * tool-policy mutates it exclusively through `recordBlock`/`escalate`.
178
+ */
179
+ export interface LandingGateCapability {
180
+ /** True when the task already carries a reviewer `VERDICT: APPROVE` (release signal). */
181
+ hasApproval(): boolean
182
+ /** Count of `no-landing-approval` blocks recorded this task. */
183
+ priorBlocks(): number
184
+ /** Increment {@link LandingGateCapability.priorBlocks} (block path only). */
185
+ recordBlock(): void
186
+ /** Signal the setup-side permission wiring to arm the human ask (repeat attempt). */
187
+ escalate(): void
188
+ }
189
+
190
+ /**
191
+ * The stateless tool-call policy bound to one resolved config + journaler.
192
+ * Every guard fails open on an internal error; only a deliberate block throws.
193
+ */
194
+ export interface ToolPolicy {
195
+ /**
196
+ * Yields on allow; journals and throws (`BlockError`) under `enforce`, or
197
+ * journals only under `shadow`, when `command` matches a `bashDeny` rule;
198
+ * journals `{type:"error"}` and allows on an empty/absent command; no-op
199
+ * under `off`.
200
+ *
201
+ * The `command` argument is the host payload value coerced by `setup.ts` via
202
+ * `event.input?.command ?? event.input?.cmd` — those two keys are the
203
+ * documented candidates; if the host reshapes the payload the empty-command
204
+ * path journals the misconfiguration so the change is observable rather than
205
+ * silently allowed.
206
+ * @param tool The tool name, recorded in the journal (`"bash"` or `"shell"`).
207
+ * @param command The raw command value from the host payload (typed `unknown`).
208
+ * @param sessionID The session ID, recorded in journal entries.
209
+ * @throws Error with a `BLOCKED by turnstile:` prefix under `enforce` only.
210
+ */
211
+ guardBash(tool: "bash" | "shell", command: unknown, sessionID: string): void
212
+ /**
213
+ * Yields on allow; journals and throws (`BlockError`) under `enforce`, or
214
+ * journals only under `shadow`, when `filePath` matches a `pathDeny` rule;
215
+ * journals `{type:"error"}` and allows on an empty/absent path; no-op under
216
+ * `off`.
217
+ *
218
+ * The `filePath` argument is the host payload value coerced by `setup.ts` via
219
+ * `event.input?.filePath ?? event.input?.path` — those two keys are the
220
+ * documented candidates; if the host reshapes the payload the empty-path path
221
+ * journals the misconfiguration so the change is observable.
222
+ * @param tool The tool name, recorded in the journal (`"write"` or `"edit"`).
223
+ * @param filePath The raw target-path value from the host payload (`unknown`).
224
+ * @param sessionID The session ID, recorded in journal entries.
225
+ * @throws Error with a `BLOCKED by turnstile:` prefix under `enforce` only.
226
+ */
227
+ guardPath(tool: "write" | "edit", filePath: unknown, sessionID: string): void
228
+ /**
229
+ * Journals a `session_move` dispatch as telemetry; never blocks. Only writes
230
+ * when `toolPolicy.observeSessionMove` is true. The directory is recorded only
231
+ * when the trace is enabled (the same redaction gating `bash` commands).
232
+ * @param sessionID The session ID, recorded in the journal entry.
233
+ * @param directory The move target directory from the host payload (`unknown`).
234
+ */
235
+ observeSessionMove(sessionID: string, directory: unknown): void
236
+ /**
237
+ * Lints `git commit` messages as conventional commits under `gitGates.commitMessages`.
238
+ * Extracts `git commit` invocations (not substrings such as `git commit-graph`
239
+ * or `echo git commit`), validates each `-m` argument against the compiled
240
+ * `commitFormat`, and for uninspectable forms (`-F`/`-t`/`--edit`/`-e` or a
241
+ * bare interactive commit) fail-closes: journals `{type:"blocked",
242
+ * reason:"commit-message-uninspectable"}` and throws (`BlockError`) under
243
+ * `enforce`, journals only under `shadow`, no-op under `off`. A `-m` message
244
+ * that fails to match journals `{type:"blocked", reason:"invalid-commit-message"}`
245
+ * the same way. Every internal failure fails open.
246
+ * @param tool The tool name, recorded in the journal (`"bash"` or `"shell"`).
247
+ * @param command The raw command value from the host payload (`unknown`).
248
+ * @param sessionID The session ID, recorded in journal entries.
249
+ * @throws Error with a `BLOCKED by turnstile:` prefix under `enforce` only.
250
+ */
251
+ guardCommitMessage(tool: "bash" | "shell", command: unknown, sessionID: string): void
252
+ /**
253
+ * Journals `{type:"tool", tool:"bash", landing:true}` telemetry for
254
+ * `git push` / `gh pr create|ready|merge` candidates — in every mode
255
+ * (non-blocking). Never touches `gitGates.mode`: a landing deny comes from
256
+ * `guardLanding`, and `gh pr` is denied wholesale by the `bashDeny` rule
257
+ * before this runs. Fail-open on an internal error.
258
+ * @param sessionID The session ID, recorded in the journal entry.
259
+ * @param command The raw command value from the host payload (`unknown`).
260
+ */
261
+ observeLanding(sessionID: string, command: unknown): void
262
+ /**
263
+ * Gates a `git push` on a reviewer `VERDICT: APPROVE` under `gitGates.landingGate`.
264
+ * Candidate detection is {@link isPushLanding} (the single source of truth
265
+ * also used by `setup.ts` to arm the evaluate-handler ask). `gate` is the
266
+ * stateful capability from `setup.ts`. With approval: allow, no journal noise.
267
+ * Without approval, first block this task: journal
268
+ * `{type:"blocked", reason:"no-landing-approval"}`, call `gate.recordBlock()`,
269
+ * and throw (`BlockError`) under `enforce`, journal-only under `shadow`,
270
+ * no-op under `off`. A repeat attempt (`priorBlocks() >= 1`) without approval
271
+ * under `enforce` journals `{type:"permission", action:"ask", reason:"landing"}`
272
+ * and calls `gate.escalate()` (arming the pending-landing-ask latch);
273
+ * `execute.before` then passes so the setup-side evaluate handler forces the
274
+ * human ask naming the missing reviewer verdict. `shadow` journals only; `off`
275
+ * is a no-op. Fail-toward-strict: a host `reject` leaves approval absent, so
276
+ * the next attempt re-blocks and re-arms the ask. Fail-open on an internal
277
+ * error.
278
+ * @param tool The tool name, recorded in the journal.
279
+ * @param command The raw command value from the host payload (`unknown`).
280
+ * @param sessionID The session ID, recorded in journal entries.
281
+ * @param gate The landing-gate capability supplied by `setup.ts`.
282
+ * @throws Error with a `BLOCKED by turnstile:` prefix under `enforce` only.
283
+ */
284
+ guardLanding(
285
+ tool: "bash" | "shell",
286
+ command: unknown,
287
+ sessionID: string,
288
+ gate: LandingGateCapability,
289
+ ): void
290
+ }
291
+
292
+ /**
293
+ * Binds the tool-call policy to a resolved config and journaler, compiling the
294
+ * denylists once (each rule paired with its source index) so every later guard
295
+ * is a cached `RegExp.test`.
296
+ * @param config The resolved plugin configuration.
297
+ * @param journaler The telemetry writer for blocked and error entries.
298
+ * @returns The stateless tool-call policy.
299
+ */
300
+ export function createToolPolicy(config: TurnstileConfig, journaler: Journaler): ToolPolicy {
301
+ const mode = config.toolPolicy.mode
302
+ const gateMode = config.gitGates.mode
303
+
304
+ /**
305
+ * Compiles a denylist, dropping and journaling any non-compiling source.
306
+ * A drop is journaled as a persistent `{type:"error"}` so a typed rule that
307
+ * weakens the denylist is recorded for the operator, never silent.
308
+ * @param name Which list is compiled (`"bashDeny"` or `"pathDeny"`).
309
+ * @param sources The regex sources to compile.
310
+ * @returns The compilable rules, each paired with its source index.
311
+ */
312
+ function compile(name: "bashDeny" | "pathDeny", sources: string[]): CompiledRule[] {
313
+ const rules: CompiledRule[] = []
314
+ sources.forEach((source, index) => {
315
+ try {
316
+ rules.push({ re: new RegExp(source, "i"), index, source })
317
+ } catch {
318
+ journaler.journal({
319
+ type: "error",
320
+ error: `tool policy: dropped invalid ${name}[${index}] regex "${source}"; the operation it guarded is now allowed`,
321
+ })
322
+ }
323
+ })
324
+ return rules
325
+ }
326
+
327
+ const bashDeny = compile("bashDeny", config.toolPolicy.bashDeny)
328
+ const pathDeny = compile("pathDeny", config.toolPolicy.pathDeny)
329
+
330
+ // The commit-format matcher: `commitFormat` is a conventional-commits regex
331
+ // SOURCE (no anchors); callers wrap it with `"^…$"` + the `"s"` flag so `.`
332
+ // spans the optional body line. The default (a validated conventional-commit
333
+ // source) is the fall-back when the configured source does not compile —
334
+ // `mergeConfig` already drops invalid sources, so this is a defensive
335
+ // re-check, but it is journaled (never silent) if a source slips through.
336
+ const commitFormat = (() => {
337
+ const source =
338
+ typeof config.gitGates.commitFormat === "string"
339
+ ? config.gitGates.commitFormat
340
+ : DEFAULT_COMMIT_FORMAT
341
+ try {
342
+ return new RegExp(`^${source}$`, "s")
343
+ } catch {
344
+ journaler.journal({
345
+ type: "error",
346
+ error: `tool policy: dropped invalid gitGates.commitFormat "${source}"; falling back to the conventional-commit default`,
347
+ })
348
+ return new RegExp(`^${DEFAULT_COMMIT_FORMAT}$`, "s")
349
+ }
350
+ })()
351
+ // The landing candidate matchers (compiled once). `pushRe` gates a `git push`
352
+ // on a reviewer verdict (guardLanding); `ghPrRe` additionally covers `gh pr
353
+ // ready` for telemetry only, since `gh pr create|merge` is denied by the
354
+ // bashDeny rule before observeLanding ever runs. Both use the same
355
+ // global-option-tolerant base as bashDeny so a `git [opts] push` still matches.
356
+ const ghPrRe = new RegExp("\\bgh\\s+pr\\s+(create|ready|merge)\\b")
357
+
358
+ /**
359
+ * Journals a block (identical under every mode save the stamped `mode`) and
360
+ * returns true when the caller must throw (`enforce`). Fail-open: an internal
361
+ * error is journaled and the guard returns without throwing.
362
+ * @param tool The tool name, recorded in the journal entry.
363
+ * @param rule The matching rule.
364
+ * @param sessionID The session ID, recorded in journal entries.
365
+ */
366
+ function journalBlocked(tool: string, rule: CompiledRule, sessionID: string): boolean {
367
+ journaler.journal({
368
+ type: "blocked",
369
+ sessionID,
370
+ tool,
371
+ reason: "tool-policy",
372
+ rule: rule.index,
373
+ mode,
374
+ })
375
+ journaler.traceEvent("tool-policy", "blocked", `${tool} rule ${rule.index}`, sessionID)
376
+ return mode === "enforce"
377
+ }
378
+
379
+ // Given one simple command's words, return its `git commit` analysis: the
380
+ // extracted `-m` messages and whether the invocation is uninspectable
381
+ // (`-F`/`-t`/`--edit`/`-e` present). Returns null when `words` is not a
382
+ // `git commit` invocation — so `git commit-graph` (subcommand `commit-graph`)
383
+ // and `echo git commit` (command `echo`) never match.
384
+ function analyzeCommit(words: string[]): { messages: string[]; uninspectable: boolean } | null {
385
+ if (words[0] !== "git") return null
386
+ const commitIdx = words.indexOf("commit", 1)
387
+ if (commitIdx === -1) return null
388
+ const args = words.slice(commitIdx + 1)
389
+ let uninspectable = false
390
+ const messages: string[] = []
391
+ // `-F`/`-t` values fall through to the catch-all (never a message).
392
+ for (const [j, a] of args.entries()) {
393
+ if (a === "-m" || a === "--message") {
394
+ const next = args[j + 1]
395
+ if (next !== undefined) messages.push(next)
396
+ else uninspectable = true
397
+ } else if (a.startsWith("--message=")) {
398
+ messages.push(a.slice("--message=".length))
399
+ } else if (a.startsWith("-m")) {
400
+ messages.push(a.slice(2))
401
+ } else if (
402
+ a === "-F" ||
403
+ a === "--file" ||
404
+ a === "-t" ||
405
+ a === "--template" ||
406
+ a === "-e" ||
407
+ a === "--edit"
408
+ ) {
409
+ uninspectable = true
410
+ }
411
+ }
412
+ return { messages, uninspectable }
413
+ }
414
+
415
+ // --- Block-throw journal helpers -------------------------------------------
416
+ // Journals a commit-lint block and returns true when the caller must throw
417
+ // (`enforce`). The offending text rides the `BlockError` (host-facing); the
418
+ // journal records only the reason, mirroring the denylist's minimal journal.
419
+ function journalCommitBlock(tool: string, sessionID: string, reason: string): boolean {
420
+ journaler.journal({ type: "blocked", sessionID, tool, reason, mode: gateMode })
421
+ journaler.traceEvent("tool-policy", "blocked", `${tool} ${reason}`, sessionID)
422
+ return gateMode === "enforce"
423
+ }
424
+
425
+ // Journals a landing `no-landing-approval` block and returns true when the
426
+ // caller must throw (`enforce`).
427
+ function journalLandingBlock(tool: string, sessionID: string): boolean {
428
+ journaler.journal({
429
+ type: "blocked",
430
+ sessionID,
431
+ tool,
432
+ reason: "no-landing-approval",
433
+ mode: gateMode,
434
+ })
435
+ journaler.traceEvent("tool-policy", "blocked", `${tool} no-landing-approval`, sessionID)
436
+ return gateMode === "enforce"
437
+ }
438
+
439
+ /**
440
+ * Guards a bash/shell command: fails open on an empty command (misconfiguration
441
+ * telemetry), yields on a non-match, and blocks a match per `mode`.
442
+ * @param tool The tool name, recorded in the journal entry.
443
+ * @param command The raw command value from the host payload.
444
+ * @param sessionID The session ID, recorded in journal entries.
445
+ * @throws Error with a `BLOCKED by turnstile:` prefix under `enforce` only.
446
+ */
447
+ function guardBash(tool: "bash" | "shell", command: unknown, sessionID: string): void {
448
+ try {
449
+ if (mode === "off") return
450
+ const text = typeof command === "string" ? command : ""
451
+ if (text.length === 0) {
452
+ journaler.journal({
453
+ type: "error",
454
+ sessionID,
455
+ error:
456
+ "execute.before: bash/shell carried no command; does the host input shape still use command or cmd?",
457
+ })
458
+ return
459
+ }
460
+ const rule = bashDeny.find((r) => r.re.test(text))
461
+ if (!rule) return
462
+ if (journalBlocked(tool, rule, sessionID)) {
463
+ throw new BlockError(
464
+ `BLOCKED by turnstile: ${tool} blocked by policy rule ${rule.index}: ${rule.source}`,
465
+ )
466
+ }
467
+ } catch (e) {
468
+ if (e instanceof BlockError) throw e
469
+ journaler.journal({ type: "error", error: `tool policy guard: ${e}` })
470
+ }
471
+ }
472
+
473
+ /**
474
+ * Guards a write/edit target path: same shape as {@link guardBash} but matched
475
+ * against `pathDeny` (empty by default, so ordinary editing is never blocked
476
+ * until a user configures `pathDeny`).
477
+ * @param tool The tool name, recorded in the journal entry.
478
+ * @param filePath The raw target-path value from the host payload.
479
+ * @param sessionID The session ID, recorded in journal entries.
480
+ * @throws Error with a `BLOCKED by turnstile:` prefix under `enforce` only.
481
+ */
482
+ function guardPath(tool: "write" | "edit", filePath: unknown, sessionID: string): void {
483
+ try {
484
+ if (mode === "off") return
485
+ const text = typeof filePath === "string" ? filePath : ""
486
+ if (text.length === 0) {
487
+ journaler.journal({
488
+ type: "error",
489
+ sessionID,
490
+ error:
491
+ "execute.before: write/edit carried no target path; does the host input shape still use filePath or path?",
492
+ })
493
+ return
494
+ }
495
+ const rule = pathDeny.find((r) => r.re.test(text))
496
+ if (!rule) return
497
+ if (journalBlocked(tool, rule, sessionID)) {
498
+ throw new BlockError(
499
+ `BLOCKED by turnstile: ${tool} blocked by policy rule ${rule.index}: ${rule.source}`,
500
+ )
501
+ }
502
+ } catch (e) {
503
+ if (e instanceof BlockError) throw e
504
+ journaler.journal({ type: "error", error: `tool policy guard: ${e}` })
505
+ }
506
+ }
507
+
508
+ /**
509
+ * Journals a `session_move` dispatch as telemetry only; never blocks. No-op
510
+ * when `observeSessionMove` is false.
511
+ * @param sessionID The session ID, recorded in the journal entry.
512
+ * @param directory The move target directory from the host payload.
513
+ */
514
+ function observeSessionMove(sessionID: string, directory: unknown): void {
515
+ if (!config.toolPolicy.observeSessionMove) return
516
+ journaler.journal({
517
+ type: "tool",
518
+ sessionID,
519
+ tool: "session_move",
520
+ directory: config.trace ? String(directory ?? "").slice(0, 200) : "<redacted>",
521
+ })
522
+ }
523
+
524
+ /**
525
+ * Lints `git commit` messages as conventional commits. Extracts the `git
526
+ * commit` invocations (via the shell-aware tokenizer above, so substrings and
527
+ * `git commit-graph` are never matched), validates each `-m` argument against
528
+ * the compiled `commitFormat`, and fail-closes on uninspectable forms
529
+ * (`-F`/`-t`/`--edit`/`-e`, or a bare interactive commit with no `-m`).
530
+ * Journal-only under `shadow`, no-op under `off`.
531
+ * @param tool The tool name, recorded in the journal.
532
+ * @param command The raw command value from the host payload (`unknown`).
533
+ * @param sessionID The session ID, recorded in journal entries.
534
+ * @throws Error with a `BLOCKED by turnstile:` prefix under `enforce` only.
535
+ */
536
+ function guardCommitMessage(tool: "bash" | "shell", command: unknown, sessionID: string): void {
537
+ try {
538
+ if (gateMode === "off" || !config.gitGates.commitMessages) return
539
+ const text = typeof command === "string" ? command : ""
540
+ if (text.length === 0) {
541
+ journaler.journal({
542
+ type: "error",
543
+ sessionID,
544
+ error:
545
+ "execute.before: git commit lint carried no command; does the host input shape still use command or cmd?",
546
+ })
547
+ return
548
+ }
549
+ for (const simple of splitCommands(text)) {
550
+ const analysis = analyzeCommit(tokenizeWords(simple))
551
+ if (analysis === null) continue
552
+ // Uninspectable (an -F/-t/--edit/-e flag, or a bare interactive commit
553
+ // with no -m): the operator cannot see the subject, so fail-closed —
554
+ // treat it as non-conventional under enforce.
555
+ if (analysis.uninspectable || analysis.messages.length === 0) {
556
+ if (journalCommitBlock(tool, sessionID, "commit-message-uninspectable")) {
557
+ throw new BlockError(
558
+ `BLOCKED by turnstile: commit message could not be inspected — pass it with 'git commit -m <subject>' (-F/-t/--edit/-e are not inspectable)`,
559
+ )
560
+ }
561
+ // Shadow (audit) path: a `continue` keeps linting the later simple
562
+ // commands in this string; enforce still threw on the first above.
563
+ continue
564
+ }
565
+ for (const message of analysis.messages) {
566
+ if (!commitFormat.test(message)) {
567
+ const subject = message.slice(0, 80)
568
+ if (journalCommitBlock(tool, sessionID, "invalid-commit-message")) {
569
+ throw new BlockError(
570
+ `BLOCKED by turnstile: commit message '${subject}' ${COMMIT_EXPECTED}`,
571
+ )
572
+ }
573
+ return
574
+ }
575
+ }
576
+ }
577
+ } catch (e) {
578
+ if (e instanceof BlockError) throw e
579
+ journaler.journal({ type: "error", error: `tool policy guard: ${e}` })
580
+ }
581
+ }
582
+
583
+ /**
584
+ * Journals landing telemetry (`{type:"tool", tool:"bash", landing:true}`) for
585
+ * `git push` / `gh pr create|ready|merge` candidates — in every mode, and
586
+ * never blocking. `gh pr` telemetry is disjoint from the `bashDeny` deny that
587
+ * handles `gh pr create|merge` (that deny fires in `guardBash` before this).
588
+ * @param sessionID The session ID, recorded in the journal entry.
589
+ * @param command The raw command value from the host payload (`unknown`).
590
+ */
591
+ function observeLanding(sessionID: string, command: unknown): void {
592
+ try {
593
+ const text = typeof command === "string" ? command : ""
594
+ if (text.length === 0) return
595
+ if (isPushLanding(text) || ghPrRe.test(text)) {
596
+ journaler.journal({ type: "tool", sessionID, tool: "bash", landing: true })
597
+ }
598
+ } catch (e) {
599
+ journaler.journal({ type: "error", error: `tool policy guard: ${e}` })
600
+ }
601
+ }
602
+
603
+ /**
604
+ * Gates a `git push` on a reviewer `VERDICT: APPROVE`. `gate` is the
605
+ * stateful capability from `setup.ts`. With approval: allow, no journal noise
606
+ * beyond telemetry. Without, the first block this task journals
607
+ * `no-landing-approval` (and throws under `enforce`); a repeat attempt under
608
+ * `enforce` journals a `permission` ask and signals escalation (allowing
609
+ * `execute.before` to pass so the evaluate handler forces the human ask);
610
+ * `shadow` journals only, `off` is a no-op.
611
+ * @param tool The tool name, recorded in the journal.
612
+ * @param command The raw command value from the host payload (`unknown`).
613
+ * @param sessionID The session ID, recorded in journal entries.
614
+ * @param gate The landing-gate capability supplied by `setup.ts`.
615
+ * @throws Error with a `BLOCKED by turnstile:` prefix under `enforce` only.
616
+ */
617
+ function guardLanding(
618
+ tool: "bash" | "shell",
619
+ command: unknown,
620
+ sessionID: string,
621
+ gate: LandingGateCapability,
622
+ ): void {
623
+ try {
624
+ if (gateMode === "off" || !config.gitGates.landingGate) return
625
+ const text = typeof command === "string" ? command : ""
626
+ if (text.length === 0) {
627
+ journaler.journal({
628
+ type: "error",
629
+ sessionID,
630
+ error:
631
+ "execute.before: landing gate carried no command; does the host input shape still use command or cmd?",
632
+ })
633
+ return
634
+ }
635
+ if (!isPushLanding(text)) return
636
+ // Approval releases the gate: allow, no journal noise beyond telemetry.
637
+ if (gate.hasApproval()) return
638
+ const prior = gate.priorBlocks()
639
+ if (prior >= 1) {
640
+ // Repeat attempt without approval. Under enforce, arm the human ask:
641
+ // journal the landing ask and call gate.escalate() to set the
642
+ // pending-landing-ask latch, then allow execute.before to pass so the
643
+ // setup-side evaluate handler (which forces the ask off that latch,
644
+ // naming the missing reviewer verdict) fires it. Fail-toward-strict: a
645
+ // host reject leaves approval absent, so the next attempt re-blocks and
646
+ // re-arms. Under shadow, journal-only like the first block (never arms).
647
+ if (gateMode === "enforce") {
648
+ journaler.journal({
649
+ type: "permission",
650
+ sessionID,
651
+ tool,
652
+ action: "ask",
653
+ reason: "landing",
654
+ mode: gateMode,
655
+ })
656
+ gate.escalate()
657
+ return
658
+ }
659
+ journalLandingBlock(tool, sessionID)
660
+ gate.recordBlock()
661
+ return
662
+ }
663
+ // First block this task: journal + record, then throw under enforce.
664
+ const shouldThrow = journalLandingBlock(tool, sessionID)
665
+ gate.recordBlock()
666
+ if (shouldThrow) {
667
+ throw new BlockError(`BLOCKED by turnstile: ${LANDING_REQUIREMENT}.`)
668
+ }
669
+ } catch (e) {
670
+ if (e instanceof BlockError) throw e
671
+ journaler.journal({ type: "error", error: `tool policy guard: ${e}` })
672
+ }
673
+ }
674
+
675
+ return {
676
+ guardBash,
677
+ guardPath,
678
+ observeSessionMove,
679
+ guardCommitMessage,
680
+ observeLanding,
681
+ guardLanding,
682
+ }
683
+ }