attesso 0.4.1 → 0.6.1
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/LICENSE +201 -0
- package/NOTICE +4 -0
- package/README.md +158 -20
- package/bin/attesso-control-mcp.mjs +13 -0
- package/bin/attesso-mcp.mjs +2 -0
- package/bin/attesso.mjs +554 -75
- package/package.json +8 -4
- package/src/bundle.mjs +128 -0
- package/src/classify.mjs +83 -0
- package/src/cli-io.mjs +130 -0
- package/src/connect.mjs +160 -0
- package/src/control.mjs +101 -0
- package/src/decide.mjs +10 -0
- package/src/fs-paths.mjs +121 -0
- package/src/harness.mjs +130 -0
- package/src/hook.mjs +64 -40
- package/src/install.mjs +122 -0
- package/src/jsonl.mjs +16 -0
- package/src/ledger.mjs +79 -20
- package/src/mcp/control.mjs +171 -0
- package/src/mcp-proxy.mjs +605 -0
- package/src/merkle.mjs +83 -0
- package/src/policy.mjs +8 -8
- package/src/receipt.mjs +104 -0
- package/src/replay.mjs +133 -0
- package/src/report.mjs +69 -27
- package/src/rules.mjs +58 -11
- package/src/serve.mjs +4 -2
- package/src/setup.mjs +34 -45
- package/src/store.mjs +425 -64
- package/src/stream.mjs +72 -0
- package/src/toolpin.mjs +131 -0
- package/src/ui/catalog.mjs +266 -0
- package/src/ui/live.mjs +58 -0
- package/src/ui/query.mjs +163 -0
- package/src/ui/server.mjs +379 -0
- package/src/ui/static/index.html +1695 -0
- package/src/usage.mjs +96 -0
- package/src/watch.mjs +78 -0
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
|
}
|
package/src/receipt.mjs
ADDED
|
@@ -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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
|
21
|
-
allowed: actions
|
|
22
|
-
blocked: blocked
|
|
23
|
-
blocked_share: actions
|
|
24
|
-
reasons: [...reasons.entries()]
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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)
|
|
68
|
+
if (!groups.has(task)) groups.set(task, emptyGroup());
|
|
69
|
+
add(groups.get(task), entry);
|
|
39
70
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
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
|
|
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
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
if (
|
|
69
|
-
|
|
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
|
|
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.
|
|
42
|
-
?
|
|
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,
|
|
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(
|
|
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(
|
|
36
|
+
const claude = join(home, '.claude', 'settings.json');
|
|
46
37
|
if (existsSync(claude)) found.push({ key: 'claude-code', path: claude });
|
|
47
|
-
const
|
|
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
|
|
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
|
|
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)}`;
|