attesso 0.8.0 → 0.10.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 +17 -2
- package/bin/attesso.mjs +36 -2
- package/package.json +4 -4
- package/src/connect.mjs +37 -26
- package/src/remote-policy.mjs +142 -0
- package/src/setup.mjs +6 -4
- package/src/ui/static/index.html +78 -41
package/README.md
CHANGED
|
@@ -22,10 +22,10 @@ are appended, and writes mode and path changes to the same files the hook reads:
|
|
|
22
22
|
|
|
23
23
|
```sh
|
|
24
24
|
npx attesso ui
|
|
25
|
-
npx attesso ui --
|
|
25
|
+
npx attesso ui --no-open
|
|
26
26
|
```
|
|
27
27
|
|
|
28
|
-
The server binds `127.0.0.1`, mints a per-process token, and serves the page from
|
|
28
|
+
The server binds `127.0.0.1:8197`, mints a per-process token, and serves the page from
|
|
29
29
|
the installed package. It has no account, no hosted data, and no dependency on a
|
|
30
30
|
snapshot file. `Ctrl-C` stops it.
|
|
31
31
|
|
|
@@ -45,6 +45,21 @@ npx attesso sync
|
|
|
45
45
|
npx attesso sync --watch
|
|
46
46
|
```
|
|
47
47
|
|
|
48
|
+
## Hosted policy
|
|
49
|
+
|
|
50
|
+
A hosted workspace can publish its portable rule set from the dashboard. The
|
|
51
|
+
local engine fetches the signed bundle, verifies it against Attesso's published
|
|
52
|
+
JWKS and only then writes `.attesso/rules.json`; path scope stays local.
|
|
53
|
+
|
|
54
|
+
```sh
|
|
55
|
+
export ATTESSO_SYNC_URL=https://api.attesso.com
|
|
56
|
+
export ATTESSO_API_KEY=att_live_...
|
|
57
|
+
npx attesso policy pull --apply
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Without `--apply` the command verifies and previews the version and digest. A
|
|
61
|
+
tampered signature, an unknown key or an invalid rule set writes nothing.
|
|
62
|
+
|
|
48
63
|
The API key must belong to an entitled live workspace. A retry after a failed
|
|
49
64
|
request is safe: the server deduplicates by device and sequence, and the local
|
|
50
65
|
cursor does not move until the batch is accepted. `--watch` repeats every five
|
package/bin/attesso.mjs
CHANGED
|
@@ -30,6 +30,7 @@ import { CAPABILITIES, TRUST_LEVELS, validateRules } from './../src/rules.mjs';
|
|
|
30
30
|
import { validatePathsConfig } from './../src/fs-paths.mjs';
|
|
31
31
|
import { startUi } from './../src/ui/server.mjs';
|
|
32
32
|
import { sync, watchSync } from './../src/sync.mjs';
|
|
33
|
+
import { pullPolicy } from './../src/remote-policy.mjs';
|
|
33
34
|
|
|
34
35
|
const argv = process.argv.slice(2);
|
|
35
36
|
const dirAt = argv.indexOf('--dir');
|
|
@@ -71,12 +72,16 @@ const USAGE = `attesso: a local, signed ledger of what your agents attempted
|
|
|
71
72
|
attesso hook [--observe] harness hook: one tool call on stdin, native refusal on stdout
|
|
72
73
|
attesso setup [--write] [--enforce] [--dry-run]
|
|
73
74
|
detect a harness, wire the hook, write the agent block
|
|
74
|
-
attesso connect [--write] [--mode observe|enforce|off] [--json]
|
|
75
|
+
attesso connect [--write] [--mode observe|enforce|off] [--harness KEY] [--json]
|
|
75
76
|
self-service: init, mode, hook and AGENTS.md
|
|
76
77
|
attesso status [--json] connection, mode and wired harnesses
|
|
77
78
|
attesso mode [observe|enforce|off] read or set the active hook mode
|
|
78
79
|
attesso paths [--json|--set FILE] read or replace the filesystem scope
|
|
79
80
|
attesso ui [--port N] [--no-open] local control surface for the signed ledger
|
|
81
|
+
(default 127.0.0.1:8197)
|
|
82
|
+
attesso policy pull [--url URL] [--apply] [--json]
|
|
83
|
+
verify the hosted rule bundle and optionally
|
|
84
|
+
write rules.json (key from ATTESSO_API_KEY)
|
|
80
85
|
attesso sync [--url URL] [--limit N] [--json] [--dry-run] [--watch]
|
|
81
86
|
send the next decisions to the hosted mirror
|
|
82
87
|
--watch keeps it current every 5 seconds
|
|
@@ -625,6 +630,34 @@ switch (command) {
|
|
|
625
630
|
}
|
|
626
631
|
break;
|
|
627
632
|
}
|
|
633
|
+
case 'policy': {
|
|
634
|
+
try {
|
|
635
|
+
const options = flags(rest);
|
|
636
|
+
const subcommand = rest.find((value) => !value.startsWith('--'));
|
|
637
|
+
if (subcommand !== 'pull') throw new Error('policy takes pull');
|
|
638
|
+
requireLedger();
|
|
639
|
+
const url = typeof options.url === 'string' ? options.url : process.env.ATTESSO_SYNC_URL;
|
|
640
|
+
const key = typeof options.key === 'string' ? options.key : process.env.ATTESSO_API_KEY;
|
|
641
|
+
const result = await pullPolicy({
|
|
642
|
+
dir: DIR,
|
|
643
|
+
url,
|
|
644
|
+
key,
|
|
645
|
+
apply: rest.includes('--apply'),
|
|
646
|
+
});
|
|
647
|
+
if (options.json) {
|
|
648
|
+
console.log(JSON.stringify(result, null, 2));
|
|
649
|
+
} else {
|
|
650
|
+
console.log(`policy: v${result.version} verified with ${result.kid}`);
|
|
651
|
+
console.log(`digest: ${result.digest}`);
|
|
652
|
+
if (result.applied) console.log(`rules: ${result.changed ? 'written' : 'already current'} (${paths(DIR).rules})`);
|
|
653
|
+
else console.log('preview only; re-run with --apply to write rules.json');
|
|
654
|
+
}
|
|
655
|
+
} catch (error) {
|
|
656
|
+
console.error(`policy: ${error.message}`);
|
|
657
|
+
process.exit(2);
|
|
658
|
+
}
|
|
659
|
+
break;
|
|
660
|
+
}
|
|
628
661
|
case 'sync': {
|
|
629
662
|
try {
|
|
630
663
|
const options = flags(rest);
|
|
@@ -665,11 +698,12 @@ switch (command) {
|
|
|
665
698
|
case 'ui': {
|
|
666
699
|
try {
|
|
667
700
|
const options = flags(rest);
|
|
668
|
-
const requested = options.port === undefined ?
|
|
701
|
+
const requested = options.port === undefined ? 8197 : Number(options.port);
|
|
669
702
|
if (!Number.isInteger(requested) || requested < 0 || requested > 65535) {
|
|
670
703
|
console.error('ui: --port must be an integer from 0 to 65535');
|
|
671
704
|
process.exit(2);
|
|
672
705
|
}
|
|
706
|
+
requireLedger();
|
|
673
707
|
const ui = await startUi({
|
|
674
708
|
dir: DIR,
|
|
675
709
|
cwd: process.cwd(),
|
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "attesso",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
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": "
|
|
9
|
-
"attesso-mcp": "
|
|
10
|
-
"attesso-control-mcp": "
|
|
8
|
+
"attesso": "bin/attesso.mjs",
|
|
9
|
+
"attesso-mcp": "bin/attesso-mcp.mjs",
|
|
10
|
+
"attesso-control-mcp": "bin/attesso-control-mcp.mjs"
|
|
11
11
|
},
|
|
12
12
|
"files": [
|
|
13
13
|
"bin",
|
package/src/connect.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// harness hook, and report a machine-readable result on stdout.
|
|
4
4
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
5
5
|
import { homedir } from 'node:os';
|
|
6
|
-
import { dirname, join } from 'node:path';
|
|
6
|
+
import { dirname, join, resolve } from 'node:path';
|
|
7
7
|
import { detectHarnesses, writeAgentContext } from './setup.mjs';
|
|
8
8
|
import { wireHarness } from './install.mjs';
|
|
9
9
|
import { init, isInitialised, paths } from './store.mjs';
|
|
@@ -57,36 +57,43 @@ function legacyModeDirs(home) {
|
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
function
|
|
61
|
-
|
|
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) {
|
|
60
|
+
function wiredHarnesses(harnesses, { dir, cwd }) {
|
|
61
|
+
const ledger = resolve(dir);
|
|
71
62
|
return harnesses.map((harness) => {
|
|
72
63
|
const candidate = harness.key === 'cline' ? join(harness.path, 'PreToolUse') : harness.path;
|
|
73
|
-
|
|
64
|
+
let wired = false;
|
|
65
|
+
if (existsSync(candidate)) {
|
|
66
|
+
try {
|
|
67
|
+
const text = readFileSync(candidate, 'utf8');
|
|
68
|
+
// A hook for a different ledger is not this project's connection. The
|
|
69
|
+
// legacy wrapper has no --dir, so it keeps counting as wired.
|
|
70
|
+
const match = text.match(/--dir\s+(?:'([^']+)'|"([^"]+)"|(\S+))/);
|
|
71
|
+
const target = match ? match[1] || match[2] || match[3] : null;
|
|
72
|
+
wired = target ? resolve(cwd, target) === ledger : /attesso[^\n"]*hook/.test(text);
|
|
73
|
+
} catch {
|
|
74
|
+
wired = false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return { ...harness, wired };
|
|
74
78
|
});
|
|
75
79
|
}
|
|
76
80
|
|
|
77
|
-
function instructions({ mode, wired, manual }) {
|
|
81
|
+
function instructions({ initialized, mode, wired, manual }) {
|
|
82
|
+
if (!initialized) return ['run `attesso init` first'];
|
|
78
83
|
const next = [];
|
|
79
84
|
if (!mode) next.push('run `attesso connect --write --mode observe` from the agent');
|
|
80
85
|
else if (mode === 'off') next.push('run `attesso mode observe` to start recording');
|
|
81
86
|
if (manual.length) next.push('wire the listed harnesses by hand, or use the generic hook contract');
|
|
82
|
-
if (mode && !wired.length
|
|
87
|
+
if (mode && !wired.length && !manual.length) {
|
|
88
|
+
next.push('no agent harness detected; wire the generic hook contract or run `attesso connect` from an agent project');
|
|
89
|
+
}
|
|
83
90
|
return next;
|
|
84
91
|
}
|
|
85
92
|
|
|
86
93
|
export function status({ dir, cwd = process.cwd(), home = homedir() } = {}) {
|
|
87
94
|
const initialized = isInitialised(dir);
|
|
88
95
|
const mode = readMode(dir);
|
|
89
|
-
const detected = wiredHarnesses(detectHarnesses({ home, cwd }));
|
|
96
|
+
const detected = wiredHarnesses(detectHarnesses({ home, cwd }), { dir, cwd });
|
|
90
97
|
const wired = detected.filter((harness) => harness.wired);
|
|
91
98
|
const manual = detected.filter((harness) => !['hermes', 'claude-code', 'codex', 'gemini', 'cline'].includes(harness.key));
|
|
92
99
|
return {
|
|
@@ -97,7 +104,7 @@ export function status({ dir, cwd = process.cwd(), home = homedir() } = {}) {
|
|
|
97
104
|
wired: wired.map((harness) => harness.key),
|
|
98
105
|
manual: manual.map((harness) => harness.key),
|
|
99
106
|
connected: initialized && mode !== null && mode !== 'off' && wired.length > 0,
|
|
100
|
-
next: instructions({ mode, wired, manual }),
|
|
107
|
+
next: instructions({ initialized, mode, wired, manual }),
|
|
101
108
|
};
|
|
102
109
|
}
|
|
103
110
|
|
|
@@ -113,8 +120,12 @@ export function connect({
|
|
|
113
120
|
if (!CONNECT_MODES.includes(mode)) {
|
|
114
121
|
throw new Error(`invalid mode '${mode}'; expected ${CONNECT_MODES.join(', ')}`);
|
|
115
122
|
}
|
|
116
|
-
|
|
117
|
-
|
|
123
|
+
// Harness configs are global. A relative --dir becomes a hook that points at
|
|
124
|
+
// whatever .attesso happens to be in the agent's current directory, so the
|
|
125
|
+
// ledger path must be absolute before it is written anywhere.
|
|
126
|
+
const ledgerDir = resolve(dir);
|
|
127
|
+
const initialized = isInitialised(ledgerDir);
|
|
128
|
+
if (write && !initialized) init(ledgerDir);
|
|
118
129
|
const detected = detectHarnesses({ home, cwd }).filter((harness) => !only || harness.key === only);
|
|
119
130
|
const agentContext = writeAgentContext(cwd, { dryRun: !write });
|
|
120
131
|
const results = [];
|
|
@@ -130,7 +141,7 @@ export function connect({
|
|
|
130
141
|
continue;
|
|
131
142
|
}
|
|
132
143
|
try {
|
|
133
|
-
const result = wireHarness(harness.key, cli,
|
|
144
|
+
const result = wireHarness(harness.key, cli, ledgerDir, {
|
|
134
145
|
dryRun: !write,
|
|
135
146
|
enforce: mode === 'enforce',
|
|
136
147
|
modeFile: true,
|
|
@@ -143,18 +154,18 @@ export function connect({
|
|
|
143
154
|
results.push({ key: harness.key, path: harness.path, wired: false, error: error.message });
|
|
144
155
|
}
|
|
145
156
|
}
|
|
146
|
-
if (write) setModeAll(
|
|
157
|
+
if (write) setModeAll(ledgerDir, mode, { home });
|
|
147
158
|
const wired = results.filter((result) => result.applied || result.reason === 'already wired');
|
|
148
|
-
const effectiveMode = write ? mode : readMode(
|
|
159
|
+
const effectiveMode = write ? mode : readMode(ledgerDir);
|
|
149
160
|
return {
|
|
150
|
-
initialized: isInitialised(
|
|
161
|
+
initialized: isInitialised(ledgerDir),
|
|
151
162
|
mode: effectiveMode,
|
|
152
|
-
connected: isInitialised(
|
|
153
|
-
ledger: paths(
|
|
163
|
+
connected: isInitialised(ledgerDir) && effectiveMode !== null && effectiveMode !== 'off' && wired.length > 0,
|
|
164
|
+
ledger: paths(ledgerDir).dir,
|
|
154
165
|
harnesses: results,
|
|
155
166
|
wired: wired.map((result) => result.key),
|
|
156
167
|
manual: results.filter((result) => result.manual || result.error || result.reason?.includes('merge by hand')).map((result) => result.key),
|
|
157
168
|
agent_context: agentContext,
|
|
158
|
-
next: instructions({ mode: effectiveMode, wired, manual: results.filter((result) => result.manual || result.error) }),
|
|
169
|
+
next: instructions({ initialized: isInitialised(ledgerDir), mode: effectiveMode, wired, manual: results.filter((result) => result.manual || result.error) }),
|
|
159
170
|
};
|
|
160
171
|
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// Hosted policy distribution: fetch a signed rule bundle, verify it against the
|
|
2
|
+
// published Attesso JWKS, and only then let the local engine write rules.json.
|
|
3
|
+
import { createHash, createPublicKey, verify as verifySignature } from 'node:crypto';
|
|
4
|
+
import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { paths } from './store.mjs';
|
|
6
|
+
import { validateRules } from './rules.mjs';
|
|
7
|
+
|
|
8
|
+
export const POLICY_FORMAT = 'attesso.bounds.policy.v1';
|
|
9
|
+
|
|
10
|
+
const fromB64url = (value) => {
|
|
11
|
+
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
|
|
12
|
+
return Buffer.from(normalized + '='.repeat((4 - (normalized.length % 4)) % 4), 'base64');
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function policyEndpoint(base) {
|
|
16
|
+
const parsed = new URL(base);
|
|
17
|
+
if (parsed.protocol !== 'https:' && parsed.hostname !== '127.0.0.1' && parsed.hostname !== 'localhost') {
|
|
18
|
+
throw new Error('policy url must use HTTPS, except on localhost');
|
|
19
|
+
}
|
|
20
|
+
const path = parsed.pathname.replace(/\/+$/, '').replace(/\/v1\/bounds\/(sync|policy)$/, '');
|
|
21
|
+
parsed.pathname = `${path}/v1/bounds/policy`;
|
|
22
|
+
parsed.search = '';
|
|
23
|
+
parsed.hash = '';
|
|
24
|
+
return parsed.toString();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function jwksEndpoint(base) {
|
|
28
|
+
return new URL('/.well-known/jwks.json', new URL(base)).toString();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function pullPolicy({
|
|
32
|
+
dir,
|
|
33
|
+
url,
|
|
34
|
+
key,
|
|
35
|
+
apply = false,
|
|
36
|
+
fetchImpl = fetch,
|
|
37
|
+
} = {}) {
|
|
38
|
+
if (!url) throw new Error('policy url is required');
|
|
39
|
+
if (!key) throw new Error('policy API key is required');
|
|
40
|
+
const response = await fetchImpl(policyEndpoint(url), {
|
|
41
|
+
headers: { Authorization: `Bearer ${key}` },
|
|
42
|
+
signal: AbortSignal.timeout(20_000),
|
|
43
|
+
});
|
|
44
|
+
if (response.status === 404) throw new Error('no hosted policy is published');
|
|
45
|
+
if (!response.ok) throw new Error(`policy fetch failed with status ${response.status}`);
|
|
46
|
+
let bundle;
|
|
47
|
+
try {
|
|
48
|
+
bundle = await response.json();
|
|
49
|
+
} catch {
|
|
50
|
+
throw new Error('policy response is invalid');
|
|
51
|
+
}
|
|
52
|
+
if (!bundle || typeof bundle !== 'object' || !bundle.envelope || typeof bundle.envelope !== 'object') {
|
|
53
|
+
throw new Error('policy response is missing its signature');
|
|
54
|
+
}
|
|
55
|
+
if (bundle.envelope.format !== POLICY_FORMAT) {
|
|
56
|
+
throw new Error('policy response has an unexpected format');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const jwksResponse = await fetchImpl(jwksEndpoint(url), { signal: AbortSignal.timeout(20_000) });
|
|
60
|
+
if (!jwksResponse.ok) throw new Error(`policy key fetch failed with status ${jwksResponse.status}`);
|
|
61
|
+
let jwks;
|
|
62
|
+
try {
|
|
63
|
+
jwks = await jwksResponse.json();
|
|
64
|
+
} catch {
|
|
65
|
+
throw new Error('policy key response is invalid');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const verified = verifyPolicyEnvelope(bundle.envelope, jwks);
|
|
69
|
+
const changed = !existsSync(paths(dir).rules) ||
|
|
70
|
+
JSON.stringify(JSON.parse(readFileSync(paths(dir).rules, 'utf8'))) !== JSON.stringify(verified.rules);
|
|
71
|
+
if (apply) writeRules(dir, verified.rules);
|
|
72
|
+
return {
|
|
73
|
+
version: verified.version,
|
|
74
|
+
kid: verified.kid,
|
|
75
|
+
digest: verified.digest,
|
|
76
|
+
applied: apply,
|
|
77
|
+
changed,
|
|
78
|
+
rules: verified.rules,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function verifyPolicyEnvelope(envelope, jwks) {
|
|
83
|
+
if (!envelope || typeof envelope !== 'object') throw new Error('policy envelope is invalid');
|
|
84
|
+
if (envelope.format !== POLICY_FORMAT) throw new Error('policy envelope has an unexpected format');
|
|
85
|
+
for (const field of ['protected', 'payload', 'signature']) {
|
|
86
|
+
if (typeof envelope[field] !== 'string' || envelope[field] === '') {
|
|
87
|
+
throw new Error('policy envelope is incomplete');
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
let header;
|
|
91
|
+
try {
|
|
92
|
+
header = JSON.parse(fromB64url(envelope.protected).toString('utf8'));
|
|
93
|
+
} catch {
|
|
94
|
+
throw new Error('policy envelope header is invalid');
|
|
95
|
+
}
|
|
96
|
+
if (header.alg !== 'ES256' || typeof header.kid !== 'string') {
|
|
97
|
+
throw new Error('policy envelope must use an ES256 key');
|
|
98
|
+
}
|
|
99
|
+
const key = Array.isArray(jwks?.keys) ? jwks.keys.find((item) => item.kid === header.kid) : undefined;
|
|
100
|
+
if (!key || key.kty !== 'EC' || key.crv !== 'P-256' || key.alg !== 'ES256') {
|
|
101
|
+
throw new Error(`no ES256 verification key for ${header.kid}`);
|
|
102
|
+
}
|
|
103
|
+
let publicKey;
|
|
104
|
+
try {
|
|
105
|
+
publicKey = createPublicKey({ key: { kty: key.kty, crv: key.crv, x: key.x, y: key.y }, format: 'jwk' });
|
|
106
|
+
} catch {
|
|
107
|
+
throw new Error(`verification key ${header.kid} is invalid`);
|
|
108
|
+
}
|
|
109
|
+
const valid = verifySignature(
|
|
110
|
+
'sha256',
|
|
111
|
+
Buffer.from(`${envelope.protected}.${envelope.payload}`, 'utf8'),
|
|
112
|
+
{ key: publicKey, dsaEncoding: 'ieee-p1363' },
|
|
113
|
+
fromB64url(envelope.signature),
|
|
114
|
+
);
|
|
115
|
+
if (!valid) throw new Error('policy signature did not verify');
|
|
116
|
+
|
|
117
|
+
let payload;
|
|
118
|
+
try {
|
|
119
|
+
payload = JSON.parse(fromB64url(envelope.payload).toString('utf8'));
|
|
120
|
+
} catch {
|
|
121
|
+
throw new Error('policy payload is invalid');
|
|
122
|
+
}
|
|
123
|
+
if (payload.schema !== POLICY_FORMAT ||
|
|
124
|
+
!Number.isSafeInteger(payload.version) || payload.version < 1 ||
|
|
125
|
+
typeof payload.rules !== 'object' || payload.rules === null) {
|
|
126
|
+
throw new Error('policy payload is invalid');
|
|
127
|
+
}
|
|
128
|
+
const rules = validateRules(payload.rules);
|
|
129
|
+
return {
|
|
130
|
+
version: payload.version,
|
|
131
|
+
rules,
|
|
132
|
+
kid: header.kid,
|
|
133
|
+
digest: `sha256:${createHash('sha256').update(envelope.payload).digest('hex')}`,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function writeRules(dir, rules) {
|
|
138
|
+
const file = paths(dir).rules;
|
|
139
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
140
|
+
writeFileSync(temporary, `${JSON.stringify(rules, null, 2)}\n`, { mode: 0o600 });
|
|
141
|
+
renameSync(temporary, file);
|
|
142
|
+
}
|
package/src/setup.mjs
CHANGED
|
@@ -10,10 +10,12 @@ import { join, resolve } from 'node:path';
|
|
|
10
10
|
import { argsHashOf } from './rules.mjs';
|
|
11
11
|
|
|
12
12
|
const AGENT_BLOCK = `<!-- attesso:start -->
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
13
|
+
Bounds evaluates your tool calls locally and records every decision. In observe mode
|
|
14
|
+
a refusal is recorded but not blocked; in enforce mode it blocks. When a call is
|
|
15
|
+
refused you get a tool error naming the rule and the step that tainted the task.
|
|
16
|
+
Do not retry a refused call unchanged: read \`attesso explain <seq>\`, take another
|
|
17
|
+
path, or ask the user whether the rule is wrong. To see rules the ledger supports,
|
|
18
|
+
run \`attesso suggest\`.
|
|
17
19
|
<!-- attesso:end -->`;
|
|
18
20
|
|
|
19
21
|
function stripAgentBlock(text) {
|
package/src/ui/static/index.html
CHANGED
|
@@ -69,6 +69,7 @@
|
|
|
69
69
|
.badge{border:1px solid #ead6b3;background:#fbf5e8;color:#7a4b12;padding:2px 6px;font-size:10px}
|
|
70
70
|
.actions{display:flex;gap:7px;align-items:center}
|
|
71
71
|
.button{border:1px solid #d0d5dd;background:#fff;color:var(--body);padding:7px 11px;font-size:12px;cursor:pointer}
|
|
72
|
+
.button:disabled{opacity:.4;cursor:default}
|
|
72
73
|
.button.primary{border-color:var(--ink);background:var(--ink);color:#fff}
|
|
73
74
|
.button:hover{background:#f2f4f7}
|
|
74
75
|
.page{max-width:1440px;padding:24px 30px 48px}
|
|
@@ -303,7 +304,7 @@
|
|
|
303
304
|
<div class="topbar">
|
|
304
305
|
<div class="crumbs"><span>Bounds</span><span>/</span><span class="current">local</span><span class="badge">ledger</span></div>
|
|
305
306
|
<div class="actions">
|
|
306
|
-
<button class="button" type="button" id="export-draft">Export draft</button>
|
|
307
|
+
<button class="button" type="button" id="export-draft" title="Set a rule on a block first" disabled>Export draft</button>
|
|
307
308
|
<button class="button" type="button" id="all-calls">All calls</button>
|
|
308
309
|
</div>
|
|
309
310
|
</div>
|
|
@@ -686,7 +687,13 @@
|
|
|
686
687
|
el('agent-sub').textContent = 'Hook active' + seq;
|
|
687
688
|
el('agent-action').textContent = 'Manage agent';
|
|
688
689
|
} else {
|
|
689
|
-
|
|
690
|
+
const wired = Array.isArray(agentData.wired) ? agentData.wired.length : 0;
|
|
691
|
+
const recorded = days.reduce((total, day) => total + summarize(day).calls, 0);
|
|
692
|
+
el('agent-sub').textContent = wired
|
|
693
|
+
? 'Hook is ' + (agentData.mode || 'not configured') + ' - connect any agent to start observing'
|
|
694
|
+
: recorded > 0
|
|
695
|
+
? fmt(recorded) + ' recorded calls; no harness wired'
|
|
696
|
+
: 'No harness wired - connect an agent to start observing';
|
|
690
697
|
el('agent-action').textContent = 'Connect agent';
|
|
691
698
|
}
|
|
692
699
|
renderAgentPanel();
|
|
@@ -903,7 +910,11 @@
|
|
|
903
910
|
renderAgentStatus();
|
|
904
911
|
if (body.status?.paths) pathState = body.status.paths;
|
|
905
912
|
renderPaths();
|
|
906
|
-
status.textContent = agentData.connected
|
|
913
|
+
status.textContent = agentData.connected
|
|
914
|
+
? 'Agent connected.'
|
|
915
|
+
: mode === 'off'
|
|
916
|
+
? 'Bounds is off; choose Observe and connect again.'
|
|
917
|
+
: 'No agent harness detected; copy the setup command below.';
|
|
907
918
|
} catch (error) {
|
|
908
919
|
status.textContent = 'Connect failed: ' + error.message;
|
|
909
920
|
}
|
|
@@ -943,6 +954,7 @@
|
|
|
943
954
|
function renderFlow(day, preserveCenter = null) {
|
|
944
955
|
if (v1) return renderV1Flow(day, preserveCenter);
|
|
945
956
|
const summary = summarize(day);
|
|
957
|
+
const empty = summary.calls === 0;
|
|
946
958
|
const width = flowWidth();
|
|
947
959
|
const inner = width - GUTTER;
|
|
948
960
|
el('flow-track').style.width = width + 'px';
|
|
@@ -953,13 +965,15 @@
|
|
|
953
965
|
el('flow-count').textContent = fmt(summary.calls) + ' calls';
|
|
954
966
|
const hours = el('flow-hours');
|
|
955
967
|
hours.replaceChildren();
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
968
|
+
if (!empty) {
|
|
969
|
+
for (let hour = 0; hour <= 24; hour += 1) {
|
|
970
|
+
const line = document.createElement('div');
|
|
971
|
+
line.className = 'flow-hour' + (hour % 3 === 0 ? ' major' : '');
|
|
972
|
+
line.style.left = (GUTTER + (hour / 24) * inner) + 'px';
|
|
973
|
+
const labelEvery = zoom >= 2 ? 1 : 3;
|
|
974
|
+
if (hour % labelEvery === 0) line.textContent = String(hour).padStart(2, '0') + ':00';
|
|
975
|
+
hours.append(line);
|
|
976
|
+
}
|
|
963
977
|
}
|
|
964
978
|
const lanes = el('flow-lanes');
|
|
965
979
|
lanes.replaceChildren();
|
|
@@ -1008,18 +1022,20 @@
|
|
|
1008
1022
|
for (const row of rows) markerBySeq.set(String(row[0]), marker);
|
|
1009
1023
|
fragments.get(cap).append(marker);
|
|
1010
1024
|
}
|
|
1011
|
-
|
|
1012
|
-
const
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1025
|
+
if (!empty) {
|
|
1026
|
+
for (const cap of CAPS) {
|
|
1027
|
+
const lane = document.createElement('div');
|
|
1028
|
+
lane.className = 'f-lane';
|
|
1029
|
+
lane.dataset.capability = cap;
|
|
1030
|
+
const label = document.createElement('div');
|
|
1031
|
+
label.className = 'f-label';
|
|
1032
|
+
label.textContent = cap.toUpperCase();
|
|
1033
|
+
const track = document.createElement('div');
|
|
1034
|
+
track.className = 'f-track';
|
|
1035
|
+
track.append(fragments.get(cap));
|
|
1036
|
+
lane.append(label, track);
|
|
1037
|
+
lanes.append(lane);
|
|
1038
|
+
}
|
|
1023
1039
|
}
|
|
1024
1040
|
const scroller = el('flow-scroller');
|
|
1025
1041
|
if (preserveCenter == null) {
|
|
@@ -1032,10 +1048,12 @@
|
|
|
1032
1048
|
closePopover();
|
|
1033
1049
|
applyFlowFilter();
|
|
1034
1050
|
renderTokens(day);
|
|
1051
|
+
updateExportDraft();
|
|
1035
1052
|
}
|
|
1036
1053
|
|
|
1037
1054
|
function renderV1Flow(day, preserveCenter = null) {
|
|
1038
1055
|
const summary = summarize(day);
|
|
1056
|
+
const empty = summary.calls === 0;
|
|
1039
1057
|
const buckets = flowCache.get(day)?.buckets || [];
|
|
1040
1058
|
const width = flowWidth();
|
|
1041
1059
|
const inner = width - GUTTER;
|
|
@@ -1050,13 +1068,15 @@
|
|
|
1050
1068
|
el('flow-count').textContent = fmt(summary.calls) + ' calls';
|
|
1051
1069
|
const hours = el('flow-hours');
|
|
1052
1070
|
hours.replaceChildren();
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1071
|
+
if (!empty) {
|
|
1072
|
+
for (let hour = 0; hour <= 24; hour += 1) {
|
|
1073
|
+
const line = document.createElement('div');
|
|
1074
|
+
line.className = 'flow-hour' + (hour % 3 === 0 ? ' major' : '');
|
|
1075
|
+
line.style.left = (GUTTER + (hour / 24) * inner) + 'px';
|
|
1076
|
+
const labelEvery = zoom >= 2 ? 1 : 3;
|
|
1077
|
+
if (hour % labelEvery === 0) line.textContent = String(hour).padStart(2, '0') + ':00';
|
|
1078
|
+
hours.append(line);
|
|
1079
|
+
}
|
|
1060
1080
|
}
|
|
1061
1081
|
const lanes = el('flow-lanes');
|
|
1062
1082
|
lanes.replaceChildren();
|
|
@@ -1092,18 +1112,20 @@
|
|
|
1092
1112
|
}
|
|
1093
1113
|
fragments.get(cap).append(marker);
|
|
1094
1114
|
}
|
|
1095
|
-
|
|
1096
|
-
const
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1115
|
+
if (!empty) {
|
|
1116
|
+
for (const cap of CAPS) {
|
|
1117
|
+
const lane = document.createElement('div');
|
|
1118
|
+
lane.className = 'f-lane';
|
|
1119
|
+
lane.dataset.capability = cap;
|
|
1120
|
+
const label = document.createElement('div');
|
|
1121
|
+
label.className = 'f-label';
|
|
1122
|
+
label.textContent = cap.toUpperCase();
|
|
1123
|
+
const track = document.createElement('div');
|
|
1124
|
+
track.className = 'f-track';
|
|
1125
|
+
track.append(fragments.get(cap));
|
|
1126
|
+
lane.append(label, track);
|
|
1127
|
+
lanes.append(lane);
|
|
1128
|
+
}
|
|
1107
1129
|
}
|
|
1108
1130
|
const scroller = el('flow-scroller');
|
|
1109
1131
|
if (preserveCenter == null) {
|
|
@@ -1115,6 +1137,7 @@
|
|
|
1115
1137
|
closePopover();
|
|
1116
1138
|
applyFlowFilter();
|
|
1117
1139
|
renderTokens(day);
|
|
1140
|
+
updateExportDraft();
|
|
1118
1141
|
}
|
|
1119
1142
|
|
|
1120
1143
|
function applyFlowFilter() {
|
|
@@ -1311,6 +1334,7 @@
|
|
|
1311
1334
|
if (allOn) drafts.delete(key); else drafts.set(key, state);
|
|
1312
1335
|
}
|
|
1313
1336
|
updateMarkerDraft(marker);
|
|
1337
|
+
updateExportDraft();
|
|
1314
1338
|
const popover = el('flow-popover');
|
|
1315
1339
|
if (popover && !popover.hidden) {
|
|
1316
1340
|
const draft = popover.querySelector('#fp-block-draft');
|
|
@@ -1330,6 +1354,7 @@
|
|
|
1330
1354
|
if (!row) continue;
|
|
1331
1355
|
if (next === 'unset') drafts.delete(String(row[0])); else drafts.set(String(row[0]), next);
|
|
1332
1356
|
}
|
|
1357
|
+
updateExportDraft();
|
|
1333
1358
|
const marker = selectedMarker || markerBySeq.get(key);
|
|
1334
1359
|
if (marker) {
|
|
1335
1360
|
marker.classList.remove('draft-allow', 'draft-deny', 'draft-ask');
|
|
@@ -1365,6 +1390,10 @@
|
|
|
1365
1390
|
async function renderDay(day) {
|
|
1366
1391
|
if (!calls[day] && !v1) return;
|
|
1367
1392
|
const request = ++dayRequest;
|
|
1393
|
+
if (selectedDay !== day && drafts.size > 0) {
|
|
1394
|
+
drafts.clear();
|
|
1395
|
+
updateExportDraft();
|
|
1396
|
+
}
|
|
1368
1397
|
selectedDay = day;
|
|
1369
1398
|
callsDay = day;
|
|
1370
1399
|
callsPage = 0;
|
|
@@ -1397,7 +1426,15 @@
|
|
|
1397
1426
|
renderFlow(selectedDay, centerMinute);
|
|
1398
1427
|
}
|
|
1399
1428
|
|
|
1429
|
+
function updateExportDraft() {
|
|
1430
|
+
const button = el('export-draft');
|
|
1431
|
+
if (!button) return;
|
|
1432
|
+
button.disabled = drafts.size === 0;
|
|
1433
|
+
button.title = drafts.size === 0 ? 'Set a rule on a block first' : '';
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1400
1436
|
function exportDraft() {
|
|
1437
|
+
if (drafts.size === 0) return;
|
|
1401
1438
|
const output = {
|
|
1402
1439
|
generated_at: new Date().toISOString(),
|
|
1403
1440
|
day: selectedDay,
|