attesso 0.3.1 → 0.4.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/README.md +43 -2
- package/bin/attesso.mjs +99 -0
- package/package.json +2 -2
- package/src/hook.mjs +76 -0
- package/src/rules.mjs +28 -3
- package/src/serve.mjs +50 -0
- package/src/setup.mjs +142 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Bounds
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Installed as `attesso`: a local, signed ledger of what your AI agents attempted, what was allowed, and what was refused. Free and unlimited on your own machine; $0.0002 per synced decision when the record has to travel. Any action, not only payments. No account, no server, no dependencies.
|
|
4
4
|
|
|
5
5
|
```sh
|
|
6
6
|
npx attesso init
|
|
@@ -27,6 +27,47 @@ hermes-local-9552aa28437e88c0610a… 2058 455 (22%) PATH_DENIED×455 3,92
|
|
|
27
27
|
|
|
28
28
|
Actions are tool calls; refusals carry the reason; tokens are input plus output, with cache reads shown separately because they bill at a different rate and dwarf the rest. Cost comes from a `llm.session` entry the recorder writes from the agent's own usage record, never from a number the report invents. `--json` gives the same data for a dashboard.
|
|
29
29
|
|
|
30
|
+
## Setup, explain, suggest
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
npx attesso setup # detects ~/.hermes/config.yaml, .claude/, .cursor/;
|
|
34
|
+
# writes the hook for Hermes, prints the snippet for the rest
|
|
35
|
+
npx attesso setup --dry-run
|
|
36
|
+
npx attesso explain 42 # entry #42: rule, the step that tainted the task, what to do
|
|
37
|
+
npx attesso suggest # rules the ledger's own counts justify
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`setup` also writes an `AGENTS.md` block so the agent knows a veto is active, that a
|
|
41
|
+
refusal is not retryable unchanged, and how to ask about a rule that looks wrong.
|
|
42
|
+
`suggest` is how rules get discovered instead of configured: it counts identical
|
|
43
|
+
calls (same tool, same arguments) and prints a rule with the count that justifies it.
|
|
44
|
+
|
|
45
|
+
## Wire it into an agent
|
|
46
|
+
|
|
47
|
+
`attesso hook` speaks the shell-hook contract that Claude Code, Cursor and Hermes
|
|
48
|
+
already use: one tool call on stdin, exit 2 to refuse. Start in observe mode so the
|
|
49
|
+
ledger records what the rules would have refused and nothing is blocked:
|
|
50
|
+
|
|
51
|
+
```sh
|
|
52
|
+
node bin/attesso.mjs --dir ~/.hermes/attesso-veto/ledger hook --observe
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Hermes config (`~/.hermes/config.yaml`), or the same shape in any harness with hooks:
|
|
56
|
+
|
|
57
|
+
```yaml
|
|
58
|
+
hooks:
|
|
59
|
+
pre_tool_call:
|
|
60
|
+
- command: npx attesso --dir ~/.attesso hook
|
|
61
|
+
timeout: 10
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Drop `--observe` when the refusal rate on your own work looks right: a deny then
|
|
65
|
+
exits 2 with a message the agent reads, and the step never runs.
|
|
66
|
+
|
|
67
|
+
For a long-lived harness, `attesso serve` answers the same questions over one
|
|
68
|
+
process: write one JSON request per line, read one verdict per line. A decision
|
|
69
|
+
costs an IPC round trip instead of a process spawn.
|
|
70
|
+
|
|
30
71
|
## What it records
|
|
31
72
|
|
|
32
73
|
One signed line per proposed action:
|
package/bin/attesso.mjs
CHANGED
|
@@ -10,6 +10,9 @@ import { verifyChain } from './../src/ledger.mjs';
|
|
|
10
10
|
import { formatMinor, toMinor } from './../src/policy.mjs';
|
|
11
11
|
import { append, init, isInitialised, paths, readState } from './../src/store.mjs';
|
|
12
12
|
import { buildReport, renderReport } from './../src/report.mjs';
|
|
13
|
+
import { serve } from './../src/serve.mjs';
|
|
14
|
+
import { decide } from './../src/hook.mjs';
|
|
15
|
+
import { detectHarnesses, explain, suggest, wireHermes, writeAgentContext } from './../src/setup.mjs';
|
|
13
16
|
|
|
14
17
|
const argv = process.argv.slice(2);
|
|
15
18
|
const dirAt = argv.indexOf('--dir');
|
|
@@ -27,6 +30,11 @@ const USAGE = `attesso: a local, signed ledger of what your agents attempted
|
|
|
27
30
|
attesso verify recompute the chain and report the first defect
|
|
28
31
|
attesso show what was allowed, what was refused, and why
|
|
29
32
|
attesso report [--json] per task: actions, refusals with reasons, tokens and cost
|
|
33
|
+
attesso serve [--observe] long-lived: one JSON request per line in, one verdict out
|
|
34
|
+
attesso hook [--observe] harness hook: reads one tool call on stdin, exits 2 to refuse
|
|
35
|
+
attesso setup [--dry-run] detect a harness, wire the hook, write the agent block
|
|
36
|
+
attesso explain <seq> one entry in full: rule, tainting step, what to do
|
|
37
|
+
attesso suggest [--json] rules the ledger's own counts justify
|
|
30
38
|
|
|
31
39
|
Any action can be logged, not only payments. --subject is the thing acted on (a
|
|
32
40
|
tool name, a payee, a repo). --task scopes the path, --trust marks where the
|
|
@@ -219,6 +227,97 @@ switch (command) {
|
|
|
219
227
|
case 'show':
|
|
220
228
|
show();
|
|
221
229
|
break;
|
|
230
|
+
case 'setup': {
|
|
231
|
+
const dryRun = rest.includes('--dry-run');
|
|
232
|
+
const harnesses = detectHarnesses();
|
|
233
|
+
const cli = process.argv[1];
|
|
234
|
+
const agent = writeAgentContext(DIR, { dryRun });
|
|
235
|
+
console.log(`agent context: ${agent.changed ? (dryRun ? 'would update' : 'updated') : 'already current'} ${agent.path}`);
|
|
236
|
+
if (harnesses.length === 0) {
|
|
237
|
+
console.log('no harness config found in this directory or ~/.hermes');
|
|
238
|
+
console.log('wire any harness with this contract: run `attesso hook` as a pre_tool_call hook; exit 2 refuses.');
|
|
239
|
+
}
|
|
240
|
+
for (const harness of harnesses) {
|
|
241
|
+
if (harness.key !== 'hermes') {
|
|
242
|
+
console.log(`${harness.key}: found ${harness.path}; snippet for its pre_tool_call hook:`);
|
|
243
|
+
console.log(` ${cli} --dir ${DIR} hook --observe`);
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
const result = wireHermes(cli, DIR, { dryRun, path: harness.path });
|
|
247
|
+
console.log(`hermes: ${result.applied ? (dryRun ? 'would wire' : 'wired') : result.reason}`);
|
|
248
|
+
if (result.snippet && !result.applied) console.log(result.snippet);
|
|
249
|
+
}
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
case 'explain': {
|
|
253
|
+
requireLedger();
|
|
254
|
+
const seq = rest[0];
|
|
255
|
+
if (!seq) {
|
|
256
|
+
console.error('attesso explain <seq>');
|
|
257
|
+
process.exit(1);
|
|
258
|
+
}
|
|
259
|
+
const detail = explain(DIR, seq, readState(DIR));
|
|
260
|
+
if (!detail.found) {
|
|
261
|
+
console.error(`no entry #${seq} in this ledger`);
|
|
262
|
+
process.exit(1);
|
|
263
|
+
}
|
|
264
|
+
console.log(JSON.stringify(detail, null, 2));
|
|
265
|
+
break;
|
|
266
|
+
}
|
|
267
|
+
case 'suggest': {
|
|
268
|
+
requireLedger();
|
|
269
|
+
const proposal = suggest(DIR, readState(DIR));
|
|
270
|
+
if (rest.includes('--json')) {
|
|
271
|
+
console.log(JSON.stringify(proposal, null, 2));
|
|
272
|
+
} else if (proposal.repeats.length === 0) {
|
|
273
|
+
console.log('no repeats yet: nothing the ledger can justify narrowing');
|
|
274
|
+
} else {
|
|
275
|
+
for (const repeat of proposal.repeats) {
|
|
276
|
+
console.log(`${repeat.id}: ${repeat.why}`);
|
|
277
|
+
console.log(` ${JSON.stringify(repeat.rule)}`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
break;
|
|
281
|
+
}
|
|
282
|
+
case 'hook': {
|
|
283
|
+
requireLedger();
|
|
284
|
+
const run = decide({ append, dir: DIR, observe: rest.includes('--observe') });
|
|
285
|
+
let raw = '';
|
|
286
|
+
process.stdin.setEncoding('utf8');
|
|
287
|
+
process.stdin.on('data', (chunk) => {
|
|
288
|
+
raw += chunk;
|
|
289
|
+
});
|
|
290
|
+
process.stdin.on('end', () => {
|
|
291
|
+
let payload;
|
|
292
|
+
try {
|
|
293
|
+
payload = JSON.parse(raw || '{}');
|
|
294
|
+
} catch {
|
|
295
|
+
console.error('attesso hook: stdin was not JSON, so no decision was made');
|
|
296
|
+
process.exit(1);
|
|
297
|
+
}
|
|
298
|
+
try {
|
|
299
|
+
const result = run(payload);
|
|
300
|
+
if (result.exitCode !== 0) {
|
|
301
|
+
process.stdout.write(`${JSON.stringify({ decision: 'block', message: result.body.message, code: result.body.code })}\n`);
|
|
302
|
+
process.exit(2);
|
|
303
|
+
}
|
|
304
|
+
process.stdout.write(`${JSON.stringify({ decision: result.body.decision, code: result.body.code })}\n`);
|
|
305
|
+
} catch (error) {
|
|
306
|
+
// An unwritable ledger is an outage, not a policy decision: say so and
|
|
307
|
+
// exit 1 so a fail-closed harness can tell the two apart.
|
|
308
|
+
console.error(`attesso hook: ${error.message}`);
|
|
309
|
+
process.exit(1);
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
break;
|
|
313
|
+
}
|
|
314
|
+
case 'serve':
|
|
315
|
+
requireLedger();
|
|
316
|
+
serve(DIR, { observe: rest.includes('--observe') }).catch((error) => {
|
|
317
|
+
console.error(`attesso serve: ${error.message}`);
|
|
318
|
+
process.exit(1);
|
|
319
|
+
});
|
|
320
|
+
break;
|
|
222
321
|
case 'report': {
|
|
223
322
|
requireLedger();
|
|
224
323
|
const report = buildReport(DIR);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "attesso",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.4.1",
|
|
4
|
+
"description": "Bounds: decide an AI agent's next tool call before it runs. Free and unlimited locally, $0.0002 per synced decision.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
package/src/hook.mjs
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { canonical } from './canonical.mjs';
|
|
3
|
+
|
|
4
|
+
// One decision for a harness hook. Reads the shell-hook payload on stdin
|
|
5
|
+
// ({hook_event_name, tool_name, tool_input, session_id, ...}, the shape Claude
|
|
6
|
+
// Code, Cursor and Hermes all use) and answers in the same contract: exit 2 with
|
|
7
|
+
// a message refuses, exit 0 lets the call run.
|
|
8
|
+
//
|
|
9
|
+
// The mapping from a tool name to (action, capability, trust) is the only
|
|
10
|
+
// harness-specific knowledge here, so every harness shares one policy engine.
|
|
11
|
+
const EXECUTE = new Set(['terminal', 'execute_code', 'process_manage', 'shell', 'bash']);
|
|
12
|
+
const WRITE = new Set(['write_file', 'patch', 'skill_manage', 'str_replace_editor', 'edit']);
|
|
13
|
+
const UNTRUSTED_READ = new Set([
|
|
14
|
+
'browser_exec',
|
|
15
|
+
'web_search',
|
|
16
|
+
'web_extract',
|
|
17
|
+
'firecrawl',
|
|
18
|
+
'firecrawl_scrape',
|
|
19
|
+
'firecrawl_search',
|
|
20
|
+
'firecrawl_crawl',
|
|
21
|
+
'firecrawl_map',
|
|
22
|
+
'firecrawl_extract',
|
|
23
|
+
'firecrawl_deep_research',
|
|
24
|
+
'deepseek-search',
|
|
25
|
+
'ds',
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
export function classifyTool(toolName) {
|
|
29
|
+
const name = String(toolName ?? '').trim();
|
|
30
|
+
if (UNTRUSTED_READ.has(name)) return { action: 'web.read', capability: 'read', trust: 'untrusted' };
|
|
31
|
+
if (EXECUTE.has(name)) return { action: 'shell.execute', capability: 'execute', trust: 'internal' };
|
|
32
|
+
if (WRITE.has(name)) return { action: 'file.write', capability: 'execute', trust: 'internal' };
|
|
33
|
+
if (/send|mail|post|publish/i.test(name)) return { action: 'message.send', capability: 'send', trust: 'internal' };
|
|
34
|
+
return { action: 'tool.call', capability: 'read', trust: 'internal' };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// A repeat is the same tool with the same arguments, so the fingerprint of the
|
|
38
|
+
// arguments is what the rule compares. Storing the digest, never the payload: an
|
|
39
|
+
// argument can hold a secret, and the ledger is meant to be shareable.
|
|
40
|
+
export function argsFingerprint(toolInput) {
|
|
41
|
+
return createHash('sha256').update(canonical(toolInput ?? {})).digest('base64url').slice(0, 16);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function requestFromPayload(payload) {
|
|
45
|
+
const { action, capability, trust } = classifyTool(payload.tool_name);
|
|
46
|
+
return {
|
|
47
|
+
action,
|
|
48
|
+
subject: payload.tool_name ?? null,
|
|
49
|
+
task: payload.session_id ?? 'unknown',
|
|
50
|
+
trust,
|
|
51
|
+
capability,
|
|
52
|
+
meta: { args_hash: argsFingerprint(payload.tool_input) },
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function decide({ append, dir, observe }) {
|
|
57
|
+
return (payload) => {
|
|
58
|
+
const entry = append(dir, requestFromPayload(payload));
|
|
59
|
+
const refused = entry.decision !== 'ALLOW';
|
|
60
|
+
return {
|
|
61
|
+
exitCode: refused && !observe ? 2 : 0,
|
|
62
|
+
body: {
|
|
63
|
+
// The harness obeys `decision`, so observe mode must answer allow even
|
|
64
|
+
// when the verdict is DENY: the ledger keeps the verdict, work continues.
|
|
65
|
+
decision: refused && !observe ? 'block' : 'allow',
|
|
66
|
+
verdict: entry.decision,
|
|
67
|
+
code: entry.code,
|
|
68
|
+
enforced: refused && !observe,
|
|
69
|
+
seq: entry.seq,
|
|
70
|
+
message: entry.meta?.rule
|
|
71
|
+
? `Attesso refused ${payload.tool_name}: ${entry.code} by ${entry.meta.rule} (step #${entry.meta.tainted_by} tainted this task). Entry #${entry.seq} in the signed ledger.`
|
|
72
|
+
: `Attesso refused ${payload.tool_name}: ${entry.code}. Entry #${entry.seq} in the signed ledger.`,
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
};
|
|
76
|
+
}
|
package/src/rules.mjs
CHANGED
|
@@ -21,7 +21,7 @@ export function validateRules(rules) {
|
|
|
21
21
|
for (const rule of rules.rules) {
|
|
22
22
|
if (typeof rule.id !== 'string' || rule.id === '') fail('every rule needs an id');
|
|
23
23
|
const deny = rule.deny ?? {};
|
|
24
|
-
const denies = deny.capabilities ?? (deny.capability ? [deny.capability] : []);
|
|
24
|
+
const denies = deny.all ? CAPABILITIES : (deny.capabilities ?? (deny.capability ? [deny.capability] : []));
|
|
25
25
|
if (denies.length === 0) fail(`rule ${rule.id} denies nothing`);
|
|
26
26
|
for (const capability of denies) {
|
|
27
27
|
if (!CAPABILITIES.includes(capability)) fail(`rule ${rule.id} denies unknown capability ${capability}`);
|
|
@@ -31,9 +31,15 @@ export function validateRules(rules) {
|
|
|
31
31
|
if (trigger.capability && !CAPABILITIES.includes(trigger.capability)) {
|
|
32
32
|
fail(`rule ${rule.id} triggers on unknown capability ${trigger.capability}`);
|
|
33
33
|
}
|
|
34
|
-
|
|
34
|
+
const counts = rule.max_steps !== undefined || rule.max_repeats !== undefined;
|
|
35
|
+
if (!counts && trigger.trust === undefined && trigger.capability === undefined) {
|
|
35
36
|
fail(`rule ${rule.id} has no path condition`);
|
|
36
37
|
}
|
|
38
|
+
for (const field of ['max_steps', 'max_repeats']) {
|
|
39
|
+
if (rule[field] !== undefined && (!Number.isInteger(rule[field]) || rule[field] < 1)) {
|
|
40
|
+
fail(`rule ${rule.id} needs a whole ${field} of at least 1`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
37
43
|
if (trigger.within_minutes !== undefined && (!Number.isInteger(trigger.within_minutes) || trigger.within_minutes < 0)) {
|
|
38
44
|
fail(`rule ${rule.id} needs whole minutes for within_minutes`);
|
|
39
45
|
}
|
|
@@ -47,13 +53,32 @@ export function evaluatePath(rules, history, proposal, now = new Date()) {
|
|
|
47
53
|
if (!rules) return null;
|
|
48
54
|
for (const rule of rules.rules ?? []) {
|
|
49
55
|
const deny = rule.deny ?? {};
|
|
50
|
-
const denied = deny.capabilities ?? (deny.capability ? [deny.capability] : []);
|
|
56
|
+
const denied = deny.all ? CAPABILITIES : (deny.capabilities ?? (deny.capability ? [deny.capability] : []));
|
|
51
57
|
if (proposal.capability && !denied.includes(proposal.capability)) continue;
|
|
58
|
+
|
|
59
|
+
// A ceiling on the task itself. This is the rule that stops a runaway loop,
|
|
60
|
+
// which is where an agent's money actually goes missing.
|
|
61
|
+
if (rule.max_steps !== undefined && history.length >= rule.max_steps) {
|
|
62
|
+
return { decision: 'DENY', code: 'STEP_LIMIT', rule: rule.id, tainted_by: history.length };
|
|
63
|
+
}
|
|
64
|
+
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 };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
52
72
|
const trigger = rule.if_path ?? {};
|
|
73
|
+
// Counting rules have no trigger of their own; without this they would fall
|
|
74
|
+
// through to the path matcher and deny the second call for no reason.
|
|
75
|
+
if (trigger.trust === undefined && trigger.capability === undefined) continue;
|
|
53
76
|
const window = (trigger.within_minutes ?? 0) * 60_000;
|
|
54
77
|
const hit = [...history].reverse().find((entry) => {
|
|
55
78
|
if (trigger.trust && entry.trust !== trigger.trust) return false;
|
|
56
79
|
if (trigger.capability && entry.capability !== trigger.capability) return false;
|
|
80
|
+
if (trigger.subject && entry.subject !== trigger.subject) return false;
|
|
81
|
+
if (trigger.same_arguments && entry.meta?.args_hash !== proposal.meta?.args_hash) return false;
|
|
57
82
|
if (window > 0 && now.getTime() - Date.parse(entry.at) > window) return false;
|
|
58
83
|
return true;
|
|
59
84
|
});
|
package/src/serve.mjs
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// A long-lived decision daemon: any harness writes one JSON request per line and
|
|
2
|
+
// reads one verdict per line. The harness keeps the process for its whole
|
|
3
|
+
// session, so a decision costs an IPC round trip instead of a process spawn.
|
|
4
|
+
//
|
|
5
|
+
// A refusal is recorded either way; `enforced` says whether the caller may
|
|
6
|
+
// proceed, which is what makes observe mode trustworthy: the ledger keeps the
|
|
7
|
+
// verdict, the agent keeps working.
|
|
8
|
+
import { createInterface } from 'node:readline';
|
|
9
|
+
import { append } from './store.mjs';
|
|
10
|
+
|
|
11
|
+
const parse = (line) => {
|
|
12
|
+
try {
|
|
13
|
+
return JSON.parse(line);
|
|
14
|
+
} catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function serve(dir, { observe = false, input = process.stdin, output = process.stdout } = {}) {
|
|
20
|
+
const rl = createInterface({ input, crlfDelay: Infinity });
|
|
21
|
+
rl.on('line', (line) => {
|
|
22
|
+
const request = parse(line);
|
|
23
|
+
if (!request) {
|
|
24
|
+
output.write(`${JSON.stringify({ error: 'request is not JSON' })}\n`);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
let entry;
|
|
28
|
+
try {
|
|
29
|
+
entry = append(dir, request);
|
|
30
|
+
} catch (error) {
|
|
31
|
+
// Never answer with a policy verdict when the record could not be written:
|
|
32
|
+
// an unwritable ledger is an outage, not a decision.
|
|
33
|
+
output.write(`${JSON.stringify({ id: request.id ?? null, error: String(error.message ?? error) })}\n`);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
output.write(
|
|
37
|
+
`${JSON.stringify({
|
|
38
|
+
id: request.id ?? null,
|
|
39
|
+
decision: entry.decision,
|
|
40
|
+
code: entry.code,
|
|
41
|
+
reason: entry.meta?.rule
|
|
42
|
+
? `${entry.meta.rule} (tainted by step #${entry.meta.tainted_by})`
|
|
43
|
+
: null,
|
|
44
|
+
enforced: entry.decision === 'ALLOW' || observe ? false : true,
|
|
45
|
+
seq: entry.seq,
|
|
46
|
+
})}\n`,
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
return new Promise((resolve) => rl.on('close', resolve));
|
|
50
|
+
}
|
package/src/setup.mjs
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// Setup, explain and suggest: the three commands that make the loop feel like it
|
|
2
|
+
// configures itself.
|
|
3
|
+
//
|
|
4
|
+
// setup detect a harness, wire the hook, leave a short block the agent reads
|
|
5
|
+
// explain one ledger entry in full, including what tainted the path
|
|
6
|
+
// suggest rules the ledger's own counts justify, ready to accept
|
|
7
|
+
import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { homedir } from 'node:os';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
|
|
11
|
+
const AGENT_BLOCK = `<!-- attesso:start -->
|
|
12
|
+
A veto is active on your tool calls. When a call is refused you get a tool error naming
|
|
13
|
+
the rule and the step that tainted the task. Do not retry a refused call unchanged:
|
|
14
|
+
read \`attesso explain <seq>\`, take another path, or ask the user whether the rule is
|
|
15
|
+
wrong. To see rules the ledger supports, run \`attesso suggest\`.
|
|
16
|
+
<!-- attesso:end -->`;
|
|
17
|
+
|
|
18
|
+
function stripAgentBlock(text) {
|
|
19
|
+
return text.replace(/<!-- attesso:start -->[\s\S]*?<!-- attesso:end -->\n?/g, '');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function writeAgentContext(dir, { dryRun = false } = {}) {
|
|
23
|
+
const path = join(process.cwd(), 'AGENTS.md');
|
|
24
|
+
const existing = existsSync(path) ? readFileSync(path, 'utf8') : '';
|
|
25
|
+
const next = `${stripAgentBlock(existing).trimEnd()}\n\n${AGENT_BLOCK}\n`;
|
|
26
|
+
if (dryRun) return { path, changed: next !== existing, preview: AGENT_BLOCK };
|
|
27
|
+
if (next !== existing) writeFileSync(path, next.startsWith('\n') ? next.slice(1) : next);
|
|
28
|
+
return { path, changed: next !== existing };
|
|
29
|
+
}
|
|
30
|
+
|
|
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
|
+
export function detectHarnesses({ home = homedir(), cwd = process.cwd() } = {}) {
|
|
42
|
+
const found = [];
|
|
43
|
+
const hermes = join(home, '.hermes', 'config.yaml');
|
|
44
|
+
if (existsSync(hermes)) found.push({ key: 'hermes', path: hermes });
|
|
45
|
+
const claude = join(cwd, '.claude', 'settings.json');
|
|
46
|
+
if (existsSync(claude)) found.push({ key: 'claude-code', path: claude });
|
|
47
|
+
const cursor = join(cwd, '.cursor', 'hooks.json');
|
|
48
|
+
if (existsSync(cursor)) found.push({ key: 'cursor', path: cursor });
|
|
49
|
+
return found;
|
|
50
|
+
}
|
|
51
|
+
|
|
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
|
+
export function explain(dir, seq, { entries }) {
|
|
73
|
+
const entry = entries.find((candidate) => candidate.seq === Number(seq));
|
|
74
|
+
if (!entry) return { found: false, seq };
|
|
75
|
+
const taintedBy = entry.meta?.tainted_by;
|
|
76
|
+
const source = taintedBy ? entries.find((candidate) => candidate.seq === taintedBy) : null;
|
|
77
|
+
return {
|
|
78
|
+
found: true,
|
|
79
|
+
seq: entry.seq,
|
|
80
|
+
action: entry.action,
|
|
81
|
+
subject: entry.subject,
|
|
82
|
+
capability: entry.capability,
|
|
83
|
+
trust: entry.trust,
|
|
84
|
+
verdict: entry.decision,
|
|
85
|
+
code: entry.code,
|
|
86
|
+
rule: entry.meta?.rule ?? null,
|
|
87
|
+
task: entry.task ?? null,
|
|
88
|
+
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.',
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const signature = (entry) => `${entry.action}\u0000${entry.subject ?? ''}\u0000${entry.meta?.args_hash ?? ''}`;
|
|
99
|
+
|
|
100
|
+
export function suggest(dir, { entries, minRepeats = 3 }) {
|
|
101
|
+
const within = entries.filter((entry) => entry.decision === 'ALLOW');
|
|
102
|
+
const counts = new Map();
|
|
103
|
+
for (const entry of within) {
|
|
104
|
+
const key = `${entry.task ?? ''}\u0000${signature(entry)}`;
|
|
105
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
106
|
+
}
|
|
107
|
+
const repeats = [...counts.entries()]
|
|
108
|
+
.filter(([, count]) => count >= minRepeats)
|
|
109
|
+
.sort((left, right) => right[1] - left[1])
|
|
110
|
+
.slice(0, 3)
|
|
111
|
+
.map(([key, count]) => {
|
|
112
|
+
const [task, action, subject] = key.split('\u0000');
|
|
113
|
+
const id = `no-repeat-${subject || action}`.replace(/[^a-z0-9-]/gi, '-').toLowerCase();
|
|
114
|
+
const capability = capabilityOf(entries, action, subject);
|
|
115
|
+
return {
|
|
116
|
+
id,
|
|
117
|
+
count,
|
|
118
|
+
task: task || null,
|
|
119
|
+
rule: {
|
|
120
|
+
id,
|
|
121
|
+
deny: { capabilities: [capability] },
|
|
122
|
+
if_path: { capability, subject, same_arguments: true, within_minutes: 30 },
|
|
123
|
+
},
|
|
124
|
+
why: `${count} identical ${action} calls with the same arguments in one task`,
|
|
125
|
+
};
|
|
126
|
+
});
|
|
127
|
+
return { repeats, refusals: refusalSummary(entries) };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function capabilityOf(entries, action, subject) {
|
|
131
|
+
const match = [...entries].reverse().find((entry) => entry.action === action && (entry.subject ?? '') === subject);
|
|
132
|
+
return match?.capability ?? 'read';
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function refusalSummary(entries) {
|
|
136
|
+
const counts = new Map();
|
|
137
|
+
for (const entry of entries) {
|
|
138
|
+
if (entry.decision === 'ALLOW') continue;
|
|
139
|
+
counts.set(entry.code, (counts.get(entry.code) ?? 0) + 1);
|
|
140
|
+
}
|
|
141
|
+
return [...counts.entries()].map(([code, count]) => ({ code, count })).sort((left, right) => right.count - left.count);
|
|
142
|
+
}
|