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/config.ts
CHANGED
|
@@ -8,13 +8,21 @@
|
|
|
8
8
|
* contract.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { createHash } from "node:crypto"
|
|
11
12
|
import * as fs from "node:fs"
|
|
12
13
|
import * as os from "node:os"
|
|
13
14
|
import * as path from "node:path"
|
|
14
15
|
|
|
15
16
|
import { appendLine, defaultMetricsDir } from "./journal.ts"
|
|
16
17
|
import { GATE_RE, VERDICT_RE } from "./markers.ts"
|
|
17
|
-
import type {
|
|
18
|
+
import type {
|
|
19
|
+
GateEvidenceMode,
|
|
20
|
+
TurnstileAgents,
|
|
21
|
+
TurnstileConfig,
|
|
22
|
+
TurnstileGitGates,
|
|
23
|
+
TurnstileToolPolicy,
|
|
24
|
+
ToolPolicyMode,
|
|
25
|
+
} from "./types.ts"
|
|
18
26
|
|
|
19
27
|
/** Default config file location. */
|
|
20
28
|
export const CONFIG_PATH: string = path.join(os.homedir(), ".config", "opencode", "turnstile.json")
|
|
@@ -26,6 +34,26 @@ export const ENV_KEYS = {
|
|
|
26
34
|
config: "TURNSTILE_CONFIG",
|
|
27
35
|
} as const
|
|
28
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Default case-insensitive evidence pattern: plausible test commands across
|
|
39
|
+
* the common JS and non-JS toolchains. Compiled with `"i"`; a host can override
|
|
40
|
+
* `evidencePattern` for toolchains the default does not cover.
|
|
41
|
+
*/
|
|
42
|
+
export const DEFAULT_EVIDENCE_PATTERN =
|
|
43
|
+
"\\b(npm\\s+(run\\s+)?test|pnpm\\s+test|yarn\\s+test|bun\\s+test|deno\\s+test|node\\s+--test|npx\\s+node\\s+--test|pytest|make\\s+test(\\s|$)|go\\s+test|cargo\\s+test)\\b"
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Default conventional-commits regex SOURCE string for `gitGates.commitFormat`
|
|
47
|
+
* (compiled by callers with `new RegExp("^" + source + "$", "s")`). It accepts
|
|
48
|
+
* a single type from the conventional set `{feat,fix,docs,style,refactor,perf,
|
|
49
|
+
* test,build,ci,chore,revert}`, an optional `(scope)`, a required `!` or `:`
|
|
50
|
+
* separator (only one is needed — no `BREAKING CHANGE` trailer), a subject of
|
|
51
|
+
* up to 72 characters on the first line, and one optional following body line.
|
|
52
|
+
* Note the source carries no leading `^`/trailing `$`: the caller wraps them.
|
|
53
|
+
*/
|
|
54
|
+
export const DEFAULT_COMMIT_FORMAT =
|
|
55
|
+
"(?:feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(?:\\([^)]*\\))?(?:[!]:?|:) [^\\r\\n]{1,72}(?:(?:\\r?\\n){1,2}[^\\r\\n]*)?"
|
|
56
|
+
|
|
29
57
|
/**
|
|
30
58
|
* Builds the code defaults for every config key.
|
|
31
59
|
* @param env Environment consulted for XDG_DATA_HOME only.
|
|
@@ -44,9 +72,11 @@ export function defaultConfig(
|
|
|
44
72
|
readWarn: 8,
|
|
45
73
|
readBlock: 15,
|
|
46
74
|
waiverMarker: "USER WAIVER:",
|
|
75
|
+
approvalMarker: "PLAN APPROVED:",
|
|
47
76
|
plansDir: path.join(".opencode", "plans"),
|
|
48
77
|
trace: true,
|
|
49
78
|
agents: {
|
|
79
|
+
planner: "planner",
|
|
50
80
|
reviewer: "reviewer",
|
|
51
81
|
planReviewer: "plan-reviewer",
|
|
52
82
|
executor: "executor",
|
|
@@ -56,6 +86,41 @@ export function defaultConfig(
|
|
|
56
86
|
},
|
|
57
87
|
gateMarker: GATE_RE.source,
|
|
58
88
|
verdictMarker: VERDICT_RE.source,
|
|
89
|
+
gateEvidence: "enforce",
|
|
90
|
+
evidencePattern: DEFAULT_EVIDENCE_PATTERN,
|
|
91
|
+
toolPolicy: {
|
|
92
|
+
mode: "enforce",
|
|
93
|
+
// Git-protective defaults, encoding src/policy.ts verbatim. Case-insensitive
|
|
94
|
+
// regex sources over bash/shell commands. Each git rule uses a global-option
|
|
95
|
+
// tolerant base (`\\bgit\\b[^;&|]*?\\s<subcommand>`), so a global option
|
|
96
|
+
// (`-C dir`, `--git-dir=…`, a `git alias`) between `git` and the subcommand
|
|
97
|
+
// still matches without terminating at a shell operator; the branch guard
|
|
98
|
+
// excludes the file-restore form so `git [opts] checkout [<ref>] -- <path>`
|
|
99
|
+
// is allowed while `git [opts] checkout <branch>` / `-b`/`-C` is blocked.
|
|
100
|
+
// Tradeoff (see README limits): the wider base can over-match on the bare
|
|
101
|
+
// subcommand word elsewhere, e.g. `git commit -m "… merge …"`.
|
|
102
|
+
bashDeny: [
|
|
103
|
+
"\\bgit\\b[^;&|]*?\\sworktree\\s+(add|remove|prune)\\b",
|
|
104
|
+
"\\bgit\\b[^;&|]*?\\s(switch|checkout)\\b(?![^;&|]*?--\\s)",
|
|
105
|
+
"\\bgit\\b[^;&|]*?\\smerge\\b",
|
|
106
|
+
"\\bgh\\s+pr\\s+(create|merge)\\b",
|
|
107
|
+
],
|
|
108
|
+
// No default path blocks: write/edit are covered (a decision is made and,
|
|
109
|
+
// on block, journaled) without breaking ordinary editing.
|
|
110
|
+
pathDeny: [],
|
|
111
|
+
observeSessionMove: true,
|
|
112
|
+
},
|
|
113
|
+
// Git-and-commit gates ship in `shadow` (audit-only) by default: the gates
|
|
114
|
+
// are active but non-blocking until a user flips a key to `enforce`,
|
|
115
|
+
// mirroring the tool-policy "adopt shadow first" adoption path. `commitFormat`
|
|
116
|
+
// is a conventional-commits regex SOURCE (no anchors): callers wrap it with
|
|
117
|
+
// `^…$` + the `"s"` flag (see src/tool-policy.ts).
|
|
118
|
+
gitGates: {
|
|
119
|
+
mode: "shadow",
|
|
120
|
+
commitMessages: true,
|
|
121
|
+
commitFormat: DEFAULT_COMMIT_FORMAT,
|
|
122
|
+
landingGate: true,
|
|
123
|
+
},
|
|
59
124
|
}
|
|
60
125
|
}
|
|
61
126
|
|
|
@@ -67,10 +132,29 @@ export function defaultConfig(
|
|
|
67
132
|
*/
|
|
68
133
|
function isValidScalar(key: keyof TurnstileConfig, value: unknown): boolean {
|
|
69
134
|
if (key === "trace") return typeof value === "boolean"
|
|
70
|
-
if (
|
|
135
|
+
if (
|
|
136
|
+
key === "metricsDir" ||
|
|
137
|
+
key === "waiverMarker" ||
|
|
138
|
+
key === "approvalMarker" ||
|
|
139
|
+
key === "plansDir"
|
|
140
|
+
) {
|
|
141
|
+
if (key === "metricsDir") {
|
|
142
|
+
return typeof value === "string" && value.trim().length > 0 && path.isAbsolute(value)
|
|
143
|
+
}
|
|
71
144
|
return typeof value === "string" && value.trim().length > 0
|
|
72
145
|
}
|
|
73
146
|
if (key === "gateMarker" || key === "verdictMarker") return typeof value === "string"
|
|
147
|
+
if (key === "gateEvidence" && (value === "enforce" || value === "flag" || value === "off"))
|
|
148
|
+
return true
|
|
149
|
+
if (key === "evidencePattern" && typeof value === "string") {
|
|
150
|
+
// A non-compiling pattern is rejected so `new RegExp` never throws later.
|
|
151
|
+
try {
|
|
152
|
+
new RegExp(value, "i")
|
|
153
|
+
return true
|
|
154
|
+
} catch {
|
|
155
|
+
return false
|
|
156
|
+
}
|
|
157
|
+
}
|
|
74
158
|
return typeof value === "number" && Number.isFinite(value) && value > 0
|
|
75
159
|
}
|
|
76
160
|
|
|
@@ -96,9 +180,12 @@ function assignScalar(config: TurnstileConfig, key: keyof TurnstileConfig, value
|
|
|
96
180
|
if (key === "trace") config.trace = value as boolean
|
|
97
181
|
else if (key === "metricsDir") config.metricsDir = value as string
|
|
98
182
|
else if (key === "waiverMarker") config.waiverMarker = value as string
|
|
183
|
+
else if (key === "approvalMarker") config.approvalMarker = value as string
|
|
99
184
|
else if (key === "plansDir") config.plansDir = value as string
|
|
100
185
|
else if (key === "gateMarker") config.gateMarker = value as string
|
|
101
186
|
else if (key === "verdictMarker") config.verdictMarker = value as string
|
|
187
|
+
else if (key === "gateEvidence") config.gateEvidence = value as GateEvidenceMode
|
|
188
|
+
else if (key === "evidencePattern") config.evidencePattern = value as string
|
|
102
189
|
else config[key as NumericConfigKey] = value as number
|
|
103
190
|
}
|
|
104
191
|
|
|
@@ -115,7 +202,12 @@ export function mergeConfig(
|
|
|
115
202
|
patch: unknown,
|
|
116
203
|
): { config: TurnstileConfig; warnings: string[] } {
|
|
117
204
|
const warnings: string[] = []
|
|
118
|
-
const config: TurnstileConfig = {
|
|
205
|
+
const config: TurnstileConfig = {
|
|
206
|
+
...base,
|
|
207
|
+
agents: { ...base.agents },
|
|
208
|
+
toolPolicy: { ...base.toolPolicy },
|
|
209
|
+
gitGates: { ...base.gitGates },
|
|
210
|
+
}
|
|
119
211
|
if (patch === null || typeof patch !== "object" || Array.isArray(patch)) {
|
|
120
212
|
if (patch !== undefined) {
|
|
121
213
|
warnings.push(
|
|
@@ -160,10 +252,130 @@ export function mergeConfig(
|
|
|
160
252
|
}
|
|
161
253
|
continue
|
|
162
254
|
}
|
|
255
|
+
if (key === "toolPolicy") {
|
|
256
|
+
// Merge one level deep like `agents`. A non-object keeps all defaults; each
|
|
257
|
+
// known sub-key is validated, and invalid bashDeny/pathDeny elements are
|
|
258
|
+
// dropped individually (compiled with "i") with one warning each so a
|
|
259
|
+
// config typo weakens the policy visibly in the journal, never silently.
|
|
260
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
261
|
+
warnings.push("config: toolPolicy must be an object; keeping defaults")
|
|
262
|
+
continue
|
|
263
|
+
}
|
|
264
|
+
const merged: TurnstileToolPolicy = { ...config.toolPolicy }
|
|
265
|
+
for (const [tpKey, tpValue] of Object.entries(value as Record<string, unknown>)) {
|
|
266
|
+
if (tpKey === "mode") {
|
|
267
|
+
if (tpValue === "enforce" || tpValue === "shadow" || tpValue === "off") {
|
|
268
|
+
merged.mode = tpValue as ToolPolicyMode
|
|
269
|
+
} else {
|
|
270
|
+
warnings.push(
|
|
271
|
+
'config: toolPolicy.mode must be "enforce", "shadow", or "off"; keeping default',
|
|
272
|
+
)
|
|
273
|
+
}
|
|
274
|
+
} else if (tpKey === "observeSessionMove") {
|
|
275
|
+
if (typeof tpValue === "boolean") {
|
|
276
|
+
merged.observeSessionMove = tpValue
|
|
277
|
+
} else {
|
|
278
|
+
warnings.push(
|
|
279
|
+
"config: toolPolicy.observeSessionMove must be a boolean; keeping default",
|
|
280
|
+
)
|
|
281
|
+
}
|
|
282
|
+
} else if (tpKey === "bashDeny" || tpKey === "pathDeny") {
|
|
283
|
+
// Only the value itself being non-array falls back to the default
|
|
284
|
+
// (fail-safe); otherwise every element is validated so a single bad
|
|
285
|
+
// element never discards the whole user list, nor silently widens the
|
|
286
|
+
// policy by letting an empty/blank string through. Bad elements are
|
|
287
|
+
// dropped individually and journaled ({type:"error"} via loadConfig),
|
|
288
|
+
// mirroring the non-compiling-regex drop below.
|
|
289
|
+
if (!Array.isArray(tpValue)) {
|
|
290
|
+
warnings.push(
|
|
291
|
+
`config: toolPolicy.${tpKey} must be an array of non-empty strings; keeping default`,
|
|
292
|
+
)
|
|
293
|
+
continue
|
|
294
|
+
}
|
|
295
|
+
const sources: string[] = []
|
|
296
|
+
tpValue.forEach((src, i) => {
|
|
297
|
+
if (typeof src !== "string" || src.trim().length === 0) {
|
|
298
|
+
warnings.push(
|
|
299
|
+
`config: toolPolicy.${tpKey}[${i}] is not a non-empty string; dropping element`,
|
|
300
|
+
)
|
|
301
|
+
return
|
|
302
|
+
}
|
|
303
|
+
try {
|
|
304
|
+
new RegExp(src, "i")
|
|
305
|
+
sources.push(src)
|
|
306
|
+
} catch {
|
|
307
|
+
warnings.push(
|
|
308
|
+
`config: toolPolicy.${tpKey}[${i}] is not a valid regex; dropping element`,
|
|
309
|
+
)
|
|
310
|
+
}
|
|
311
|
+
})
|
|
312
|
+
merged[tpKey] = sources
|
|
313
|
+
} else {
|
|
314
|
+
warnings.push(`config: unknown toolPolicy key "${tpKey}" ignored`)
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
config.toolPolicy = merged
|
|
318
|
+
continue
|
|
319
|
+
}
|
|
320
|
+
if (key === "gitGates") {
|
|
321
|
+
// Merge one level deep like `toolPolicy` and `agents`. A non-object keeps
|
|
322
|
+
// all defaults; each known sub-key is validated individually with a
|
|
323
|
+
// journaled warning so a config typo weakens the gate visibly, never
|
|
324
|
+
// silently. `commitFormat` is a regex SOURCE (compilation checked with the
|
|
325
|
+
// same `^…$` + `"s"` wrap the caller uses, so `new RegExp` never throws).
|
|
326
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
327
|
+
warnings.push("config: gitGates must be an object; keeping defaults")
|
|
328
|
+
continue
|
|
329
|
+
}
|
|
330
|
+
const merged: TurnstileGitGates = { ...config.gitGates }
|
|
331
|
+
for (const [gk, gv] of Object.entries(value as Record<string, unknown>)) {
|
|
332
|
+
if (gk === "mode") {
|
|
333
|
+
if (gv === "enforce" || gv === "shadow" || gv === "off") {
|
|
334
|
+
merged.mode = gv as ToolPolicyMode
|
|
335
|
+
} else {
|
|
336
|
+
warnings.push(
|
|
337
|
+
'config: gitGates.mode must be "enforce", "shadow", or "off"; keeping default',
|
|
338
|
+
)
|
|
339
|
+
}
|
|
340
|
+
} else if (gk === "commitMessages" || gk === "landingGate") {
|
|
341
|
+
if (typeof gv === "boolean") {
|
|
342
|
+
if (gk === "commitMessages") merged.commitMessages = gv
|
|
343
|
+
else merged.landingGate = gv
|
|
344
|
+
} else {
|
|
345
|
+
warnings.push(`config: gitGates.${gk} must be a boolean; keeping default`)
|
|
346
|
+
}
|
|
347
|
+
} else if (gk === "commitFormat") {
|
|
348
|
+
if (typeof gv !== "string") {
|
|
349
|
+
warnings.push("config: gitGates.commitFormat must be a string; keeping default")
|
|
350
|
+
} else {
|
|
351
|
+
try {
|
|
352
|
+
new RegExp("^" + gv + "$", "s")
|
|
353
|
+
merged.commitFormat = gv
|
|
354
|
+
} catch {
|
|
355
|
+
warnings.push("config: gitGates.commitFormat is not a valid regex; keeping default")
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
} else {
|
|
359
|
+
warnings.push(`config: unknown gitGates key "${gk}" ignored`)
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
config.gitGates = merged
|
|
363
|
+
continue
|
|
364
|
+
}
|
|
163
365
|
if (!(key in config)) {
|
|
164
366
|
warnings.push(`config: unknown key "${key}" ignored`)
|
|
165
367
|
continue
|
|
166
368
|
}
|
|
369
|
+
if (key === "gateEvidence") {
|
|
370
|
+
// Validated/assigned by the shared helpers; only the warning wording is
|
|
371
|
+
// special-cased to name the three accepted literals.
|
|
372
|
+
if (isValidScalar(key as keyof TurnstileConfig, value)) {
|
|
373
|
+
assignScalar(config, key as keyof TurnstileConfig, value)
|
|
374
|
+
} else {
|
|
375
|
+
warnings.push(`config: gateEvidence must be "enforce", "flag", or "off"; keeping default`)
|
|
376
|
+
}
|
|
377
|
+
continue
|
|
378
|
+
}
|
|
167
379
|
if (isValidScalar(key as keyof TurnstileConfig, value)) {
|
|
168
380
|
assignScalar(config, key as keyof TurnstileConfig, value)
|
|
169
381
|
} else {
|
|
@@ -234,12 +446,66 @@ function applyEnvLayer(config: TurnstileConfig, env: Record<string, string | und
|
|
|
234
446
|
}
|
|
235
447
|
const metricsDir = env[ENV_KEYS.metricsDir]
|
|
236
448
|
if (metricsDir !== undefined) {
|
|
237
|
-
if (metricsDir.trim().length
|
|
238
|
-
|
|
449
|
+
if (metricsDir.trim().length === 0) {
|
|
450
|
+
warnings.push(`env ${ENV_KEYS.metricsDir} empty; keeping current value`)
|
|
451
|
+
} else if (!path.isAbsolute(metricsDir)) {
|
|
452
|
+
warnings.push(`env ${ENV_KEYS.metricsDir}="${metricsDir}" is relative; keeping current value`)
|
|
453
|
+
} else {
|
|
454
|
+
config.metricsDir = metricsDir
|
|
455
|
+
}
|
|
239
456
|
}
|
|
240
457
|
return warnings
|
|
241
458
|
}
|
|
242
459
|
|
|
460
|
+
/**
|
|
461
|
+
* Defends the fully-resolved config against inter-key and path invariants that
|
|
462
|
+
* no single layer validator catches, so hand-constructed configs cannot slip a
|
|
463
|
+
* bad value past the merge-time checks. Mutates `config` in place (clamping
|
|
464
|
+
* `readWarn` below `readBlock`, warning on a relative `metricsDir`) and
|
|
465
|
+
* returns one warning string per correction.
|
|
466
|
+
*
|
|
467
|
+
* Called once at the end of `loadConfig` after every layer has merged, so the
|
|
468
|
+
* defaults (8 < 15, absolute dir) never trip it and the golden digest stays
|
|
469
|
+
* stable. Exported for tests: every merge layer forces an absolute
|
|
470
|
+
* `metricsDir`, so the relative-dir warning is reachable only directly.
|
|
471
|
+
* @param config The resolved config to validate.
|
|
472
|
+
* @returns One warning string per corrected/resolved invariant.
|
|
473
|
+
*/
|
|
474
|
+
export function validateResolved(config: TurnstileConfig): string[] {
|
|
475
|
+
const warnings: string[] = []
|
|
476
|
+
if (config.readWarn >= config.readBlock) {
|
|
477
|
+
const warn = config.readWarn
|
|
478
|
+
const block = config.readBlock
|
|
479
|
+
config.readWarn = Math.max(block - 1, 0)
|
|
480
|
+
warnings.push(
|
|
481
|
+
`config: readWarn (${warn}) >= readBlock (${block}); clamping readWarn to ${config.readWarn}`,
|
|
482
|
+
)
|
|
483
|
+
}
|
|
484
|
+
if (!path.isAbsolute(config.metricsDir)) {
|
|
485
|
+
warnings.push(
|
|
486
|
+
`config: metricsDir "${config.metricsDir}" is relative; journaling resolves against the process cwd`,
|
|
487
|
+
)
|
|
488
|
+
}
|
|
489
|
+
return warnings
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Re-derives `bareOutput` from the configured agent names when the resolved
|
|
494
|
+
* list is still the unchanged default literal (i.e. nobody configured it), so
|
|
495
|
+
* a renamed agent stays bare-output-qualified automatically. Explicit lists
|
|
496
|
+
* are kept verbatim so no configured name ever vanishes. Mutates
|
|
497
|
+
* `agents.bareOutput` in place.
|
|
498
|
+
* @param agents The resolved agents block to update in place.
|
|
499
|
+
* @param defaultBareOutput The current default bare-output literal.
|
|
500
|
+
*/
|
|
501
|
+
function deriveBareOutput(agents: TurnstileAgents, defaultBareOutput: string[]): void {
|
|
502
|
+
if (JSON.stringify(agents.bareOutput) !== JSON.stringify(defaultBareOutput)) return
|
|
503
|
+
const derived = [agents.testRunner, agents.reviewer, agents.planReviewer].filter(
|
|
504
|
+
(name): name is string => typeof name === "string" && name.trim().length > 0,
|
|
505
|
+
)
|
|
506
|
+
agents.bareOutput = derived.filter((name, i) => derived.indexOf(name) === i)
|
|
507
|
+
}
|
|
508
|
+
|
|
243
509
|
/**
|
|
244
510
|
* Resolves the full layered configuration: defaults ⊕ config file ⊕ env ⊕
|
|
245
511
|
* injected overrides. Config-file and validation problems are journaled to
|
|
@@ -254,8 +520,9 @@ export function loadConfig(
|
|
|
254
520
|
overrides?: Partial<TurnstileConfig>,
|
|
255
521
|
): { config: TurnstileConfig; warnings: string[] } {
|
|
256
522
|
const warnings: string[] = []
|
|
523
|
+
const defaults = defaultConfig(env)
|
|
257
524
|
const fileLayer = readFileLayer(env)
|
|
258
|
-
const fileMerge = mergeConfig(
|
|
525
|
+
const fileMerge = mergeConfig(defaults, fileLayer.patch)
|
|
259
526
|
const config = fileMerge.config
|
|
260
527
|
warnings.push(...fileLayer.warnings, ...fileMerge.warnings)
|
|
261
528
|
warnings.push(...applyEnvLayer(config, env))
|
|
@@ -265,8 +532,30 @@ export function loadConfig(
|
|
|
265
532
|
resolved = merged.config
|
|
266
533
|
warnings.push(...merged.warnings)
|
|
267
534
|
}
|
|
535
|
+
warnings.push(...validateResolved(resolved))
|
|
536
|
+
deriveBareOutput(resolved.agents, defaults.agents.bareOutput)
|
|
268
537
|
for (const warning of warnings) {
|
|
269
538
|
if (!warning.startsWith("config file")) journalConfigError(warning, env)
|
|
270
539
|
}
|
|
271
540
|
return { config: resolved, warnings }
|
|
272
541
|
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Computes a short, stable digest of a resolved config.
|
|
545
|
+
*
|
|
546
|
+
* The digest is the first 12 hex characters of the SHA-256 of the config's
|
|
547
|
+
* JSON serialization: two setups with the same config produce the same
|
|
548
|
+
* digest, and any config change flips it — so the digest ties a session's
|
|
549
|
+
* journal entries to the exact config that drove them (and is reproducible
|
|
550
|
+
* offline). `JSON.stringify` serializes a plain object's keys in insertion
|
|
551
|
+
* order, and every config is built by the same literal + spread paths
|
|
552
|
+
* (`defaultConfig`, `mergeConfig`), so the serialization is stable *today*.
|
|
553
|
+
* A future refactor that conditionally inserts keys would change the
|
|
554
|
+
* serialization and invalidate historical digests — by design, a changed
|
|
555
|
+
* config should no longer match an old one.
|
|
556
|
+
* @param config The resolved config to digest.
|
|
557
|
+
* @returns 12 lowercase hex characters.
|
|
558
|
+
*/
|
|
559
|
+
export function configDigest(config: TurnstileConfig): string {
|
|
560
|
+
return createHash("sha256").update(JSON.stringify(config)).digest("hex").slice(0, 12)
|
|
561
|
+
}
|
package/src/evidence.ts
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Evidence boundary: correlates an observed `GATE: PASS` against
|
|
3
|
+
* journaled successful (exit 0) bash/shell test-command executions attributed
|
|
4
|
+
* to the emitting session's parent within the current task window.
|
|
5
|
+
*
|
|
6
|
+
* This owns only the *evidence* dimension — whether the claimed tests ran.
|
|
7
|
+
* Marker authority (*who* may flip gates) is deliberately separate
|
|
8
|
+
* and out of scope here. The ledger is in-memory only (never command text);
|
|
9
|
+
* audited verdicts are journaled so replay restores evidence-bearing state
|
|
10
|
+
* rather than re-proving it. Pure w.r.t. journal/fs by design.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { DEFAULT_EVIDENCE_PATTERN } from "./config.ts"
|
|
14
|
+
import type { Journaler } from "./journal.ts"
|
|
15
|
+
import type { TurnstileConfig } from "./types.ts"
|
|
16
|
+
|
|
17
|
+
/** Per-session hit cap: a ring buffer of exit-0 hit timestamps. */
|
|
18
|
+
const MAX_HITS_PER_SESSION = 8
|
|
19
|
+
|
|
20
|
+
/** Fallback pattern used when the configured `evidencePattern` fails to compile. */
|
|
21
|
+
const FALLBACK_EVIDENCE_PATTERN = new RegExp(DEFAULT_EVIDENCE_PATTERN, "i")
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Compiles the evidence-pattern regex once, with a `compileMarkerRegex`-style
|
|
25
|
+
* fallback: on a compile failure the caller is notified via `onFallback` and
|
|
26
|
+
* the default pattern is used.
|
|
27
|
+
* @param source The configured pattern source.
|
|
28
|
+
* @param fallback Pattern to return on compile failure.
|
|
29
|
+
* @param onFallback Optional callback invoked with the caught cause first.
|
|
30
|
+
* @returns The compiled pattern (configured, or the fallback).
|
|
31
|
+
*/
|
|
32
|
+
function compileEvidencePattern(
|
|
33
|
+
source: string,
|
|
34
|
+
fallback: RegExp,
|
|
35
|
+
onFallback?: (cause: unknown) => void,
|
|
36
|
+
): RegExp {
|
|
37
|
+
try {
|
|
38
|
+
return new RegExp(source, "i")
|
|
39
|
+
} catch (cause) {
|
|
40
|
+
onFallback?.(cause)
|
|
41
|
+
return fallback
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Evidence tracker: recorded exit-0 bash/shell hits and their resolution. */
|
|
46
|
+
export interface EvidenceTracker {
|
|
47
|
+
/**
|
|
48
|
+
* Records a plausible exit-0 test execution under `raw`, returning whether a
|
|
49
|
+
* hit was recorded. Non-zero exits and non-matching commands are ignored;
|
|
50
|
+
* the command text is never stored.
|
|
51
|
+
* @param raw The session ID that ran the command.
|
|
52
|
+
* @param command The command string (matched, never retained).
|
|
53
|
+
* @param exit The command exit code.
|
|
54
|
+
* @returns True when a hit was recorded.
|
|
55
|
+
*/
|
|
56
|
+
observe(raw: string, command: unknown, exit: unknown): boolean
|
|
57
|
+
/**
|
|
58
|
+
* True when `sid` or any child whose child→parent edge resolves to `sid`
|
|
59
|
+
* has a recorded hit. Re-keys a found child bucket to `sid` (lazy
|
|
60
|
+
* promotion, so an async parent edge established after `observe` still
|
|
61
|
+
* resolves).
|
|
62
|
+
* @param sid The parent (or raw) session ID to check.
|
|
63
|
+
* @returns True when correlated evidence exists.
|
|
64
|
+
*/
|
|
65
|
+
has(sid: string): boolean
|
|
66
|
+
/**
|
|
67
|
+
* Drops `sid`'s bucket and every child's bucket whose edge resolves to `sid`.
|
|
68
|
+
* @param sid The parent (or raw) session ID to clear.
|
|
69
|
+
*/
|
|
70
|
+
clear(sid: string): void
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Creates an in-memory evidence tracker bound to a config + parent resolver.
|
|
75
|
+
* A configured `evidencePattern` that fails to compile falls back to the
|
|
76
|
+
* default pattern and journals the fallback via `journaler`.
|
|
77
|
+
* @param config The resolved config (provides `evidencePattern`, `maxSessions`).
|
|
78
|
+
* @param parentOf Resolves a raw session ID to its parent (the ledger's `parentOf`).
|
|
79
|
+
* @param journaler Optional telemetry writer; used only to journal the
|
|
80
|
+
* pattern-fallback (a compile failure) when set.
|
|
81
|
+
* @returns The observe/has/clear tracker.
|
|
82
|
+
*/
|
|
83
|
+
export function createEvidenceTracker(
|
|
84
|
+
config: TurnstileConfig,
|
|
85
|
+
parentOf: (raw: string) => string,
|
|
86
|
+
journaler?: Pick<Journaler, "journal">,
|
|
87
|
+
): EvidenceTracker {
|
|
88
|
+
const pattern = compileEvidencePattern(
|
|
89
|
+
config.evidencePattern,
|
|
90
|
+
FALLBACK_EVIDENCE_PATTERN,
|
|
91
|
+
(cause) =>
|
|
92
|
+
journaler?.journal({
|
|
93
|
+
type: "error",
|
|
94
|
+
error: `config evidencePattern: invalid regex, using default: ${String(cause)}`,
|
|
95
|
+
}),
|
|
96
|
+
)
|
|
97
|
+
// Raw session ID → ring of exit-0 hit timestamps; insertion-order LRU,
|
|
98
|
+
// capped at `config.maxSessions` (mirroring state.ts bookkeeping).
|
|
99
|
+
const hits = new Map<string, number[]>()
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Promotes a key to most-recently-used in an insertion-ordered map.
|
|
103
|
+
* @param map The map to reorder.
|
|
104
|
+
* @param key The key to touch (no-op when absent).
|
|
105
|
+
*/
|
|
106
|
+
function promote(map: Map<string, number[]>, key: string): void {
|
|
107
|
+
const v = map.get(key)
|
|
108
|
+
if (v !== undefined) {
|
|
109
|
+
map.delete(key)
|
|
110
|
+
map.set(key, v)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Evicts least-recently-used entries until within the cap.
|
|
116
|
+
* @param map The map to trim.
|
|
117
|
+
*/
|
|
118
|
+
function cap(map: Map<string, number[]>): void {
|
|
119
|
+
while (map.size > config.maxSessions) {
|
|
120
|
+
// The loop condition guarantees a first entry exists.
|
|
121
|
+
const oldest = map.keys().next().value as string
|
|
122
|
+
map.delete(oldest)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function observe(raw: string, command: unknown, exit: unknown): boolean {
|
|
127
|
+
if (!raw) return false
|
|
128
|
+
if (exit !== 0) return false
|
|
129
|
+
if (!pattern.test(String(command ?? ""))) return false
|
|
130
|
+
const bucket = [...(hits.get(raw) ?? []), Date.now()]
|
|
131
|
+
if (bucket.length > MAX_HITS_PER_SESSION) bucket.length = MAX_HITS_PER_SESSION
|
|
132
|
+
hits.set(raw, bucket)
|
|
133
|
+
cap(hits)
|
|
134
|
+
return true
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function has(sid: string): boolean {
|
|
138
|
+
if (!sid) return false
|
|
139
|
+
// Touch unconditionally: present keys reorder to MRU, absent keys no-op.
|
|
140
|
+
promote(hits, sid)
|
|
141
|
+
if (hits.has(sid)) return true
|
|
142
|
+
for (const [child, bucket] of hits) {
|
|
143
|
+
if (parentOf(child) === sid) {
|
|
144
|
+
hits.delete(child)
|
|
145
|
+
hits.set(sid, bucket)
|
|
146
|
+
promote(hits, sid)
|
|
147
|
+
return true
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return false
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function clear(sid: string): void {
|
|
154
|
+
if (!sid) return
|
|
155
|
+
hits.delete(sid)
|
|
156
|
+
for (const [child] of hits) {
|
|
157
|
+
if (parentOf(child) === sid) hits.delete(child)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return { observe, has, clear }
|
|
162
|
+
}
|