attesso 0.4.1 → 0.6.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.
@@ -0,0 +1,121 @@
1
+ // Filesystem scope for file tools. This is deliberately separate from the
2
+ // action policy: a tool can be allowed by the policy and still fail because the
3
+ // path it names is outside the roots the user approved.
4
+ import { existsSync, realpathSync } from 'node:fs';
5
+ import { homedir } from 'node:os';
6
+ import { dirname, isAbsolute, join, normalize, relative, resolve, sep } from 'node:path';
7
+
8
+ export const PATH_ACCESS = ['read', 'write'];
9
+ const CASE_INSENSITIVE = process.platform === 'darwin' || process.platform === 'win32';
10
+ export const FILE_TOOLS = new Set([
11
+ 'read_file', 'write_file', 'patch', 'apply_patch', 'search_files', 'list_dir',
12
+ 'create_file', 'delete_file', 'edit_file', 'str_replace_editor', 'notebook_edit',
13
+ 'Read', 'Write', 'Edit', 'Glob', 'Grep', 'read_files', 'write_to_file', 'replace_in_file',
14
+ ]);
15
+
16
+ export function validatePathsConfig(value) {
17
+ const fail = (message) => { throw new Error(`paths.json is invalid: ${message}`); };
18
+ if (!value || typeof value !== 'object' || Array.isArray(value)) fail('not an object');
19
+ if (value.version !== 1) fail('version must be 1');
20
+ if (!Array.isArray(value.allowed_roots)) fail('allowed_roots must be a list');
21
+ if (!Array.isArray(value.exclusions)) fail('exclusions must be a list');
22
+ if (value.allowed_roots.length > 128) fail('too many allowed roots');
23
+ if (value.exclusions.length > 128) fail('too many exclusions');
24
+ const roots = value.allowed_roots.map((entry) => {
25
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) fail('every allowed root needs path and access');
26
+ if (typeof entry.path !== 'string' || entry.path.trim() === '') fail('allowed root path must not be empty');
27
+ if (entry.path.includes('..')) fail(`allowed root may not traverse: ${entry.path}`);
28
+ if (!Array.isArray(entry.access) || entry.access.length === 0) fail(`allowed root needs access: ${entry.path}`);
29
+ const access = [...new Set(entry.access)];
30
+ if (access.some((item) => !PATH_ACCESS.includes(item))) fail(`unknown access: ${entry.path}`);
31
+ return { path: entry.path.trim(), access };
32
+ });
33
+ const exclusions = value.exclusions.map((item) => {
34
+ if (typeof item !== 'string' || item.trim() === '') fail('exclusion must be a non-empty path');
35
+ if (item.includes('..')) fail(`exclusion may not traverse: ${item}`);
36
+ return item.trim();
37
+ });
38
+ const shellScope = value.shell_scope ?? 'unscoped';
39
+ if (!['unscoped', 'deny'].includes(shellScope)) fail('shell_scope must be unscoped or deny');
40
+ return { version: 1, allowed_roots: roots, exclusions, ...(value.shell_scope === undefined ? {} : { shell_scope: shellScope }) };
41
+ }
42
+
43
+ const expand = (value, home) => value.startsWith('~/') ? join(home, value.slice(2)) : value;
44
+ const posix = (value) => value.split(sep).join('/');
45
+
46
+ function absolutePath(value, { cwd, home }) {
47
+ const expanded = expand(value, home);
48
+ return isAbsolute(expanded) ? normalize(expanded) : resolve(cwd, expanded);
49
+ }
50
+
51
+ function realOrLexical(value) {
52
+ try {
53
+ return realpathSync(value);
54
+ } catch {
55
+ // A write target may not exist yet. Resolve its existing parent so a symlink
56
+ // in the parent cannot be used to escape the configured root.
57
+ try {
58
+ return join(realpathSync(dirname(value)), value.slice(dirname(value).length + 1));
59
+ } catch {
60
+ return value;
61
+ }
62
+ }
63
+ }
64
+
65
+ function under(root, target) {
66
+ const rel = relative(root, target);
67
+ return rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
68
+ }
69
+
70
+ function glob(pattern) {
71
+ const chars = [...posix(pattern)];
72
+ let expression = '';
73
+ for (let index = 0; index < chars.length; index += 1) {
74
+ const char = chars[index];
75
+ if (char === '*' && chars[index + 1] === '*') {
76
+ if (chars[index + 2] === '/') {
77
+ expression += '(?:.*/)?';
78
+ index += 2;
79
+ } else {
80
+ expression += '.*';
81
+ index += 1;
82
+ }
83
+ } else if (char === '*') {
84
+ expression += '[^/]*';
85
+ } else if (char === '?') {
86
+ expression += '[^/]';
87
+ } else {
88
+ expression += char.replace(/[.+^${}()|[\]\\]/g, '\\$&');
89
+ }
90
+ }
91
+ return new RegExp(`^${expression}$`, CASE_INSENSITIVE ? 'i' : '');
92
+ }
93
+
94
+ export function evaluatePathScope(config, proposal, { cwd = process.cwd(), home = homedir() } = {}) {
95
+ if (!config) return null;
96
+ if (config.shell_scope === 'deny' && proposal.capability === 'execute') {
97
+ return { decision: 'DENY', code: 'SHELL_SCOPE_DENIED', rule: 'shell-scope' };
98
+ }
99
+ if (typeof proposal.path !== 'string' || proposal.path === '') {
100
+ return FILE_TOOLS.has(proposal.subject) ? { decision: 'DENY', code: 'PATH_REQUIRED', rule: 'path-required' } : null;
101
+ }
102
+ const target = realOrLexical(absolutePath(proposal.path, { cwd, home }));
103
+ const targetPosix = posix(target);
104
+ const excluded = config.exclusions.some((pattern) => {
105
+ const expression = glob(pattern);
106
+ return expression.test(targetPosix) || expression.test(posix(proposal.path));
107
+ });
108
+ if (excluded) return { decision: 'DENY', code: 'PATH_SCOPE_DENIED', rule: 'path-exclusion' };
109
+ const capability = proposal.capability;
110
+ if (!PATH_ACCESS.includes(capability)) return null;
111
+ const roots = config.allowed_roots.map((entry) => ({
112
+ root: realOrLexical(absolutePath(entry.path.replace(/\/\*\*$/, ''), { cwd, home })),
113
+ access: entry.access,
114
+ }));
115
+ const match = roots.find((entry) => under(entry.root, target));
116
+ if (!match) return { decision: 'DENY', code: 'PATH_SCOPE_DENIED', rule: 'path-root' };
117
+ if (!match.access.includes(capability)) {
118
+ return { decision: 'DENY', code: 'PATH_SCOPE_DENIED', rule: 'path-access' };
119
+ }
120
+ return null;
121
+ }
@@ -0,0 +1,130 @@
1
+ // Harness I/O belongs here, not in the decision path. Each product sends a
2
+ // different event name and reads a different decision object; the payload
3
+ // carries enough to detect them, so one hook command can serve them all.
4
+ //
5
+ // A refusal is rendered in the caller's own shape. Observe mode and an ALLOW
6
+ // never answer "allow" on harnesses whose allow bypasses their native approval
7
+ // prompt; they stay silent and let the product ask its own question.
8
+ const CLAUDE = 'claude-code';
9
+ const CURSOR = 'cursor';
10
+ const CLINE = 'cline';
11
+ const GEMINI = 'gemini';
12
+ const HERMES = 'hermes';
13
+ const GENERIC = 'generic';
14
+
15
+ // One descriptor per caller. Detection reads `event`; installation reads the
16
+ // same entry, so adding a caller cannot leave the two halves disagreeing.
17
+ export const HARNESSES = {
18
+ [HERMES]: { event: 'pre_tool_call', kind: 'yaml' },
19
+ [CLAUDE]: { event: 'PreToolUse', kind: 'json' },
20
+ codex: { event: 'PreToolUse', kind: 'json' },
21
+ [CURSOR]: { event: 'preToolUse', kind: 'json' },
22
+ [GEMINI]: { event: 'BeforeTool', kind: 'json' },
23
+ [CLINE]: { event: 'preToolUse', kind: 'script', script: 'PreToolUse' },
24
+ };
25
+
26
+ export function normalizeHookPayload(payload = {}) {
27
+ if (payload.preToolUse && typeof payload.preToolUse === 'object') {
28
+ return {
29
+ harness: CLINE,
30
+ payload: {
31
+ tool_name: payload.preToolUse.tool,
32
+ tool_input: payload.preToolUse.parameters,
33
+ session_id: payload.taskId ?? 'unknown',
34
+ },
35
+ };
36
+ }
37
+
38
+ const harness = harnessOf(payload.hook_event_name);
39
+ return {
40
+ harness,
41
+ payload: {
42
+ tool_name: payload.tool_name,
43
+ tool_input: payload.tool_input,
44
+ session_id: payload.session_id ?? payload.conversation_id ?? 'unknown',
45
+ tool_definition_hash: payload.tool_definition_hash,
46
+ },
47
+ };
48
+ }
49
+
50
+ function harnessOf(event) {
51
+ if (event === HARNESSES[HERMES].event) return HERMES;
52
+ const match = Object.entries(HARNESSES).find(([, spec]) => spec.kind === 'json' && spec.event === event);
53
+ return match?.[0] ?? GENERIC;
54
+ }
55
+
56
+ export function renderHook({ harness, result }) {
57
+ const body = result.body;
58
+ const message = body.message ?? `Attesso refused ${body.code}.`;
59
+
60
+ if (!body.enforced) {
61
+ if (harness === HERMES) {
62
+ return { stdout: `${JSON.stringify({ decision: 'allow', code: body.code })}\n`, exitCode: 0 };
63
+ }
64
+ if (harness === CLINE) {
65
+ return { stdout: `${JSON.stringify({ cancel: false })}\n`, exitCode: 0 };
66
+ }
67
+ if (harness === GEMINI) {
68
+ return { stdout: '{}\n', exitCode: 0 };
69
+ }
70
+ return { stdout: '', exitCode: 0 };
71
+ }
72
+
73
+ if (harness === HERMES) {
74
+ return {
75
+ stdout: `${JSON.stringify({ decision: 'block', message, code: body.code })}\n`,
76
+ exitCode: 2,
77
+ };
78
+ }
79
+
80
+ if (harness === CLAUDE) {
81
+ return {
82
+ stdout: `${JSON.stringify({
83
+ hookSpecificOutput: {
84
+ hookEventName: 'PreToolUse',
85
+ permissionDecision: 'deny',
86
+ permissionDecisionReason: message,
87
+ },
88
+ })}\n`,
89
+ exitCode: 0,
90
+ };
91
+ }
92
+
93
+ if (harness === CURSOR) {
94
+ return {
95
+ stdout: `${JSON.stringify({
96
+ permission: 'deny',
97
+ user_message: message,
98
+ agent_message: message,
99
+ })}\n`,
100
+ exitCode: 0,
101
+ };
102
+ }
103
+
104
+ if (harness === CLINE) {
105
+ return { stdout: `${JSON.stringify({ cancel: true, errorMessage: message })}\n`, exitCode: 0 };
106
+ }
107
+
108
+ if (harness === GEMINI) {
109
+ return {
110
+ stdout: `${JSON.stringify({ decision: 'deny', reason: message, systemMessage: message })}\n`,
111
+ exitCode: 0,
112
+ };
113
+ }
114
+
115
+ return {
116
+ stdout: `${JSON.stringify({
117
+ decision: 'block',
118
+ hookSpecificOutput: {
119
+ hookEventName: 'PreToolUse',
120
+ permissionDecision: 'deny',
121
+ permissionDecisionReason: message,
122
+ },
123
+ permission: 'deny',
124
+ cancel: true,
125
+ errorMessage: message,
126
+ reason: message,
127
+ })}\n`,
128
+ exitCode: 2,
129
+ };
130
+ }
package/src/hook.mjs CHANGED
@@ -1,38 +1,12 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { canonical } from './canonical.mjs';
3
+ import { classifyTool, loadTools } from './classify.mjs';
4
+ import { taskControl } from './control.mjs';
3
5
 
4
6
  // One decision for a harness hook. Reads the shell-hook payload on stdin
5
7
  // ({hook_event_name, tool_name, tool_input, session_id, ...}, the shape Claude
6
8
  // Code, Cursor and Hermes all use) and answers in the same contract: exit 2 with
7
9
  // 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
10
 
37
11
  // A repeat is the same tool with the same arguments, so the fingerprint of the
38
12
  // arguments is what the rule compares. Storing the digest, never the payload: an
@@ -41,36 +15,86 @@ export function argsFingerprint(toolInput) {
41
15
  return createHash('sha256').update(canonical(toolInput ?? {})).digest('base64url').slice(0, 16);
42
16
  }
43
17
 
44
- export function requestFromPayload(payload) {
45
- const { action, capability, trust } = classifyTool(payload.tool_name);
18
+ const PATH_FIELDS = ['path', 'file_path', 'target_file', 'notebook_path', 'directory', 'cwd'];
19
+
20
+ function pathFromPayload(payload) {
21
+ const input = payload.tool_input;
22
+ if (!input || typeof input !== 'object') return undefined;
23
+ for (const field of PATH_FIELDS) {
24
+ if (typeof input[field] === 'string' && input[field] !== '') return input[field];
25
+ }
26
+ return undefined;
27
+ }
28
+
29
+ export function requestFromPayload(payload, tools) {
30
+ const { action, capability, trust } = classifyTool(payload.tool_name, tools);
31
+ const toolHash =
32
+ typeof payload.tool_definition_hash === 'string' && payload.tool_definition_hash !== ''
33
+ ? payload.tool_definition_hash
34
+ : undefined;
46
35
  return {
47
36
  action,
48
37
  subject: payload.tool_name ?? null,
49
38
  task: payload.session_id ?? 'unknown',
50
39
  trust,
51
40
  capability,
52
- meta: { args_hash: argsFingerprint(payload.tool_input) },
41
+ args_hash: argsFingerprint(payload.tool_input),
42
+ tool_hash: toolHash,
43
+ path: pathFromPayload(payload),
53
44
  };
54
45
  }
55
46
 
56
- export function decide({ append, dir, observe }) {
47
+ export function createHookDecider({ append, dir, observe }) {
57
48
  return (payload) => {
58
- const entry = append(dir, requestFromPayload(payload));
49
+ const proposal = requestFromPayload(payload, loadTools(dir));
50
+ let control;
51
+ try {
52
+ control = taskControl(dir, proposal.task);
53
+ } catch (error) {
54
+ return {
55
+ exitCode: observe ? 0 : 2,
56
+ body: {
57
+ decision: observe ? 'allow' : 'block',
58
+ code: 'CONTROL_UNAVAILABLE',
59
+ enforced: !observe,
60
+ message: `Attesso could not read the task control: ${error.message}`,
61
+ },
62
+ };
63
+ }
64
+ const entry = append(
65
+ dir,
66
+ proposal,
67
+ control ? { control: { mode: control.mode, at: control.at, reason: control.reason } } : undefined,
68
+ );
59
69
  const refused = entry.decision !== 'ALLOW';
70
+ const enforced = refused && (!observe || entry.control !== undefined);
60
71
  return {
61
- exitCode: refused && !observe ? 2 : 0,
72
+ exitCode: enforced ? 2 : 0,
62
73
  body: {
63
74
  // 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',
75
+ // when the verdict is DENY. An explicit pause or stop is the exception:
76
+ // it is the operator's steering act, not an observation.
77
+ decision: enforced ? 'block' : 'allow',
66
78
  verdict: entry.decision,
67
79
  code: entry.code,
68
- enforced: refused && !observe,
80
+ enforced,
69
81
  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.`,
82
+ message: refusalMessage(entry, payload.tool_name),
73
83
  },
74
84
  };
75
85
  };
76
86
  }
87
+
88
+ function refusalMessage(entry, toolName) {
89
+ if (entry.control) {
90
+ const verb = entry.control.mode === 'stop' ? 'stopped' : 'paused';
91
+ return `Attesso ${verb} task ${entry.task}: ${entry.code}. Run attesso resume ${entry.task} when ready.`;
92
+ }
93
+ if (entry.rule && entry.tainted_by !== undefined) {
94
+ return `Attesso refused ${toolName}: ${entry.code} by ${entry.rule} (step #${entry.tainted_by} tainted this task). Entry #${entry.seq} in the signed ledger.`;
95
+ }
96
+ if (entry.rule) {
97
+ return `Attesso refused ${toolName}: ${entry.code} by ${entry.rule}. Entry #${entry.seq} in the signed ledger.`;
98
+ }
99
+ return `Attesso refused ${toolName}: ${entry.code}. Entry #${entry.seq} in the signed ledger.`;
100
+ }
@@ -0,0 +1,122 @@
1
+ // Harness installation belongs here. setup decides what to install; this
2
+ // module knows only how a hook is written, backed up and replaced.
3
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
4
+ import { homedir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { HARNESSES } from './harness.mjs';
7
+
8
+ const shellQuote = (value) => `'${String(value).replaceAll("'", `'\\''`)}'`;
9
+
10
+ const hookCommand = (cli, dir, enforce = false, modeFile = false) =>
11
+ `${shellQuote(cli)} --dir ${shellQuote(dir)} hook${modeFile ? '' : enforce ? '' : ' --observe'}`;
12
+
13
+ const hermesConfig = () => join(homedir(), '.hermes', 'config.yaml');
14
+
15
+ function readJsonConfig(path, label) {
16
+ let parsed;
17
+ try {
18
+ parsed = JSON.parse(readFileSync(path, 'utf8'));
19
+ } catch (error) {
20
+ throw new Error(`${label} is not valid JSON: ${error.message}`);
21
+ }
22
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
23
+ throw new Error(`${label} must hold an object`);
24
+ }
25
+ return parsed;
26
+ }
27
+
28
+ const containsAttesso = (group) => /attesso[^"]*hook/.test(JSON.stringify(group));
29
+
30
+ function wireJsonHook(path, event, cli, dir, { dryRun = false, enforce = false, failureBlock = false, modeFile = false } = {}) {
31
+ if (!existsSync(path)) return { applied: false, reason: `no config at ${path}` };
32
+ const config = readJsonConfig(path, path);
33
+ const hooks = config.hooks;
34
+ if (hooks !== undefined && (hooks === null || typeof hooks !== 'object' || Array.isArray(hooks))) {
35
+ return { applied: false, reason: 'hooks is not an object; merge by hand' };
36
+ }
37
+ const existing = hooks?.[event];
38
+ if (existing !== undefined && !Array.isArray(existing)) {
39
+ return { applied: false, reason: `${event} is not a list; merge by hand` };
40
+ }
41
+ const handler = { type: 'command', command: hookCommand(cli, dir, enforce, modeFile), timeout: 10 };
42
+ if (enforce && failureBlock) handler.onFailure = 'block';
43
+ const groups = existing ?? [];
44
+ const ourGroups = groups.filter(containsAttesso);
45
+ if (ourGroups.some((group) => group.hooks?.some((entry) => entry.command === handler.command))) {
46
+ return { applied: false, reason: 'already wired' };
47
+ }
48
+ const nextGroups = ourGroups.length
49
+ ? groups.map((group) =>
50
+ containsAttesso(group)
51
+ ? {
52
+ ...group,
53
+ hooks: (group.hooks ?? []).map((entry) =>
54
+ /attesso[^"]*hook/.test(entry.command ?? '') ? handler : entry,
55
+ ),
56
+ }
57
+ : group,
58
+ )
59
+ : [...groups, { matcher: '*', hooks: [handler] }];
60
+ const next = {
61
+ ...config,
62
+ hooks: { ...(hooks ?? {}), [event]: nextGroups },
63
+ };
64
+ if (dryRun) return { applied: false, dryRun: true, path, preview: next };
65
+ renameSync(path, `${path}.bak`);
66
+ writeFileSync(path, `${JSON.stringify(next, null, 2)}\n`);
67
+ return { applied: true, path, changed: ourGroups.length > 0 };
68
+ }
69
+
70
+ export function wireClaude(cli, dir, { dryRun = false, enforce = false, modeFile = false, path = join(homedir(), '.claude', 'settings.json') } = {}) {
71
+ return wireJsonHook(path, HARNESSES['claude-code'].event, cli, dir, { dryRun, enforce, modeFile, failureBlock: true });
72
+ }
73
+
74
+ export function wireCodex(cli, dir, { dryRun = false, enforce = false, modeFile = false, path = join(homedir(), '.codex', 'hooks.json') } = {}) {
75
+ return wireJsonHook(path, HARNESSES.codex.event, cli, dir, { dryRun, enforce, modeFile });
76
+ }
77
+
78
+ export function wireGemini(cli, dir, { dryRun = false, enforce = false, modeFile = false, path = join(homedir(), '.gemini', 'settings.json') } = {}) {
79
+ return wireJsonHook(path, HARNESSES.gemini.event, cli, dir, { dryRun, enforce, modeFile });
80
+ }
81
+
82
+ export function wireCline(cli, dir, { dryRun = false, enforce = false, modeFile = false, path = join(homedir(), 'Documents', 'Cline', 'Hooks') } = {}) {
83
+ const file = join(path, HARNESSES.cline.script);
84
+ const script = `#!/bin/sh\nexec ${hookCommand(cli, dir, enforce, modeFile)}\n`;
85
+ const current = existsSync(file) ? readFileSync(file, 'utf8') : '';
86
+ if (current === script) return { applied: false, reason: 'already wired' };
87
+ if (dryRun) return { applied: false, dryRun: true, path: file, preview: script };
88
+ mkdirSync(path, { recursive: true });
89
+ if (existsSync(file)) renameSync(file, `${file}.bak`);
90
+ writeFileSync(file, script, { mode: 0o755 });
91
+ chmodSync(file, 0o755);
92
+ return { applied: true, path: file, changed: current.includes('attesso') };
93
+ }
94
+
95
+ export function wireHermes(cli, dir, { dryRun = false, enforce = false, modeFile = false, path = hermesConfig() } = {}) {
96
+ if (!existsSync(path)) return { applied: false, reason: 'no Hermes config at ~/.hermes/config.yaml' };
97
+ const text = readFileSync(path, 'utf8');
98
+ if (/attesso.*hook/.test(text)) return { applied: false, reason: 'already wired' };
99
+ const block = `hooks:\n ${HARNESSES.hermes.event}:\n - command: ${hookCommand(cli, dir, enforce, modeFile)}\n timeout: 10\n`;
100
+ if (/^hooks:/m.test(text)) {
101
+ return { applied: false, reason: 'config already has a hooks: block, merge by hand', snippet: block };
102
+ }
103
+ const next = text.replace(/^approvals:/m, `${block}approvals:`);
104
+ if (next === text) return { applied: false, reason: 'no anchor to insert before; add the block by hand', snippet: block };
105
+ if (dryRun) return { applied: false, dryRun: true, path, snippet: block };
106
+ renameSync(path, `${path}.bak`);
107
+ writeFileSync(path, next);
108
+ return { applied: true, path, snippet: block };
109
+ }
110
+
111
+ const writers = {
112
+ hermes: wireHermes,
113
+ 'claude-code': wireClaude,
114
+ codex: wireCodex,
115
+ gemini: wireGemini,
116
+ cline: wireCline,
117
+ };
118
+
119
+ export function wireHarness(key, cli, dir, options = {}) {
120
+ const writer = writers[key];
121
+ return writer ? writer(cli, dir, options) : { applied: false, reason: `no installer for ${key}` };
122
+ }
package/src/jsonl.mjs ADDED
@@ -0,0 +1,16 @@
1
+ // One reading of a JSONL line, shared by the array reader and the stream.
2
+ export function parseLine(line, index) {
3
+ try {
4
+ return JSON.parse(line);
5
+ } catch {
6
+ throw new Error(`ledger line ${index} is not JSON; the file was edited or truncated mid-line`);
7
+ }
8
+ }
9
+
10
+ export function parseTail(line, index) {
11
+ try {
12
+ return JSON.parse(line);
13
+ } catch {
14
+ return undefined;
15
+ }
16
+ }
package/src/ledger.mjs CHANGED
@@ -36,10 +36,39 @@ export function signEntry(entry, privatePem) {
36
36
  }
37
37
 
38
38
  export function verifyEntry(entry, publicPem) {
39
- return verify(null, signingPayload(entry), publicPem, Buffer.from(entry.sig, 'base64url'));
39
+ if (!entry || typeof entry.sig !== 'string' || entry.sig === '') return false;
40
+ try {
41
+ return verify(null, signingPayload(entry), publicPem, Buffer.from(entry.sig, 'base64url'));
42
+ } catch {
43
+ return false;
44
+ }
40
45
  }
41
46
 
42
- export function buildEntry({ seq, prev, action, subject, amount, meta, task, trust, capability, decision, code, policyDigest, kid, at }) {
47
+ export function buildEntry({
48
+ seq,
49
+ prev,
50
+ action,
51
+ subject,
52
+ amount,
53
+ meta,
54
+ task,
55
+ trust,
56
+ capability,
57
+ decision,
58
+ code,
59
+ rule,
60
+ taintedBy,
61
+ argsHash,
62
+ policyDigest,
63
+ rulesDigest,
64
+ toolsDigest,
65
+ toolHash,
66
+ toolPin,
67
+ mcpDigest,
68
+ control,
69
+ kid,
70
+ at,
71
+ }) {
43
72
  const entry = {
44
73
  v: ENTRY_VERSION,
45
74
  seq,
@@ -56,32 +85,62 @@ export function buildEntry({ seq, prev, action, subject, amount, meta, task, tru
56
85
  if (task) entry.task = task;
57
86
  if (trust) entry.trust = trust;
58
87
  if (capability) entry.capability = capability;
88
+ if (rule) entry.rule = rule;
89
+ if (taintedBy !== undefined) entry.tainted_by = taintedBy;
90
+ if (argsHash) entry.args_hash = argsHash;
91
+ if (rulesDigest) entry.rules_digest = rulesDigest;
92
+ if (toolsDigest) entry.tools_digest = toolsDigest;
93
+ if (toolHash) entry.tool_hash = toolHash;
94
+ if (toolPin) entry.tool_pin = toolPin;
95
+ if (mcpDigest) entry.mcp_digest = mcpDigest;
96
+ if (control) entry.control = control;
59
97
  if (meta && Object.keys(meta).length > 0) entry.meta = meta;
60
98
  return entry;
61
99
  }
62
100
 
63
101
  // Returns the first defect rather than a boolean, so a reader learns which entry
64
102
  // to look at. `head` comes from config, which is what makes truncation visible.
65
- export function verifyChain(entries, publicPem, head) {
66
- let previous = 'genesis';
67
- for (const [index, entry] of entries.entries()) {
68
- if (entry.seq !== index + 1) {
69
- return { ok: false, reason: `entry ${index + 1} carries seq ${entry.seq}` };
70
- }
71
- if (entry.prev !== previous) {
72
- return { ok: false, reason: `entry ${entry.seq} does not chain to the entry before it` };
73
- }
74
- if (!verifyEntry(entry, publicPem)) {
75
- return { ok: false, reason: `entry ${entry.seq} signature does not verify` };
76
- }
77
- previous = entryHash(entry);
78
- }
103
+ export function verifyHead(count, hash, head) {
79
104
  if (!head) return { ok: false, reason: 'no recorded head, so the end of the log cannot be checked' };
80
- if (head.count !== entries.length) {
81
- return { ok: false, reason: `config records ${head.count} entries, the log holds ${entries.length}` };
105
+ if (head.count !== count) {
106
+ if (head.count < count) {
107
+ const extra = count - head.count;
108
+ const noun = extra === 1 ? 'entry' : 'entries';
109
+ return {
110
+ ok: false,
111
+ reason: `the log holds ${extra} more ${noun} than the recorded head; an interrupted append left them, and the next append repairs the head`,
112
+ };
113
+ }
114
+ return { ok: false, reason: `config records ${head.count} entries, the log holds ${count}` };
82
115
  }
83
- if (head.hash !== previous) {
116
+ if (head.hash !== hash) {
84
117
  return { ok: false, reason: 'the last entry does not match the recorded head' };
85
118
  }
86
- return { ok: true, reason: `chain of ${entries.length} entries verifies` };
119
+ return { ok: true, reason: `chain of ${count} entries verifies` };
120
+ }
121
+
122
+ export function verifyStep(entry, index, previous, publicPem) {
123
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
124
+ return { ok: false, reason: `entry ${index + 1} is not an object` };
125
+ }
126
+ if (entry.seq !== index + 1) {
127
+ return { ok: false, reason: `entry ${index + 1} carries seq ${entry.seq}` };
128
+ }
129
+ if (entry.prev !== previous) {
130
+ return { ok: false, reason: `entry ${entry.seq} does not chain to the entry before it` };
131
+ }
132
+ if (!verifyEntry(entry, publicPem)) {
133
+ return { ok: false, reason: `entry ${entry.seq} signature does not verify` };
134
+ }
135
+ return { ok: true, hash: entryHash(entry) };
136
+ }
137
+
138
+ export function verifyChain(entries, publicPem, head) {
139
+ let previous = 'genesis';
140
+ for (const [index, entry] of entries.entries()) {
141
+ const step = verifyStep(entry, index, previous, publicPem);
142
+ if (!step.ok) return step;
143
+ previous = step.hash;
144
+ }
145
+ return verifyHead(entries.length, previous, head);
87
146
  }