attesso 0.3.0 → 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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "attesso",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "A local, signed ledger of what your AI agents attempted, what was allowed, and what was refused.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
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
+ }
package/src/store.mjs CHANGED
@@ -43,10 +43,12 @@ export function init(dir) {
43
43
  const file = paths(dir);
44
44
  const key = newSigningKey();
45
45
  writeJson(file.key, { version: 1, kid: key.kid, public: key.publicPem, private: key.privatePem });
46
+ // Record everything by default and let rules.json decide: a policy that names
47
+ // one action refuses every other action, so a fresh install could not log the
48
+ // very commands its own README shows.
46
49
  writeJson(file.policy, {
47
50
  version: 1,
48
- action: 'EXAMPLE.action',
49
- subjects: [],
51
+ action: '*',
50
52
  });
51
53
  writeJson(file.config, { version: 1, head: { count: 0, hash: 'genesis' } });
52
54
  writeJson(file.rules, {