From 5e2a0066876c3c7f0014825f370413905ff2c298 Mon Sep 17 00:00:00 2001 From: blocksifrdev Date: Tue, 15 Sep 2026 11:46:01 -0400 Subject: [PATCH] Close the loop: pctr learn, and nine responses instead of two MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gaps between what the doctrine claims and what the code did. 1. Nothing learned. The only thing any run read from a previous one was priorReceiptHash, for chaining. Run 100 knew exactly what run 1 knew, while the whole premise is that the system gets more useful the longer it runs. pctr learn reads the accumulated receipts and timelines and reports what they say to change: actions that executed but were never declared, consequences declared smaller than they turned out to be, agents whose evidence is chronically stale, wildcard authority nobody uses, approvals always granted (a rubber stamp) or never granted (a wall), protected actions with no admissible route, and denials that keep repeating. Findings are proposals, never silent edits; --apply is a separate act and each finding carries its evidence. The compounding is verifiable: after applying, previewing payments.transfer with no arguments returns CRITICAL where it returned HIGH, because the system now knows the action moves $18,000 rather than the $1,800 someone typed. 2. The decision vocabulary was allow/deny. The doctrine specifies nine responses to a trust change, and denying everything that wobbles is as wrong as allowing it — the useful answer is usually narrower than no: reroute around the stale agent, cap the batch, ask a human, slow it down. KEEP, REROUTE, CONSTRAIN, STEP_UP, ESCALATE, THROTTLE, SUSPEND, DENY, REVOKE, checked most-restrictive first so a revoked credential is never answered with a reroute. reconcile() refuses to answer a change with a weaker response than it warranted. Also classifies export/download/dump/backup as data read rather than shrugging at it — a bulk export is data leaving the building. Mirrored in the Python bridge; the parity gate holds at 52 checks. 156 tests (18 new). Co-Authored-By: Claude Opus 5 --- packages/pctr/README.md | 77 +++++++ packages/pctr/bin/pctr.mjs | 51 +++++ packages/pctr/src/consequences.mjs | 2 +- packages/pctr/src/decisions.mjs | 146 ++++++++++++ packages/pctr/src/index.mjs | 2 + packages/pctr/src/learn.mjs | 299 +++++++++++++++++++++++++ packages/pctr/src/render.mjs | 44 ++++ packages/pctr/tests/decisions.test.mjs | 96 ++++++++ packages/pctr/tests/learn.test.mjs | 125 +++++++++++ sdk/python/agt.py | 2 +- 10 files changed, 842 insertions(+), 2 deletions(-) create mode 100644 packages/pctr/src/decisions.mjs create mode 100644 packages/pctr/src/learn.mjs create mode 100644 packages/pctr/tests/decisions.test.mjs create mode 100644 packages/pctr/tests/learn.test.mjs diff --git a/packages/pctr/README.md b/packages/pctr/README.md index f774f4f..e7e7fcd 100644 --- a/packages/pctr/README.md +++ b/packages/pctr/README.md @@ -160,6 +160,81 @@ pctr replay --fork e3 pctr replay --compare ``` +## The loop closes: `pctr learn` + +Every protected execution leaves evidence. Without a learning step that evidence just +piles up; with one, the map, the policy and the discovery gaps get better the longer the +system runs — which is the entire claim. + +```bash +pctr learn # what the accumulated receipts and runs say to change +pctr learn --apply # write the proposed changes into pctr.json +``` + +``` +WHAT THIS RUN TAUGHT PCTR + +Receipts analysed 10 +Runs analysed 10 + +HIGH payments.transfer declares $1800 but has moved up to $18,000 + Approval thresholds key off this, so the declared value has been under-protecting this action. + can be applied automatically + +MEDIUM planner holds authority "*" but has only ever used 1 + Observed: payments.transfer. Narrowing authority to what it actually does shrinks the blast radius. + +MEDIUM payments.transfer has been denied 5× for APPROVAL_REQUIRED + Something keeps asking for what policy keeps refusing. +``` + +It finds undeclared actions that executed anyway, consequences declared smaller than they +turned out to be, agents whose evidence is chronically stale, wildcard authority nobody +uses, approvals that are always granted (a rubber stamp) or never granted (a wall), +protected actions with no admissible route, and denials that keep repeating. + +**Findings are proposals, never silent edits.** `--apply` is a separate, explicit act, and +each finding carries the evidence it came from so you can disagree with it. After +applying, `pctr preview payments.transfer` with no arguments returns `CRITICAL` where it +used to say `HIGH` — because the system now knows what that action actually moves. + +## Nine answers, not two: `pctr decide` + +A trust change is not a binary. Denying everything that wobbles is as wrong as allowing +it — the useful answer is usually narrower than "no". + +```bash +pctr decide customers.delete --records 1842 +``` + +``` +TRUST REEVALUATION + +Action customers.delete +Response CONSTRAIN +Proceeds YES + +1,842 records is above the batch limit of 25; bound it and the consequence is recoverable + +Proposed bound: {"recordsAffected":25} +``` + +| Response | When | Proceeds | +| --- | --- | --- | +| `KEEP` | nothing material changed | yes | +| `REROUTE` | the route changed, a trustworthy path remains | yes | +| `CONSTRAIN` | admissible once the parameters are bounded | yes | +| `THROTTLE` | permitted, but not at this rate | yes | +| `STEP_UP` | same principal, stronger evidence required | no | +| `ESCALATE` | above this principal's authority entirely | no | +| `SUSPEND` | the agent stops until something is repaired | no | +| `DENY` | this execution does not happen | no | +| `REVOKE` | the authority itself is withdrawn | no | + +Checks run most-restrictive first, so a revoked credential is never answered with a +reroute. `reconcile()` refuses to answer a change with a weaker response than it +warranted — that is how authority expands by accident. + ## Execution authority A valid identity is not enough. A valid credential is not enough. A valid route is not @@ -232,6 +307,8 @@ pctr simulate Same, without executing any effect pctr replay [run] Agent Time Machine pctr explain Why was this allowed, denied, or rerouted? pctr receipt [id] Show an execution receipt +pctr decide How should a trust change be answered right now? +pctr learn What the accumulated evidence says to change pctr verify [id] Verify receipt signatures and the receipt chain pctr keys Show your signing key id and public key pctr serve Run the effect boundary as its own process diff --git a/packages/pctr/bin/pctr.mjs b/packages/pctr/bin/pctr.mjs index 416510e..a506fac 100755 --- a/packages/pctr/bin/pctr.mjs +++ b/packages/pctr/bin/pctr.mjs @@ -13,6 +13,8 @@ import fs from 'node:fs'; import path from 'node:path'; import * as store from '../src/store.mjs'; import { renderReport, badgeUrl } from '../src/report.mjs'; +import { learn, applyProposal } from '../src/learn.mjs'; +import { respondToChange } from '../src/decisions.mjs'; import * as r from '../src/render.mjs'; const VERSION = '0.1.0'; @@ -268,6 +270,42 @@ async function main() { return bad.length ? 1 : 0; } + case 'learn': { + const graph = loadGraph(); + const receipts = store.listReceipts(); + const runs = store.listRuns().map((r) => store.loadRun(r.runId)).filter(Boolean); + const result = learn({ graph, receipts, runs }); + + if (flag('apply')) { + if (result.proposal.empty) return out('Nothing to apply.', { applied: [] }); + const { manifest, applied } = applyProposal(graph.manifest, result.proposal); + store.writeManifest(manifest); + return out([r.heading('applied to pctr.json'), ...applied.map((a) => ` ${r.green('+')} ${a}`), + '', r.dim('Re-run pctr scan to see the updated map.')].join('\n'), { applied, manifest }); + } + return out(r.renderLearn(result), result); + } + + case 'decide': { + const graph = loadGraph(); + const action = positional[0] ?? protectedActions(graph)[0]?.id; + if (!action) return fail('Pass an action: pctr decide '); + const preview = await previewMeasured(graph, action, params(), { target: flag('target') }); + const route = resolveRoute(graph, action, { severity: preview.severity, target: flag('target') }); + const agents = (route.selected?.agents ?? []).map((id) => graph.nodes.get(id)).filter(Boolean); + const receipts = store.listReceipts().filter((x) => x.requested?.action === action); + const decision = respondToChange({ + action, preview, currentRoute: route.selected, agents, + policy: graph.manifest.policy ?? {}, + history: { + inWindow: receipts.length, + consecutiveFailures: countTrailingFailures(receipts) + } + }); + out(r.renderDecision(decision), decision); + return decision.proceeds ? 0 : 1; + } + case 'doctor': { const manifest = store.readManifest(); const checks = []; @@ -309,6 +347,16 @@ async function main() { } const check = (label, ok, fix) => ({ label, ok, fix }); + +// Trailing run of failures, newest first: how many times in a row this last failed. +function countTrailingFailures(receipts) { + let n = 0; + for (const receipt of [...receipts].reverse()) { + if (receipt.verifier?.decision === 'EXECUTION_ALLOWED') break; + n++; + } + return n; +} const fail = (message) => { console.error(message); return 2; }; function renderCompare(diff, leftId, rightId) { @@ -363,6 +411,8 @@ Usage pctr verify [id] Verify receipt signatures and the receipt chain pctr keys Show your signing key id and public key pctr serve Run the effect boundary as its own process + pctr decide How should a trust change be answered right now? + pctr learn What the accumulated evidence says to change pctr doctor Check your setup Options @@ -376,6 +426,7 @@ Options --target Execution target --boundary protect: verify authority at a remote effect boundary --port serve: port for the effect boundary (default 8787) + --apply learn: write the proposed changes into pctr.json --probe preview: measure the real consequence with this probe --key verify: check signatures against this public key --export keys: write the public key to a file diff --git a/packages/pctr/src/consequences.mjs b/packages/pctr/src/consequences.mjs index 24f2e28..f63dc64 100644 --- a/packages/pctr/src/consequences.mjs +++ b/packages/pctr/src/consequences.mjs @@ -34,7 +34,7 @@ const RULES = [ [/(terraform|infra|instance|bucket|dns|firewall|network|cluster)/i, 'INFRA_MODIFIED'], [/(send|email|slack|sms|post_message|notify|publish|tweet)/i, 'MESSAGE_SENT'], [/(write|create|update|insert|upsert|patch|put)/i, 'DATA_WRITTEN'], - [/(read|get|list|search|query|select|fetch)/i, 'DATA_READ'] + [/(read|get|list|search|query|select|fetch|export|download|dump|backup)/i, 'DATA_READ'] ]; export function classifyAction(actionId, hints = {}) { diff --git a/packages/pctr/src/decisions.mjs b/packages/pctr/src/decisions.mjs new file mode 100644 index 0000000..cabfe16 --- /dev/null +++ b/packages/pctr/src/decisions.mjs @@ -0,0 +1,146 @@ +import { severityRank } from './consequences.mjs'; + +// TRUST CHANGE -> ROUTE REEVALUATION -> RESPONSE. +// +// A trust change is not a binary. Denying everything that wobbles is as wrong as +// allowing it: the useful answer is usually narrower than "no" — reroute around the +// agent that went stale, cap the batch, ask a human, slow it down. These are the nine +// responses a reevaluation can produce, ordered from least to most restrictive. +export const RESPONSES = [ + 'KEEP', // nothing material changed; the route stands + 'REROUTE', // another admissible route exists + 'CONSTRAIN', // admissible once the parameters are bounded + 'STEP_UP', // same principal, stronger evidence required + 'ESCALATE', // a higher authority or a human must decide + 'THROTTLE', // permitted, but not at this rate + 'SUSPEND', // this agent stops until something is repaired + 'DENY', // this execution does not happen + 'REVOKE' // the authority itself is withdrawn +]; + +const RESTRICTIVENESS = Object.fromEntries(RESPONSES.map((r, i) => [r, i])); +export const isMoreRestrictive = (a, b) => RESTRICTIVENESS[a] > RESTRICTIVENESS[b]; + +// Whether an execution may still proceed under this response, and on what terms. +export const PROCEEDS = { + KEEP: true, REROUTE: true, CONSTRAIN: true, THROTTLE: true, + STEP_UP: false, ESCALATE: false, SUSPEND: false, DENY: false, REVOKE: false +}; + +/** + * Decide how to respond when a route is reevaluated. Checks run most-restrictive first, + * so a revoked credential is never answered with a reroute. + */ +export function respondToChange({ + action, preview, previousRoute = null, currentRoute = null, + agents = [], policy = {}, history = {}, at = new Date().toISOString() +} = {}) { + const severity = preview?.severity ?? 'LOW'; + const decide = (response, reason, detail = {}) => ({ + response, reason, action, severity, at, proceeds: PROCEEDS[response], detail + }); + + // REVOKE — the authority itself is gone. Nothing downstream can repair this. + const revoked = agents.find((a) => a.revoked || a.credentialRevoked); + if (revoked) { + return decide('REVOKE', `${revoked.id}'s credential has been revoked, so any authority derived from it is withdrawn`, + { agent: revoked.id }); + } + + // SUSPEND — the agent is quarantined, killed, or has failed repeatedly enough that + // continuing to ask is itself the problem. + const suspended = agents.find((a) => a.quarantined || a.killed || a.trust === 0); + if (suspended) { + return decide('SUSPEND', `${suspended.id} is quarantined or has no trust left; it stops until that is repaired`, + { agent: suspended.id }); + } + const failureLimit = policy.suspendAfterFailures ?? 5; + const failures = history.consecutiveFailures ?? 0; + if (failures >= failureLimit) { + return decide('SUSPEND', `${failures} consecutive failures on ${action} reached the limit of ${failureLimit}`, + { consecutiveFailures: failures }); + } + + // DENY — nothing admissible, and no constraint would change that. + if (!currentRoute) { + const constraint = proposeConstraint({ preview, policy }); + if (!constraint) { + return decide('DENY', `no admissible route can reach ${action} under current trust and policy`); + } + // A route may exist for a smaller version of the same action. + return decide('CONSTRAIN', constraint.reason, { constraint: constraint.bound }); + } + + // THROTTLE — permitted, but not this often. Velocity is a consequence in its own right. + const rateLimit = policy.maxPerWindow?.[severity] ?? policy.maxPerWindow?.default; + if (rateLimit != null && (history.inWindow ?? 0) >= rateLimit) { + return decide('THROTTLE', `${history.inWindow} executions of ${severity} actions in this window reached the limit of ${rateLimit}`, + { inWindow: history.inWindow, limit: rateLimit }); + } + + // ESCALATE — beyond what this principal can authorize at all. + if (policy.escalateAtOrAbove && severityRank(severity) >= severityRank(policy.escalateAtOrAbove)) { + return decide('ESCALATE', `${severity} consequences are above this principal's authority and go to ${policy.escalateTo ?? 'a higher authority'}`, + { escalateTo: policy.escalateTo ?? null }); + } + + // STEP_UP — the same principal may proceed, with stronger evidence. + const stale = (currentRoute.trustStates ?? []).find((t) => t.evidenceStale); + if (stale) { + return decide('STEP_UP', `${stale.agentId}'s evidence is ${stale.evidenceAgeSeconds}s old; re-attest before a ${severity} action`, + { agent: stale.agentId, maxEvidenceAgeSeconds: stale.maxEvidenceAgeSeconds }); + } + if (requiresApproval(severity, policy, preview)) { + return decide('STEP_UP', `policy requires human approval for ${severity} consequences`, + { approvalRequired: true }); + } + + // CONSTRAIN — admissible, but the blast radius should be bounded first. + const constraint = proposeConstraint({ preview, policy }); + if (constraint) return decide('CONSTRAIN', constraint.reason, { constraint: constraint.bound }); + + // REROUTE — the route changed underneath us, but a trustworthy path remains. + if (previousRoute && previousRoute.routeId !== currentRoute.routeId) { + return decide('REROUTE', `${previousRoute.routeId} is no longer admissible; ${currentRoute.routeId} is`, + { from: previousRoute.routeId, to: currentRoute.routeId }); + } + + return decide('KEEP', 'the selected route is still admissible and nothing material changed', + { routeId: currentRoute.routeId }); +} + +// A constraint is only worth proposing when bounding the parameters would genuinely +// change the consequence — not as a way to wave something through. +function proposeConstraint({ preview, policy }) { + if (!preview) return null; + const batchLimit = policy.batchLimit ?? 25; + if (preview.recordsAffected > batchLimit) { + return { + reason: `${preview.recordsAffected.toLocaleString('en-US')} records is above the batch limit of ${batchLimit}; bound it and the consequence is recoverable`, + bound: { max: { recordsAffected: batchLimit } } + }; + } + const amountLimit = policy.constrainAmountAbove; + if (amountLimit != null && preview.financialExposure > amountLimit) { + return { + reason: `$${preview.financialExposure.toLocaleString('en-US')} is above the per-execution limit of $${amountLimit}`, + bound: { max: { amount: amountLimit } } + }; + } + return null; +} + +const requiresApproval = (severity, policy, preview) => + (policy.requireApprovalAtOrAbove + ? severityRank(severity) >= severityRank(policy.requireApprovalAtOrAbove) + : severity === 'CRITICAL') || + (policy.approvalThresholds?.amount != null && preview?.financialExposure >= policy.approvalThresholds.amount); + +// Rerouting may never answer a trust change with a weaker response than the one the +// change warranted — that is how authority expands by accident. +export function reconcile(previousResponse, nextResponse) { + if (!previousResponse) return nextResponse; + return isMoreRestrictive(previousResponse.response, nextResponse.response) + ? { ...previousResponse, reason: `${previousResponse.reason} (a weaker response was proposed and refused)`, refusedWeaker: nextResponse.response } + : nextResponse; +} diff --git a/packages/pctr/src/index.mjs b/packages/pctr/src/index.mjs index 32ccfbc..c0d3edd 100644 --- a/packages/pctr/src/index.mjs +++ b/packages/pctr/src/index.mjs @@ -13,6 +13,8 @@ export * from './agt.mjs'; export * from './probe.mjs'; export * from './boundary.mjs'; export * from './report.mjs'; +export * from './decisions.mjs'; +export * from './learn.mjs'; export * from './keys.mjs'; export * from './protect.mjs'; export { EVENTS, isCanonicalEvent } from './events.mjs'; diff --git a/packages/pctr/src/learn.mjs b/packages/pctr/src/learn.mjs new file mode 100644 index 0000000..dac78ed --- /dev/null +++ b/packages/pctr/src/learn.mjs @@ -0,0 +1,299 @@ +import { classifyAction, severityRank } from './consequences.mjs'; +import { resolveRoute } from './router.mjs'; + +// LEARN — the step that closes the loop. +// +// Every protected execution leaves evidence behind: what was proposed, what it turned +// out to affect, which routes were rejected and why, what a human approved. Without this +// step that evidence just accumulates. With it, the graph, the policy and the discovery +// map get better the longer the system runs, which is the whole claim. +// +// Findings are proposals, never silent edits. Each one carries the evidence it came from +// so a human can disagree with it. + +const HIGH = 'HIGH', MEDIUM = 'MEDIUM', LOW = 'LOW'; + +export function learn({ graph, receipts = [], runs = [], policy = {} } = {}) { + const effectivePolicy = { ...(graph?.manifest?.policy ?? {}), ...policy }; + const findings = [ + ...undeclaredActions(graph, receipts, runs), + ...misdeclaredConsequences(graph, receipts), + ...chronicStaleEvidence(graph, receipts, runs), + ...unusedAuthority(graph, receipts), + ...approvalPatterns(receipts, effectivePolicy), + ...unreachableProtectedActions(graph), + ...repeatedDenials(receipts) + ]; + return { + observations: { receipts: receipts.length, runs: runs.length, actions: countActions(receipts).size }, + findings: findings.sort((a, b) => weight(b) - weight(a)), + proposal: toManifestProposal(graph, findings) + }; +} + +const weight = (f) => ({ HIGH: 2, MEDIUM: 1, LOW: 0 }[f.confidence] ?? 0) * 10 + (f.occurrences ?? 1); + +// An action PCTR executed but the manifest never declared: the map is incomplete, and an +// undeclared action is one nobody chose to protect. +function undeclaredActions(graph, receipts, runs) { + if (!graph) return []; + const declared = new Set([...graph.nodes.values()].filter((n) => n.type === 'action').map((n) => n.id)); + const seen = new Map(); + const toolFor = new Map(); + for (const [action, count] of countActions(receipts)) if (!declared.has(action)) seen.set(action, count); + // A declared action with no tool never enters the graph, so recover the tool from the + // route the receipt recorded where we can. + for (const receipt of receipts) { + const action = receipt.requested?.action; + const hops = receipt.routeSelected?.routeId?.split(' -> ') ?? []; + if (action && seen.has(action) && hops.length >= 2) toolFor.set(action, hops.at(-2)); + } + for (const run of runs) { + for (const event of run.events ?? []) { + const action = event.detail?.action; + if (action && !declared.has(action)) seen.set(action, (seen.get(action) ?? 0) + 1); + } + } + return [...seen].map(([action, occurrences]) => { + const c = classifyAction(action); + return { + type: 'UNDECLARED_ACTION', action, occurrences, + confidence: c.protected ? HIGH : MEDIUM, + summary: `${action} has executed ${occurrences}× but is not in pctr.json`, + detail: [ + c.protected + ? `It can cause "${c.label}" (${c.severity}) and is currently outside every policy you have written.` + : `It appears to be ${c.label}; declaring it keeps the map complete.`, + toolFor.has(action) ? null : 'No tool could be inferred from the receipts, so you will need to say which tool performs it.' + ].filter(Boolean).join(' '), + proposal: { addAction: { id: action, tool: toolFor.get(action) ?? null } } + }; + }); +} + +// The consequence that was declared versus the one the parameters actually showed. +function misdeclaredConsequences(graph, receipts) { + if (!graph) return []; + const findings = []; + const observed = new Map(); + for (const receipt of receipts) { + const action = receipt.requested?.action; + const records = receipt.requested?.params?.recordsAffected; + const amount = receipt.requested?.params?.amount; + if (!action || (records == null && amount == null)) continue; + const entry = observed.get(action) ?? { records: [], amounts: [] }; + if (records != null) entry.records.push(records); + if (amount != null) entry.amounts.push(amount); + observed.set(action, entry); + } + for (const [action, entry] of observed) { + const node = graph.nodes.get(action); + if (!node) continue; + const maxRecords = Math.max(0, ...entry.records); + const maxAmount = Math.max(0, ...entry.amounts); + const declaredRecords = node.recordsAffected ?? 0; + const declaredAmount = node.amount ?? 0; + + if (maxRecords > declaredRecords * 2 && maxRecords > 50) { + findings.push({ + type: 'UNDERSTATED_CONSEQUENCE', action, occurrences: entry.records.length, confidence: HIGH, + summary: `${action} declares ${declaredRecords || 'no'} records but has affected up to ${maxRecords.toLocaleString('en-US')}`, + detail: `Severity is computed from that number, so the declared value has been under-protecting this action.`, + proposal: { updateAction: { id: action, recordsAffected: maxRecords } } + }); + } + if (maxAmount > declaredAmount * 2 && maxAmount > 100) { + findings.push({ + type: 'UNDERSTATED_CONSEQUENCE', action, occurrences: entry.amounts.length, confidence: HIGH, + summary: `${action} declares $${declaredAmount} but has moved up to $${maxAmount.toLocaleString('en-US')}`, + detail: 'Approval thresholds key off this, so the declared value has been under-protecting this action.', + proposal: { updateAction: { id: action, amount: maxAmount } } + }); + } + } + return findings; +} + +// An agent whose evidence is repeatedly stale is not a routing problem to work around; +// it is an attestation pipeline that is not running. +function chronicStaleEvidence(graph, receipts, runs) { + const stale = new Map(); + for (const run of runs) { + for (const event of run.events ?? []) { + if (event.event === 'TRUST_CHANGED' && event.detail?.becameStale) { + stale.set(event.subject, (stale.get(event.subject) ?? 0) + 1); + } + if (event.event === 'ROUTE_INVALIDATED' && /evidence is \d+s old/.test(event.detail?.reason ?? '')) { + const agent = /(\S+) evidence is/.exec(event.detail.reason)?.[1]; + if (agent) stale.set(agent, (stale.get(agent) ?? 0) + 1); + } + } + } + return [...stale].filter(([, n]) => n >= 2).map(([agent, occurrences]) => ({ + type: 'CHRONIC_STALE_EVIDENCE', agent, occurrences, confidence: HIGH, + summary: `${agent} has been rejected ${occurrences}× for stale evidence`, + detail: 'Routing around it each time hides the real problem: its attestation is not being refreshed.', + proposal: null + })); +} + +// Authority nobody has used is authority nobody needs. +function unusedAuthority(graph, receipts) { + if (!graph || receipts.length < 5) return []; // too little evidence to claim anything + const used = new Set(); + for (const receipt of receipts) for (const agent of receipt.routeSelected?.agents ?? []) { + used.add(`${agent}::${receipt.requested?.action}`); + } + const findings = []; + for (const node of graph.nodes.values()) { + if (node.type !== 'agent') continue; + const wildcard = (node.authority ?? []).filter((a) => a === '*'); + if (!wildcard.length) continue; + const actionsUsed = [...used].filter((k) => k.startsWith(`${node.id}::`)).map((k) => k.split('::')[1]); + findings.push({ + type: 'BROAD_AUTHORITY', agent: node.id, occurrences: actionsUsed.length, confidence: actionsUsed.length ? MEDIUM : HIGH, + summary: `${node.id} holds authority "*" but has only ever used ${actionsUsed.length || 'none'}`, + detail: actionsUsed.length + ? `Observed: ${[...new Set(actionsUsed)].join(', ')}. Narrowing authority to what it actually does shrinks the blast radius.` + : 'It has executed nothing. A wildcard on an unused agent is pure exposure.', + proposal: actionsUsed.length ? { updateAgent: { id: node.id, authority: [...new Set(actionsUsed)] } } : null + }); + } + return findings; +} + +// Approval that is always granted is a rubber stamp; approval never granted is a wall. +// Both are policy telling you something. +function approvalPatterns(receipts, policy) { + const byAction = new Map(); + for (const receipt of receipts) { + const action = receipt.requested?.action; + if (!action) continue; + const needed = (receipt.authorityBound?.constraints?.requiresApproval) || + (receipt.verifier?.failures ?? []).some((f) => f.code === 'APPROVAL_REQUIRED'); + if (!needed) continue; + const entry = byAction.get(action) ?? { granted: 0, refused: 0 }; + if (receipt.verifier?.decision === 'EXECUTION_ALLOWED') entry.granted++; else entry.refused++; + byAction.set(action, entry); + } + const findings = []; + for (const [action, { granted, refused }] of byAction) { + const total = granted + refused; + if (total < 4) continue; + if (granted === total) { + findings.push({ + type: 'APPROVAL_ALWAYS_GRANTED', action, occurrences: total, confidence: MEDIUM, + summary: `${action} has required approval ${total}× and been approved every time`, + detail: 'An approval that is never refused trains people to click through it. Either raise the threshold so it fires less, or add a constraint that makes the approval meaningful.', + proposal: null + }); + } + if (refused === total) { + findings.push({ + type: 'APPROVAL_NEVER_GRANTED', action, occurrences: total, confidence: MEDIUM, + summary: `${action} has required approval ${total}× and never been approved`, + detail: 'Nobody is approving this. Consider removing the authority rather than leaving a request nobody answers.', + proposal: null + }); + } + } + return findings; +} + +// A protected action with no admissible route may be correct — or may be a policy that +// quietly broke and nobody noticed. +function unreachableProtectedActions(graph) { + if (!graph) return []; + const findings = []; + for (const node of graph.nodes.values()) { + if (node.type !== 'action' || !node.protected) continue; + const route = resolveRoute(graph, node.id); + if (route.admissible.length) continue; + const reasons = [...new Set(route.candidates.flatMap((c) => c.rejections.map((r) => r.code)))]; + findings.push({ + type: 'UNREACHABLE_PROTECTED_ACTION', action: node.id, occurrences: 1, + confidence: reasons.includes('INSUFFICIENT_AUTHORITY') ? LOW : MEDIUM, + summary: `${node.id} (${node.severity}) has no admissible route`, + detail: reasons.length + ? `Every candidate route fails on: ${reasons.join(', ')}. That may be exactly right — or a policy that broke.` + : 'No agent in the graph can reach it at all.', + proposal: null + }); + } + return findings; +} + +function repeatedDenials(receipts) { + const denials = new Map(); + for (const receipt of receipts) { + if (receipt.verifier?.decision === 'EXECUTION_ALLOWED') continue; + for (const failure of receipt.verifier?.failures ?? []) { + const key = `${receipt.requested?.action}::${failure.code}`; + denials.set(key, (denials.get(key) ?? 0) + 1); + } + } + return [...denials].filter(([, n]) => n >= 3).map(([key, occurrences]) => { + const [action, code] = key.split('::'); + return { + type: 'REPEATED_DENIAL', action, code, occurrences, confidence: MEDIUM, + summary: `${action} has been denied ${occurrences}× for ${code}`, + detail: 'Something keeps asking for what policy keeps refusing. Either the agent should stop trying, or the policy is wrong.', + proposal: null + }; + }); +} + +function countActions(receipts) { + const counts = new Map(); + for (const receipt of receipts) { + const action = receipt.requested?.action; + if (action) counts.set(action, (counts.get(action) ?? 0) + 1); + } + return counts; +} + +// The subset of findings that can be applied to pctr.json, as a patch a human approves. +export function toManifestProposal(graph, findings) { + const addActions = []; + const updateActions = []; + const updateAgents = []; + for (const finding of findings) { + const p = finding.proposal; + if (!p) continue; + if (p.addAction) addActions.push(p.addAction); + if (p.updateAction) updateActions.push(p.updateAction); + if (p.updateAgent) updateAgents.push(p.updateAgent); + } + return { addActions, updateActions, updateAgents, empty: !addActions.length && !updateActions.length && !updateAgents.length }; +} + +// Applying is explicit and total: the manifest is rewritten from the proposal, and the +// caller decides whether to keep it. +export function applyProposal(manifest, proposal) { + const next = JSON.parse(JSON.stringify(manifest)); + next.actions = next.actions ?? []; + const applied = []; + + for (const add of proposal.addActions) { + const { tool, ...action } = add; + if (!next.actions.some((a) => a.id === action.id)) { next.actions.push(action); applied.push(`declared ${action.id}`); } + // Attaching it to the tool that performed it is what puts it in the graph. + const target = tool ? (next.tools ?? []).find((t) => t.id === tool) : null; + if (target && !(target.actions ?? []).includes(action.id)) { + target.actions = [...(target.actions ?? []), action.id]; + applied.push(`attached ${action.id} to tool ${tool}`); + } + } + for (const update of proposal.updateActions) { + const existing = next.actions.find((a) => a.id === update.id); + if (existing) { Object.assign(existing, update); applied.push(`updated ${update.id}`); } + else { next.actions.push(update); applied.push(`declared ${update.id}`); } + } + for (const update of proposal.updateAgents) { + const existing = (next.agents ?? []).find((a) => a.id === update.id); + if (existing) { Object.assign(existing, update); applied.push(`narrowed authority for ${update.id}`); } + } + return { manifest: next, applied }; +} + +export { severityRank }; diff --git a/packages/pctr/src/render.mjs b/packages/pctr/src/render.mjs index b0b3550..c76b4be 100644 --- a/packages/pctr/src/render.mjs +++ b/packages/pctr/src/render.mjs @@ -119,6 +119,50 @@ export function renderWhy(w) { return out.join('\n'); } +const RESPONSE_COLOR = { + KEEP: green, REROUTE: cyan, CONSTRAIN: cyan, THROTTLE: yellow, + STEP_UP: yellow, ESCALATE: yellow, SUSPEND: red, DENY: red, REVOKE: red +}; + +export function renderDecision(d) { + const paint = RESPONSE_COLOR[d.response] ?? dim; + const out = [heading('trust reevaluation')]; + out.push(field('Action', d.action)); + out.push(field('Consequence', sev(d.severity))); + out.push(field('Response', paint(d.response))); + out.push(field('Proceeds', d.proceeds ? green('YES') : red('NO'))); + out.push('', d.reason); + if (d.detail?.constraint) out.push('', `${bold('Proposed bound:')} ${JSON.stringify(d.detail.constraint.max)}`); + if (d.refusedWeaker) out.push('', yellow(`A weaker response (${d.refusedWeaker}) was proposed and refused.`)); + return out.join('\n'); +} + +const CONFIDENCE = { HIGH: red, MEDIUM: yellow, LOW: dim }; + +export function renderLearn(result) { + const out = [heading('what this run taught pctr')]; + out.push(field('Receipts analysed', String(result.observations.receipts))); + out.push(field('Runs analysed', String(result.observations.runs))); + out.push(field('Distinct actions', String(result.observations.actions))); + if (!result.findings.length) { + out.push('', 'Nothing new. The map matches what actually happened.'); + return out.join('\n'); + } + out.push('', bold(`${result.findings.length} finding(s)`), ''); + for (const f of result.findings) { + const mark = (CONFIDENCE[f.confidence] ?? dim)(f.confidence.padEnd(6)); + out.push(`${mark} ${bold(f.summary)}`); + out.push(` ${dim(f.detail)}`); + if (f.proposal) out.push(` ${green('can be applied automatically')}`); + out.push(''); + } + if (!result.proposal.empty) { + const n = result.proposal.addActions.length + result.proposal.updateActions.length + result.proposal.updateAgents.length; + out.push(dim(`${n} change(s) can be written to pctr.json with: pctr learn --apply`)); + } + return out.join('\n'); +} + export function renderReceipt(receipt, verification) { const out = [heading('execution receipt')]; out.push(field('Receipt', receipt.receiptId)); diff --git a/packages/pctr/tests/decisions.test.mjs b/packages/pctr/tests/decisions.test.mjs new file mode 100644 index 0000000..833a483 --- /dev/null +++ b/packages/pctr/tests/decisions.test.mjs @@ -0,0 +1,96 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { respondToChange, reconcile, RESPONSES, isMoreRestrictive, PROCEEDS } from '../src/decisions.mjs'; + +const preview = (over = {}) => ({ severity: 'HIGH', recordsAffected: 10, financialExposure: 100, ...over }); +const route = (over = {}) => ({ routeId: 'r1', agents: ['a'], trustStates: [{ agentId: 'a', evidenceStale: false }], ...over }); + +test('the vocabulary is the nine the doctrine specifies, ordered by restrictiveness', () => { + assert.deepEqual(RESPONSES, ['KEEP', 'REROUTE', 'CONSTRAIN', 'STEP_UP', 'ESCALATE', 'THROTTLE', 'SUSPEND', 'DENY', 'REVOKE']); + assert.ok(isMoreRestrictive('DENY', 'REROUTE')); + assert.ok(!isMoreRestrictive('KEEP', 'SUSPEND')); + // Only some responses let the execution continue at all. + assert.deepEqual(RESPONSES.filter((r) => PROCEEDS[r]), ['KEEP', 'REROUTE', 'CONSTRAIN', 'THROTTLE']); +}); + +test('a revoked credential outranks every softer answer', () => { + const d = respondToChange({ + action: 'payments.transfer', preview: preview(), currentRoute: route(), + agents: [{ id: 'a', revoked: true }], policy: {} + }); + assert.equal(d.response, 'REVOKE'); + assert.equal(d.proceeds, false); + assert.match(d.reason, /revoked/); +}); + +test('a quarantined agent suspends rather than reroutes around itself', () => { + const d = respondToChange({ action: 'x', preview: preview(), currentRoute: route(), agents: [{ id: 'a', quarantined: true }] }); + assert.equal(d.response, 'SUSPEND'); +}); + +test('consecutive failures suspend at the policy limit', () => { + const d = respondToChange({ action: 'x', preview: preview(), currentRoute: route(), agents: [], policy: { suspendAfterFailures: 3 }, history: { consecutiveFailures: 3 } }); + assert.equal(d.response, 'SUSPEND'); + assert.match(d.reason, /3 consecutive failures/); +}); + +test('no route and no possible bound is a denial; a possible bound is not', () => { + const denied = respondToChange({ action: 'x', preview: preview({ recordsAffected: 5 }), currentRoute: null, policy: {} }); + assert.equal(denied.response, 'DENY'); + + // The same missing route, but the action is only too big — that is a constraint. + const constrained = respondToChange({ action: 'x', preview: preview({ recordsAffected: 4000 }), currentRoute: null, policy: { batchLimit: 25 } }); + assert.equal(constrained.response, 'CONSTRAIN'); + assert.deepEqual(constrained.detail.constraint.max, { recordsAffected: 25 }); +}); + +test('velocity is a consequence: too many in the window throttles', () => { + const d = respondToChange({ + action: 'x', preview: preview(), currentRoute: route(), agents: [], + policy: { maxPerWindow: { HIGH: 3 } }, history: { inWindow: 3 } + }); + assert.equal(d.response, 'THROTTLE'); + assert.equal(d.proceeds, true, 'throttling slows an action down; it does not forbid it'); +}); + +test('beyond the principal\'s authority escalates; stale evidence steps up', () => { + const escalated = respondToChange({ + action: 'x', preview: preview({ severity: 'CRITICAL' }), currentRoute: route(), agents: [], + policy: { escalateAtOrAbove: 'CRITICAL', escalateTo: 'security-oncall' } + }); + assert.equal(escalated.response, 'ESCALATE'); + assert.match(escalated.reason, /security-oncall/); + + const steppedUp = respondToChange({ + action: 'x', preview: preview(), agents: [], policy: {}, + currentRoute: route({ trustStates: [{ agentId: 'a', evidenceStale: true, evidenceAgeSeconds: 4200, maxEvidenceAgeSeconds: 900 }] }) + }); + assert.equal(steppedUp.response, 'STEP_UP'); + assert.match(steppedUp.reason, /re-attest/); +}); + +test('a changed route that is still admissible reroutes, and an unchanged one keeps', () => { + const rerouted = respondToChange({ + action: 'x', preview: preview({ recordsAffected: 1 }), agents: [], policy: {}, + previousRoute: { routeId: 'old' }, currentRoute: route({ routeId: 'new' }) + }); + assert.equal(rerouted.response, 'REROUTE'); + assert.equal(rerouted.detail.from, 'old'); + + const kept = respondToChange({ + action: 'x', preview: preview({ recordsAffected: 1 }), agents: [], policy: {}, + previousRoute: { routeId: 'r1' }, currentRoute: route() + }); + assert.equal(kept.response, 'KEEP'); +}); + +test('reconcile refuses to answer a change with a weaker response than it warranted', () => { + const strong = { response: 'SUSPEND', reason: 'agent quarantined' }; + const weak = { response: 'REROUTE', reason: 'another path exists' }; + const result = reconcile(strong, weak); + assert.equal(result.response, 'SUSPEND'); + assert.equal(result.refusedWeaker, 'REROUTE'); + + // A stronger response is allowed to win. + assert.equal(reconcile(weak, strong).response, 'SUSPEND'); +}); diff --git a/packages/pctr/tests/learn.test.mjs b/packages/pctr/tests/learn.test.mjs new file mode 100644 index 0000000..1120811 --- /dev/null +++ b/packages/pctr/tests/learn.test.mjs @@ -0,0 +1,125 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { learn, applyProposal } from '../src/learn.mjs'; +import { buildGraph } from '../src/graph.mjs'; +import { protect } from '../src/protect.mjs'; +import { previewConsequence } from '../src/twin.mjs'; + +const manifest = () => ({ + principal: 'user:test', + agents: [{ id: 'planner', trust: 0.99, evidenceAgeSeconds: 5, authority: ['*'], tools: ['payments'] }], + tools: [{ id: 'payments', actions: ['payments.transfer'] }], + actions: [{ id: 'payments.transfer', amount: 1800 }], + policy: { requireApprovalAtOrAbove: 'CRITICAL', approvalThresholds: { amount: 5000 }, batchLimit: 25 } +}); + +const receipt = (over = {}) => ({ + receiptId: `r-${Math.random().toString(36).slice(2, 8)}`, + requested: { action: 'payments.transfer', target: 't', params: {} }, + routeSelected: { routeId: 'user:test -> planner -> payments -> payments.transfer', agents: ['planner'] }, + verifier: { decision: 'EXECUTION_ALLOWED', failures: [] }, + issuedAt: '2026-09-15T00:00:00Z', + ...over +}); + +test('the loop compounds: what executed teaches the map what it got wrong', async () => { + const graph = buildGraph(manifest()); + // Declared $1,800. Observed $18,000, repeatedly. + const receipts = Array.from({ length: 3 }, () => + receipt({ requested: { action: 'payments.transfer', target: 't', params: { amount: 18000 } } })); + + const result = learn({ graph, receipts }); + const understated = result.findings.find((f) => f.type === 'UNDERSTATED_CONSEQUENCE'); + assert.ok(understated, 'expected the declared amount to be challenged'); + assert.equal(understated.confidence, 'HIGH'); + assert.equal(understated.proposal.updateAction.amount, 18000); + + // Applying it changes what the very next preview concludes, with no arguments passed. + const before = previewConsequence(graph, 'payments.transfer'); + assert.equal(before.severity, 'HIGH'); + + const { manifest: updated, applied } = applyProposal(graph.manifest, result.proposal); + assert.ok(applied.some((a) => a.includes('payments.transfer'))); + const after = previewConsequence(buildGraph(updated), 'payments.transfer'); + assert.equal(after.severity, 'CRITICAL', 'the system is better informed than it was before the runs'); +}); + +test('an action that executed but was never declared is surfaced with its tool', () => { + const graph = buildGraph(manifest()); + const receipts = [receipt({ + requested: { action: 'customers.delete', target: 't', params: {} }, + routeSelected: { routeId: 'user:test -> planner -> payments -> customers.delete', agents: ['planner'] } + })]; + const finding = learn({ graph, receipts }).findings.find((f) => f.type === 'UNDECLARED_ACTION'); + assert.equal(finding.action, 'customers.delete'); + assert.equal(finding.confidence, 'HIGH', 'an undeclared destructive action is not a footnote'); + assert.equal(finding.proposal.addAction.tool, 'payments'); + + // Applying attaches it to the tool, which is what actually puts it in the graph. + const { manifest: updated } = applyProposal(graph.manifest, learn({ graph, receipts }).proposal); + assert.ok(updated.tools.find((t) => t.id === 'payments').actions.includes('customers.delete')); + assert.ok(buildGraph(updated).nodes.has('customers.delete')); +}); + +test('wildcard authority is measured against what was actually used', () => { + const graph = buildGraph(manifest()); + const receipts = Array.from({ length: 6 }, () => receipt()); + const finding = learn({ graph, receipts }).findings.find((f) => f.type === 'BROAD_AUTHORITY'); + assert.equal(finding.agent, 'planner'); + assert.deepEqual(finding.proposal.updateAgent.authority, ['payments.transfer']); +}); + +test('approval that is always granted is reported as a rubber stamp', () => { + const graph = buildGraph(manifest()); + const receipts = Array.from({ length: 5 }, () => receipt({ + authorityBound: { constraints: { requiresApproval: true } }, + verifier: { decision: 'EXECUTION_ALLOWED', failures: [] } + })); + const finding = learn({ graph, receipts }).findings.find((f) => f.type === 'APPROVAL_ALWAYS_GRANTED'); + assert.ok(finding); + assert.match(finding.detail, /click through/); +}); + +test('repeated denials for the same reason are surfaced, not buried', () => { + const graph = buildGraph(manifest()); + const receipts = Array.from({ length: 4 }, () => receipt({ + verifier: { decision: 'EXECUTION_DENIED', failures: [{ code: 'APPROVAL_REQUIRED', message: 'no approval' }] } + })); + const finding = learn({ graph, receipts }).findings.find((f) => f.type === 'REPEATED_DENIAL'); + assert.equal(finding.code, 'APPROVAL_REQUIRED'); + assert.equal(finding.occurrences, 4); +}); + +test('learning claims nothing from too little evidence', () => { + const graph = buildGraph(manifest()); + const result = learn({ graph, receipts: [receipt()] }); + assert.equal(result.findings.some((f) => f.type === 'BROAD_AUTHORITY'), false, 'one receipt is not a pattern'); + assert.equal(result.findings.some((f) => f.type === 'APPROVAL_ALWAYS_GRANTED'), false); +}); + +test('an empty history teaches nothing and says so', () => { + const result = learn({ graph: buildGraph(manifest()), receipts: [], runs: [] }); + assert.equal(result.proposal.empty, true); + assert.equal(result.observations.receipts, 0); +}); + +test('findings never edit the manifest on their own', async () => { + const graph = buildGraph(manifest()); + const original = JSON.stringify(graph.manifest); + const result = learn({ graph, receipts: Array.from({ length: 3 }, () => receipt({ requested: { action: 'payments.transfer', target: 't', params: { amount: 18000 } } })) }); + assert.ok(result.findings.length); + assert.equal(JSON.stringify(graph.manifest), original, 'learning proposes; applying is a separate, explicit act'); +}); + +test('real runs feed the loop end to end', async () => { + const graph = buildGraph(manifest()); + const receipts = []; + for (let i = 0; i < 4; i++) { + const run = await protect(graph, { action: 'payments.transfer', target: 'acct:1', params: { amount: 18000 } }); + receipts.push(run.receipt); + } + const result = learn({ graph, receipts }); + assert.ok(result.findings.some((f) => f.type === 'UNDERSTATED_CONSEQUENCE')); + assert.ok(result.findings.some((f) => f.type === 'REPEATED_DENIAL'), 'four unapproved CRITICAL attempts is a pattern'); +}); diff --git a/sdk/python/agt.py b/sdk/python/agt.py index 051a374..6f0693a 100644 --- a/sdk/python/agt.py +++ b/sdk/python/agt.py @@ -150,7 +150,7 @@ def parse_spiffe_id(agent_id: Any) -> Optional[Dict[str, str]]: (re.compile(r"terraform|infra|instance|bucket|dns|firewall|network|cluster", re.I), "INFRA_MODIFIED"), (re.compile(r"send|email|slack|sms|post_message|notify|publish|tweet", re.I), "MESSAGE_SENT"), (re.compile(r"write|create|update|insert|upsert|patch|put", re.I), "DATA_WRITTEN"), - (re.compile(r"read|get|list|search|query|select|fetch", re.I), "DATA_READ"), + (re.compile(r"read|get|list|search|query|select|fetch|export|download|dump|backup", re.I), "DATA_READ"), ] _CONSEQUENCES = {