diff --git a/docs/dev/FILE-SYSTEM-MAP.md b/docs/dev/FILE-SYSTEM-MAP.md index 7e1a61bf3..094354377 100644 --- a/docs/dev/FILE-SYSTEM-MAP.md +++ b/docs/dev/FILE-SYSTEM-MAP.md @@ -282,9 +282,7 @@ | core/tools/grep.ts | Tool System, File Search | Pattern search tool | | core/tools/ls.ts | Tool System | Directory listing tool | | core/tools/truncate.ts | Tool System, Text Processing | Output truncation utility | -| core/tools/hashline.ts | Tool System | Hash-based line identification | -| core/tools/hashline-read.ts | Tool System | File reading with hash-based line ranges | -| core/tools/hashline-edit.ts | Tool System | File editing with hash-based line identification | +| core/tools/hashline.ts | Tool System | Hash-based line identification (consumed by `Edit({match: "anchor"})` and `Read({format: "tagged"})` modes since 2026-05-17 fold) | | core/tools/path-utils.ts | Tool System | Path normalization and validation | | core/bash-executor.ts | Tool System | High-level bash execution with event handling | | core/exec.ts | Tool System | Utility functions for command execution | diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 3af49e694..0c2df7be7 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -331,6 +331,7 @@ export { type EditToolDetails, type EditToolInput, type EditToolOptions, + createAstGrepTool, editTool, type FindOperations, type FindToolDetails, @@ -345,6 +346,7 @@ export { getAllToolCompatibility, getToolCompatibility, grepTool, + createInsertAroundSymbolTool, type LsOperations, type LsToolDetails, type LsToolInput, @@ -355,6 +357,7 @@ export { type ReadToolInput, type ReadToolOptions, readTool, + createReplaceSymbolTool, registerMcpToolCompatibility, // Tool compatibility registry (ADR-005) registerToolCompatibility, diff --git a/src/resources/extensions/sf/auto/run-unit.js b/src/resources/extensions/sf/auto/run-unit.js index 3803b7073..09eee79a2 100644 --- a/src/resources/extensions/sf/auto/run-unit.js +++ b/src/resources/extensions/sf/auto/run-unit.js @@ -30,6 +30,7 @@ import { import { DispatchLayer } from "../dispatch/dispatch-layer.js"; import { isInlineEligible } from "../dispatch/run-unit-inline.js"; import { swarmDispatchAndWait } from "../uok/swarm-dispatch.js"; +import { inlineRuntimeGate } from "../uok/inline-runtime-gate.js"; /** * #M010/S05: Try inline-scope dispatch via DispatchLayer. @@ -53,6 +54,29 @@ import { swarmDispatchAndWait } from "../uok/swarm-dispatch.js"; async function tryInlineDispatch(ctx, s, unitType, unitId, _prompt, options) { if (!isInlineEligible(unitType)) return null; const basePath = s.basePath ?? ctx.basePath ?? process.cwd(); + const gateResult = inlineRuntimeGate.execute({ + ...ctx, + unitType, + unitId, + basePath, + }); + if (gateResult.outcome !== "pass") { + debugLog("runUnit", { + phase: "inline-route-refused", + unitType, + unitId, + rationale: gateResult.rationale, + }); + return { + status: "cancelled", + errorContext: { + message: `[inline-runtime-gate] ${gateResult.rationale}`, + category: "policy", + isTransient: false, + }, + _via: "inline-gate", + }; + } debugLog("runUnit", { phase: "inline-route", unitType, @@ -1258,12 +1282,10 @@ async function runUnitViaSwarm(ctx, _pi, s, unitType, unitId, prompt, options) { * Default: false (each new unit starts with a clean session). */ export async function runUnit(ctx, pi, s, unitType, unitId, prompt, options) { - // #M010/S05: Inline-scope dispatch is default-on for inline-eligible unit types. - // SF_INLINE_DISPATCH=0 is the escape hatch for operators who need to force the - // swarm/legacy path. validate-milestone, complete-milestone, and reassess-roadmap - // are inline-eligible (R013). This was the S03 feature-flag path; S05 makes it - // the default and adds SF_INLINE_DISPATCH=0 as the off-switch. - if (isInlineEligible(unitType) && process.env.SF_INLINE_DISPATCH !== "0") { + // #M010/S05 + R074: inline dispatch is attempted for eligible unit types, + // but the inlineRuntimeGate refuses it until R020 and R066 are validated + // unless SF_INLINE_DISPATCH=1 is set as an audited bypass. + if (isInlineEligible(unitType)) { const inline = await tryInlineDispatch( ctx, s, diff --git a/src/resources/extensions/sf/bootstrap/register-hooks.js b/src/resources/extensions/sf/bootstrap/register-hooks.js index 54888f356..49aeed6bc 100644 --- a/src/resources/extensions/sf/bootstrap/register-hooks.js +++ b/src/resources/extensions/sf/bootstrap/register-hooks.js @@ -438,6 +438,23 @@ export function registerHooks(pi, ecosystemHandlers = []) { "../self-feedback-drain.js" ); dispatchSelfFeedbackInlineFixIfNeeded(process.cwd(), ctx, pi); + try { + const { drainAdversarialFindingsBridge } = await import( + "../safety/adversarial-finding-bridge.js" + ); + const bridged = drainAdversarialFindingsBridge(triage.stillBlocked, { + basePath: process.cwd(), + }); + if (bridged > 0) { + ctx.ui?.notify?.( + `adversarial-finding bridge: smoke_gate quarantine applied for ${bridged} high-severity finding${bridged === 1 ? "" : "s"}.`, + "warning", + { noticeKind: NOTICE_KIND.SYSTEM_NOTICE }, + ); + } + } catch { + /* non-fatal — adversarial bridge must never block session start */ + } } catch { /* non-fatal — self-feedback drain must never block session start */ } diff --git a/src/resources/extensions/sf/constants.js b/src/resources/extensions/sf/constants.js index ff316ea37..1ed8ffb97 100644 --- a/src/resources/extensions/sf/constants.js +++ b/src/resources/extensions/sf/constants.js @@ -8,6 +8,16 @@ export const DEFAULT_COMMAND_TIMEOUT_MS = 120_000; /** Default timeout for the dynamic bash tool (seconds). */ export const DEFAULT_BASH_TIMEOUT_SECS = 120; +/** + * Maximum output tokens allowed for one adversarial/challenge review turn. + * + * Purpose: cap adversarial review cost/runaway risk while still allowing a + * substantive critique of milestone or slice evidence. + * + * Consumer: uok/auto-dispatch.js challenge envelopes and + * uok/adversarial-budget.js enforcement helpers. + */ +export const ADVERSARIAL_REVIEW_MAX_TOKENS = 60_000; // ─── Cache Sizes ────────────────────────────────────────────────────────────── /** Max directory-listing cache entries before eviction (#611). */ export const DIR_CACHE_MAX = 200; diff --git a/src/resources/extensions/sf/safety/adversarial-finding-bridge.js b/src/resources/extensions/sf/safety/adversarial-finding-bridge.js new file mode 100644 index 000000000..a510ad37a --- /dev/null +++ b/src/resources/extensions/sf/safety/adversarial-finding-bridge.js @@ -0,0 +1,123 @@ +/** + * adversarial-finding-bridge.js — quarantine smoke_gate on severe findings. + * + * Purpose: connect high-severity adversarial findings to the existing + * smoke_gate quarantine control so a challenge result can stop promotion until + * an operator reviews the finding. + * + * Consumer: session-start self-feedback drain in bootstrap/register-hooks.js. + */ +import { setExperimentalFlag } from "../experimental.js"; +import { recordSelfFeedback } from "../self-feedback.js"; +import { isDbAvailable, listSelfFeedbackEntries } from "../sf-db.js"; + +/** + * Return true for adversarial findings severe enough to quarantine smoke_gate. + * + * Purpose: keep the bridge predicate explicit and testable. + * + * Consumer: bridgeAdversarialFindingToQuarantine and drainAdversarialFindingsBridge. + */ +export function isHighSeverityAdversarialFinding(entry) { + if (!entry || typeof entry !== "object") return false; + const kind = String(entry.kind ?? ""); + const severity = String(entry.severity ?? ""); + return ( + (kind === "adversarial-finding" || + kind.startsWith("adversarial-finding:")) && + severity === "high" + ); +} + +function quarantineKeyFor(sourceEntry) { + return `quarantine:adversarial-finding:${sourceEntry.id ?? "unknown"}`; +} + +function alreadyQuarantinedByKey(key) { + if (!isDbAvailable()) return false; + return listSelfFeedbackEntries().some( + (entry) => + entry.kind === "smoke-gate-quarantined" && + entry.evidence?.quarantineKey === key, + ); +} + +/** + * Bridge one high-severity adversarial finding to smoke_gate quarantine. + * + * Purpose: make adversarial review actionable by flipping the promotion gate + * and recording a child self-feedback entry with a durable source reference. + * + * Consumer: drainAdversarialFindingsBridge. + */ +export function bridgeAdversarialFindingToQuarantine(entry, opts = {}) { + if (!isHighSeverityAdversarialFinding(entry)) { + return { + ok: false, + reason: "entry is not a high-severity adversarial-finding", + }; + } + + const basePath = opts.basePath ?? process.cwd(); + const quarantineKey = quarantineKeyFor(entry); + const alreadyQuarantined = + opts.alreadyQuarantined ?? ((key) => alreadyQuarantinedByKey(key)); + if (alreadyQuarantined(quarantineKey, basePath)) { + return { ok: true, quarantined: false, reason: "already-quarantined" }; + } + + const setFlag = + opts.setExperimentalFlag ?? + ((name, value) => setExperimentalFlag(name, value)); + const record = + opts.recordSelfFeedback ?? ((child, bp) => recordSelfFeedback(child, bp)); + + try { + setFlag("smoke_gate", false); + } catch { + /* child entry below still records the attempted quarantine */ + } + + const childEntry = { + kind: "smoke-gate-quarantined", + severity: "high", + summary: `smoke_gate disabled by adversarial-finding bridge. Source: ${entry.id ?? "unknown"} - ${String(entry.summary ?? "").slice(0, 200)}`, + evidence: { + sourceEntryId: entry.id, + sourceKind: entry.kind, + sourceSeverity: entry.severity, + quarantineKey, + bridgeReason: + "high-severity adversarial-finding triggered smoke_gate quarantine", + }, + suggestedFix: + "Review the source adversarial-finding entry and decide whether the finding warrants quarantine. Re-enable smoke_gate after the issue is resolved.", + }; + + let childEntryId; + try { + childEntryId = record(childEntry, basePath)?.entry?.id; + } catch { + /* quarantine flag decision already happened */ + } + return { ok: true, quarantined: true, childEntryId }; +} + +/** + * Process blocked self-feedback entries and quarantine severe adversarial finds. + * + * Purpose: make findings from previous runs effective at the next session + * boundary, even if the bridge was not loaded when the finding was filed. + * + * Consumer: bootstrap/register-hooks.js self-feedback drain. + */ +export function drainAdversarialFindingsBridge(entries, opts = {}) { + if (!Array.isArray(entries)) return 0; + let count = 0; + for (const entry of entries) { + if (!isHighSeverityAdversarialFinding(entry)) continue; + bridgeAdversarialFindingToQuarantine(entry, opts); + count += 1; + } + return count; +} diff --git a/src/resources/extensions/sf/self-feedback.js b/src/resources/extensions/sf/self-feedback.js index dac6b314a..02209fdd4 100644 --- a/src/resources/extensions/sf/self-feedback.js +++ b/src/resources/extensions/sf/self-feedback.js @@ -489,6 +489,8 @@ const ALLOWED_KIND_DOMAINS = new Set([ // this, R075 challenge units have no kind to file their findings under // (gap reported by sf-mp9u4i25-fczmcj on 2026-05-17). "adversarial-finding", + "adversarial-budget-exceeded", + "smoke-gate-quarantined", ]); const KIND_SEGMENT_RE = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/; diff --git a/src/resources/extensions/sf/tests/adversarial-budget.test.mjs b/src/resources/extensions/sf/tests/adversarial-budget.test.mjs new file mode 100644 index 000000000..34cda08d6 --- /dev/null +++ b/src/resources/extensions/sf/tests/adversarial-budget.test.mjs @@ -0,0 +1,63 @@ +/** + * adversarial-budget.test.mjs — R075 adversarial review token budget. + * + * Purpose: verify challenge review budget calculation and feedback filing + * behavior before wiring the cap into dispatch envelopes. + */ +import assert from "node:assert/strict"; +import { afterEach, test } from "vitest"; +import { ADVERSARIAL_REVIEW_MAX_TOKENS } from "../constants.js"; +import { + enforceAdversarialBudget, + isAdversarialBudgetExceeded, + resolveAdversarialBudget, +} from "../uok/adversarial-budget.js"; + +let savedBudget; + +afterEach(() => { + if (savedBudget === undefined) delete process.env.SF_ADVERSARIAL_MAX_TOKENS; + else process.env.SF_ADVERSARIAL_MAX_TOKENS = savedBudget; +}); + +test("resolveAdversarialBudget_when_env_unset_returns_constant", () => { + savedBudget = process.env.SF_ADVERSARIAL_MAX_TOKENS; + delete process.env.SF_ADVERSARIAL_MAX_TOKENS; + assert.equal(resolveAdversarialBudget(), ADVERSARIAL_REVIEW_MAX_TOKENS); +}); + +test("resolveAdversarialBudget_when_env_positive_uses_override", () => { + savedBudget = process.env.SF_ADVERSARIAL_MAX_TOKENS; + process.env.SF_ADVERSARIAL_MAX_TOKENS = "12345"; + assert.equal(resolveAdversarialBudget(), 12345); +}); + +test("isAdversarialBudgetExceeded_when_at_limit_returns_true", () => { + savedBudget = process.env.SF_ADVERSARIAL_MAX_TOKENS; + delete process.env.SF_ADVERSARIAL_MAX_TOKENS; + assert.equal( + isAdversarialBudgetExceeded(ADVERSARIAL_REVIEW_MAX_TOKENS), + true, + ); + assert.equal( + isAdversarialBudgetExceeded(ADVERSARIAL_REVIEW_MAX_TOKENS - 1), + false, + ); +}); + +test("enforceAdversarialBudget_when_over_limit_records_feedback", () => { + savedBudget = process.env.SF_ADVERSARIAL_MAX_TOKENS; + delete process.env.SF_ADVERSARIAL_MAX_TOKENS; + const entries = []; + const result = enforceAdversarialBudget( + ADVERSARIAL_REVIEW_MAX_TOKENS + 1, + { unitId: "challenge-M001", milestoneId: "M001" }, + { recordSelfFeedback: (entry) => entries.push(entry) }, + ); + + assert.equal(result.shortCircuit, true); + assert.equal(result.reason, "adversarial-budget-exceeded"); + assert.equal(entries.length, 1); + assert.equal(entries[0].kind, "adversarial-budget-exceeded"); + assert.equal(entries[0].evidence.unitId, "challenge-M001"); +}); diff --git a/src/resources/extensions/sf/tests/adversarial-finding-bridge.test.mjs b/src/resources/extensions/sf/tests/adversarial-finding-bridge.test.mjs new file mode 100644 index 000000000..77dcc27a0 --- /dev/null +++ b/src/resources/extensions/sf/tests/adversarial-finding-bridge.test.mjs @@ -0,0 +1,98 @@ +/** + * adversarial-finding-bridge.test.mjs — R075 finding-to-quarantine bridge. + * + * Purpose: verify high-severity adversarial findings disable smoke_gate once + * and create a child self-feedback entry with a stable source reference. + */ +import assert from "node:assert/strict"; +import { test } from "vitest"; +import { + bridgeAdversarialFindingToQuarantine, + drainAdversarialFindingsBridge, + isHighSeverityAdversarialFinding, +} from "../safety/adversarial-finding-bridge.js"; + +test("isHighSeverityAdversarialFinding_when_high_adversarial_returns_true", () => { + assert.equal( + isHighSeverityAdversarialFinding({ + kind: "adversarial-finding:red-team", + severity: "high", + }), + true, + ); + assert.equal( + isHighSeverityAdversarialFinding({ + kind: "adversarial-finding", + severity: "medium", + }), + false, + ); +}); + +test("bridgeAdversarialFindingToQuarantine_when_high_finding_disables_smoke_gate", () => { + const flags = []; + const entries = []; + const result = bridgeAdversarialFindingToQuarantine( + { + id: "sf-finding-1", + kind: "adversarial-finding", + severity: "high", + summary: "Promotion bypass found.", + }, + { + setExperimentalFlag: (name, value) => flags.push({ name, value }), + recordSelfFeedback: (entry) => { + entries.push(entry); + return { entry: { id: "child-1" } }; + }, + alreadyQuarantined: () => false, + }, + ); + + assert.equal(result.ok, true); + assert.equal(result.quarantined, true); + assert.deepEqual(flags, [{ name: "smoke_gate", value: false }]); + assert.equal(entries.length, 1); + assert.equal(entries[0].kind, "smoke-gate-quarantined"); + assert.equal(entries[0].evidence.sourceEntryId, "sf-finding-1"); +}); + +test("bridgeAdversarialFindingToQuarantine_when_already_quarantined_is_idempotent", () => { + const flags = []; + const entries = []; + const result = bridgeAdversarialFindingToQuarantine( + { + id: "sf-finding-1", + kind: "adversarial-finding", + severity: "high", + }, + { + setExperimentalFlag: (name, value) => flags.push({ name, value }), + recordSelfFeedback: (entry) => entries.push(entry), + alreadyQuarantined: () => true, + }, + ); + + assert.equal(result.reason, "already-quarantined"); + assert.equal(flags.length, 0); + assert.equal(entries.length, 0); +}); + +test("drainAdversarialFindingsBridge_when_mixed_entries_processes_only_high_findings", () => { + const flags = []; + const count = drainAdversarialFindingsBridge( + [ + { id: "one", kind: "adversarial-finding", severity: "high" }, + { id: "two", kind: "adversarial-finding", severity: "medium" }, + { id: "three", kind: "gap", severity: "high" }, + ], + { + setExperimentalFlag: (name, value) => flags.push({ name, value }), + recordSelfFeedback: () => {}, + alreadyQuarantined: () => false, + }, + ); + + assert.equal(count, 1); + assert.deepEqual(flags, [{ name: "smoke_gate", value: false }]); +}); diff --git a/src/resources/extensions/sf/tests/inline-runtime-gate.test.mjs b/src/resources/extensions/sf/tests/inline-runtime-gate.test.mjs new file mode 100644 index 000000000..0df0ad2ed --- /dev/null +++ b/src/resources/extensions/sf/tests/inline-runtime-gate.test.mjs @@ -0,0 +1,115 @@ +/** + * inline-runtime-gate.test.mjs — R074 inline dispatch safety gate. + * + * Purpose: verify inline dispatch fails closed until R020 and R066 are + * validated, with SF_INLINE_DISPATCH=1 as the explicit audited bypass. + */ +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, test } from "vitest"; +import { closeDatabase, openDatabase, upsertRequirement } from "../sf-db.js"; +import { inlineRuntimeGate } from "../uok/inline-runtime-gate.js"; + +const roots = []; +let savedBypass; + +function makeProject() { + const root = mkdtempSync(join(tmpdir(), "sf-inline-gate-")); + mkdirSync(join(root, ".sf"), { recursive: true }); + roots.push(root); + openDatabase(join(root, ".sf", "sf.db")); + return root; +} + +function seedRequirement(id, status) { + upsertRequirement({ + id, + class: "R", + status, + description: `Test requirement ${id}`, + why: "test", + source: "test", + primary_owner: "test", + supporting_slices: null, + validation: null, + notes: null, + full_content: `# ${id}\n- Status: ${status}`, + superseded_by: null, + }); +} + +beforeEach(() => { + savedBypass = process.env.SF_INLINE_DISPATCH; + delete process.env.SF_INLINE_DISPATCH; +}); + +afterEach(() => { + closeDatabase(); + for (const root of roots.splice(0)) + rmSync(root, { recursive: true, force: true }); + if (savedBypass === undefined) delete process.env.SF_INLINE_DISPATCH; + else process.env.SF_INLINE_DISPATCH = savedBypass; +}); + +describe("inlineRuntimeGate", () => { + test("execute_when_requirements_missing_fails_closed", () => { + const root = makeProject(); + const result = inlineRuntimeGate.execute({ + basePath: root, + unitType: "validate-milestone", + unitId: "M001", + }); + + assert.equal(result.outcome, "fail"); + assert.equal(result.failureClass, "policy"); + assert.match(result.rationale, /R020=unknown R066=unknown/); + }); + + test("execute_when_one_requirement_active_fails", () => { + const root = makeProject(); + seedRequirement("R020", "validated"); + seedRequirement("R066", "active"); + + const result = inlineRuntimeGate.execute({ + basePath: root, + unitType: "complete-milestone", + unitId: "M001", + }); + + assert.equal(result.outcome, "fail"); + assert.match(result.rationale, /R020=validated R066=active/); + }); + + test("execute_when_both_requirements_validated_passes", () => { + const root = makeProject(); + seedRequirement("R020", "validated"); + seedRequirement("R066", "validated"); + + const result = inlineRuntimeGate.execute({ + basePath: root, + unitType: "reassess-roadmap", + unitId: "M001/S01", + }); + + assert.equal(result.outcome, "pass"); + assert.match(result.rationale, /R020=validated R066=validated/); + }); + + test("execute_when_bypass_set_passes_with_audited_rationale", () => { + const root = makeProject(); + seedRequirement("R020", "active"); + seedRequirement("R066", "active"); + process.env.SF_INLINE_DISPATCH = "1"; + + const result = inlineRuntimeGate.execute({ + basePath: root, + unitType: "validate-milestone", + unitId: "M001", + }); + + assert.equal(result.outcome, "pass"); + assert.match(result.rationale, /audited bypass/); + }); +}); diff --git a/src/resources/extensions/sf/uok/adversarial-budget.js b/src/resources/extensions/sf/uok/adversarial-budget.js new file mode 100644 index 000000000..7cd5a1e78 --- /dev/null +++ b/src/resources/extensions/sf/uok/adversarial-budget.js @@ -0,0 +1,86 @@ +/** + * adversarial-budget.js — token budget policy for adversarial review units. + * + * Purpose: prevent challenge/adversarial review turns from consuming unbounded + * tokens while still allowing the review to file structured feedback when the + * cap is reached. + * + * Consumer: uok/auto-dispatch.js challenge dispatch envelopes and future + * streaming budget checks. + */ +import { ADVERSARIAL_REVIEW_MAX_TOKENS } from "../constants.js"; + +/** + * Resolve the effective adversarial review token budget. + * + * Purpose: allow an explicit operator override without forking dispatch code. + * + * Consumer: challenge dispatch and focused tests. + */ +export function resolveAdversarialBudget() { + const override = Number(process.env.SF_ADVERSARIAL_MAX_TOKENS ?? ""); + if (Number.isFinite(override) && override > 0) return Math.floor(override); + return ADVERSARIAL_REVIEW_MAX_TOKENS; +} + +/** + * Return true when the observed token count has reached the adversarial budget. + * + * Purpose: provide a cheap predicate for both pre-dispatch and streaming checks. + * + * Consumer: enforceAdversarialBudget. + */ +export function isAdversarialBudgetExceeded(tokenCount) { + return ( + Number.isFinite(tokenCount) && tokenCount >= resolveAdversarialBudget() + ); +} + +/** + * File structured self-feedback and short-circuit when the budget is exceeded. + * + * Purpose: turn runaway adversarial review into a durable, reviewable feedback + * item instead of allowing silent token burn. + * + * Consumer: future challenge streaming enforcement. + */ +export function enforceAdversarialBudget(tokenCount, context = {}, opts = {}) { + const budget = resolveAdversarialBudget(); + if (!isAdversarialBudgetExceeded(tokenCount)) return null; + + const { unitId, milestoneId, basePath } = context; + const entry = { + kind: "adversarial-budget-exceeded", + reason: "adversarial-budget-exceeded", + severity: "medium", + summary: `Adversarial review unit${unitId ? ` "${unitId}"` : ""} exceeded token budget (${tokenCount} >= ${budget}). Review was short-circuited.`, + evidence: { + tokenCount, + budget, + ...(unitId ? { unitId } : {}), + ...(milestoneId ? { milestoneId } : {}), + }, + suggestedFix: + "Tune the challenge prompt or reduce inlined context so the review fits within the budget. Adjust SF_ADVERSARIAL_MAX_TOKENS only when the higher cap is intentional.", + }; + + const record = + opts.recordSelfFeedback ?? + (async () => { + try { + const { recordSelfFeedback } = await import("../self-feedback.js"); + recordSelfFeedback(entry, basePath ?? process.cwd()); + } catch { + /* feedback filing must never mask the budget decision */ + } + }); + const result = record(entry, basePath ?? process.cwd()); + if (result && typeof result.catch === "function") result.catch(() => {}); + + return { + shortCircuit: true, + reason: "adversarial-budget-exceeded", + tokenCount, + budget, + }; +} diff --git a/src/resources/extensions/sf/uok/auto-dispatch.js b/src/resources/extensions/sf/uok/auto-dispatch.js index 268600df5..ffc487624 100644 --- a/src/resources/extensions/sf/uok/auto-dispatch.js +++ b/src/resources/extensions/sf/uok/auto-dispatch.js @@ -115,6 +115,7 @@ import { decideUnitRuntimeDispatch, readUnitRuntimeRecord, } from "./unit-runtime.js"; +import { resolveAdversarialBudget } from "./adversarial-budget.js"; const MAX_PARALLEL_RESEARCH_SLICES = 8; const PARALLEL_RESEARCH_BLOCKING_PHASES = new Set([ @@ -963,6 +964,7 @@ export const DISPATCH_RULES = [ action: "dispatch", unitType: "challenge", unitId: challengeUnitId(mid, slice.id), + maxOutputTokens: resolveAdversarialBudget(), prompt: await buildChallengePrompt( mid, midTitle, @@ -1894,6 +1896,7 @@ export const DISPATCH_RULES = [ action: "dispatch", unitType: "challenge", unitId: challengeUnitId(mid), + maxOutputTokens: resolveAdversarialBudget(), prompt: await buildChallengePrompt( mid, midTitle, diff --git a/src/resources/extensions/sf/uok/gate-registry-bootstrap.js b/src/resources/extensions/sf/uok/gate-registry-bootstrap.js index 8502567da..c1fa9194a 100644 --- a/src/resources/extensions/sf/uok/gate-registry-bootstrap.js +++ b/src/resources/extensions/sf/uok/gate-registry-bootstrap.js @@ -11,6 +11,7 @@ import { repeatedFeedbackKindGate } from "../detectors/repeated-feedback-kind.js import { artifactFlapGate } from "../detectors/artifact-flap.js"; import { staleLockGate } from "../detectors/stale-lock.js"; import { periodicDetectorSweepGate } from "../detectors/periodic-runner.js"; +import { inlineRuntimeGate } from "./inline-runtime-gate.js"; /** * gate-registry-bootstrap.js — register ADR-0075 UOK gates that are liftable. @@ -41,5 +42,6 @@ registry.register(repeatedFeedbackKindGate); registry.register(artifactFlapGate); registry.register(staleLockGate); registry.register(periodicDetectorSweepGate); +registry.register(inlineRuntimeGate); export { registry as gateRegistry }; diff --git a/src/resources/extensions/sf/uok/inline-runtime-gate.js b/src/resources/extensions/sf/uok/inline-runtime-gate.js new file mode 100644 index 000000000..054993995 --- /dev/null +++ b/src/resources/extensions/sf/uok/inline-runtime-gate.js @@ -0,0 +1,74 @@ +/** + * inline-runtime-gate.js — policy gate for default-on inline dispatch. + * + * Purpose: refuse inline dispatch until R020 (inline equivalence proof) and + * R066 (regression firewall) are validated, unless the operator explicitly + * uses the audited `SF_INLINE_DISPATCH=1` bypass. + * + * Consumer: auto/run-unit.js before DispatchLayer enters the inline execution + * path for validate/complete/reassess units. + */ +import { debugLog } from "../debug-logger.js"; +import { getRequirementById, isDbAvailable } from "../sf-db.js"; + +export const INLINE_RUNTIME_GATE_ID = "inline-runtime-gate"; +export const INLINE_RUNTIME_GATE_REQUIREMENTS = ["R020", "R066"]; + +function readRequirementStatus(id) { + if (!isDbAvailable()) return "unknown"; + try { + return getRequirementById(id)?.status ?? "unknown"; + } catch { + return "unknown"; + } +} + +/** + * Decide whether inline dispatch may run for the current unit. + * + * Purpose: make the unsafe default-on inline path fail closed until its + * contract requirements are validated, while retaining an explicit audited + * escape hatch for operator-driven development. + * + * Consumer: tryInlineDispatch in auto/run-unit.js. + */ +export const inlineRuntimeGate = { + id: INLINE_RUNTIME_GATE_ID, + type: "policy", + execute(ctx = {}) { + const statuses = Object.fromEntries( + INLINE_RUNTIME_GATE_REQUIREMENTS.map((id) => [ + id, + readRequirementStatus(id), + ]), + ); + const allValidated = INLINE_RUNTIME_GATE_REQUIREMENTS.every( + (id) => statuses[id] === "validated", + ); + if (allValidated) { + return { + outcome: "pass", + rationale: `inline dispatch allowed: R020=${statuses.R020} R066=${statuses.R066}`, + }; + } + + if (process.env.SF_INLINE_DISPATCH === "1") { + debugLog(INLINE_RUNTIME_GATE_ID, { + event: "audited-bypass", + statuses, + unitType: ctx.unitType, + unitId: ctx.unitId, + }); + return { + outcome: "pass", + rationale: `inline dispatch allowed via audited bypass (SF_INLINE_DISPATCH=1): R020=${statuses.R020} R066=${statuses.R066}`, + }; + } + + return { + outcome: "fail", + failureClass: "policy", + rationale: `inline dispatch refused: R020=${statuses.R020} R066=${statuses.R066}; both must be validated or SF_INLINE_DISPATCH=1 must be set`, + }; + }, +};