@occasiolabs/occasio 0.8.1
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/LICENSE +202 -0
- package/NOTICE +10 -0
- package/README.md +216 -0
- package/bin/occasio-mcp.js +5 -0
- package/bin/occasio.js +2 -0
- package/bin/supervisor/README.md +90 -0
- package/bin/supervisor/com.occasio.proxy.plist.template +36 -0
- package/bin/supervisor/install-windows-task.ps1 +48 -0
- package/bin/supervisor/occasio.service +18 -0
- package/docs/AUDIT.md +120 -0
- package/docs/attest_verify.py +283 -0
- package/docs/audit_walker.py +65 -0
- package/docs/canonicalize.py +99 -0
- package/docs/compliance-mapping.md +93 -0
- package/docs/demos/mcp-block.md +148 -0
- package/docs/edr-calibration.md +73 -0
- package/docs/edr-demo.md +83 -0
- package/docs/python-verifier.md +74 -0
- package/docs/reference-pipeline.md +140 -0
- package/package.json +69 -0
- package/policy-templates/dev-default.yml +84 -0
- package/policy-templates/finance.yml +61 -0
- package/policy-templates/strict.yml +49 -0
- package/schemas/agent-attestation-v1.json +190 -0
- package/schemas/occasio-policy.schema.json +99 -0
- package/spec/agent-attestation/v1/README.md +137 -0
- package/src/adapters/claude-code.js +518 -0
- package/src/adapters/cline.js +161 -0
- package/src/adapters/computer-use-cli.js +198 -0
- package/src/adapters/computer-use.js +227 -0
- package/src/analyzer.js +170 -0
- package/src/anomaly/cli.js +143 -0
- package/src/anomaly/detectors/deny-rate.js +84 -0
- package/src/anomaly/detectors/file-read-volume.js +109 -0
- package/src/anomaly/detectors/secret-redact-rate.js +107 -0
- package/src/anomaly/detectors/unknown-tool-input.js +83 -0
- package/src/anomaly/index.js +169 -0
- package/src/attest/canonicalize.js +97 -0
- package/src/attest/index.js +355 -0
- package/src/attest/run-slice.js +57 -0
- package/src/attest/sign.js +186 -0
- package/src/attest/verify.js +192 -0
- package/src/audit/errors.js +21 -0
- package/src/audit/input-normalizer.js +121 -0
- package/src/audit/jsonl-auditor.js +178 -0
- package/src/audit/verifier.js +152 -0
- package/src/baseline.js +507 -0
- package/src/boundary.js +238 -0
- package/src/budget.js +42 -0
- package/src/classifier.js +115 -0
- package/src/context-budget.js +77 -0
- package/src/core/boundary-event.js +75 -0
- package/src/core/decision.js +61 -0
- package/src/core/pipeline.js +66 -0
- package/src/core/tool-names.js +105 -0
- package/src/dashboard.js +892 -0
- package/src/demo/README.md +31 -0
- package/src/demo/anomalies-demo.js +211 -0
- package/src/demo/attest-demo.js +198 -0
- package/src/distiller.js +155 -0
- package/src/embeddings.json +72 -0
- package/src/executor/dispatcher.js +230 -0
- package/src/harness.js +817 -0
- package/src/index.js +1711 -0
- package/src/inspect.js +329 -0
- package/src/interceptor.js +1198 -0
- package/src/lao.js +185 -0
- package/src/lao_prep.py +119 -0
- package/src/ledger.js +209 -0
- package/src/mcp-experiment.js +140 -0
- package/src/mcp-normalize.js +139 -0
- package/src/mcp-server.js +320 -0
- package/src/outbound-policy.js +433 -0
- package/src/policy/built-in-classifiers.js +78 -0
- package/src/policy/doctor.js +226 -0
- package/src/policy/engine.js +339 -0
- package/src/policy/init.js +153 -0
- package/src/policy/loader.js +448 -0
- package/src/policy/rules-default.js +36 -0
- package/src/policy/shell-path.js +135 -0
- package/src/policy/show.js +196 -0
- package/src/policy/validate.js +310 -0
- package/src/preflight/cli.js +164 -0
- package/src/preflight/miner.js +329 -0
- package/src/proxy/agent-router.js +93 -0
- package/src/redteam.js +428 -0
- package/src/replay.js +446 -0
- package/src/report/index.js +224 -0
- package/src/runtime.js +595 -0
- package/src/scanner/index.js +49 -0
- package/src/selftest.js +192 -0
- package/src/session.js +36 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* JsonlAuditor — appends one tamper-evident record per (event, decision, result)
|
|
5
|
+
* tuple to a JSONL file.
|
|
6
|
+
*
|
|
7
|
+
* Each row carries:
|
|
8
|
+
* prev_hash — SHA-256 hex of the previous row (GENESIS sentinel for first row)
|
|
9
|
+
* hash — SHA-256 hex of JSON.stringify(rowWithoutHash)
|
|
10
|
+
*
|
|
11
|
+
* The chain is continuous across process restarts: on startup the auditor reads
|
|
12
|
+
* the last hash from the existing file. Legacy rows (pre-hash-chain, no hash
|
|
13
|
+
* field) are preserved as-is; the chain starts at GENESIS from the first
|
|
14
|
+
* hash-bearing row.
|
|
15
|
+
*
|
|
16
|
+
* Verification: see src/audit/verifier.js / `occasio audit verify`.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const fs = require('fs');
|
|
20
|
+
const path = require('path');
|
|
21
|
+
const os = require('os');
|
|
22
|
+
const crypto = require('crypto');
|
|
23
|
+
const { normalizeToolInputsForAudit } = require('./input-normalizer');
|
|
24
|
+
|
|
25
|
+
const DEFAULT_LOG = path.join(os.homedir(), '.occasio', 'pipeline-events.jsonl');
|
|
26
|
+
|
|
27
|
+
// Sentinel prev_hash for the first row in a chain (64 zero hex digits).
|
|
28
|
+
const GENESIS = '0'.repeat(64);
|
|
29
|
+
|
|
30
|
+
function sha256hex(str) {
|
|
31
|
+
return crypto.createHash('sha256').update(str, 'utf8').digest('hex');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Hash a row object that already contains prev_hash but not hash.
|
|
35
|
+
// Field order must be stable (guaranteed by the explicit object literal in record()).
|
|
36
|
+
function computeHash(rowWithoutHash) {
|
|
37
|
+
return sha256hex(JSON.stringify(rowWithoutHash));
|
|
38
|
+
}
|
|
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--) {
|
|
47
|
+
try {
|
|
48
|
+
const row = JSON.parse(lines[i]);
|
|
49
|
+
if (typeof row.hash === 'string' && row.hash.length === 64) return row.hash;
|
|
50
|
+
} catch {}
|
|
51
|
+
}
|
|
52
|
+
return GENESIS;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Build an auditor that writes to the given file (default: pipeline-events.jsonl).
|
|
57
|
+
* Returns { record(event, decision, result), file }.
|
|
58
|
+
*
|
|
59
|
+
* v0.6.4 contract change: record() returns { ok: true } on a successful append,
|
|
60
|
+
* or { ok: false, error, droppedRow } if the append throws. Callers (pipeline)
|
|
61
|
+
* promote ok=false into AuditWriteError so the proxy can fail-closed for
|
|
62
|
+
* governance — a missing audit row must not coexist with a successful cloud
|
|
63
|
+
* call. prevHash is only advanced on a successful append, keeping the
|
|
64
|
+
* in-memory chain consistent with what is on disk if the proxy is restarted.
|
|
65
|
+
*/
|
|
66
|
+
function createAuditor(filePath = DEFAULT_LOG) {
|
|
67
|
+
try { fs.mkdirSync(path.dirname(filePath), { recursive: true }); } catch {}
|
|
68
|
+
|
|
69
|
+
let prevHash = loadPrevHash(filePath);
|
|
70
|
+
|
|
71
|
+
function record(event, decision, result) {
|
|
72
|
+
if (!event || !decision) return { ok: true };
|
|
73
|
+
// Field order is explicit and must remain stable — computeHash depends on it.
|
|
74
|
+
// The Python walker in docs/audit_walker.py mirrors this order; any change
|
|
75
|
+
// here without updating that walker breaks independent verifiability.
|
|
76
|
+
const row = {
|
|
77
|
+
ts: event.timestamp,
|
|
78
|
+
event_id: event.id,
|
|
79
|
+
session_id: event.sessionId,
|
|
80
|
+
run_id: event.runId,
|
|
81
|
+
agent: event.agent,
|
|
82
|
+
protocol: event.protocol,
|
|
83
|
+
direction: event.direction,
|
|
84
|
+
kind: event.kind,
|
|
85
|
+
tool_name: event.toolName,
|
|
86
|
+
tool_inputs: normalizeToolInputsForAudit(event.toolName, event.toolInput),
|
|
87
|
+
action: decision.action,
|
|
88
|
+
reason: decision.reason,
|
|
89
|
+
policy_source: decision.policySource,
|
|
90
|
+
executor: decision.executor,
|
|
91
|
+
transform: decision.transform,
|
|
92
|
+
result_kind: result?.passThrough ? 'pass' :
|
|
93
|
+
result?.blocked ? 'block' :
|
|
94
|
+
result?.transformed ? 'transform' :
|
|
95
|
+
result?.exitCode !== undefined ? 'local' : 'unknown',
|
|
96
|
+
exit_code: typeof result?.exitCode === 'number' ? result.exitCode : undefined,
|
|
97
|
+
secrets_redacted: result?.secretsRedacted?.length || undefined,
|
|
98
|
+
distilled: result?.distilled || undefined,
|
|
99
|
+
tokens_saved: result?.savedTokens || undefined,
|
|
100
|
+
prev_hash: prevHash,
|
|
101
|
+
};
|
|
102
|
+
row.hash = computeHash(row);
|
|
103
|
+
try {
|
|
104
|
+
fs.appendFileSync(filePath, JSON.stringify(row) + '\n');
|
|
105
|
+
} catch (e) {
|
|
106
|
+
// Do NOT advance prevHash — a dropped row must not poison the chain.
|
|
107
|
+
// The dropped row is returned so the caller can surface it to stderr
|
|
108
|
+
// before aborting the proxy.
|
|
109
|
+
return { ok: false, error: e, droppedRow: row };
|
|
110
|
+
}
|
|
111
|
+
prevHash = row.hash;
|
|
112
|
+
return { ok: true };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* v0.6.6: write a synthetic `policy_loaded` row.
|
|
117
|
+
*
|
|
118
|
+
* The row uses the same field order as record() so the canonical
|
|
119
|
+
* serialization (and therefore the Python independent walker) does not
|
|
120
|
+
* change. Field semantics for this row kind:
|
|
121
|
+
*
|
|
122
|
+
* kind: 'policy_loaded'
|
|
123
|
+
* tool_name: 'policy_loaded' (placeholder; not a real tool)
|
|
124
|
+
* tool_inputs: { policy_hash, policy_path, version }
|
|
125
|
+
* action: 'INFO' (not a Decision action)
|
|
126
|
+
* reason: 'policy-loaded'
|
|
127
|
+
* policy_source: 'user' | 'default'
|
|
128
|
+
* result_kind: omitted (no Result, per v0.6.6 design note)
|
|
129
|
+
*
|
|
130
|
+
* Returns { ok: true } on success; { ok: false, error, droppedRow } on
|
|
131
|
+
* append failure, mirroring record()'s contract so the caller can
|
|
132
|
+
* propagate AuditWriteError uniformly.
|
|
133
|
+
*/
|
|
134
|
+
function recordPolicyLoaded({ hash, path: policyPath, version, source }) {
|
|
135
|
+
const row = {
|
|
136
|
+
ts: new Date().toISOString(),
|
|
137
|
+
event_id: crypto.randomUUID(),
|
|
138
|
+
session_id: undefined,
|
|
139
|
+
run_id: undefined,
|
|
140
|
+
agent: 'occasio',
|
|
141
|
+
protocol: 'internal',
|
|
142
|
+
direction: 'inbound',
|
|
143
|
+
kind: 'policy_loaded',
|
|
144
|
+
tool_name: 'policy_loaded',
|
|
145
|
+
tool_inputs: {
|
|
146
|
+
policy_hash: hash,
|
|
147
|
+
policy_path: policyPath,
|
|
148
|
+
version: typeof version === 'number' ? version : 1,
|
|
149
|
+
},
|
|
150
|
+
action: 'INFO',
|
|
151
|
+
reason: 'policy-loaded',
|
|
152
|
+
policy_source: source === 'user' ? 'user' : 'default',
|
|
153
|
+
executor: undefined,
|
|
154
|
+
transform: undefined,
|
|
155
|
+
// result_kind intentionally omitted: a policy_loaded event has no
|
|
156
|
+
// dispatcher Result. Confirmed by the v0.6.6 design note in
|
|
157
|
+
// docs/AUDIT.md §1 — the row format spec marks result_kind absent
|
|
158
|
+
// for kind=policy_loaded rows.
|
|
159
|
+
exit_code: undefined,
|
|
160
|
+
secrets_redacted: undefined,
|
|
161
|
+
distilled: undefined,
|
|
162
|
+
tokens_saved: undefined,
|
|
163
|
+
prev_hash: prevHash,
|
|
164
|
+
};
|
|
165
|
+
row.hash = computeHash(row);
|
|
166
|
+
try {
|
|
167
|
+
fs.appendFileSync(filePath, JSON.stringify(row) + '\n');
|
|
168
|
+
} catch (e) {
|
|
169
|
+
return { ok: false, error: e, droppedRow: row };
|
|
170
|
+
}
|
|
171
|
+
prevHash = row.hash;
|
|
172
|
+
return { ok: true };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return { record, recordPolicyLoaded, file: filePath };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
module.exports = { createAuditor, DEFAULT_LOG, GENESIS, computeHash };
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* verifier.js — hash-chain verification for pipeline-events.jsonl.
|
|
5
|
+
*
|
|
6
|
+
* verifyFile(filePath) → { ok, total, legacy, chained, errors, firstHash, lastHash }
|
|
7
|
+
* runAuditCli(args) — CLI entry point for `occasio audit [verify]`
|
|
8
|
+
*
|
|
9
|
+
* Chain rules:
|
|
10
|
+
* 1. The first hash-chained row must have prev_hash === GENESIS.
|
|
11
|
+
* 2. Each subsequent chained row's prev_hash must equal the previous row's hash.
|
|
12
|
+
* 3. Every chained row's hash must equal sha256(JSON.stringify(rowWithoutHash)).
|
|
13
|
+
*
|
|
14
|
+
* Legacy rows (no hash field) are skipped and counted separately.
|
|
15
|
+
* They are not verifiable; their presence does not break the chain.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
const { DEFAULT_LOG, GENESIS, computeHash } = require('./jsonl-auditor');
|
|
20
|
+
|
|
21
|
+
const col = {
|
|
22
|
+
r: s => `\x1b[31m${s}\x1b[0m`,
|
|
23
|
+
g: s => `\x1b[32m${s}\x1b[0m`,
|
|
24
|
+
y: s => `\x1b[33m${s}\x1b[0m`,
|
|
25
|
+
d: s => `\x1b[2m${s}\x1b[0m`,
|
|
26
|
+
b: s => `\x1b[1m${s}\x1b[0m`,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Verify the hash chain in a pipeline-events.jsonl file.
|
|
31
|
+
*
|
|
32
|
+
* Returns:
|
|
33
|
+
* ok — true iff no errors found in the chained segment
|
|
34
|
+
* total — total non-empty lines in the file
|
|
35
|
+
* legacy — rows without hash/prev_hash (not verifiable)
|
|
36
|
+
* chained — rows with hash/prev_hash (verified)
|
|
37
|
+
* errors — [{ line, detail }] for any broken links or hash mismatches
|
|
38
|
+
* firstHash — prev_hash of the first chained row (should be GENESIS)
|
|
39
|
+
* lastHash — hash of the last chained row (null if none)
|
|
40
|
+
*/
|
|
41
|
+
function verifyFile(filePath = DEFAULT_LOG) {
|
|
42
|
+
let content;
|
|
43
|
+
try {
|
|
44
|
+
content = fs.readFileSync(filePath, 'utf8');
|
|
45
|
+
} catch (e) {
|
|
46
|
+
return {
|
|
47
|
+
ok: false, total: 0, legacy: 0, chained: 0,
|
|
48
|
+
errors: [{ line: 0, detail: `Cannot read file: ${e.message}` }],
|
|
49
|
+
firstHash: null, lastHash: null,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const lines = content.split('\n').filter(Boolean);
|
|
54
|
+
const errors = [];
|
|
55
|
+
let legacy = 0, chained = 0;
|
|
56
|
+
let expectedPrevHash = null; // null = no chained row seen yet
|
|
57
|
+
let firstHash = null, lastHash = null;
|
|
58
|
+
|
|
59
|
+
for (let i = 0; i < lines.length; i++) {
|
|
60
|
+
let row;
|
|
61
|
+
try {
|
|
62
|
+
row = JSON.parse(lines[i]);
|
|
63
|
+
} catch {
|
|
64
|
+
errors.push({ line: i + 1, detail: 'Invalid JSON — cannot parse row' });
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (typeof row.hash !== 'string' || row.hash.length !== 64) {
|
|
69
|
+
legacy++;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
chained++;
|
|
74
|
+
|
|
75
|
+
if (expectedPrevHash === null) {
|
|
76
|
+
// First chained row: prev_hash must be GENESIS.
|
|
77
|
+
firstHash = row.prev_hash;
|
|
78
|
+
if (row.prev_hash !== GENESIS) {
|
|
79
|
+
errors.push({ line: i + 1, detail: `first chained row has unexpected prev_hash (expected GENESIS, got ${String(row.prev_hash).slice(0, 16)}…)` });
|
|
80
|
+
}
|
|
81
|
+
expectedPrevHash = GENESIS;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Chain continuity: prev_hash must equal the previous row's hash.
|
|
85
|
+
if (row.prev_hash !== expectedPrevHash) {
|
|
86
|
+
errors.push({ line: i + 1, detail: `chain broken — prev_hash mismatch (expected ${expectedPrevHash.slice(0, 12)}…, got ${String(row.prev_hash).slice(0, 12)}…)` });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Content integrity: stored hash must equal recomputed hash.
|
|
90
|
+
const { hash: storedHash, ...rowWithoutHash } = row;
|
|
91
|
+
const recomputed = computeHash(rowWithoutHash);
|
|
92
|
+
if (recomputed !== storedHash) {
|
|
93
|
+
errors.push({ line: i + 1, detail: `hash mismatch — row was modified (stored ${storedHash.slice(0, 12)}…, computed ${recomputed.slice(0, 12)}…)` });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Advance chain using the recomputed hash so that a tampered row causes the
|
|
97
|
+
// next row's prev_hash check to fail too — makes cascade breaks visible.
|
|
98
|
+
expectedPrevHash = recomputed;
|
|
99
|
+
lastHash = storedHash;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return { ok: errors.length === 0, total: lines.length, legacy, chained, errors, firstHash, lastHash };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function runAuditCli(args) {
|
|
106
|
+
const sub = args[0];
|
|
107
|
+
|
|
108
|
+
// Accept: `occasio audit`, `occasio audit verify`, `occasio audit verify --file <path>`
|
|
109
|
+
if (sub && sub !== 'verify' && !sub.startsWith('-')) {
|
|
110
|
+
console.error(`Unknown audit subcommand: ${sub}\nUsage: occasio audit [verify] [--file <path>]`);
|
|
111
|
+
process.exit(1);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const rest = sub === 'verify' ? args.slice(1) : args;
|
|
115
|
+
const fileIdx = rest.indexOf('--file');
|
|
116
|
+
const filePath = fileIdx !== -1 && rest[fileIdx + 1] ? rest[fileIdx + 1] : DEFAULT_LOG;
|
|
117
|
+
|
|
118
|
+
console.log(`\n${col.b('Occasio — audit log verification')}\n`);
|
|
119
|
+
console.log(` File: ${col.d(filePath)}`);
|
|
120
|
+
|
|
121
|
+
const r = verifyFile(filePath);
|
|
122
|
+
|
|
123
|
+
console.log(` Rows: ${r.total} total (${r.chained} chained, ${r.legacy} legacy/unverified)\n`);
|
|
124
|
+
|
|
125
|
+
if (r.total === 0) {
|
|
126
|
+
console.log(` ${col.d('Log is empty — nothing to verify.')}`);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (r.chained === 0) {
|
|
131
|
+
console.log(` ${col.y('⚠ No hash-chained rows found.')}`);
|
|
132
|
+
console.log(col.d(' All rows pre-date hash-chain support and are not verifiable.'));
|
|
133
|
+
console.log(col.d(' New sessions will start a fresh chain from the GENESIS sentinel.'));
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (r.ok) {
|
|
138
|
+
console.log(` ${col.g('✓ Chain intact')} (${r.chained} rows verified)`);
|
|
139
|
+
if (r.lastHash) {
|
|
140
|
+
console.log(` ${col.d('Last: ' + r.lastHash.slice(0, 32) + '…')}`);
|
|
141
|
+
}
|
|
142
|
+
} else {
|
|
143
|
+
console.log(` ${col.r('✗ Chain broken')} (${r.errors.length} error(s) detected)`);
|
|
144
|
+
for (const err of r.errors) {
|
|
145
|
+
console.log(` ${col.r('Line ' + err.line + ':')} ${err.detail}`);
|
|
146
|
+
}
|
|
147
|
+
console.log('');
|
|
148
|
+
process.exit(1);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
module.exports = { verifyFile, runAuditCli };
|