@try-works/dsh-recursive-mode 0.4.9 → 0.6.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/README.md +21 -8
- package/lib/config.d.ts +14 -0
- package/lib/enforcement.d.ts +118 -0
- package/lib/guard-log.d.ts +7 -0
- package/lib/index.js +568 -133
- package/lib/memory.d.ts +35 -2
- package/lib/phase-rules.d.ts +102 -0
- package/lib/policy-globs.d.ts +24 -0
- package/lib/recursive_ask.tool.d.ts +37 -0
- package/lib/runtime.d.ts +1 -1
- package/lib/training.d.ts +240 -2
- package/package.json +1 -1
- package/references/artifact-template.md +26 -61
- package/references/bodies/claude.md +1 -1
- package/references/bodies/copilot.md +1 -1
- package/references/bodies/cursorrules.md +4 -2
- package/references/bodies/memory-router.md +1 -1
- package/references/bodies/recursive-agents-router.md +4 -3
- package/references/bootstrap/RECURSIVE.md +21 -30
- package/scripts/test-recursive-mode-smoke.ts +11 -1
- package/src/bootstrap.ts +30 -14
- package/src/config.ts +11 -4
- package/src/enforcement.ts +202 -15
- package/src/guard-log.ts +7 -0
- package/src/index.ts +795 -700
- package/src/memory.ts +50 -9
- package/src/phase-rules.ts +162 -1
- package/src/policy-globs.ts +64 -8
- package/src/recursive_ask.tool.ts +48 -0
- package/src/recursive_lock.tool.ts +8 -2
- package/src/runtime.ts +7 -4
- package/src/training.ts +634 -6
package/src/enforcement.ts
CHANGED
|
@@ -3,11 +3,12 @@
|
|
|
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'
|
|
10
10
|
import { getLockStatus } from './lock.ts'
|
|
11
|
+
import { runIdProblem } from './run-id.ts'
|
|
11
12
|
import { validateTransition, type GateCheckResult } from './lifecycle.ts'
|
|
12
13
|
import {
|
|
13
14
|
evaluateToolPolicy, loadToolPolicyFile,
|
|
@@ -15,6 +16,7 @@ import {
|
|
|
15
16
|
type ToolPolicy, type ToolPolicyContext, type Decision as PolicyDecision,
|
|
16
17
|
} from './policy-globs.ts'
|
|
17
18
|
import { withPhaseBaseline, phaseNumberForArtifact, resolveFrom } from './phase-rules.ts'
|
|
19
|
+
import { buildGateBlockAsk, type GateBlockAsk } from './recursive_ask.tool.ts'
|
|
18
20
|
|
|
19
21
|
/**
|
|
20
22
|
* The BUILT-IN default rule list (T16) is defined in `src/policy-globs.ts`,
|
|
@@ -75,6 +77,29 @@ const BUDGET_KEYS: ReadonlyArray<keyof BudgetConfig> = [
|
|
|
75
77
|
'maxAuditRounds', 'maxRepairAttempts', 'maxDelegationDepth', 'maxChildrenPerPhase', 'maxResultBytes',
|
|
76
78
|
]
|
|
77
79
|
|
|
80
|
+
/**
|
|
81
|
+
* THE DEFAULT POSTURE: STRICT, on all three gates — and this const is the ONE literal.
|
|
82
|
+
*
|
|
83
|
+
* The owner's rule is *only one phase may be active at a time, and the phases should be
|
|
84
|
+
* sequential and the active phase must be locked before proceeding to next phase*. In
|
|
85
|
+
* `advisory` that rule is only WARNED about, and a live run showed what that costs: the
|
|
86
|
+
* run ignored the lock chain for over an hour, wrote phase 8 before phase 1.5 and locked
|
|
87
|
+
* nothing (twelve DRAFT artifacts, one operations entry). Strict was previously unsafe as
|
|
88
|
+
* a default because it also refused the run's OWN artifacts — a false positive. That was
|
|
89
|
+
* fixed, and `tests/strict-run-tree.spec.ts` now walks all twelve phases asserting the
|
|
90
|
+
* active artifact stays writable while a later one is refused. Strict therefore refuses
|
|
91
|
+
* exactly the ordering violations it is meant to refuse, so the default is the enforcing
|
|
92
|
+
* posture rather than a warning nobody has to act on.
|
|
93
|
+
*
|
|
94
|
+
* ⚠ WHY IT IS A NAMED CONST AND NOT THREE LITERALS. A default restated per site is this
|
|
95
|
+
* project's recurring failure: the same value exists in the Config schema, in
|
|
96
|
+
* `DEFAULT_ENFORCEMENT`, in an omitted config section, and in the parameter defaults of
|
|
97
|
+
* the helpers below, and moving only some of them leaves a caller that "still gets
|
|
98
|
+
* advisory". Every one of those sites now reads THIS const, so a revert is a one-line
|
|
99
|
+
* change and nothing can drift from it.
|
|
100
|
+
*/
|
|
101
|
+
export const DEFAULT_ENFORCEMENT_MODE: EnforcementMode = 'strict'
|
|
102
|
+
|
|
78
103
|
/**
|
|
79
104
|
* Validate the enforcement config shape (unknown keys fail at plugin load).
|
|
80
105
|
*
|
|
@@ -89,7 +114,17 @@ export function resolveEnforcementConfig(config: unknown): EnforcementConfig {
|
|
|
89
114
|
if (unknown.length > 0) {
|
|
90
115
|
throw new Error('EnforcementConfig has unknown key(s) ' + unknown.join(', ') + ' - config is { preStep, toolGuards, tamper, budgets }')
|
|
91
116
|
}
|
|
92
|
-
|
|
117
|
+
// ⚠ AN ABSENT MODE RESOLVES TO THE DEFAULT MODE, not to the permissive branch. This is
|
|
118
|
+
// the twin-default trap in its most consequential form: a caller that supplies a PARTIAL
|
|
119
|
+
// section — `enforcement: { toolGuards: 'advisory' }` from a settings patch, or just the
|
|
120
|
+
// budgets — leaves the other gates unstated, and filling those with `advisory` would
|
|
121
|
+
// hand back a config that is looser than the plugin's own default with nothing saying so.
|
|
122
|
+
// An UNRECOGNIZED value resolves the same way and thus fails CLOSED. The Config schema in
|
|
123
|
+
// src/config.ts still rejects a typo loudly at the settings boundary; this resolver is
|
|
124
|
+
// the lenient one, and a lenient resolver must bend towards the safe posture: a typo that
|
|
125
|
+
// blocks is a visible stop, a typo that permits is the hour-long out-of-order run again.
|
|
126
|
+
const mode = (value: unknown): EnforcementMode =>
|
|
127
|
+
value === 'strict' ? 'strict' : value === 'advisory' ? 'advisory' : DEFAULT_ENFORCEMENT_MODE
|
|
93
128
|
|
|
94
129
|
const rawBudgets = (raw.budgets ?? {}) as Record<string, unknown>
|
|
95
130
|
if (typeof raw.budgets !== 'undefined' && (raw.budgets === null || typeof raw.budgets !== 'object')) {
|
|
@@ -117,10 +152,16 @@ export function resolveEnforcementConfig(config: unknown): EnforcementConfig {
|
|
|
117
152
|
}
|
|
118
153
|
}
|
|
119
154
|
|
|
155
|
+
/**
|
|
156
|
+
* The runtime default: what a caller gets when it supplies no `enforcement` section at all
|
|
157
|
+
* (a profile mounting this plugin with no config, e.g. `preset/recursive.patch.yml`). It is
|
|
158
|
+
* `DEFAULT_ENFORCEMENT_MODE` per gate, so this object and the resolver cannot disagree —
|
|
159
|
+
* see that const for WHY the default is strict.
|
|
160
|
+
*/
|
|
120
161
|
export const DEFAULT_ENFORCEMENT: EnforcementConfig = {
|
|
121
|
-
preStep:
|
|
122
|
-
toolGuards:
|
|
123
|
-
tamper:
|
|
162
|
+
preStep: DEFAULT_ENFORCEMENT_MODE,
|
|
163
|
+
toolGuards: DEFAULT_ENFORCEMENT_MODE,
|
|
164
|
+
tamper: DEFAULT_ENFORCEMENT_MODE,
|
|
124
165
|
budgets: DEFAULT_BUDGETS,
|
|
125
166
|
}
|
|
126
167
|
|
|
@@ -153,11 +194,24 @@ export interface GuardTransition {
|
|
|
153
194
|
* optional — `coerceAskToDecision` is asserted with `toEqual({ kind: ... })`
|
|
154
195
|
* (an EXACT match) in tests/enforcement.spec.ts, so the coercion path may never
|
|
155
196
|
* grow extra keys. `evaluateToolGuard` itself always sets `rule`.
|
|
197
|
+
*
|
|
198
|
+
* ⚠ FU-7: `ask` IS OPTIONAL AND ADDITIVE TOO, for the same reason and one more. A refusal that a
|
|
199
|
+
* PERSON has to resolve carries the gate-block decision alongside its sentence (see `verdictFor`),
|
|
200
|
+
* and the payload is built by `buildGateBlockAsk` — the SAME builder the lock tool uses, so the two
|
|
201
|
+
* refusals cannot offer different options. It is absent on every decision that is not a lock-order
|
|
202
|
+
* refusal decided from real blockers, which is why every reader must treat it as optional.
|
|
203
|
+
*
|
|
204
|
+
* ⚠ ISSUE 2 (b): `runId` IS THE RUN THE GUARD RESOLVED AND READ — optional and additive for the same
|
|
205
|
+
* reason. `evaluateToolGuard` sets it on EVERY decision, allow included, and `index.ts` logs it instead of
|
|
206
|
+
* the filesystem's active run: a record whose `runId` came from one resolution while the rule read another
|
|
207
|
+
* is exactly the two-answers-in-one-payload defect this pairs with. It stays OPTIONAL so a hand-built
|
|
208
|
+
* decision (and `coerceAskToDecision`'s key-frozen input/output) is unaffected; a reader falls back to the
|
|
209
|
+
* active run when it is absent, which is the pre-existing behaviour.
|
|
156
210
|
*/
|
|
157
211
|
export type ToolGuardDecision =
|
|
158
|
-
| { kind: 'allow'; warn?: string; rule?: GuardRule; transition?: GuardTransition }
|
|
159
|
-
| { kind: 'deny'; reason: string; rule?: GuardRule; transition?: GuardTransition }
|
|
160
|
-
| { kind: 'ask'; reason?: string; rule?: GuardRule; transition?: GuardTransition }
|
|
212
|
+
| { kind: 'allow'; warn?: string; rule?: GuardRule; transition?: GuardTransition; runId?: string }
|
|
213
|
+
| { kind: 'deny'; reason: string; rule?: GuardRule; transition?: GuardTransition; ask?: GateBlockAsk; runId?: string }
|
|
214
|
+
| { kind: 'ask'; reason?: string; rule?: GuardRule; transition?: GuardTransition; runId?: string }
|
|
161
215
|
|
|
162
216
|
export interface ToolExecLike {
|
|
163
217
|
name: string
|
|
@@ -249,15 +303,100 @@ export function currentPhaseArtifact(worktreeRoot: string, runId: string): strin
|
|
|
249
303
|
return inForce !== '' ? inForce : best
|
|
250
304
|
}
|
|
251
305
|
|
|
306
|
+
/**
|
|
307
|
+
* ISSUE 2 (a) — THE RUN A GUARD CALL IS ABOUT, and the one whose tree it may read.
|
|
308
|
+
*
|
|
309
|
+
* THE DEFECT THIS ANSWERS, measured before the fix: `recursive_lock {runId: 'run-b', artifact:
|
|
310
|
+
* '01-as-is.md'}` was REFUSED with `monotonic lock-order: … 00-requirements.md (DRAFT)` — run-A's blocker —
|
|
311
|
+
* while `run-b` had `00-requirements.md` LOCKED and `01-as-is.md` DRAFT, so locking it in run-b was LEGAL.
|
|
312
|
+
* The guard resolved the run from the FILESYSTEM (`resolveRunDir`, i.e. the active/newest run) while the
|
|
313
|
+
* tool resolves it from `args.runId`, so the guard judged a DIFFERENT RUN than the call was about. Under
|
|
314
|
+
* `advisory` the deny was coerced to an allow-with-warning and the tool refused on its own terms, which is
|
|
315
|
+
* why the strict default is what made it bite.
|
|
316
|
+
*
|
|
317
|
+
* SO THE RULE IS: for a LOCK call that NAMES a run, the guard judges THAT RUN. It is the same choice the
|
|
318
|
+
* tool makes, so the two layers cannot disagree about which tree the ordering rule is a property of. A
|
|
319
|
+
* caller that names nothing (every real `write`, and a lock that relies on the active run) is unaffected:
|
|
320
|
+
* the active run still governs, which is what the write-side rules rely on.
|
|
321
|
+
*
|
|
322
|
+
* ⚠ THIS IS SCOPED TO THE LOCK TOOLS DELIBERATELY, and the scope is per rule, not per convenience:
|
|
323
|
+
*
|
|
324
|
+
* - `lock-order` (`recursive_lock*`) — the caller's run WINS. The tool acts on `args.runId`, and the
|
|
325
|
+
* rule is about THAT run's prerequisites, so the guard must not answer for another run. This is the
|
|
326
|
+
* measured defect.
|
|
327
|
+
* - `locked-write` (the write-tool family) — NOT APPLICABLE, by construction: the rule resolves no run
|
|
328
|
+
* at all. It reads the target file's own `Status:` through the path the caller named, so there is no
|
|
329
|
+
* run to prefer and nothing could disagree.
|
|
330
|
+
* - `phase-order` (the write-tool family) — the ACTIVE run KEEPS WINNING, and this function does not
|
|
331
|
+
* touch it. Two reasons, both deliberate: (1) a `write` call carries no run id — no write tool declares
|
|
332
|
+
* one — so consulting `args.runId` here would hand a caller a way to ESCAPE the active run's ordering
|
|
333
|
+
* by naming some other run in an argument the tool ignores; and (2) the rule's declared scope is the
|
|
334
|
+
* run being worked in (it abstains for another run's tree, documented in `phaseOrderRule`), and moving
|
|
335
|
+
* that scope would be a new refusal, not a consistency fix.
|
|
336
|
+
*
|
|
337
|
+
* ⚠ A CALLER-SUPPLIED ID IS A NAME, NEVER A PATH, and it is validated before it can point the guard at
|
|
338
|
+
* anything: the id is trimmed the way `recursive_lock` trims it, then put through `runIdProblem` — the
|
|
339
|
+
* SAME gate the run-id-shaped tools use, which refuses separators, drive specifiers, `..`, a colon, a
|
|
340
|
+
* leading/trailing dot and an over-long name — and finally the resolved directory must sit UNDER this
|
|
341
|
+
* worktree's `<root>/.recursive/run`, the containment rule `runtime.ts` applies to a run directory.
|
|
342
|
+
*
|
|
343
|
+
* An id that fails any of those is NOT USED: the guard falls back to the active run, exactly as it behaved
|
|
344
|
+
* before this change. Falling back (rather than denying) is deliberate: an unusable id is a caller mistake
|
|
345
|
+
* the tool itself refuses (`BAD_RUN_ID` / `Artifact not found`), and inventing a new guard refusal for it
|
|
346
|
+
* would be a second, competing answer to a question `runIdProblem` already owns.
|
|
347
|
+
*
|
|
348
|
+
* A usable id does NOT have to name an EXISTING run: a run with no tree has no unlocked prerequisites, so
|
|
349
|
+
* the ordering rule abstains and the LOCK TOOL still refuses the lock (it checks the artifact exists before
|
|
350
|
+
* anything else). Requiring existence would instead re-introduce the defect in its ugliest form — a refusal
|
|
351
|
+
* built from ANOTHER run's blockers.
|
|
352
|
+
*/
|
|
353
|
+
export function resolveGuardRunId(
|
|
354
|
+
name: string,
|
|
355
|
+
args: Record<string, unknown>,
|
|
356
|
+
worktreeRoot: string,
|
|
357
|
+
activeRunId: string,
|
|
358
|
+
): string {
|
|
359
|
+
if (!LOCK_TOOL_NAMES.has(name) || !worktreeRoot) return activeRunId
|
|
360
|
+
const raw = args.runId
|
|
361
|
+
if (typeof raw !== 'string') return activeRunId
|
|
362
|
+
// The lock tool's own normalisation (`args.runId.trim()`), applied before the shape gate so a padded id
|
|
363
|
+
// is judged as the name the tool will act on, not as the padded string the guard happened to receive.
|
|
364
|
+
const declared = raw.trim()
|
|
365
|
+
if (declared === '' || runIdProblem(declared) !== null) return activeRunId
|
|
366
|
+
// CONTAINMENT. With a shape-valid name the join cannot escape, but the rule is asserted rather than
|
|
367
|
+
// assumed: a future change to the id grammar must not be able to move the guard's read outside the run
|
|
368
|
+
// layer, where the "prerequisites" it found would belong to something else entirely.
|
|
369
|
+
const runRoot = resolve(worktreeRoot, '.recursive', 'run')
|
|
370
|
+
const prefix = runRoot.endsWith(sep) ? runRoot : runRoot + sep
|
|
371
|
+
if (!resolve(join(runRoot, declared)).startsWith(prefix)) return activeRunId
|
|
372
|
+
return declared
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* `mode` is the gate's configured posture. Its parameter default FOLLOWS the config
|
|
377
|
+
* default by REFERENCE (`DEFAULT_ENFORCEMENT.toolGuards`) rather than repeating the
|
|
378
|
+
* literal: a bare call is "the caller had no mode to hand", and the answer to that must
|
|
379
|
+
* be the same posture the config would have produced. Two literals are two defaults, and
|
|
380
|
+
* a helper left on the old `advisory` literal while the config moved to `strict` is
|
|
381
|
+
* exactly the twin-default hole this change closes — a caller that forgot the argument
|
|
382
|
+
* would silently get the permissive branch, which no config could then undo. Every
|
|
383
|
+
* production call site passes the mode explicitly (`index.ts` `runToolGuard`,
|
|
384
|
+
* `runtime.ts` `guardTool`, the preview tool); this default serves bare callers, and a
|
|
385
|
+
* bare caller must not be the one place enforcement quietly turns itself off.
|
|
386
|
+
*/
|
|
252
387
|
export function evaluateToolGuard(
|
|
253
388
|
exec: ToolExecLike,
|
|
254
389
|
worktreeRoot: string,
|
|
255
390
|
activeRunId: string,
|
|
256
|
-
mode: EnforcementMode =
|
|
391
|
+
mode: EnforcementMode = DEFAULT_ENFORCEMENT.toolGuards,
|
|
257
392
|
): ToolGuardDecision {
|
|
258
393
|
const name = exec.name
|
|
259
394
|
const args = (exec.arguments ?? {}) as Record<string, unknown>
|
|
260
|
-
|
|
395
|
+
// ⚠ ISSUE 2 — THE RUN IS RESOLVED ONCE, HERE, for the whole call: the policy's phase baseline, the
|
|
396
|
+
// lock-order rule's subject and the decision's own `runId` all read this one answer, so a refusal cannot
|
|
397
|
+
// describe a run the rule did not read. See `resolveGuardRunId` for which rule prefers the caller's run
|
|
398
|
+
// and why the others do not.
|
|
399
|
+
const runId = resolveGuardRunId(name, args, worktreeRoot, typeof activeRunId === 'string' ? activeRunId.trim() : '')
|
|
261
400
|
const runDir = join(worktreeRoot, '.recursive', 'run', runId)
|
|
262
401
|
|
|
263
402
|
// T15: the transition gate is consulted BEFORE the verdict so its result can
|
|
@@ -276,7 +415,10 @@ export function evaluateToolGuard(
|
|
|
276
415
|
const policy = resolveToolPolicyForGuard(worktreeRoot, runId, activePhaseArtifact)
|
|
277
416
|
const context: ToolPolicyContext = { args, runDir, runId, worktreeRoot, activePhaseArtifact }
|
|
278
417
|
const decision = evaluateToolPolicy(policy, name, args, context)
|
|
279
|
-
|
|
418
|
+
// The resolved run rides on EVERY decision, including an allow: the guard-decision log records which run
|
|
419
|
+
// a decision was about, and a record that named the ACTIVE run while the rule read another one is exactly
|
|
420
|
+
// the two-answers-in-one-payload defect this pairs with (see `[run: <id>]` in `lockOrderRule`).
|
|
421
|
+
return { ...advisory(verdictFor(mode, decision, String(args.artifact ?? '')), transition), runId }
|
|
280
422
|
}
|
|
281
423
|
|
|
282
424
|
/**
|
|
@@ -284,12 +426,35 @@ export function evaluateToolGuard(
|
|
|
284
426
|
* `advisory` asks (the pre-T16 wording, unchanged), `allow` stays an allow. The
|
|
285
427
|
* decision's `rule` is the label of the rule that decided it, so a policy
|
|
286
428
|
* verdict is traceable to an auditable line in the policy file.
|
|
429
|
+
*
|
|
430
|
+
* ⚠ FU-7 — THE ORDERING REFUSAL CARRIES THE HUMAN'S CHOICE. `fix | reopen | abandon` is how a
|
|
431
|
+
* person unblocks a lock, and before this the ask was attached ONLY by `recursive_lock`'s own
|
|
432
|
+
* catch — the branch that runs when the guard ABSTAINS. Under the strict default the guard
|
|
433
|
+
* refuses a lock ahead of its prerequisites BEFORE dispatch, so that branch never ran on the
|
|
434
|
+
* default path and the caller got a bare sentence: the recovery options existed in the code and
|
|
435
|
+
* were unreachable in the product, which is worse than the advisory posture they replaced (an
|
|
436
|
+
* advisory `ask` at least surfaced the reason).
|
|
437
|
+
*
|
|
438
|
+
* THE TRIGGER IS THE BLOCKERS, NOT THE LABEL. `PolicyDecision.blockers` is present exactly when a
|
|
439
|
+
* predicate read prerequisite blockers from disk and they were non-empty, so gating on it means
|
|
440
|
+
* "this refusal was decided from an ordering violation" — including a policy FILE whose
|
|
441
|
+
* `recursive_lock*` deny carries no label (the file-authored rule is given the same condition by
|
|
442
|
+
* `attachPolicyPredicate`, and its `rule` would otherwise read `none`). Nothing is recomputed
|
|
443
|
+
* here: the blockers arrive from the rule that already resolved them.
|
|
444
|
+
*
|
|
445
|
+
* IT IS ATTACHED TO THE REFUSAL ONLY. Under `advisory` the same verdict becomes an `ask` that the
|
|
446
|
+
* live path coerces to an allow-with-warning, and the tool then refuses with its OWN payload when
|
|
447
|
+
* `lockArtifact` throws — so an ask attached here would be a claim about a refusal that this layer
|
|
448
|
+
* did not make. One refusal, one ask.
|
|
287
449
|
*/
|
|
288
|
-
function verdictFor(mode: EnforcementMode, decision: PolicyDecision): ToolGuardDecision {
|
|
450
|
+
function verdictFor(mode: EnforcementMode, decision: PolicyDecision, artifact: string): ToolGuardDecision {
|
|
289
451
|
const rule = (decision.rule ?? 'none') as GuardRule
|
|
290
452
|
if (decision.kind === 'allow') return { kind: 'allow', rule }
|
|
291
453
|
const reason = decision.reason ?? 'tool policy denied this call'
|
|
292
|
-
|
|
454
|
+
if (mode !== 'strict') return { kind: 'ask', reason, rule }
|
|
455
|
+
const blocked = decision.blockers
|
|
456
|
+
if (blocked === undefined || blocked.length === 0) return { kind: 'deny', reason, rule }
|
|
457
|
+
return { kind: 'deny', reason, rule, ask: buildGateBlockAsk(artifact, reason) }
|
|
293
458
|
}
|
|
294
459
|
|
|
295
460
|
/**
|
|
@@ -337,10 +502,17 @@ function advisory(decision: ToolGuardDecision, transition: GateCheckResult | und
|
|
|
337
502
|
if (transition.passed) return { ...decision, transition }
|
|
338
503
|
// The verdict stays `allow`; the gate only names itself as the dissenting
|
|
339
504
|
// voice, so an advisory pass is never silent.
|
|
505
|
+
//
|
|
506
|
+
// ⚠ FU-7 (the log fix) — "REPORT-ONLY", NOT "advisory". This sentence travels into the guard's
|
|
507
|
+
// log line and into the decision a caller reads, and `advisory` there named the GATE's posture
|
|
508
|
+
// — not the configured mode — while the line around it said `tool guard (advisory)`. Under the
|
|
509
|
+
// strict default a reader was told enforcement was off while every gate was strict. What is
|
|
510
|
+
// actually true of this gate in BOTH modes is that it reports and never changes the verdict, so
|
|
511
|
+
// that is what it now says. The warn-on-allow semantics are untouched.
|
|
340
512
|
return {
|
|
341
513
|
...decision,
|
|
342
514
|
rule: 'transition',
|
|
343
|
-
warn: 'transition gate (
|
|
515
|
+
warn: 'transition gate (report-only) failed: ' + transition.failures.join('; '),
|
|
344
516
|
transition,
|
|
345
517
|
}
|
|
346
518
|
}
|
|
@@ -350,8 +522,23 @@ function advisory(decision: ToolGuardDecision, transition: GateCheckResult | und
|
|
|
350
522
|
* allow. Under `strict` it coerces to `deny`; under `advisory` it stays `allow`
|
|
351
523
|
* but flags a `warn` so the caller never lets it through unlogged. Non-ask
|
|
352
524
|
* decisions pass through unchanged.
|
|
525
|
+
*
|
|
526
|
+
* ⚠ THE `mode` DEFAULT IS DELIBERATE, and it is NOT a neutral fallback — there is no
|
|
527
|
+
* neutral branch here. The domain is two postures, one of which ALLOWS the call, so
|
|
528
|
+
* "unspecified" has to be resolved rather than left open, and this codebase's rule for an
|
|
529
|
+
* undecidable path is to fail CLOSED (`index.ts`: *"we could not decide" is not
|
|
530
|
+
* permission*). It therefore FOLLOWS the config default by REFERENCE
|
|
531
|
+
* (`DEFAULT_ENFORCEMENT.toolGuards`), for the same reason as `evaluateToolGuard`'s: an
|
|
532
|
+
* `advisory` literal here would be a second, hidden copy of the old default inside the
|
|
533
|
+
* very module this change moves, and a future caller that omitted the argument would
|
|
534
|
+
* re-open the permissive path with no config able to close it. The production call site
|
|
535
|
+
* (`index.ts` `runToolGuard`) always passes the configured mode, so this changes no live
|
|
536
|
+
* behaviour — it removes the last place where "we were not told" meant "allow".
|
|
353
537
|
*/
|
|
354
|
-
export function coerceAskToDecision(
|
|
538
|
+
export function coerceAskToDecision(
|
|
539
|
+
decision: ToolGuardDecision,
|
|
540
|
+
mode: EnforcementMode = DEFAULT_ENFORCEMENT.toolGuards,
|
|
541
|
+
): ToolGuardDecision {
|
|
355
542
|
if (decision.kind !== 'ask') return decision
|
|
356
543
|
if (mode === 'strict') {
|
|
357
544
|
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). */
|