@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.
- package/README.md +1 -0
- package/docs/ADAPTER-STAGE-2-MIGRATION.md +59 -0
- package/docs/ARCHITECTURE.md +171 -0
- package/docs/STAGE-2-STEP-5-SHELL-PLAN.md +107 -0
- package/docs/THREAT-MODEL.md +195 -0
- package/docs/edr-calibration.md +29 -0
- package/package.json +12 -2
- package/src/adapters/claude-code.js +1 -2
- package/src/adapters/computer-use.js +1 -1
- package/src/anomaly/cli.js +4 -1
- package/src/anomaly/detectors/deny-rate.js +2 -1
- package/src/anomaly/detectors/file-read-volume.js +2 -1
- package/src/anomaly/index.js +5 -0
- package/src/attest/check-summary.js +1 -1
- package/src/attest/index.js +14 -1
- package/src/audit/jsonl-auditor.js +180 -14
- package/src/audit/repair.js +118 -0
- package/src/audit/verifier.js +36 -2
- package/src/boundary.js +1 -1
- package/src/classifier.js +1 -1
- package/src/cli/clear.js +55 -0
- package/src/cli/help.js +102 -0
- package/src/cli/register.js +90 -0
- package/src/cli/status.js +94 -0
- package/src/cost/prices.js +106 -0
- package/src/dashboard.js +2 -3
- package/src/distiller.js +1 -1
- package/src/executor/dispatcher.js +2 -2
- package/src/executor/native-handlers/glob.js +173 -0
- package/src/executor/native-handlers/grep.js +258 -0
- package/src/executor/native-handlers/read.js +99 -0
- package/src/executor/native-handlers/todo.js +56 -0
- package/src/harness.js +8 -10
- package/src/index.js +26 -283
- package/src/inspect.js +1 -1
- package/src/interceptor.js +9 -29
- package/src/ledger.js +2 -3
- package/src/mcp-experiment.js +4 -4
- package/src/mcp-server.js +3 -3
- package/src/policy/doctor.js +2 -2
- package/src/policy/engine.js +0 -1
- package/src/policy/init.js +1 -1
- package/src/policy/loader.js +3 -3
- package/src/policy/show.js +1 -2
- package/src/preflight/cli.js +0 -1
- package/src/preflight/miner.js +3 -6
- package/src/redteam.js +1 -2
- package/src/replay.js +1 -1
- package/src/report/index.js +0 -4
- package/src/runtime.js +42 -444
- package/src/selftest.js +1 -1
- package/src/session.js +1 -1
package/src/anomaly/cli.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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';
|
package/src/anomaly/index.js
CHANGED
|
@@ -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) {
|
package/src/attest/index.js
CHANGED
|
@@ -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 {
|
|
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
|
-
//
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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)
|
|
50
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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 = {
|
|
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 };
|
package/src/audit/verifier.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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,
|
|
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
|
}
|
package/src/cli/clear.js
ADDED
|
@@ -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 };
|