@try-works/dsh-recursive-mode 0.4.9 → 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 +60 -0
- package/lib/guard-log.d.ts +7 -0
- package/lib/index.js +152 -33
- package/lib/policy-globs.d.ts +19 -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 +115 -12
- package/src/guard-log.ts +7 -0
- package/src/index.ts +26 -3
- package/src/policy-globs.ts +37 -5
- 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,6 +72,12 @@ 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
|
|
@@ -77,6 +106,12 @@ export interface GuardTransition {
|
|
|
77
106
|
* optional — `coerceAskToDecision` is asserted with `toEqual({ kind: ... })`
|
|
78
107
|
* (an EXACT match) in tests/enforcement.spec.ts, so the coercion path may never
|
|
79
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.
|
|
80
115
|
*/
|
|
81
116
|
export type ToolGuardDecision = {
|
|
82
117
|
kind: 'allow';
|
|
@@ -88,6 +123,7 @@ export type ToolGuardDecision = {
|
|
|
88
123
|
reason: string;
|
|
89
124
|
rule?: GuardRule;
|
|
90
125
|
transition?: GuardTransition;
|
|
126
|
+
ask?: GateBlockAsk;
|
|
91
127
|
} | {
|
|
92
128
|
kind: 'ask';
|
|
93
129
|
reason?: string;
|
|
@@ -122,12 +158,36 @@ export declare function resolveToolPolicyForGuard(worktreeRoot: string, runId: s
|
|
|
122
158
|
* needs, because a cached phase would apply yesterday's baseline to today's lock.
|
|
123
159
|
*/
|
|
124
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
|
+
*/
|
|
125
173
|
export declare function evaluateToolGuard(exec: ToolExecLike, worktreeRoot: string, activeRunId: string, mode?: EnforcementMode): ToolGuardDecision;
|
|
126
174
|
/**
|
|
127
175
|
* T6 (approval ask→policy bridge): an `ask` decision must never be a silent
|
|
128
176
|
* allow. Under `strict` it coerces to `deny`; under `advisory` it stays `allow`
|
|
129
177
|
* but flags a `warn` so the caller never lets it through unlogged. Non-ask
|
|
130
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".
|
|
131
191
|
*/
|
|
132
192
|
export declare function coerceAskToDecision(decision: ToolGuardDecision, mode?: EnforcementMode): ToolGuardDecision;
|
|
133
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
|
/**
|
|
@@ -7328,6 +7336,31 @@ function buildAskQuestion(gateId) {
|
|
|
7328
7336
|
options: gate.options.map((option) => ({ ...option }))
|
|
7329
7337
|
});
|
|
7330
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
|
+
}
|
|
7331
7364
|
/**
|
|
7332
7365
|
* PHASE 0 — build the question for ANY accepted gate, including the run-start gate.
|
|
7333
7366
|
*
|
|
@@ -7792,7 +7825,7 @@ function coupleGateBlockToGoal(goalService, agent, ref, reason) {
|
|
|
7792
7825
|
* (Phase C R3/R4/R7/R8, PROPOSAL 8.4/8.6/13.5).
|
|
7793
7826
|
*
|
|
7794
7827
|
* Layer 2 (tool guards) and Layer 8 (tamper) are the remaining enforcement
|
|
7795
|
-
* layers. Configurable strict|advisory per gate (default
|
|
7828
|
+
* layers. Configurable strict|advisory per gate (default strict).
|
|
7796
7829
|
*/
|
|
7797
7830
|
/**
|
|
7798
7831
|
* The BUILT-IN default rule list (T16) is defined in `src/policy-globs.ts`,
|
|
@@ -7824,6 +7857,28 @@ const BUDGET_KEYS = [
|
|
|
7824
7857
|
"maxResultBytes"
|
|
7825
7858
|
];
|
|
7826
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
|
+
/**
|
|
7827
7882
|
* Validate the enforcement config shape (unknown keys fail at plugin load).
|
|
7828
7883
|
*
|
|
7829
7884
|
* A budget must be a POSITIVE INTEGER. Zero and negatives are rejected rather than
|
|
@@ -7835,7 +7890,7 @@ function resolveEnforcementConfig(config) {
|
|
|
7835
7890
|
const raw = config ?? {};
|
|
7836
7891
|
const unknown = Object.keys(raw).filter((k) => !CONFIG_KEYS.includes(k));
|
|
7837
7892
|
if (unknown.length > 0) throw new Error("EnforcementConfig has unknown key(s) " + unknown.join(", ") + " - config is { preStep, toolGuards, tamper, budgets }");
|
|
7838
|
-
const mode = (value) => value === "strict" ? "strict" : "advisory";
|
|
7893
|
+
const mode = (value) => value === "strict" ? "strict" : value === "advisory" ? "advisory" : DEFAULT_ENFORCEMENT_MODE;
|
|
7839
7894
|
const rawBudgets = raw.budgets ?? {};
|
|
7840
7895
|
if (typeof raw.budgets !== "undefined" && (raw.budgets === null || typeof raw.budgets !== "object")) throw new Error("EnforcementConfig budgets must be an object of caps");
|
|
7841
7896
|
const unknownBudget = Object.keys(rawBudgets).filter((k) => !BUDGET_KEYS.includes(k));
|
|
@@ -7854,10 +7909,16 @@ function resolveEnforcementConfig(config) {
|
|
|
7854
7909
|
budgets
|
|
7855
7910
|
};
|
|
7856
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
|
+
*/
|
|
7857
7918
|
const DEFAULT_ENFORCEMENT = {
|
|
7858
|
-
preStep:
|
|
7859
|
-
toolGuards:
|
|
7860
|
-
tamper:
|
|
7919
|
+
preStep: DEFAULT_ENFORCEMENT_MODE,
|
|
7920
|
+
toolGuards: DEFAULT_ENFORCEMENT_MODE,
|
|
7921
|
+
tamper: DEFAULT_ENFORCEMENT_MODE,
|
|
7861
7922
|
budgets: DEFAULT_BUDGETS
|
|
7862
7923
|
};
|
|
7863
7924
|
/**
|
|
@@ -7920,7 +7981,19 @@ function currentPhaseArtifact(worktreeRoot, runId) {
|
|
|
7920
7981
|
}
|
|
7921
7982
|
return inForce !== "" ? inForce : best;
|
|
7922
7983
|
}
|
|
7923
|
-
|
|
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) {
|
|
7924
7997
|
const name = exec.name;
|
|
7925
7998
|
const args = exec.arguments ?? {};
|
|
7926
7999
|
const runId = typeof activeRunId === "string" ? activeRunId.trim() : "";
|
|
@@ -7933,30 +8006,58 @@ function evaluateToolGuard(exec, worktreeRoot, activeRunId, mode = "advisory") {
|
|
|
7933
8006
|
runId,
|
|
7934
8007
|
worktreeRoot,
|
|
7935
8008
|
activePhaseArtifact
|
|
7936
|
-
})), transition);
|
|
8009
|
+
}), String(args.artifact ?? "")), transition);
|
|
7937
8010
|
}
|
|
7938
8011
|
/**
|
|
7939
8012
|
* Map the policy's verdict onto the guard's decision kind: `strict` denies,
|
|
7940
8013
|
* `advisory` asks (the pre-T16 wording, unchanged), `allow` stays an allow. The
|
|
7941
8014
|
* decision's `rule` is the label of the rule that decided it, so a policy
|
|
7942
8015
|
* verdict is traceable to an auditable line in the policy file.
|
|
7943
|
-
|
|
7944
|
-
|
|
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) {
|
|
7945
8038
|
const rule = decision.rule ?? "none";
|
|
7946
8039
|
if (decision.kind === "allow") return {
|
|
7947
8040
|
kind: "allow",
|
|
7948
8041
|
rule
|
|
7949
8042
|
};
|
|
7950
8043
|
const reason = decision.reason ?? "tool policy denied this call";
|
|
7951
|
-
|
|
7952
|
-
kind: "
|
|
8044
|
+
if (mode !== "strict") return {
|
|
8045
|
+
kind: "ask",
|
|
7953
8046
|
reason,
|
|
7954
8047
|
rule
|
|
7955
|
-
}
|
|
7956
|
-
|
|
8048
|
+
};
|
|
8049
|
+
const blocked = decision.blockers;
|
|
8050
|
+
if (blocked === void 0 || blocked.length === 0) return {
|
|
8051
|
+
kind: "deny",
|
|
7957
8052
|
reason,
|
|
7958
8053
|
rule
|
|
7959
8054
|
};
|
|
8055
|
+
return {
|
|
8056
|
+
kind: "deny",
|
|
8057
|
+
reason,
|
|
8058
|
+
rule,
|
|
8059
|
+
ask: buildGateBlockAsk(artifact, reason)
|
|
8060
|
+
};
|
|
7960
8061
|
}
|
|
7961
8062
|
/**
|
|
7962
8063
|
* T15 — consult the transition gate (`validateTransition`) from the guard,
|
|
@@ -8008,7 +8109,7 @@ function advisory(decision, transition) {
|
|
|
8008
8109
|
return {
|
|
8009
8110
|
...decision,
|
|
8010
8111
|
rule: "transition",
|
|
8011
|
-
warn: "transition gate (
|
|
8112
|
+
warn: "transition gate (report-only) failed: " + transition.failures.join("; "),
|
|
8012
8113
|
transition
|
|
8013
8114
|
};
|
|
8014
8115
|
}
|
|
@@ -8017,8 +8118,20 @@ function advisory(decision, transition) {
|
|
|
8017
8118
|
* allow. Under `strict` it coerces to `deny`; under `advisory` it stays `allow`
|
|
8018
8119
|
* but flags a `warn` so the caller never lets it through unlogged. Non-ask
|
|
8019
8120
|
* decisions pass through unchanged.
|
|
8020
|
-
|
|
8021
|
-
|
|
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) {
|
|
8022
8135
|
if (decision.kind !== "ask") return decision;
|
|
8023
8136
|
if (mode === "strict") return {
|
|
8024
8137
|
kind: "deny",
|
|
@@ -11516,7 +11629,7 @@ var RecursiveRuntime = class extends Service {
|
|
|
11516
11629
|
coupleGateBlockToGoal(goalService, agent, ref, reason) {
|
|
11517
11630
|
return coupleGateBlockToGoal(goalService, agent, ref, reason);
|
|
11518
11631
|
}
|
|
11519
|
-
/** Phase C R7: resolve the enforcement config (strict|advisory, default
|
|
11632
|
+
/** Phase C R7: resolve the enforcement config (strict|advisory, default strict). */
|
|
11520
11633
|
get enforcementConfig() {
|
|
11521
11634
|
return this._enforcementConfig ?? DEFAULT_ENFORCEMENT;
|
|
11522
11635
|
}
|
|
@@ -11932,12 +12045,7 @@ function createRecursiveLockTool(recursive) {
|
|
|
11932
12045
|
const refusal = codeRuntimeRefusal(message);
|
|
11933
12046
|
if (message.startsWith("Prerequisite blockers:")) return {
|
|
11934
12047
|
error: refusal,
|
|
11935
|
-
ask:
|
|
11936
|
-
gate: "gate-block",
|
|
11937
|
-
...buildAskQuestion("gate-block"),
|
|
11938
|
-
artifact: args.artifact ?? "",
|
|
11939
|
-
blocked: message
|
|
11940
|
-
}
|
|
12048
|
+
ask: buildGateBlockAsk(args.artifact ?? "", message)
|
|
11941
12049
|
};
|
|
11942
12050
|
return { error: refusal };
|
|
11943
12051
|
}
|
|
@@ -14554,10 +14662,17 @@ const enforcementMode = z.union([z.const("strict"), z.const("advisory")]);
|
|
|
14554
14662
|
const Config = z.object({
|
|
14555
14663
|
shellOnly: z.boolean().default(false).description("Client-shell only: registers nothing on the server (no tools, no command, no projection)."),
|
|
14556
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
|
+
*/
|
|
14557
14672
|
enforcement: z.object({
|
|
14558
|
-
preStep: enforcementMode.default(
|
|
14559
|
-
toolGuards: enforcementMode.default(
|
|
14560
|
-
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."),
|
|
14561
14676
|
budgets: z.object({
|
|
14562
14677
|
maxAuditRounds: z.natural().default(DEFAULT_BUDGETS.maxAuditRounds).description("Rounds one phase audit loop may run, even while every round makes progress."),
|
|
14563
14678
|
maxRepairAttempts: z.natural().default(DEFAULT_BUDGETS.maxRepairAttempts).description("How many times a phase may be sent back for repair before the loop stops."),
|
|
@@ -14637,6 +14752,7 @@ function runToolGuard(recursive, exec, root, runId) {
|
|
|
14637
14752
|
if (final.warn) record.reason = final.warn;
|
|
14638
14753
|
} else if (final.reason) record.reason = final.reason;
|
|
14639
14754
|
if (final.transition) record.transition = final.transition;
|
|
14755
|
+
if (final.kind === "deny" && final.ask) record.ask = final.ask;
|
|
14640
14756
|
appendGuardDecision(root, record);
|
|
14641
14757
|
}
|
|
14642
14758
|
return final;
|
|
@@ -14791,8 +14907,11 @@ function apply(ctx, config) {
|
|
|
14791
14907
|
kind: "deny",
|
|
14792
14908
|
reason: "the tool guard produced no decision"
|
|
14793
14909
|
};
|
|
14794
|
-
if (final.kind === "deny") return final
|
|
14795
|
-
|
|
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);
|
|
14796
14915
|
return typeof next === "function" ? next() : { kind: "allow" };
|
|
14797
14916
|
}));
|
|
14798
14917
|
const observationRuntime = ctx;
|
|
@@ -14909,4 +15028,4 @@ function apply(ctx, config) {
|
|
|
14909
15028
|
});
|
|
14910
15029
|
}
|
|
14911
15030
|
//#endregion
|
|
14912
|
-
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 };
|
package/lib/policy-globs.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type PrerequisiteBlocker } from './lock.ts';
|
|
1
2
|
/** The three verdicts a rule may carry. */
|
|
2
3
|
export type Verdict = 'allow' | 'deny' | 'ask';
|
|
3
4
|
/**
|
|
@@ -10,6 +11,18 @@ export interface Decision {
|
|
|
10
11
|
kind: Verdict;
|
|
11
12
|
reason?: string;
|
|
12
13
|
rule?: string;
|
|
14
|
+
/**
|
|
15
|
+
* THE FACTS THE PREDICATE DECIDED FROM, when it read any. The lock-order rule resolves the
|
|
16
|
+
* artifact's prerequisites from disk, and the layer that turns this decision into a refusal
|
|
17
|
+
* needs those same facts to build the caller's recovery options. Carrying them is what makes
|
|
18
|
+
* "the guard already has them" true: a second `getPrerequisiteBlockers` call from the denial
|
|
19
|
+
* would re-read a run tree that is on disk and unlocked, and could answer differently.
|
|
20
|
+
*
|
|
21
|
+
* Absent for every rule that decides on its pattern alone, and absent when the predicate read
|
|
22
|
+
* nothing — so its presence means "this refusal was decided from these blockers", not "the
|
|
23
|
+
* policy mentions blockers".
|
|
24
|
+
*/
|
|
25
|
+
blockers?: readonly PrerequisiteBlocker[];
|
|
13
26
|
}
|
|
14
27
|
/**
|
|
15
28
|
* Extra facts a rule predicate may need. `args` is always the tool call's
|
|
@@ -49,6 +62,12 @@ export interface ToolPolicyContext {
|
|
|
49
62
|
export interface ToolPolicyPredicateMatch {
|
|
50
63
|
verdict: Verdict;
|
|
51
64
|
detail?: string;
|
|
65
|
+
/**
|
|
66
|
+
* The blockers the predicate READ, when it read any (see `Decision.blockers`). Optional and
|
|
67
|
+
* additive: a predicate that decided from something else returns none, and the engine's
|
|
68
|
+
* verdict is unchanged either way.
|
|
69
|
+
*/
|
|
70
|
+
blockers?: readonly PrerequisiteBlocker[];
|
|
52
71
|
}
|
|
53
72
|
export type ToolPolicyPredicate = (id: string, args: Record<string, unknown>, ctx: ToolPolicyContext) => ToolPolicyPredicateMatch | null;
|
|
54
73
|
export interface ToolPolicyRule {
|
|
@@ -58,6 +58,43 @@ export declare class AskValidationError extends Error {
|
|
|
58
58
|
export declare function validateAskQuestion(question: AskQuestion): AskQuestion;
|
|
59
59
|
/** Build the question for a gate, validated. */
|
|
60
60
|
export declare function buildAskQuestion(gateId: AskGateId): AskQuestion;
|
|
61
|
+
/**
|
|
62
|
+
* FU-7 — THE GATE-BLOCK REFUSAL PAYLOAD, BUILT IN ONE PLACE.
|
|
63
|
+
*
|
|
64
|
+
* A blocked lock is refused by TWO layers. The TOOL refuses when `lockArtifact` throws
|
|
65
|
+
* `Prerequisite blockers:`; the GUARD refuses pre-dispatch when the lock-order rule fires
|
|
66
|
+
* (`monotonic lock-order`), and under the strict default that is the layer the caller meets
|
|
67
|
+
* first. Both must hand the caller the SAME choice, so both build it HERE.
|
|
68
|
+
*
|
|
69
|
+
* ⚠ WHY NOT TWO LITERALS. `fix | reopen | abandon` is the human's way out of a blocked lock,
|
|
70
|
+
* and it existed in exactly one call site (`recursive_lock.tool.ts`). The guard's refusal moved
|
|
71
|
+
* to the default path, and a second hand-written copy of the options there would be a second
|
|
72
|
+
* answer to "what can a person do about this?": the day the options change, one of the two
|
|
73
|
+
* refusals keeps offering the old set and nothing fails. One builder, called from both.
|
|
74
|
+
*
|
|
75
|
+
* `blocked` is the refusal's OWN sentence — the guard's rule text or the tool's exception
|
|
76
|
+
* message — carried as data so the payload says what it is about without a reader having to
|
|
77
|
+
* match it against the text beside it.
|
|
78
|
+
*/
|
|
79
|
+
export interface GateBlockAsk extends AskQuestion {
|
|
80
|
+
gate: 'gate-block';
|
|
81
|
+
artifact: string;
|
|
82
|
+
blocked: string;
|
|
83
|
+
}
|
|
84
|
+
/** The payload, as the refusal carries it. Validated through `buildAskQuestion`. */
|
|
85
|
+
export declare function buildGateBlockAsk(artifact: string, blocked: string): GateBlockAsk;
|
|
86
|
+
/**
|
|
87
|
+
* FU-7 — THE OPTIONS AS TEXT, DERIVED FROM THE PAYLOAD rather than restated.
|
|
88
|
+
*
|
|
89
|
+
* WHY A RENDERER IS NEEDED AT ALL: the harness renders a `tools/pre-execute` denial as
|
|
90
|
+
* `Error: <reason>` and drops every other field of the decision (measured in
|
|
91
|
+
* `packages/core/tools`: `content: [{ type: 'text', text: 'Error: ' + denialReason }]`), so an
|
|
92
|
+
* ask that rode along as a SIBLING field would reach the model as nothing at all — which is
|
|
93
|
+
* exactly how a strict-by-default guard made the recovery path unreachable. The refusal
|
|
94
|
+
* therefore renders the payload into the text it hands back, and it renders THIS object, so
|
|
95
|
+
* the visible sentence and the structured payload cannot disagree.
|
|
96
|
+
*/
|
|
97
|
+
export declare function renderGateBlockAsk(ask: GateBlockAsk): string;
|
|
61
98
|
/**
|
|
62
99
|
* PHASE 0 — build the question for ANY accepted gate, including the run-start gate.
|
|
63
100
|
*
|
package/lib/runtime.d.ts
CHANGED
|
@@ -743,7 +743,7 @@ export declare class RecursiveRuntime extends Service {
|
|
|
743
743
|
code: string;
|
|
744
744
|
message: string;
|
|
745
745
|
}): boolean;
|
|
746
|
-
/** Phase C R7: resolve the enforcement config (strict|advisory, default
|
|
746
|
+
/** Phase C R7: resolve the enforcement config (strict|advisory, default strict). */
|
|
747
747
|
get enforcementConfig(): EnforcementConfig;
|
|
748
748
|
setEnforcementConfig(config: unknown): EnforcementConfig;
|
|
749
749
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@try-works/dsh-recursive-mode",
|
|
3
3
|
"description": "recursive-mode workflow as a DeepSeek Harness bundle: RecursiveRuntime service + 13 recursive_* tools (recursive_status, recursive_init, recursive_lock, recursive_lint, recursive_closeout, recursive_scratch, recursive_worktree, recursive_phase, recursive_audit_team, recursive_review, recursive_delegate, recursive_ask, recursive_preview)",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.5.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/index.d.ts",
|
|
@@ -166,7 +166,17 @@ async function main() {
|
|
|
166
166
|
const coercedAdvisory = coerceAskToDecision(askDecision, 'advisory')
|
|
167
167
|
check('T6 ask->deny under strict', coercedStrict.kind === 'deny')
|
|
168
168
|
check('T6 ask->allow+warn under advisory (never silent)', coercedAdvisory.kind === 'allow' && typeof (coercedAdvisory as { warn?: string }).warn === 'string')
|
|
169
|
-
|
|
169
|
+
// ⚠ THE CHECK THAT USED TO BE HERE WAS VACUOUS: it compared the resolver's output with
|
|
170
|
+
// `DEFAULT_ENFORCEMENT`, and both sides move together, so a change of default could never
|
|
171
|
+
// fail it. Read the default and assert the VALUE, so a revert to advisory is caught.
|
|
172
|
+
const defaultConfig = resolveEnforcementConfig(undefined)
|
|
173
|
+
check('R7 config default strict (all three gates)',
|
|
174
|
+
defaultConfig.preStep === 'strict' && defaultConfig.toolGuards === 'strict' && defaultConfig.tamper === 'strict'
|
|
175
|
+
&& JSON.stringify(defaultConfig) === JSON.stringify(DEFAULT_ENFORCEMENT))
|
|
176
|
+
// A PARTIAL section must fill the unstated gates with the same posture, not with advisory:
|
|
177
|
+
// the settings service merges ONE edited field into an entry's config.
|
|
178
|
+
const partial = resolveEnforcementConfig({ toolGuards: 'advisory' })
|
|
179
|
+
check('R7 partial config fills strict', partial.preStep === 'strict' && partial.tamper === 'strict' && partial.toolGuards === 'advisory')
|
|
170
180
|
let configError = ''
|
|
171
181
|
try { resolveEnforcementConfig({ bogus: 1 }) } catch (err) { configError = (err as Error).message }
|
|
172
182
|
check('R7 unknown config key fails', configError.includes('unknown key'))
|
package/src/config.ts
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* silently coerced a bad value would be a second, weaker contract beside the real one.
|
|
18
18
|
*/
|
|
19
19
|
import z from '@deepseek-ai/schemastery'
|
|
20
|
-
import { DEFAULT_BUDGETS } from './enforcement.ts'
|
|
20
|
+
import { DEFAULT_BUDGETS, DEFAULT_ENFORCEMENT_MODE } from './enforcement.ts'
|
|
21
21
|
|
|
22
22
|
/** One enforcement mode, as the settings form presents it. */
|
|
23
23
|
const enforcementMode = z.union([z.const('strict'), z.const('advisory')])
|
|
@@ -58,14 +58,21 @@ export const Config = z.object({
|
|
|
58
58
|
repoRoot: z.string().description(
|
|
59
59
|
'Control-plane root. Defaults to the process working directory when unset.',
|
|
60
60
|
),
|
|
61
|
+
/**
|
|
62
|
+
* ⚠ THE THREE MODE DEFAULTS READ `DEFAULT_ENFORCEMENT_MODE` FROM `enforcement.ts`, and
|
|
63
|
+
* that is deliberate: the schema default and the runtime default are the SAME value, and
|
|
64
|
+
* two literals here would be two defaults. A caller that omits the section gets
|
|
65
|
+
* `DEFAULT_ENFORCEMENT` from the runtime; a caller that supplies a partial section gets
|
|
66
|
+
* the resolver's fill. Both must be the enforcing posture — see the const for why.
|
|
67
|
+
*/
|
|
61
68
|
enforcement: z.object({
|
|
62
|
-
preStep: enforcementMode.default(
|
|
69
|
+
preStep: enforcementMode.default(DEFAULT_ENFORCEMENT_MODE).description(
|
|
63
70
|
'Phase pre-step enforcement: strict refuses an out-of-order transition, advisory warns and proceeds.',
|
|
64
71
|
),
|
|
65
|
-
toolGuards: enforcementMode.default(
|
|
72
|
+
toolGuards: enforcementMode.default(DEFAULT_ENFORCEMENT_MODE).description(
|
|
66
73
|
'Tool guard mode: strict DENIES an out-of-order tool call, advisory allows it and carries the warning.',
|
|
67
74
|
),
|
|
68
|
-
tamper: enforcementMode.default(
|
|
75
|
+
tamper: enforcementMode.default(DEFAULT_ENFORCEMENT_MODE).description(
|
|
69
76
|
'Tamper detection: strict refuses an artifact whose LockHash no longer matches its body.',
|
|
70
77
|
),
|
|
71
78
|
budgets: z.object({
|
package/src/enforcement.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* (Phase C R3/R4/R7/R8, PROPOSAL 8.4/8.6/13.5).
|
|
4
4
|
*
|
|
5
5
|
* Layer 2 (tool guards) and Layer 8 (tamper) are the remaining enforcement
|
|
6
|
-
* layers. Configurable strict|advisory per gate (default
|
|
6
|
+
* layers. Configurable strict|advisory per gate (default strict).
|
|
7
7
|
*/
|
|
8
8
|
import { existsSync, readdirSync } from 'node:fs'
|
|
9
9
|
import { join, isAbsolute, resolve, sep } from 'node:path'
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
type ToolPolicy, type ToolPolicyContext, type Decision as PolicyDecision,
|
|
16
16
|
} from './policy-globs.ts'
|
|
17
17
|
import { withPhaseBaseline, phaseNumberForArtifact, resolveFrom } from './phase-rules.ts'
|
|
18
|
+
import { buildGateBlockAsk, type GateBlockAsk } from './recursive_ask.tool.ts'
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
21
|
* The BUILT-IN default rule list (T16) is defined in `src/policy-globs.ts`,
|
|
@@ -75,6 +76,29 @@ const BUDGET_KEYS: ReadonlyArray<keyof BudgetConfig> = [
|
|
|
75
76
|
'maxAuditRounds', 'maxRepairAttempts', 'maxDelegationDepth', 'maxChildrenPerPhase', 'maxResultBytes',
|
|
76
77
|
]
|
|
77
78
|
|
|
79
|
+
/**
|
|
80
|
+
* THE DEFAULT POSTURE: STRICT, on all three gates — and this const is the ONE literal.
|
|
81
|
+
*
|
|
82
|
+
* The owner's rule is *only one phase may be active at a time, and the phases should be
|
|
83
|
+
* sequential and the active phase must be locked before proceeding to next phase*. In
|
|
84
|
+
* `advisory` that rule is only WARNED about, and a live run showed what that costs: the
|
|
85
|
+
* run ignored the lock chain for over an hour, wrote phase 8 before phase 1.5 and locked
|
|
86
|
+
* nothing (twelve DRAFT artifacts, one operations entry). Strict was previously unsafe as
|
|
87
|
+
* a default because it also refused the run's OWN artifacts — a false positive. That was
|
|
88
|
+
* fixed, and `tests/strict-run-tree.spec.ts` now walks all twelve phases asserting the
|
|
89
|
+
* active artifact stays writable while a later one is refused. Strict therefore refuses
|
|
90
|
+
* exactly the ordering violations it is meant to refuse, so the default is the enforcing
|
|
91
|
+
* posture rather than a warning nobody has to act on.
|
|
92
|
+
*
|
|
93
|
+
* ⚠ WHY IT IS A NAMED CONST AND NOT THREE LITERALS. A default restated per site is this
|
|
94
|
+
* project's recurring failure: the same value exists in the Config schema, in
|
|
95
|
+
* `DEFAULT_ENFORCEMENT`, in an omitted config section, and in the parameter defaults of
|
|
96
|
+
* the helpers below, and moving only some of them leaves a caller that "still gets
|
|
97
|
+
* advisory". Every one of those sites now reads THIS const, so a revert is a one-line
|
|
98
|
+
* change and nothing can drift from it.
|
|
99
|
+
*/
|
|
100
|
+
export const DEFAULT_ENFORCEMENT_MODE: EnforcementMode = 'strict'
|
|
101
|
+
|
|
78
102
|
/**
|
|
79
103
|
* Validate the enforcement config shape (unknown keys fail at plugin load).
|
|
80
104
|
*
|
|
@@ -89,7 +113,17 @@ export function resolveEnforcementConfig(config: unknown): EnforcementConfig {
|
|
|
89
113
|
if (unknown.length > 0) {
|
|
90
114
|
throw new Error('EnforcementConfig has unknown key(s) ' + unknown.join(', ') + ' - config is { preStep, toolGuards, tamper, budgets }')
|
|
91
115
|
}
|
|
92
|
-
|
|
116
|
+
// ⚠ AN ABSENT MODE RESOLVES TO THE DEFAULT MODE, not to the permissive branch. This is
|
|
117
|
+
// the twin-default trap in its most consequential form: a caller that supplies a PARTIAL
|
|
118
|
+
// section — `enforcement: { toolGuards: 'advisory' }` from a settings patch, or just the
|
|
119
|
+
// budgets — leaves the other gates unstated, and filling those with `advisory` would
|
|
120
|
+
// hand back a config that is looser than the plugin's own default with nothing saying so.
|
|
121
|
+
// An UNRECOGNIZED value resolves the same way and thus fails CLOSED. The Config schema in
|
|
122
|
+
// src/config.ts still rejects a typo loudly at the settings boundary; this resolver is
|
|
123
|
+
// the lenient one, and a lenient resolver must bend towards the safe posture: a typo that
|
|
124
|
+
// blocks is a visible stop, a typo that permits is the hour-long out-of-order run again.
|
|
125
|
+
const mode = (value: unknown): EnforcementMode =>
|
|
126
|
+
value === 'strict' ? 'strict' : value === 'advisory' ? 'advisory' : DEFAULT_ENFORCEMENT_MODE
|
|
93
127
|
|
|
94
128
|
const rawBudgets = (raw.budgets ?? {}) as Record<string, unknown>
|
|
95
129
|
if (typeof raw.budgets !== 'undefined' && (raw.budgets === null || typeof raw.budgets !== 'object')) {
|
|
@@ -117,10 +151,16 @@ export function resolveEnforcementConfig(config: unknown): EnforcementConfig {
|
|
|
117
151
|
}
|
|
118
152
|
}
|
|
119
153
|
|
|
154
|
+
/**
|
|
155
|
+
* The runtime default: what a caller gets when it supplies no `enforcement` section at all
|
|
156
|
+
* (a profile mounting this plugin with no config, e.g. `preset/recursive.patch.yml`). It is
|
|
157
|
+
* `DEFAULT_ENFORCEMENT_MODE` per gate, so this object and the resolver cannot disagree —
|
|
158
|
+
* see that const for WHY the default is strict.
|
|
159
|
+
*/
|
|
120
160
|
export const DEFAULT_ENFORCEMENT: EnforcementConfig = {
|
|
121
|
-
preStep:
|
|
122
|
-
toolGuards:
|
|
123
|
-
tamper:
|
|
161
|
+
preStep: DEFAULT_ENFORCEMENT_MODE,
|
|
162
|
+
toolGuards: DEFAULT_ENFORCEMENT_MODE,
|
|
163
|
+
tamper: DEFAULT_ENFORCEMENT_MODE,
|
|
124
164
|
budgets: DEFAULT_BUDGETS,
|
|
125
165
|
}
|
|
126
166
|
|
|
@@ -153,10 +193,16 @@ export interface GuardTransition {
|
|
|
153
193
|
* optional — `coerceAskToDecision` is asserted with `toEqual({ kind: ... })`
|
|
154
194
|
* (an EXACT match) in tests/enforcement.spec.ts, so the coercion path may never
|
|
155
195
|
* grow extra keys. `evaluateToolGuard` itself always sets `rule`.
|
|
196
|
+
*
|
|
197
|
+
* ⚠ FU-7: `ask` IS OPTIONAL AND ADDITIVE TOO, for the same reason and one more. A refusal that a
|
|
198
|
+
* PERSON has to resolve carries the gate-block decision alongside its sentence (see `verdictFor`),
|
|
199
|
+
* and the payload is built by `buildGateBlockAsk` — the SAME builder the lock tool uses, so the two
|
|
200
|
+
* refusals cannot offer different options. It is absent on every decision that is not a lock-order
|
|
201
|
+
* refusal decided from real blockers, which is why every reader must treat it as optional.
|
|
156
202
|
*/
|
|
157
203
|
export type ToolGuardDecision =
|
|
158
204
|
| { kind: 'allow'; warn?: string; rule?: GuardRule; transition?: GuardTransition }
|
|
159
|
-
| { kind: 'deny'; reason: string; rule?: GuardRule; transition?: GuardTransition }
|
|
205
|
+
| { kind: 'deny'; reason: string; rule?: GuardRule; transition?: GuardTransition; ask?: GateBlockAsk }
|
|
160
206
|
| { kind: 'ask'; reason?: string; rule?: GuardRule; transition?: GuardTransition }
|
|
161
207
|
|
|
162
208
|
export interface ToolExecLike {
|
|
@@ -249,11 +295,23 @@ export function currentPhaseArtifact(worktreeRoot: string, runId: string): strin
|
|
|
249
295
|
return inForce !== '' ? inForce : best
|
|
250
296
|
}
|
|
251
297
|
|
|
298
|
+
/**
|
|
299
|
+
* `mode` is the gate's configured posture. Its parameter default FOLLOWS the config
|
|
300
|
+
* default by REFERENCE (`DEFAULT_ENFORCEMENT.toolGuards`) rather than repeating the
|
|
301
|
+
* literal: a bare call is "the caller had no mode to hand", and the answer to that must
|
|
302
|
+
* be the same posture the config would have produced. Two literals are two defaults, and
|
|
303
|
+
* a helper left on the old `advisory` literal while the config moved to `strict` is
|
|
304
|
+
* exactly the twin-default hole this change closes — a caller that forgot the argument
|
|
305
|
+
* would silently get the permissive branch, which no config could then undo. Every
|
|
306
|
+
* production call site passes the mode explicitly (`index.ts` `runToolGuard`,
|
|
307
|
+
* `runtime.ts` `guardTool`, the preview tool); this default serves bare callers, and a
|
|
308
|
+
* bare caller must not be the one place enforcement quietly turns itself off.
|
|
309
|
+
*/
|
|
252
310
|
export function evaluateToolGuard(
|
|
253
311
|
exec: ToolExecLike,
|
|
254
312
|
worktreeRoot: string,
|
|
255
313
|
activeRunId: string,
|
|
256
|
-
mode: EnforcementMode =
|
|
314
|
+
mode: EnforcementMode = DEFAULT_ENFORCEMENT.toolGuards,
|
|
257
315
|
): ToolGuardDecision {
|
|
258
316
|
const name = exec.name
|
|
259
317
|
const args = (exec.arguments ?? {}) as Record<string, unknown>
|
|
@@ -276,7 +334,7 @@ export function evaluateToolGuard(
|
|
|
276
334
|
const policy = resolveToolPolicyForGuard(worktreeRoot, runId, activePhaseArtifact)
|
|
277
335
|
const context: ToolPolicyContext = { args, runDir, runId, worktreeRoot, activePhaseArtifact }
|
|
278
336
|
const decision = evaluateToolPolicy(policy, name, args, context)
|
|
279
|
-
return advisory(verdictFor(mode, decision), transition)
|
|
337
|
+
return advisory(verdictFor(mode, decision, String(args.artifact ?? '')), transition)
|
|
280
338
|
}
|
|
281
339
|
|
|
282
340
|
/**
|
|
@@ -284,12 +342,35 @@ export function evaluateToolGuard(
|
|
|
284
342
|
* `advisory` asks (the pre-T16 wording, unchanged), `allow` stays an allow. The
|
|
285
343
|
* decision's `rule` is the label of the rule that decided it, so a policy
|
|
286
344
|
* verdict is traceable to an auditable line in the policy file.
|
|
345
|
+
*
|
|
346
|
+
* ⚠ FU-7 — THE ORDERING REFUSAL CARRIES THE HUMAN'S CHOICE. `fix | reopen | abandon` is how a
|
|
347
|
+
* person unblocks a lock, and before this the ask was attached ONLY by `recursive_lock`'s own
|
|
348
|
+
* catch — the branch that runs when the guard ABSTAINS. Under the strict default the guard
|
|
349
|
+
* refuses a lock ahead of its prerequisites BEFORE dispatch, so that branch never ran on the
|
|
350
|
+
* default path and the caller got a bare sentence: the recovery options existed in the code and
|
|
351
|
+
* were unreachable in the product, which is worse than the advisory posture they replaced (an
|
|
352
|
+
* advisory `ask` at least surfaced the reason).
|
|
353
|
+
*
|
|
354
|
+
* THE TRIGGER IS THE BLOCKERS, NOT THE LABEL. `PolicyDecision.blockers` is present exactly when a
|
|
355
|
+
* predicate read prerequisite blockers from disk and they were non-empty, so gating on it means
|
|
356
|
+
* "this refusal was decided from an ordering violation" — including a policy FILE whose
|
|
357
|
+
* `recursive_lock*` deny carries no label (the file-authored rule is given the same condition by
|
|
358
|
+
* `attachPolicyPredicate`, and its `rule` would otherwise read `none`). Nothing is recomputed
|
|
359
|
+
* here: the blockers arrive from the rule that already resolved them.
|
|
360
|
+
*
|
|
361
|
+
* IT IS ATTACHED TO THE REFUSAL ONLY. Under `advisory` the same verdict becomes an `ask` that the
|
|
362
|
+
* live path coerces to an allow-with-warning, and the tool then refuses with its OWN payload when
|
|
363
|
+
* `lockArtifact` throws — so an ask attached here would be a claim about a refusal that this layer
|
|
364
|
+
* did not make. One refusal, one ask.
|
|
287
365
|
*/
|
|
288
|
-
function verdictFor(mode: EnforcementMode, decision: PolicyDecision): ToolGuardDecision {
|
|
366
|
+
function verdictFor(mode: EnforcementMode, decision: PolicyDecision, artifact: string): ToolGuardDecision {
|
|
289
367
|
const rule = (decision.rule ?? 'none') as GuardRule
|
|
290
368
|
if (decision.kind === 'allow') return { kind: 'allow', rule }
|
|
291
369
|
const reason = decision.reason ?? 'tool policy denied this call'
|
|
292
|
-
|
|
370
|
+
if (mode !== 'strict') return { kind: 'ask', reason, rule }
|
|
371
|
+
const blocked = decision.blockers
|
|
372
|
+
if (blocked === undefined || blocked.length === 0) return { kind: 'deny', reason, rule }
|
|
373
|
+
return { kind: 'deny', reason, rule, ask: buildGateBlockAsk(artifact, reason) }
|
|
293
374
|
}
|
|
294
375
|
|
|
295
376
|
/**
|
|
@@ -337,10 +418,17 @@ function advisory(decision: ToolGuardDecision, transition: GateCheckResult | und
|
|
|
337
418
|
if (transition.passed) return { ...decision, transition }
|
|
338
419
|
// The verdict stays `allow`; the gate only names itself as the dissenting
|
|
339
420
|
// voice, so an advisory pass is never silent.
|
|
421
|
+
//
|
|
422
|
+
// ⚠ FU-7 (the log fix) — "REPORT-ONLY", NOT "advisory". This sentence travels into the guard's
|
|
423
|
+
// log line and into the decision a caller reads, and `advisory` there named the GATE's posture
|
|
424
|
+
// — not the configured mode — while the line around it said `tool guard (advisory)`. Under the
|
|
425
|
+
// strict default a reader was told enforcement was off while every gate was strict. What is
|
|
426
|
+
// actually true of this gate in BOTH modes is that it reports and never changes the verdict, so
|
|
427
|
+
// that is what it now says. The warn-on-allow semantics are untouched.
|
|
340
428
|
return {
|
|
341
429
|
...decision,
|
|
342
430
|
rule: 'transition',
|
|
343
|
-
warn: 'transition gate (
|
|
431
|
+
warn: 'transition gate (report-only) failed: ' + transition.failures.join('; '),
|
|
344
432
|
transition,
|
|
345
433
|
}
|
|
346
434
|
}
|
|
@@ -350,8 +438,23 @@ function advisory(decision: ToolGuardDecision, transition: GateCheckResult | und
|
|
|
350
438
|
* allow. Under `strict` it coerces to `deny`; under `advisory` it stays `allow`
|
|
351
439
|
* but flags a `warn` so the caller never lets it through unlogged. Non-ask
|
|
352
440
|
* decisions pass through unchanged.
|
|
441
|
+
*
|
|
442
|
+
* ⚠ THE `mode` DEFAULT IS DELIBERATE, and it is NOT a neutral fallback — there is no
|
|
443
|
+
* neutral branch here. The domain is two postures, one of which ALLOWS the call, so
|
|
444
|
+
* "unspecified" has to be resolved rather than left open, and this codebase's rule for an
|
|
445
|
+
* undecidable path is to fail CLOSED (`index.ts`: *"we could not decide" is not
|
|
446
|
+
* permission*). It therefore FOLLOWS the config default by REFERENCE
|
|
447
|
+
* (`DEFAULT_ENFORCEMENT.toolGuards`), for the same reason as `evaluateToolGuard`'s: an
|
|
448
|
+
* `advisory` literal here would be a second, hidden copy of the old default inside the
|
|
449
|
+
* very module this change moves, and a future caller that omitted the argument would
|
|
450
|
+
* re-open the permissive path with no config able to close it. The production call site
|
|
451
|
+
* (`index.ts` `runToolGuard`) always passes the configured mode, so this changes no live
|
|
452
|
+
* behaviour — it removes the last place where "we were not told" meant "allow".
|
|
353
453
|
*/
|
|
354
|
-
export function coerceAskToDecision(
|
|
454
|
+
export function coerceAskToDecision(
|
|
455
|
+
decision: ToolGuardDecision,
|
|
456
|
+
mode: EnforcementMode = DEFAULT_ENFORCEMENT.toolGuards,
|
|
457
|
+
): ToolGuardDecision {
|
|
355
458
|
if (decision.kind !== 'ask') return decision
|
|
356
459
|
if (mode === 'strict') {
|
|
357
460
|
return { kind: 'deny', reason: decision.reason ?? 'ask under strict enforcement denies' }
|
package/src/guard-log.ts
CHANGED
|
@@ -22,11 +22,17 @@
|
|
|
22
22
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
23
23
|
import { dirname, join } from 'node:path'
|
|
24
24
|
import type { GuardRule } from './enforcement.ts'
|
|
25
|
+
import type { GateBlockAsk } from './recursive_ask.tool.ts'
|
|
25
26
|
|
|
26
27
|
/**
|
|
27
28
|
* One logged guard decision (the JSONL record shape the board/tests read).
|
|
28
29
|
* `rule` is always set (the guard's own machine-readable reason for the
|
|
29
30
|
* verdict); `transition` is present whenever the transition gate was consulted.
|
|
31
|
+
*
|
|
32
|
+
* FU-7: a REFUSAL that a person has to resolve also carries `ask` — the gate-block decision, in the
|
|
33
|
+
* same shape `recursive_lock` attaches to its own refusal. It is recorded because the log is where
|
|
34
|
+
* "why was this lock refused?" is answered, and the options are the other half of that answer; a
|
|
35
|
+
* caller (or a board) reading the trace can act on the refusal without parsing the sentence.
|
|
30
36
|
*/
|
|
31
37
|
export interface GuardDecisionRecord {
|
|
32
38
|
at: string
|
|
@@ -36,6 +42,7 @@ export interface GuardDecisionRecord {
|
|
|
36
42
|
rule: GuardRule
|
|
37
43
|
reason?: string
|
|
38
44
|
transition?: { passed: boolean; failures: string[] }
|
|
45
|
+
ask?: GateBlockAsk
|
|
39
46
|
}
|
|
40
47
|
|
|
41
48
|
/** One logged observed-write tamper (a LOCKED artifact whose hash no longer matches). */
|
package/src/index.ts
CHANGED
|
@@ -22,6 +22,7 @@ import { createRecursiveAuditTeamTool } from './recursive_audit_team.tool.ts'
|
|
|
22
22
|
import { createRecursiveReviewTool } from './recursive_review.tool.ts'
|
|
23
23
|
import { createRecursiveDelegateTool } from './recursive_delegate.tool.ts'
|
|
24
24
|
import { createRecursiveAskTool } from './recursive_ask.tool.ts'
|
|
25
|
+
import { renderGateBlockAsk } from './recursive_ask.tool.ts'
|
|
25
26
|
import { createRecursivePreviewTool } from './recursive_preview.tool.ts'
|
|
26
27
|
import type { SubagentsRuntimeLike } from './delegation.ts'
|
|
27
28
|
import { registerRecursiveCommand } from './commands.ts'
|
|
@@ -187,6 +188,9 @@ function runToolGuard(
|
|
|
187
188
|
record.reason = final.reason
|
|
188
189
|
}
|
|
189
190
|
if (final.transition) record.transition = final.transition
|
|
191
|
+
// FU-7: a refusal a person has to resolve carries its options into the trace too, so the
|
|
192
|
+
// question "why was this lock refused?" and the answer to it are read from one record.
|
|
193
|
+
if (final.kind === 'deny' && final.ask) record.ask = final.ask
|
|
190
194
|
appendGuardDecision(root, record)
|
|
191
195
|
}
|
|
192
196
|
return final
|
|
@@ -512,10 +516,29 @@ export function apply(ctx: Context, config?: RecursiveModeConfig) {
|
|
|
512
516
|
return { kind: 'deny', reason: 'the tool guard produced no decision' }
|
|
513
517
|
}
|
|
514
518
|
// The guard's own object, returned VERBATIM — the pinned contract.
|
|
515
|
-
|
|
519
|
+
//
|
|
520
|
+
// ⚠ EXCEPT THAT A DENIAL'S `ask` MUST BE CARRIED IN THE TEXT, and this is the one place the
|
|
521
|
+
// plugin can do it. Measured in the harness (`packages/core/tools`): a `tools/pre-execute`
|
|
522
|
+
// deny becomes `content: [{ type: 'text', text: 'Error: ' + reason }]` and EVERY other field
|
|
523
|
+
// of the decision is dropped, so the gate-block payload added for FU-7 would have reached the
|
|
524
|
+
// model as nothing at all — which is precisely the defect: a strict-by-default guard refusing
|
|
525
|
+
// a lock with a bare sentence, while `fix | reopen | abandon` was how the run got unblocked.
|
|
526
|
+
// The sentence is rendered FROM the payload (`renderGateBlockAsk`), so what the caller reads
|
|
527
|
+
// and what the decision carries cannot drift; the plain reason stays first and intact, so a
|
|
528
|
+
// caller that ignores the ask still gets the rule name and the blocking artifact.
|
|
529
|
+
if (final.kind === 'deny') {
|
|
530
|
+
return final.ask === undefined
|
|
531
|
+
? final
|
|
532
|
+
: { ...final, reason: final.reason + ' ' + renderGateBlockAsk(final.ask) }
|
|
533
|
+
}
|
|
516
534
|
if (final.kind === 'allow' && final.warn) {
|
|
517
|
-
//
|
|
518
|
-
|
|
535
|
+
// ⚠ IT NAMES THE MODE IT ACTUALLY RAN UNDER. This line used to begin `tool guard (advisory)`
|
|
536
|
+
// unconditionally, while the warning it carries comes from the transition gate's REPORT-ONLY
|
|
537
|
+
// consult — which attaches a warning to an ALLOW in BOTH modes. Under the strict default the
|
|
538
|
+
// line therefore told a reader that enforcement was off while every gate was strict: text
|
|
539
|
+
// asserting a state that was not so. The mode is read from the same config the guard ran
|
|
540
|
+
// under, so the prefix moves with the setting; the warn semantics are unchanged.
|
|
541
|
+
console.warn('[recursive] tool guard (' + recursive.enforcementConfig.toolGuards + ') allowed this call: ' + final.warn)
|
|
519
542
|
}
|
|
520
543
|
return typeof next === 'function' ? next() : { kind: 'allow' }
|
|
521
544
|
}))
|
package/src/policy-globs.ts
CHANGED
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
*/
|
|
42
42
|
import { existsSync, readFileSync } from 'node:fs'
|
|
43
43
|
import { basename, dirname, join, resolve } from 'node:path'
|
|
44
|
-
import { getLockStatus, getPrerequisiteBlockers } from './lock.ts'
|
|
44
|
+
import { getLockStatus, getPrerequisiteBlockers, type PrerequisiteBlocker } from './lock.ts'
|
|
45
45
|
import { getMdFieldValue } from './status.ts'
|
|
46
46
|
import { phaseNumberForArtifact, policyTargetPath, resolveFrom } from './phase-rules.ts'
|
|
47
47
|
|
|
@@ -58,6 +58,18 @@ export interface Decision {
|
|
|
58
58
|
kind: Verdict
|
|
59
59
|
reason?: string
|
|
60
60
|
rule?: string
|
|
61
|
+
/**
|
|
62
|
+
* THE FACTS THE PREDICATE DECIDED FROM, when it read any. The lock-order rule resolves the
|
|
63
|
+
* artifact's prerequisites from disk, and the layer that turns this decision into a refusal
|
|
64
|
+
* needs those same facts to build the caller's recovery options. Carrying them is what makes
|
|
65
|
+
* "the guard already has them" true: a second `getPrerequisiteBlockers` call from the denial
|
|
66
|
+
* would re-read a run tree that is on disk and unlocked, and could answer differently.
|
|
67
|
+
*
|
|
68
|
+
* Absent for every rule that decides on its pattern alone, and absent when the predicate read
|
|
69
|
+
* nothing — so its presence means "this refusal was decided from these blockers", not "the
|
|
70
|
+
* policy mentions blockers".
|
|
71
|
+
*/
|
|
72
|
+
blockers?: readonly PrerequisiteBlocker[]
|
|
61
73
|
}
|
|
62
74
|
|
|
63
75
|
/**
|
|
@@ -99,6 +111,12 @@ export interface ToolPolicyContext {
|
|
|
99
111
|
export interface ToolPolicyPredicateMatch {
|
|
100
112
|
verdict: Verdict
|
|
101
113
|
detail?: string
|
|
114
|
+
/**
|
|
115
|
+
* The blockers the predicate READ, when it read any (see `Decision.blockers`). Optional and
|
|
116
|
+
* additive: a predicate that decided from something else returns none, and the engine's
|
|
117
|
+
* verdict is unchanged either way.
|
|
118
|
+
*/
|
|
119
|
+
blockers?: readonly PrerequisiteBlocker[]
|
|
102
120
|
}
|
|
103
121
|
|
|
104
122
|
export type ToolPolicyPredicate = (
|
|
@@ -338,7 +356,7 @@ export function evaluateToolPolicy(
|
|
|
338
356
|
// Abstention: this rule does not govern this call, so it does not
|
|
339
357
|
// participate and the next rule in precedence order decides.
|
|
340
358
|
if (match === null) continue
|
|
341
|
-
return decide(rule, match.verdict, match.detail ? rule.reason + ' ' + match.detail : rule.reason)
|
|
359
|
+
return decide(rule, match.verdict, match.detail ? rule.reason + ' ' + match.detail : rule.reason, match.blockers)
|
|
342
360
|
}
|
|
343
361
|
return decide(rule, rule.verdict, rule.reason)
|
|
344
362
|
}
|
|
@@ -347,10 +365,17 @@ export function evaluateToolPolicy(
|
|
|
347
365
|
return { kind: 'ask', reason: 'no policy rule matches ' + id + ' - ask is the no-match default' }
|
|
348
366
|
}
|
|
349
367
|
|
|
350
|
-
/**
|
|
351
|
-
|
|
368
|
+
/**
|
|
369
|
+
* One place where a rule's verdict becomes a decision, so `label` cannot drift.
|
|
370
|
+
*
|
|
371
|
+
* `blockers` rides along untouched when the predicate supplied any (see `Decision.blockers`);
|
|
372
|
+
* an EMPTY list is dropped rather than carried, so `blockers` on a decision always means "there
|
|
373
|
+
* were blockers", never "the rule looked and found none".
|
|
374
|
+
*/
|
|
375
|
+
function decide(rule: ToolPolicyRule, kind: Verdict, reason: string, blockers?: readonly PrerequisiteBlocker[]): Decision {
|
|
352
376
|
const decision: Decision = { kind, reason }
|
|
353
377
|
if (rule.label) decision.rule = rule.label
|
|
378
|
+
if (blockers !== undefined && blockers.length > 0) decision.blockers = blockers
|
|
354
379
|
return decision
|
|
355
380
|
}
|
|
356
381
|
|
|
@@ -425,7 +450,14 @@ function lockOrderRule(artifact: unknown, runDir: string | undefined): ToolPolic
|
|
|
425
450
|
if (!name || !runDir) return null
|
|
426
451
|
const blockers = getPrerequisiteBlockers(runDir, name)
|
|
427
452
|
if (blockers.length === 0) return null
|
|
428
|
-
return {
|
|
453
|
+
return {
|
|
454
|
+
verdict: 'deny',
|
|
455
|
+
detail: blockers.map((b) => b.artifact + ' (' + b.status + ')').join(', '),
|
|
456
|
+
// THE SAME READ, carried up rather than thrown away: the denial's recovery options are built
|
|
457
|
+
// from these blockers, and re-deriving them one layer higher would be a second filesystem
|
|
458
|
+
// answer to a question this rule has already answered.
|
|
459
|
+
blockers,
|
|
460
|
+
}
|
|
429
461
|
}
|
|
430
462
|
|
|
431
463
|
/**
|
|
@@ -170,6 +170,54 @@ export function buildAskQuestion(gateId: AskGateId): AskQuestion {
|
|
|
170
170
|
})
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
+
/**
|
|
174
|
+
* FU-7 — THE GATE-BLOCK REFUSAL PAYLOAD, BUILT IN ONE PLACE.
|
|
175
|
+
*
|
|
176
|
+
* A blocked lock is refused by TWO layers. The TOOL refuses when `lockArtifact` throws
|
|
177
|
+
* `Prerequisite blockers:`; the GUARD refuses pre-dispatch when the lock-order rule fires
|
|
178
|
+
* (`monotonic lock-order`), and under the strict default that is the layer the caller meets
|
|
179
|
+
* first. Both must hand the caller the SAME choice, so both build it HERE.
|
|
180
|
+
*
|
|
181
|
+
* ⚠ WHY NOT TWO LITERALS. `fix | reopen | abandon` is the human's way out of a blocked lock,
|
|
182
|
+
* and it existed in exactly one call site (`recursive_lock.tool.ts`). The guard's refusal moved
|
|
183
|
+
* to the default path, and a second hand-written copy of the options there would be a second
|
|
184
|
+
* answer to "what can a person do about this?": the day the options change, one of the two
|
|
185
|
+
* refusals keeps offering the old set and nothing fails. One builder, called from both.
|
|
186
|
+
*
|
|
187
|
+
* `blocked` is the refusal's OWN sentence — the guard's rule text or the tool's exception
|
|
188
|
+
* message — carried as data so the payload says what it is about without a reader having to
|
|
189
|
+
* match it against the text beside it.
|
|
190
|
+
*/
|
|
191
|
+
export interface GateBlockAsk extends AskQuestion {
|
|
192
|
+
gate: 'gate-block'
|
|
193
|
+
artifact: string
|
|
194
|
+
blocked: string
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** The payload, as the refusal carries it. Validated through `buildAskQuestion`. */
|
|
198
|
+
export function buildGateBlockAsk(artifact: string, blocked: string): GateBlockAsk {
|
|
199
|
+
return { gate: 'gate-block', ...buildAskQuestion('gate-block'), artifact, blocked }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* FU-7 — THE OPTIONS AS TEXT, DERIVED FROM THE PAYLOAD rather than restated.
|
|
204
|
+
*
|
|
205
|
+
* WHY A RENDERER IS NEEDED AT ALL: the harness renders a `tools/pre-execute` denial as
|
|
206
|
+
* `Error: <reason>` and drops every other field of the decision (measured in
|
|
207
|
+
* `packages/core/tools`: `content: [{ type: 'text', text: 'Error: ' + denialReason }]`), so an
|
|
208
|
+
* ask that rode along as a SIBLING field would reach the model as nothing at all — which is
|
|
209
|
+
* exactly how a strict-by-default guard made the recovery path unreachable. The refusal
|
|
210
|
+
* therefore renders the payload into the text it hands back, and it renders THIS object, so
|
|
211
|
+
* the visible sentence and the structured payload cannot disagree.
|
|
212
|
+
*/
|
|
213
|
+
export function renderGateBlockAsk(ask: GateBlockAsk): string {
|
|
214
|
+
const options = ask.options
|
|
215
|
+
.map((option) => option.label + (option.description === undefined ? '' : ' (' + option.description + ')'))
|
|
216
|
+
.join(' ')
|
|
217
|
+
const target = ask.artifact === '' ? 'recursive_ask gate=gate-block' : 'recursive_ask gate=gate-block artifact=' + ask.artifact
|
|
218
|
+
return ask.header + ': ' + ask.question + ' Options: ' + options + ' Answer with ' + target + '.'
|
|
219
|
+
}
|
|
220
|
+
|
|
173
221
|
/**
|
|
174
222
|
* PHASE 0 — build the question for ANY accepted gate, including the run-start gate.
|
|
175
223
|
*
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
2
2
|
import { codeRuntimeRefusal, toolError } from './errors.ts'
|
|
3
|
-
import {
|
|
3
|
+
import { buildGateBlockAsk } from './recursive_ask.tool.ts'
|
|
4
4
|
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
|
5
5
|
import type { RecursiveRuntime } from './runtime.ts'
|
|
6
6
|
|
|
@@ -37,10 +37,16 @@ export function createRecursiveLockTool(recursive: RecursiveRuntime) {
|
|
|
37
37
|
// ⚠ IT IS ATTACHED TO THE ORDERING REFUSAL SPECIFICALLY (`Prerequisite blockers:`), because that
|
|
38
38
|
// is the one a human resolves. A missing run id or an already-locked artifact is a caller mistake
|
|
39
39
|
// with a mechanical fix, and offering "reopen / abandon the run" for those would be noise.
|
|
40
|
+
//
|
|
41
|
+
// ⚠ AND THE PAYLOAD COMES FROM `buildGateBlockAsk`, NOT FROM A LITERAL HERE. The GUARD refuses
|
|
42
|
+
// the same ordering violation pre-dispatch (it resolves the same blockers from the same run
|
|
43
|
+
// tree) and attaches this same payload; one builder is what keeps the two refusals offering the
|
|
44
|
+
// same options. This branch still fires whenever the guard ABSTAINS — most visibly when the call
|
|
45
|
+
// names a run other than the active one, because the guard resolves the run from the filesystem.
|
|
40
46
|
if (message.startsWith('Prerequisite blockers:')) {
|
|
41
47
|
return {
|
|
42
48
|
error: refusal,
|
|
43
|
-
ask:
|
|
49
|
+
ask: buildGateBlockAsk(args.artifact ?? '', message),
|
|
44
50
|
} as unknown as JsonValue
|
|
45
51
|
}
|
|
46
52
|
return { error: refusal } as const
|
package/src/runtime.ts
CHANGED
|
@@ -1881,7 +1881,7 @@ export class RecursiveRuntime extends Service {
|
|
|
1881
1881
|
return coupleGateBlockToGoal(goalService as never, agent, ref, reason)
|
|
1882
1882
|
}
|
|
1883
1883
|
|
|
1884
|
-
/** Phase C R7: resolve the enforcement config (strict|advisory, default
|
|
1884
|
+
/** Phase C R7: resolve the enforcement config (strict|advisory, default strict). */
|
|
1885
1885
|
get enforcementConfig(): EnforcementConfig {
|
|
1886
1886
|
return this._enforcementConfig ?? DEFAULT_ENFORCEMENT
|
|
1887
1887
|
}
|