@occasiolabs/occasio 0.8.5 → 0.8.6
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 +4 -3
- package/package.json +2 -1
- package/src/cli/conversation.js +121 -0
- package/src/cli/help.js +5 -2
- package/src/cli/recap.js +367 -0
- package/src/demo/audit-demo.js +330 -0
- package/src/index.js +105 -15
package/README.md
CHANGED
|
@@ -9,12 +9,13 @@ Occasio sits between the agent and the cloud on your own machine, decides every
|
|
|
9
9
|
```bash
|
|
10
10
|
npm install -g @occasiolabs/occasio
|
|
11
11
|
|
|
12
|
-
occasio demo
|
|
13
|
-
occasio demo
|
|
12
|
+
occasio demo audit # Auditor scenario: signed attestation + cross-verifier proof (10s, no API key)
|
|
13
|
+
occasio demo attest # End-to-end attestation pipeline against a synthetic chain
|
|
14
|
+
occasio demo anomalies # Live EDR detection on a synthetic adversarial chain
|
|
14
15
|
occasio harness # Real Claude Code attacking a denied path — defense holds
|
|
15
16
|
```
|
|
16
17
|
|
|
17
|
-
The first
|
|
18
|
+
The first three demos run against synthetic data so you can see the full pipeline in seconds with no external dependencies. The fourth spawns a real Claude Code subordinate under your Anthropic login (bundled auth — no API key required) and proves the defense end-to-end. Start with `demo audit` — it answers the only question that actually matters: *"prove what your AI agent did in CI."*
|
|
18
19
|
|
|
19
20
|
---
|
|
20
21
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@occasiolabs/occasio",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.6",
|
|
4
4
|
"mcpName": "io.github.occasiolabs/occasio",
|
|
5
5
|
"description": "Occasio — cryptographically verifiable behavioral attestation for AI coding agents. Tool-call interception + policy enforcement + tamper-evident audit chain + Sigstore-signed in-toto attestations + windowed EDR detection. Same engine for Claude Code and MCP; Computer-Use scaffold included.",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
"NOTICE"
|
|
16
16
|
],
|
|
17
17
|
"scripts": {
|
|
18
|
+
"prepack": "node -e \"const fs=require('fs'),p=require('path');function rm(d){if(!fs.existsSync(d))return;for(const e of fs.readdirSync(d,{withFileTypes:true})){const f=p.join(d,e.name);if(e.isDirectory())rm(f);else fs.unlinkSync(f);}fs.rmdirSync(d);}rm('docs/__pycache__');console.log('prepack: cleared docs/__pycache__');\"",
|
|
18
19
|
"pretest": "npm run lint:all",
|
|
19
20
|
"test": "node test-interceptor.js && node test-native-handlers.js && node test-audit-chain.js && node test-attest.js && node test-policy-paths.js && node test-anomaly.js",
|
|
20
21
|
"lint": "eslint src/audit src/attest src/core src/executor",
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* conversation.js — read the last conversation snippet from Claude Code's
|
|
5
|
+
* own per-project session files at ~/.claude/projects/<cwd-encoded>/<id>.jsonl
|
|
6
|
+
*
|
|
7
|
+
* Why: pipeline-events.jsonl captures tool calls, but the actual prompts and
|
|
8
|
+
* model replies live in Claude Code's session files. Those already exist on
|
|
9
|
+
* disk — we don't need to add a new capture path in the proxy.
|
|
10
|
+
*
|
|
11
|
+
* Read-only, no I/O outside ~/.claude. Returns null on any miss (no claude
|
|
12
|
+
* dir, no project dir, no session file, malformed JSONL, etc.) — caller
|
|
13
|
+
* degrades gracefully.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
const path = require('path');
|
|
18
|
+
const os = require('os');
|
|
19
|
+
|
|
20
|
+
// Claude Code encodes the cwd by replacing every path separator (':', '\', '/')
|
|
21
|
+
// with a single '-'. So `C:\Users\leona\foo` → `C--Users-leona-foo` because
|
|
22
|
+
// `C` + `:` (-) + `\` (-) + `Users`… yields two dashes between C and Users.
|
|
23
|
+
function encodeCwd(cwd) {
|
|
24
|
+
return cwd.replace(/[:\\/]/g, '-');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function findProjectDir(cwd) {
|
|
28
|
+
const root = path.join(os.homedir(), '.claude', 'projects');
|
|
29
|
+
if (!fs.existsSync(root)) return null;
|
|
30
|
+
const encoded = encodeCwd(cwd);
|
|
31
|
+
const candidate = path.join(root, encoded);
|
|
32
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
33
|
+
// Case-insensitive fallback (Windows drive letters): scan once.
|
|
34
|
+
try {
|
|
35
|
+
const entries = fs.readdirSync(root);
|
|
36
|
+
const hit = entries.find(n => n.toLowerCase() === encoded.toLowerCase());
|
|
37
|
+
return hit ? path.join(root, hit) : null;
|
|
38
|
+
} catch { return null; }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function listSessionFiles(projectDir) {
|
|
42
|
+
if (!projectDir) return [];
|
|
43
|
+
try {
|
|
44
|
+
return fs.readdirSync(projectDir)
|
|
45
|
+
.filter(n => n.endsWith('.jsonl') && /^[0-9a-f-]{36}\.jsonl$/i.test(n))
|
|
46
|
+
.map(n => {
|
|
47
|
+
const full = path.join(projectDir, n);
|
|
48
|
+
let mtime = 0;
|
|
49
|
+
try { mtime = fs.statSync(full).mtimeMs; } catch { /* */ }
|
|
50
|
+
return { name: n, full, mtime };
|
|
51
|
+
})
|
|
52
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
53
|
+
} catch { return []; }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Pull a string preview out of message.content, which Claude Code stores as
|
|
57
|
+
// either a raw string or an array of typed blocks.
|
|
58
|
+
function previewText(content, max = 240) {
|
|
59
|
+
if (!content) return '';
|
|
60
|
+
if (typeof content === 'string') return content.slice(0, max).trim();
|
|
61
|
+
if (!Array.isArray(content)) return '';
|
|
62
|
+
for (const block of content) {
|
|
63
|
+
if (!block) continue;
|
|
64
|
+
if (typeof block === 'string') return block.slice(0, max).trim();
|
|
65
|
+
if (block.type === 'text' && typeof block.text === 'string') {
|
|
66
|
+
return block.text.slice(0, max).trim();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return '';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function loadConversation(sessionFile) {
|
|
73
|
+
let text;
|
|
74
|
+
try { text = fs.readFileSync(sessionFile, 'utf8'); }
|
|
75
|
+
catch { return null; }
|
|
76
|
+
|
|
77
|
+
let firstUser = null, lastUser = null, lastAssistant = null;
|
|
78
|
+
let firstTs = null, lastTs = null;
|
|
79
|
+
|
|
80
|
+
for (const line of text.split('\n')) {
|
|
81
|
+
if (!line) continue;
|
|
82
|
+
let row;
|
|
83
|
+
try { row = JSON.parse(line); } catch { continue; }
|
|
84
|
+
if (row.type !== 'user' && row.type !== 'assistant') continue;
|
|
85
|
+
const msg = row.message;
|
|
86
|
+
if (!msg) continue;
|
|
87
|
+
const preview = previewText(msg.content);
|
|
88
|
+
if (!preview) continue;
|
|
89
|
+
const ts = row.timestamp || null;
|
|
90
|
+
if (ts && !firstTs) firstTs = ts;
|
|
91
|
+
if (ts) lastTs = ts;
|
|
92
|
+
if (row.type === 'user') {
|
|
93
|
+
if (!firstUser) firstUser = preview;
|
|
94
|
+
lastUser = preview;
|
|
95
|
+
} else if (row.type === 'assistant') {
|
|
96
|
+
lastAssistant = preview;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (!firstUser && !lastUser && !lastAssistant) return null;
|
|
101
|
+
return { firstUser, lastUser, lastAssistant, firstTs, lastTs };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Best-effort: find and read the most recent Claude Code conversation for
|
|
106
|
+
* the given cwd. Returns null if none found.
|
|
107
|
+
*/
|
|
108
|
+
function readLastConversation(cwd) {
|
|
109
|
+
const dir = findProjectDir(cwd);
|
|
110
|
+
if (!dir) return null;
|
|
111
|
+
const sessions = listSessionFiles(dir);
|
|
112
|
+
if (!sessions.length) return null;
|
|
113
|
+
// Try the newest first; fall through to older if newest is empty.
|
|
114
|
+
for (const s of sessions.slice(0, 3)) {
|
|
115
|
+
const conv = loadConversation(s.full);
|
|
116
|
+
if (conv) return { ...conv, sessionFile: s.full };
|
|
117
|
+
}
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
module.exports = { readLastConversation, encodeCwd, findProjectDir, listSessionFiles, loadConversation };
|
package/src/cli/help.js
CHANGED
|
@@ -46,6 +46,7 @@ ${col.b('Run')} ${col.d('— start a session, observe live state')}
|
|
|
46
46
|
|
|
47
47
|
${col.b('Inspect')} ${col.d('— forensics over what the agent did')}
|
|
48
48
|
replay ${col.d('(stable)')} Replay run audit (--last N, --detail, --run <id>)
|
|
49
|
+
recap ${col.d('(stable)')} Markdown session summary for agent context (--last N, --run, --format)
|
|
49
50
|
boundary ${col.d('(stable)')} Per-request: produced / re-entered / prevented
|
|
50
51
|
inspect ${col.d('(stable)')} Cloud-boundary manifest (--last N, --entry N)
|
|
51
52
|
distill ${col.d('(stable)')} Inspect distilled outputs (--last N, --entry <N>)
|
|
@@ -73,9 +74,10 @@ ${col.b('Policy & extras')}
|
|
|
73
74
|
policy doctor ${col.d('(beta)')} Cross-reference logs with policy; suggest tightening
|
|
74
75
|
computer-use ${col.d('(alpha)')} Apply policy to a JSONL of tool_use blocks (--dry-run --example)
|
|
75
76
|
mcp-experiment ${col.d('(beta)')} MCP vs. built-in tool adoption stats
|
|
76
|
-
demo
|
|
77
|
+
demo audit ${col.d('(stable)')} Auditor scenario: signed attestation + cross-verifier proof
|
|
77
78
|
demo attest ${col.d('(stable)')} End-to-end attestation pipeline against a synthetic chain
|
|
78
79
|
demo anomalies ${col.d('(stable)')} End-to-end EDR test: synthetic adversarial chain
|
|
80
|
+
demo ${col.d('(stable)')} 10-second proof: see Occasio block real secrets
|
|
79
81
|
|
|
80
82
|
${col.b('Presets:')}
|
|
81
83
|
--preset balanced (default) Intercept safe reads locally, log all requests
|
|
@@ -88,7 +90,8 @@ ${col.b('Flags:')}
|
|
|
88
90
|
--block-secrets Alias for --preset strict
|
|
89
91
|
--log-only Alias for --preset off
|
|
90
92
|
--dashboard Open live dashboard at http://localhost:3001
|
|
91
|
-
--port <N> Proxy port (default:
|
|
93
|
+
--port <N> Proxy port (default: auto-assigned by OS)
|
|
94
|
+
--recap Print a previous-session banner at startup
|
|
92
95
|
--verbose Print live per-request chatter (off by default)
|
|
93
96
|
|
|
94
97
|
${col.b('Multi-agent routing:')}
|
package/src/cli/recap.js
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* occasio recap — markdown summary of a past session, sized to paste into
|
|
5
|
+
* a new Claude prompt so the next agent picks up where the last one left off.
|
|
6
|
+
*
|
|
7
|
+
* Reads from ~/.occasio/pipeline-events.jsonl (read-only). Groups by run_id,
|
|
8
|
+
* picks the most recent session by default. No network, no signing, no policy
|
|
9
|
+
* decisions made — pure read view.
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* occasio recap # last session, markdown
|
|
13
|
+
* occasio recap --last 3 # last 3 sessions
|
|
14
|
+
* occasio recap --run <id> # specific session
|
|
15
|
+
* occasio recap --format text|json # alt outputs
|
|
16
|
+
*
|
|
17
|
+
* The output is shaped to be useful as agent context — short, factual,
|
|
18
|
+
* decisions-first. Not an audit view (use `occasio replay --detail` for that).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const fs = require('fs');
|
|
22
|
+
const path = require('path');
|
|
23
|
+
const os = require('os');
|
|
24
|
+
|
|
25
|
+
const { readLastConversation } = require('./conversation');
|
|
26
|
+
|
|
27
|
+
const CHAIN_FILE = path.join(os.homedir(), '.occasio', 'pipeline-events.jsonl');
|
|
28
|
+
|
|
29
|
+
const col = {
|
|
30
|
+
r: s => `\x1b[31m${s}\x1b[0m`,
|
|
31
|
+
g: s => `\x1b[32m${s}\x1b[0m`,
|
|
32
|
+
y: s => `\x1b[33m${s}\x1b[0m`,
|
|
33
|
+
c: s => `\x1b[36m${s}\x1b[0m`,
|
|
34
|
+
d: s => `\x1b[2m${s}\x1b[0m`,
|
|
35
|
+
b: s => `\x1b[1m${s}\x1b[0m`,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// Normalise tool names: chain has both PascalCase (Claude-Code-style) and
|
|
39
|
+
// snake_case (shell-tool aliases via interceptor). Render as the canonical
|
|
40
|
+
// display name so the recap doesn't double-count Read/read_file.
|
|
41
|
+
const TOOL_DISPLAY = {
|
|
42
|
+
read_file: 'Read',
|
|
43
|
+
read: 'Read',
|
|
44
|
+
Read: 'Read',
|
|
45
|
+
grep: 'Grep',
|
|
46
|
+
Grep: 'Grep',
|
|
47
|
+
glob: 'Glob',
|
|
48
|
+
Glob: 'Glob',
|
|
49
|
+
find_files: 'Glob',
|
|
50
|
+
edit_file: 'Edit',
|
|
51
|
+
Edit: 'Edit',
|
|
52
|
+
MultiEdit: 'Edit',
|
|
53
|
+
write_file: 'Write',
|
|
54
|
+
Write: 'Write',
|
|
55
|
+
bash: 'Bash',
|
|
56
|
+
Bash: 'Bash',
|
|
57
|
+
shell_bash: 'Bash',
|
|
58
|
+
shell_powershell: 'PowerShell',
|
|
59
|
+
TodoWrite: 'TodoWrite',
|
|
60
|
+
TodoRead: 'TodoRead',
|
|
61
|
+
WebFetch: 'WebFetch',
|
|
62
|
+
WebSearch: 'WebSearch',
|
|
63
|
+
};
|
|
64
|
+
const TOOLS_THAT_READ = new Set(['Read', 'Grep', 'Glob']);
|
|
65
|
+
const TOOLS_THAT_MODIFY = new Set(['Edit', 'Write']);
|
|
66
|
+
|
|
67
|
+
function normTool(name) { return TOOL_DISPLAY[name] || name || 'unknown'; }
|
|
68
|
+
|
|
69
|
+
function readChain(file) {
|
|
70
|
+
if (!fs.existsSync(file)) return [];
|
|
71
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
72
|
+
const out = [];
|
|
73
|
+
for (const line of text.split('\n')) {
|
|
74
|
+
if (!line) continue;
|
|
75
|
+
try { out.push(JSON.parse(line)); } catch { /* skip malformed */ }
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function groupByRun(rows) {
|
|
81
|
+
const map = new Map();
|
|
82
|
+
for (const r of rows) {
|
|
83
|
+
const id = r.run_id || 'legacy';
|
|
84
|
+
if (!map.has(id)) map.set(id, []);
|
|
85
|
+
map.get(id).push(r);
|
|
86
|
+
}
|
|
87
|
+
for (const arr of map.values()) {
|
|
88
|
+
arr.sort((a, b) => (a.ts || '') < (b.ts || '') ? -1 : 1);
|
|
89
|
+
}
|
|
90
|
+
return map;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function pickRecentRuns(rowsByRun, n) {
|
|
94
|
+
const entries = [...rowsByRun.entries()].map(([id, rows]) => ({
|
|
95
|
+
id,
|
|
96
|
+
rows,
|
|
97
|
+
end: rows.length ? rows[rows.length - 1].ts || '' : '',
|
|
98
|
+
}));
|
|
99
|
+
entries.sort((a, b) => a.end < b.end ? 1 : -1);
|
|
100
|
+
return entries.slice(0, n);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function pathOf(row) {
|
|
104
|
+
const t = row.tool_inputs || {};
|
|
105
|
+
return t.path || t.file_path || t.pattern || t.command || null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function aggregate(rows) {
|
|
109
|
+
const tools = {};
|
|
110
|
+
const readPaths = new Set();
|
|
111
|
+
const modifyPaths = new Set();
|
|
112
|
+
const blocked = [];
|
|
113
|
+
const transformed = [];
|
|
114
|
+
const toolCalls = [];
|
|
115
|
+
|
|
116
|
+
for (const r of rows) {
|
|
117
|
+
if (r.kind !== 'tool_call') continue;
|
|
118
|
+
const tool = normTool(r.tool_name);
|
|
119
|
+
tools[tool] = (tools[tool] || 0) + 1;
|
|
120
|
+
toolCalls.push(r);
|
|
121
|
+
|
|
122
|
+
const p = pathOf(r);
|
|
123
|
+
if (p && r.action !== 'BLOCK') {
|
|
124
|
+
if (TOOLS_THAT_READ.has(tool)) readPaths.add(p);
|
|
125
|
+
if (TOOLS_THAT_MODIFY.has(tool)) modifyPaths.add(p);
|
|
126
|
+
}
|
|
127
|
+
if (r.action === 'BLOCK') {
|
|
128
|
+
blocked.push({ tool, target: p, reason: r.reason || 'policy' });
|
|
129
|
+
}
|
|
130
|
+
if (r.action === 'TRANSFORM') {
|
|
131
|
+
transformed.push({ tool, target: p, redacted: r.secrets_redacted || 0 });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
tools, readPaths: [...readPaths], modifyPaths: [...modifyPaths],
|
|
137
|
+
blocked, transformed, toolCalls,
|
|
138
|
+
start: rows[0]?.ts || null,
|
|
139
|
+
end: rows[rows.length - 1]?.ts || null,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function shortPath(p, root) {
|
|
144
|
+
if (!p) return '(no path)';
|
|
145
|
+
if (root && p.startsWith(root)) {
|
|
146
|
+
const rel = p.slice(root.length).replace(/^[\\/]+/, '');
|
|
147
|
+
return rel || p;
|
|
148
|
+
}
|
|
149
|
+
return p;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function durationStr(start, end) {
|
|
153
|
+
if (!start || !end) return 'unknown';
|
|
154
|
+
const ms = new Date(end) - new Date(start);
|
|
155
|
+
if (!Number.isFinite(ms) || ms < 0) return 'unknown';
|
|
156
|
+
const min = Math.round(ms / 60000);
|
|
157
|
+
if (min < 1) return '<1 min';
|
|
158
|
+
if (min < 60) return min + ' min';
|
|
159
|
+
return Math.floor(min/60) + 'h ' + (min%60) + 'm';
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function lastActionsLines(toolCalls, root, n = 5) {
|
|
163
|
+
const last = toolCalls.slice(-n);
|
|
164
|
+
return last.map((r, i) => {
|
|
165
|
+
const tool = normTool(r.tool_name);
|
|
166
|
+
const p = shortPath(pathOf(r), root);
|
|
167
|
+
const action = r.action || '';
|
|
168
|
+
const tag = action === 'BLOCK' ? '✗' : action === 'TRANSFORM' ? '⚙' : '·';
|
|
169
|
+
return `${i+1}. ${tag} ${tool} ${p}${action && action !== 'PASS' && action !== 'LOCAL' ? ' → ' + action : ''}`;
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function renderMarkdown(run, root, conv) {
|
|
174
|
+
const a = aggregate(run.rows);
|
|
175
|
+
const dur = durationStr(a.start, a.end);
|
|
176
|
+
const date = a.start ? new Date(a.start).toISOString().replace('T', ' ').slice(0, 16) : 'unknown';
|
|
177
|
+
|
|
178
|
+
const lines = [];
|
|
179
|
+
lines.push(`# Previous session — ${date}, ${dur}`);
|
|
180
|
+
lines.push('');
|
|
181
|
+
|
|
182
|
+
if (conv && (conv.lastUser || conv.lastAssistant)) {
|
|
183
|
+
lines.push('## Last conversation');
|
|
184
|
+
if (conv.lastUser) {
|
|
185
|
+
lines.push(`- **You said:** ${conv.lastUser}`);
|
|
186
|
+
}
|
|
187
|
+
if (conv.lastAssistant) {
|
|
188
|
+
lines.push(`- **Agent replied:** ${conv.lastAssistant}`);
|
|
189
|
+
}
|
|
190
|
+
lines.push('');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (a.readPaths.length || a.modifyPaths.length) {
|
|
194
|
+
lines.push('## Files touched');
|
|
195
|
+
if (a.modifyPaths.length) {
|
|
196
|
+
const shown = a.modifyPaths.slice(0, 10).map(p => shortPath(p, root));
|
|
197
|
+
const more = a.modifyPaths.length > 10 ? ` (+${a.modifyPaths.length - 10} more)` : '';
|
|
198
|
+
lines.push(`- modified: ${shown.join(', ')}${more}`);
|
|
199
|
+
}
|
|
200
|
+
if (a.readPaths.length) {
|
|
201
|
+
const shown = a.readPaths.slice(0, 10).map(p => shortPath(p, root));
|
|
202
|
+
const more = a.readPaths.length > 10 ? ` (+${a.readPaths.length - 10} more)` : '';
|
|
203
|
+
lines.push(`- read: ${shown.join(', ')}${more}`);
|
|
204
|
+
}
|
|
205
|
+
lines.push('');
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const toolEntries = Object.entries(a.tools).sort((x, y) => y[1] - x[1]);
|
|
209
|
+
if (toolEntries.length) {
|
|
210
|
+
lines.push('## Tools used');
|
|
211
|
+
lines.push('- ' + toolEntries.map(([k, v]) => `${v} ${k}`).join(' · '));
|
|
212
|
+
lines.push('');
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (a.blocked.length || a.transformed.length) {
|
|
216
|
+
lines.push('## Decisions');
|
|
217
|
+
if (a.blocked.length) {
|
|
218
|
+
const byTarget = {};
|
|
219
|
+
for (const b of a.blocked) {
|
|
220
|
+
const key = `${b.tool} ${shortPath(b.target, root)}`;
|
|
221
|
+
byTarget[key] = (byTarget[key] || 0) + 1;
|
|
222
|
+
}
|
|
223
|
+
for (const [k, count] of Object.entries(byTarget)) {
|
|
224
|
+
lines.push(`- ${count}× BLOCK ${k}`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (a.transformed.length) {
|
|
228
|
+
const total = a.transformed.reduce((s, t) => s + (t.redacted || 0), 0);
|
|
229
|
+
lines.push(`- ${a.transformed.length}× TRANSFORM (${total} secret${total === 1 ? '' : 's'} redacted)`);
|
|
230
|
+
}
|
|
231
|
+
lines.push('');
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (a.toolCalls.length) {
|
|
235
|
+
lines.push('## Last actions');
|
|
236
|
+
for (const line of lastActionsLines(a.toolCalls, root)) lines.push(line);
|
|
237
|
+
lines.push('');
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Open-thread heuristic: surface last Bash/Edit/Write so the next agent
|
|
241
|
+
// knows the most recent state-changing action.
|
|
242
|
+
const lastBash = [...a.toolCalls].reverse().find(r => normTool(r.tool_name) === 'Bash' || normTool(r.tool_name) === 'PowerShell');
|
|
243
|
+
const lastWrite = [...a.toolCalls].reverse().find(r => TOOLS_THAT_MODIFY.has(normTool(r.tool_name)));
|
|
244
|
+
if (lastBash || lastWrite) {
|
|
245
|
+
lines.push('## Open thread (best-guess from last actions)');
|
|
246
|
+
if (lastWrite) {
|
|
247
|
+
lines.push(`- last write: ${normTool(lastWrite.tool_name)} ${shortPath(pathOf(lastWrite), root)}`);
|
|
248
|
+
}
|
|
249
|
+
if (lastBash) {
|
|
250
|
+
const cmd = (lastBash.tool_inputs && (lastBash.tool_inputs.command || lastBash.tool_inputs.cmd)) || '(no cmd)';
|
|
251
|
+
const exit = (typeof lastBash.exit_code === 'number') ? ` (exit ${lastBash.exit_code})` : '';
|
|
252
|
+
lines.push(`- last shell: ${cmd.slice(0, 80)}${cmd.length > 80 ? '…' : ''}${exit}`);
|
|
253
|
+
}
|
|
254
|
+
lines.push('');
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return lines.join('\n');
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function renderText(run, root, conv) {
|
|
261
|
+
// Plain text variant, no markdown noise — convenient for CLI piping.
|
|
262
|
+
return renderMarkdown(run, root, conv).replace(/^#+\s*/gm, '').replace(/\n{3,}/g, '\n\n');
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function renderJson(run, root, conv) {
|
|
266
|
+
const a = aggregate(run.rows);
|
|
267
|
+
return JSON.stringify({
|
|
268
|
+
run_id: run.id,
|
|
269
|
+
started_at: a.start,
|
|
270
|
+
ended_at: a.end,
|
|
271
|
+
duration: durationStr(a.start, a.end),
|
|
272
|
+
conversation: conv ? {
|
|
273
|
+
last_user: conv.lastUser || null,
|
|
274
|
+
last_assistant: conv.lastAssistant || null,
|
|
275
|
+
} : null,
|
|
276
|
+
files: {
|
|
277
|
+
read: a.readPaths.map(p => shortPath(p, root)),
|
|
278
|
+
modified: a.modifyPaths.map(p => shortPath(p, root)),
|
|
279
|
+
},
|
|
280
|
+
tools: a.tools,
|
|
281
|
+
decisions: { blocked: a.blocked, transformed: a.transformed },
|
|
282
|
+
last_actions: a.toolCalls.slice(-5).map(r => ({
|
|
283
|
+
tool: normTool(r.tool_name), action: r.action,
|
|
284
|
+
path: shortPath(pathOf(r), root), ts: r.ts,
|
|
285
|
+
})),
|
|
286
|
+
}, null, 2);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function parseArgs(argv) {
|
|
290
|
+
const out = { last: 1, run: null, format: 'md' };
|
|
291
|
+
for (let i = 0; i < argv.length; i++) {
|
|
292
|
+
const a = argv[i];
|
|
293
|
+
if (a === '--last') out.last = Math.max(1, parseInt(argv[++i], 10) || 1);
|
|
294
|
+
else if (a === '--run') out.run = argv[++i];
|
|
295
|
+
else if (a === '--format') out.format = argv[++i];
|
|
296
|
+
else if (a === '--help' || a === '-h') out.help = true;
|
|
297
|
+
}
|
|
298
|
+
return out;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function printHelp() {
|
|
302
|
+
process.stdout.write(`
|
|
303
|
+
${col.b('occasio recap')} ${col.d('— session summary for agent context')}
|
|
304
|
+
|
|
305
|
+
Markdown summary of a past Occasio session: files touched, tools used,
|
|
306
|
+
decisions made, last actions. Designed to paste into a new Claude prompt
|
|
307
|
+
so the next session picks up where the last one left off.
|
|
308
|
+
|
|
309
|
+
${col.b('Usage')}
|
|
310
|
+
occasio recap last session (markdown)
|
|
311
|
+
occasio recap --last 3 last 3 sessions
|
|
312
|
+
occasio recap --run <id> specific session
|
|
313
|
+
occasio recap --format text|json alternative output
|
|
314
|
+
|
|
315
|
+
${col.b('Tip')}
|
|
316
|
+
occasio recap | clip ${col.d('(Windows)')}
|
|
317
|
+
occasio recap | pbcopy ${col.d('(macOS)')}
|
|
318
|
+
occasio recap | xclip -sel clip ${col.d('(Linux)')}
|
|
319
|
+
|
|
320
|
+
`);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function run(argv) {
|
|
324
|
+
const opts = parseArgs(argv || []);
|
|
325
|
+
if (opts.help) { printHelp(); return 0; }
|
|
326
|
+
|
|
327
|
+
const rows = readChain(CHAIN_FILE);
|
|
328
|
+
if (!rows.length) {
|
|
329
|
+
process.stderr.write(col.d(`No chain at ${CHAIN_FILE} — run \`occasio claude\` first.\n`));
|
|
330
|
+
return 1;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const byRun = groupByRun(rows);
|
|
334
|
+
let runs;
|
|
335
|
+
if (opts.run) {
|
|
336
|
+
if (!byRun.has(opts.run)) {
|
|
337
|
+
process.stderr.write(col.r(`No session with run_id ${opts.run}\n`));
|
|
338
|
+
return 1;
|
|
339
|
+
}
|
|
340
|
+
runs = [{ id: opts.run, rows: byRun.get(opts.run) }];
|
|
341
|
+
} else {
|
|
342
|
+
runs = pickRecentRuns(byRun, opts.last);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const root = process.cwd();
|
|
346
|
+
// Conversation snippet from Claude Code's own session files for the cwd.
|
|
347
|
+
// Only attach to the most-recent run; older runs in --last N get none
|
|
348
|
+
// (we have no way to map run_id to a specific Claude session reliably).
|
|
349
|
+
const conv = readLastConversation(root);
|
|
350
|
+
const parts = [];
|
|
351
|
+
for (let i = 0; i < runs.length; i++) {
|
|
352
|
+
const r = runs[i];
|
|
353
|
+
const useConv = i === 0 ? conv : null;
|
|
354
|
+
if (opts.format === 'json') {
|
|
355
|
+
parts.push(renderJson(r, root, useConv));
|
|
356
|
+
} else if (opts.format === 'text') {
|
|
357
|
+
parts.push(renderText(r, root, useConv));
|
|
358
|
+
} else {
|
|
359
|
+
parts.push(renderMarkdown(r, root, useConv));
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
|
364
|
+
return 0;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
module.exports = { run, aggregate, renderMarkdown, normTool };
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* demo/audit-demo.js — `occasio demo audit`
|
|
5
|
+
*
|
|
6
|
+
* Hero demo: the auditor scenario. Shows the actual moat — a signed,
|
|
7
|
+
* offline-verifiable attestation of agent behavior that any third
|
|
8
|
+
* party can re-check with two independent verifiers (Node + Python).
|
|
9
|
+
*
|
|
10
|
+
* Flow:
|
|
11
|
+
* 1. Auditor question framed
|
|
12
|
+
* 2. Build richer synthetic chain (~12 rows: PASS + BLOCKs on the
|
|
13
|
+
* asked-about path + TRANSFORM + LOCAL)
|
|
14
|
+
* 3. Verify chain integrity (Node SHA-256 walker)
|
|
15
|
+
* 4. Build unsigned attestation
|
|
16
|
+
* 5. Answer the auditor's question from the chain
|
|
17
|
+
* 6. Re-verify with the independent Python verifier (docs/attest_verify.py)
|
|
18
|
+
* on the same artifact → byte-identical result
|
|
19
|
+
* 7. Show what --sign would add in real CI
|
|
20
|
+
* 8. CTA + spec link
|
|
21
|
+
*
|
|
22
|
+
* No API key, no network, no touching the user's real ~/.occasio chain.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const fs = require('fs');
|
|
26
|
+
const os = require('os');
|
|
27
|
+
const path = require('path');
|
|
28
|
+
const crypto = require('crypto');
|
|
29
|
+
const { spawnSync } = require('child_process');
|
|
30
|
+
|
|
31
|
+
const { buildAttestation } = require('../attest');
|
|
32
|
+
const { verifyFile } = require('../audit/verifier');
|
|
33
|
+
const { canonicalize } = require('../attest/canonicalize');
|
|
34
|
+
|
|
35
|
+
const C = {
|
|
36
|
+
r: s => `\x1b[31m${s}\x1b[0m`,
|
|
37
|
+
g: s => `\x1b[32m${s}\x1b[0m`,
|
|
38
|
+
y: s => `\x1b[33m${s}\x1b[0m`,
|
|
39
|
+
c: s => `\x1b[36m${s}\x1b[0m`,
|
|
40
|
+
d: s => `\x1b[2m${s}\x1b[0m`,
|
|
41
|
+
b: s => `\x1b[1m${s}\x1b[0m`,
|
|
42
|
+
rb: s => `\x1b[31;1m${s}\x1b[0m`,
|
|
43
|
+
gb: s => `\x1b[32;1m${s}\x1b[0m`,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const GENESIS = '0'.repeat(64);
|
|
47
|
+
const DENIED_PATH = '/etc/secrets/db.yml';
|
|
48
|
+
|
|
49
|
+
// Strip the OS temp prefix so demo output is screen-recording-safe
|
|
50
|
+
// (Windows TEMP contains the username; macOS/Linux less so but still
|
|
51
|
+
// machine-specific). Internal use still uses the real path.
|
|
52
|
+
function displayPath(p) {
|
|
53
|
+
const tmp = os.tmpdir();
|
|
54
|
+
if (p && p.startsWith(tmp)) {
|
|
55
|
+
const suffix = p.slice(tmp.length).replace(/^[\\/]+/, '');
|
|
56
|
+
return process.platform === 'win32' ? `%TEMP%\\${suffix}` : `$TMPDIR/${suffix}`;
|
|
57
|
+
}
|
|
58
|
+
return p;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function appendRow(file, prevHash, row) {
|
|
62
|
+
const withPrev = { ...row, prev_hash: prevHash };
|
|
63
|
+
const hash = crypto.createHash('sha256').update(JSON.stringify(withPrev), 'utf8').digest('hex');
|
|
64
|
+
const full = { ...withPrev, hash };
|
|
65
|
+
fs.appendFileSync(file, JSON.stringify(full) + '\n');
|
|
66
|
+
return { hash, full };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function buildAuditScenarioChain(chainFile, policyFile) {
|
|
70
|
+
fs.writeFileSync(chainFile, '');
|
|
71
|
+
fs.writeFileSync(policyFile, [
|
|
72
|
+
'version: 1',
|
|
73
|
+
'deny_paths:',
|
|
74
|
+
' - "/etc/secrets/**"',
|
|
75
|
+
' - "**/.env"',
|
|
76
|
+
' - "**/.ssh/**"',
|
|
77
|
+
'deny_patterns:',
|
|
78
|
+
' api_key: "(?i)api[_-]?key\\\\s*[:=]\\\\s*\\\\S+"',
|
|
79
|
+
'block_secrets_in_tool_results: true',
|
|
80
|
+
'',
|
|
81
|
+
].join('\n'));
|
|
82
|
+
|
|
83
|
+
const RUN = crypto.randomBytes(16).toString('hex');
|
|
84
|
+
const RUN_ID = `${RUN.slice(0,8)}-${RUN.slice(8,12)}-${RUN.slice(12,16)}-${RUN.slice(16,20)}-${RUN.slice(20,32)}`;
|
|
85
|
+
const policyHash = crypto.createHash('sha256').update(fs.readFileSync(policyFile)).digest('hex');
|
|
86
|
+
|
|
87
|
+
let prev = GENESIS;
|
|
88
|
+
const ts = (sec) => new Date(Date.UTC(2026, 4, 16, 9, 30, sec)).toISOString();
|
|
89
|
+
|
|
90
|
+
const blockedHashes = [];
|
|
91
|
+
|
|
92
|
+
// 1. policy_loaded
|
|
93
|
+
({ hash: prev } = appendRow(chainFile, prev, {
|
|
94
|
+
ts: ts(0), event_id: crypto.randomUUID(), run_id: RUN_ID, agent: 'occasio',
|
|
95
|
+
kind: 'policy_loaded', tool_name: 'policy_loaded', action: 'INFO',
|
|
96
|
+
tool_inputs: { policy_hash: policyHash, policy_path: policyFile, version: 1 },
|
|
97
|
+
policy_source: 'user', reason: 'policy-loaded',
|
|
98
|
+
}));
|
|
99
|
+
|
|
100
|
+
// 2-4. Three normal PASS reads of source files
|
|
101
|
+
for (const [i, p] of [[5, 'src/server.js'], [8, 'src/db.js'], [12, 'package.json']]) {
|
|
102
|
+
({ hash: prev } = appendRow(chainFile, prev, {
|
|
103
|
+
ts: ts(i), event_id: crypto.randomUUID(), run_id: RUN_ID, agent: 'claude-code',
|
|
104
|
+
kind: 'tool_call', tool_name: 'Read', action: 'PASS',
|
|
105
|
+
tool_inputs: { path: p },
|
|
106
|
+
}));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// 5. LOCAL Glob discovery
|
|
110
|
+
({ hash: prev } = appendRow(chainFile, prev, {
|
|
111
|
+
ts: ts(15), event_id: crypto.randomUUID(), run_id: RUN_ID, agent: 'claude-code',
|
|
112
|
+
kind: 'tool_call', tool_name: 'Glob', action: 'LOCAL',
|
|
113
|
+
tool_inputs: { pattern: 'src/**/*.js' },
|
|
114
|
+
}));
|
|
115
|
+
|
|
116
|
+
// 6-8. THREE attempts to read the denied path — all BLOCKED
|
|
117
|
+
// (this is what the auditor will ask about)
|
|
118
|
+
for (const [i, attempt] of [
|
|
119
|
+
[18, DENIED_PATH],
|
|
120
|
+
[22, '/etc/secrets/../secrets/db.yml'], // traversal attempt
|
|
121
|
+
[27, DENIED_PATH], // retry
|
|
122
|
+
].entries()) {
|
|
123
|
+
const r = appendRow(chainFile, prev, {
|
|
124
|
+
ts: ts(attempt[0]), event_id: crypto.randomUUID(), run_id: RUN_ID, agent: 'claude-code',
|
|
125
|
+
kind: 'tool_call', tool_name: 'Read', action: 'BLOCK',
|
|
126
|
+
tool_inputs: { path: attempt[1] },
|
|
127
|
+
reason: 'path-denied',
|
|
128
|
+
});
|
|
129
|
+
prev = r.hash;
|
|
130
|
+
blockedHashes.push({ ts: ts(attempt[0]), hash: r.hash, path: attempt[1] });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// 9. TRANSFORM — Grep result had an API key, redacted before forward
|
|
134
|
+
({ hash: prev } = appendRow(chainFile, prev, {
|
|
135
|
+
ts: ts(32), event_id: crypto.randomUUID(), run_id: RUN_ID, agent: 'claude-code',
|
|
136
|
+
kind: 'tool_call', tool_name: 'Grep', action: 'TRANSFORM',
|
|
137
|
+
tool_inputs: { pattern: 'config', path: 'src/' },
|
|
138
|
+
secrets_redacted: 1,
|
|
139
|
+
transform: 'redact-secrets',
|
|
140
|
+
}));
|
|
141
|
+
|
|
142
|
+
// 10-11. Two more PASS reads
|
|
143
|
+
for (const [i, p] of [[36, 'src/auth.js'], [40, 'src/routes.js']]) {
|
|
144
|
+
({ hash: prev } = appendRow(chainFile, prev, {
|
|
145
|
+
ts: ts(i), event_id: crypto.randomUUID(), run_id: RUN_ID, agent: 'claude-code',
|
|
146
|
+
kind: 'tool_call', tool_name: 'Read', action: 'PASS',
|
|
147
|
+
tool_inputs: { path: p },
|
|
148
|
+
}));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// 12. LOCAL grep
|
|
152
|
+
({ hash: prev } = appendRow(chainFile, prev, {
|
|
153
|
+
ts: ts(45), event_id: crypto.randomUUID(), run_id: RUN_ID, agent: 'claude-code',
|
|
154
|
+
kind: 'tool_call', tool_name: 'Grep', action: 'LOCAL',
|
|
155
|
+
tool_inputs: { pattern: 'TODO', path: 'src/' },
|
|
156
|
+
}));
|
|
157
|
+
|
|
158
|
+
return { runId: RUN_ID, blockedHashes };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function tryPython(att, chainFile, attFile) {
|
|
162
|
+
const candidates = process.platform === 'win32' ? ['python', 'py'] : ['python3', 'python'];
|
|
163
|
+
for (const cmd of candidates) {
|
|
164
|
+
const probe = spawnSync(cmd, ['--version'], { stdio: 'pipe', shell: false });
|
|
165
|
+
if (probe.status === 0) {
|
|
166
|
+
const verifierPath = path.join(__dirname, '..', '..', 'docs', 'attest_verify.py');
|
|
167
|
+
if (!fs.existsSync(verifierPath)) {
|
|
168
|
+
return { ran: false, reason: `verifier script not found at ${verifierPath}` };
|
|
169
|
+
}
|
|
170
|
+
const result = spawnSync(cmd, [verifierPath, attFile, '--chain', chainFile], {
|
|
171
|
+
stdio: 'pipe', shell: false, encoding: 'utf8',
|
|
172
|
+
});
|
|
173
|
+
const out = (result.stdout || '') + (result.stderr || '');
|
|
174
|
+
// Parse the verifier's "[STATUS] check name" lines. For an unsigned
|
|
175
|
+
// attestation, sigstore steps SKIP and the chain step OKs — that is
|
|
176
|
+
// the success shape for the demo. Real failure = any FAIL line.
|
|
177
|
+
const lines = out.split('\n');
|
|
178
|
+
const checks = [];
|
|
179
|
+
for (const line of lines) {
|
|
180
|
+
const m = line.match(/\[\s*(OK|FAIL|SKIP)\s*\]\s+(.+?)\s*$/);
|
|
181
|
+
if (m) checks.push({ status: m[1], name: m[2] });
|
|
182
|
+
}
|
|
183
|
+
const anyFail = checks.some(c => c.status === 'FAIL');
|
|
184
|
+
const chainOk = checks.some(c => c.status === 'OK' && /chain/i.test(c.name));
|
|
185
|
+
return {
|
|
186
|
+
ran: true,
|
|
187
|
+
cmd,
|
|
188
|
+
exitCode: result.status,
|
|
189
|
+
checks,
|
|
190
|
+
anyFail,
|
|
191
|
+
chainOk,
|
|
192
|
+
stdout: result.stdout || '',
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return { ran: false, reason: 'no python3/python on PATH' };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function runAuditDemoCli(_args = []) {
|
|
200
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lf-demo-audit-'));
|
|
201
|
+
const chainFile = path.join(tmpDir, 'pipeline-events.jsonl');
|
|
202
|
+
const policyFile = path.join(tmpDir, 'policy.yml');
|
|
203
|
+
const attFile = path.join(tmpDir, 'occasio-attestation.json');
|
|
204
|
+
|
|
205
|
+
console.log(C.b('\n⚡ Occasio — auditor demo ') + C.d('(no API key, no network, ~10s)\n'));
|
|
206
|
+
|
|
207
|
+
// ── Scene ──────────────────────────────────────────────────────────────
|
|
208
|
+
console.log(C.b('━━━ The question ━━━\n'));
|
|
209
|
+
console.log(' ' + C.y('Auditor:') + ' "Your CI merged 47 AI-authored PRs last month. Prove the');
|
|
210
|
+
console.log(' agent in build #2849 never read ' + C.c(DENIED_PATH) + '."');
|
|
211
|
+
console.log('');
|
|
212
|
+
console.log(' ' + C.d('Without Occasio: you grep through stderr logs, hope nothing was'));
|
|
213
|
+
console.log(' ' + C.d('rotated, and email Anthropic for server-side traces.'));
|
|
214
|
+
console.log(' ' + C.d('With Occasio: you hand the auditor one JSON file. They verify it offline.'));
|
|
215
|
+
console.log('');
|
|
216
|
+
|
|
217
|
+
// ── Step 1: chain ──────────────────────────────────────────────────────
|
|
218
|
+
console.log(C.b('1.') + ' Synthesizing the CI-run audit chain ' + C.d('(12 rows, hash-linked)'));
|
|
219
|
+
const { runId, blockedHashes } = buildAuditScenarioChain(chainFile, policyFile);
|
|
220
|
+
console.log(' ' + C.g('✓') + ' run_id: ' + C.c(runId));
|
|
221
|
+
console.log(' ' + C.d('chain: ' + displayPath(chainFile)));
|
|
222
|
+
console.log('');
|
|
223
|
+
|
|
224
|
+
// ── Step 2: verify ─────────────────────────────────────────────────────
|
|
225
|
+
console.log(C.b('2.') + ' Verifying chain integrity ' + C.d('(SHA-256 walk: prev_hash → hash, GENESIS → HEAD)'));
|
|
226
|
+
const ver = verifyFile(chainFile);
|
|
227
|
+
if (!ver.ok) {
|
|
228
|
+
console.error(' ' + C.r('✗') + ' chain broken — demo bug');
|
|
229
|
+
return 1;
|
|
230
|
+
}
|
|
231
|
+
console.log(' ' + C.g('✓') + ' all ' + ver.chained + ' rows chained, no tamper gap');
|
|
232
|
+
console.log('');
|
|
233
|
+
|
|
234
|
+
// ── Step 3: build attestation ──────────────────────────────────────────
|
|
235
|
+
console.log(C.b('3.') + ' Building behavioral attestation ' + C.d('(unsigned; --sign needs OIDC)'));
|
|
236
|
+
const att = buildAttestation({ runId, logFile: chainFile, policyFile });
|
|
237
|
+
if (!att) {
|
|
238
|
+
console.error(' ' + C.r('✗') + ' buildAttestation returned null');
|
|
239
|
+
return 1;
|
|
240
|
+
}
|
|
241
|
+
fs.writeFileSync(attFile, JSON.stringify(att, null, 2));
|
|
242
|
+
console.log(' ' + C.g('✓') + ' ' + C.d(displayPath(attFile)));
|
|
243
|
+
console.log(' ' + C.d('tool_calls: ' + att.execution_summary.tool_calls
|
|
244
|
+
+ ' blocked: ' + att.execution_summary.blocked
|
|
245
|
+
+ ' transformed: ' + att.execution_summary.transformed
|
|
246
|
+
+ ' secrets_redacted: ' + att.execution_summary.secrets_redacted));
|
|
247
|
+
console.log('');
|
|
248
|
+
|
|
249
|
+
// ── Step 4: ANSWER THE AUDITOR ─────────────────────────────────────────
|
|
250
|
+
console.log(C.b('━━━ Answer to the auditor ━━━\n'));
|
|
251
|
+
console.log(' ' + C.y('Q:') + ' "Did the agent read ' + C.c(DENIED_PATH) + '?"');
|
|
252
|
+
console.log(' ' + C.gb('A: NO.') + ' ' + blockedHashes.length + ' attempts, all denied by policy. Evidence:');
|
|
253
|
+
console.log('');
|
|
254
|
+
for (const b of blockedHashes) {
|
|
255
|
+
console.log(' ' + C.d(b.ts) + ' ' + C.rb('BLOCK') + ' '
|
|
256
|
+
+ b.path.padEnd(38) + ' ' + C.d('hash=' + b.hash.slice(0, 16) + '…'));
|
|
257
|
+
}
|
|
258
|
+
console.log('');
|
|
259
|
+
console.log(' ' + C.d('These row hashes are linked into the chain. Tampering with any'));
|
|
260
|
+
console.log(' ' + C.d('one of them breaks the SHA-256 walk and the attestation fails verify.'));
|
|
261
|
+
console.log('');
|
|
262
|
+
|
|
263
|
+
// ── Step 5: Node verifier (canonical round-trip) ───────────────────────
|
|
264
|
+
console.log(C.b('4.') + ' Re-verifying with the Node verifier ' + C.d('(canonical JSON round-trip)'));
|
|
265
|
+
const reparsed = JSON.parse(fs.readFileSync(attFile, 'utf8'));
|
|
266
|
+
const { signature: _o1, ...expected } = att;
|
|
267
|
+
const { signature: _o2, ...observed } = reparsed;
|
|
268
|
+
if (canonicalize(expected) !== canonicalize(observed)) {
|
|
269
|
+
console.error(' ' + C.r('✗') + ' canonical bytes diverged');
|
|
270
|
+
return 1;
|
|
271
|
+
}
|
|
272
|
+
console.log(' ' + C.g('✓') + ' predicate canonical-stable across reparse');
|
|
273
|
+
console.log('');
|
|
274
|
+
|
|
275
|
+
// ── Step 6: Python cross-verifier ──────────────────────────────────────
|
|
276
|
+
console.log(C.b('5.') + ' Re-verifying with the independent Python verifier ' + C.d('(docs/attest_verify.py)'));
|
|
277
|
+
const py = tryPython(att, chainFile, attFile);
|
|
278
|
+
if (!py.ran) {
|
|
279
|
+
console.log(' ' + C.y('○') + ' Python verifier skipped — ' + py.reason);
|
|
280
|
+
console.log(' ' + C.d(' install python3 to run the cross-verifier; the Node check above already passed'));
|
|
281
|
+
} else if (py.anyFail) {
|
|
282
|
+
console.log(' ' + C.r('✗') + ' Python verifier reported FAIL ' + C.d('(' + py.cmd + ' exit ' + py.exitCode + ')'));
|
|
283
|
+
for (const c of py.checks) {
|
|
284
|
+
const tag = c.status === 'OK' ? C.g('OK ') : c.status === 'FAIL' ? C.r('FAIL') : C.y('SKIP');
|
|
285
|
+
console.log(' [' + tag + '] ' + c.name);
|
|
286
|
+
}
|
|
287
|
+
} else if (py.chainOk) {
|
|
288
|
+
console.log(' ' + C.g('✓') + ' independent Python verifier agrees on the audit chain');
|
|
289
|
+
for (const c of py.checks) {
|
|
290
|
+
const tag = c.status === 'OK' ? C.g('OK ') : C.y('SKIP');
|
|
291
|
+
console.log(' [' + tag + '] ' + c.name);
|
|
292
|
+
}
|
|
293
|
+
console.log(' ' + C.d(' (sigstore steps SKIP because the demo attestation is unsigned;'));
|
|
294
|
+
console.log(' ' + C.d(' in real CI with --sign, all three rows would show OK)'));
|
|
295
|
+
} else {
|
|
296
|
+
console.log(' ' + C.y('○') + ' Python verifier ran but did not produce the expected check lines');
|
|
297
|
+
console.log(' ' + C.d(' exit ' + py.exitCode + ' — see ' + displayPath(attFile) + ' for the artifact'));
|
|
298
|
+
}
|
|
299
|
+
console.log('');
|
|
300
|
+
|
|
301
|
+
// ── Step 7: what --sign adds in CI ─────────────────────────────────────
|
|
302
|
+
console.log(C.b('━━━ In real CI: --sign adds the third check ━━━\n'));
|
|
303
|
+
console.log(' ' + C.c('occasio attest --run-id ' + runId.slice(0, 8) + '… --sign'));
|
|
304
|
+
console.log(' ' + C.d(' → signs via Sigstore keyless using GitHub Actions OIDC (no key mgmt)'));
|
|
305
|
+
console.log(' ' + C.d(' → emits .json attestation + .bundle.json Sigstore bundle'));
|
|
306
|
+
console.log(' ' + C.d(' → workflow posts a Check Run on the PR with the verify summary'));
|
|
307
|
+
console.log('');
|
|
308
|
+
console.log(' ' + C.b('Auditor verifies offline with three independent paths:'));
|
|
309
|
+
console.log(' ' + C.d(' • ') + C.c('occasio attest verify <file>') + C.d(' (Node)'));
|
|
310
|
+
console.log(' ' + C.d(' • ') + C.c('python3 docs/attest_verify.py <file>') + C.d(' (stdlib + sigstore-python)'));
|
|
311
|
+
console.log(' ' + C.d(' • ') + C.c('cosign verify-blob …') + C.d(' (any sigstore-conformant tool)'));
|
|
312
|
+
console.log('');
|
|
313
|
+
console.log(' ' + C.d('All three must agree. None of them trust Occasio\'s own verifier.'));
|
|
314
|
+
console.log('');
|
|
315
|
+
|
|
316
|
+
// ── CTA ────────────────────────────────────────────────────────────────
|
|
317
|
+
console.log(C.b('━━━ Try it on your own CI ━━━\n'));
|
|
318
|
+
console.log(' ' + C.c('npm install -g @occasiolabs/occasio'));
|
|
319
|
+
console.log(' ' + C.c('occasio policy init ') + C.d('# write ~/.occasio/policy.yml'));
|
|
320
|
+
console.log(' ' + C.c('occasio register ') + C.d('# alias `claude` through the proxy'));
|
|
321
|
+
console.log(' ' + C.d('Add .github/workflows/attest-on-pr.yml from docs/reference-pipeline.md'));
|
|
322
|
+
console.log('');
|
|
323
|
+
console.log(' ' + C.b('Spec:') + ' https://github.com/occasiolabs/occasio/tree/main/spec/agent-attestation/v1');
|
|
324
|
+
console.log(' ' + C.b('Scratch artifacts kept at:') + ' ' + C.d(displayPath(tmpDir)));
|
|
325
|
+
console.log('');
|
|
326
|
+
|
|
327
|
+
return 0;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
module.exports = { runAuditDemoCli, buildAuditScenarioChain };
|
package/src/index.js
CHANGED
|
@@ -56,10 +56,11 @@ const VERSION = (() => {
|
|
|
56
56
|
catch { return '0.0.0-unknown'; }
|
|
57
57
|
})();
|
|
58
58
|
const LOG_SCHEMA_VERSION = 2;
|
|
59
|
-
// Port
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
let PORT = parseInt(process.env.OCCASIO_PORT, 10) ||
|
|
59
|
+
// Port: defaults to 0 (auto-assigned by the OS) so multiple `occasio claude`
|
|
60
|
+
// sessions can run in parallel. Explicit overrides via OCCASIO_PORT env or
|
|
61
|
+
// --port flag still pin a fixed port (and surface EADDRINUSE if taken).
|
|
62
|
+
let PORT = parseInt(process.env.OCCASIO_PORT, 10) || 0;
|
|
63
|
+
let PORT_EXPLICIT = PORT !== 0;
|
|
63
64
|
const ANTHROPIC_REAL = 'api.anthropic.com';
|
|
64
65
|
const LOG_DIR = path.join(os.homedir(), '.occasio');
|
|
65
66
|
const SESSION_FILE = path.join(LOG_DIR, 'session.json');
|
|
@@ -131,6 +132,60 @@ const col = {
|
|
|
131
132
|
d: s => `\x1b[2m${s}\x1b[0m`, b: s => `\x1b[1m${s}\x1b[0m`,
|
|
132
133
|
};
|
|
133
134
|
|
|
135
|
+
// Print a compact recap banner just before claude starts — gives the user
|
|
136
|
+
// (and the agent, via on-screen context) a memory anchor for the previous
|
|
137
|
+
// session in this cwd. Silent if no prior data exists.
|
|
138
|
+
function printSessionRecapBanner() {
|
|
139
|
+
let conv = null;
|
|
140
|
+
try { conv = require('./cli/conversation').readLastConversation(process.cwd()); }
|
|
141
|
+
catch { /* module load issue → skip */ }
|
|
142
|
+
|
|
143
|
+
// Last run summary from the audit chain.
|
|
144
|
+
let runLine = null;
|
|
145
|
+
try {
|
|
146
|
+
const chainFile = path.join(LOG_DIR, 'pipeline-events.jsonl');
|
|
147
|
+
if (fs.existsSync(chainFile)) {
|
|
148
|
+
const text = fs.readFileSync(chainFile, 'utf8');
|
|
149
|
+
const rows = [];
|
|
150
|
+
for (const line of text.split('\n')) {
|
|
151
|
+
if (!line) continue;
|
|
152
|
+
try { rows.push(JSON.parse(line)); } catch { /* skip */ }
|
|
153
|
+
}
|
|
154
|
+
const byRun = new Map();
|
|
155
|
+
for (const r of rows) {
|
|
156
|
+
const id = r.run_id || 'legacy';
|
|
157
|
+
if (!byRun.has(id)) byRun.set(id, []);
|
|
158
|
+
byRun.get(id).push(r);
|
|
159
|
+
}
|
|
160
|
+
const newest = [...byRun.entries()].map(([id, rs]) => ({
|
|
161
|
+
id, rs, end: rs[rs.length - 1]?.ts || '',
|
|
162
|
+
})).sort((a, b) => a.end < b.end ? 1 : -1)[0];
|
|
163
|
+
if (newest) {
|
|
164
|
+
const tc = newest.rs.filter(r => r.kind === 'tool_call').length;
|
|
165
|
+
const blocked = newest.rs.filter(r => r.action === 'BLOCK').length;
|
|
166
|
+
const date = newest.end ? new Date(newest.end).toISOString().slice(0, 16).replace('T', ' ') : 'unknown';
|
|
167
|
+
runLine = `${tc} tool calls, ${blocked} blocked · ${date}`;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
} catch { /* skip */ }
|
|
171
|
+
|
|
172
|
+
if (!conv && !runLine) return;
|
|
173
|
+
|
|
174
|
+
const oneLine = (s, max = 160) => {
|
|
175
|
+
const flat = String(s).replace(/\s+/g, ' ').trim();
|
|
176
|
+
return flat.length > max ? flat.slice(0, max - 1) + '…' : flat;
|
|
177
|
+
};
|
|
178
|
+
process.stderr.write('\n' + col.b('── previous session in this directory ──') + '\n');
|
|
179
|
+
if (runLine) process.stderr.write(' ' + col.d(runLine) + '\n');
|
|
180
|
+
if (conv && conv.lastUser) {
|
|
181
|
+
process.stderr.write(' ' + col.y('You: ') + oneLine(conv.lastUser) + '\n');
|
|
182
|
+
}
|
|
183
|
+
if (conv && conv.lastAssistant) {
|
|
184
|
+
process.stderr.write(' ' + col.g('Agent: ') + oneLine(conv.lastAssistant) + '\n');
|
|
185
|
+
}
|
|
186
|
+
process.stderr.write(col.d(' (enabled via --recap; suppress next time by omitting it)') + '\n\n');
|
|
187
|
+
}
|
|
188
|
+
|
|
134
189
|
// ── Pre-send manifest ─────────────────────────────────────────────────────────
|
|
135
190
|
|
|
136
191
|
function fmtTok(n) {
|
|
@@ -284,6 +339,10 @@ if (cmd === 'replay') {
|
|
|
284
339
|
process.exit(0);
|
|
285
340
|
}
|
|
286
341
|
|
|
342
|
+
if (cmd === 'recap') {
|
|
343
|
+
process.exit(require('./cli/recap').run(args.slice(1)));
|
|
344
|
+
}
|
|
345
|
+
|
|
287
346
|
if (cmd === 'distill') {
|
|
288
347
|
runDistillCli(args.slice(1));
|
|
289
348
|
process.exit(0);
|
|
@@ -404,6 +463,13 @@ if (cmd === 'demo' && (args[1] === 'anomalies' || args[1] === 'anomaly')) {
|
|
|
404
463
|
process.exit(runAnomaliesDemoCli(args.slice(2)));
|
|
405
464
|
}
|
|
406
465
|
|
|
466
|
+
if (cmd === 'demo' && (args[1] === 'audit' || args[1] === 'auditor')) {
|
|
467
|
+
const { runAuditDemoCli } = require('./demo/audit-demo');
|
|
468
|
+
runAuditDemoCli(args.slice(2)).then(code => process.exit(code))
|
|
469
|
+
.catch(e => { process.stderr.write(`[demo audit] ${e.message}\n`); process.exit(1); });
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
|
|
407
473
|
if (cmd === 'demo') {
|
|
408
474
|
const { scanSecrets } = require('../src/analyzer');
|
|
409
475
|
// Realistic-looking but synthetic credentials. Each hits exactly one pattern.
|
|
@@ -537,16 +603,22 @@ if (cmd === 'doctor' || cmd === 'check') {
|
|
|
537
603
|
ok('log dir', LOG_DIR);
|
|
538
604
|
} catch (e) { bad('log dir', e.message); }
|
|
539
605
|
|
|
540
|
-
// 4. Port availability (async)
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
606
|
+
// 4. Port availability (async) — only probe when an explicit port is pinned.
|
|
607
|
+
// With auto-assignment (default), the OS picks a free port at listen-time
|
|
608
|
+
// so there's nothing meaningful to probe in advance.
|
|
609
|
+
if (PORT_EXPLICIT) {
|
|
610
|
+
await new Promise(resolve => {
|
|
611
|
+
const srv = net.createServer();
|
|
612
|
+
srv.once('error', () => {
|
|
613
|
+
bad(`port ${PORT}`, `already in use — kill it: netstat -ano | findstr :${PORT}`);
|
|
614
|
+
resolve();
|
|
615
|
+
});
|
|
616
|
+
srv.once('listening', () => { srv.close(); ok(`port ${PORT}`, 'available'); resolve(); });
|
|
617
|
+
srv.listen(PORT, '127.0.0.1');
|
|
546
618
|
});
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
}
|
|
619
|
+
} else {
|
|
620
|
+
ok('port', 'auto-assigned at runtime');
|
|
621
|
+
}
|
|
550
622
|
|
|
551
623
|
// 5. Python + LAO scorer script
|
|
552
624
|
const laoPyPath = path.join(__dirname, 'lao_prep.py');
|
|
@@ -632,8 +704,15 @@ if (prIdx >= 0) {
|
|
|
632
704
|
const di = claudeArgs.indexOf('--dashboard');
|
|
633
705
|
const useDashboard = di >= 0;
|
|
634
706
|
if (di >= 0) claudeArgs.splice(di, 1);
|
|
707
|
+
const recapIdx = claudeArgs.indexOf('--recap');
|
|
708
|
+
const showRecap = recapIdx >= 0;
|
|
709
|
+
if (recapIdx >= 0) claudeArgs.splice(recapIdx, 1);
|
|
635
710
|
const pi = claudeArgs.indexOf('--port');
|
|
636
|
-
if (pi >= 0) {
|
|
711
|
+
if (pi >= 0) {
|
|
712
|
+
const parsed = parseInt(claudeArgs[pi+1], 10);
|
|
713
|
+
if (parsed > 0) { PORT = parsed; PORT_EXPLICIT = true; }
|
|
714
|
+
claudeArgs.splice(pi, 2);
|
|
715
|
+
}
|
|
637
716
|
|
|
638
717
|
// Budget flag: --budget <N> sets a session dollar limit.
|
|
639
718
|
const bgtIdx = claudeArgs.indexOf('--budget');
|
|
@@ -1353,7 +1432,7 @@ const server = http.createServer((req, res) => {
|
|
|
1353
1432
|
|
|
1354
1433
|
server.on('error', e => {
|
|
1355
1434
|
if (e.code === 'EADDRINUSE') {
|
|
1356
|
-
process.stderr.write(col.r(`\n❌ Port ${PORT} already in use. Kill the old process first:\n`));
|
|
1435
|
+
process.stderr.write(col.r(`\n❌ Port ${PORT} already in use. Kill the old process first or omit --port / OCCASIO_PORT to let the OS auto-assign:\n`));
|
|
1357
1436
|
process.stderr.write(col.d(` netstat -ano | findstr :${PORT} → then: taskkill /PID <pid> /F\n\n`));
|
|
1358
1437
|
} else {
|
|
1359
1438
|
process.stderr.write(col.r(`\n❌ ${e.message}\n`));
|
|
@@ -1362,6 +1441,17 @@ server.on('error', e => {
|
|
|
1362
1441
|
});
|
|
1363
1442
|
|
|
1364
1443
|
server.listen(PORT, '127.0.0.1', () => {
|
|
1444
|
+
PORT = server.address().port;
|
|
1445
|
+
process.stderr.write(col.d(`occasio proxy listening on 127.0.0.1:${PORT}\n`));
|
|
1446
|
+
|
|
1447
|
+
// Best-effort: print a 3-line recap of the previous session so the user
|
|
1448
|
+
// (and via screen-context, the agent itself) has a memory anchor before
|
|
1449
|
+
// typing the next prompt. Silent if no prior session in this cwd.
|
|
1450
|
+
// Disable with --no-recap.
|
|
1451
|
+
if (showRecap) {
|
|
1452
|
+
try { printSessionRecapBanner(); } catch { /* never block startup */ }
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1365
1455
|
const env = { ...process.env, ANTHROPIC_BASE_URL: `http://localhost:${PORT}` };
|
|
1366
1456
|
// On Windows, npm installs binaries as .cmd wrappers (claude.cmd).
|
|
1367
1457
|
// spawn() without shell:true calls CreateProcess directly, which won't
|