@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 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 attest # End-to-end attestation pipeline (30s, no API key)
13
- occasio demo anomalies # Live EDR detection on a synthetic adversarial chain (5s)
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 two demos run against synthetic data so you can see the full pipeline in under a minute with no external dependencies. The third spawns a real Claude Code subordinate under your Anthropic login (bundled auth — no API key required) and proves the defense end-to-end.
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.5",
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 ${col.d('(stable)')} 10-second proof: see Occasio block real secrets
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: 8081)
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:')}
@@ -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 override via env var (used by `occasio harness` and redteam to
60
- // run isolated proxies against scratch audit chains on free ports). Default
61
- // is 8081 to preserve existing user-facing behaviour.
62
- let PORT = parseInt(process.env.OCCASIO_PORT, 10) || 8081;
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
- await new Promise(resolve => {
542
- const srv = net.createServer();
543
- srv.once('error', () => {
544
- bad(`port ${PORT}`, `already in use — kill it: netstat -ano | findstr :${PORT}`);
545
- resolve();
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
- srv.once('listening', () => { srv.close(); ok(`port ${PORT}`, 'available'); resolve(); });
548
- srv.listen(PORT, '127.0.0.1');
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) { PORT = parseInt(claudeArgs[pi+1], 10) || PORT; claudeArgs.splice(pi, 2); }
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