Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions packages/pctr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,81 @@ pctr replay <run> --fork e3
pctr replay <run> --compare <other-run>
```

## 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
Expand Down Expand Up @@ -232,6 +307,8 @@ pctr simulate <action> Same, without executing any effect
pctr replay [run] Agent Time Machine
pctr explain <event> Why was this allowed, denied, or rerouted?
pctr receipt [id] Show an execution receipt
pctr decide <action> 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
Expand Down
51 changes: 51 additions & 0 deletions packages/pctr/bin/pctr.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 <action>');
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 = [];
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 <action> How should a trust change be answered right now?
pctr learn What the accumulated evidence says to change
pctr doctor Check your setup

Options
Expand All @@ -376,6 +426,7 @@ Options
--target <resource> Execution target
--boundary <url> protect: verify authority at a remote effect boundary
--port <n> serve: port for the effect boundary (default 8787)
--apply learn: write the proposed changes into pctr.json
--probe <file|command> preview: measure the real consequence with this probe
--key <public-key.pem> verify: check signatures against this public key
--export <file> keys: write the public key to a file
Expand Down
2 changes: 1 addition & 1 deletion packages/pctr/src/consequences.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}) {
Expand Down
146 changes: 146 additions & 0 deletions packages/pctr/src/decisions.mjs
Original file line number Diff line number Diff line change
@@ -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;
}
2 changes: 2 additions & 0 deletions packages/pctr/src/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading
Loading