attesso 0.4.0 → 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.
package/package.json CHANGED
@@ -1,16 +1,20 @@
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.6.0",
4
+ "description": "Bounds: decide an AI agent's next tool call before it runs, with a signed record. Free and unlimited locally, $0.00085 per synced decision.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
7
7
  "bin": {
8
- "attesso": "./bin/attesso.mjs"
8
+ "attesso": "./bin/attesso.mjs",
9
+ "attesso-mcp": "./bin/attesso-mcp.mjs",
10
+ "attesso-control-mcp": "./bin/attesso-control-mcp.mjs"
9
11
  },
10
12
  "files": [
11
13
  "bin",
12
14
  "src",
13
- "README.md"
15
+ "README.md",
16
+ "LICENSE",
17
+ "NOTICE"
14
18
  ],
15
19
  "engines": {
16
20
  "node": ">=20"
package/src/bundle.mjs ADDED
@@ -0,0 +1,128 @@
1
+ // A bundle is the smallest artifact that lets a stranger re-run one decision:
2
+ // the entry, its receipt, the exact policy and rules bytes, and the task history
3
+ // the path rule read.
4
+ import { entryHash, verifyEntry } from './ledger.mjs';
5
+ import { policyDigest } from './policy.mjs';
6
+ import { receiptFor, signerOf, verifyReceipt } from './receipt.mjs';
7
+ import { decide } from './decide.mjs';
8
+ import { proposalOf } from './replay.mjs';
9
+ import { rulesDigest } from './rules.mjs';
10
+ import { toolsDigest, manifestDigest, validateToolLock } from './toolpin.mjs';
11
+ import { readSnapshot, readState, readToolLock } from './store.mjs';
12
+
13
+ export function bundleFor(dir, seq) {
14
+ const state = readState(dir);
15
+ const index = state.entries.findIndex((entry) => entry.seq === Number(seq));
16
+ if (index === -1) throw new Error(`no entry #${seq} in this ledger`);
17
+ const entry = state.entries[index];
18
+ const policy = readSnapshot(dir, entry.policy_digest, 'policy');
19
+ const rules = entry.rules_digest ? readSnapshot(dir, entry.rules_digest, 'rules') : null;
20
+ const tools = entry.tools_digest ? readSnapshot(dir, entry.tools_digest, 'tools') : null;
21
+ const mcp = entry.mcp_digest ? readSnapshot(dir, entry.mcp_digest, 'mcp') : null;
22
+ const toolLock = readToolLock(dir);
23
+ const history = state.entries.filter(
24
+ (candidate) => (candidate.task ?? null) === (entry.task ?? null) && candidate.seq < entry.seq,
25
+ );
26
+ return {
27
+ v: 1,
28
+ statement: entry,
29
+ key: state.key.public,
30
+ receipt: receiptFor(state.entries, index, signerOf(state.key)),
31
+ policy,
32
+ rules,
33
+ tools,
34
+ mcp,
35
+ tool_lock: toolLock,
36
+ history,
37
+ };
38
+ }
39
+
40
+ export function verifyBundle(bundle, { replay = false, publicPem } = {}) {
41
+ if (!bundle || bundle.v !== 1 || !bundle.statement || !bundle.receipt) {
42
+ return { ok: false, reason: 'bundle is not version 1' };
43
+ }
44
+ const key = publicPem ?? bundle.key;
45
+ const { statement, receipt, policy, rules, tools, mcp, tool_lock: toolLock, history } = bundle;
46
+ if (!verifyEntry(statement, key)) return { ok: false, reason: 'statement signature does not verify' };
47
+
48
+ const receiptResult = verifyReceipt(receipt, key);
49
+ if (!receiptResult.ok) return { ok: false, reason: receiptResult.reason };
50
+ if (entryHash(receipt.entry) !== entryHash(statement)) {
51
+ return { ok: false, reason: 'the receipt proves a different entry' };
52
+ }
53
+ if (policyDigest(policy) !== statement.policy_digest) {
54
+ return { ok: false, reason: 'policy_digest does not match the policy in the bundle' };
55
+ }
56
+ let historical = false;
57
+ if (statement.rules_digest) {
58
+ if (!rules) return { ok: false, reason: 'rules_digest is set but the bundle carries no rules' };
59
+ if (rulesDigest(rules) !== statement.rules_digest) {
60
+ return { ok: false, reason: 'rules_digest does not match the rules in the bundle' };
61
+ }
62
+ } else {
63
+ historical = true;
64
+ }
65
+ if (statement.tools_digest) {
66
+ if (!tools) return { ok: false, reason: 'tools_digest is set but the bundle carries no tools' };
67
+ if (toolsDigest(tools) !== statement.tools_digest) {
68
+ return { ok: false, reason: 'tools_digest does not match the tools in the bundle' };
69
+ }
70
+ } else {
71
+ historical = true;
72
+ }
73
+ if (statement.mcp_digest) {
74
+ if (!mcp) return { ok: false, reason: 'mcp_digest is set but the bundle carries no MCP manifest' };
75
+ if (manifestDigest(mcp) !== statement.mcp_digest) {
76
+ return { ok: false, reason: 'mcp_digest does not match the MCP manifest in the bundle' };
77
+ }
78
+ }
79
+ if (statement.tool_pin === 'verified') {
80
+ if (!toolLock) return { ok: false, reason: 'tool_pin is verified but the bundle carries no tool lock' };
81
+ try {
82
+ validateToolLock(toolLock);
83
+ } catch (error) {
84
+ return { ok: false, reason: error.message };
85
+ }
86
+ if (toolLock.tools[statement.subject] !== statement.tool_hash) {
87
+ return { ok: false, reason: 'tool_hash does not match the pinned tool in the bundle' };
88
+ }
89
+ }
90
+ let previous = 0;
91
+ for (const item of history ?? []) {
92
+ if (!verifyEntry(item, key)) return { ok: false, reason: `history entry #${item.seq} signature does not verify` };
93
+ if ((item.task ?? null) !== (statement.task ?? null)) {
94
+ return { ok: false, reason: `history entry #${item.seq} belongs to another task` };
95
+ }
96
+ if (item.seq >= statement.seq || item.seq <= previous) {
97
+ return { ok: false, reason: `history entry #${item.seq} is out of order` };
98
+ }
99
+ previous = item.seq;
100
+ }
101
+ if (!replay) return { ok: true, reason: 'bundle verifies', historical };
102
+ if (statement.control) {
103
+ return { ok: true, reason: 'bundle verifies; control verdict is not replayed', historical };
104
+ }
105
+ if (statement.code === 'TOOL_CHANGED' || statement.code === 'TOOL_UNPINNED') {
106
+ return { ok: true, reason: 'bundle verifies; the tool-pin verdict is not replayed', historical };
107
+ }
108
+
109
+ const verdict = decide({
110
+ policy,
111
+ rules,
112
+ history: history ?? [],
113
+ proposal: proposalOf(statement),
114
+ now: new Date(statement.at),
115
+ });
116
+ if (verdict.decision !== statement.decision || verdict.code !== statement.code) {
117
+ return {
118
+ ok: false,
119
+ reason: `replay produced ${verdict.decision}/${verdict.code}, the record says ${statement.decision}/${statement.code}`,
120
+ };
121
+ }
122
+ return {
123
+ ok: true,
124
+ reason: 'bundle verifies and replays to the recorded verdict',
125
+ historical,
126
+ replayed: { decision: verdict.decision, code: verdict.code, rule: verdict.rule ?? null },
127
+ };
128
+ }
@@ -0,0 +1,83 @@
1
+ // What a tool call is, decided once for every harness. An unknown tool is an
2
+ // untrusted read: the opposite default silently trusts whatever a harness adds.
3
+ import { existsSync, readFileSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+
6
+ export const DEFAULT_TOOLS = {
7
+ version: 1,
8
+ trusted_read: [
9
+ 'read_file',
10
+ 'search_files',
11
+ 'skill_view',
12
+ 'fact_store',
13
+ 'fact_feedback',
14
+ 'todo_list',
15
+ 'session_search',
16
+ ],
17
+ execute: ['terminal', 'execute_code', 'process_manage', 'delegate_task', 'cronjob_manage', 'shell', 'bash'],
18
+ write: ['write_file', 'patch', 'skill_manage', 'str_replace_editor', 'edit'],
19
+ send: [],
20
+ };
21
+
22
+ const FIELDS = ['trusted_read', 'execute', 'write', 'send'];
23
+ const SEND_SHAPE = /send|mail|post|publish/i;
24
+ const ALIASES = {
25
+ Bash: 'terminal',
26
+ Shell: 'terminal',
27
+ execute_command: 'terminal',
28
+ run_shell_command: 'terminal',
29
+ Read: 'read_file',
30
+ Grep: 'search_files',
31
+ Glob: 'search_files',
32
+ WebFetch: 'web_extract',
33
+ WebSearch: 'web_search',
34
+ Write: 'write_file',
35
+ Edit: 'patch',
36
+ MultiEdit: 'patch',
37
+ NotebookEdit: 'write_file',
38
+ apply_patch: 'patch',
39
+ write_to_file: 'write_file',
40
+ replace_in_file: 'patch',
41
+ replace: 'patch',
42
+ Delete: 'write_file',
43
+ Task: 'delegate_task',
44
+ Agent: 'delegate_task',
45
+ spawn_agent: 'delegate_task',
46
+ web_fetch: 'web_extract',
47
+ google_web_search: 'web_search',
48
+ browser_action: 'web_search',
49
+ update_plan: 'read_file',
50
+ view_image: 'read_file',
51
+ };
52
+
53
+ export function validateTools(tools) {
54
+ const fail = (message) => {
55
+ throw new Error(`tools.json is invalid: ${message}`);
56
+ };
57
+ if (tools === null || typeof tools !== 'object') fail('not an object');
58
+ if (tools.version !== 1) fail('version must be 1');
59
+ for (const field of FIELDS) {
60
+ if (!Array.isArray(tools[field])) fail(`${field} must be a list`);
61
+ if (tools[field].some((name) => typeof name !== 'string' || name === '')) {
62
+ fail(`${field} must hold non-empty tool names`);
63
+ }
64
+ }
65
+ return tools;
66
+ }
67
+
68
+ export function loadTools(dir) {
69
+ const file = join(dir, 'tools.json');
70
+ if (!existsSync(file)) return DEFAULT_TOOLS;
71
+ return validateTools(JSON.parse(readFileSync(file, 'utf8')));
72
+ }
73
+
74
+ export function classifyTool(toolName, tools = DEFAULT_TOOLS) {
75
+ const raw = String(toolName ?? '').trim();
76
+ const name = ALIASES[raw] ?? raw;
77
+ if (tools.execute.includes(name)) return { action: 'shell.execute', capability: 'execute', trust: 'internal' };
78
+ if (tools.write.includes(name)) return { action: 'file.write', capability: 'write', trust: 'internal' };
79
+ if (tools.send.includes(name)) return { action: 'message.send', capability: 'send', trust: 'internal' };
80
+ if (tools.trusted_read.includes(name)) return { action: 'tool.call', capability: 'read', trust: 'internal' };
81
+ if (SEND_SHAPE.test(name)) return { action: 'message.send', capability: 'send', trust: 'untrusted' };
82
+ return { action: 'web.read', capability: 'read', trust: 'untrusted' };
83
+ }
package/src/cli-io.mjs ADDED
@@ -0,0 +1,130 @@
1
+ // CLI plumbing: argument shapes, JSON files and the --out guard. Keeping it
2
+ // out of the command dispatch makes the entry point a wiring table, not a
3
+ // second implementation of path handling.
4
+ import { lstatSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs';
5
+ import { basename, dirname, join, resolve, sep } from 'node:path';
6
+ import { paths } from './store.mjs';
7
+
8
+ export function flags(argv) {
9
+ const out = {};
10
+ for (let index = 0; index < argv.length; index += 1) {
11
+ const token = argv[index];
12
+ if (!token.startsWith('--')) continue;
13
+ const name = token.slice(2);
14
+ const next = argv[index + 1];
15
+ if (next === undefined || next.startsWith('--')) {
16
+ out[name] = true;
17
+ continue;
18
+ }
19
+ if (name === 'meta') {
20
+ out.meta = [...(out.meta ?? []), next];
21
+ } else {
22
+ out[name] = next;
23
+ }
24
+ index += 1;
25
+ }
26
+ return out;
27
+ }
28
+
29
+ export function parseMeta(pairs = []) {
30
+ const meta = {};
31
+ for (const pair of pairs) {
32
+ const at = pair.indexOf('=');
33
+ if (at <= 0) throw new Error(`--meta expects key=value, got ${pair}`);
34
+ const key = pair.slice(0, at);
35
+ const raw = pair.slice(at + 1);
36
+ meta[key] = raw !== '' && !Number.isNaN(Number(raw)) ? Number(raw) : raw;
37
+ }
38
+ return meta;
39
+ }
40
+
41
+ export function readJsonFile(path, label) {
42
+ try {
43
+ return JSON.parse(readFileSync(path, 'utf8'));
44
+ } catch (error) {
45
+ console.error(`${label}: ${error.message}`);
46
+ process.exit(2);
47
+ }
48
+ }
49
+
50
+ export function publicKey(path) {
51
+ const text = readFileSync(path, 'utf8');
52
+ return text.trim().startsWith('{') ? JSON.parse(text).public : text;
53
+ }
54
+
55
+ function canonical(path) {
56
+ try {
57
+ return realpathSync(path);
58
+ } catch {
59
+ try {
60
+ return join(realpathSync(dirname(resolve(path))), basename(resolve(path)));
61
+ } catch {
62
+ return resolve(path);
63
+ }
64
+ }
65
+ }
66
+
67
+ export function canonicalOut(path) {
68
+ const absolute = resolve(path);
69
+ try {
70
+ const entry = lstatSync(absolute);
71
+ if (entry.isSymbolicLink()) return null;
72
+ return canonical(absolute);
73
+ } catch (error) {
74
+ if (error.code !== 'ENOENT') throw error;
75
+ return canonical(absolute);
76
+ }
77
+ }
78
+
79
+ // An --out path that lands on the ledger, its key or a stored snapshot destroys
80
+ // the record a command is supposed to prove. Refuse before opening the file.
81
+ // A symlink is refused too: resolving only the spelling of the path would let
82
+ // `--out receipt.json` overwrite the ledger through a link.
83
+ export function assertSafeOut(dir, out) {
84
+ if (!out) return;
85
+ const file = paths(dir);
86
+ const target = canonicalOut(out);
87
+ if (target === null) {
88
+ console.error(`refusing to write ${resolve(out)}; it is a symbolic link`);
89
+ process.exit(2);
90
+ }
91
+ const guarded = [file.key, file.policy, file.rules, file.ledger, file.config, file.tools, file.toolLock, file.lock].map(canonical);
92
+ const snapshotDirs = [file.policies, file.toolsSnapshots].map(canonical);
93
+ for (const snapshotDir of [file.policies, file.toolsSnapshots]) {
94
+ try {
95
+ for (const name of readdirSync(snapshotDir)) guarded.push(canonical(join(snapshotDir, name)));
96
+ } catch {
97
+ // A snapshot directory is created on the first append.
98
+ }
99
+ }
100
+ let targetStat = null;
101
+ try {
102
+ targetStat = statSync(target);
103
+ } catch {
104
+ // A new output file has no inode to compare.
105
+ }
106
+ const sameFile = guarded.some((path) => {
107
+ try {
108
+ const guardedStat = statSync(path);
109
+ return targetStat !== null && guardedStat.dev === targetStat.dev && guardedStat.ino === targetStat.ino;
110
+ } catch {
111
+ return false;
112
+ }
113
+ });
114
+ if (
115
+ sameFile ||
116
+ guarded.includes(target) ||
117
+ snapshotDirs.includes(target) ||
118
+ snapshotDirs.some((snapshotDir) => target.startsWith(`${snapshotDir}${sep}`))
119
+ ) {
120
+ console.error(`refusing to write ${target}; it is part of the ledger`);
121
+ process.exit(2);
122
+ }
123
+ }
124
+
125
+ export function writeJsonOut(dir, value, out) {
126
+ assertSafeOut(dir, out);
127
+ const text = `${JSON.stringify(value, null, 2)}\n`;
128
+ if (out) writeFileSync(out, text);
129
+ else process.stdout.write(text);
130
+ }
@@ -0,0 +1,160 @@
1
+ // Agent-controlled setup. The connector is the command an agent can run
2
+ // without opening Bounds: initialize the ledger, write a mode file, wire the
3
+ // harness hook, and report a machine-readable result on stdout.
4
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
5
+ import { homedir } from 'node:os';
6
+ import { dirname, join } from 'node:path';
7
+ import { detectHarnesses, writeAgentContext } from './setup.mjs';
8
+ import { wireHarness } from './install.mjs';
9
+ import { init, isInitialised, paths } from './store.mjs';
10
+
11
+ export const CONNECT_MODES = ['off', 'observe', 'enforce'];
12
+ export const MODE_FILE = 'mode';
13
+
14
+ export function modePath(dir) {
15
+ return join(dir, MODE_FILE);
16
+ }
17
+
18
+ export function readMode(dir) {
19
+ const file = modePath(dir);
20
+ if (!existsSync(file)) return null;
21
+ const value = readFileSync(file, 'utf8').trim();
22
+ if (!CONNECT_MODES.includes(value)) {
23
+ throw new Error(`invalid mode '${value}'; expected ${CONNECT_MODES.join(', ')}`);
24
+ }
25
+ return value;
26
+ }
27
+
28
+ export function setModeAll(dir, mode, { home = homedir() } = {}) {
29
+ setMode(dir, mode);
30
+ for (const legacyDir of legacyModeDirs(home)) setMode(legacyDir, mode);
31
+ return mode;
32
+ }
33
+
34
+ export function setMode(dir, mode) {
35
+ if (!CONNECT_MODES.includes(mode)) {
36
+ throw new Error(`invalid mode '${mode}'; expected ${CONNECT_MODES.join(', ')}`);
37
+ }
38
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
39
+ const file = modePath(dir);
40
+ const temporary = `${file}.${process.pid}.tmp`;
41
+ writeFileSync(temporary, `${mode}\n`, { mode: 0o600 });
42
+ renameSync(temporary, file);
43
+ return mode;
44
+ }
45
+
46
+ function legacyModeDirs(home) {
47
+ const config = join(home, '.hermes', 'config.yaml');
48
+ if (!existsSync(config)) return [];
49
+ try {
50
+ const text = readFileSync(config, 'utf8');
51
+ const match = text.match(/^\s*-\s*command:\s*(.+hook\.sh)\s*$/m);
52
+ if (!match) return [];
53
+ const command = match[1].trim().replace(/^['"]|['"]$/g, '');
54
+ return command.includes('attesso-veto') ? [dirname(command)] : [];
55
+ } catch {
56
+ return [];
57
+ }
58
+ }
59
+
60
+ function hasAttessoHook(path) {
61
+ if (!existsSync(path)) return false;
62
+ try {
63
+ const text = readFileSync(path, 'utf8');
64
+ return /attesso[^\n"]*hook/.test(text);
65
+ } catch {
66
+ return false;
67
+ }
68
+ }
69
+
70
+ function wiredHarnesses(harnesses) {
71
+ return harnesses.map((harness) => {
72
+ const candidate = harness.key === 'cline' ? join(harness.path, 'PreToolUse') : harness.path;
73
+ return { ...harness, wired: hasAttessoHook(candidate) };
74
+ });
75
+ }
76
+
77
+ function instructions({ mode, wired, manual }) {
78
+ const next = [];
79
+ if (!mode) next.push('run `attesso connect --write --mode observe` from the agent');
80
+ else if (mode === 'off') next.push('run `attesso mode observe` to start recording');
81
+ if (manual.length) next.push('wire the listed harnesses by hand, or use the generic hook contract');
82
+ if (!wired.length) next.push('run `attesso connect --write --mode observe` from the agent');
83
+ return next;
84
+ }
85
+
86
+ export function status({ dir, cwd = process.cwd(), home = homedir() } = {}) {
87
+ const initialized = isInitialised(dir);
88
+ const mode = readMode(dir);
89
+ const detected = wiredHarnesses(detectHarnesses({ home, cwd }));
90
+ const wired = detected.filter((harness) => harness.wired);
91
+ const manual = detected.filter((harness) => !['hermes', 'claude-code', 'codex', 'gemini', 'cline'].includes(harness.key));
92
+ return {
93
+ initialized,
94
+ mode,
95
+ ledger: initialized ? paths(dir).dir : null,
96
+ harnesses: detected,
97
+ wired: wired.map((harness) => harness.key),
98
+ manual: manual.map((harness) => harness.key),
99
+ connected: initialized && mode !== null && mode !== 'off' && wired.length > 0,
100
+ next: instructions({ mode, wired, manual }),
101
+ };
102
+ }
103
+
104
+ export function connect({
105
+ dir,
106
+ cwd = process.cwd(),
107
+ home = homedir(),
108
+ cli,
109
+ write = false,
110
+ mode = 'observe',
111
+ harness: only = null,
112
+ } = {}) {
113
+ if (!CONNECT_MODES.includes(mode)) {
114
+ throw new Error(`invalid mode '${mode}'; expected ${CONNECT_MODES.join(', ')}`);
115
+ }
116
+ const initialized = isInitialised(dir);
117
+ if (write && !initialized) init(dir);
118
+ const detected = detectHarnesses({ home, cwd }).filter((harness) => !only || harness.key === only);
119
+ const agentContext = writeAgentContext(cwd, { dryRun: !write });
120
+ const results = [];
121
+ for (const harness of detected) {
122
+ if (!['hermes', 'claude-code', 'codex', 'gemini', 'cline'].includes(harness.key)) {
123
+ results.push({
124
+ key: harness.key,
125
+ path: harness.path,
126
+ wired: false,
127
+ manual: true,
128
+ reason: 'no automatic installer; use the generic hook contract',
129
+ });
130
+ continue;
131
+ }
132
+ try {
133
+ const result = wireHarness(harness.key, cli, dir, {
134
+ dryRun: !write,
135
+ enforce: mode === 'enforce',
136
+ modeFile: true,
137
+ path: harness.path,
138
+ });
139
+ const warnings = [];
140
+ if (harness.key === 'codex' && result.applied) warnings.push('Codex requires running /hooks and trusting the installed command');
141
+ results.push({ key: harness.key, path: harness.path, ...result, ...(warnings.length ? { warnings } : {}) });
142
+ } catch (error) {
143
+ results.push({ key: harness.key, path: harness.path, wired: false, error: error.message });
144
+ }
145
+ }
146
+ if (write) setModeAll(dir, mode, { home });
147
+ const wired = results.filter((result) => result.applied || result.reason === 'already wired');
148
+ const effectiveMode = write ? mode : readMode(dir);
149
+ return {
150
+ initialized: isInitialised(dir),
151
+ mode: effectiveMode,
152
+ connected: isInitialised(dir) && effectiveMode !== null && effectiveMode !== 'off' && wired.length > 0,
153
+ ledger: paths(dir).dir,
154
+ harnesses: results,
155
+ wired: wired.map((result) => result.key),
156
+ manual: results.filter((result) => result.manual || result.error || result.reason?.includes('merge by hand')).map((result) => result.key),
157
+ agent_context: agentContext,
158
+ next: instructions({ mode: effectiveMode, wired, manual: results.filter((result) => result.manual || result.error) }),
159
+ };
160
+ }
@@ -0,0 +1,101 @@
1
+ // Local steering state. This file is deliberately outside the signed ledger:
2
+ // it is the operator's current intent, not a decision about an action. A
3
+ // decision made under it is signed by append as a control entry.
4
+ import { closeSync, existsSync, fsyncSync, openSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
5
+ import { randomBytes } from 'node:crypto';
6
+ import { join } from 'node:path';
7
+ import { withLedgerLock } from './store.mjs';
8
+
9
+ export const CONTROL_MODES = ['pause', 'stop'];
10
+ const controlPath = (dir) => join(dir, 'control.json');
11
+ const empty = () => ({ version: 1, tasks: {} });
12
+
13
+ function fail(message) {
14
+ throw new Error(`CONTROL_INVALID: ${message}`);
15
+ }
16
+
17
+ function validate(value) {
18
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) fail('not an object');
19
+ if (value.version !== 1) fail('version must be 1');
20
+ if (value.tasks === null || typeof value.tasks !== 'object' || Array.isArray(value.tasks)) fail('tasks must be an object');
21
+ for (const [task, control] of Object.entries(value.tasks)) {
22
+ if (task === '') fail('task id must not be empty');
23
+ if (control === null || typeof control !== 'object' || Array.isArray(control)) fail(`task ${task} is not an object`);
24
+ if (!CONTROL_MODES.includes(control.mode)) fail(`task ${task} has unknown mode`);
25
+ if (typeof control.at !== 'string' || Number.isNaN(Date.parse(control.at))) fail(`task ${task} has no timestamp`);
26
+ if (control.reason !== undefined && typeof control.reason !== 'string') fail(`task ${task} has a non-string reason`);
27
+ }
28
+ return value;
29
+ }
30
+
31
+ function fsyncDir(path) {
32
+ let handle;
33
+ try {
34
+ handle = openSync(path, 'r');
35
+ fsyncSync(handle);
36
+ } catch {
37
+ // Directory fsync is unavailable on some platforms.
38
+ } finally {
39
+ if (handle !== undefined) closeSync(handle);
40
+ }
41
+ }
42
+
43
+ function writeControl(dir, value) {
44
+ validate(value);
45
+ const file = controlPath(dir);
46
+ const temporary = `${file}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
47
+ const handle = openSync(temporary, 'w', 0o600);
48
+ try {
49
+ writeFileSync(handle, `${JSON.stringify(value, null, 2)}\n`);
50
+ fsyncSync(handle);
51
+ } finally {
52
+ closeSync(handle);
53
+ }
54
+ renameSync(temporary, file);
55
+ fsyncDir(dir);
56
+ }
57
+
58
+ export function readControl(dir) {
59
+ const file = controlPath(dir);
60
+ if (!existsSync(file)) return empty();
61
+ let value;
62
+ try {
63
+ value = JSON.parse(readFileSync(file, 'utf8'));
64
+ } catch (error) {
65
+ fail(error.message);
66
+ }
67
+ return validate(value);
68
+ }
69
+
70
+ export function taskControl(dir, task) {
71
+ return readControl(dir).tasks[task];
72
+ }
73
+
74
+ export function listControl(dir) {
75
+ return Object.entries(readControl(dir).tasks)
76
+ .map(([task, control]) => ({ task, ...control }))
77
+ .sort((left, right) => Date.parse(left.at) - Date.parse(right.at));
78
+ }
79
+
80
+ export function setTaskControl(dir, task, mode, { reason } = {}) {
81
+ if (typeof task !== 'string' || task === '') throw new Error('task id must not be empty');
82
+ if (!CONTROL_MODES.includes(mode)) throw new Error(`mode must be ${CONTROL_MODES.join(' or ')}`);
83
+ if (reason !== undefined && typeof reason !== 'string') throw new Error('reason must be a string');
84
+ return withLedgerLock(dir, () => {
85
+ const value = readControl(dir);
86
+ value.tasks[task] = { mode, at: new Date().toISOString(), ...(reason === undefined ? {} : { reason }) };
87
+ writeControl(dir, value);
88
+ return { task, ...value.tasks[task] };
89
+ });
90
+ }
91
+
92
+ export function clearTaskControl(dir, task) {
93
+ if (typeof task !== 'string' || task === '') throw new Error('task id must not be empty');
94
+ return withLedgerLock(dir, () => {
95
+ const value = readControl(dir);
96
+ if (value.tasks[task] === undefined) return false;
97
+ delete value.tasks[task];
98
+ writeControl(dir, value);
99
+ return true;
100
+ });
101
+ }
package/src/decide.mjs ADDED
@@ -0,0 +1,10 @@
1
+ // One decision path, shared by the live gate and by replay. The order matters:
2
+ // a policy refusal wins, and a path rule is only consulted when the policy allows.
3
+ import { evaluate } from './policy.mjs';
4
+ import { evaluatePath } from './rules.mjs';
5
+
6
+ export function decide({ policy, rules, history, proposal, now }) {
7
+ const policyVerdict = evaluate(policy, proposal, now);
8
+ if (policyVerdict.decision !== 'ALLOW') return policyVerdict;
9
+ return evaluatePath(rules, history, proposal, now) ?? policyVerdict;
10
+ }