attesso 0.4.0 → 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 CHANGED
@@ -1,6 +1,6 @@
1
- # attesso
1
+ # Bounds
2
2
 
3
- A local, signed ledger of what your AI agents attempted, what was allowed, and what was refused. Any action, not only payments. No account, no server, no dependencies.
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,21 @@ 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
+
30
45
  ## Wire it into an agent
31
46
 
32
47
  `attesso hook` speaks the shell-hook contract that Claude Code, Cursor and Hermes
package/bin/attesso.mjs CHANGED
@@ -12,6 +12,7 @@ import { append, init, isInitialised, paths, readState } from './../src/store.mj
12
12
  import { buildReport, renderReport } from './../src/report.mjs';
13
13
  import { serve } from './../src/serve.mjs';
14
14
  import { decide } from './../src/hook.mjs';
15
+ import { detectHarnesses, explain, suggest, wireHermes, writeAgentContext } from './../src/setup.mjs';
15
16
 
16
17
  const argv = process.argv.slice(2);
17
18
  const dirAt = argv.indexOf('--dir');
@@ -31,6 +32,9 @@ const USAGE = `attesso: a local, signed ledger of what your agents attempted
31
32
  attesso report [--json] per task: actions, refusals with reasons, tokens and cost
32
33
  attesso serve [--observe] long-lived: one JSON request per line in, one verdict out
33
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
34
38
 
35
39
  Any action can be logged, not only payments. --subject is the thing acted on (a
36
40
  tool name, a payee, a repo). --task scopes the path, --trust marks where the
@@ -223,6 +227,58 @@ switch (command) {
223
227
  case 'show':
224
228
  show();
225
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
+ }
226
282
  case 'hook': {
227
283
  requireLedger();
228
284
  const run = decide({ append, dir: DIR, observe: rest.includes('--observe') });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "attesso",
3
- "version": "0.4.0",
4
- "description": "A local, signed ledger of what your AI agents attempted, what was allowed, and what was refused.",
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 CHANGED
@@ -1,3 +1,6 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { canonical } from './canonical.mjs';
3
+
1
4
  // One decision for a harness hook. Reads the shell-hook payload on stdin
2
5
  // ({hook_event_name, tool_name, tool_input, session_id, ...}, the shape Claude
3
6
  // Code, Cursor and Hermes all use) and answers in the same contract: exit 2 with
@@ -31,6 +34,13 @@ export function classifyTool(toolName) {
31
34
  return { action: 'tool.call', capability: 'read', trust: 'internal' };
32
35
  }
33
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
+
34
44
  export function requestFromPayload(payload) {
35
45
  const { action, capability, trust } = classifyTool(payload.tool_name);
36
46
  return {
@@ -39,6 +49,7 @@ export function requestFromPayload(payload) {
39
49
  task: payload.session_id ?? 'unknown',
40
50
  trust,
41
51
  capability,
52
+ meta: { args_hash: argsFingerprint(payload.tool_input) },
42
53
  };
43
54
  }
44
55
 
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
- if (trigger.trust === undefined && trigger.capability === undefined) {
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/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
+ }