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
package/src/types.ts
CHANGED
|
@@ -16,6 +16,14 @@ export interface TaskInput {
|
|
|
16
16
|
description?: unknown
|
|
17
17
|
command?: unknown
|
|
18
18
|
cmd?: unknown
|
|
19
|
+
/**
|
|
20
|
+
* Target path as delivered by the host's `write`/`edit` tools. The shape is
|
|
21
|
+
* unconfirmed on v2.0.27, hence `unknown`; `src/tool-policy.ts` reads it as a
|
|
22
|
+
* documented candidate (`filePath ?? path`) and fails open when absent.
|
|
23
|
+
*/
|
|
24
|
+
filePath?: unknown
|
|
25
|
+
/** Alternate candidate key for the same write/edit target path. */
|
|
26
|
+
path?: unknown
|
|
19
27
|
}
|
|
20
28
|
|
|
21
29
|
/** Tool result as delivered by the host's `execute.after` hook. */
|
|
@@ -29,6 +37,14 @@ export interface ToolResult {
|
|
|
29
37
|
/** Payload of the `tool.execute.before` and `tool.execute.after` hooks. */
|
|
30
38
|
export interface ToolHookEvent {
|
|
31
39
|
tool?: unknown
|
|
40
|
+
/**
|
|
41
|
+
* Dispatched subagent/agent name, observed on v2.0.27
|
|
42
|
+
* execute.before/after payloads. The host contract does not type this
|
|
43
|
+
* field, hence `unknown`; `src/setup.ts` reads it (against the configured
|
|
44
|
+
* agent-name set) to skip injecting orchestrator policy into child
|
|
45
|
+
* sessions.
|
|
46
|
+
*/
|
|
47
|
+
agent?: unknown
|
|
32
48
|
sessionID?: unknown
|
|
33
49
|
input?: TaskInput
|
|
34
50
|
result?: ToolResult
|
|
@@ -37,6 +53,15 @@ export interface ToolHookEvent {
|
|
|
37
53
|
/** Payload of the `session.prompt` hook. */
|
|
38
54
|
export interface PromptHookProperties {
|
|
39
55
|
sessionID?: unknown
|
|
56
|
+
/**
|
|
57
|
+
* Candidate key for the raw user prompt text on v2.0.27. The host does not
|
|
58
|
+
* type this field, hence `unknown`; `src/setup.ts` reads it (against the
|
|
59
|
+
* candidate `text ?? prompt`) to capture user-authored waiver/approval markers
|
|
60
|
+
* at prompt admission. Fails open when absent.
|
|
61
|
+
*/
|
|
62
|
+
text?: unknown
|
|
63
|
+
/** Alternate candidate key for the raw user prompt text. */
|
|
64
|
+
prompt?: unknown
|
|
40
65
|
}
|
|
41
66
|
|
|
42
67
|
/** Generation draft passed to the `session.generate` hook. */
|
|
@@ -67,6 +92,23 @@ export interface EventProperties {
|
|
|
67
92
|
text?: unknown
|
|
68
93
|
delta?: unknown
|
|
69
94
|
error?: unknown
|
|
95
|
+
/**
|
|
96
|
+
* Host request id on `permission.asked`; the matched id on `permission.replied`.
|
|
97
|
+
* Present only on the permission stream events (opencode v2.0.27+).
|
|
98
|
+
*/
|
|
99
|
+
id?: unknown
|
|
100
|
+
requestID?: unknown
|
|
101
|
+
/**
|
|
102
|
+
* A human reply to a permission ask: `"once"`, `"always"`, or `"reject"`.
|
|
103
|
+
* Present only on `permission.replied`.
|
|
104
|
+
*/
|
|
105
|
+
reply?: unknown
|
|
106
|
+
/**
|
|
107
|
+
* The ask message echoed by the host on `permission.asked`. Read to
|
|
108
|
+
* recognize plan-gate asks (the evaluate handler stamps a known prefix);
|
|
109
|
+
* unknown-typed, hence `unknown`.
|
|
110
|
+
*/
|
|
111
|
+
message?: unknown
|
|
70
112
|
}
|
|
71
113
|
|
|
72
114
|
/** A single host stream event delivered by `event.subscribe`. */
|
|
@@ -76,15 +118,169 @@ export interface StreamEvent {
|
|
|
76
118
|
properties?: EventProperties
|
|
77
119
|
}
|
|
78
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Evidence-correlation mode for the reviewer gate. `enforce` (default) refuses
|
|
123
|
+
* to flip `hasGatePass` on a `GATE: PASS` with no correlated exit-0
|
|
124
|
+
* bash/shell test execution; `flag` flips but still stamps `verified`;
|
|
125
|
+
* `off` behaves exactly as before (no correlation).
|
|
126
|
+
*/
|
|
127
|
+
export type GateEvidenceMode = "enforce" | "flag" | "off"
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Enforcement mode for the tool-call denylist policy in `src/tool-policy.ts`.
|
|
131
|
+
* `enforce` (default) blocks matching bash/shell commands and write/edit paths;
|
|
132
|
+
* `shadow` journals the would-block without throwing (adoption path); `off` is
|
|
133
|
+
* a silent no-op escape hatch. Mirrors `GateEvidenceMode`.
|
|
134
|
+
*/
|
|
135
|
+
export type ToolPolicyMode = "enforce" | "shadow" | "off"
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Denylist policy governing the tools that flow through `execute.before`:
|
|
139
|
+
* bash/shell commands and write/edit target paths. Defaults encode the
|
|
140
|
+
* git-protective prose of `src/policy.ts` verbatim in `bashDeny` and leave
|
|
141
|
+
* `pathDeny` empty (ordinary editing is covered — a decision is made and, on
|
|
142
|
+
* block, journaled — with no default blocks, so it never breaks until a user
|
|
143
|
+
* configures `pathDeny`). Regex sources are case-insensitive, compiled once at
|
|
144
|
+
* setup, and matched with `RegExp.test`.
|
|
145
|
+
*/
|
|
146
|
+
export interface TurnstileToolPolicy {
|
|
147
|
+
/** Enforcement mode: `enforce` (default), `shadow`, or `off`. */
|
|
148
|
+
mode: ToolPolicyMode
|
|
149
|
+
/** Case-insensitive regex sources matched against bash/shell commands. */
|
|
150
|
+
bashDeny: string[]
|
|
151
|
+
/** Case-insensitive regex sources matched against write/edit target paths. */
|
|
152
|
+
pathDeny: string[]
|
|
153
|
+
/** Journal `session_move` dispatches (never blocks). */
|
|
154
|
+
observeSessionMove: boolean
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Git-and-commit gate configuration backing the mechanical gates in
|
|
159
|
+
* `src/tool-policy.ts` (chunk-7/8). The default `mode` is `"shadow"`: the
|
|
160
|
+
* gates are active but non-blocking (audit-only) until a user flips a key to
|
|
161
|
+
* `"enforce"`, mirroring the tool-policy "adopt shadow first" adoption path.
|
|
162
|
+
*
|
|
163
|
+
* `commitFormat` is a conventional-commits regex SOURCE string (no leading
|
|
164
|
+
* `^`/trailing `$`): callers compile it with `new RegExp("^" + source + "$",
|
|
165
|
+
* "s")`. It accepts one of the default types `{feat,fix,docs,style,refactor,
|
|
166
|
+
* perf,test,build,ci,chore,revert}`, an optional `(scope)`, a required `!` or
|
|
167
|
+
* `:` separator (only one is needed — no `BREAKING CHANGE` trailer), a subject
|
|
168
|
+
* of up to 72 characters on the first line, and one optional following body
|
|
169
|
+
* line.
|
|
170
|
+
*/
|
|
171
|
+
export interface TurnstileGitGates {
|
|
172
|
+
/** Enforcement mode: `enforce` (blocks), `shadow` (audit-only, default), or `off` (silent no-op). */
|
|
173
|
+
mode: ToolPolicyMode
|
|
174
|
+
/** Whether `git commit` messages are linted as conventional commits. */
|
|
175
|
+
commitMessages: boolean
|
|
176
|
+
/** Conventional-commit regex SOURCE string (compiled by callers with `"^…$"` + `"s"`). */
|
|
177
|
+
commitFormat: string
|
|
178
|
+
/** Whether pushing/PR-landing is gated on a reviewer `VERDICT: APPROVE`. */
|
|
179
|
+
landingGate: boolean
|
|
180
|
+
}
|
|
181
|
+
|
|
79
182
|
/** Gate state for one parent session. */
|
|
80
183
|
export interface GateState {
|
|
81
184
|
hasGatePass: boolean
|
|
185
|
+
/**
|
|
186
|
+
* Last `GATE: PASS` rejected for lack of correlated test execution (an
|
|
187
|
+
* unverified claim under `enforce`); "" when none. Cleared on a green flip,
|
|
188
|
+
* on `GATE: FAIL`, and on a task reset. Task-scoped: never carried through
|
|
189
|
+
* `mergeAfterReplay`.
|
|
190
|
+
*/
|
|
191
|
+
unverifiedPass: string
|
|
82
192
|
/** True when this task's plan has a current plan-reviewer APPROVE. */
|
|
83
193
|
hasPlanVerdict: boolean
|
|
84
|
-
|
|
194
|
+
/**
|
|
195
|
+
* True once this task's reviewer `execute.after` delivery has carried a
|
|
196
|
+
* `VERDICT: APPROVE` for the parent — the landing gate's release signal.
|
|
197
|
+
* Restart-sticky like `hasPlanVerdict`: restored by journal replay across a
|
|
198
|
+
* restart (chunk-7) and carried one prompt via `mergeAfterReplay`; fresh
|
|
199
|
+
* `false` on an ordinary prompt reset.
|
|
200
|
+
*/
|
|
201
|
+
hasReviewApprove: boolean
|
|
202
|
+
/**
|
|
203
|
+
* Latest user-authored prompt text containing the waiver marker; "" when
|
|
204
|
+
* none. Non-empty ⇒ the current task's reviewer/dispatch gates are waived.
|
|
205
|
+
* Task-scoped: cleared on every prompt reset and never carried through
|
|
206
|
+
* `mergeAfterReplay`.
|
|
207
|
+
*/
|
|
208
|
+
userWaiver: string
|
|
209
|
+
/**
|
|
210
|
+
* Provenance flag: true once a human (prompt marker or permission reply)
|
|
211
|
+
* rather than the plan-reviewer subagent released the plan gate. Provenance
|
|
212
|
+
* only — `execute.before` never consults it; it is read by the status CLI and
|
|
213
|
+
* trace lines. Fresh `false` on each prompt reset, but restart-restored values
|
|
214
|
+
* survive the first post-restart prompt via `mergeAfterReplay`, exactly like
|
|
215
|
+
* `hasPlanVerdict`.
|
|
216
|
+
*/
|
|
217
|
+
humanApproval: boolean
|
|
218
|
+
/**
|
|
219
|
+
* A single-dispatch grant — the matched host `requestID` for a once-granted
|
|
220
|
+
* human approval, keyed per ask-reason so a plan-gate `once` grant (releasing
|
|
221
|
+
* exactly one executor dispatch) can never be consumed by a landing-gate
|
|
222
|
+
* `once` grant (releasing exactly one push) and vice versa: `{ plan }` for the
|
|
223
|
+
* plan gate (consumed by the executor dispatch short-circuit), `{ landing }`
|
|
224
|
+
* for the landing gate (consumed by the bash short-circuit). "" per reason
|
|
225
|
+
* when none. Task-scoped: cleared immediately after the single dispatch
|
|
226
|
+
* consumes it (a stale grant cannot outlive one dispatch) and never carried
|
|
227
|
+
* through `mergeAfterReplay`.
|
|
228
|
+
*/
|
|
229
|
+
pendingOnceGrant: {
|
|
230
|
+
/** Plan-gate once grant: the matched requestID, or "" when none. */
|
|
231
|
+
plan: string
|
|
232
|
+
/** Landing-gate once grant: the matched requestID, or "" when none. */
|
|
233
|
+
landing: string
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* True once this task's first parent task/subagent result delivery has
|
|
237
|
+
* carried the injected orchestrator policy forward into model context.
|
|
238
|
+
* Cleared per task by `ledger.resetState()` on every user prompt, so the
|
|
239
|
+
* policy is re-injected on each task (the intended per-task re-supply,
|
|
240
|
+
* which also re-applies it after compaction).
|
|
241
|
+
*/
|
|
242
|
+
policyInjected: boolean
|
|
243
|
+
/**
|
|
244
|
+
* Dedup key → raw session ID that first observed the marker; written
|
|
245
|
+
* alongside `seen.add` by `observeMarker`. Tracks first-sight provenance so
|
|
246
|
+
* a dedup hit from a different raw session can be accounted as cross-child
|
|
247
|
+
* suppression (a different raw session than the stored provenance).
|
|
248
|
+
*
|
|
249
|
+
* Holds gate-kind provenance only: verdict-kind streaming dedup lives in the
|
|
250
|
+
* per-child `childVerdicts` store, not here.
|
|
251
|
+
*/
|
|
252
|
+
seenFrom: Map<string, string>
|
|
253
|
+
/**
|
|
254
|
+
* Cross-child suppressions pending attribution on the next emitted
|
|
255
|
+
* `MarkerEvent` (a `GATE:` dedup hit whose raw session differs from the
|
|
256
|
+
* provenance stored in `seenFrom`). Read and zeroed when an event emits; 0 on
|
|
257
|
+
* a fresh bucket. Verdict-kind deliveries use the per-child store instead, so
|
|
258
|
+
* this counter is now gated on gate-kind hits only.
|
|
259
|
+
*/
|
|
260
|
+
suppressedPending: number
|
|
85
261
|
reviewRounds: number
|
|
262
|
+
/**
|
|
263
|
+
* Count of `git push`/PR-landing blocks this task under the landing gate —
|
|
264
|
+
* telemetry that arms the permission-ask escalation on a repeat attempt
|
|
265
|
+
* (chunk-8). Task-scoped: fresh `0` on each prompt reset and never carried
|
|
266
|
+
* through `mergeAfterReplay`.
|
|
267
|
+
*/
|
|
268
|
+
landingBlocks: number
|
|
86
269
|
reads: number
|
|
87
270
|
seen: Set<string>
|
|
271
|
+
/**
|
|
272
|
+
* Plans-dir signature the approval was issued against; "" means
|
|
273
|
+
* unknown (journal-replayed or pre-stamp) and is accepted once, then
|
|
274
|
+
* stamped, on the next enforcing dispatch.
|
|
275
|
+
*/
|
|
276
|
+
plansSignature: string
|
|
277
|
+
/**
|
|
278
|
+
* True while a journal-replayed bucket's unconsumed one-prompt grace is
|
|
279
|
+
* outstanding: the next reset carries hasGatePass/hasPlanVerdict forward
|
|
280
|
+
* instead of wiping them. Cleared by the first reset; re-granted only by
|
|
281
|
+
* journal replay at ledger creation (process restart or plugin reload).
|
|
282
|
+
*/
|
|
283
|
+
replayGrace: boolean
|
|
88
284
|
}
|
|
89
285
|
|
|
90
286
|
/** One first-sight marker observation returned by the marker parser. */
|
|
@@ -93,13 +289,50 @@ export interface MarkerEvent {
|
|
|
93
289
|
marker: string
|
|
94
290
|
result?: "PASS" | "FAIL"
|
|
95
291
|
rounds?: number
|
|
292
|
+
/**
|
|
293
|
+
* Evidence verdict of a `PASS` gate event: true (correlated exit-0 test
|
|
294
|
+
* execution exists for this session) or false (flagged, unverified). Present
|
|
295
|
+
* only on `PASS` gate events when the evidence mode is not `"off"`.
|
|
296
|
+
*/
|
|
297
|
+
verified?: boolean
|
|
298
|
+
/** Evidence mode at the time this event was stamped, when `verified` is. */
|
|
299
|
+
mode?: GateEvidenceMode
|
|
96
300
|
/** Marker matches observed in the same text but not journaled
|
|
97
301
|
* (latest-wins policy); 0 when this was the only match. */
|
|
98
302
|
suppressed: number
|
|
99
303
|
}
|
|
100
304
|
|
|
305
|
+
/**
|
|
306
|
+
* The latest `VERDICT:` marker observation for a single child session and the
|
|
307
|
+
* provenance label of the pipeline that carried it. The store is
|
|
308
|
+
* verdict-kind only: only `VERDICT:` markers are stored (never `GATE:`), so a
|
|
309
|
+
* GATE-only child can never suppress a plan-reviewer bare-output violation.
|
|
310
|
+
*
|
|
311
|
+
* It is also the streamed-verdict observation ledger: `recordChildVerdict`
|
|
312
|
+
* returns whether the observation changed (first-record, marker flip, or
|
|
313
|
+
* source change), so a caller can distinguish a flip from an identical
|
|
314
|
+
* re-record without re-reading the store. Gate-kind (`GATE:`) dedup still
|
|
315
|
+
* lives in `GateState.seen`; this store is verdict-kind only.
|
|
316
|
+
*/
|
|
317
|
+
export interface ChildVerdict {
|
|
318
|
+
/** Last `VERDICT:` marker observed for the child, or "" when none. */
|
|
319
|
+
marker: string
|
|
320
|
+
/** Pipeline provenance label, e.g. "child-text" or "child-message". */
|
|
321
|
+
source: string
|
|
322
|
+
/**
|
|
323
|
+
* `Date.now()` when this verdict was last recorded; `0` on the empty default.
|
|
324
|
+
*
|
|
325
|
+
* Used by the plausibility join: an unresolved-child verdict is
|
|
326
|
+
* plausible for a parent only when it was recorded at-or-after that parent's
|
|
327
|
+
* most recent plan-reviewer dispatch.
|
|
328
|
+
*/
|
|
329
|
+
recordedAt: number
|
|
330
|
+
}
|
|
331
|
+
|
|
101
332
|
/** Agent names turnstile recognizes in dispatches and generation drafts. */
|
|
102
333
|
export interface TurnstileAgents {
|
|
334
|
+
/** Planner: first in the pipeline; the primary session's plan gate gates dispatch. */
|
|
335
|
+
planner: string
|
|
103
336
|
reviewer: string
|
|
104
337
|
/** Plan reviewer: adversarial review of the implementation plan. */
|
|
105
338
|
planReviewer: string
|
|
@@ -135,6 +368,13 @@ export interface TurnstileConfig {
|
|
|
135
368
|
readBlock: number
|
|
136
369
|
/** Literal marker that waives the reviewer gate when in the prompt. */
|
|
137
370
|
waiverMarker: string
|
|
371
|
+
/**
|
|
372
|
+
* Literal marker that releases the plan gate via an explicit human
|
|
373
|
+
* approval (chunk-3). Present at config resolution so the key is valid and
|
|
374
|
+
* the digest is stable; not yet consumed by the chunk-2 prompt-channel
|
|
375
|
+
* waiver. Defaults to `PLAN APPROVED:`.
|
|
376
|
+
*/
|
|
377
|
+
approvalMarker: string
|
|
138
378
|
/** Project-relative directory scanned for `## Decomposition` plans. */
|
|
139
379
|
plansDir: string
|
|
140
380
|
/** Whether the human-readable trace file is written (JSONL journal always). */
|
|
@@ -145,6 +385,36 @@ export interface TurnstileConfig {
|
|
|
145
385
|
gateMarker: string
|
|
146
386
|
/** Verdict marker regex source (compiled with `gm`). */
|
|
147
387
|
verdictMarker: string
|
|
388
|
+
/**
|
|
389
|
+
* Evidence-correlation mode for the reviewer gate: `enforce` (default)
|
|
390
|
+
* requires a correlated exit-0 bash/shell test execution before a
|
|
391
|
+
* `GATE: PASS` flips the gate; `flag` flips yet stamps `verified`; `off`
|
|
392
|
+
* keeps the pre-evidence behavior. See `src/evidence.ts`.
|
|
393
|
+
*/
|
|
394
|
+
gateEvidence: GateEvidenceMode
|
|
395
|
+
/**
|
|
396
|
+
* Case-insensitive regex source a successful (exit-0) bash/shell command
|
|
397
|
+
* must match to count as test execution. Overridable for non-JS/other
|
|
398
|
+
* toolchains; the default covers npm/pnpm/yarn/bun/deno/node --test/
|
|
399
|
+
* pytest/make test/go test/cargo test.
|
|
400
|
+
*/
|
|
401
|
+
evidencePattern: string
|
|
402
|
+
/**
|
|
403
|
+
* Denylist policy for bash/shell and write/edit tools in `execute.before`.
|
|
404
|
+
* Default `mode` is `enforce`; the default `bashDeny` encodes the
|
|
405
|
+
* git-protective prose of `src/policy.ts`, `pathDeny` defaults empty, and
|
|
406
|
+
* `observeSessionMove` journals `session_move` dispatches without blocking.
|
|
407
|
+
* See `src/tool-policy.ts`.
|
|
408
|
+
*/
|
|
409
|
+
toolPolicy: TurnstileToolPolicy
|
|
410
|
+
/**
|
|
411
|
+
* Git-and-commit gate configuration. Default `mode` is `shadow` (audit-only
|
|
412
|
+
* until a user opts into `enforce`), `commitMessages` lints `git commit`
|
|
413
|
+
* messages against the conventional-commits `commitFormat`, and `landingGate`
|
|
414
|
+
* gates push/PR-landing on a reviewer `VERDICT: APPROVE`. See
|
|
415
|
+
* `src/tool-policy.ts`.
|
|
416
|
+
*/
|
|
417
|
+
gitGates: TurnstileGitGates
|
|
148
418
|
}
|
|
149
419
|
|
|
150
420
|
/** Options accepted by the plugin factory; tests inject config and env here. */
|
|
@@ -173,4 +443,33 @@ export type Host = {
|
|
|
173
443
|
event?: {
|
|
174
444
|
subscribe?: (options?: { signal?: AbortSignal }) => AsyncIterable<StreamEvent>
|
|
175
445
|
}
|
|
446
|
+
permission?: {
|
|
447
|
+
hook?: (
|
|
448
|
+
name: string,
|
|
449
|
+
callback: (event: unknown) => unknown | Promise<unknown>,
|
|
450
|
+
) => Promise<unknown>
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Properties carried by a host `permission.evaluate` request at the wire
|
|
456
|
+
* boundary. Every field is optional and typed `unknown`: the host does not
|
|
457
|
+
* expose a stable shape for this payload, so a reshaped or partial payload
|
|
458
|
+
* fails open rather than failing hard. The plugin matches on `action` (the
|
|
459
|
+
* host's task/subagent dispatch name) and `source` rather than the tool input
|
|
460
|
+
* (which this payload does not carry), so escalation surfaces the same decision
|
|
461
|
+
* to a human without leaking the dispatch target to the subagent.
|
|
462
|
+
*/
|
|
463
|
+
export interface PermissionEvaluateEvent {
|
|
464
|
+
sessionID?: unknown
|
|
465
|
+
action?: unknown
|
|
466
|
+
resources?: unknown
|
|
467
|
+
metadata?: unknown
|
|
468
|
+
source?: {
|
|
469
|
+
type?: unknown
|
|
470
|
+
messageID?: unknown
|
|
471
|
+
id?: unknown
|
|
472
|
+
}
|
|
473
|
+
effect?: unknown
|
|
474
|
+
message?: unknown
|
|
176
475
|
}
|
package/turnstile.ts
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* turnstile: runtime enforcement of the
|
|
2
|
+
* turnstile: runtime enforcement of the orchestrator pipeline contracts.
|
|
3
|
+
*
|
|
4
|
+
* - Orchestrator policy: injected into the primary (parent) session per task
|
|
5
|
+
* via the `execute.after` hook — the `ORCHESTRATOR_POLICY` block from
|
|
6
|
+
* `src/policy.ts` is prepended to the first `task`/`subagent` result delivery
|
|
7
|
+
* per task, with per-task idempotency via `GateState.policyInjected`, fully
|
|
8
|
+
* guarded and fail-open (a broken injection journals `type:"policy"` and
|
|
9
|
+
* proceeds). Nothing is written to an instructions file at install time — the
|
|
10
|
+
* AGENTS.md contract block is a historical install step, removed.
|
|
3
11
|
*
|
|
4
12
|
* Written for OpenCode v2.x promise-plugin host (`{ id, setup }`):
|
|
5
13
|
* - `ctx.tool.hook("execute.before" | "execute.after", cb)`
|
|
@@ -8,27 +16,153 @@
|
|
|
8
16
|
*
|
|
9
17
|
* Enforced mechanically:
|
|
10
18
|
* - Plan-approval gate: `task` dispatches to `executor` require a current
|
|
11
|
-
* plan
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
19
|
+
* plan approval whenever a plan file exists in `.opencode/plans/`: a
|
|
20
|
+
* plan-reviewer `VERDICT: APPROVE`, a prompt-channel human approval, or a
|
|
21
|
+
* permission-prompt human approval. A prompt-channel approval is a
|
|
22
|
+
* user-authored prompt (a `PLAN APPROVED:` literal in the user's prompt sets
|
|
23
|
+
* the plan verdict and records human provenance) and replays identically to
|
|
24
|
+
* a plan-reviewer verdict. When none holds, the first executor dispatch
|
|
25
|
+
* escalates to the human through the host permission surface:
|
|
26
|
+
* `permission.evaluate` forced `ask` with the plan file + head; a reply
|
|
27
|
+
* `always` sets the verdict, `once` grants exactly one executor dispatch
|
|
28
|
+
* (any other agent's dispatch leaves the grant pending and still runs its
|
|
29
|
+
* own guards), and `reject` keeps the gate red (that single reply is
|
|
30
|
+
* consumed). Replies match only asks escalated as plan-gate asks (recorded
|
|
31
|
+
* per request id, bounded, per session) — a reply to an unrelated permission
|
|
32
|
+
* ask can never release the gate: an ask is recorded only when its message
|
|
33
|
+
* carries the plan-ask prefix, or when the parent's escalation latch is
|
|
34
|
+
* still live (it expires after a few seconds and is cleared by a prompt
|
|
35
|
+
* reset) and the ask carries no message of its own. The explicit
|
|
36
|
+
* user waiver is prompt-channel too (see the hard-gate bullet): a
|
|
37
|
+
* `USER WAIVER:` literal in the user's prompt waives the gate; a
|
|
38
|
+
* `USER WAIVER:` literal in an agent-composed dispatch prompt is inert —
|
|
39
|
+
* issue #34's injection hole, now closed. Plan-content changes invalidate
|
|
40
|
+
* approvals issued against the previous plans-dir signature (replayed
|
|
41
|
+
* approvals are accepted once, then stamped). A journal-replayed approval
|
|
42
|
+
* survives the first prompt after a restart (the resume prompt) and then
|
|
43
|
+
* every later prompt starts a clean cycle.
|
|
15
44
|
* - Hard gate: `task` dispatches to `reviewer` require a recorded `GATE: PASS`
|
|
16
45
|
* from test-runner for the current task (a later `GATE: FAIL` un-sets
|
|
17
|
-
* it), or an explicit user waiver (
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
46
|
+
* it), or an explicit user waiver (`USER WAIVER:` literal in the user's
|
|
47
|
+
* prompt). Under the default `gateEvidence:enforce`
|
|
48
|
+
* mode a `GATE: PASS` satisfies the gate only when the current task's
|
|
49
|
+
* bash/shell executions include a matching exit-0 test command (plausibility
|
|
50
|
+
* via `evidencePattern`); an unverified PASS is journaled `verified:false`
|
|
51
|
+
* and does not flip the gate. `flag` degrades to the historical
|
|
52
|
+
* flip-on-trust semantics while still stamping `verified`, and `off`
|
|
53
|
+
* journals without the field — both documented in the README. A
|
|
54
|
+
* journal-replayed `GATE: PASS` survives the first prompt after a restart
|
|
55
|
+
* (the resume prompt) and then every later prompt starts a clean cycle.
|
|
56
|
+
* - Round cap: max 2 `task -> reviewer` dispatches per task, regardless of gate
|
|
57
|
+
* state; a prompt-channel user waiver bypasses the cap without counting
|
|
58
|
+
* toward it (a dispatch prompt with the `USER WAIVER:` marker is inert); a
|
|
59
|
+
* new user prompt resets it.
|
|
60
|
+
* - Parent-bucket eviction: live-parent gate buckets (hasGatePass,
|
|
61
|
+
* hasPlanVerdict, read counter, policyInjected) are registered per
|
|
62
|
+
* prompt/dispatch/delivery/edge via `markParent`, and the `states` LRU never
|
|
63
|
+
* evicts a registered parent; unresolved child buckets remain evictable and
|
|
64
|
+
* can no longer push a live parent out past `maxSessions`.
|
|
65
|
+
* - Horizon: read-counter backstop (warn @8, block @15 whole-file reads),
|
|
66
|
+
* keyed off the parent session: the child→parent edge is awaited
|
|
67
|
+
* (host `session.get` when `session.created` has not yet landed) before the
|
|
68
|
+
* guard fires, so an in-flight child draws down the parent's budget; a
|
|
69
|
+
* still-unresolved child self-keys and journals a bounded signal.
|
|
21
70
|
* - Decomposition: `task -> executor` dispatches must cite `chunk-N` / `AC-n`
|
|
22
71
|
* when a plan file with a `## Decomposition` block exists.
|
|
72
|
+
* - Status: `turnstile status` (the `opencode-turnstile status` CLI) reads
|
|
73
|
+
* the journal read-only — it never writes — and reports gate + plan state
|
|
74
|
+
* per parent session: gate flags restored like replay, prompt-channel plan
|
|
75
|
+
* approvals and waiver events (with channel), last markers, and
|
|
76
|
+
* dispatch/block/permission counts (a human table or `--json`).
|
|
77
|
+
* - Tool-call policy: `bash`/`shell` commands and `write`/`edit`
|
|
78
|
+
* target paths are matched in `execute.before` against a configurable
|
|
79
|
+
* denylist (`src/tool-policy.ts`). Under `toolPolicy.mode: enforce` a match
|
|
80
|
+
* throws `BLOCKED by turnstile:`; `shadow` journals the would-block without
|
|
81
|
+
* throwing and `off` is a no-op. Default `bashDeny` blocks the git-protective
|
|
82
|
+
* operations in `src/policy.ts` (worktree, branch `switch`/`checkout`, `merge`,
|
|
83
|
+
* `gh pr create|merge`); `pathDeny` defaults empty. `session_move` is journaled
|
|
84
|
+
* as telemetry only, never blocked. A misconfigured/absent host payload fails
|
|
85
|
+
* open with a journal entry rather than blocking.
|
|
86
|
+
* - Commit-lint: `bash`/`shell` `git commit -m '<subject>'` messages are
|
|
87
|
+
* validated as conventional commits when `gitGates.commitMessages` is on
|
|
88
|
+
* (default) — a `type(scope)!: subject` shape (`type` ∈
|
|
89
|
+
* `{feat,fix,docs,style,refactor,perf,test,build,ci,chore,revert}`, `!`
|
|
90
|
+
* optional, subject ≤ 72 chars). A non-conventional message is
|
|
91
|
+
* `BLOCKED by turnstile: commit message '<subject>' is not a conventional
|
|
92
|
+
* commit — expected type(scope)!: subject` under `gitGates.mode: enforce`
|
|
93
|
+
* (journaled `reason:"invalid-commit-message"`); `shadow` journals only,
|
|
94
|
+
* `off` is a no-op. An uninspectable form (`-F`/`-t`/`--edit`/interactive;
|
|
95
|
+
* `--amend -m` is inspectable) is treated as non-conventional and blocked
|
|
96
|
+
* under `enforce` (`reason:"commit-message-uninspectable"`): pass the subject
|
|
97
|
+
* with `git commit -m <subject>`, not an editor or file.
|
|
98
|
+
* - Landing gate: `git push` with no delivery-attributed reviewer
|
|
99
|
+
* `VERDICT: APPROVE` for the current task is `BLOCKED by turnstile:
|
|
100
|
+
* push/PR requires a reviewer VERDICT: APPROVE before landing — wait for the
|
|
101
|
+
* reviewer or escalate to the user` under `gitGates.mode: enforce` (journaled
|
|
102
|
+
* `reason:"no-landing-approval"`); `shadow` journals only, `off` is a no-op.
|
|
103
|
+
* Approval is set only when the reviewer's task-result delivery carries
|
|
104
|
+
* `VERDICT: APPROVE` (a streamed-only verdict in child text does not
|
|
105
|
+
* satisfy the gate); `gh pr create|merge` stays denied by the default
|
|
106
|
+
* `bashDeny` list (disjointly journaled), and every `git push` is journaled
|
|
107
|
+
* as landing telemetry in every mode.
|
|
108
|
+
* - Landing escalation: on a repeat `git push` with no approval and
|
|
109
|
+
* `gitGates.mode: enforce`, turnstile journals a permission ask
|
|
110
|
+
* (`reason:"landing"`) and lets `execute.before` pass so the
|
|
111
|
+
* `permission.evaluate` handler forces a human ask
|
|
112
|
+
* (`turnstile: push/PR requires a reviewer VERDICT: APPROVE before
|
|
113
|
+
* landing`). The reply `always` stands a reviewer approval for the task,
|
|
114
|
+
* `once` grants exactly one push (consumed by the next push), and `reject`
|
|
115
|
+
* re-blocks and re-arms — the repeat push is the human decision point.
|
|
23
116
|
* - Markers: `GATE:` / `VERDICT:` parsed from task results and child-session
|
|
24
117
|
* assistant text, deduped per task (streaming re-delivery is ignored);
|
|
25
118
|
* marker-less outputs from marker-contracted agents (test-runner/reviewer/
|
|
26
|
-
* plan-reviewer) are journaled as violations;
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
119
|
+
* plan-reviewer) are journaled as violations; the summary/divergence
|
|
120
|
+
* suppression (a child's streamed verdict hiding a marker-less summarized
|
|
121
|
+
* delivery) is scoped to the plan-reviewer only — reviewer/test-runner
|
|
122
|
+
* violate on a marker-less delivery even when the child's streamed text
|
|
123
|
+
* quoted a VERDICT; the last `VERDICT` for a dispatch moves the plan-approval
|
|
124
|
+
* gate, taken from the task result or, when the host summarized the result,
|
|
125
|
+
* from that child session's assistant text; the journaled `type:"plan"`
|
|
126
|
+
* `source` names the winning pipeline; a delivery with no marker on any
|
|
127
|
+
* source keeps the gate red (parent-keyed, journaled as type `plan`); gate
|
|
128
|
+
* journal entries are keyed by the parent session so replay restores where
|
|
129
|
+
* the gates look.
|
|
130
|
+
* - Verdict attribution: every journaled `type:"plan"` entry is
|
|
131
|
+
* keyed `raw` = an actual child session id, never the parent/session id; a
|
|
132
|
+
* marker-less parent-keyed re-fire can never journal a `{raw: p1}` phantom
|
|
133
|
+
* that clobbers a live verification. Attribution is gated on the delivery's
|
|
134
|
+
* agent being `plan-reviewer`: the child→parent edge is awaited (host
|
|
135
|
+
* `session.get` when `session.created` has not yet landed), then, for any
|
|
136
|
+
* self-keyed delivery (its own id is not a registered child, marker-less or
|
|
137
|
+
* not), candidate discovery attributes it to the unique evidenced registered
|
|
138
|
+
* child (via the child-text verdict store); an own VERDICT: APPROVE with zero
|
|
139
|
+
* evidenced candidates is dropped to the error journal (fail-safe), and zero
|
|
140
|
+
* or more-than-one candidate journals an unattributable `type:"error"` and
|
|
141
|
+
* returns (fail-open, gate unchanged, nothing mutated). Child-text verdict
|
|
142
|
+
* observations dedup per (child, marker, source): identical re-delivery and a
|
|
143
|
+
* cross-source same-marker replay are both suppressed; only a marker flip
|
|
144
|
+
* journals, and a prompt reset drops the previous
|
|
145
|
+
* task's children's evidence (scoped to the reset parent, other parents intact).
|
|
146
|
+
* - Verdict attribution, unresolved edge: a self-keyed delivery whose
|
|
147
|
+
* child→parent edge is unresolved now also claims the unique child evidenced
|
|
148
|
+
* only in the streaming store — plausibility = a recorded plan-reviewer
|
|
149
|
+
* dispatch for the parent predates (at-or-before) the observation's recordedAt,
|
|
150
|
+
* plausibly unique across parents (a verdict with two candidate parents is
|
|
151
|
+
* ambiguous and excluded). Timestamps are last-wins and cleared on prompt reset,
|
|
152
|
+
* so a verdict is plausible only within the parent's current task scope. Every
|
|
153
|
+
* self-keyed delivery journals one `type:"attribution"` entry before any mutation
|
|
154
|
+
* (`{decision, deliveryIsParent, sessionApi, candidates:{edge,unresolved,
|
|
155
|
+
* plausible} counts, unresolvedIDs, lookup:{session:{attempts,reason}}}`) so the
|
|
156
|
+
* field can tell the three root-cause hypotheses apart; the attribution is
|
|
157
|
+
* unanimous-pool driven. A plausible streamed `VERDICT: APPROVE` makes a
|
|
158
|
+
* self-keyed `BLOCK` unreachable (structural fail-open over BLOCK). The
|
|
159
|
+
* edge-resolved branch and the never-BLOCK-on-zero-candidates guarantee are
|
|
160
|
+
* unchanged, so an unattributable parent-keyed delivery stays a single
|
|
161
|
+
* `/unattributable/` error on fail-open paths.
|
|
30
162
|
* - Trace: JSONL journal of dispatches, gate transitions, verdicts, blocked
|
|
31
|
-
* calls, bash commands, errors
|
|
163
|
+
* calls, bash commands, errors, and the load heartbeat
|
|
164
|
+
* (a "setup" entry: plugin version, config digest, hook count). Errors also
|
|
165
|
+
* cover the stream-end and unresolvable-agent signals; all telemetry only.
|
|
32
166
|
* - Decoding: executor/test-runner/debugger temperature clamped ≤ 0.2;
|
|
33
167
|
* reviewer temperature = 0.
|
|
34
168
|
* - Config: layered and read once at setup: code defaults ⊕
|
|
@@ -48,6 +182,8 @@
|
|
|
48
182
|
* - `src/markers.ts`: GATE/VERDICT parsing and dedup.
|
|
49
183
|
* - `src/gates.ts`: enforcement decisions (plan-approval gate, reviewer
|
|
50
184
|
* gate, read horizon, decomposition).
|
|
185
|
+
* - `src/tool-policy.ts`: stateless denylist matching for bash/shell and
|
|
186
|
+
* write/edit tools in `execute.before`.
|
|
51
187
|
* - `src/setup.ts`: hook registration and the stream-event loop.
|
|
52
188
|
*/
|
|
53
189
|
import { createTurnstile } from "./src/setup.ts"
|