@try-works/dsh-recursive-mode 0.4.8 → 0.5.0
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/lib/config.d.ts +14 -0
- package/lib/enforcement.d.ts +68 -2
- package/lib/guard-log.d.ts +7 -0
- package/lib/index.js +250 -37
- package/lib/policy-globs.d.ts +32 -0
- package/lib/recursive_ask.tool.d.ts +37 -0
- package/lib/runtime.d.ts +1 -1
- package/package.json +1 -1
- package/scripts/test-recursive-mode-smoke.ts +11 -1
- package/src/config.ts +11 -4
- package/src/enforcement.ts +135 -17
- package/src/guard-log.ts +7 -0
- package/src/index.ts +26 -3
- package/src/policy-globs.ts +160 -7
- package/src/policy.ts +1 -0
- package/src/recursive_ask.tool.ts +48 -0
- package/src/recursive_lock.tool.ts +8 -2
- package/src/runtime.ts +1 -1
package/lib/config.d.ts
CHANGED
|
@@ -48,6 +48,13 @@ export interface RecursiveModeConfig {
|
|
|
48
48
|
export declare const Config: z<Schemastery.ObjectS<NoInfer<{
|
|
49
49
|
shellOnly: z<boolean, boolean, "defined">;
|
|
50
50
|
repoRoot: z<string, string, "plain">;
|
|
51
|
+
/**
|
|
52
|
+
* ⚠ THE THREE MODE DEFAULTS READ `DEFAULT_ENFORCEMENT_MODE` FROM `enforcement.ts`, and
|
|
53
|
+
* that is deliberate: the schema default and the runtime default are the SAME value, and
|
|
54
|
+
* two literals here would be two defaults. A caller that omits the section gets
|
|
55
|
+
* `DEFAULT_ENFORCEMENT` from the runtime; a caller that supplies a partial section gets
|
|
56
|
+
* the resolver's fill. Both must be the enforcing posture — see the const for why.
|
|
57
|
+
*/
|
|
51
58
|
enforcement: z<Schemastery.ObjectS<NoInfer<{
|
|
52
59
|
preStep: z<"strict" | "advisory", "strict" | "advisory", "defined">;
|
|
53
60
|
toolGuards: z<"strict" | "advisory", "strict" | "advisory", "defined">;
|
|
@@ -125,6 +132,13 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
|
|
|
125
132
|
}>>, Schemastery.ObjectT<NoInfer<{
|
|
126
133
|
shellOnly: z<boolean, boolean, "defined">;
|
|
127
134
|
repoRoot: z<string, string, "plain">;
|
|
135
|
+
/**
|
|
136
|
+
* ⚠ THE THREE MODE DEFAULTS READ `DEFAULT_ENFORCEMENT_MODE` FROM `enforcement.ts`, and
|
|
137
|
+
* that is deliberate: the schema default and the runtime default are the SAME value, and
|
|
138
|
+
* two literals here would be two defaults. A caller that omits the section gets
|
|
139
|
+
* `DEFAULT_ENFORCEMENT` from the runtime; a caller that supplies a partial section gets
|
|
140
|
+
* the resolver's fill. Both must be the enforcing posture — see the const for why.
|
|
141
|
+
*/
|
|
128
142
|
enforcement: z<Schemastery.ObjectS<NoInfer<{
|
|
129
143
|
preStep: z<"strict" | "advisory", "strict" | "advisory", "defined">;
|
|
130
144
|
toolGuards: z<"strict" | "advisory", "strict" | "advisory", "defined">;
|
package/lib/enforcement.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { builtInToolPolicyDefault, type ToolPolicy } from './policy-globs.ts';
|
|
2
|
+
import { type GateBlockAsk } from './recursive_ask.tool.ts';
|
|
2
3
|
/**
|
|
3
4
|
* The BUILT-IN default rule list (T16) is defined in `src/policy-globs.ts`,
|
|
4
5
|
* beside the evaluator and the loader that use it as the ABSENT-file fallback,
|
|
@@ -40,6 +41,28 @@ export interface EnforcementConfig {
|
|
|
40
41
|
/** T28: the caps. Always present, so a caller never has to guess a default. */
|
|
41
42
|
budgets: BudgetConfig;
|
|
42
43
|
}
|
|
44
|
+
/**
|
|
45
|
+
* THE DEFAULT POSTURE: STRICT, on all three gates — and this const is the ONE literal.
|
|
46
|
+
*
|
|
47
|
+
* The owner's rule is *only one phase may be active at a time, and the phases should be
|
|
48
|
+
* sequential and the active phase must be locked before proceeding to next phase*. In
|
|
49
|
+
* `advisory` that rule is only WARNED about, and a live run showed what that costs: the
|
|
50
|
+
* run ignored the lock chain for over an hour, wrote phase 8 before phase 1.5 and locked
|
|
51
|
+
* nothing (twelve DRAFT artifacts, one operations entry). Strict was previously unsafe as
|
|
52
|
+
* a default because it also refused the run's OWN artifacts — a false positive. That was
|
|
53
|
+
* fixed, and `tests/strict-run-tree.spec.ts` now walks all twelve phases asserting the
|
|
54
|
+
* active artifact stays writable while a later one is refused. Strict therefore refuses
|
|
55
|
+
* exactly the ordering violations it is meant to refuse, so the default is the enforcing
|
|
56
|
+
* posture rather than a warning nobody has to act on.
|
|
57
|
+
*
|
|
58
|
+
* ⚠ WHY IT IS A NAMED CONST AND NOT THREE LITERALS. A default restated per site is this
|
|
59
|
+
* project's recurring failure: the same value exists in the Config schema, in
|
|
60
|
+
* `DEFAULT_ENFORCEMENT`, in an omitted config section, and in the parameter defaults of
|
|
61
|
+
* the helpers below, and moving only some of them leaves a caller that "still gets
|
|
62
|
+
* advisory". Every one of those sites now reads THIS const, so a revert is a one-line
|
|
63
|
+
* change and nothing can drift from it.
|
|
64
|
+
*/
|
|
65
|
+
export declare const DEFAULT_ENFORCEMENT_MODE: EnforcementMode;
|
|
43
66
|
/**
|
|
44
67
|
* Validate the enforcement config shape (unknown keys fail at plugin load).
|
|
45
68
|
*
|
|
@@ -49,14 +72,26 @@ export interface EnforcementConfig {
|
|
|
49
72
|
* A config error is loud at load, not mysterious later.
|
|
50
73
|
*/
|
|
51
74
|
export declare function resolveEnforcementConfig(config: unknown): EnforcementConfig;
|
|
75
|
+
/**
|
|
76
|
+
* The runtime default: what a caller gets when it supplies no `enforcement` section at all
|
|
77
|
+
* (a profile mounting this plugin with no config, e.g. `preset/recursive.patch.yml`). It is
|
|
78
|
+
* `DEFAULT_ENFORCEMENT_MODE` per gate, so this object and the resolver cannot disagree —
|
|
79
|
+
* see that const for WHY the default is strict.
|
|
80
|
+
*/
|
|
52
81
|
export declare const DEFAULT_ENFORCEMENT: EnforcementConfig;
|
|
53
82
|
/**
|
|
54
83
|
* T15: the rule that produced a guard decision (a machine-readable reason for
|
|
55
84
|
* every allow/deny/ask the guard hands back). `'none'` means no guard predicate
|
|
56
85
|
* fired; `'transition'` marks a decision whose only dissenting gate was the
|
|
57
86
|
* advisory transition gate (see consultTransitionGate).
|
|
87
|
+
*
|
|
88
|
+
* `'phase-order'` is the WRITE half of the ordering rule the owner states as *only
|
|
89
|
+
* one phase may be active at a time, and the phases should be sequential and the
|
|
90
|
+
* active phase must be locked before proceeding to next phase*: `'lock-order'` refuses
|
|
91
|
+
* locking ahead, `'phase-order'` refuses WRITING ahead. Two labels rather than one,
|
|
92
|
+
* because the guard log has to tell the owner which of the two the agent attempted.
|
|
58
93
|
*/
|
|
59
|
-
export type GuardRule = 'lock-order' | 'tdd-evidence' | 'locked-write' | 'transition' | 'none';
|
|
94
|
+
export type GuardRule = 'lock-order' | 'phase-order' | 'tdd-evidence' | 'locked-write' | 'transition' | 'none';
|
|
60
95
|
/** T15: the transition gate's verdict as attached to a decision (advisory only). */
|
|
61
96
|
export interface GuardTransition {
|
|
62
97
|
passed: boolean;
|
|
@@ -71,6 +106,12 @@ export interface GuardTransition {
|
|
|
71
106
|
* optional — `coerceAskToDecision` is asserted with `toEqual({ kind: ... })`
|
|
72
107
|
* (an EXACT match) in tests/enforcement.spec.ts, so the coercion path may never
|
|
73
108
|
* grow extra keys. `evaluateToolGuard` itself always sets `rule`.
|
|
109
|
+
*
|
|
110
|
+
* ⚠ FU-7: `ask` IS OPTIONAL AND ADDITIVE TOO, for the same reason and one more. A refusal that a
|
|
111
|
+
* PERSON has to resolve carries the gate-block decision alongside its sentence (see `verdictFor`),
|
|
112
|
+
* and the payload is built by `buildGateBlockAsk` — the SAME builder the lock tool uses, so the two
|
|
113
|
+
* refusals cannot offer different options. It is absent on every decision that is not a lock-order
|
|
114
|
+
* refusal decided from real blockers, which is why every reader must treat it as optional.
|
|
74
115
|
*/
|
|
75
116
|
export type ToolGuardDecision = {
|
|
76
117
|
kind: 'allow';
|
|
@@ -82,6 +123,7 @@ export type ToolGuardDecision = {
|
|
|
82
123
|
reason: string;
|
|
83
124
|
rule?: GuardRule;
|
|
84
125
|
transition?: GuardTransition;
|
|
126
|
+
ask?: GateBlockAsk;
|
|
85
127
|
} | {
|
|
86
128
|
kind: 'ask';
|
|
87
129
|
reason?: string;
|
|
@@ -108,7 +150,7 @@ export interface ToolExecLike {
|
|
|
108
150
|
* `recursive:policy` later — can read the effective rules instead of inferring
|
|
109
151
|
* them from a code path.
|
|
110
152
|
*/
|
|
111
|
-
export declare function resolveToolPolicyForGuard(worktreeRoot: string, runId: string): ToolPolicy;
|
|
153
|
+
export declare function resolveToolPolicyForGuard(worktreeRoot: string, runId: string, activePhaseArtifact?: string): ToolPolicy;
|
|
112
154
|
/**
|
|
113
155
|
* The artifact whose phase baseline applies: the HIGHEST-numbered phase artifact
|
|
114
156
|
* present in the run (a run at phase 3 has `00`-`03` on disk). Read from the
|
|
@@ -116,12 +158,36 @@ export declare function resolveToolPolicyForGuard(worktreeRoot: string, runId: s
|
|
|
116
158
|
* needs, because a cached phase would apply yesterday's baseline to today's lock.
|
|
117
159
|
*/
|
|
118
160
|
export declare function currentPhaseArtifact(worktreeRoot: string, runId: string): string;
|
|
161
|
+
/**
|
|
162
|
+
* `mode` is the gate's configured posture. Its parameter default FOLLOWS the config
|
|
163
|
+
* default by REFERENCE (`DEFAULT_ENFORCEMENT.toolGuards`) rather than repeating the
|
|
164
|
+
* literal: a bare call is "the caller had no mode to hand", and the answer to that must
|
|
165
|
+
* be the same posture the config would have produced. Two literals are two defaults, and
|
|
166
|
+
* a helper left on the old `advisory` literal while the config moved to `strict` is
|
|
167
|
+
* exactly the twin-default hole this change closes — a caller that forgot the argument
|
|
168
|
+
* would silently get the permissive branch, which no config could then undo. Every
|
|
169
|
+
* production call site passes the mode explicitly (`index.ts` `runToolGuard`,
|
|
170
|
+
* `runtime.ts` `guardTool`, the preview tool); this default serves bare callers, and a
|
|
171
|
+
* bare caller must not be the one place enforcement quietly turns itself off.
|
|
172
|
+
*/
|
|
119
173
|
export declare function evaluateToolGuard(exec: ToolExecLike, worktreeRoot: string, activeRunId: string, mode?: EnforcementMode): ToolGuardDecision;
|
|
120
174
|
/**
|
|
121
175
|
* T6 (approval ask→policy bridge): an `ask` decision must never be a silent
|
|
122
176
|
* allow. Under `strict` it coerces to `deny`; under `advisory` it stays `allow`
|
|
123
177
|
* but flags a `warn` so the caller never lets it through unlogged. Non-ask
|
|
124
178
|
* decisions pass through unchanged.
|
|
179
|
+
*
|
|
180
|
+
* ⚠ THE `mode` DEFAULT IS DELIBERATE, and it is NOT a neutral fallback — there is no
|
|
181
|
+
* neutral branch here. The domain is two postures, one of which ALLOWS the call, so
|
|
182
|
+
* "unspecified" has to be resolved rather than left open, and this codebase's rule for an
|
|
183
|
+
* undecidable path is to fail CLOSED (`index.ts`: *"we could not decide" is not
|
|
184
|
+
* permission*). It therefore FOLLOWS the config default by REFERENCE
|
|
185
|
+
* (`DEFAULT_ENFORCEMENT.toolGuards`), for the same reason as `evaluateToolGuard`'s: an
|
|
186
|
+
* `advisory` literal here would be a second, hidden copy of the old default inside the
|
|
187
|
+
* very module this change moves, and a future caller that omitted the argument would
|
|
188
|
+
* re-open the permissive path with no config able to close it. The production call site
|
|
189
|
+
* (`index.ts` `runToolGuard`) always passes the configured mode, so this changes no live
|
|
190
|
+
* behaviour — it removes the last place where "we were not told" meant "allow".
|
|
125
191
|
*/
|
|
126
192
|
export declare function coerceAskToDecision(decision: ToolGuardDecision, mode?: EnforcementMode): ToolGuardDecision;
|
|
127
193
|
/**
|
package/lib/guard-log.d.ts
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import type { GuardRule } from './enforcement.ts';
|
|
2
|
+
import type { GateBlockAsk } from './recursive_ask.tool.ts';
|
|
2
3
|
/**
|
|
3
4
|
* One logged guard decision (the JSONL record shape the board/tests read).
|
|
4
5
|
* `rule` is always set (the guard's own machine-readable reason for the
|
|
5
6
|
* verdict); `transition` is present whenever the transition gate was consulted.
|
|
7
|
+
*
|
|
8
|
+
* FU-7: a REFUSAL that a person has to resolve also carries `ask` — the gate-block decision, in the
|
|
9
|
+
* same shape `recursive_lock` attaches to its own refusal. It is recorded because the log is where
|
|
10
|
+
* "why was this lock refused?" is answered, and the options are the other half of that answer; a
|
|
11
|
+
* caller (or a board) reading the trace can act on the refusal without parsing the sentence.
|
|
6
12
|
*/
|
|
7
13
|
export interface GuardDecisionRecord {
|
|
8
14
|
at: string;
|
|
@@ -15,6 +21,7 @@ export interface GuardDecisionRecord {
|
|
|
15
21
|
passed: boolean;
|
|
16
22
|
failures: string[];
|
|
17
23
|
};
|
|
24
|
+
ask?: GateBlockAsk;
|
|
18
25
|
}
|
|
19
26
|
/** One logged observed-write tamper (a LOCKED artifact whose hash no longer matches). */
|
|
20
27
|
export interface ObservedTamperRecord {
|
package/lib/index.js
CHANGED
|
@@ -1409,7 +1409,7 @@ function evaluateToolPolicy(policy, id, args = {}, ctx) {
|
|
|
1409
1409
|
if (rule.predicate) {
|
|
1410
1410
|
const match = rule.predicate(id, args, context);
|
|
1411
1411
|
if (match === null) continue;
|
|
1412
|
-
return decide(rule, match.verdict, match.detail ? rule.reason + " " + match.detail : rule.reason);
|
|
1412
|
+
return decide(rule, match.verdict, match.detail ? rule.reason + " " + match.detail : rule.reason, match.blockers);
|
|
1413
1413
|
}
|
|
1414
1414
|
return decide(rule, rule.verdict, rule.reason);
|
|
1415
1415
|
}
|
|
@@ -1418,13 +1418,20 @@ function evaluateToolPolicy(policy, id, args = {}, ctx) {
|
|
|
1418
1418
|
reason: "no policy rule matches " + id + " - ask is the no-match default"
|
|
1419
1419
|
};
|
|
1420
1420
|
}
|
|
1421
|
-
/**
|
|
1422
|
-
|
|
1421
|
+
/**
|
|
1422
|
+
* One place where a rule's verdict becomes a decision, so `label` cannot drift.
|
|
1423
|
+
*
|
|
1424
|
+
* `blockers` rides along untouched when the predicate supplied any (see `Decision.blockers`);
|
|
1425
|
+
* an EMPTY list is dropped rather than carried, so `blockers` on a decision always means "there
|
|
1426
|
+
* were blockers", never "the rule looked and found none".
|
|
1427
|
+
*/
|
|
1428
|
+
function decide(rule, kind, reason, blockers) {
|
|
1423
1429
|
const decision = {
|
|
1424
1430
|
kind,
|
|
1425
1431
|
reason
|
|
1426
1432
|
};
|
|
1427
1433
|
if (rule.label) decision.rule = rule.label;
|
|
1434
|
+
if (blockers !== void 0 && blockers.length > 0) decision.blockers = blockers;
|
|
1428
1435
|
return decision;
|
|
1429
1436
|
}
|
|
1430
1437
|
/**
|
|
@@ -1484,7 +1491,8 @@ function lockOrderRule(artifact, runDir) {
|
|
|
1484
1491
|
if (blockers.length === 0) return null;
|
|
1485
1492
|
return {
|
|
1486
1493
|
verdict: "deny",
|
|
1487
|
-
detail: blockers.map((b) => b.artifact + " (" + b.status + ")").join(", ")
|
|
1494
|
+
detail: blockers.map((b) => b.artifact + " (" + b.status + ")").join(", "),
|
|
1495
|
+
blockers
|
|
1488
1496
|
};
|
|
1489
1497
|
}
|
|
1490
1498
|
/**
|
|
@@ -1512,11 +1520,91 @@ function lockedWriteRule(target, worktreeRoot) {
|
|
|
1512
1520
|
};
|
|
1513
1521
|
}
|
|
1514
1522
|
/**
|
|
1523
|
+
* The file name when `abs` is a DIRECT CHILD of `runDir`, and `null` otherwise.
|
|
1524
|
+
*
|
|
1525
|
+
* ⚠ THE DIRECT-CHILD TEST IS NOT TIDINESS — IT IS WHAT KEEPS THE RULE OFF THE SUPPORT
|
|
1526
|
+
* FILES. `phaseNumberForArtifact` reads the leading digits of a NAME, so
|
|
1527
|
+
* `<run>/evidence/01-as-is.md` or `<run>/subagents/child/03-brief.md` would look like
|
|
1528
|
+
* phase 1 and phase 3 artifacts if the name were all that was examined. A phase artifact
|
|
1529
|
+
* is a file the run tree holds DIRECTLY beside the others (`recursive_init` writes all
|
|
1530
|
+
* twelve into `<run>/` itself), so the parent directory is part of the definition.
|
|
1531
|
+
*
|
|
1532
|
+
* The comparison normalizes separators and case: the same run directory reached through
|
|
1533
|
+
* a Windows spelling that differs in case is the same directory, and the rule must not
|
|
1534
|
+
* abstain on one spelling and fire on the other.
|
|
1535
|
+
*/
|
|
1536
|
+
function directChildName(abs, runDir) {
|
|
1537
|
+
const normalize = (path) => resolve(path).replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
|
|
1538
|
+
if (normalize(dirname(abs)) !== normalize(runDir)) return null;
|
|
1539
|
+
return basename(abs);
|
|
1540
|
+
}
|
|
1541
|
+
/**
|
|
1542
|
+
* PHASE-ORDER rule: a denial when the target is a LATER phase's artifact than the phase
|
|
1543
|
+
* currently active, `null` when it is not.
|
|
1544
|
+
*
|
|
1545
|
+
* ⚠ THE HOLE THIS CLOSES. The monotonic rule was enforced on `recursive_lock` ONLY. An
|
|
1546
|
+
* agent could therefore write `08-memory-impact.md` while the run sat at phase 0 — and a
|
|
1547
|
+
* live run did exactly that: twelve artifacts, not one of them locked, written out of
|
|
1548
|
+
* order, with a single line in `operations/operations.jsonl`. Ordering that only binds
|
|
1549
|
+
* the lock tool is not ordering; the model's ordinary `write` is the path that mattered.
|
|
1550
|
+
*
|
|
1551
|
+
* The owner's rule, verbatim: *only one phase may be active at a time, and the phases
|
|
1552
|
+
* should be sequential and the active phase must be locked before proceeding to next
|
|
1553
|
+
* phase*. The ACTIVE phase is the one the selector names (`ctx.activePhaseArtifact`), so:
|
|
1554
|
+
*
|
|
1555
|
+
* - the target is the ACTIVE artifact, or shares its phase number (`00-requirements.md`
|
|
1556
|
+
* and `00-worktree.md` are both phase 0; `01-as-is.md` and `01.5-root-cause.md` are
|
|
1557
|
+
* both phase 1) -> ABSTAIN, the write is allowed;
|
|
1558
|
+
* - the target is an EARLIER phase -> ABSTAIN. Such an artifact is LOCKED by
|
|
1559
|
+
* construction (the active phase is the lowest UNLOCKED one), so the locked-artifact
|
|
1560
|
+
* rule above decides it, and its rule label is preserved;
|
|
1561
|
+
* - the target is a LATER phase -> DENY: working ahead.
|
|
1562
|
+
*
|
|
1563
|
+
* ⚠ THE ALLOW HALF IS LOAD-BEARING. An enforcement rule in this exact area was once the
|
|
1564
|
+
* bug: strict enforcement denied the run's OWN artifacts in every phase and made the
|
|
1565
|
+
* workflow unusable (see `resolveFrom` and `currentPhaseArtifact`). "The active artifact
|
|
1566
|
+
* stays writable at every phase" is therefore asserted by walking every phase, not by
|
|
1567
|
+
* one case — `tests/strict-run-tree.spec.ts` (d).
|
|
1568
|
+
*
|
|
1569
|
+
* WHAT IT ABSTAINS ON, deliberately:
|
|
1570
|
+
* - no `activePhaseArtifact` (a caller with no run context, or a run with no phase
|
|
1571
|
+
* artifacts yet) -> abstain, never guess a phase;
|
|
1572
|
+
* - a target outside the active run's own directory -> abstain. The rule is about THIS
|
|
1573
|
+
* run's sequence; another run's tree is a different question and denying it here
|
|
1574
|
+
* would be a false positive;
|
|
1575
|
+
* - a support file (anything not a direct child) -> abstain: `evidence/`, `scratch/`,
|
|
1576
|
+
* `addenda/`, `subagents/`, `operations/` and a plain `<run>/notes.md` are not phases.
|
|
1577
|
+
*
|
|
1578
|
+
* The predicate adds only the per-call particular — which artifact, which phase, which is
|
|
1579
|
+
* active; the rule keeps the static, auditable sentence.
|
|
1580
|
+
*/
|
|
1581
|
+
function phaseOrderRule(target, ctx) {
|
|
1582
|
+
const active = ctx.activePhaseArtifact;
|
|
1583
|
+
if (!target || !ctx.runDir || !ctx.worktreeRoot) return null;
|
|
1584
|
+
if (typeof active !== "string" || active === "") return null;
|
|
1585
|
+
const activePhaseText = phaseNumberForArtifact(active);
|
|
1586
|
+
if (!activePhaseText) return null;
|
|
1587
|
+
const normalized = target.replace(/\\/g, "/");
|
|
1588
|
+
if (!normalized.endsWith(".md")) return null;
|
|
1589
|
+
const abs = resolveFrom(ctx.worktreeRoot, normalized);
|
|
1590
|
+
if (!abs) return null;
|
|
1591
|
+
const name = directChildName(abs, ctx.runDir);
|
|
1592
|
+
if (name === null) return null;
|
|
1593
|
+
const phaseText = phaseNumberForArtifact(name);
|
|
1594
|
+
if (!phaseText) return null;
|
|
1595
|
+
if (Number(phaseText) <= Number(activePhaseText)) return null;
|
|
1596
|
+
return {
|
|
1597
|
+
verdict: "deny",
|
|
1598
|
+
detail: name + " is phase " + phaseText + " but the ACTIVE phase is " + active + " (phase " + activePhaseText + ") - the active phase must be locked before writing a later phase"
|
|
1599
|
+
};
|
|
1600
|
+
}
|
|
1601
|
+
/**
|
|
1515
1602
|
* The BUILT-IN default rule list — the pre-T16 guard behaviour expressed as
|
|
1516
1603
|
* data:
|
|
1517
1604
|
*
|
|
1518
1605
|
* `recursive_lock*` -> the monotonic lock-order denial;
|
|
1519
1606
|
* the write-tool ids -> the locked-artifact write denial;
|
|
1607
|
+
* the write-tool ids -> the phase-order (write-ahead) denial;
|
|
1520
1608
|
* `*` -> allow, so an ordinary tool is not turned into an `ask`.
|
|
1521
1609
|
*
|
|
1522
1610
|
* Every `deny` precedes the `allow`, which is what makes "deny wins over allow" a
|
|
@@ -1543,6 +1631,13 @@ function builtInToolPolicyRules() {
|
|
|
1543
1631
|
label: "locked-write",
|
|
1544
1632
|
predicate: (id, args, ctx) => WRITE_TOOL_NAMES.has(id) ? lockedWriteRule(policyTargetPath(args), ctx.worktreeRoot) : null
|
|
1545
1633
|
});
|
|
1634
|
+
for (const name of WRITE_TOOL_NAMES) rules.push({
|
|
1635
|
+
pattern: name,
|
|
1636
|
+
verdict: "deny",
|
|
1637
|
+
reason: "phase order: only one phase may be active at a time - the active phase must be locked before a later phase artifact is written",
|
|
1638
|
+
label: "phase-order",
|
|
1639
|
+
predicate: (id, args, ctx) => WRITE_TOOL_NAMES.has(id) ? phaseOrderRule(policyTargetPath(args), ctx) : null
|
|
1640
|
+
});
|
|
1546
1641
|
rules.push({
|
|
1547
1642
|
pattern: "*",
|
|
1548
1643
|
verdict: "allow",
|
|
@@ -1581,6 +1676,10 @@ function attachPolicyPredicate(rule) {
|
|
|
1581
1676
|
...rule,
|
|
1582
1677
|
predicate: (id, args, ctx) => LOCK_TOOL_NAMES.has(id) ? lockOrderRule(args.artifact, ctx.runDir) : null
|
|
1583
1678
|
};
|
|
1679
|
+
if (rule.label === "phase-order") return {
|
|
1680
|
+
...rule,
|
|
1681
|
+
predicate: (id, args, ctx) => WRITE_TOOL_NAMES.has(id) ? phaseOrderRule(policyTargetPath(args), ctx) : null
|
|
1682
|
+
};
|
|
1584
1683
|
if (WRITE_TOOL_NAMES.has(rule.pattern)) return {
|
|
1585
1684
|
...rule,
|
|
1586
1685
|
predicate: (id, args, ctx) => WRITE_TOOL_NAMES.has(id) ? lockedWriteRule(policyTargetPath(args), ctx.worktreeRoot) : null
|
|
@@ -7237,6 +7336,31 @@ function buildAskQuestion(gateId) {
|
|
|
7237
7336
|
options: gate.options.map((option) => ({ ...option }))
|
|
7238
7337
|
});
|
|
7239
7338
|
}
|
|
7339
|
+
/** The payload, as the refusal carries it. Validated through `buildAskQuestion`. */
|
|
7340
|
+
function buildGateBlockAsk(artifact, blocked) {
|
|
7341
|
+
return {
|
|
7342
|
+
gate: "gate-block",
|
|
7343
|
+
...buildAskQuestion("gate-block"),
|
|
7344
|
+
artifact,
|
|
7345
|
+
blocked
|
|
7346
|
+
};
|
|
7347
|
+
}
|
|
7348
|
+
/**
|
|
7349
|
+
* FU-7 — THE OPTIONS AS TEXT, DERIVED FROM THE PAYLOAD rather than restated.
|
|
7350
|
+
*
|
|
7351
|
+
* WHY A RENDERER IS NEEDED AT ALL: the harness renders a `tools/pre-execute` denial as
|
|
7352
|
+
* `Error: <reason>` and drops every other field of the decision (measured in
|
|
7353
|
+
* `packages/core/tools`: `content: [{ type: 'text', text: 'Error: ' + denialReason }]`), so an
|
|
7354
|
+
* ask that rode along as a SIBLING field would reach the model as nothing at all — which is
|
|
7355
|
+
* exactly how a strict-by-default guard made the recovery path unreachable. The refusal
|
|
7356
|
+
* therefore renders the payload into the text it hands back, and it renders THIS object, so
|
|
7357
|
+
* the visible sentence and the structured payload cannot disagree.
|
|
7358
|
+
*/
|
|
7359
|
+
function renderGateBlockAsk(ask) {
|
|
7360
|
+
const options = ask.options.map((option) => option.label + (option.description === void 0 ? "" : " (" + option.description + ")")).join(" ");
|
|
7361
|
+
const target = ask.artifact === "" ? "recursive_ask gate=gate-block" : "recursive_ask gate=gate-block artifact=" + ask.artifact;
|
|
7362
|
+
return ask.header + ": " + ask.question + " Options: " + options + " Answer with " + target + ".";
|
|
7363
|
+
}
|
|
7240
7364
|
/**
|
|
7241
7365
|
* PHASE 0 — build the question for ANY accepted gate, including the run-start gate.
|
|
7242
7366
|
*
|
|
@@ -7701,7 +7825,7 @@ function coupleGateBlockToGoal(goalService, agent, ref, reason) {
|
|
|
7701
7825
|
* (Phase C R3/R4/R7/R8, PROPOSAL 8.4/8.6/13.5).
|
|
7702
7826
|
*
|
|
7703
7827
|
* Layer 2 (tool guards) and Layer 8 (tamper) are the remaining enforcement
|
|
7704
|
-
* layers. Configurable strict|advisory per gate (default
|
|
7828
|
+
* layers. Configurable strict|advisory per gate (default strict).
|
|
7705
7829
|
*/
|
|
7706
7830
|
/**
|
|
7707
7831
|
* The BUILT-IN default rule list (T16) is defined in `src/policy-globs.ts`,
|
|
@@ -7733,6 +7857,28 @@ const BUDGET_KEYS = [
|
|
|
7733
7857
|
"maxResultBytes"
|
|
7734
7858
|
];
|
|
7735
7859
|
/**
|
|
7860
|
+
* THE DEFAULT POSTURE: STRICT, on all three gates — and this const is the ONE literal.
|
|
7861
|
+
*
|
|
7862
|
+
* The owner's rule is *only one phase may be active at a time, and the phases should be
|
|
7863
|
+
* sequential and the active phase must be locked before proceeding to next phase*. In
|
|
7864
|
+
* `advisory` that rule is only WARNED about, and a live run showed what that costs: the
|
|
7865
|
+
* run ignored the lock chain for over an hour, wrote phase 8 before phase 1.5 and locked
|
|
7866
|
+
* nothing (twelve DRAFT artifacts, one operations entry). Strict was previously unsafe as
|
|
7867
|
+
* a default because it also refused the run's OWN artifacts — a false positive. That was
|
|
7868
|
+
* fixed, and `tests/strict-run-tree.spec.ts` now walks all twelve phases asserting the
|
|
7869
|
+
* active artifact stays writable while a later one is refused. Strict therefore refuses
|
|
7870
|
+
* exactly the ordering violations it is meant to refuse, so the default is the enforcing
|
|
7871
|
+
* posture rather than a warning nobody has to act on.
|
|
7872
|
+
*
|
|
7873
|
+
* ⚠ WHY IT IS A NAMED CONST AND NOT THREE LITERALS. A default restated per site is this
|
|
7874
|
+
* project's recurring failure: the same value exists in the Config schema, in
|
|
7875
|
+
* `DEFAULT_ENFORCEMENT`, in an omitted config section, and in the parameter defaults of
|
|
7876
|
+
* the helpers below, and moving only some of them leaves a caller that "still gets
|
|
7877
|
+
* advisory". Every one of those sites now reads THIS const, so a revert is a one-line
|
|
7878
|
+
* change and nothing can drift from it.
|
|
7879
|
+
*/
|
|
7880
|
+
const DEFAULT_ENFORCEMENT_MODE = "strict";
|
|
7881
|
+
/**
|
|
7736
7882
|
* Validate the enforcement config shape (unknown keys fail at plugin load).
|
|
7737
7883
|
*
|
|
7738
7884
|
* A budget must be a POSITIVE INTEGER. Zero and negatives are rejected rather than
|
|
@@ -7744,7 +7890,7 @@ function resolveEnforcementConfig(config) {
|
|
|
7744
7890
|
const raw = config ?? {};
|
|
7745
7891
|
const unknown = Object.keys(raw).filter((k) => !CONFIG_KEYS.includes(k));
|
|
7746
7892
|
if (unknown.length > 0) throw new Error("EnforcementConfig has unknown key(s) " + unknown.join(", ") + " - config is { preStep, toolGuards, tamper, budgets }");
|
|
7747
|
-
const mode = (value) => value === "strict" ? "strict" : "advisory";
|
|
7893
|
+
const mode = (value) => value === "strict" ? "strict" : value === "advisory" ? "advisory" : DEFAULT_ENFORCEMENT_MODE;
|
|
7748
7894
|
const rawBudgets = raw.budgets ?? {};
|
|
7749
7895
|
if (typeof raw.budgets !== "undefined" && (raw.budgets === null || typeof raw.budgets !== "object")) throw new Error("EnforcementConfig budgets must be an object of caps");
|
|
7750
7896
|
const unknownBudget = Object.keys(rawBudgets).filter((k) => !BUDGET_KEYS.includes(k));
|
|
@@ -7763,10 +7909,16 @@ function resolveEnforcementConfig(config) {
|
|
|
7763
7909
|
budgets
|
|
7764
7910
|
};
|
|
7765
7911
|
}
|
|
7912
|
+
/**
|
|
7913
|
+
* The runtime default: what a caller gets when it supplies no `enforcement` section at all
|
|
7914
|
+
* (a profile mounting this plugin with no config, e.g. `preset/recursive.patch.yml`). It is
|
|
7915
|
+
* `DEFAULT_ENFORCEMENT_MODE` per gate, so this object and the resolver cannot disagree —
|
|
7916
|
+
* see that const for WHY the default is strict.
|
|
7917
|
+
*/
|
|
7766
7918
|
const DEFAULT_ENFORCEMENT = {
|
|
7767
|
-
preStep:
|
|
7768
|
-
toolGuards:
|
|
7769
|
-
tamper:
|
|
7919
|
+
preStep: DEFAULT_ENFORCEMENT_MODE,
|
|
7920
|
+
toolGuards: DEFAULT_ENFORCEMENT_MODE,
|
|
7921
|
+
tamper: DEFAULT_ENFORCEMENT_MODE,
|
|
7770
7922
|
budgets: DEFAULT_BUDGETS
|
|
7771
7923
|
};
|
|
7772
7924
|
/**
|
|
@@ -7784,8 +7936,8 @@ const DEFAULT_ENFORCEMENT = {
|
|
|
7784
7936
|
* `recursive:policy` later — can read the effective rules instead of inferring
|
|
7785
7937
|
* them from a code path.
|
|
7786
7938
|
*/
|
|
7787
|
-
function resolveToolPolicyForGuard(worktreeRoot, runId) {
|
|
7788
|
-
return withPhaseBaseline(loadToolPolicyFile(worktreeRoot).policy, currentPhaseArtifact(worktreeRoot, runId));
|
|
7939
|
+
function resolveToolPolicyForGuard(worktreeRoot, runId, activePhaseArtifact) {
|
|
7940
|
+
return withPhaseBaseline(loadToolPolicyFile(worktreeRoot).policy, activePhaseArtifact ?? currentPhaseArtifact(worktreeRoot, runId));
|
|
7789
7941
|
}
|
|
7790
7942
|
/**
|
|
7791
7943
|
* The artifact whose phase baseline applies: the HIGHEST-numbered phase artifact
|
|
@@ -7829,41 +7981,83 @@ function currentPhaseArtifact(worktreeRoot, runId) {
|
|
|
7829
7981
|
}
|
|
7830
7982
|
return inForce !== "" ? inForce : best;
|
|
7831
7983
|
}
|
|
7832
|
-
|
|
7984
|
+
/**
|
|
7985
|
+
* `mode` is the gate's configured posture. Its parameter default FOLLOWS the config
|
|
7986
|
+
* default by REFERENCE (`DEFAULT_ENFORCEMENT.toolGuards`) rather than repeating the
|
|
7987
|
+
* literal: a bare call is "the caller had no mode to hand", and the answer to that must
|
|
7988
|
+
* be the same posture the config would have produced. Two literals are two defaults, and
|
|
7989
|
+
* a helper left on the old `advisory` literal while the config moved to `strict` is
|
|
7990
|
+
* exactly the twin-default hole this change closes — a caller that forgot the argument
|
|
7991
|
+
* would silently get the permissive branch, which no config could then undo. Every
|
|
7992
|
+
* production call site passes the mode explicitly (`index.ts` `runToolGuard`,
|
|
7993
|
+
* `runtime.ts` `guardTool`, the preview tool); this default serves bare callers, and a
|
|
7994
|
+
* bare caller must not be the one place enforcement quietly turns itself off.
|
|
7995
|
+
*/
|
|
7996
|
+
function evaluateToolGuard(exec, worktreeRoot, activeRunId, mode = DEFAULT_ENFORCEMENT.toolGuards) {
|
|
7833
7997
|
const name = exec.name;
|
|
7834
7998
|
const args = exec.arguments ?? {};
|
|
7835
7999
|
const runId = typeof activeRunId === "string" ? activeRunId.trim() : "";
|
|
7836
8000
|
const runDir = join(worktreeRoot, ".recursive", "run", runId);
|
|
7837
8001
|
const transition = consultTransitionGate(name, args, worktreeRoot, runId);
|
|
7838
|
-
|
|
8002
|
+
const activePhaseArtifact = currentPhaseArtifact(worktreeRoot, runId);
|
|
8003
|
+
return advisory(verdictFor(mode, evaluateToolPolicy(resolveToolPolicyForGuard(worktreeRoot, runId, activePhaseArtifact), name, args, {
|
|
7839
8004
|
args,
|
|
7840
8005
|
runDir,
|
|
7841
8006
|
runId,
|
|
7842
|
-
worktreeRoot
|
|
7843
|
-
|
|
8007
|
+
worktreeRoot,
|
|
8008
|
+
activePhaseArtifact
|
|
8009
|
+
}), String(args.artifact ?? "")), transition);
|
|
7844
8010
|
}
|
|
7845
8011
|
/**
|
|
7846
8012
|
* Map the policy's verdict onto the guard's decision kind: `strict` denies,
|
|
7847
8013
|
* `advisory` asks (the pre-T16 wording, unchanged), `allow` stays an allow. The
|
|
7848
8014
|
* decision's `rule` is the label of the rule that decided it, so a policy
|
|
7849
8015
|
* verdict is traceable to an auditable line in the policy file.
|
|
7850
|
-
|
|
7851
|
-
|
|
8016
|
+
*
|
|
8017
|
+
* ⚠ FU-7 — THE ORDERING REFUSAL CARRIES THE HUMAN'S CHOICE. `fix | reopen | abandon` is how a
|
|
8018
|
+
* person unblocks a lock, and before this the ask was attached ONLY by `recursive_lock`'s own
|
|
8019
|
+
* catch — the branch that runs when the guard ABSTAINS. Under the strict default the guard
|
|
8020
|
+
* refuses a lock ahead of its prerequisites BEFORE dispatch, so that branch never ran on the
|
|
8021
|
+
* default path and the caller got a bare sentence: the recovery options existed in the code and
|
|
8022
|
+
* were unreachable in the product, which is worse than the advisory posture they replaced (an
|
|
8023
|
+
* advisory `ask` at least surfaced the reason).
|
|
8024
|
+
*
|
|
8025
|
+
* THE TRIGGER IS THE BLOCKERS, NOT THE LABEL. `PolicyDecision.blockers` is present exactly when a
|
|
8026
|
+
* predicate read prerequisite blockers from disk and they were non-empty, so gating on it means
|
|
8027
|
+
* "this refusal was decided from an ordering violation" — including a policy FILE whose
|
|
8028
|
+
* `recursive_lock*` deny carries no label (the file-authored rule is given the same condition by
|
|
8029
|
+
* `attachPolicyPredicate`, and its `rule` would otherwise read `none`). Nothing is recomputed
|
|
8030
|
+
* here: the blockers arrive from the rule that already resolved them.
|
|
8031
|
+
*
|
|
8032
|
+
* IT IS ATTACHED TO THE REFUSAL ONLY. Under `advisory` the same verdict becomes an `ask` that the
|
|
8033
|
+
* live path coerces to an allow-with-warning, and the tool then refuses with its OWN payload when
|
|
8034
|
+
* `lockArtifact` throws — so an ask attached here would be a claim about a refusal that this layer
|
|
8035
|
+
* did not make. One refusal, one ask.
|
|
8036
|
+
*/
|
|
8037
|
+
function verdictFor(mode, decision, artifact) {
|
|
7852
8038
|
const rule = decision.rule ?? "none";
|
|
7853
8039
|
if (decision.kind === "allow") return {
|
|
7854
8040
|
kind: "allow",
|
|
7855
8041
|
rule
|
|
7856
8042
|
};
|
|
7857
8043
|
const reason = decision.reason ?? "tool policy denied this call";
|
|
7858
|
-
|
|
7859
|
-
kind: "
|
|
8044
|
+
if (mode !== "strict") return {
|
|
8045
|
+
kind: "ask",
|
|
7860
8046
|
reason,
|
|
7861
8047
|
rule
|
|
7862
|
-
}
|
|
7863
|
-
|
|
8048
|
+
};
|
|
8049
|
+
const blocked = decision.blockers;
|
|
8050
|
+
if (blocked === void 0 || blocked.length === 0) return {
|
|
8051
|
+
kind: "deny",
|
|
7864
8052
|
reason,
|
|
7865
8053
|
rule
|
|
7866
8054
|
};
|
|
8055
|
+
return {
|
|
8056
|
+
kind: "deny",
|
|
8057
|
+
reason,
|
|
8058
|
+
rule,
|
|
8059
|
+
ask: buildGateBlockAsk(artifact, reason)
|
|
8060
|
+
};
|
|
7867
8061
|
}
|
|
7868
8062
|
/**
|
|
7869
8063
|
* T15 — consult the transition gate (`validateTransition`) from the guard,
|
|
@@ -7915,7 +8109,7 @@ function advisory(decision, transition) {
|
|
|
7915
8109
|
return {
|
|
7916
8110
|
...decision,
|
|
7917
8111
|
rule: "transition",
|
|
7918
|
-
warn: "transition gate (
|
|
8112
|
+
warn: "transition gate (report-only) failed: " + transition.failures.join("; "),
|
|
7919
8113
|
transition
|
|
7920
8114
|
};
|
|
7921
8115
|
}
|
|
@@ -7924,8 +8118,20 @@ function advisory(decision, transition) {
|
|
|
7924
8118
|
* allow. Under `strict` it coerces to `deny`; under `advisory` it stays `allow`
|
|
7925
8119
|
* but flags a `warn` so the caller never lets it through unlogged. Non-ask
|
|
7926
8120
|
* decisions pass through unchanged.
|
|
7927
|
-
|
|
7928
|
-
|
|
8121
|
+
*
|
|
8122
|
+
* ⚠ THE `mode` DEFAULT IS DELIBERATE, and it is NOT a neutral fallback — there is no
|
|
8123
|
+
* neutral branch here. The domain is two postures, one of which ALLOWS the call, so
|
|
8124
|
+
* "unspecified" has to be resolved rather than left open, and this codebase's rule for an
|
|
8125
|
+
* undecidable path is to fail CLOSED (`index.ts`: *"we could not decide" is not
|
|
8126
|
+
* permission*). It therefore FOLLOWS the config default by REFERENCE
|
|
8127
|
+
* (`DEFAULT_ENFORCEMENT.toolGuards`), for the same reason as `evaluateToolGuard`'s: an
|
|
8128
|
+
* `advisory` literal here would be a second, hidden copy of the old default inside the
|
|
8129
|
+
* very module this change moves, and a future caller that omitted the argument would
|
|
8130
|
+
* re-open the permissive path with no config able to close it. The production call site
|
|
8131
|
+
* (`index.ts` `runToolGuard`) always passes the configured mode, so this changes no live
|
|
8132
|
+
* behaviour — it removes the last place where "we were not told" meant "allow".
|
|
8133
|
+
*/
|
|
8134
|
+
function coerceAskToDecision(decision, mode = DEFAULT_ENFORCEMENT.toolGuards) {
|
|
7929
8135
|
if (decision.kind !== "ask") return decision;
|
|
7930
8136
|
if (mode === "strict") return {
|
|
7931
8137
|
kind: "deny",
|
|
@@ -8086,6 +8292,7 @@ function renderStableContract(config = DEFAULT_ENFORCEMENT) {
|
|
|
8086
8292
|
"- Gates in force: pre-step " + config.preStep + ", tool guards " + config.toolGuards + ", tamper detection " + config.tamper + ".",
|
|
8087
8293
|
"- A transition that fails its gates is BLOCKED (strict) or warns (advisory); no rejected transition proceeds silently.",
|
|
8088
8294
|
"- Writes to a Status: LOCKED phase doc are denied/asked; reopen explicitly to edit.",
|
|
8295
|
+
"- Phase order binds WRITES as well as locks: only the ACTIVE phase (the lowest-numbered artifact not yet LOCKED) may be written; a write to a LATER phase artifact is denied/asked. Run support files (evidence/, scratch/, addenda/, subagents/, operations/) are not phases.",
|
|
8089
8296
|
"- Phase 3 lock requires TDD evidence (strict) or rationale (pragmatic); Phase 5 requires QA evidence.",
|
|
8090
8297
|
"- The control-plane root is resolved STRICTLY from this session workspace (never scanned from another)."
|
|
8091
8298
|
].join("\n");
|
|
@@ -11422,7 +11629,7 @@ var RecursiveRuntime = class extends Service {
|
|
|
11422
11629
|
coupleGateBlockToGoal(goalService, agent, ref, reason) {
|
|
11423
11630
|
return coupleGateBlockToGoal(goalService, agent, ref, reason);
|
|
11424
11631
|
}
|
|
11425
|
-
/** Phase C R7: resolve the enforcement config (strict|advisory, default
|
|
11632
|
+
/** Phase C R7: resolve the enforcement config (strict|advisory, default strict). */
|
|
11426
11633
|
get enforcementConfig() {
|
|
11427
11634
|
return this._enforcementConfig ?? DEFAULT_ENFORCEMENT;
|
|
11428
11635
|
}
|
|
@@ -11838,12 +12045,7 @@ function createRecursiveLockTool(recursive) {
|
|
|
11838
12045
|
const refusal = codeRuntimeRefusal(message);
|
|
11839
12046
|
if (message.startsWith("Prerequisite blockers:")) return {
|
|
11840
12047
|
error: refusal,
|
|
11841
|
-
ask:
|
|
11842
|
-
gate: "gate-block",
|
|
11843
|
-
...buildAskQuestion("gate-block"),
|
|
11844
|
-
artifact: args.artifact ?? "",
|
|
11845
|
-
blocked: message
|
|
11846
|
-
}
|
|
12048
|
+
ask: buildGateBlockAsk(args.artifact ?? "", message)
|
|
11847
12049
|
};
|
|
11848
12050
|
return { error: refusal };
|
|
11849
12051
|
}
|
|
@@ -14460,10 +14662,17 @@ const enforcementMode = z.union([z.const("strict"), z.const("advisory")]);
|
|
|
14460
14662
|
const Config = z.object({
|
|
14461
14663
|
shellOnly: z.boolean().default(false).description("Client-shell only: registers nothing on the server (no tools, no command, no projection)."),
|
|
14462
14664
|
repoRoot: z.string().description("Control-plane root. Defaults to the process working directory when unset."),
|
|
14665
|
+
/**
|
|
14666
|
+
* ⚠ THE THREE MODE DEFAULTS READ `DEFAULT_ENFORCEMENT_MODE` FROM `enforcement.ts`, and
|
|
14667
|
+
* that is deliberate: the schema default and the runtime default are the SAME value, and
|
|
14668
|
+
* two literals here would be two defaults. A caller that omits the section gets
|
|
14669
|
+
* `DEFAULT_ENFORCEMENT` from the runtime; a caller that supplies a partial section gets
|
|
14670
|
+
* the resolver's fill. Both must be the enforcing posture — see the const for why.
|
|
14671
|
+
*/
|
|
14463
14672
|
enforcement: z.object({
|
|
14464
|
-
preStep: enforcementMode.default(
|
|
14465
|
-
toolGuards: enforcementMode.default(
|
|
14466
|
-
tamper: enforcementMode.default(
|
|
14673
|
+
preStep: enforcementMode.default(DEFAULT_ENFORCEMENT_MODE).description("Phase pre-step enforcement: strict refuses an out-of-order transition, advisory warns and proceeds."),
|
|
14674
|
+
toolGuards: enforcementMode.default(DEFAULT_ENFORCEMENT_MODE).description("Tool guard mode: strict DENIES an out-of-order tool call, advisory allows it and carries the warning."),
|
|
14675
|
+
tamper: enforcementMode.default(DEFAULT_ENFORCEMENT_MODE).description("Tamper detection: strict refuses an artifact whose LockHash no longer matches its body."),
|
|
14467
14676
|
budgets: z.object({
|
|
14468
14677
|
maxAuditRounds: z.natural().default(DEFAULT_BUDGETS.maxAuditRounds).description("Rounds one phase audit loop may run, even while every round makes progress."),
|
|
14469
14678
|
maxRepairAttempts: z.natural().default(DEFAULT_BUDGETS.maxRepairAttempts).description("How many times a phase may be sent back for repair before the loop stops."),
|
|
@@ -14543,6 +14752,7 @@ function runToolGuard(recursive, exec, root, runId) {
|
|
|
14543
14752
|
if (final.warn) record.reason = final.warn;
|
|
14544
14753
|
} else if (final.reason) record.reason = final.reason;
|
|
14545
14754
|
if (final.transition) record.transition = final.transition;
|
|
14755
|
+
if (final.kind === "deny" && final.ask) record.ask = final.ask;
|
|
14546
14756
|
appendGuardDecision(root, record);
|
|
14547
14757
|
}
|
|
14548
14758
|
return final;
|
|
@@ -14697,8 +14907,11 @@ function apply(ctx, config) {
|
|
|
14697
14907
|
kind: "deny",
|
|
14698
14908
|
reason: "the tool guard produced no decision"
|
|
14699
14909
|
};
|
|
14700
|
-
if (final.kind === "deny") return final
|
|
14701
|
-
|
|
14910
|
+
if (final.kind === "deny") return final.ask === void 0 ? final : {
|
|
14911
|
+
...final,
|
|
14912
|
+
reason: final.reason + " " + renderGateBlockAsk(final.ask)
|
|
14913
|
+
};
|
|
14914
|
+
if (final.kind === "allow" && final.warn) console.warn("[recursive] tool guard (" + recursive.enforcementConfig.toolGuards + ") allowed this call: " + final.warn);
|
|
14702
14915
|
return typeof next === "function" ? next() : { kind: "allow" };
|
|
14703
14916
|
}));
|
|
14704
14917
|
const observationRuntime = ctx;
|
|
@@ -14815,4 +15028,4 @@ function apply(ctx, config) {
|
|
|
14815
15028
|
});
|
|
14816
15029
|
}
|
|
14817
15030
|
//#endregion
|
|
14818
|
-
export { Config, DEFAULT_BUDGETS, DEFAULT_ENFORCEMENT, OPTIONAL_PHASES, PHASES, PHASE_POSITIONS, PHASE_SEQUENCE, RECURSIVE_API_PREFIX, RUN_ARTIFACT_SEQUENCE, RUN_STATES, RecursiveRuntime, actionRecordStatus, apply, auditToPass, buildDelegationPrompt, buildReviewBundle, buildWorkSlice, builtInToolPolicy, capabilityProbe, childScratchPath, coerceAskToDecision, contentSha256, contractDigest, coupleGateBlockToGoal, createChildBrief, createHandoff, createRecursiveCloseoutTool, createRecursiveInitTool, createRecursiveLintTool, createRecursiveLockTool, createRecursivePhaseTool, createRecursiveScratchTool, createRecursiveStatusTool, createRecursiveWorktreeTool, currentPhaseArtifact, defaultReviewToolFilter, delegate, delegateContinuable, delegationDecisionBasis, delegationError, detectTamper, discoverRuns, drainContinuableChildren, drainContinuableDescendants, escapeRegExp, evaluateDelegationResult, evaluateToolGuard, foldDiagnostics, foldRun, foldRunCard, getAllStaleReceipts, getArtifactState, getGateStatus, getLatestRunDirectory, getLockStatus, getMdFieldValue, getNextLegalPhase, getPrerequisiteBlockers, getPrerequisites, getStaleDownstreamPhases, getTodoStats, getWorkflowProfile, inject, interruptContinuable, invalidateReceipt, isCoreArtifact, isTaskClaimedBy, loadRouterPolicy, lockHashFromContent, makeRecursiveRoutes, mountRecursiveRoutesOnce, name, normalizeForLockHash, parseReplyVerdict, pendingWork, phaseIndex, phasePosition, probeCapabilities, readReceipt, readRepairFromReply, readRepairFromStructured, readVerdictFromReply, readVerdictFromStructured, receiptPath, referencesFromResult, registerRecursiveSkill, remainingDepthFor, renderPhaseTail, renderRecursivePolicy, renderStableContract, renderTaskHistory, replyPath, resetFoldCache, resolveEnforcementConfig, resolveRole, resolveRunDir, resolveToolPolicyForGuard, reviewBundleDir, reviewOutputSchema, routerPolicyPath, snapshotWorkspace, tamperCandidatePath, trimMdValue, validateChain, validateReferences, validateTransition, writeActionRecord, writeReceipt };
|
|
15031
|
+
export { Config, DEFAULT_BUDGETS, DEFAULT_ENFORCEMENT, DEFAULT_ENFORCEMENT_MODE, OPTIONAL_PHASES, PHASES, PHASE_POSITIONS, PHASE_SEQUENCE, RECURSIVE_API_PREFIX, RUN_ARTIFACT_SEQUENCE, RUN_STATES, RecursiveRuntime, actionRecordStatus, apply, auditToPass, buildDelegationPrompt, buildReviewBundle, buildWorkSlice, builtInToolPolicy, capabilityProbe, childScratchPath, coerceAskToDecision, contentSha256, contractDigest, coupleGateBlockToGoal, createChildBrief, createHandoff, createRecursiveCloseoutTool, createRecursiveInitTool, createRecursiveLintTool, createRecursiveLockTool, createRecursivePhaseTool, createRecursiveScratchTool, createRecursiveStatusTool, createRecursiveWorktreeTool, currentPhaseArtifact, defaultReviewToolFilter, delegate, delegateContinuable, delegationDecisionBasis, delegationError, detectTamper, discoverRuns, drainContinuableChildren, drainContinuableDescendants, escapeRegExp, evaluateDelegationResult, evaluateToolGuard, foldDiagnostics, foldRun, foldRunCard, getAllStaleReceipts, getArtifactState, getGateStatus, getLatestRunDirectory, getLockStatus, getMdFieldValue, getNextLegalPhase, getPrerequisiteBlockers, getPrerequisites, getStaleDownstreamPhases, getTodoStats, getWorkflowProfile, inject, interruptContinuable, invalidateReceipt, isCoreArtifact, isTaskClaimedBy, loadRouterPolicy, lockHashFromContent, makeRecursiveRoutes, mountRecursiveRoutesOnce, name, normalizeForLockHash, parseReplyVerdict, pendingWork, phaseIndex, phasePosition, probeCapabilities, readReceipt, readRepairFromReply, readRepairFromStructured, readVerdictFromReply, readVerdictFromStructured, receiptPath, referencesFromResult, registerRecursiveSkill, remainingDepthFor, renderPhaseTail, renderRecursivePolicy, renderStableContract, renderTaskHistory, replyPath, resetFoldCache, resolveEnforcementConfig, resolveRole, resolveRunDir, resolveToolPolicyForGuard, reviewBundleDir, reviewOutputSchema, routerPolicyPath, snapshotWorkspace, tamperCandidatePath, trimMdValue, validateChain, validateReferences, validateTransition, writeActionRecord, writeReceipt };
|