opencode-turnstile 0.2.0 → 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.
- package/README.md +263 -99
- package/agents/debugger.md +3 -2
- package/agents/executor.md +7 -2
- package/bin/cli.mjs +34 -22
- package/package.json +5 -6
- package/scripts/branch-coverage.mjs +206 -0
- package/scripts/check-markers.mjs +23 -1
- package/scripts/install.mjs +113 -13
- package/scripts/status.mjs +333 -0
- package/skills/turnstile-setup/SKILL.md +22 -38
- package/src/config.ts +295 -6
- package/src/evidence.ts +162 -0
- package/src/gates.ts +338 -93
- package/src/journal.ts +10 -3
- package/src/markers.ts +117 -15
- package/src/policy.ts +33 -0
- package/src/setup.ts +1265 -96
- package/src/state.ts +316 -21
- package/src/tool-policy.ts +683 -0
- package/src/types.ts +300 -1
- package/turnstile.ts +150 -14
- package/scripts/install-agents.mjs +0 -504
- package/templates/global-AGENTS.md +0 -55
|
@@ -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
|
+
}
|