attesso 0.4.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/policy.mjs CHANGED
@@ -65,33 +65,33 @@ export function validatePolicy(policy) {
65
65
  // names one cause rather than a list.
66
66
  export function evaluate(policy, proposal, now = new Date()) {
67
67
  if (policy.action !== '*' && proposal.action !== policy.action) {
68
- return { decision: 'DENY', code: 'ACTION_MISMATCH' };
68
+ return { decision: 'DENY', code: 'ACTION_MISMATCH', rule: 'policy.action' };
69
69
  }
70
70
  if (policy.subjects !== undefined) {
71
71
  if (typeof proposal.subject !== 'string') {
72
- return { decision: 'DENY', code: 'REQUIRED_ATTRIBUTE_MISSING' };
72
+ return { decision: 'DENY', code: 'REQUIRED_ATTRIBUTE_MISSING', rule: 'policy.subjects' };
73
73
  }
74
74
  if (!policy.subjects.includes(proposal.subject)) {
75
- return { decision: 'DENY', code: 'VALUE_NOT_ALLOWED' };
75
+ return { decision: 'DENY', code: 'VALUE_NOT_ALLOWED', rule: 'policy.subjects' };
76
76
  }
77
77
  }
78
78
  if (policy.max_amount !== undefined) {
79
79
  if (!proposal.amount || !Number.isInteger(proposal.amount.minor)) {
80
- return { decision: 'DENY', code: 'REQUIRED_ATTRIBUTE_MISSING' };
80
+ return { decision: 'DENY', code: 'REQUIRED_ATTRIBUTE_MISSING', rule: 'policy.max_amount' };
81
81
  }
82
82
  if (proposal.amount.currency !== policy.currency) {
83
- return { decision: 'DENY', code: 'VALUE_MISMATCH' };
83
+ return { decision: 'DENY', code: 'VALUE_MISMATCH', rule: 'policy.currency' };
84
84
  }
85
85
  if (proposal.amount.minor > policy.max_amount) {
86
- return { decision: 'DENY', code: 'ABOVE_MAXIMUM' };
86
+ return { decision: 'DENY', code: 'ABOVE_MAXIMUM', rule: 'policy.max_amount' };
87
87
  }
88
88
  }
89
89
  const at = now.getTime();
90
90
  if (policy.on_or_after && at < Date.parse(policy.on_or_after)) {
91
- return { decision: 'DENY', code: 'OUTSIDE_DATE_RANGE' };
91
+ return { decision: 'DENY', code: 'OUTSIDE_DATE_RANGE', rule: 'policy.on_or_after' };
92
92
  }
93
93
  if (policy.on_or_before && at > Date.parse(policy.on_or_before)) {
94
- return { decision: 'DENY', code: 'OUTSIDE_DATE_RANGE' };
94
+ return { decision: 'DENY', code: 'OUTSIDE_DATE_RANGE', rule: 'policy.on_or_before' };
95
95
  }
96
96
  return { decision: ALLOW, code: ALLOW };
97
97
  }
@@ -0,0 +1,104 @@
1
+ // A receipt is one entry, its path to the root, and a signed checkpoint. It is
2
+ // self-contained: a verifier needs the public key, nothing else.
3
+ import { sign, verify } from 'node:crypto';
4
+ import { canonical } from './canonical.mjs';
5
+ import { inclusionPath, leafHash, merkleRoot, prefixRoot, verifyInclusion } from './merkle.mjs';
6
+
7
+ const encode = (buffer) => buffer.toString('base64url');
8
+ const decode = (text) => Buffer.from(text, 'base64url');
9
+
10
+ // The key file and the signer object carry the same three facts under different
11
+ // names; map once so a command never spells the clump out again.
12
+ export const signerOf = (key) => ({ kid: key.kid, privatePem: key.private, publicPem: key.public });
13
+
14
+ export function checkpointFor(entries, { kid, privatePem, at = new Date() }) {
15
+ const body = {
16
+ v: 1,
17
+ origin: `attesso.local/${kid}`,
18
+ size: entries.length,
19
+ root: encode(merkleRoot(entries.map(leafHash))),
20
+ at: at.toISOString(),
21
+ };
22
+ const sig = sign(null, Buffer.from(canonical(body)), privatePem).toString('base64url');
23
+ return { ...body, sig };
24
+ }
25
+
26
+ export function verifyCheckpoint(checkpoint, publicPem) {
27
+ if (!checkpoint || typeof checkpoint.sig !== 'string') return false;
28
+ const { sig, ...body } = checkpoint;
29
+ if (
30
+ body.v !== 1 ||
31
+ typeof body.origin !== 'string' ||
32
+ !Number.isInteger(body.size) ||
33
+ typeof body.root !== 'string' ||
34
+ body.root === ''
35
+ ) {
36
+ return false;
37
+ }
38
+ return verify(null, Buffer.from(canonical(body)), publicPem, Buffer.from(sig, 'base64url'));
39
+ }
40
+
41
+ export function receiptFor(entries, index, { kid, privatePem, publicPem }) {
42
+ const entry = entries[index];
43
+ if (!entry) throw new Error(`no entry at index ${index}`);
44
+ const leaves = entries.map(leafHash);
45
+ return {
46
+ v: 1,
47
+ entry,
48
+ leaf_index: index,
49
+ tree_size: entries.length,
50
+ path: inclusionPath(leaves, index).map(encode),
51
+ checkpoint: checkpointFor(entries, { kid, privatePem }),
52
+ key: publicPem,
53
+ };
54
+ }
55
+
56
+ export function verifyReceipt(receipt, publicPem) {
57
+ if (!receipt || receipt.v !== 1 || !receipt.entry || !Array.isArray(receipt.path)) {
58
+ return { ok: false, reason: 'receipt is not version 1' };
59
+ }
60
+ if (!Number.isInteger(receipt.leaf_index) || !Number.isInteger(receipt.tree_size) || receipt.tree_size < 1) {
61
+ return { ok: false, reason: 'receipt has no integer index and tree size' };
62
+ }
63
+ if (!receipt.path.every((hash) => typeof hash === 'string' && hash !== '')) {
64
+ return { ok: false, reason: 'receipt path is not a list of hashes' };
65
+ }
66
+ if (receipt.entry.seq !== receipt.leaf_index + 1) {
67
+ return { ok: false, reason: 'leaf index does not match the entry sequence' };
68
+ }
69
+ const key = publicPem ?? receipt.key;
70
+ if (!verifyCheckpoint(receipt.checkpoint, key)) {
71
+ return { ok: false, reason: 'checkpoint signature does not verify' };
72
+ }
73
+ if (receipt.checkpoint.size !== receipt.tree_size) {
74
+ return { ok: false, reason: 'checkpoint size does not match the receipt' };
75
+ }
76
+ const ok = verifyInclusion(
77
+ leafHash(receipt.entry),
78
+ receipt.leaf_index,
79
+ receipt.tree_size,
80
+ receipt.path.map(decode),
81
+ decode(receipt.checkpoint.root),
82
+ );
83
+ return ok
84
+ ? { ok: true, reason: `entry #${receipt.entry.seq} is in the tree of ${receipt.tree_size}` }
85
+ : { ok: false, reason: 'the entry is not in the tree its proof names' };
86
+ }
87
+
88
+ // The pin is the only thing an attacker with the key cannot re-derive. It is
89
+ // checked against the local log, so the log must be present.
90
+ export function verifyPinned(entries, checkpoint, publicPem) {
91
+ if (!verifyCheckpoint(checkpoint, publicPem)) {
92
+ return { ok: false, reason: 'pinned checkpoint signature does not verify' };
93
+ }
94
+ if (!Number.isInteger(checkpoint.size) || checkpoint.size < 0) {
95
+ return { ok: false, reason: 'pinned checkpoint has no size' };
96
+ }
97
+ if (entries.length < checkpoint.size) {
98
+ return { ok: false, reason: `the log holds ${entries.length} entries, fewer than the pinned ${checkpoint.size}` };
99
+ }
100
+ if (!prefixRoot(entries, checkpoint.size).equals(decode(checkpoint.root))) {
101
+ return { ok: false, reason: 'the log was rewritten below the pinned head' };
102
+ }
103
+ return { ok: true, reason: `log extends the pinned head at ${checkpoint.size} entries` };
104
+ }
package/src/replay.mjs ADDED
@@ -0,0 +1,133 @@
1
+ // Re-judge a recorded ledger under a candidate policy. The ledger is a record of
2
+ // attempts, so every entry stays in the history even when it was refused: that is
3
+ // what makes an observe-mode run replayable.
4
+ import { decide } from './decide.mjs';
5
+ import { argsHashOf, deniedCapabilities } from './rules.mjs';
6
+
7
+ export const proposalOf = (entry) => ({
8
+ action: entry.action,
9
+ subject: entry.subject,
10
+ amount: entry.amount,
11
+ task: entry.task,
12
+ trust: entry.trust,
13
+ capability: entry.capability,
14
+ args_hash: argsHashOf(entry),
15
+ meta: entry.meta,
16
+ });
17
+
18
+ // A rule can only be defeated by a field this entry would have needed to match
19
+ // it. One web-only same-arguments rule must not make every other tool
20
+ // unenforceable, or a replay cannot judge the rows it is meant to compare.
21
+ const matchesTrigger = (trigger, entry) => {
22
+ if (trigger.trust && trigger.trust !== entry.trust) return false;
23
+ if (trigger.subject && trigger.subject !== entry.subject) return false;
24
+ if (trigger.capability && entry.capability !== undefined && trigger.capability !== entry.capability) return false;
25
+ return true;
26
+ };
27
+
28
+ function needsArguments(entry, rules) {
29
+ return (rules?.rules ?? []).some((rule) => {
30
+ const trigger = rule.if_path ?? {};
31
+ if (trigger.same_arguments) {
32
+ if (!matchesTrigger(trigger, entry)) return false;
33
+ // Without a capability, the missing-field check below reports the gap.
34
+ return !(trigger.capability && entry.capability === undefined);
35
+ }
36
+ if (rule.max_repeats !== undefined) {
37
+ if (entry.capability !== undefined) {
38
+ const denied = deniedCapabilities(rule);
39
+ if (!denied.includes(entry.capability)) return false;
40
+ }
41
+ return true;
42
+ }
43
+ return false;
44
+ });
45
+ }
46
+
47
+ // A candidate rule may read a field this entry never stored. Say so instead of
48
+ // reporting a clean allow the rule could not actually have reached.
49
+ function missingFields(entry, rules) {
50
+ const reasons = [];
51
+ const pathRules = (rules?.rules ?? []).filter(
52
+ (rule) => rule.if_path?.trust !== undefined || rule.if_path?.capability !== undefined,
53
+ );
54
+ if (
55
+ pathRules.some(
56
+ (rule) => rule.if_path?.capability && matchesTrigger(rule.if_path, entry) && !entry.capability,
57
+ )
58
+ ) {
59
+ reasons.push('NO_CAPABILITY');
60
+ }
61
+ if (needsArguments(entry, rules) && argsHashOf(entry) === undefined) reasons.push('NO_ARGUMENTS');
62
+ return reasons;
63
+ }
64
+
65
+ export function replay(entries, { policy, rules }) {
66
+ const histories = new Map();
67
+ const summary = {
68
+ total: entries.length,
69
+ original_denials: entries.filter((entry) => entry.decision !== 'ALLOW').length,
70
+ candidate_denials: 0,
71
+ denial_rate: 0,
72
+ unchanged: 0,
73
+ newly_allowed: 0,
74
+ newly_denied: 0,
75
+ unenforceable: 0,
76
+ changed: [],
77
+ unenforceable_rows: [],
78
+ };
79
+ for (const entry of entries) {
80
+ const key = entry.task ?? null;
81
+ const history = histories.get(key) ?? [];
82
+ const from = { decision: entry.decision, code: entry.code };
83
+ if (entry.control) {
84
+ summary.unchanged += 1;
85
+ history.push(entry);
86
+ histories.set(key, history);
87
+ continue;
88
+ }
89
+ const reasons = missingFields(entry, rules);
90
+ if (reasons.length > 0) {
91
+ summary.unenforceable += 1;
92
+ summary.unenforceable_rows.push({ seq: entry.seq, reasons });
93
+ } else {
94
+ const to = decide({ policy, rules, history, proposal: proposalOf(entry), now: new Date(entry.at) });
95
+ if (to.decision === from.decision && to.code === from.code) {
96
+ summary.unchanged += 1;
97
+ } else {
98
+ if (from.decision === 'ALLOW' && to.decision === 'DENY') summary.newly_denied += 1;
99
+ if (from.decision !== 'ALLOW' && to.decision === 'ALLOW') summary.newly_allowed += 1;
100
+ summary.changed.push({
101
+ seq: entry.seq,
102
+ action: entry.action,
103
+ subject: entry.subject,
104
+ task: entry.task ?? null,
105
+ from,
106
+ to: { decision: to.decision, code: to.code, rule: to.rule ?? null },
107
+ });
108
+ }
109
+ }
110
+ history.push(entry);
111
+ histories.set(key, history);
112
+ }
113
+ summary.candidate_denials = summary.original_denials - summary.newly_allowed + summary.newly_denied;
114
+ summary.denial_rate = entries.length === 0 ? 0 : summary.candidate_denials / entries.length;
115
+ return summary;
116
+ }
117
+
118
+ export function renderReplay(summary) {
119
+ const percent = `${Math.round(summary.denial_rate * 100)}%`;
120
+ const lines = [
121
+ `${summary.total} entries: ${summary.candidate_denials} refusals under the candidate (${percent}), ` +
122
+ `${summary.unchanged} unchanged, ${summary.newly_denied} newly denied, ` +
123
+ `${summary.newly_allowed} newly allowed, ${summary.unenforceable} unenforceable`,
124
+ ];
125
+ for (const row of summary.changed.slice(0, 10)) {
126
+ const subject = row.subject ? ` ${row.subject}` : '';
127
+ lines.push(` #${row.seq} ${row.from.decision}/${row.from.code} -> ${row.to.decision}/${row.to.code} ${row.action}${subject}`);
128
+ }
129
+ for (const row of summary.unenforceable_rows.slice(0, 10)) {
130
+ lines.push(` #${row.seq} unenforceable (${row.reasons.join(', ')})`);
131
+ }
132
+ return `${lines.join('\n')}\n`;
133
+ }
package/src/report.mjs CHANGED
@@ -2,46 +2,88 @@
2
2
  // stop": per task, how many actions ran, how many were refused and why, and the
3
3
  // tokens and money the task spent. Costs come from a llm.session entry the
4
4
  // recorder writes, never from an estimate the report invents.
5
- import { readState } from './store.mjs';
5
+ import { paths, readKey, readState } from './store.mjs';
6
+ import { forEachEntry } from './stream.mjs';
6
7
 
7
8
  const isSpend = (entry) => entry.action === 'llm.session';
8
- const isAction = (entry) => !isSpend(entry);
9
9
 
10
- function summarise(entries) {
11
- const actions = entries.filter(isAction);
12
- const blocked = actions.filter((entry) => entry.decision !== 'ALLOW');
13
- const reasons = new Map();
14
- for (const entry of blocked) {
15
- const key = entry.code ?? 'UNKNOWN';
16
- reasons.set(key, (reasons.get(key) ?? 0) + 1);
17
- }
18
- const sum = (field) => entries.reduce((total, entry) => total + (Number(entry.meta?.[field]) || 0), 0);
10
+ const emptyGroup = () => ({
11
+ actions: 0,
12
+ blocked: 0,
13
+ reasons: new Map(),
14
+ tokens: 0,
15
+ cached_tokens: 0,
16
+ cost_usd: 0,
17
+ api_calls: 0,
18
+ });
19
+
20
+ const add = (group, entry) => {
21
+ const meta = entry.meta ?? {};
22
+ group.tokens += Number(meta.tokens) || 0;
23
+ group.cached_tokens += Number(meta.cached_tokens) || 0;
24
+ group.cost_usd += Number(meta.cost_usd) || 0;
25
+ group.api_calls += Number(meta.api_calls) || 0;
26
+ if (isSpend(entry)) return;
27
+ group.actions += 1;
28
+ if (entry.decision === 'ALLOW') return;
29
+ group.blocked += 1;
30
+ const code = entry.code ?? 'UNKNOWN';
31
+ group.reasons.set(code, (group.reasons.get(code) ?? 0) + 1);
32
+ };
33
+
34
+ function finish(group) {
19
35
  return {
20
- actions: actions.length,
21
- allowed: actions.length - blocked.length,
22
- blocked: blocked.length,
23
- blocked_share: actions.length === 0 ? null : blocked.length / actions.length,
24
- reasons: [...reasons.entries()].sort((left, right) => right[1] - left[1]).map(([code, count]) => ({ code, count })),
25
- tokens: sum('tokens'),
26
- cached_tokens: sum('cached_tokens'),
27
- cost_usd: Number(sum('cost_usd').toFixed(6)),
28
- api_calls: sum('api_calls'),
36
+ actions: group.actions,
37
+ allowed: group.actions - group.blocked,
38
+ blocked: group.blocked,
39
+ blocked_share: group.actions === 0 ? null : group.blocked / group.actions,
40
+ reasons: [...group.reasons.entries()]
41
+ .sort((left, right) => right[1] - left[1])
42
+ .map(([code, count]) => ({ code, count })),
43
+ tokens: group.tokens,
44
+ cached_tokens: group.cached_tokens,
45
+ cost_usd: Number(group.cost_usd.toFixed(6)),
46
+ api_calls: group.api_calls,
47
+ };
48
+ }
49
+
50
+ function reportOf(groups, count, kid, all) {
51
+ return {
52
+ key: kid,
53
+ entries: count,
54
+ tasks: [...groups.entries()]
55
+ .map(([task, group]) => ({ task, ...finish(group) }))
56
+ .sort((left, right) => right.actions - left.actions),
57
+ totals: finish(all),
29
58
  };
30
59
  }
31
60
 
32
61
  export function buildReport(dir) {
33
62
  const { entries, key } = readState(dir);
34
63
  const groups = new Map();
64
+ const all = emptyGroup();
35
65
  for (const entry of entries) {
66
+ add(all, entry);
36
67
  const task = entry.task ?? '(unscoped)';
37
- if (!groups.has(task)) groups.set(task, []);
38
- groups.get(task).push(entry);
68
+ if (!groups.has(task)) groups.set(task, emptyGroup());
69
+ add(groups.get(task), entry);
39
70
  }
40
- const tasks = [...groups.entries()]
41
- .map(([task, rows]) => ({ task, ...summarise(rows) }))
42
- .sort((left, right) => right.actions - left.actions);
43
- const totals = summarise(entries);
44
- return { key: key.kid, entries: entries.length, tasks, totals };
71
+ return reportOf(groups, entries.length, key.kid, all);
72
+ }
73
+
74
+ export async function buildReportStream(dir) {
75
+ const key = readKey(dir);
76
+ const groups = new Map();
77
+ const all = emptyGroup();
78
+ let count = 0;
79
+ await forEachEntry(paths(dir).ledger, (entry) => {
80
+ count += 1;
81
+ add(all, entry);
82
+ const task = entry.task ?? '(unscoped)';
83
+ if (!groups.has(task)) groups.set(task, emptyGroup());
84
+ add(groups.get(task), entry);
85
+ });
86
+ return reportOf(groups, count, key.kid, all);
45
87
  }
46
88
 
47
89
  const percent = (share) => (share === null ? 'n/a' : `${Math.round(share * 100)}%`);
package/src/rules.mjs CHANGED
@@ -4,13 +4,57 @@
4
4
  import { createHash } from 'node:crypto';
5
5
  import { canonical } from './canonical.mjs';
6
6
 
7
- export const CAPABILITIES = ['read', 'send', 'execute', 'spend'];
7
+ export const CAPABILITIES = ['read', 'write', 'send', 'execute', 'spend'];
8
+ export const RULE_PROFILES = ['balanced', 'strict'];
8
9
  export const TRUST_LEVELS = ['internal', 'untrusted', 'unknown'];
9
10
 
11
+ // One policy, two postures. Balanced catches the outbound boundary and duplicate
12
+ // fetches; strict keeps the old conservative posture for an untrusted
13
+ // environment, where a shell command itself can exfiltrate.
14
+ export function rulesForProfile(profile = 'balanced') {
15
+ const untrustedRead = { trust: 'untrusted', capability: 'read', within_minutes: 30 };
16
+ const repeatWeb = {
17
+ capability: 'read',
18
+ subject: 'web_extract',
19
+ same_arguments: true,
20
+ within_minutes: 30,
21
+ };
22
+ if (profile === 'balanced') {
23
+ return {
24
+ version: 1,
25
+ rules: [
26
+ { id: 'no-send-after-untrusted-read', deny: { capabilities: ['send'] }, if_path: { ...untrustedRead } },
27
+ { id: 'no-repeat-web-extract', deny: { capabilities: ['read'] }, if_path: { ...repeatWeb } },
28
+ ],
29
+ };
30
+ }
31
+ if (profile === 'strict') {
32
+ return {
33
+ version: 1,
34
+ rules: [
35
+ { id: 'no-send-after-untrusted-read', deny: { capabilities: ['send', 'write', 'execute'] }, if_path: { ...untrustedRead } },
36
+ { id: 'no-repeat-web-extract', deny: { capabilities: ['read'] }, if_path: { ...repeatWeb } },
37
+ ],
38
+ };
39
+ }
40
+ throw new Error(`unknown rule profile ${profile}; choose ${RULE_PROFILES.join(' or ')}`);
41
+ }
42
+
43
+ // Entries written before the argument fingerprint moved out of meta keep it
44
+ // there; every reader goes through this helper so both shapes agree.
45
+ export const argsHashOf = (value) => value?.args_hash ?? value?.meta?.args_hash;
46
+
10
47
  export function rulesDigest(rules) {
11
48
  return createHash('sha256').update(canonical(rules)).digest('base64url');
12
49
  }
13
50
 
51
+ // One reading of a rule's deny block, shared by validation and evaluation.
52
+ export function deniedCapabilities(rule) {
53
+ const deny = rule.deny ?? {};
54
+ if (deny.all) return CAPABILITIES;
55
+ return deny.capabilities ?? (deny.capability ? [deny.capability] : []);
56
+ }
57
+
14
58
  export function validateRules(rules) {
15
59
  const fail = (message) => {
16
60
  throw new Error(`rules are invalid: ${message}`);
@@ -20,8 +64,7 @@ export function validateRules(rules) {
20
64
  if (!Array.isArray(rules.rules)) fail('rules must be a list');
21
65
  for (const rule of rules.rules) {
22
66
  if (typeof rule.id !== 'string' || rule.id === '') fail('every rule needs an id');
23
- const deny = rule.deny ?? {};
24
- const denies = deny.all ? CAPABILITIES : (deny.capabilities ?? (deny.capability ? [deny.capability] : []));
67
+ const denies = deniedCapabilities(rule);
25
68
  if (denies.length === 0) fail(`rule ${rule.id} denies nothing`);
26
69
  for (const capability of denies) {
27
70
  if (!CAPABILITIES.includes(capability)) fail(`rule ${rule.id} denies unknown capability ${capability}`);
@@ -52,8 +95,7 @@ export function validateRules(rules) {
52
95
  export function evaluatePath(rules, history, proposal, now = new Date()) {
53
96
  if (!rules) return null;
54
97
  for (const rule of rules.rules ?? []) {
55
- const deny = rule.deny ?? {};
56
- const denied = deny.all ? CAPABILITIES : (deny.capabilities ?? (deny.capability ? [deny.capability] : []));
98
+ const denied = deniedCapabilities(rule);
57
99
  if (proposal.capability && !denied.includes(proposal.capability)) continue;
58
100
 
59
101
  // A ceiling on the task itself. This is the rule that stops a runaway loop,
@@ -62,11 +104,16 @@ export function evaluatePath(rules, history, proposal, now = new Date()) {
62
104
  return { decision: 'DENY', code: 'STEP_LIMIT', rule: rule.id, tainted_by: history.length };
63
105
  }
64
106
  if (rule.max_repeats !== undefined) {
65
- const repeats = history.filter(
66
- (entry) => entry.subject === proposal.subject && entry.meta?.args_hash === proposal.meta?.args_hash,
67
- );
68
- if (repeats.length >= rule.max_repeats) {
69
- return { decision: 'DENY', code: 'REPEAT_LIMIT', rule: rule.id, tainted_by: repeats[0].seq };
107
+ const proposalHash = argsHashOf(proposal);
108
+ // Without a fingerprint a repeat cannot be identified; counting every call
109
+ // with the same subject would refuse the second unrelated command.
110
+ if (proposalHash !== undefined) {
111
+ const repeats = history.filter(
112
+ (entry) => entry.subject === proposal.subject && argsHashOf(entry) === proposalHash,
113
+ );
114
+ if (repeats.length >= rule.max_repeats) {
115
+ return { decision: 'DENY', code: 'REPEAT_LIMIT', rule: rule.id, tainted_by: repeats[0].seq };
116
+ }
70
117
  }
71
118
  }
72
119
  const trigger = rule.if_path ?? {};
@@ -78,7 +125,7 @@ export function evaluatePath(rules, history, proposal, now = new Date()) {
78
125
  if (trigger.trust && entry.trust !== trigger.trust) return false;
79
126
  if (trigger.capability && entry.capability !== trigger.capability) return false;
80
127
  if (trigger.subject && entry.subject !== trigger.subject) return false;
81
- if (trigger.same_arguments && entry.meta?.args_hash !== proposal.meta?.args_hash) return false;
128
+ if (trigger.same_arguments && argsHashOf(entry) !== argsHashOf(proposal)) return false;
82
129
  if (window > 0 && now.getTime() - Date.parse(entry.at) > window) return false;
83
130
  return true;
84
131
  });
package/src/serve.mjs CHANGED
@@ -38,8 +38,10 @@ export function serve(dir, { observe = false, input = process.stdin, output = pr
38
38
  id: request.id ?? null,
39
39
  decision: entry.decision,
40
40
  code: entry.code,
41
- reason: entry.meta?.rule
42
- ? `${entry.meta.rule} (tainted by step #${entry.meta.tainted_by})`
41
+ reason: entry.rule
42
+ ? entry.tainted_by === undefined
43
+ ? entry.rule
44
+ : `${entry.rule} (tainted by step #${entry.tainted_by})`
43
45
  : null,
44
46
  enforced: entry.decision === 'ALLOW' || observe ? false : true,
45
47
  seq: entry.seq,
package/src/setup.mjs CHANGED
@@ -4,9 +4,10 @@
4
4
  // setup detect a harness, wire the hook, leave a short block the agent reads
5
5
  // explain one ledger entry in full, including what tainted the path
6
6
  // suggest rules the ledger's own counts justify, ready to accept
7
- import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
7
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
8
8
  import { homedir } from 'node:os';
9
- import { join } from 'node:path';
9
+ import { join, resolve } from 'node:path';
10
+ import { argsHashOf } from './rules.mjs';
10
11
 
11
12
  const AGENT_BLOCK = `<!-- attesso:start -->
12
13
  A veto is active on your tool calls. When a call is refused you get a tool error naming
@@ -20,7 +21,7 @@ function stripAgentBlock(text) {
20
21
  }
21
22
 
22
23
  export function writeAgentContext(dir, { dryRun = false } = {}) {
23
- const path = join(process.cwd(), 'AGENTS.md');
24
+ const path = join(resolve(dir), 'AGENTS.md');
24
25
  const existing = existsSync(path) ? readFileSync(path, 'utf8') : '';
25
26
  const next = `${stripAgentBlock(existing).trimEnd()}\n\n${AGENT_BLOCK}\n`;
26
27
  if (dryRun) return { path, changed: next !== existing, preview: AGENT_BLOCK };
@@ -28,52 +29,45 @@ export function writeAgentContext(dir, { dryRun = false } = {}) {
28
29
  return { path, changed: next !== existing };
29
30
  }
30
31
 
31
- function hermesConfig() {
32
- return join(homedir(), '.hermes', 'config.yaml');
33
- }
34
-
35
- const HOOK_BLOCK = (cli, dir) => `hooks:
36
- pre_tool_call:
37
- - command: ${cli} --dir ${dir} hook --observe
38
- timeout: 10
39
- `;
40
-
41
32
  export function detectHarnesses({ home = homedir(), cwd = process.cwd() } = {}) {
42
33
  const found = [];
43
34
  const hermes = join(home, '.hermes', 'config.yaml');
44
35
  if (existsSync(hermes)) found.push({ key: 'hermes', path: hermes });
45
- const claude = join(cwd, '.claude', 'settings.json');
36
+ const claude = join(home, '.claude', 'settings.json');
46
37
  if (existsSync(claude)) found.push({ key: 'claude-code', path: claude });
47
- const cursor = join(cwd, '.cursor', 'hooks.json');
38
+ const projectClaude = join(cwd, '.claude', 'settings.json');
39
+ if (existsSync(projectClaude) && projectClaude !== claude) found.push({ key: 'claude-code-project', path: projectClaude });
40
+ const codex = join(home, '.codex', 'hooks.json');
41
+ if (existsSync(codex)) found.push({ key: 'codex', path: codex });
42
+ const gemini = join(home, '.gemini', 'settings.json');
43
+ if (existsSync(gemini)) found.push({ key: 'gemini', path: gemini });
44
+ const cline = join(home, '.cline');
45
+ if (existsSync(cline)) found.push({ key: 'cline', path: join(home, 'Documents', 'Cline', 'Hooks') });
46
+ const cursor = join(home, '.cursor', 'hooks.json');
48
47
  if (existsSync(cursor)) found.push({ key: 'cursor', path: cursor });
48
+ const projectCursor = join(cwd, '.cursor', 'hooks.json');
49
+ if (existsSync(projectCursor) && projectCursor !== cursor) found.push({ key: 'cursor-project', path: projectCursor });
49
50
  return found;
50
51
  }
51
52
 
52
- // Hermes is the one harness we verify end to end today, so it is the only one we
53
- // modify. For the others we print the snippet: writing a config format we have not
54
- // tested against the real product would be a guess, and a guess in a config file is
55
- // worse than a line of instructions.
56
- export function wireHermes(cli, dir, { dryRun = false, path = hermesConfig() } = {}) {
57
- if (!existsSync(path)) return { applied: false, reason: 'no Hermes config at ~/.hermes/config.yaml' };
58
- const text = readFileSync(path, 'utf8');
59
- if (/attesso.*hook/.test(text)) return { applied: false, reason: 'already wired' };
60
- if (/^hooks:/m.test(text)) {
61
- return { applied: false, reason: 'config already has a hooks: block, merge by hand', snippet: HOOK_BLOCK(cli, dir) };
62
- }
63
- const next = text.replace(/^approvals:/m, `${HOOK_BLOCK(cli, dir)}approvals:`);
64
- if (next === text) return { applied: false, reason: 'no anchor to insert before; add the block by hand', snippet: HOOK_BLOCK(cli, dir) };
65
- if (!dryRun) {
66
- renameSync(path, `${path}.bak`);
67
- writeFileSync(path, next);
68
- }
69
- return { applied: !dryRun, path, snippet: HOOK_BLOCK(cli, dir) };
70
- }
71
-
72
53
  export function explain(dir, seq, { entries }) {
73
54
  const entry = entries.find((candidate) => candidate.seq === Number(seq));
74
55
  if (!entry) return { found: false, seq };
75
- const taintedBy = entry.meta?.tainted_by;
56
+ const taintedBy = entry.tainted_by ?? entry.meta?.tainted_by;
57
+ const rule = entry.rule ?? entry.meta?.rule ?? null;
76
58
  const source = taintedBy ? entries.find((candidate) => candidate.seq === taintedBy) : null;
59
+ const advice =
60
+ entry.decision === 'ALLOW'
61
+ ? 'Nothing to fix: this call was allowed.'
62
+ : entry.code === 'PATH_DENIED'
63
+ ? `The task read ${source?.subject ?? 'untrusted content'} at step #${taintedBy}. Take another path, or narrow ${rule}.`
64
+ : entry.code === 'STEP_LIMIT'
65
+ ? `The task reached the step ceiling in ${rule}. Split the task or raise the ceiling.`
66
+ : entry.code === 'REPEAT_LIMIT'
67
+ ? `The task repeated the same call past ${rule}. Change the call or raise the ceiling.`
68
+ : rule
69
+ ? `The call broke ${rule}. Check the field it names.`
70
+ : 'The call broke the policy, not a path rule. Check the amount, payee or window.';
77
71
  return {
78
72
  found: true,
79
73
  seq: entry.seq,
@@ -83,22 +77,17 @@ export function explain(dir, seq, { entries }) {
83
77
  trust: entry.trust,
84
78
  verdict: entry.decision,
85
79
  code: entry.code,
86
- rule: entry.meta?.rule ?? null,
80
+ rule,
87
81
  task: entry.task ?? null,
88
82
  tainted_by: source ? { seq: source.seq, action: source.action, subject: source.subject, trust: source.trust } : null,
89
- advice:
90
- entry.decision === 'ALLOW'
91
- ? 'Nothing to fix: this call was allowed.'
92
- : entry.meta?.rule
93
- ? `The task read ${source?.subject ?? 'untrusted content'} at step #${taintedBy}. Take another path, or narrow ${entry.meta.rule}.`
94
- : 'The call broke the policy, not a path rule. Check the amount, payee or window.',
83
+ advice,
95
84
  };
96
85
  }
97
86
 
98
- const signature = (entry) => `${entry.action}\u0000${entry.subject ?? ''}\u0000${entry.meta?.args_hash ?? ''}`;
87
+ const signature = (entry) => `${entry.action}\u0000${entry.subject ?? ''}\u0000${argsHashOf(entry) ?? ''}`;
99
88
 
100
89
  export function suggest(dir, { entries, minRepeats = 3 }) {
101
- const within = entries.filter((entry) => entry.decision === 'ALLOW');
90
+ const within = entries.filter((entry) => entry.decision === 'ALLOW' && argsHashOf(entry) !== undefined);
102
91
  const counts = new Map();
103
92
  for (const entry of within) {
104
93
  const key = `${entry.task ?? ''}\u0000${signature(entry)}`;