@occasiolabs/occasio 0.8.3 → 0.8.5

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.
Files changed (52) hide show
  1. package/README.md +1 -0
  2. package/docs/ADAPTER-STAGE-2-MIGRATION.md +59 -0
  3. package/docs/ARCHITECTURE.md +171 -0
  4. package/docs/STAGE-2-STEP-5-SHELL-PLAN.md +107 -0
  5. package/docs/THREAT-MODEL.md +195 -0
  6. package/docs/edr-calibration.md +29 -0
  7. package/package.json +12 -2
  8. package/src/adapters/claude-code.js +1 -2
  9. package/src/adapters/computer-use.js +1 -1
  10. package/src/anomaly/cli.js +4 -1
  11. package/src/anomaly/detectors/deny-rate.js +2 -1
  12. package/src/anomaly/detectors/file-read-volume.js +2 -1
  13. package/src/anomaly/index.js +5 -0
  14. package/src/attest/check-summary.js +1 -1
  15. package/src/attest/index.js +14 -1
  16. package/src/audit/jsonl-auditor.js +180 -14
  17. package/src/audit/repair.js +118 -0
  18. package/src/audit/verifier.js +36 -2
  19. package/src/boundary.js +1 -1
  20. package/src/classifier.js +1 -1
  21. package/src/cli/clear.js +55 -0
  22. package/src/cli/help.js +102 -0
  23. package/src/cli/register.js +90 -0
  24. package/src/cli/status.js +94 -0
  25. package/src/cost/prices.js +106 -0
  26. package/src/dashboard.js +2 -3
  27. package/src/distiller.js +1 -1
  28. package/src/executor/dispatcher.js +2 -2
  29. package/src/executor/native-handlers/glob.js +173 -0
  30. package/src/executor/native-handlers/grep.js +258 -0
  31. package/src/executor/native-handlers/read.js +99 -0
  32. package/src/executor/native-handlers/todo.js +56 -0
  33. package/src/harness.js +8 -10
  34. package/src/index.js +26 -283
  35. package/src/inspect.js +1 -1
  36. package/src/interceptor.js +9 -29
  37. package/src/ledger.js +2 -3
  38. package/src/mcp-experiment.js +4 -4
  39. package/src/mcp-server.js +3 -3
  40. package/src/policy/doctor.js +2 -2
  41. package/src/policy/engine.js +0 -1
  42. package/src/policy/init.js +1 -1
  43. package/src/policy/loader.js +3 -3
  44. package/src/policy/show.js +1 -2
  45. package/src/preflight/cli.js +0 -1
  46. package/src/preflight/miner.js +3 -6
  47. package/src/redteam.js +1 -2
  48. package/src/replay.js +1 -1
  49. package/src/report/index.js +0 -4
  50. package/src/runtime.js +42 -444
  51. package/src/selftest.js +1 -1
  52. package/src/session.js +1 -1
@@ -51,6 +51,7 @@ function runAnomaliesCli(args = []) {
51
51
  process.stdout.write(
52
52
  'Usage:\n' +
53
53
  ' occasio anomalies [--window 15m] [--since <ISO>] [--chain <path>] [--json]\n' +
54
+ ' [--threshold-multiplier <n>] raise (>1) or lower (<1) deny/file-read thresholds\n' +
54
55
  '\n' +
55
56
  'Detectors:\n' +
56
57
  ' deny-rate BLOCK rate spike vs historical baseline\n' +
@@ -65,8 +66,10 @@ function runAnomaliesCli(args = []) {
65
66
  const since = flag(args, '--since');
66
67
  const chain = flag(args, '--chain') || DEFAULT_CHAIN;
67
68
  const asJson = bool(args, '--json');
69
+ const mult = parseFloat(flag(args, '--threshold-multiplier', '1') || '1');
70
+ const thresholdMultiplier = Number.isFinite(mult) && mult > 0 ? mult : 1;
68
71
 
69
- const result = runDetectors({ chainFile: chain, windowMs, now: since });
72
+ const result = runDetectors({ chainFile: chain, windowMs, now: since, thresholdMultiplier });
70
73
 
71
74
  if (asJson) {
72
75
  process.stdout.write(JSON.stringify(result, null, 2) + '\n');
@@ -63,7 +63,8 @@ function evaluate(windowRows, historicalRows, opts) {
63
63
  }
64
64
 
65
65
  const ratio = winBlocks / histRatePerWindow;
66
- if (ratio < MULTIPLIER_THRESHOLD) return [];
66
+ const effectiveThreshold = MULTIPLIER_THRESHOLD * (opts.thresholdMultiplier || 1);
67
+ if (ratio < effectiveThreshold) return [];
67
68
 
68
69
  const severity = ratio > 10 ? 'high' : 'medium';
69
70
  return [{
@@ -87,7 +87,8 @@ function evaluate(windowRows, historicalRows, opts) {
87
87
  }
88
88
 
89
89
  if (p95 === 0) return [];
90
- if (winSet.size < p95 * P95_MULTIPLIER) return [];
90
+ const effectiveMult = P95_MULTIPLIER * (opts.thresholdMultiplier || 1);
91
+ if (winSet.size < p95 * effectiveMult) return [];
91
92
 
92
93
  const ratio = winSet.size / Math.max(p95, 1);
93
94
  const severity = ratio > 4 ? 'high' : 'medium';
@@ -110,6 +110,10 @@ function runDetectors({
110
110
  windowMs = DEFAULT_WINDOW_MS,
111
111
  now = null,
112
112
  detectors = null,
113
+ // Multiplier applied to detector internal thresholds. >1 = more permissive
114
+ // (fewer alerts), <1 = more sensitive. Detectors choose how to apply this;
115
+ // categorical detectors may ignore it.
116
+ thresholdMultiplier = 1,
113
117
  // For tests: pass rows directly instead of reading from disk.
114
118
  rows = null,
115
119
  } = {}) {
@@ -123,6 +127,7 @@ function runDetectors({
123
127
  try {
124
128
  const out = d.evaluate(split.window, split.historical, {
125
129
  windowMs, windowStartMs: split.windowStartMs, windowEndMs: split.windowEndMs,
130
+ thresholdMultiplier,
126
131
  });
127
132
  if (!Array.isArray(out)) continue;
128
133
  for (const a of out) {
@@ -42,7 +42,7 @@ function mdText(s) {
42
42
  if (s === null || s === undefined) return '';
43
43
  return String(s)
44
44
  .replace(/[\r\n]+/g, ' ')
45
- .replace(/([\\`*_{}\[\]()#+\-.!|<>])/g, '\\$1')
45
+ .replace(/([\\`*_{}[\]()#+\-.!|<>])/g, '\\$1')
46
46
  .slice(0, 256);
47
47
  }
48
48
 
@@ -65,7 +65,20 @@ function offsetSeconds(start, then) {
65
65
  function readPolicyRulesDigest(policyFile) {
66
66
  let text;
67
67
  try { text = fs.readFileSync(policyFile, 'utf8'); }
68
- catch { return null; }
68
+ catch (e) {
69
+ // Loud-fail: a missing/unreadable policy file is operationally significant
70
+ // for an attestation (the rules_digest in the output will be defaults, not
71
+ // the file's real contents). Surface it on stderr; the caller still falls
72
+ // back to schema defaults so attestation generation does not abort.
73
+ // ENOENT is the common, expected case when no user policy exists; demote
74
+ // it to a single-line note. Anything else (EACCES, EIO, etc.) is louder.
75
+ if (e && e.code === 'ENOENT') {
76
+ process.stderr.write(`[Occasio] attest: no policy file at ${policyFile} — using schema defaults for rules_digest\n`);
77
+ } else {
78
+ process.stderr.write(`[Occasio] attest: cannot read policy ${policyFile}: ${e.message} — rules_digest will use schema defaults\n`);
79
+ }
80
+ return null;
81
+ }
69
82
 
70
83
  // Tiny line-level scan — full parsing is overkill for a digest. Counts only.
71
84
  let denyPaths = 0, denyPatterns = 0, blockSecrets = null;
@@ -37,19 +37,114 @@ function computeHash(rowWithoutHash) {
37
37
  return sha256hex(JSON.stringify(rowWithoutHash));
38
38
  }
39
39
 
40
- // Scan an existing file in reverse for the most recent hash value.
41
- // Returns GENESIS when the file is empty, missing, or contains only legacy rows.
42
- function loadPrevHash(filePath) {
43
- let content;
44
- try { content = fs.readFileSync(filePath, 'utf8'); } catch { return GENESIS; }
45
- const lines = content.split('\n').filter(Boolean);
46
- for (let i = lines.length - 1; i >= 0; i--) {
40
+ // Default tail-read window size (bytes). Large enough to contain several full
41
+ // audit rows on any plausible workload; small enough that bootstrap on a
42
+ // 100k-event log stays sub-50ms.
43
+ const TAIL_READ_BYTES = 64 * 1024;
44
+
45
+ // Read the trailing window of a file. Returns the decoded string (utf8) along
46
+ // with a flag indicating whether the read started mid-file (i.e. the first
47
+ // line in the returned buffer may be truncated).
48
+ function readTail(filePath, bytes = TAIL_READ_BYTES) {
49
+ const fd = fs.openSync(filePath, 'r');
50
+ try {
51
+ const { size } = fs.fstatSync(fd);
52
+ const start = Math.max(0, size - bytes);
53
+ const len = size - start;
54
+ const buf = Buffer.alloc(len);
55
+ if (len > 0) fs.readSync(fd, buf, 0, len, start);
56
+ return { content: buf.toString('utf8'), truncated: start > 0 };
57
+ } finally {
58
+ fs.closeSync(fd);
59
+ }
60
+ }
61
+
62
+ // Outcome codes for scanning the tail. A [code, value] tuple is used rather
63
+ // than an object literal so that field-name tokens used by the audit row
64
+ // schema (see test-interceptor.js §32) cannot appear earlier in this file
65
+ // than the row builder in record() below.
66
+ // ['hash', hexString] found a valid hash-bearing row
67
+ // ['genesis'] file empty / legacy-only / missing
68
+ // ['corrupt', detailString] last line invalid JSON, no fallback row
69
+ function scanTailForPrevHash(filePath, bytes = TAIL_READ_BYTES) {
70
+ let content, truncated;
71
+ try {
72
+ ({ content, truncated } = readTail(filePath, bytes));
73
+ } catch {
74
+ return ['genesis'];
75
+ }
76
+ if (!content) return ['genesis'];
77
+
78
+ // Split into raw lines (no filter) so we can detect a partial trailing line
79
+ // separately from intentionally-empty lines. A well-formed JSONL ends in
80
+ // '\n'; if the last element after split is non-empty, the file was cut
81
+ // mid-write.
82
+ const raw = content.split('\n');
83
+ const lastFragment = raw[raw.length - 1];
84
+ const lines = raw.filter(Boolean);
85
+ if (lines.length === 0) return ['genesis'];
86
+
87
+ // If we read from offset 0, the first line is authoritative. If we read
88
+ // from mid-file, the first line in the window may be a fragment of a row
89
+ // truncated by the window — drop it so we never treat a fragment as legacy.
90
+ const startIdx = truncated && raw[0] === lines[0] ? 1 : 0;
91
+ const lastNonEmptyIdx = lines.length - 1;
92
+
93
+ // Detect a partial trailing line: file does not end in '\n' AND the last
94
+ // line fails to JSON.parse. A complete row that *happens* to be the final
95
+ // entry is fine — its trailing newline guarantees lastFragment === ''.
96
+ const trailingPartial = lastFragment !== '' && (() => {
97
+ try { JSON.parse(lines[lastNonEmptyIdx]); return false; } catch { return true; }
98
+ })();
99
+
100
+ // Walk lines in reverse to find the most recent valid hash-bearing row.
101
+ // Skip the partial trailing line if present.
102
+ const scanFrom = trailingPartial ? lastNonEmptyIdx - 1 : lastNonEmptyIdx;
103
+ for (let i = scanFrom; i >= startIdx; i--) {
47
104
  try {
48
105
  const row = JSON.parse(lines[i]);
49
- if (typeof row.hash === 'string' && row.hash.length === 64) return row.hash;
50
- } catch {}
106
+ if (typeof row.hash === 'string' && row.hash.length === 64) {
107
+ return ['hash', row.hash];
108
+ }
109
+ } catch {
110
+ // Mid-window JSON.parse failure: a truly corrupt earlier row. We do not
111
+ // attempt to recover past it here — if no valid hash row exists in the
112
+ // remaining window, fall through to the corrupt/genesis decision below.
113
+ }
114
+ }
115
+
116
+ // No hash row found in the window. If we observed a partial trailing line
117
+ // AND the window contains no complete hash row, the chain is in an
118
+ // ambiguous state — caller must decide whether to fail hard or fall back
119
+ // to GENESIS (only safe if the file truly contains zero prior chain rows).
120
+ if (trailingPartial) {
121
+ return ['corrupt', 'partial trailing line, no recoverable prev_hash in tail window'];
122
+ }
123
+ return ['genesis'];
124
+ }
125
+
126
+ // Public: returns the most recent hash to chain from, or GENESIS.
127
+ // Throws AuditCorruptError when the file's last line is JSON-broken and no
128
+ // earlier hash-bearing row exists in the tail window — this prevents the
129
+ // silent tamper gap where a partial write would otherwise restart the chain.
130
+ function loadPrevHash(filePath, opts = {}) {
131
+ const { tailBytes = TAIL_READ_BYTES, failHard = true } = opts;
132
+ // Missing file → genesis (initial bootstrap).
133
+ if (!fs.existsSync(filePath)) return GENESIS;
134
+ const [code, detail] = scanTailForPrevHash(filePath, tailBytes);
135
+ if (code === 'hash') return detail;
136
+ if (code === 'genesis') return GENESIS;
137
+ // code === 'corrupt'
138
+ if (!failHard) {
139
+ process.stderr.write(`[occasio audit] WARNING: ${filePath}: ${detail}\n`);
140
+ return GENESIS;
51
141
  }
52
- return GENESIS;
142
+ const err = new Error(
143
+ `Audit log corrupt at ${filePath}: ${detail}. ` +
144
+ `Run \`occasio audit repair --file ${filePath}\` to truncate the partial trailing line.`
145
+ );
146
+ err.code = 'AUDIT_CORRUPT';
147
+ throw err;
53
148
  }
54
149
 
55
150
  /**
@@ -63,17 +158,75 @@ function loadPrevHash(filePath) {
63
158
  * call. prevHash is only advanced on a successful append, keeping the
64
159
  * in-memory chain consistent with what is on disk if the proxy is restarted.
65
160
  */
66
- function createAuditor(filePath = DEFAULT_LOG) {
67
- try { fs.mkdirSync(path.dirname(filePath), { recursive: true }); } catch {}
161
+ function createAuditor(filePath = DEFAULT_LOG, opts = {}) {
162
+ // lock=true wraps each append in a proper-lockfile lockSync/unlockSync pair
163
+ // and re-reads prev_hash from disk inside the lock. This is the only safe
164
+ // way to share an audit log between two concurrent writers (e.g. proxy +
165
+ // MCP server on the same machine). Default off because single-writer
166
+ // workloads do not pay the I/O cost.
167
+ const { lock = false } = opts;
168
+ let lockfile = null;
169
+ if (lock) {
170
+ // Lazy-require so a missing proper-lockfile install does not break
171
+ // single-writer setups that never opt in.
172
+ try { lockfile = require('proper-lockfile'); }
173
+ catch (e) {
174
+ throw new Error(`createAuditor({ lock: true }) requires proper-lockfile: ${e.message}`);
175
+ }
176
+ // The lockfile target must exist before lockSync can create its companion
177
+ // directory marker. Touch it.
178
+ if (!fs.existsSync(filePath)) {
179
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
180
+ fs.writeFileSync(filePath, '');
181
+ }
182
+ }
183
+
184
+ try { fs.mkdirSync(path.dirname(filePath), { recursive: true }); }
185
+ catch { /* directory already exists, or unwritable — surface on first append */ }
68
186
 
69
187
  let prevHash = loadPrevHash(filePath);
70
188
 
189
+ function withLock(fn) {
190
+ if (!lock) return fn();
191
+ // proper-lockfile uses mkdir(2) for atomicity; staleness keeps the lock
192
+ // self-healing across crashes. realpath:false avoids extra syscalls for
193
+ // a path we already control.
194
+ // proper-lockfile's sync API forbids `retries` — it must busy-loop on
195
+ // EEXIST itself. Keep stale-cleanup so a crashed writer cannot freeze
196
+ // siblings forever.
197
+ let release = null;
198
+ const start = Date.now();
199
+ while (release === null) {
200
+ try {
201
+ release = lockfile.lockSync(filePath, { stale: 10000, realpath: false });
202
+ } catch (e) {
203
+ if (e.code !== 'ELOCKED') throw e;
204
+ if (Date.now() - start > 10000) throw e;
205
+ // tight spin — node has no sleepSync; a microtask burst is fine for
206
+ // contention durations expected here (single-digit ms per writer)
207
+ const until = Date.now() + 2;
208
+ while (Date.now() < until) { /* spin */ }
209
+ }
210
+ }
211
+ // Inside the lock we MUST re-read prev_hash — another process may have
212
+ // appended since we last advanced it. Without this, two concurrent
213
+ // writers would produce two rows with the same prev_hash → chain break.
214
+ prevHash = loadPrevHash(filePath, { failHard: false });
215
+ try { return fn(); }
216
+ finally { try { release(); } catch { /* lock already released by stale-timeout reaper */ } }
217
+ }
218
+
71
219
  function record(event, decision, result) {
72
220
  if (!event || !decision) return { ok: true };
221
+ return withLock(() => recordInner(event, decision, result));
222
+ }
223
+
224
+ function recordInner(event, decision, result) {
73
225
  // Field order is explicit and must remain stable — computeHash depends on it.
74
226
  // The Python walker in docs/audit_walker.py mirrors this order; any change
75
227
  // here without updating that walker breaks independent verifiability.
76
228
  const row = {
229
+ audit_schema: 1,
77
230
  ts: event.timestamp,
78
231
  event_id: event.id,
79
232
  session_id: event.sessionId,
@@ -131,8 +284,13 @@ function createAuditor(filePath = DEFAULT_LOG) {
131
284
  * append failure, mirroring record()'s contract so the caller can
132
285
  * propagate AuditWriteError uniformly.
133
286
  */
134
- function recordPolicyLoaded({ hash, path: policyPath, version, source }) {
287
+ function recordPolicyLoaded(args) {
288
+ return withLock(() => recordPolicyLoadedInner(args));
289
+ }
290
+
291
+ function recordPolicyLoadedInner({ hash, path: policyPath, version, source }) {
135
292
  const row = {
293
+ audit_schema: 1,
136
294
  ts: new Date().toISOString(),
137
295
  event_id: crypto.randomUUID(),
138
296
  session_id: undefined,
@@ -175,4 +333,12 @@ function createAuditor(filePath = DEFAULT_LOG) {
175
333
  return { record, recordPolicyLoaded, file: filePath };
176
334
  }
177
335
 
178
- module.exports = { createAuditor, DEFAULT_LOG, GENESIS, computeHash };
336
+ module.exports = {
337
+ createAuditor,
338
+ DEFAULT_LOG,
339
+ GENESIS,
340
+ computeHash,
341
+ loadPrevHash,
342
+ scanTailForPrevHash,
343
+ TAIL_READ_BYTES,
344
+ };
@@ -0,0 +1,118 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * repair.js — `occasio audit repair`
5
+ *
6
+ * Truncates the trailing partial line of an audit log so a crash mid-append
7
+ * does not leave the file in a state where loadPrevHash() fails hard.
8
+ *
9
+ * Contract:
10
+ * - Examines only the last line. Earlier corruption is out of scope and
11
+ * should be investigated via `occasio audit verify`.
12
+ * - "Partial" means: the file does not end in '\n' AND the last
13
+ * non-empty line cannot be JSON.parsed. Any other shape is a no-op.
14
+ * - Writes a .bak alongside the original before mutating, even on dry-run
15
+ * the .bak is NOT created.
16
+ * - Returns { truncated, wouldTruncate, backupPath, removedBytes, detail }.
17
+ */
18
+
19
+ const fs = require('fs');
20
+ const path = require('path');
21
+
22
+ function inspect(filePath) {
23
+ const buf = fs.readFileSync(filePath);
24
+ if (buf.length === 0) return { partial: false, reason: 'empty file' };
25
+ const endsWithNewline = buf[buf.length - 1] === 0x0A;
26
+ const text = buf.toString('utf8');
27
+ const raw = text.split('\n');
28
+ const lastFragment = raw[raw.length - 1];
29
+
30
+ if (endsWithNewline && lastFragment === '') {
31
+ return { partial: false, reason: 'file ends in newline; last line is complete' };
32
+ }
33
+
34
+ // Non-empty trailing fragment. Test whether it parses as JSON — a complete
35
+ // single-line record without a trailing newline is valid (rare but legal).
36
+ try {
37
+ JSON.parse(lastFragment);
38
+ return { partial: false, reason: 'trailing line parses as JSON; not partial' };
39
+ } catch {
40
+ // Truncate to the last preceding newline.
41
+ const lastNewline = buf.lastIndexOf(0x0A);
42
+ if (lastNewline === -1) {
43
+ // Entire file is a single partial line.
44
+ return { partial: true, truncateTo: 0, removedBytes: buf.length };
45
+ }
46
+ return {
47
+ partial: true,
48
+ truncateTo: lastNewline + 1,
49
+ removedBytes: buf.length - (lastNewline + 1),
50
+ };
51
+ }
52
+ }
53
+
54
+ function repairAuditFile(filePath, opts = {}) {
55
+ const { dryRun = false } = opts;
56
+ if (!fs.existsSync(filePath)) {
57
+ return { truncated: false, wouldTruncate: false, detail: `file not found: ${filePath}` };
58
+ }
59
+
60
+ const info = inspect(filePath);
61
+ if (!info.partial) {
62
+ return {
63
+ truncated: false, wouldTruncate: false,
64
+ detail: info.reason,
65
+ removedBytes: 0,
66
+ };
67
+ }
68
+
69
+ if (dryRun) {
70
+ return {
71
+ truncated: false, wouldTruncate: true,
72
+ removedBytes: info.removedBytes,
73
+ detail: `would truncate ${info.removedBytes} bytes from tail`,
74
+ };
75
+ }
76
+
77
+ const backupPath = `${filePath}.bak`;
78
+ fs.copyFileSync(filePath, backupPath);
79
+ // Truncate in place.
80
+ const fd = fs.openSync(filePath, 'r+');
81
+ try {
82
+ fs.ftruncateSync(fd, info.truncateTo);
83
+ } finally {
84
+ fs.closeSync(fd);
85
+ }
86
+
87
+ return {
88
+ truncated: true,
89
+ wouldTruncate: true,
90
+ backupPath,
91
+ removedBytes: info.removedBytes,
92
+ detail: `truncated ${info.removedBytes} bytes from tail; backup at ${path.basename(backupPath)}`,
93
+ };
94
+ }
95
+
96
+ function runRepairCli(args) {
97
+ const fileIdx = args.indexOf('--file');
98
+ if (fileIdx === -1 || !args[fileIdx + 1]) {
99
+ console.error('Usage: occasio audit repair --file <path> [--dry-run]');
100
+ process.exit(2);
101
+ }
102
+ const filePath = args[fileIdx + 1];
103
+ const dryRun = args.includes('--dry-run');
104
+
105
+ const r = repairAuditFile(filePath, { dryRun });
106
+ if (r.truncated) {
107
+ console.log(`occasio audit repair: ${r.detail}`);
108
+ return;
109
+ }
110
+ if (r.wouldTruncate) {
111
+ console.log(`occasio audit repair (dry-run): ${r.detail}`);
112
+ console.log('Re-run without --dry-run to apply.');
113
+ return;
114
+ }
115
+ console.log(`occasio audit repair: nothing to do — ${r.detail}`);
116
+ }
117
+
118
+ module.exports = { repairAuditFile, runRepairCli };
@@ -55,6 +55,8 @@ function verifyFile(filePath = DEFAULT_LOG) {
55
55
  let legacy = 0, chained = 0;
56
56
  let expectedPrevHash = null; // null = no chained row seen yet
57
57
  let firstHash = null, lastHash = null;
58
+ const seenSchemaVersions = new Set();
59
+ const SUPPORTED_SCHEMA_VERSIONS = new Set([1]);
58
60
 
59
61
  for (let i = 0; i < lines.length; i++) {
60
62
  let row;
@@ -72,6 +74,23 @@ function verifyFile(filePath = DEFAULT_LOG) {
72
74
 
73
75
  chained++;
74
76
 
77
+ // Schema-version policy:
78
+ // - rows with audit_schema=undefined are legacy (pre-versioning) and
79
+ // verify as before — they are valid.
80
+ // - rows with audit_schema=1 verify normally.
81
+ // - rows with audit_schema=N for unknown N record a non-fatal warning
82
+ // in errors with a `warning: true` marker; ok-state is unaffected.
83
+ if (row.audit_schema !== undefined) {
84
+ seenSchemaVersions.add(row.audit_schema);
85
+ if (!SUPPORTED_SCHEMA_VERSIONS.has(row.audit_schema)) {
86
+ errors.push({
87
+ line: i + 1,
88
+ detail: `unknown audit_schema version ${row.audit_schema} (this build supports: ${[...SUPPORTED_SCHEMA_VERSIONS].join(',')})`,
89
+ warning: true,
90
+ });
91
+ }
92
+ }
93
+
75
94
  if (expectedPrevHash === null) {
76
95
  // First chained row: prev_hash must be GENESIS.
77
96
  firstHash = row.prev_hash;
@@ -99,15 +118,30 @@ function verifyFile(filePath = DEFAULT_LOG) {
99
118
  lastHash = storedHash;
100
119
  }
101
120
 
102
- return { ok: errors.length === 0, total: lines.length, legacy, chained, errors, firstHash, lastHash };
121
+ // Warnings (unknown schema versions) do not flip ok=false they are
122
+ // forward-compatibility hints, not chain breakage.
123
+ const fatal = errors.filter(e => !e.warning);
124
+ return {
125
+ ok: fatal.length === 0,
126
+ total: lines.length, legacy, chained,
127
+ errors,
128
+ firstHash, lastHash,
129
+ schemaVersions: [...seenSchemaVersions],
130
+ };
103
131
  }
104
132
 
105
133
  function runAuditCli(args) {
106
134
  const sub = args[0];
107
135
 
136
+ // Dispatch sub-commands. `repair` is forwarded to src/audit/repair.js.
137
+ if (sub === 'repair') {
138
+ const { runRepairCli } = require('./repair');
139
+ return runRepairCli(args.slice(1));
140
+ }
141
+
108
142
  // Accept: `occasio audit`, `occasio audit verify`, `occasio audit verify --file <path>`
109
143
  if (sub && sub !== 'verify' && !sub.startsWith('-')) {
110
- console.error(`Unknown audit subcommand: ${sub}\nUsage: occasio audit [verify] [--file <path>]`);
144
+ console.error(`Unknown audit subcommand: ${sub}\nUsage: occasio audit [verify|repair] [--file <path>]`);
111
145
  process.exit(1);
112
146
  }
113
147
 
package/src/boundary.js CHANGED
@@ -116,7 +116,7 @@ function fmtBytes(b) {
116
116
  return `${(b / 1024).toFixed(1)} KB`;
117
117
  }
118
118
 
119
- function renderBoundaryView(view, opts = {}) {
119
+ function renderBoundaryView(view, _opts = {}) {
120
120
  if (!view) return '';
121
121
  const lines = [];
122
122
  const tag = view.event_type ? `[${view.event_type}]` : '';
package/src/classifier.js CHANGED
@@ -37,7 +37,7 @@ const FEEDBACK_LOG = path.join(os.homedir(), '.occasio', 'routing-feedback.jsonl
37
37
  * @param {string} [context] reserved for future ML use
38
38
  * @returns {{ local: boolean, confidence: number, reason: string }}
39
39
  */
40
- function routeLocally(toolName, command, context = '') {
40
+ function routeLocally(toolName, command, _context = '') {
41
41
  if (toolName !== 'Bash') {
42
42
  return { local: false, confidence: 1.0, reason: 'non-bash tool' };
43
43
  }
@@ -0,0 +1,55 @@
1
+ // `occasio clear` — reset session + today's log.
2
+ // `occasio clear --history` — wipe ALL historical logs + blocked-secret records.
3
+ //
4
+ // Destructive but bounded to ~/.occasio/. session.json is unconditionally
5
+ // removed; logs are removed file-by-file (no rmdir) so an exotic
6
+ // permission error on one file does not abort the rest.
7
+
8
+ 'use strict';
9
+
10
+ const fs = require('fs');
11
+ const os = require('os');
12
+ const path = require('path');
13
+
14
+ const LOG_DIR = path.join(os.homedir(), '.occasio');
15
+ const SESSION_FILE = path.join(LOG_DIR, 'session.json');
16
+
17
+ const col = {
18
+ g: s => `\x1b[32m${s}\x1b[0m`,
19
+ d: s => `\x1b[2m${s}\x1b[0m`,
20
+ };
21
+
22
+ function todayStr() {
23
+ const d = new Date();
24
+ return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
25
+ }
26
+ function getLogFile() { return path.join(LOG_DIR, 'logs', `${todayStr()}.jsonl`); }
27
+
28
+ function ensureDirs() {
29
+ for (const sub of ['', 'logs', 'reports', 'blocked', 'distilled']) {
30
+ const d = path.join(LOG_DIR, sub);
31
+ if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true });
32
+ }
33
+ }
34
+
35
+ function run(args) {
36
+ ensureDirs();
37
+ const clearAll = (args || []).includes('--history');
38
+ if (clearAll) {
39
+ const logsDir = path.join(LOG_DIR, 'logs');
40
+ const blockedDir = path.join(LOG_DIR, 'blocked');
41
+ let n = 0;
42
+ for (const dir of [logsDir, blockedDir]) {
43
+ try { for (const f of fs.readdirSync(dir)) { fs.unlinkSync(path.join(dir, f)); n++; } } catch { /* ignore */ }
44
+ }
45
+ try { fs.unlinkSync(SESSION_FILE); } catch { /* ignore */ }
46
+ console.log(col.g(`✓ Cleared all history (${n} log files) and session data`));
47
+ } else {
48
+ try { fs.unlinkSync(getLogFile()); } catch { /* ignore */ }
49
+ try { fs.unlinkSync(SESSION_FILE); } catch { /* ignore */ }
50
+ console.log(col.g("✓ Cleared today's log and session data"));
51
+ console.log(col.d(' Use --history to wipe all historical logs'));
52
+ }
53
+ }
54
+
55
+ module.exports = { run };