@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.
Files changed (92) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +10 -0
  3. package/README.md +216 -0
  4. package/bin/occasio-mcp.js +5 -0
  5. package/bin/occasio.js +2 -0
  6. package/bin/supervisor/README.md +90 -0
  7. package/bin/supervisor/com.occasio.proxy.plist.template +36 -0
  8. package/bin/supervisor/install-windows-task.ps1 +48 -0
  9. package/bin/supervisor/occasio.service +18 -0
  10. package/docs/AUDIT.md +120 -0
  11. package/docs/attest_verify.py +283 -0
  12. package/docs/audit_walker.py +65 -0
  13. package/docs/canonicalize.py +99 -0
  14. package/docs/compliance-mapping.md +93 -0
  15. package/docs/demos/mcp-block.md +148 -0
  16. package/docs/edr-calibration.md +73 -0
  17. package/docs/edr-demo.md +83 -0
  18. package/docs/python-verifier.md +74 -0
  19. package/docs/reference-pipeline.md +140 -0
  20. package/package.json +69 -0
  21. package/policy-templates/dev-default.yml +84 -0
  22. package/policy-templates/finance.yml +61 -0
  23. package/policy-templates/strict.yml +49 -0
  24. package/schemas/agent-attestation-v1.json +190 -0
  25. package/schemas/occasio-policy.schema.json +99 -0
  26. package/spec/agent-attestation/v1/README.md +137 -0
  27. package/src/adapters/claude-code.js +518 -0
  28. package/src/adapters/cline.js +161 -0
  29. package/src/adapters/computer-use-cli.js +198 -0
  30. package/src/adapters/computer-use.js +227 -0
  31. package/src/analyzer.js +170 -0
  32. package/src/anomaly/cli.js +143 -0
  33. package/src/anomaly/detectors/deny-rate.js +84 -0
  34. package/src/anomaly/detectors/file-read-volume.js +109 -0
  35. package/src/anomaly/detectors/secret-redact-rate.js +107 -0
  36. package/src/anomaly/detectors/unknown-tool-input.js +83 -0
  37. package/src/anomaly/index.js +169 -0
  38. package/src/attest/canonicalize.js +97 -0
  39. package/src/attest/index.js +355 -0
  40. package/src/attest/run-slice.js +57 -0
  41. package/src/attest/sign.js +186 -0
  42. package/src/attest/verify.js +192 -0
  43. package/src/audit/errors.js +21 -0
  44. package/src/audit/input-normalizer.js +121 -0
  45. package/src/audit/jsonl-auditor.js +178 -0
  46. package/src/audit/verifier.js +152 -0
  47. package/src/baseline.js +507 -0
  48. package/src/boundary.js +238 -0
  49. package/src/budget.js +42 -0
  50. package/src/classifier.js +115 -0
  51. package/src/context-budget.js +77 -0
  52. package/src/core/boundary-event.js +75 -0
  53. package/src/core/decision.js +61 -0
  54. package/src/core/pipeline.js +66 -0
  55. package/src/core/tool-names.js +105 -0
  56. package/src/dashboard.js +892 -0
  57. package/src/demo/README.md +31 -0
  58. package/src/demo/anomalies-demo.js +211 -0
  59. package/src/demo/attest-demo.js +198 -0
  60. package/src/distiller.js +155 -0
  61. package/src/embeddings.json +72 -0
  62. package/src/executor/dispatcher.js +230 -0
  63. package/src/harness.js +817 -0
  64. package/src/index.js +1711 -0
  65. package/src/inspect.js +329 -0
  66. package/src/interceptor.js +1198 -0
  67. package/src/lao.js +185 -0
  68. package/src/lao_prep.py +119 -0
  69. package/src/ledger.js +209 -0
  70. package/src/mcp-experiment.js +140 -0
  71. package/src/mcp-normalize.js +139 -0
  72. package/src/mcp-server.js +320 -0
  73. package/src/outbound-policy.js +433 -0
  74. package/src/policy/built-in-classifiers.js +78 -0
  75. package/src/policy/doctor.js +226 -0
  76. package/src/policy/engine.js +339 -0
  77. package/src/policy/init.js +153 -0
  78. package/src/policy/loader.js +448 -0
  79. package/src/policy/rules-default.js +36 -0
  80. package/src/policy/shell-path.js +135 -0
  81. package/src/policy/show.js +196 -0
  82. package/src/policy/validate.js +310 -0
  83. package/src/preflight/cli.js +164 -0
  84. package/src/preflight/miner.js +329 -0
  85. package/src/proxy/agent-router.js +93 -0
  86. package/src/redteam.js +428 -0
  87. package/src/replay.js +446 -0
  88. package/src/report/index.js +224 -0
  89. package/src/runtime.js +595 -0
  90. package/src/scanner/index.js +49 -0
  91. package/src/selftest.js +192 -0
  92. 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 };