attesso 0.3.1 → 0.4.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/README.md +26 -0
- package/bin/attesso.mjs +43 -0
- package/package.json +1 -1
- package/src/hook.mjs +65 -0
- package/src/serve.mjs +50 -0
package/README.md
CHANGED
|
@@ -27,6 +27,32 @@ 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
|
+
## Wire it into an agent
|
|
31
|
+
|
|
32
|
+
`attesso hook` speaks the shell-hook contract that Claude Code, Cursor and Hermes
|
|
33
|
+
already use: one tool call on stdin, exit 2 to refuse. Start in observe mode so the
|
|
34
|
+
ledger records what the rules would have refused and nothing is blocked:
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
node bin/attesso.mjs --dir ~/.hermes/attesso-veto/ledger hook --observe
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Hermes config (`~/.hermes/config.yaml`), or the same shape in any harness with hooks:
|
|
41
|
+
|
|
42
|
+
```yaml
|
|
43
|
+
hooks:
|
|
44
|
+
pre_tool_call:
|
|
45
|
+
- command: npx attesso --dir ~/.attesso hook
|
|
46
|
+
timeout: 10
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Drop `--observe` when the refusal rate on your own work looks right: a deny then
|
|
50
|
+
exits 2 with a message the agent reads, and the step never runs.
|
|
51
|
+
|
|
52
|
+
For a long-lived harness, `attesso serve` answers the same questions over one
|
|
53
|
+
process: write one JSON request per line, read one verdict per line. A decision
|
|
54
|
+
costs an IPC round trip instead of a process spawn.
|
|
55
|
+
|
|
30
56
|
## What it records
|
|
31
57
|
|
|
32
58
|
One signed line per proposed action:
|
package/bin/attesso.mjs
CHANGED
|
@@ -10,6 +10,8 @@ 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';
|
|
13
15
|
|
|
14
16
|
const argv = process.argv.slice(2);
|
|
15
17
|
const dirAt = argv.indexOf('--dir');
|
|
@@ -27,6 +29,8 @@ const USAGE = `attesso: a local, signed ledger of what your agents attempted
|
|
|
27
29
|
attesso verify recompute the chain and report the first defect
|
|
28
30
|
attesso show what was allowed, what was refused, and why
|
|
29
31
|
attesso report [--json] per task: actions, refusals with reasons, tokens and cost
|
|
32
|
+
attesso serve [--observe] long-lived: one JSON request per line in, one verdict out
|
|
33
|
+
attesso hook [--observe] harness hook: reads one tool call on stdin, exits 2 to refuse
|
|
30
34
|
|
|
31
35
|
Any action can be logged, not only payments. --subject is the thing acted on (a
|
|
32
36
|
tool name, a payee, a repo). --task scopes the path, --trust marks where the
|
|
@@ -219,6 +223,45 @@ switch (command) {
|
|
|
219
223
|
case 'show':
|
|
220
224
|
show();
|
|
221
225
|
break;
|
|
226
|
+
case 'hook': {
|
|
227
|
+
requireLedger();
|
|
228
|
+
const run = decide({ append, dir: DIR, observe: rest.includes('--observe') });
|
|
229
|
+
let raw = '';
|
|
230
|
+
process.stdin.setEncoding('utf8');
|
|
231
|
+
process.stdin.on('data', (chunk) => {
|
|
232
|
+
raw += chunk;
|
|
233
|
+
});
|
|
234
|
+
process.stdin.on('end', () => {
|
|
235
|
+
let payload;
|
|
236
|
+
try {
|
|
237
|
+
payload = JSON.parse(raw || '{}');
|
|
238
|
+
} catch {
|
|
239
|
+
console.error('attesso hook: stdin was not JSON, so no decision was made');
|
|
240
|
+
process.exit(1);
|
|
241
|
+
}
|
|
242
|
+
try {
|
|
243
|
+
const result = run(payload);
|
|
244
|
+
if (result.exitCode !== 0) {
|
|
245
|
+
process.stdout.write(`${JSON.stringify({ decision: 'block', message: result.body.message, code: result.body.code })}\n`);
|
|
246
|
+
process.exit(2);
|
|
247
|
+
}
|
|
248
|
+
process.stdout.write(`${JSON.stringify({ decision: result.body.decision, code: result.body.code })}\n`);
|
|
249
|
+
} catch (error) {
|
|
250
|
+
// An unwritable ledger is an outage, not a policy decision: say so and
|
|
251
|
+
// exit 1 so a fail-closed harness can tell the two apart.
|
|
252
|
+
console.error(`attesso hook: ${error.message}`);
|
|
253
|
+
process.exit(1);
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
break;
|
|
257
|
+
}
|
|
258
|
+
case 'serve':
|
|
259
|
+
requireLedger();
|
|
260
|
+
serve(DIR, { observe: rest.includes('--observe') }).catch((error) => {
|
|
261
|
+
console.error(`attesso serve: ${error.message}`);
|
|
262
|
+
process.exit(1);
|
|
263
|
+
});
|
|
264
|
+
break;
|
|
222
265
|
case 'report': {
|
|
223
266
|
requireLedger();
|
|
224
267
|
const report = buildReport(DIR);
|
package/package.json
CHANGED
package/src/hook.mjs
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// One decision for a harness hook. Reads the shell-hook payload on stdin
|
|
2
|
+
// ({hook_event_name, tool_name, tool_input, session_id, ...}, the shape Claude
|
|
3
|
+
// Code, Cursor and Hermes all use) and answers in the same contract: exit 2 with
|
|
4
|
+
// a message refuses, exit 0 lets the call run.
|
|
5
|
+
//
|
|
6
|
+
// The mapping from a tool name to (action, capability, trust) is the only
|
|
7
|
+
// harness-specific knowledge here, so every harness shares one policy engine.
|
|
8
|
+
const EXECUTE = new Set(['terminal', 'execute_code', 'process_manage', 'shell', 'bash']);
|
|
9
|
+
const WRITE = new Set(['write_file', 'patch', 'skill_manage', 'str_replace_editor', 'edit']);
|
|
10
|
+
const UNTRUSTED_READ = new Set([
|
|
11
|
+
'browser_exec',
|
|
12
|
+
'web_search',
|
|
13
|
+
'web_extract',
|
|
14
|
+
'firecrawl',
|
|
15
|
+
'firecrawl_scrape',
|
|
16
|
+
'firecrawl_search',
|
|
17
|
+
'firecrawl_crawl',
|
|
18
|
+
'firecrawl_map',
|
|
19
|
+
'firecrawl_extract',
|
|
20
|
+
'firecrawl_deep_research',
|
|
21
|
+
'deepseek-search',
|
|
22
|
+
'ds',
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
export function classifyTool(toolName) {
|
|
26
|
+
const name = String(toolName ?? '').trim();
|
|
27
|
+
if (UNTRUSTED_READ.has(name)) return { action: 'web.read', capability: 'read', trust: 'untrusted' };
|
|
28
|
+
if (EXECUTE.has(name)) return { action: 'shell.execute', capability: 'execute', trust: 'internal' };
|
|
29
|
+
if (WRITE.has(name)) return { action: 'file.write', capability: 'execute', trust: 'internal' };
|
|
30
|
+
if (/send|mail|post|publish/i.test(name)) return { action: 'message.send', capability: 'send', trust: 'internal' };
|
|
31
|
+
return { action: 'tool.call', capability: 'read', trust: 'internal' };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function requestFromPayload(payload) {
|
|
35
|
+
const { action, capability, trust } = classifyTool(payload.tool_name);
|
|
36
|
+
return {
|
|
37
|
+
action,
|
|
38
|
+
subject: payload.tool_name ?? null,
|
|
39
|
+
task: payload.session_id ?? 'unknown',
|
|
40
|
+
trust,
|
|
41
|
+
capability,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function decide({ append, dir, observe }) {
|
|
46
|
+
return (payload) => {
|
|
47
|
+
const entry = append(dir, requestFromPayload(payload));
|
|
48
|
+
const refused = entry.decision !== 'ALLOW';
|
|
49
|
+
return {
|
|
50
|
+
exitCode: refused && !observe ? 2 : 0,
|
|
51
|
+
body: {
|
|
52
|
+
// The harness obeys `decision`, so observe mode must answer allow even
|
|
53
|
+
// when the verdict is DENY: the ledger keeps the verdict, work continues.
|
|
54
|
+
decision: refused && !observe ? 'block' : 'allow',
|
|
55
|
+
verdict: entry.decision,
|
|
56
|
+
code: entry.code,
|
|
57
|
+
enforced: refused && !observe,
|
|
58
|
+
seq: entry.seq,
|
|
59
|
+
message: entry.meta?.rule
|
|
60
|
+
? `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.`
|
|
61
|
+
: `Attesso refused ${payload.tool_name}: ${entry.code}. Entry #${entry.seq} in the signed ledger.`,
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
}
|
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
|
+
}
|