From 5e9c6f5031e39c8b0f8a6536c9959efae8efdade Mon Sep 17 00:00:00 2001 From: Daedalus Date: Mon, 3 Aug 2026 20:26:19 +0000 Subject: [PATCH 1/2] fix: harden receipt enforcement readiness --- .github/workflows/ci.yml | 20 ++++ AGENTS.md | 2 +- README.md | 7 +- bin/leadline.js | 11 +- src/claude-code.js | 96 +++++++++++++++--- src/contract-engine.js | 190 ++++++++++++++++++++++++++++++----- test/claude-code.test.js | 106 ++++++++++++++++++- test/contract-engine.test.js | 73 +++++++++++++- 8 files changed, 459 insertions(+), 46 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ef4938c..a83d298 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,5 +56,25 @@ jobs: - name: Run test suite run: npm test + - name: Smoke the real init path + shell: bash + run: | + set -euo pipefail + dry_target="$(mktemp -d)/not-created" + node ./bin/leadline.js init --claude-code --project "$dry_target" --mode advisory --dry-run > "$RUNNER_TEMP/leadline-init-preview.json" + test ! -e "$dry_target" + target="$(mktemp -d)" + node ./bin/leadline.js init --claude-code --project "$target" --mode advisory > "$RUNNER_TEMP/leadline-init-result.json" + node - "$target" <<'NODE' + const fs = require('node:fs'); + const path = require('node:path'); + const target = process.argv[2]; + const settings = JSON.parse(fs.readFileSync(path.join(target, '.claude', 'settings.json'), 'utf8')); + const events = ['UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop']; + if (!events.every((event) => settings.hooks?.[event]?.length === 1)) process.exit(1); + const config = fs.readFileSync(path.join(target, '.leadline', 'config.yaml'), 'utf8'); + if (!config.includes('mode: advisory')) process.exit(1); + NODE + - name: Run frozen benchmark run: npm run bench diff --git a/AGENTS.md b/AGENTS.md index cfb464c..41644fb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,7 +27,7 @@ A local-first evidence router and policy engine for coding agents. It classifies ## Commands ```bash -npx github:Nazim22/leadline init --claude-code [--dry-run] # install hooks into a project +npx github:Nazim22/leadline init --claude-code [--mode advisory|enforce] [--dry-run] # preview/install hooks npx github:Nazim22/leadline route "" # see the evidence contract for a prompt npx github:Nazim22/leadline trace --project . --session # render the decision trace npm test # full suite diff --git a/README.md b/README.md index 3455ac6..ba62005 100644 --- a/README.md +++ b/README.md @@ -87,10 +87,11 @@ Four evidence families, mapped by configured routes (`policy/routes.yaml`) and e ## Get started (60 seconds) ```bash -npx github:Nazim22/leadline init --claude-code --dry-run # advisory: observes, never blocks +npx github:Nazim22/leadline init --claude-code --mode advisory --dry-run # preview only; writes nothing +npx github:Nazim22/leadline init --claude-code --mode advisory # install observation-only hooks # work normally for a while, then: -npx github:Nazim22/leadline trace --project . --session # what WOULD have been caught -npx github:Nazim22/leadline init --claude-code # flip to enforce +npx github:Nazim22/leadline trace --project . --session # what WOULD have been caught +npx github:Nazim22/leadline init --claude-code --mode enforce # flip to enforce ``` (An npm package — plain `npx leadline` — is coming; the GitHub specifier above works today.) diff --git a/bin/leadline.js b/bin/leadline.js index 0bb7860..d6622a7 100755 --- a/bin/leadline.js +++ b/bin/leadline.js @@ -7,7 +7,7 @@ const YAML = require('yaml'); const { runBenchmark } = require('../src/benchmark'); const { createPlanner } = require('../src/planner'); const { - handleClaudeHook, installClaudeCode, readTrace, renderDecisionTrace, + handleClaudeHook, installClaudeCode, planClaudeCodeInstall, readTrace, renderDecisionTrace, } = require('../src/claude-code'); const root = path.join(__dirname, '..'); @@ -33,7 +33,7 @@ function usage() { 'usage:', ' leadline route ', ' leadline bench', - ' leadline init --claude-code [--project ] [--dry-run]', + ' leadline init --claude-code [--project ] [--mode ] [--dry-run]', ' leadline hook [--project ]', ' leadline trace [--project ] [--session ]', '', @@ -79,7 +79,12 @@ function main(argv = process.argv.slice(2), io = {}) { if (command === 'init') { if (!args.includes('--claude-code')) { stderr.write('leadline init requires --claude-code\n'); return 2; } const projectDir = path.resolve(takeOption(args, '--project', cwd)); - const result = installClaudeCode({ projectDir, packageRoot: root, mode: args.includes('--dry-run') ? 'advisory' : 'enforce' }); + const mode = takeOption(args, '--mode', 'enforce'); + if (!['enforce', 'advisory'].includes(mode)) throw new TypeError('mode must be advisory or enforce'); + const options = { projectDir, packageRoot: root, mode }; + const result = args.includes('--dry-run') + ? planClaudeCodeInstall(options) + : installClaudeCode(options); stdout.write(`${JSON.stringify(result)}\n`); return 0; } diff --git a/src/claude-code.js b/src/claude-code.js index 576b922..fc036d2 100644 --- a/src/claude-code.js +++ b/src/claude-code.js @@ -6,7 +6,7 @@ const path = require('node:path'); const YAML = require('yaml'); const { createPlanner } = require('./planner'); const { - createContractEngine, loadPolicyPacks, negotiateAdapterMode, renderDecisionTrace, + createContractEngine, loadPolicyPacks, negotiateAdapterMode, renderDecisionTrace, validateTraceRow, } = require('./contract-engine'); const HOOK_EVENTS = Object.freeze(['UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop']); @@ -57,6 +57,7 @@ function traceFile(projectDir, sessionId) { function appendTrace(file, trace) { if (!trace) return; + validateTraceRow(trace); fs.mkdirSync(path.dirname(file), { recursive: true }); fs.appendFileSync(file, `${JSON.stringify(trace)}\n`, { encoding: 'utf8', mode: 0o600, flag: 'a' }); } @@ -69,6 +70,22 @@ function loadMode(projectDir) { return config.mode; } +function correctionRouteAvailable(projectDir, route) { + const configured = String(route || ''); + if (!configured.toLocaleLowerCase().startsWith('read ')) return true; + const target = configured.slice(5).trim(); + if (!target) return false; + const absolute = path.resolve(projectDir, target); + const relative = path.relative(projectDir, absolute); + if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return false; + try { + return fs.statSync(absolute).isFile(); + } catch (error) { + if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return false; + throw error; + } +} + function loadRuntime(projectDir, packageRoot) { const packDirectory = fs.existsSync(path.join(projectDir, '.leadline', 'packs')) ? path.join(projectDir, '.leadline', 'packs') @@ -81,7 +98,10 @@ function loadRuntime(projectDir, packageRoot) { directory: packDirectory, schemaPath: path.join(packageRoot, 'schema', 'policy-pack.schema.json'), }); - return createContractEngine({ planner, packs, mode: loadMode(projectDir) }); + return createContractEngine({ + planner, packs, mode: loadMode(projectDir), + routeAvailable: (route) => correctionRouteAvailable(projectDir, route), + }); } function canonicalJson(value) { @@ -127,20 +147,49 @@ function emptyState(engine, input) { }).state; } +function stripInjectedSpans(prompt) { + return String(prompt || '') + .replace(/]*)?>[\s\S]*?(?:<\/task-notification>|$)/giu, '\n') + .replace(/]*)?>[\s\S]*?(?:<\/system-reminder>|$)/giu, '\n') + .replace(/\[LEADLINE\][\s\S]*?(?:\[\/LEADLINE\]|$)/giu, '\n'); +} + +function boundedField(value, maximumBytes) { + const text = String(value || '').replace(/\s+/gu, ' ').trim(); + if (Buffer.byteLength(text, 'utf8') <= maximumBytes) return text; + const words = text.split(' '); + let bounded = ''; + for (const word of words) { + const candidate = bounded ? `${bounded} ${word}` : word; + if (Buffer.byteLength(`${candidate} …`, 'utf8') > maximumBytes) break; + bounded = candidate; + } + return bounded ? `${bounded} …` : '[overlong token omitted]'; +} + function renderInjection(contract) { if (!contract.steps.length) return ''; + const unmatched = contract.unmatched_clauses.length + ? `${contract.unmatched_clauses.length} (${[...new Set(contract.unmatched_clauses.map((clause) => clause.reason))].join(', ')})` + : 'none'; const lines = [ '[LEADLINE]', `contract: ${contract.contract_id}`, `complete: ${contract.complete}`, - `unmatched: ${contract.unmatched_clauses.length - ? contract.unmatched_clauses.map((clause) => `${clause.index}:${clause.text}`).join(' | ') - : 'none'}`, + `unmatched: ${boundedField(unmatched, 160)}`, ]; - contract.steps.slice(0, 4).forEach((step, index) => { - lines.push(`obligation_${index + 1}: ${step.evidence_target.question} | family: ${step.need} | route: ${step.provider} | satisfies: real_result_only`); - }); - if (contract.steps.length > 4) lines.push(`obligations_omitted: ${contract.steps.length - 4}`); + let omitted = Math.max(0, contract.steps.length - 4); + for (const [index, step] of contract.steps.slice(0, 4).entries()) { + const subject = boundedField(step.evidence_target.subject, 120); + const route = boundedField(step.provider, 80); + const line = `obligation_${index + 1}: ${subject} | family: ${step.need} | route: ${route} | satisfies: real_result_only`; + if (Buffer.byteLength([...lines, line, '[/LEADLINE]'].join('\n'), 'utf8') <= 1200) lines.push(line); + else omitted += 1; + } + if (omitted > 0) { + const line = `obligations_omitted: ${omitted}`; + if (Buffer.byteLength([...lines, line, '[/LEADLINE]'].join('\n'), 'utf8') <= 1200) lines.push(line); + } lines.push('[/LEADLINE]'); return lines.slice(0, 10).join('\n'); } @@ -244,7 +293,8 @@ function handleClaudeHookLocked(input, { projectDir, packageRoot }) { const traces = traceFile(root, input.session_id); if (input.hook_event_name === 'UserPromptSubmit') { - const prompt = typeof input.prompt === 'string' ? input.prompt : ''; + const rawPrompt = typeof input.prompt === 'string' ? input.prompt : ''; + const prompt = stripInjectedSpans(rawPrompt).trim(); const turnId = `turn-${crypto.createHash('sha256').update(`${input.session_id || ''}\0${prompt}`).digest('hex').slice(0, 16)}`; const state = engine.begin(prompt, { sessionId: String(input.session_id || 'unknown'), turnId }).state; writeJsonAtomic(statePath, state); @@ -291,7 +341,10 @@ function handleClaudeHookLocked(input, { projectDir, packageRoot }) { obligation: 'bind tool result to its accepted call', family: 'unknown', attempted: String(input.tool_name || 'unknown'), verdict: 'CORRECT', corrected_route: 'matching PreToolUse/PostToolUse tool_use_id and arguments', - receipt: { satisfied: false, failure: 'unbound_tool_result', call_id: binding?.call_id || null }, + receipt: { + rule_id: 'receipt-binding', satisfied: false, + failure: 'unbound_tool_result', call_id: binding?.call_id || null, + }, }); if (engine.mode === 'advisory') { return { hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: message } }; @@ -333,9 +386,25 @@ function managedHookCommand(packageRoot, event) { return `${shellQuote(process.execPath)} ${shellQuote(cli)} hook ${event} --project "\${CLAUDE_PROJECT_DIR}"`; } -function installClaudeCode({ projectDir, packageRoot = path.join(__dirname, '..'), mode = 'enforce' } = {}) { +function planClaudeCodeInstall({ projectDir, packageRoot = path.join(__dirname, '..'), mode = 'enforce' } = {}) { if (!['enforce', 'advisory'].includes(mode)) throw new TypeError('mode must be enforce or advisory'); const root = path.resolve(projectDir || process.cwd()); + const packFiles = fs.readdirSync(path.join(packageRoot, 'policy', 'packs')) + .filter((name) => name.endsWith('.yaml')).sort(); + return { + project: root, adapter: 'claude-code', mode, dry_run: true, hooks: [...HOOK_EVENTS], + writes: [ + path.join(root, '.claude', 'settings.json'), + ...packFiles.map((file) => path.join(root, '.leadline', 'packs', file)), + path.join(root, '.leadline', 'config.yaml'), + path.join(root, '.leadline', '.gitignore'), + ], + }; +} + +function installClaudeCode({ projectDir, packageRoot = path.join(__dirname, '..'), mode = 'enforce' } = {}) { + const plan = planClaudeCodeInstall({ projectDir, packageRoot, mode }); + const root = plan.project; const claudeDir = path.join(root, '.claude'); const leadlineDir = path.join(root, '.leadline'); const targetPackDir = path.join(leadlineDir, 'packs'); @@ -380,5 +449,6 @@ function readTrace(projectDir, sessionId) { module.exports = { CLAUDE_CODE_CAPABILITIES, HOOK_EVENTS, _withStateLock: withStateLock, - handleClaudeHook, installClaudeCode, readTrace, renderDecisionTrace, renderInjection, + handleClaudeHook, installClaudeCode, planClaudeCodeInstall, readTrace, + renderDecisionTrace, renderInjection, stripInjectedSpans, }; diff --git a/src/contract-engine.js b/src/contract-engine.js index 6e62417..0ee8f1a 100644 --- a/src/contract-engine.js +++ b/src/contract-engine.js @@ -22,6 +22,9 @@ const RULE_KEYS = [ ]; const TEMPLATE_KEYS = ['unmet', 'corrective', 'alternative']; const TRACE_KEYS = ['obligation', 'family', 'attempted', 'verdict', 'corrected_route', 'receipt']; +// Internal anti-lockup ceiling: the third corrective fire or 15 elapsed minutes retires the obligation. +const MAX_CORRECTION_FIRES = 3; +const MAX_CORRECTION_AGE_MS = 15 * 60 * 1000; function exactKeys(value, keys) { return value && typeof value === 'object' && !Array.isArray(value) @@ -195,11 +198,25 @@ function fill(template, values) { return String(template).replace(/\{([a-z_]+)\}/gu, (_match, key) => String(values[key] ?? '')); } -function traceRow({ obligation, family, attempted = null, verdict, correctedRoute = null, receipt = null }) { +function validateTraceRow(trace) { + if (!trace || Object.keys(trace).join('\0') !== TRACE_KEYS.join('\0')) { + throw new TypeError('trace must contain the ordered Leadline trace fields'); + } + if (!nonemptyString(trace.obligation) || !nonemptyString(trace.family) + || !nonemptyString(trace.attempted) || !DECISION_SET.has(trace.verdict) + || (trace.corrected_route !== null && !nonemptyString(trace.corrected_route)) + || !trace.receipt || typeof trace.receipt !== 'object' || Array.isArray(trace.receipt) + || !nonemptyString(trace.receipt.rule_id)) { + throw new TypeError('trace obligation, family, attempted, verdict, and receipt.rule_id are required'); + } + return true; +} + +function traceRow({ obligation, family, attempted, verdict, correctedRoute = null, receipt }) { const trace = { obligation, family, attempted, verdict, corrected_route: correctedRoute, receipt, }; - if (Object.keys(trace).join('\0') !== TRACE_KEYS.join('\0')) throw new Error('trace field order changed'); + validateTraceRow(trace); return trace; } @@ -235,7 +252,10 @@ function denialFor(rule, step, verdict, mode, attempted, extraReceipt = {}) { } function currentStep(state) { - return state.contract.steps.find((step) => !state.satisfied_step_ids.includes(step.step_id)) || null; + state.retired_step_ids ||= []; + return state.contract.steps.find((step) => ( + !state.satisfied_step_ids.includes(step.step_id) && !state.retired_step_ids.includes(step.step_id) + )) || null; } function hasFreshRuleSatisfaction(state, rule, now) { @@ -260,33 +280,113 @@ function negotiateAdapterMode(capabilities = {}) { return 'AUDIT'; } -function createContractEngine({ planner, packs, mode = 'enforce', now = () => new Date() } = {}) { +function createContractEngine({ + planner, packs, mode = 'enforce', now = () => new Date(), routeAvailable = () => true, +} = {}) { if (!planner || typeof planner.plan !== 'function') throw new TypeError('planner is required'); if (!Array.isArray(packs)) throw new TypeError('packs must be an array'); if (!['enforce', 'advisory'].includes(mode)) throw new TypeError('mode must be enforce or advisory'); + if (typeof routeAvailable !== 'function') throw new TypeError('routeAvailable must be a function'); const rules = packs.flatMap((pack) => pack.rules); function begin(prompt, { sessionId, turnId } = {}) { if (!nonemptyString(sessionId) || !nonemptyString(turnId)) throw new TypeError('sessionId and turnId are required'); + const contract = planner.plan(prompt, { turnId }); + const startedAt = now().valueOf(); return { state: { schema_version: '1.0', session_id: sessionId, turn_id: turnId, - contract: planner.plan(prompt, { turnId }), mode, + contract, mode, satisfied_step_ids: [], satisfied_rules: {}, pending_rule_id: null, + pending_obligation_key: null, rule_denials: {}, awaiting_approval_rule_id: null, awaiting_approval: null, stop_denials: {}, last_denial: null, + correction_fires: {}, + obligation_started_at: Object.fromEntries( + contract.steps.map((step) => [`step:${step.step_id}`, startedAt]), + ), + retired_step_ids: [], retired_rule_operations: [], }, }; } + function retire(state, { kind, id, retirementKey = id }) { + if (kind === 'step') { + state.retired_step_ids ||= []; + if (!state.retired_step_ids.includes(id)) state.retired_step_ids.push(id); + return; + } + state.retired_rule_operations ||= []; + if (!state.retired_rule_operations.includes(retirementKey)) { + state.retired_rule_operations.push(retirementKey); + } + if (state.pending_rule_id === id && state.pending_obligation_key === retirementKey) { + state.pending_rule_id = null; + state.pending_obligation_key = null; + state.awaiting_approval_rule_id = null; + state.awaiting_approval = null; + } + } + + function abstainUnsatisfiable(state, { + kind, id, retirementKey = id, obligation, family, attempted, correctedRoute, fireCount, + }) { + retire(state, { kind, id, retirementKey }); + return emitDecision({ + verdict: 'ABSTAIN', + trace: traceRow({ + obligation, family, attempted, verdict: 'ABSTAIN', correctedRoute, + receipt: { rule_id: id, failure: 'unsatisfiable', fire_count: fireCount }, + }), + }); + } + + function correctionThreshold(state, details) { + const key = `${details.kind}:${details.retirementKey || details.id}`; + state.correction_fires ||= {}; + state.obligation_started_at ||= {}; + const fireCount = (state.correction_fires[key] || 0) + 1; + state.correction_fires[key] = fireCount; + const timestamp = now().valueOf(); + if (!Number.isFinite(state.obligation_started_at[key])) state.obligation_started_at[key] = timestamp; + const age = timestamp - state.obligation_started_at[key]; + if (fireCount < MAX_CORRECTION_FIRES && age < MAX_CORRECTION_AGE_MS) return null; + return abstainUnsatisfiable(state, { ...details, fireCount }); + } + + function correctionAvailable(state, details) { + if (routeAvailable(details.correctedRoute)) return null; + const key = `${details.kind}:${details.retirementKey || details.id}`; + return abstainUnsatisfiable(state, { + ...details, fireCount: state.correction_fires?.[key] || 0, + }); + } + function beforeTool(state, toolCall) { const attempted = toolIdentity(toolCall); const timestamp = now().valueOf(); - const operationRule = rules.find((rule) => ruleMatchesOperation(rule, toolCall)); + state.retired_rule_operations ||= []; + const matchedOperationRule = rules.find((rule) => ruleMatchesOperation(rule, toolCall)); + const operationRetirementKey = matchedOperationRule + ? `${matchedOperationRule.id}\0${operationFingerprint(toolCall)}` : null; + const operationRule = matchedOperationRule + && !state.retired_rule_operations.includes(operationRetirementKey) + ? matchedOperationRule : null; if (operationRule && !hasFreshRuleSatisfaction(state, operationRule, timestamp)) { + const details = { + kind: 'rule', id: operationRule.id, retirementKey: operationRetirementKey, + obligation: 'read the protected-operation prerequisite', + family: operationRule.required_family, attempted, + correctedRoute: operationRule.preferred_route, + }; + const unavailable = correctionAvailable(state, details); + if (unavailable) return unavailable; state.rule_denials ||= {}; if (mode === 'enforce' && state.pending_rule_id === operationRule.id - && (state.rule_denials[operationRule.id] || 0) >= 1) { + && state.pending_obligation_key === operationRetirementKey + && (state.rule_denials[operationRetirementKey] || 0) >= 1) { + const expired = correctionThreshold(state, details); + if (expired) return expired; state.awaiting_approval_rule_id = operationRule.id; state.awaiting_approval = { rule_id: operationRule.id, @@ -301,8 +401,11 @@ function createContractEngine({ planner, packs, mode = 'enforce', now = () => ne }), }); } + const expired = correctionThreshold(state, details); + if (expired) return expired; state.pending_rule_id = operationRule.id; - state.rule_denials[operationRule.id] = (state.rule_denials[operationRule.id] || 0) + 1; + state.pending_obligation_key = operationRetirementKey; + state.rule_denials[operationRetirementKey] = (state.rule_denials[operationRetirementKey] || 0) + 1; const syntheticStep = { evidence_target: { question: 'read the protected-operation prerequisite', subject: '.leadline/access-map.md' }, }; @@ -322,6 +425,14 @@ function createContractEngine({ planner, packs, mode = 'enforce', now = () => ne && (!candidate.trigger.tool_names?.length || candidate.trigger.tool_names.some((name) => name.toLocaleLowerCase() === attempted.toLocaleLowerCase()))); if (rule && !routeMatches(rule.preferred_route, toolCall)) { + const details = { + kind: 'step', id: step.step_id, obligation: step.evidence_target.question, + family: step.need, attempted, correctedRoute: rule.preferred_route, + }; + const unavailable = correctionAvailable(state, details); + if (unavailable) return unavailable; + const expired = correctionThreshold(state, details); + if (expired) return expired; const decision = denialFor(rule, step, 'DENY', mode, attempted, { gaming_attempt: false }); state.last_denial = { corrected_route: rule.preferred_route, rule_id: rule.id }; return decision; @@ -330,7 +441,7 @@ function createContractEngine({ planner, packs, mode = 'enforce', now = () => ne verdict: 'ALLOW', trace: traceRow({ obligation: step.evidence_target.question, family: step.need, attempted, verdict: 'ALLOW', - receipt: { rule_id: rule?.id || null, correction_followed: false, satisfied: false }, + receipt: { rule_id: rule?.id || step.step_id, correction_followed: false, satisfied: false }, }), }); } @@ -338,10 +449,12 @@ function createContractEngine({ planner, packs, mode = 'enforce', now = () => ne function afterTool(state, toolCall, result) { const attempted = toolIdentity(toolCall); const pending = rules.find((rule) => rule.id === state.pending_rule_id); + const pendingObligationKey = state.pending_obligation_key; const approval = state.awaiting_approval; if (pending && approval?.rule_id === pending.id && approval.operation_fingerprint === operationFingerprint(toolCall)) { state.pending_rule_id = null; + state.pending_obligation_key = null; state.awaiting_approval_rule_id = null; state.awaiting_approval = null; return emitDecision({ @@ -356,6 +469,7 @@ function createContractEngine({ planner, packs, mode = 'enforce', now = () => ne if (hasSubstantiveResult(result?.value, toolCall) && !result?.is_error && result?.error == null) { state.satisfied_rules[pending.id] = now().valueOf(); state.pending_rule_id = null; + state.pending_obligation_key = null; state.awaiting_approval_rule_id = null; state.awaiting_approval = null; return emitDecision({ @@ -366,6 +480,15 @@ function createContractEngine({ planner, packs, mode = 'enforce', now = () => ne }), }); } + const details = { + kind: 'rule', id: pending.id, retirementKey: pendingObligationKey || pending.id, + obligation: 'read the protected-operation prerequisite', + family: pending.required_family, attempted, correctedRoute: pending.preferred_route, + }; + const unavailable = correctionAvailable(state, details); + if (unavailable) return unavailable; + const expired = correctionThreshold(state, details); + if (expired) return expired; const message = `Receipt failed: run ${pending.preferred_route} and return a real non-empty result.`; return emitDecision({ verdict: 'CORRECT', message, @@ -384,13 +507,22 @@ function createContractEngine({ planner, packs, mode = 'enforce', now = () => ne ? rule.required_family : familyFor(toolCall); if (actualFamily !== step.need) { - const message = `Receipt failed: use ${rule?.preferred_route || step.provider} and return evidence for ${subjectFor(step)}.`; + const correctedRoute = rule?.preferred_route || step.provider; + const details = { + kind: 'step', id: step.step_id, obligation: step.evidence_target.question, + family: step.need, attempted, correctedRoute, + }; + const unavailable = correctionAvailable(state, details); + if (unavailable) return unavailable; + const expired = correctionThreshold(state, details); + if (expired) return expired; + const message = `Receipt failed: use ${correctedRoute} and return evidence for ${subjectFor(step)}.`; return emitDecision({ verdict: 'CORRECT', message, trace: traceRow({ obligation: step.evidence_target.question, family: step.need, attempted, verdict: 'CORRECT', - correctedRoute: rule?.preferred_route || step.provider, - receipt: { rule_id: rule?.id || null, satisfied: false, failure: 'wrong_source', gaming_attempt: true }, + correctedRoute, + receipt: { rule_id: rule?.id || step.step_id, satisfied: false, failure: 'wrong_source', gaming_attempt: true }, }), }); } @@ -405,16 +537,25 @@ function createContractEngine({ planner, packs, mode = 'enforce', now = () => ne fresh, }); if (!satisfaction.satisfied) { + const correctedRoute = rule?.preferred_route || step.provider; + const details = { + kind: 'step', id: step.step_id, obligation: step.evidence_target.question, + family: step.need, attempted, correctedRoute, + }; + const unavailable = correctionAvailable(state, details); + if (unavailable) return unavailable; + const expired = correctionThreshold(state, details); + if (expired) return expired; const requirement = satisfaction.failure === 'empty' ? 'a real non-empty result' : satisfaction.failure === 'stale' ? 'a fresh result' : `a result relevant to ${subjectFor(step)}`; - const message = `Receipt failed (${satisfaction.failure}): run ${rule?.preferred_route || step.provider} and return ${requirement}.`; + const message = `Receipt failed (${satisfaction.failure}): run ${correctedRoute} and return ${requirement}.`; return emitDecision({ verdict: 'CORRECT', message, trace: traceRow({ obligation: step.evidence_target.question, family: step.need, attempted, verdict: 'CORRECT', - correctedRoute: rule?.preferred_route || step.provider, - receipt: { rule_id: rule?.id || null, satisfied: false, failure: satisfaction.failure }, + correctedRoute, + receipt: { rule_id: rule?.id || step.step_id, satisfied: false, failure: satisfaction.failure }, }), }); } @@ -425,7 +566,7 @@ function createContractEngine({ planner, packs, mode = 'enforce', now = () => ne verdict: 'ALLOW', trace: traceRow({ obligation: step.evidence_target.question, family: step.need, attempted, verdict: 'ALLOW', - receipt: { rule_id: rule?.id || null, satisfied: true, correction_followed: Boolean(correctionFollowed) }, + receipt: { rule_id: rule?.id || step.step_id, satisfied: true, correction_followed: Boolean(correctionFollowed) }, }), }); } @@ -439,20 +580,23 @@ function createContractEngine({ planner, packs, mode = 'enforce', now = () => ne const route = pending?.preferred_route || rules.find((rule) => ruleMatchesStep(rule, step))?.preferred_route || step.provider; + const key = pending?.id || step.step_id; if (explicitAbstention(finalMessage)) { return emitDecision({ verdict: 'ABSTAIN', - trace: traceRow({ obligation, family, verdict: 'ABSTAIN', correctedRoute: route, receipt: { reason: String(finalMessage).trim() } }), + trace: traceRow({ + obligation, family, attempted: 'Stop', verdict: 'ABSTAIN', correctedRoute: route, + receipt: { rule_id: key, reason: String(finalMessage).trim() }, + }), }); } - const key = pending?.id || step.step_id; const denials = state.stop_denials[key] || 0; if (denials >= 2) { return emitDecision({ verdict: 'CORRECT', message: `WARN: allowing completion after two Stop denials; ${obligation} remains unmet.`, trace: traceRow({ - obligation, family, verdict: 'CORRECT', correctedRoute: route, - receipt: { satisfied: false, anti_lockup_downgrade: true, stop_denials: denials }, + obligation, family, attempted: 'Stop', verdict: 'CORRECT', correctedRoute: route, + receipt: { rule_id: key, satisfied: false, anti_lockup_downgrade: true, stop_denials: denials }, }), }); } @@ -465,8 +609,8 @@ function createContractEngine({ planner, packs, mode = 'enforce', now = () => ne return emitDecision({ verdict: mode === 'enforce' ? 'DENY' : 'CORRECT', message, block: mode === 'enforce', trace: traceRow({ - obligation, family, verdict: mode === 'enforce' ? 'DENY' : 'CORRECT', correctedRoute: route, - receipt: { satisfied: false, stop_denials: state.stop_denials[key] }, + obligation, family, attempted: 'Stop', verdict: mode === 'enforce' ? 'DENY' : 'CORRECT', correctedRoute: route, + receipt: { rule_id: key, satisfied: false, stop_denials: state.stop_denials[key] }, }), }); } @@ -494,5 +638,5 @@ function renderDecisionTrace(rows) { module.exports = { DECISIONS, createContractEngine, emitDecision, loadPolicyPacks, negotiateAdapterMode, - renderDecisionTrace, validatePack, + renderDecisionTrace, validatePack, validateTraceRow, }; diff --git a/test/claude-code.test.js b/test/claude-code.test.js index f55f37c..cf5cbb2 100644 --- a/test/claude-code.test.js +++ b/test/claude-code.test.js @@ -64,7 +64,7 @@ test('installer replaces only managed hook entries inside a mixed group', () => assert.match(groups[1].hooks[0].command, /leadline\.js.* hook PreToolUse/); }); -test('installer is idempotent and dry-run installs advisory mode', () => { +test('installer is idempotent in advisory mode', () => { const target = project(); installClaudeCode({ projectDir: target, packageRoot: root, mode: 'advisory' }); installClaudeCode({ projectDir: target, packageRoot: root, mode: 'advisory' }); @@ -73,6 +73,45 @@ test('installer is idempotent and dry-run installs advisory mode', () => { assert.match(fs.readFileSync(path.join(target, '.leadline', 'config.yaml'), 'utf8'), /mode: advisory/); }); +test('CLI dry-run previews the selected mode and writes nothing', () => { + for (const mode of ['advisory', 'enforce']) { + const target = project(); + const output = []; + const io = { + cwd: target, + stdout: { write: (value) => output.push(value) }, + stderr: { write: () => assert.fail('dry-run must not write stderr') }, + }; + assert.equal(main([ + 'init', '--claude-code', '--project', target, '--mode', mode, '--dry-run', + ], io), 0); + const preview = JSON.parse(output.join('')); + assert.equal(preview.dry_run, true); + assert.equal(preview.mode, mode); + assert.deepEqual(preview.hooks, ['UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop']); + assert.ok(preview.writes.some((file) => file.endsWith('/.claude/settings.json'))); + assert.ok(preview.writes.some((file) => file.endsWith('/.leadline/config.yaml'))); + assert.deepEqual(fs.readdirSync(target), []); + } +}); + +test('CLI mode is explicit, validated, and defaults to enforce', () => { + const advisory = project(); + assert.equal(main(['init', '--claude-code', '--project', advisory, '--mode', 'advisory']), 0); + assert.match(fs.readFileSync(path.join(advisory, '.leadline', 'config.yaml'), 'utf8'), /mode: advisory/); + + const enforced = project(); + assert.equal(main(['init', '--claude-code', '--project', enforced]), 0); + assert.match(fs.readFileSync(path.join(enforced, '.leadline', 'config.yaml'), 'utf8'), /mode: enforce/); + + const invalid = project(); + assert.throws( + () => main(['init', '--claude-code', '--project', invalid, '--mode', 'audit']), + /mode must be advisory or enforce/, + ); + assert.deepEqual(fs.readdirSync(invalid), []); +}); + test('advisory PostToolUse feedback never blocks the Claude loop', () => { const target = project(); installClaudeCode({ projectDir: target, packageRoot: root, mode: 'advisory' }); @@ -133,6 +172,51 @@ test('UserPromptSubmit injects a compact route block and stays silent without ob assert.deepEqual(handleClaudeHook(event(target, 'UserPromptSubmit', { prompt: 'Make it better' }), { projectDir: target, packageRoot: root }), {}); }); +test('system and hook-injected spans cannot create obligations', () => { + const target = project(); + installClaudeCode({ projectDir: target, packageRoot: root, mode: 'enforce' }); + const injected = [ + '', + 'completed', + 'What did we decide about deployment? Who calls performSync?', + '', + 'Check whether port 443 is open.', + '[LEADLINE]\nobligation_1: what did we ship last session\n[/LEADLINE]', + ].join('\n'); + assert.deepEqual( + handleClaudeHook(event(target, 'UserPromptSubmit', { prompt: injected }), { projectDir: target, packageRoot: root }), + {}, + ); + const state = JSON.parse(fs.readFileSync(path.join(target, '.leadline', 'state', 'session-live-1.json'), 'utf8')); + assert.deepEqual(state.contract.steps, []); +}); + +test('user-authored text outside injected spans still creates its own obligation', () => { + const target = project(); + installClaudeCode({ projectDir: target, packageRoot: root, mode: 'enforce' }); + const prompt = [ + 'What did we decide about deployment?', + 'Who calls performSync?', + ].join('\n'); + const result = handleClaudeHook(event(target, 'UserPromptSubmit', { prompt }), { projectDir: target, packageRoot: root }); + assert.match(result.hookSpecificOutput.additionalContext, /family: structural/); + assert.doesNotMatch(result.hookSpecificOutput.additionalContext, /deployment/); + const state = JSON.parse(fs.readFileSync(path.join(target, '.leadline', 'state', 'session-live-1.json'), 'utf8')); + assert.deepEqual(state.contract.steps.map((step) => step.need), ['structural']); +}); + +test('additionalContext is bounded and never truncates an obligation mid-word', () => { + const target = project(); + installClaudeCode({ projectDir: target, packageRoot: root, mode: 'enforce' }); + const prompt = `Who calls ${'VeryLongSymbol'.repeat(300)}?`; + const result = handleClaudeHook(event(target, 'UserPromptSubmit', { prompt }), { projectDir: target, packageRoot: root }); + const context = result.hookSpecificOutput.additionalContext; + assert.ok(Buffer.byteLength(context, 'utf8') <= 1200); + assert.ok(context.split('\n').length <= 10); + assert.doesNotMatch(context, /VeryLongSymbol/u); + assert.match(context, /overlong token omitted/u); +}); + test('procedural and protected-data prompts create no hook obligation in advisory or enforce mode', () => { const prompts = [ 'byte-review (git fetch; git diff base..head), verify hashes, then merge', @@ -168,6 +252,7 @@ test('procedural and protected-data prompts create no hook obligation in advisor test('protected operation denial escalates to an executable human approval fallback', () => { const target = project(); installClaudeCode({ projectDir: target, packageRoot: root, mode: 'enforce' }); + fs.writeFileSync(path.join(target, '.leadline', 'access-map.md'), 'production requires approval\n'); handleClaudeHook(event(target, 'UserPromptSubmit', { prompt: 'Deploy the service' }), { projectDir: target, packageRoot: root }); const pre = event(target, 'PreToolUse', { tool_name: 'Bash', tool_input: { command: 'terraform apply' }, tool_use_id: 'deploy-1', @@ -182,6 +267,23 @@ test('protected operation denial escalates to an executable human approval fallb }), { projectDir: target, packageRoot: root }), {}); }); +test('missing on-disk correction target emits one ABSTAIN and does not block the call', () => { + const target = project(); + installClaudeCode({ projectDir: target, packageRoot: root, mode: 'enforce' }); + handleClaudeHook(event(target, 'UserPromptSubmit', { prompt: 'Deploy the service' }), { projectDir: target, packageRoot: root }); + const pre = event(target, 'PreToolUse', { + tool_name: 'Bash', tool_input: { command: 'terraform apply' }, tool_use_id: 'missing-target', + }); + assert.deepEqual(handleClaudeHook(pre, { projectDir: target, packageRoot: root }), {}); + assert.deepEqual(handleClaudeHook(pre, { projectDir: target, packageRoot: root }), {}); + const rows = fs.readFileSync(path.join(target, '.leadline', 'traces', 'session-live-1.jsonl'), 'utf8') + .trim().split('\n').map(JSON.parse); + assert.equal(rows.length, 1); + assert.equal(rows[0].verdict, 'ABSTAIN'); + assert.equal(rows[0].receipt.failure, 'unsatisfiable'); + assert.equal(rows[0].receipt.fire_count, 0); +}); + test('PreToolUse deny, PostToolUse receipt, and Stop success fire through persisted session state', () => { const target = project(); installClaudeCode({ projectDir: target, packageRoot: root, mode: 'enforce' }); @@ -384,7 +486,7 @@ test('CLI main accepts documented init, hook, route, and trace argv contracts', const output = []; const errors = []; const io = { cwd: target, stdout: { write: (s) => output.push(s) }, stderr: { write: (s) => errors.push(s) } }; - assert.equal(main(['init', '--claude-code', '--project', target, '--dry-run'], io), 0); + assert.equal(main(['init', '--claude-code', '--project', target, '--mode', 'advisory'], io), 0); assert.equal(main(['route', 'Who', 'calls', 'performSync?'], io), 0); assert.equal(main(['trace', '--project', target, '--session', 'missing'], io), 0); assert.deepEqual(errors, []); diff --git a/test/contract-engine.test.js b/test/contract-engine.test.js index d2a19d6..cd461a0 100644 --- a/test/contract-engine.test.js +++ b/test/contract-engine.test.js @@ -236,11 +236,82 @@ test('human approval binds to the exact protected operation', () => { assert.equal(core.beforeTool(state, terraform).verdict, 'DENY'); assert.equal(core.beforeTool(state, terraform).verdict, 'ASK'); assert.notEqual(core.afterTool(state, kubectl, { value: 'deleted', is_error: false }).trace?.receipt?.human_approval_followed, true); - assert.equal(core.beforeTool(state, terraform).verdict, 'ASK'); assert.equal(core.afterTool(state, terraform, { value: 'applied', is_error: false }).trace.receipt.human_approval_followed, true); assert.equal(core.beforeTool(state, kubectl).verdict, 'DENY'); }); +test('a missing correction target abstains immediately and retires the rule', () => { + const core = createContractEngine({ + planner, packs, mode: 'enforce', routeAvailable: (route) => route !== 'Read .leadline/access-map.md', + }); + const state = core.begin('Deploy the service', { sessionId: 'missing', turnId: 'target' }).state; + const deploy = { provider: 'Bash', name: 'Bash', args: { command: 'terraform apply' } }; + const first = core.beforeTool(state, deploy); + assert.equal(first.verdict, 'ABSTAIN'); + assert.equal(first.block, false); + assert.equal(first.trace.attempted, 'Bash'); + assert.equal(first.trace.receipt.failure, 'unsatisfiable'); + assert.equal(first.trace.receipt.fire_count, 0); + assert.match(first.trace.receipt.rule_id, /access-map/); + assert.equal(core.beforeTool(state, deploy).verdict, 'ALLOW'); + assert.equal(core.beforeTool(state, deploy).trace, null); +}); + +test('the third correction abstains once, retires the step, and allows the call', () => { + const core = engine(); + const state = stateFor('Who calls performSync?'); + const wrong = { provider: 'Grep', name: 'Grep', args: { pattern: 'performSync' } }; + assert.equal(core.beforeTool(state, wrong).verdict, 'DENY'); + assert.equal(core.beforeTool(state, wrong).verdict, 'DENY'); + const third = core.beforeTool(state, wrong); + assert.equal(third.verdict, 'ABSTAIN'); + assert.equal(third.block, false); + assert.equal(third.trace.attempted, 'Grep'); + assert.equal(third.trace.receipt.failure, 'unsatisfiable'); + assert.equal(third.trace.receipt.fire_count, 3); + assert.ok(typeof third.trace.receipt.rule_id === 'string' && third.trace.receipt.rule_id.length > 0); + assert.equal(core.beforeTool(state, wrong).verdict, 'ALLOW'); + assert.equal(core.beforeTool(state, wrong).trace, null); +}); + +test('an obligation older than fifteen minutes abstains on its next corrective fire', () => { + let clock = new Date('2026-08-03T20:00:00.000Z'); + const core = createContractEngine({ planner, packs, mode: 'enforce', now: () => clock }); + const state = core.begin('Who calls performSync?', { sessionId: 'expiry', turnId: 'time' }).state; + clock = new Date('2026-08-03T20:15:00.000Z'); + const decision = core.beforeTool(state, { provider: 'Grep', name: 'Grep', args: { pattern: 'performSync' } }); + assert.equal(decision.verdict, 'ABSTAIN'); + assert.equal(decision.block, false); + assert.equal(decision.trace.receipt.failure, 'unsatisfiable'); + assert.equal(decision.trace.receipt.fire_count, 1); +}); + +test('protected-operation escalation also retires on the third fire in enforce mode', () => { + const core = createContractEngine({ planner, packs, mode: 'enforce', routeAvailable: () => true }); + const state = core.begin('Deploy the service', { sessionId: 'protected', turnId: 'expiry' }).state; + const deploy = { provider: 'Bash', name: 'Bash', args: { command: 'terraform apply' } }; + assert.equal(core.beforeTool(state, deploy).verdict, 'DENY'); + assert.equal(core.beforeTool(state, deploy).verdict, 'ASK'); + const third = core.beforeTool(state, deploy); + assert.equal(third.verdict, 'ABSTAIN'); + assert.equal(third.block, false); + assert.equal(third.trace.receipt.fire_count, 3); + assert.equal(core.beforeTool(state, deploy).verdict, 'ALLOW'); +}); + +test('all emitted trace rows have a non-empty attempted and receipt rule id', () => { + const core = engine(); + const state = stateFor('Who calls performSync?'); + const rows = [ + core.beforeTool(state, { provider: 'Grep', name: 'Grep', args: { pattern: 'performSync' } }).trace, + core.beforeStop(state, 'Done.').trace, + ]; + for (const row of rows) { + assert.ok(typeof row.attempted === 'string' && row.attempted.length > 0); + assert.ok(typeof row.receipt.rule_id === 'string' && row.receipt.rule_id.length > 0); + } +}); + test('stop blocks twice then warns and allows to prevent a session lockup', () => { const core = engine(); const state = stateFor('Who calls performSync?'); From e88fbc2027990c34dfe5529b897cb2d68f142ca3 Mon Sep 17 00:00:00 2001 From: Daedalus Date: Mon, 3 Aug 2026 20:41:23 +0000 Subject: [PATCH 2/2] fix: scope rule correction budget to obligation --- src/contract-engine.js | 28 ++++++++++++++------ test/contract-engine.test.js | 50 +++++++++++++++++++++++++++++++----- 2 files changed, 63 insertions(+), 15 deletions(-) diff --git a/src/contract-engine.js b/src/contract-engine.js index 0ee8f1a..9de747d 100644 --- a/src/contract-engine.js +++ b/src/contract-engine.js @@ -305,7 +305,7 @@ function createContractEngine({ obligation_started_at: Object.fromEntries( contract.steps.map((step) => [`step:${step.step_id}`, startedAt]), ), - retired_step_ids: [], retired_rule_operations: [], + retired_step_ids: [], retired_rule_ids: [], }, }; } @@ -316,9 +316,9 @@ function createContractEngine({ if (!state.retired_step_ids.includes(id)) state.retired_step_ids.push(id); return; } - state.retired_rule_operations ||= []; - if (!state.retired_rule_operations.includes(retirementKey)) { - state.retired_rule_operations.push(retirementKey); + state.retired_rule_ids ||= []; + if (!state.retired_rule_ids.includes(retirementKey)) { + state.retired_rule_ids.push(retirementKey); } if (state.pending_rule_id === id && state.pending_obligation_key === retirementKey) { state.pending_rule_id = null; @@ -341,6 +341,13 @@ function createContractEngine({ }); } + function clearCorrectionBudget(state, { kind, id, retirementKey = id }) { + const key = `${kind}:${retirementKey}`; + delete state.correction_fires?.[key]; + delete state.obligation_started_at?.[key]; + if (kind === 'rule') delete state.rule_denials?.[retirementKey]; + } + function correctionThreshold(state, details) { const key = `${details.kind}:${details.retirementKey || details.id}`; state.correction_fires ||= {}; @@ -365,12 +372,11 @@ function createContractEngine({ function beforeTool(state, toolCall) { const attempted = toolIdentity(toolCall); const timestamp = now().valueOf(); - state.retired_rule_operations ||= []; + state.retired_rule_ids ||= []; const matchedOperationRule = rules.find((rule) => ruleMatchesOperation(rule, toolCall)); - const operationRetirementKey = matchedOperationRule - ? `${matchedOperationRule.id}\0${operationFingerprint(toolCall)}` : null; + const operationRetirementKey = matchedOperationRule?.id || null; const operationRule = matchedOperationRule - && !state.retired_rule_operations.includes(operationRetirementKey) + && !state.retired_rule_ids.includes(operationRetirementKey) ? matchedOperationRule : null; if (operationRule && !hasFreshRuleSatisfaction(state, operationRule, timestamp)) { const details = { @@ -453,6 +459,9 @@ function createContractEngine({ const approval = state.awaiting_approval; if (pending && approval?.rule_id === pending.id && approval.operation_fingerprint === operationFingerprint(toolCall)) { + clearCorrectionBudget(state, { + kind: 'rule', id: pending.id, retirementKey: pendingObligationKey || pending.id, + }); state.pending_rule_id = null; state.pending_obligation_key = null; state.awaiting_approval_rule_id = null; @@ -467,6 +476,9 @@ function createContractEngine({ } if (pending && routeMatches(pending.preferred_route, toolCall)) { if (hasSubstantiveResult(result?.value, toolCall) && !result?.is_error && result?.error == null) { + clearCorrectionBudget(state, { + kind: 'rule', id: pending.id, retirementKey: pendingObligationKey || pending.id, + }); state.satisfied_rules[pending.id] = now().valueOf(); state.pending_rule_id = null; state.pending_obligation_key = null; diff --git a/test/contract-engine.test.js b/test/contract-engine.test.js index cd461a0..0d61395 100644 --- a/test/contract-engine.test.js +++ b/test/contract-engine.test.js @@ -286,17 +286,53 @@ test('an obligation older than fifteen minutes abstains on its next corrective f assert.equal(decision.trace.receipt.fire_count, 1); }); -test('protected-operation escalation also retires on the third fire in enforce mode', () => { +test('protected-operation correction budget spans fresh call ids for the same command', () => { const core = createContractEngine({ planner, packs, mode: 'enforce', routeAvailable: () => true }); - const state = core.begin('Deploy the service', { sessionId: 'protected', turnId: 'expiry' }).state; - const deploy = { provider: 'Bash', name: 'Bash', args: { command: 'terraform apply' } }; - assert.equal(core.beforeTool(state, deploy).verdict, 'DENY'); - assert.equal(core.beforeTool(state, deploy).verdict, 'ASK'); - const third = core.beforeTool(state, deploy); + const state = core.begin('Deploy the service', { sessionId: 'protected', turnId: 'fresh-ids' }).state; + const deploy = (callId) => ({ + provider: 'Bash', name: 'Bash', call_id: callId, args: { command: 'terraform apply' }, + }); + assert.equal(core.beforeTool(state, deploy('call-1')).verdict, 'DENY'); + assert.equal(core.beforeTool(state, deploy('call-2')).verdict, 'ASK'); + const third = core.beforeTool(state, deploy('call-3')); assert.equal(third.verdict, 'ABSTAIN'); assert.equal(third.block, false); assert.equal(third.trace.receipt.fire_count, 3); - assert.equal(core.beforeTool(state, deploy).verdict, 'ALLOW'); + assert.equal(core.beforeTool(state, deploy('call-4')).verdict, 'ALLOW'); +}); + +test('protected-operation correction budget spans different matching commands', () => { + const core = createContractEngine({ planner, packs, mode: 'enforce', routeAvailable: () => true }); + const state = core.begin('Deploy the service', { sessionId: 'protected', turnId: 'varying-commands' }).state; + const calls = [ + { provider: 'Bash', name: 'Bash', call_id: 'call-1', args: { command: 'terraform apply' } }, + { provider: 'Bash', name: 'Bash', call_id: 'call-2', args: { command: 'kubectl delete pod api' } }, + { provider: 'Bash', name: 'Bash', call_id: 'call-3', args: { command: 'helm upgrade api chart/' } }, + ]; + assert.equal(core.beforeTool(state, calls[0]).verdict, 'DENY'); + assert.equal(core.beforeTool(state, calls[1]).verdict, 'ASK'); + const third = core.beforeTool(state, calls[2]); + assert.equal(third.verdict, 'ABSTAIN'); + assert.equal(third.block, false); + assert.equal(third.trace.receipt.fire_count, 3); +}); + +test('protected-operation age budget spans varying calls', () => { + let clock = new Date('2026-08-03T20:00:00.000Z'); + const core = createContractEngine({ + planner, packs, mode: 'enforce', routeAvailable: () => true, now: () => clock, + }); + const state = core.begin('Deploy the service', { sessionId: 'protected', turnId: 'varying-age' }).state; + assert.equal(core.beforeTool(state, { + provider: 'Bash', name: 'Bash', call_id: 'call-1', args: { command: 'terraform apply' }, + }).verdict, 'DENY'); + clock = new Date('2026-08-03T20:15:00.000Z'); + const expired = core.beforeTool(state, { + provider: 'Bash', name: 'Bash', call_id: 'call-2', args: { command: 'kubectl delete pod api' }, + }); + assert.equal(expired.verdict, 'ABSTAIN'); + assert.equal(expired.block, false); + assert.equal(expired.trace.receipt.fire_count, 2); }); test('all emitted trace rows have a non-empty attempted and receipt rule id', () => {