@try-works/dsh-recursive-mode 0.3.1 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +959 -0
- package/lib/client.js +9 -2
- package/lib/closeout-report.d.ts +113 -0
- package/lib/closeout-standards.d.ts +35 -0
- package/lib/closeout.d.ts +12 -0
- package/lib/commands.d.ts +1 -1
- package/lib/config.d.ts +202 -0
- package/lib/delegation.d.ts +123 -3
- package/lib/enforcement.d.ts +90 -1
- package/lib/errors.d.ts +168 -0
- package/lib/git-context.d.ts +17 -0
- package/lib/guard-log.d.ts +39 -0
- package/lib/handoff.d.ts +29 -0
- package/lib/hooks.d.ts +103 -0
- package/lib/identity.d.ts +61 -0
- package/lib/index.d.ts +32 -12
- package/lib/index.js +9819 -3858
- package/lib/job-log.d.ts +34 -0
- package/lib/jobs-runner.d.ts +105 -0
- package/lib/json-safe.d.ts +33 -0
- package/lib/lock.d.ts +42 -0
- package/lib/memory-feedback.d.ts +52 -0
- package/lib/memory-select.d.ts +78 -0
- package/lib/memory.d.ts +137 -0
- package/lib/model-inventory.d.ts +106 -0
- package/lib/phase-graph.d.ts +111 -0
- package/lib/phase-rules.d.ts +67 -8
- package/lib/plan-gate.d.ts +68 -0
- package/lib/policy-globs.d.ts +222 -0
- package/lib/policy-write.d.ts +42 -0
- package/lib/policy.d.ts +39 -0
- package/lib/recursive_ask.tool.d.ts +88 -0
- package/lib/recursive_closeout.tool.d.ts +1 -1
- package/lib/recursive_delegate.tool.d.ts +22 -0
- package/lib/recursive_preview.tool.d.ts +48 -0
- package/lib/recursive_review.tool.d.ts +28 -0
- package/lib/result-cap.d.ts +70 -0
- package/lib/review-round.d.ts +82 -0
- package/lib/review.d.ts +9 -0
- package/lib/role-route.d.ts +122 -0
- package/lib/router.d.ts +90 -5
- package/lib/runtime.d.ts +252 -12
- package/lib/settlement.d.ts +132 -0
- package/lib/skills-phase.d.ts +71 -0
- package/lib/status.d.ts +53 -1
- package/lib/teams-loop.d.ts +91 -2
- package/lib/training.d.ts +211 -0
- package/lib/ts-lint.d.ts +15 -0
- package/lib/types.d.ts +48 -0
- package/lib/workflow-audit.d.ts +207 -0
- package/package.json +29 -30
- package/preset/recursive/agent.cordis.yml +2 -2
- package/preset/recursive.patch.yml +312 -0
- package/scripts/e2e-run.mjs +51 -0
- package/scripts/link-dsh.mjs +233 -0
- package/scripts/live/fake-llm.mjs +150 -0
- package/scripts/live-session-plugin.mjs +179 -0
- package/scripts/live-session-stock.mjs +106 -0
- package/scripts/live-session.mjs +139 -0
- package/src/client/derive.ts +18 -2
- package/src/closeout-report.ts +274 -0
- package/src/closeout-standards.ts +102 -0
- package/src/closeout.ts +39 -2
- package/src/commands.ts +116 -4
- package/src/config.ts +113 -0
- package/src/delegation.ts +336 -18
- package/src/enforcement.ts +262 -72
- package/src/errors.ts +197 -0
- package/src/git-context.ts +33 -2
- package/src/guard-log.ts +134 -0
- package/src/handoff.ts +62 -0
- package/src/hooks.ts +316 -0
- package/src/identity.ts +230 -0
- package/src/index.ts +385 -20
- package/src/job-log.ts +112 -0
- package/src/jobs-runner.ts +222 -0
- package/src/json-safe.ts +75 -0
- package/src/lock.ts +153 -16
- package/src/memory-feedback.ts +185 -0
- package/src/memory-select.ts +187 -0
- package/src/memory.ts +309 -0
- package/src/model-inventory.ts +196 -0
- package/src/phase-graph.ts +191 -0
- package/src/phase-rules.ts +236 -0
- package/src/plan-gate.ts +111 -0
- package/src/policy-globs.ts +636 -0
- package/src/policy-write.ts +210 -0
- package/src/policy.ts +70 -5
- package/src/recursive_ask.tool.ts +276 -0
- package/src/recursive_audit_team.tool.ts +7 -3
- package/src/recursive_closeout.tool.ts +36 -35
- package/src/recursive_delegate.tool.ts +194 -0
- package/src/recursive_init.tool.ts +4 -3
- package/src/recursive_lint.tool.ts +81 -6
- package/src/recursive_lock.tool.ts +21 -4
- package/src/recursive_phase.tool.ts +3 -2
- package/src/recursive_preview.tool.ts +142 -0
- package/src/recursive_review.tool.ts +190 -0
- package/src/recursive_scratch.tool.ts +5 -4
- package/src/recursive_status.tool.ts +3 -2
- package/src/recursive_worktree.tool.ts +6 -5
- package/src/result-cap.ts +130 -0
- package/src/review-round.ts +335 -0
- package/src/review.ts +17 -3
- package/src/role-route.ts +230 -0
- package/src/router.ts +128 -2
- package/src/runtime.ts +968 -39
- package/src/settlement.ts +355 -0
- package/src/skills-phase.ts +143 -0
- package/src/snapshot.ts +39 -8
- package/src/status.ts +209 -4
- package/src/teams-loop.ts +223 -9
- package/src/training.ts +565 -0
- package/src/ts-lint.ts +38 -4
- package/src/types.ts +51 -0
- package/src/workflow-audit.ts +288 -0
- package/scripts/install-preset.cmd +0 -7
- package/scripts/install-preset.js +0 -101
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* T16 — declarative, ordered tool policy (plan §4 T16).
|
|
3
|
+
*
|
|
4
|
+
* Enforcement used to be a hardcoded `WRITE_TOOL_NAMES` set plus a
|
|
5
|
+
* `endsWith('.md') && includes('/.recursive/run/')` substring test inside
|
|
6
|
+
* `evaluateToolGuard`. A code path is not auditable; an ordered rule list is.
|
|
7
|
+
* This module is the rule list and its evaluator, plus the loader — nothing
|
|
8
|
+
* else. The RULES THEMSELVES live in `src/enforcement.ts` (built-in defaults)
|
|
9
|
+
* and `src/phase-rules.ts` (per-phase baselines), so this module stays pure and
|
|
10
|
+
* importable by both without a cycle.
|
|
11
|
+
*
|
|
12
|
+
* - PRECEDENCE is SPECIFICITY FIRST, THEN FILE ORDER (see
|
|
13
|
+
* `patternSpecificity`/`evaluateToolPolicy`): the most specific matching
|
|
14
|
+
* pattern decides, and equally specific rules are decided by file order. This
|
|
15
|
+
* is what makes "deny beats a matching allow" true no matter where the
|
|
16
|
+
* catch-all `*` sits in the file;
|
|
17
|
+
* - a matched `deny` before a matched `allow` of the same specificity wins, and
|
|
18
|
+
* the shipped lists keep every `deny` above every `allow` as a READABILITY
|
|
19
|
+
* convention, not as the correctness mechanism;
|
|
20
|
+
* - an INVALID pattern fails the WHOLE policy closed — deny everything, and
|
|
21
|
+
* the reason NAMES the offending pattern;
|
|
22
|
+
* - NO match yields `ask` — not deny, not allow. `ask` is the only verdict
|
|
23
|
+
* that neither silently permits nor bricks the session, so it is the default.
|
|
24
|
+
*
|
|
25
|
+
* ---------------------------------------------------------------------------
|
|
26
|
+
* THE ABSENT-vs-PRESENT DISTINCTION (read this before changing the loader)
|
|
27
|
+
* ---------------------------------------------------------------------------
|
|
28
|
+
* ABSENT policy file -> the BUILT-IN default rule list, which reproduces
|
|
29
|
+
* the guard behaviour that shipped before T16. This
|
|
30
|
+
* is the SAFE DEFAULT: every existing repo, and every
|
|
31
|
+
* temp repo a test creates, keeps working.
|
|
32
|
+
* PRESENT but empty -> FAIL CLOSED: deny everything. An empty allow-list
|
|
33
|
+
* is not an allow-all list.
|
|
34
|
+
* PRESENT but invalid -> FAIL CLOSED: deny everything, naming the pattern /
|
|
35
|
+
* rule field that is wrong.
|
|
36
|
+
*
|
|
37
|
+
* Collapsing those two cases in EITHER direction is the bug this comment exists
|
|
38
|
+
* to prevent: treating absent as invalid BRICKS every repo (all tool calls
|
|
39
|
+
* denied), and treating invalid as absent silently ignores the policy a human
|
|
40
|
+
* just wrote and believes is in force.
|
|
41
|
+
*/
|
|
42
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
43
|
+
import { join } from 'node:path'
|
|
44
|
+
import { getLockStatus, getPrerequisiteBlockers } from './lock.ts'
|
|
45
|
+
import { getMdFieldValue } from './status.ts'
|
|
46
|
+
import { policyTargetPath, resolveFrom } from './phase-rules.ts'
|
|
47
|
+
|
|
48
|
+
/** The three verdicts a rule may carry. */
|
|
49
|
+
export type Verdict = 'allow' | 'deny' | 'ask'
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The policy engine's own decision shape (no `warn`/`transition` — those are the
|
|
53
|
+
* guard's, see enforcement.ts). `rule` is the `label` of the rule that decided
|
|
54
|
+
* the call, so a verdict is traceable to an auditable line in the policy file;
|
|
55
|
+
* it is absent when the no-match default decided.
|
|
56
|
+
*/
|
|
57
|
+
export interface Decision {
|
|
58
|
+
kind: Verdict
|
|
59
|
+
reason?: string
|
|
60
|
+
rule?: string
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Extra facts a rule predicate may need. `args` is always the tool call's
|
|
65
|
+
* arguments; the run coordinates are present only when the caller has them
|
|
66
|
+
* (a guard call from a real session does; a pure policy unit test need not).
|
|
67
|
+
*/
|
|
68
|
+
export interface ToolPolicyContext {
|
|
69
|
+
args: Record<string, unknown>
|
|
70
|
+
runDir?: string
|
|
71
|
+
runId?: string
|
|
72
|
+
worktreeRoot?: string
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* What a rule predicate returns when the condition it guards DOES apply:
|
|
77
|
+
* the verdict, plus an optional `detail` appended to the rule's own `reason`.
|
|
78
|
+
* The rule keeps the static, auditable sentence (the same sentence the policy
|
|
79
|
+
* file and the built-in list carry, so the two cannot drift); the predicate adds
|
|
80
|
+
* only the per-call particular — which artifact blocked the lock, and its status.
|
|
81
|
+
*
|
|
82
|
+
* `null` means the condition does NOT apply, and the engine falls through to the
|
|
83
|
+
* NEXT rule. That fall-through is how an id-glob rule such as `write` can guard
|
|
84
|
+
* write tools without deciding for an ordinary write that violates nothing
|
|
85
|
+
* (which a bare pattern match would otherwise deny).
|
|
86
|
+
*/
|
|
87
|
+
export interface ToolPolicyPredicateMatch {
|
|
88
|
+
verdict: Verdict
|
|
89
|
+
detail?: string
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export type ToolPolicyPredicate = (
|
|
93
|
+
id: string,
|
|
94
|
+
args: Record<string, unknown>,
|
|
95
|
+
ctx: ToolPolicyContext,
|
|
96
|
+
) => ToolPolicyPredicateMatch | null
|
|
97
|
+
|
|
98
|
+
export interface ToolPolicyRule {
|
|
99
|
+
pattern: string
|
|
100
|
+
verdict: Verdict
|
|
101
|
+
reason: string
|
|
102
|
+
/** The guard's machine-readable rule label for a decision this rule makes. */
|
|
103
|
+
label?: string
|
|
104
|
+
predicate?: ToolPolicyPredicate
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The `defaults` block of a policy file. `no_match_verdict` is documented for a
|
|
109
|
+
* human reader and may only ever be `"ask"`: the no-match default is a property
|
|
110
|
+
* of the engine, not a knob, because a configurable one would let a policy file
|
|
111
|
+
* silently allow (or deny) every unlisted tool.
|
|
112
|
+
*/
|
|
113
|
+
export interface ToolPolicyFileDefaults {
|
|
114
|
+
no_match_verdict?: 'ask'
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface ToolPolicy {
|
|
118
|
+
version: number
|
|
119
|
+
description?: string
|
|
120
|
+
rules: ToolPolicyRule[]
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Where the shipped default policy lives, relative to the worktree root. */
|
|
124
|
+
export const TOOL_POLICY_RELATIVE_PATH = '.recursive/config/recursive-permissions.json'
|
|
125
|
+
|
|
126
|
+
/** Absolute path of the policy file for a worktree. */
|
|
127
|
+
export function toolPolicyPath(worktreeRoot: string): string {
|
|
128
|
+
return join(worktreeRoot, ...TOOL_POLICY_RELATIVE_PATH.split('/'))
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/* -------------------------------------------------------------------------- */
|
|
132
|
+
/* Glob matching */
|
|
133
|
+
/* -------------------------------------------------------------------------- */
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Pattern grammar, deliberately tiny so a human can predict a verdict:
|
|
137
|
+
* `*` any run of characters, INCLUDING the empty run (catch-all)
|
|
138
|
+
* `<literal>` the exact tool id
|
|
139
|
+
* Combining them gives the documented id-glob form `worker::*` (every id that
|
|
140
|
+
* starts with the literal `worker::`) and `recursive_*`.
|
|
141
|
+
*
|
|
142
|
+
* A literal character must be one a tool id may contain: letters, digits, `_`,
|
|
143
|
+
* `-`, `.` and `:`. Anything else — an empty/blank pattern, a space, a path
|
|
144
|
+
* separator, a regex metacharacter such as `[` or `?` — is INVALID, and an
|
|
145
|
+
* invalid pattern fails the whole policy closed. That strictness is the point:
|
|
146
|
+
* a pattern a human believes matches something it does not is a silent hole.
|
|
147
|
+
*/
|
|
148
|
+
const LITERAL = /^[A-Za-z0-9_.:-]$/
|
|
149
|
+
|
|
150
|
+
/** Collapse runs of `*`, so bare `**` is the same catch-all as `*`. */
|
|
151
|
+
function normalizePattern(pattern: string): string {
|
|
152
|
+
return pattern.replace(/\*+/g, '*')
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The syntax error in `pattern`, or null when it is well formed.
|
|
157
|
+
*
|
|
158
|
+
* Beyond the character rule there are two STRUCTURAL rules, because a pattern
|
|
159
|
+
* that a human reads as "the worker tools" must not be a silent no-op:
|
|
160
|
+
* - a pattern may not END with a separator (`worker::`, `a.`, `a:`) — a
|
|
161
|
+
* dangling separator names nothing, so it would sit in the file looking like
|
|
162
|
+
* a rule while matching no tool id at all;
|
|
163
|
+
* - a pattern may not START with one (`.foo`), for the same reason.
|
|
164
|
+
* `worker::*` is unaffected: it ends with the wildcard, which is how the id-glob
|
|
165
|
+
* form is written.
|
|
166
|
+
*/
|
|
167
|
+
export function patternSyntaxError(pattern: unknown): string | null {
|
|
168
|
+
if (typeof pattern !== 'string') return 'pattern must be a string, got ' + (pattern === null ? 'null' : typeof pattern)
|
|
169
|
+
if (pattern.length === 0) return 'pattern is empty'
|
|
170
|
+
if (pattern.trim().length === 0) return 'pattern is blank'
|
|
171
|
+
for (const ch of pattern) {
|
|
172
|
+
if (ch === '*') continue
|
|
173
|
+
if (!LITERAL.test(ch)) return 'pattern contains the unsupported character ' + JSON.stringify(ch)
|
|
174
|
+
}
|
|
175
|
+
if (/[.:]$/.test(pattern)) return 'pattern ends with a separator, which names nothing (write "worker::*", not "worker::")'
|
|
176
|
+
if (/^[.:]/.test(pattern)) return 'pattern starts with a separator, which names nothing'
|
|
177
|
+
return null
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function isValidGlobPattern(pattern: unknown): boolean {
|
|
181
|
+
return patternSyntaxError(pattern) === null
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Glob-match a tool id. Every `*` matches strictly up to the next literal, so
|
|
186
|
+
* the matcher is anchored at both ends: `worker::*` does NOT match `xworker::y`.
|
|
187
|
+
* Single-restart backtracking, no recursion.
|
|
188
|
+
*/
|
|
189
|
+
export function globMatch(pattern: string, id: string): boolean {
|
|
190
|
+
const p = normalizePattern(pattern)
|
|
191
|
+
const n = p.length
|
|
192
|
+
let pi = 0
|
|
193
|
+
let si = 0
|
|
194
|
+
let star = -1
|
|
195
|
+
let mark = 0
|
|
196
|
+
while (si < id.length) {
|
|
197
|
+
if (pi < n && p[pi] !== '*' && p[pi] === id[si]) {
|
|
198
|
+
pi++
|
|
199
|
+
si++
|
|
200
|
+
continue
|
|
201
|
+
}
|
|
202
|
+
if (pi < n && p[pi] === '*') {
|
|
203
|
+
star = pi
|
|
204
|
+
mark = si
|
|
205
|
+
pi++
|
|
206
|
+
continue
|
|
207
|
+
}
|
|
208
|
+
if (star >= 0) {
|
|
209
|
+
// Give the star one more character and retry from just after it.
|
|
210
|
+
pi = star + 1
|
|
211
|
+
mark++
|
|
212
|
+
si = mark
|
|
213
|
+
continue
|
|
214
|
+
}
|
|
215
|
+
return false
|
|
216
|
+
}
|
|
217
|
+
while (pi < n && p[pi] === '*') pi++
|
|
218
|
+
return pi === n
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/* -------------------------------------------------------------------------- */
|
|
222
|
+
/* Evaluation */
|
|
223
|
+
/* -------------------------------------------------------------------------- */
|
|
224
|
+
|
|
225
|
+
/** The first syntax/contract problem in a policy, in rule order. */
|
|
226
|
+
export function firstPolicyDefect(policy: ToolPolicy): string | null {
|
|
227
|
+
if (!policy || typeof policy !== 'object') return 'policy must be a JSON object'
|
|
228
|
+
if (!Array.isArray(policy.rules)) return 'policy.rules must be an array'
|
|
229
|
+
for (const rule of policy.rules as unknown[]) {
|
|
230
|
+
if (!rule || typeof rule !== 'object') return 'policy.rules contains a non-object entry'
|
|
231
|
+
const r = rule as Record<string, unknown>
|
|
232
|
+
const pattern = typeof r.pattern === 'string' ? r.pattern : JSON.stringify(r.pattern)
|
|
233
|
+
const unknownKeys = Object.keys(r).filter((k) => !['pattern', 'verdict', 'reason', 'label', 'predicate'].includes(k))
|
|
234
|
+
if (unknownKeys.length > 0) return 'rule ' + pattern + ' has unknown key(s) ' + unknownKeys.join(', ')
|
|
235
|
+
const syntax = patternSyntaxError(r.pattern)
|
|
236
|
+
if (syntax) return 'invalid pattern ' + pattern + ': ' + syntax
|
|
237
|
+
if (r.verdict !== 'allow' && r.verdict !== 'deny' && r.verdict !== 'ask') {
|
|
238
|
+
return 'rule ' + pattern + ' has an invalid verdict ' + JSON.stringify(r.verdict) + ' - expected allow | deny | ask'
|
|
239
|
+
}
|
|
240
|
+
if (typeof r.reason !== 'string' || r.reason.trim() === '') {
|
|
241
|
+
return 'rule ' + pattern + ' has no reason - a verdict with no stated reason is not auditable'
|
|
242
|
+
}
|
|
243
|
+
if (r.predicate !== undefined && typeof r.predicate !== 'function') {
|
|
244
|
+
return 'rule ' + pattern + ' has a non-function predicate'
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return null
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** The fail-closed decision: everything is denied, and the defect is named. */
|
|
251
|
+
export function failClosedDecision(defect: string): Decision {
|
|
252
|
+
return { kind: 'deny', reason: 'tool policy failed closed - ' + defect }
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** True when the pattern is the bare catch-all (`*`), which is least specific. */
|
|
256
|
+
export function isCatchAllPattern(pattern: unknown): boolean {
|
|
257
|
+
return pattern === '*'
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Evaluation PRECEDENCE: SPECIFICITY FIRST, THEN FILE ORDER.
|
|
262
|
+
*
|
|
263
|
+
* T16 asks for "deny wins over allow" AND for "first-match-wins", which cannot
|
|
264
|
+
* both hold under adversarial ordering. The reconciliation — and the semantics
|
|
265
|
+
* this engine implements — is:
|
|
266
|
+
*
|
|
267
|
+
* 1. Among the rules whose pattern MATCHES the call, a rule with a MORE
|
|
268
|
+
* SPECIFIC pattern outranks one with a less specific pattern. So a
|
|
269
|
+
* catch-all `*` allow can never shield an id from a specific `deny` written
|
|
270
|
+
* BELOW it.
|
|
271
|
+
* 2. Among equally specific matching rules, the EARLIER rule in the file wins.
|
|
272
|
+
* 3. A rule whose predicate ABSTAINS (`null`) does not participate at all: it
|
|
273
|
+
* is as if the rule were not in the file for this call, so the next rule in
|
|
274
|
+
* precedence order decides.
|
|
275
|
+
*
|
|
276
|
+
* Why not plain first-match-wins: it would make "deny wins over allow" merely a
|
|
277
|
+
* property of how the file happens to be ordered. A hand-edit that put a
|
|
278
|
+
* catch-all allow at the top would then silently disable every deny in the file,
|
|
279
|
+
* which is precisely the failure the phrase exists to prevent.
|
|
280
|
+
*
|
|
281
|
+
* Specificity tiers, most to least:
|
|
282
|
+
* 3 an EXACT id (`write`) — the narrowest thing a pattern can name;
|
|
283
|
+
* 2 a PREFIX glob (`worker::*`, `recursive_*`) — still names a family;
|
|
284
|
+
* 1 a glob with a wildcard in the middle (`*_write`) — matched anywhere in
|
|
285
|
+
* the id, so it is narrower than the bare catch-all but names no family;
|
|
286
|
+
* 0 the bare catch-all (`*`).
|
|
287
|
+
* These are TIERS, not a fine-grained metric: patterns within a tier are
|
|
288
|
+
* "equally specific" and are decided by file order.
|
|
289
|
+
*/
|
|
290
|
+
export function patternSpecificity(pattern: string): number {
|
|
291
|
+
const p = normalizePattern(pattern)
|
|
292
|
+
if (p === '*') return 0
|
|
293
|
+
if (!p.includes('*')) return 3
|
|
294
|
+
if (p.endsWith('*')) return 2
|
|
295
|
+
return 1
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Evaluate a tool call against a policy. PURE: no filesystem, no config, no
|
|
300
|
+
* caching. The loader resolves WHICH policy applies; this decides what it says,
|
|
301
|
+
* using the precedence above.
|
|
302
|
+
*/
|
|
303
|
+
export function evaluateToolPolicy(
|
|
304
|
+
policy: ToolPolicy,
|
|
305
|
+
id: string,
|
|
306
|
+
args: Record<string, unknown> = {},
|
|
307
|
+
ctx?: ToolPolicyContext,
|
|
308
|
+
): Decision {
|
|
309
|
+
const context: ToolPolicyContext = ctx ?? { args }
|
|
310
|
+
const defect = firstPolicyDefect(policy)
|
|
311
|
+
if (defect) return failClosedDecision(defect)
|
|
312
|
+
|
|
313
|
+
// Specificity first, file order as the tie-break (a stable sort keeps the
|
|
314
|
+
// policy's own order within a tier).
|
|
315
|
+
const matching = policy.rules
|
|
316
|
+
.map((rule, index) => ({ rule, index }))
|
|
317
|
+
.filter(({ rule }) => globMatch(rule.pattern, id))
|
|
318
|
+
.sort((a, b) => {
|
|
319
|
+
const bySpecificity = patternSpecificity(b.rule.pattern) - patternSpecificity(a.rule.pattern)
|
|
320
|
+
return bySpecificity !== 0 ? bySpecificity : a.index - b.index
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
for (const { rule } of matching) {
|
|
324
|
+
if (rule.predicate) {
|
|
325
|
+
const match = rule.predicate(id, args, context)
|
|
326
|
+
// Abstention: this rule does not govern this call, so it does not
|
|
327
|
+
// participate and the next rule in precedence order decides.
|
|
328
|
+
if (match === null) continue
|
|
329
|
+
return decide(rule, match.verdict, match.detail ? rule.reason + ' ' + match.detail : rule.reason)
|
|
330
|
+
}
|
|
331
|
+
return decide(rule, rule.verdict, rule.reason)
|
|
332
|
+
}
|
|
333
|
+
// NO MATCH -> ask. Not deny (which would brick an unlisted tool), not allow
|
|
334
|
+
// (which would be a silent permit).
|
|
335
|
+
return { kind: 'ask', reason: 'no policy rule matches ' + id + ' - ask is the no-match default' }
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** One place where a rule's verdict becomes a decision, so `label` cannot drift. */
|
|
339
|
+
function decide(rule: ToolPolicyRule, kind: Verdict, reason: string): Decision {
|
|
340
|
+
const decision: Decision = { kind, reason }
|
|
341
|
+
if (rule.label) decision.rule = rule.label
|
|
342
|
+
return decision
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/* -------------------------------------------------------------------------- */
|
|
346
|
+
/* Loading (absent -> built-in, present-but-broken -> fail closed) */
|
|
347
|
+
/* -------------------------------------------------------------------------- */
|
|
348
|
+
|
|
349
|
+
export interface ToolPolicyLoadResult {
|
|
350
|
+
ok: boolean
|
|
351
|
+
/** `file` when a policy file was found, `builtin` when it was ABSENT. */
|
|
352
|
+
source: 'file' | 'builtin'
|
|
353
|
+
policy: ToolPolicy
|
|
354
|
+
/** Absolute path of the policy file, when one was found. */
|
|
355
|
+
path?: string
|
|
356
|
+
/** Why the file could not be used, when `ok` is false. */
|
|
357
|
+
error?: string
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* The ABSENT-file fallback policy, and the slot it lives in.
|
|
362
|
+
*
|
|
363
|
+
* `src/policy-builtin.ts` used to hold the list and call `setBuiltInToolPolicy`
|
|
364
|
+
* at its own top level. That was an import cycle — the list needs this module's
|
|
365
|
+
* predicate contract, this module imported the list — and a cycle means whichever
|
|
366
|
+
* entry loads first observes a partially initialised namespace, which is how a
|
|
367
|
+
* top-level wiring call came to throw `setBuiltInToolPolicy is not a function`.
|
|
368
|
+
*
|
|
369
|
+
* So: the list is defined BELOW, in this module, and wired by the
|
|
370
|
+
* `setBuiltInToolPolicy(builtInToolPolicyDefault)` call at its end. A consumer
|
|
371
|
+
* that imports only `policy-globs.ts` — the loader's own unit tests, for one —
|
|
372
|
+
* therefore gets the safe default without having to remember to install it, and
|
|
373
|
+
* no wiring step can be forgotten.
|
|
374
|
+
*
|
|
375
|
+
* `setBuiltInToolPolicy` stays exported for a caller that genuinely wants a
|
|
376
|
+
* different fallback. The initial value below is unreachable once this module has
|
|
377
|
+
* finished evaluating; it exists so that a hypothetical unwired state denies
|
|
378
|
+
* rather than silently allows, and names the missing wire instead of being
|
|
379
|
+
* mysterious.
|
|
380
|
+
*/
|
|
381
|
+
let builtInPolicy: () => ToolPolicy = () =>
|
|
382
|
+
failClosedPolicy('no built-in tool policy is wired (setBuiltInToolPolicy was never called)')
|
|
383
|
+
|
|
384
|
+
/** Wire the built-in default policy used when no policy file exists. */
|
|
385
|
+
export function setBuiltInToolPolicy(factory: () => ToolPolicy): void {
|
|
386
|
+
builtInPolicy = factory
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** The built-in policy in force for the absent-file case. */
|
|
390
|
+
export function builtInToolPolicy(): ToolPolicy {
|
|
391
|
+
return builtInPolicy()
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/* -------------------------------------------------------------------------- */
|
|
395
|
+
/* Built-in default rules — the ABSENT-file fallback */
|
|
396
|
+
/* -------------------------------------------------------------------------- */
|
|
397
|
+
|
|
398
|
+
/** Tool names the locked-artifact write rule guards. */
|
|
399
|
+
export const WRITE_TOOL_NAMES = new Set(['write', 'edit', 'fs_write', 'fs-write', 'pwsh', 'shell', 'bash', 'run_code'])
|
|
400
|
+
|
|
401
|
+
/** Tool names the monotonic lock-order rule guards. */
|
|
402
|
+
export const LOCK_TOOL_NAMES = new Set(['recursive_lock', 'recursive_lock_phase'])
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Monotonic lock-order rule: a denial when an earlier phase is not locked,
|
|
406
|
+
* `null` when nothing blocks the lock. The rule carries the static, auditable
|
|
407
|
+
* sentence; the predicate adds the blocking artifact and its status, which is
|
|
408
|
+
* what distinguishes a guard refusal from `lockArtifact`'s own
|
|
409
|
+
* `Prerequisite blockers:` error (`tests/guard-path.spec.ts` asserts both).
|
|
410
|
+
*/
|
|
411
|
+
function lockOrderRule(artifact: unknown, runDir: string | undefined): ToolPolicyPredicateMatch | null {
|
|
412
|
+
const name = String(artifact ?? '')
|
|
413
|
+
if (!name || !runDir) return null
|
|
414
|
+
const blockers = getPrerequisiteBlockers(runDir, name)
|
|
415
|
+
if (blockers.length === 0) return null
|
|
416
|
+
return { verdict: 'deny', detail: blockers.map((b) => b.artifact + ' (' + b.status + ')').join(', ') }
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Locked-artifact write rule: a denial when the target carries
|
|
421
|
+
* `Status: LOCKED`, `null` otherwise. Only a run-tree `*.md` is a candidate —
|
|
422
|
+
* the same admission test the pre-T16 branch used.
|
|
423
|
+
*/
|
|
424
|
+
function lockedWriteRule(target: string | null, worktreeRoot: string | undefined): ToolPolicyPredicateMatch | null {
|
|
425
|
+
if (!target || !worktreeRoot) return null
|
|
426
|
+
const normalized = target.replace(/\\/g, '/')
|
|
427
|
+
if (!normalized.endsWith('.md') || !normalized.includes('/.recursive/run/')) return null
|
|
428
|
+
const abs = resolveFrom(worktreeRoot, normalized)
|
|
429
|
+
if (!abs || getLockStatus(abs) !== 'LOCKED') return null
|
|
430
|
+
return { verdict: 'deny', detail: normalized + ' carries Status: LOCKED (reopen explicitly to edit)' }
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* The BUILT-IN default rule list — the pre-T16 guard behaviour expressed as
|
|
435
|
+
* data:
|
|
436
|
+
*
|
|
437
|
+
* `recursive_lock*` -> the monotonic lock-order denial;
|
|
438
|
+
* the write-tool ids -> the locked-artifact write denial;
|
|
439
|
+
* `*` -> allow, so an ordinary tool is not turned into an `ask`.
|
|
440
|
+
*
|
|
441
|
+
* Every `deny` precedes the `allow`, which is what makes "deny wins over allow" a
|
|
442
|
+
* fact about the list rather than a hope. The predicates return `null` when
|
|
443
|
+
* their condition does not apply, so a matched-but-clean write falls through to
|
|
444
|
+
* the allow instead of being denied by the pattern alone.
|
|
445
|
+
*
|
|
446
|
+
* `.recursive/config/recursive-permissions.json` mirrors this list in the same
|
|
447
|
+
* order with the same reasons: a reviewer reads either one and predicts the same
|
|
448
|
+
* verdict.
|
|
449
|
+
*/
|
|
450
|
+
export function builtInToolPolicyRules(): ToolPolicyRule[] {
|
|
451
|
+
const rules: ToolPolicyRule[] = [
|
|
452
|
+
{
|
|
453
|
+
pattern: 'recursive_lock*',
|
|
454
|
+
verdict: 'deny',
|
|
455
|
+
reason: 'monotonic lock-order: an earlier phase must be locked first',
|
|
456
|
+
label: 'lock-order',
|
|
457
|
+
predicate: (id, args, ctx) => (LOCK_TOOL_NAMES.has(id) ? lockOrderRule(args.artifact, ctx.runDir) : null),
|
|
458
|
+
},
|
|
459
|
+
]
|
|
460
|
+
for (const name of WRITE_TOOL_NAMES) {
|
|
461
|
+
rules.push({
|
|
462
|
+
pattern: name,
|
|
463
|
+
verdict: 'deny',
|
|
464
|
+
reason: 'locked-artifact write denial: the target carries Status: LOCKED',
|
|
465
|
+
label: 'locked-write',
|
|
466
|
+
predicate: (id, args, ctx) => (WRITE_TOOL_NAMES.has(id) ? lockedWriteRule(policyTargetPath(args), ctx.worktreeRoot) : null),
|
|
467
|
+
})
|
|
468
|
+
}
|
|
469
|
+
rules.push({
|
|
470
|
+
pattern: '*',
|
|
471
|
+
verdict: 'allow',
|
|
472
|
+
reason: 'no deny rule matches this tool',
|
|
473
|
+
})
|
|
474
|
+
return rules
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** The built-in default policy (the absent-file fallback), as a policy object. */
|
|
478
|
+
export function builtInToolPolicyDefault(): ToolPolicy {
|
|
479
|
+
return { version: 1, description: 'built-in default tool policy (no policy file present)', rules: builtInToolPolicyRules() }
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Attach the CODE-side condition to a rule that was authored in JSON.
|
|
484
|
+
*
|
|
485
|
+
* This is the seam that makes a policy file honest. JSON cannot carry a
|
|
486
|
+
* function, so a `deny` rule read from a file has no way to know whether its
|
|
487
|
+
* condition actually holds — and an unguarded `deny` is an UNCONDITIONAL denial,
|
|
488
|
+
* i.e. a policy file that denies locks whose prerequisites are met and writes to
|
|
489
|
+
* artifacts that are not locked. The pattern identifies the rule's subject, so
|
|
490
|
+
* the pattern is what selects the condition:
|
|
491
|
+
*
|
|
492
|
+
* `recursive_lock*` -> the monotonic lock-order condition;
|
|
493
|
+
* any write-tool id -> the locked-artifact condition.
|
|
494
|
+
*
|
|
495
|
+
* A pattern the code knows nothing about keeps NO condition and is therefore an
|
|
496
|
+
* absolute verdict — which is the honest reading of a rule like
|
|
497
|
+
* `{"pattern": "run_code", "verdict": "deny"}` with no lock condition behind it.
|
|
498
|
+
*/
|
|
499
|
+
export function attachPolicyPredicate(rule: ToolPolicyRule): ToolPolicyRule {
|
|
500
|
+
if (rule.predicate) return rule
|
|
501
|
+
if (rule.pattern === 'recursive_lock*') {
|
|
502
|
+
return { ...rule, predicate: (id, args, ctx) => (LOCK_TOOL_NAMES.has(id) ? lockOrderRule(args.artifact, ctx.runDir) : null) }
|
|
503
|
+
}
|
|
504
|
+
if (WRITE_TOOL_NAMES.has(rule.pattern)) {
|
|
505
|
+
return { ...rule, predicate: (id, args, ctx) => (WRITE_TOOL_NAMES.has(id) ? lockedWriteRule(policyTargetPath(args), ctx.worktreeRoot) : null) }
|
|
506
|
+
}
|
|
507
|
+
return rule
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* TDD evidence predicate for a phase-3 lock (the `tdd-evidence` guard rule): the
|
|
512
|
+
* denial match when the artifact declares `TDD Mode: strict` without both RED
|
|
513
|
+
* and GREEN evidence, `null` otherwise — nothing to say, so the global lock rules
|
|
514
|
+
* still apply. `phase-rules.ts` uses it for the phase-3 baseline.
|
|
515
|
+
*/
|
|
516
|
+
export function tddEvidenceVerdict(artifact: string, runDir: string | undefined): ToolPolicyPredicateMatch | null {
|
|
517
|
+
if (!runDir || artifact !== '03-implementation-summary.md') return null
|
|
518
|
+
const artifactPath = join(runDir, artifact)
|
|
519
|
+
if (!existsSync(artifactPath)) return null
|
|
520
|
+
const content = readFileSync(artifactPath, 'utf8')
|
|
521
|
+
const tddMode = getMdFieldValue(content, 'TDD Mode') ?? ''
|
|
522
|
+
if (tddMode !== 'strict') return null
|
|
523
|
+
const hasRed = /RED|red evidence/i.test(content)
|
|
524
|
+
const hasGreen = /GREEN|green evidence/i.test(content)
|
|
525
|
+
if (hasRed && hasGreen) return null
|
|
526
|
+
return { verdict: 'deny' }
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// At DEFINITION time, not as a separate wiring step: any module that imports
|
|
530
|
+
// this file gets the absent-file safe default.
|
|
531
|
+
setBuiltInToolPolicy(builtInToolPolicyDefault)
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Read and validate the policy file. NEVER throws: an unreadable or malformed
|
|
535
|
+
* file yields `ok: false` plus a fail-closed policy, so the guard reasons about
|
|
536
|
+
* it instead of crashing the tools/pre-execute listener.
|
|
537
|
+
*/
|
|
538
|
+
export function loadToolPolicyFile(worktreeRoot: string): ToolPolicyLoadResult {
|
|
539
|
+
const path = toolPolicyPath(worktreeRoot)
|
|
540
|
+
if (!existsSync(path)) {
|
|
541
|
+
// ABSENT -> the built-in list that reproduces pre-T16 behaviour. Do NOT make
|
|
542
|
+
// this the fail-closed branch: every repo without a policy file would then
|
|
543
|
+
// deny every tool call.
|
|
544
|
+
return { ok: true, source: 'builtin', policy: builtInToolPolicy() }
|
|
545
|
+
}
|
|
546
|
+
let raw: string
|
|
547
|
+
try {
|
|
548
|
+
raw = readFileSync(path, 'utf8')
|
|
549
|
+
} catch (error) {
|
|
550
|
+
const defect = 'policy file is unreadable: ' + messageOf(error)
|
|
551
|
+
return { ok: false, source: 'file', path, policy: failClosedPolicy(defect), error: defect }
|
|
552
|
+
}
|
|
553
|
+
// A PRESENT file is authoritative even when broken: a human wrote it, so a
|
|
554
|
+
// defect must fail closed loudly rather than silently fall back to built-ins.
|
|
555
|
+
const parsed = parseToolPolicy(raw, path)
|
|
556
|
+
return { ok: parsed.ok, source: 'file', path, policy: parsed.policy, error: parsed.error }
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Parse policy JSON into a policy. A parse/validation failure does NOT throw and
|
|
561
|
+
* does NOT return an empty rule list (which would mean "ask everything" and hide
|
|
562
|
+
* the defect) — it returns a single deny-all rule carrying the defect, plus
|
|
563
|
+
* `ok: false` and the same defect as `error`, so a caller can tell a healthy
|
|
564
|
+
* policy from a fail-closed one WITHOUT having to compare reasons. Both the
|
|
565
|
+
* malformed-JSON case and the PRESENT-but-empty case are `ok: false`.
|
|
566
|
+
*/
|
|
567
|
+
export function parseToolPolicy(raw: string, path?: string): ToolPolicyParseResult {
|
|
568
|
+
let parsed: unknown
|
|
569
|
+
try {
|
|
570
|
+
parsed = JSON.parse(raw)
|
|
571
|
+
} catch (error) {
|
|
572
|
+
return parseFailure('policy file is not valid JSON: ' + messageOf(error))
|
|
573
|
+
}
|
|
574
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
575
|
+
return parseFailure('policy must be a JSON object with a rules array')
|
|
576
|
+
}
|
|
577
|
+
const record = parsed as Record<string, unknown>
|
|
578
|
+
const unknown = Object.keys(record).filter((k) => !['version', 'description', 'defaults', 'rules'].includes(k))
|
|
579
|
+
if (unknown.length > 0) return parseFailure('policy has unknown key(s) ' + unknown.join(', '))
|
|
580
|
+
if (record.version !== undefined && typeof record.version !== 'number') {
|
|
581
|
+
return parseFailure('policy.version must be a number')
|
|
582
|
+
}
|
|
583
|
+
const defaults = record.defaults as Record<string, unknown> | undefined
|
|
584
|
+
if (defaults !== undefined) {
|
|
585
|
+
if (!defaults || typeof defaults !== 'object' || Array.isArray(defaults)) {
|
|
586
|
+
return parseFailure('policy.defaults must be an object')
|
|
587
|
+
}
|
|
588
|
+
const unknownDefaults = Object.keys(defaults).filter((k) => k !== 'no_match_verdict')
|
|
589
|
+
if (unknownDefaults.length > 0) return parseFailure('policy.defaults has unknown key(s) ' + unknownDefaults.join(', '))
|
|
590
|
+
if (defaults.no_match_verdict !== undefined && defaults.no_match_verdict !== 'ask') {
|
|
591
|
+
return parseFailure('policy.defaults.no_match_verdict must be "ask" - the no-match verdict is not configurable')
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
if (!Array.isArray(record.rules)) return parseFailure('policy.rules must be an array')
|
|
595
|
+
const rules = record.rules as ToolPolicyRule[]
|
|
596
|
+
if (rules.length === 0) {
|
|
597
|
+
// PRESENT but EMPTY -> fail closed. Not "ask everything", not "allow all".
|
|
598
|
+
const where = path ?? TOOL_POLICY_RELATIVE_PATH
|
|
599
|
+
return parseFailure('policy at ' + where + ' is PRESENT but declares no rules - every tool call is denied (an empty policy is not an allow-all policy)')
|
|
600
|
+
}
|
|
601
|
+
const policy: ToolPolicy = { version: typeof record.version === 'number' ? record.version : 1, rules: rules.map(attachPolicyPredicate) }
|
|
602
|
+
if (typeof record.description === 'string') policy.description = record.description
|
|
603
|
+
const defect = firstPolicyDefect(policy)
|
|
604
|
+
if (defect) return parseFailure(defect)
|
|
605
|
+
return { ok: true, policy }
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
export interface ToolPolicyParseResult {
|
|
609
|
+
ok: boolean
|
|
610
|
+
policy: ToolPolicy
|
|
611
|
+
/** The defect the policy failed closed on, when `ok` is false. */
|
|
612
|
+
error?: string
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/** A parse result that fails closed, carrying the defect as both error and reason. */
|
|
616
|
+
function parseFailure(defect: string): ToolPolicyParseResult {
|
|
617
|
+
return { ok: false, policy: failClosedPolicy(defect), error: defect }
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/** A one-rule policy denying everything, with the defect as its reason. */
|
|
621
|
+
export function failClosedPolicy(defect: string): ToolPolicy {
|
|
622
|
+
return { version: 1, rules: [{ pattern: '*', verdict: 'deny', reason: 'tool policy failed closed - ' + defect }] }
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function messageOf(error: unknown): string {
|
|
626
|
+
return error instanceof Error ? error.message : String(error)
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* Resolve the effective policy for a worktree: the file when present (even when
|
|
631
|
+
* broken — a present-but-invalid file fails closed), the built-in list when
|
|
632
|
+
* absent. Thin, so callers never re-implement the absent/present distinction.
|
|
633
|
+
*/
|
|
634
|
+
export function resolveToolPolicy(worktreeRoot: string): ToolPolicy {
|
|
635
|
+
return loadToolPolicyFile(worktreeRoot).policy
|
|
636
|
+
}
|