@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,169 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * anomaly/index.js — runtime anomaly detection over the tamper-evident audit
5
+ * chain (`~/.occasio/pipeline-events.jsonl`).
6
+ *
7
+ * Complements src/baseline.js (which does per-project, novelty-based detection
8
+ * over the session log) with **time-windowed rate detectors over the chain**.
9
+ * Where baseline.js answers "did this session do something it never did",
10
+ * this module answers "did the last 15 minutes look anomalous compared to the
11
+ * preceding history".
12
+ *
13
+ * Architecture
14
+ * loadChainRows(file) read + parse rows once
15
+ * splitByWindow(rows, ms, now) split into windowRows vs historical
16
+ * runDetectors(...) invoke every detector, collect alerts
17
+ *
18
+ * Each detector is a small module exporting:
19
+ * {
20
+ * id: 'deny-rate' | ...,
21
+ * label: 'Human-readable name',
22
+ * evaluate(windowRows, historicalRows, opts) → Alert[]
23
+ * }
24
+ *
25
+ * Alert shape (stable contract for CLI + chain-persist + tests)
26
+ * {
27
+ * detector_id, severity: 'low'|'medium'|'high',
28
+ * observed, baseline, message,
29
+ * rows_implicated: string[] // first ≤5 row hashes that triggered
30
+ * }
31
+ *
32
+ * Runner result shape:
33
+ * {
34
+ * alerts: Alert[] anomalies the detectors observed in the window
35
+ * errors: Error[] detectors that threw — bug reports, NOT anomalies
36
+ * window_start, window_end, window_rows, history_rows
37
+ * }
38
+ *
39
+ * Why alerts and errors are separate:
40
+ * A crashed detector is a *bug in the detector*, not an *anomaly in the
41
+ * chain*. Surfacing detector crashes as low-severity alerts trains
42
+ * compliance reviewers to dismiss real anomalies. Separate channels:
43
+ * alerts get human attention; errors go to the operator log and CI.
44
+ *
45
+ * Read-only by default. Persistence (writing alerts back into the chain as
46
+ * `kind:"anomaly_alert"` rows) is opt-in via the CLI's --persist flag and
47
+ * piggybacks the existing JsonlAuditor so alerts inherit the chain's
48
+ * tamper-evident property.
49
+ */
50
+
51
+ const fs = require('fs');
52
+ const path = require('path');
53
+ const os = require('os');
54
+
55
+ const DEFAULT_CHAIN = path.join(os.homedir(), '.occasio', 'pipeline-events.jsonl');
56
+ const DEFAULT_WINDOW_MS = 15 * 60 * 1000; // 15 minutes
57
+
58
+ // ── Built-in detectors ──────────────────────────────────────────────────────
59
+ // Order is the order alerts appear in CLI output. Keep deterministic.
60
+ function builtinDetectors() {
61
+ return [
62
+ require('./detectors/deny-rate'),
63
+ require('./detectors/file-read-volume'),
64
+ require('./detectors/unknown-tool-input'),
65
+ require('./detectors/secret-redact-rate'),
66
+ ];
67
+ }
68
+
69
+ // ── Chain row loader ─────────────────────────────────────────────────────────
70
+
71
+ function loadChainRows(filePath) {
72
+ let text;
73
+ try { text = fs.readFileSync(filePath, 'utf8'); }
74
+ catch { return []; }
75
+ const out = [];
76
+ for (const line of text.split('\n')) {
77
+ if (!line) continue;
78
+ try {
79
+ const row = JSON.parse(line);
80
+ if (row && row.ts) out.push(row);
81
+ } catch { /* skip malformed */ }
82
+ }
83
+ return out;
84
+ }
85
+
86
+ // ── Window split ─────────────────────────────────────────────────────────────
87
+ // Split rows into { window, historical }: window contains rows whose ts is
88
+ // within [now - windowMs, now]; historical contains everything strictly
89
+ // before that. We use rows[i].ts (ISO 8601) — parse failures fall through to
90
+ // historical (treat as old).
91
+ function splitByWindow(rows, windowMs, now) {
92
+ const nowMs = typeof now === 'number' ? now : (now ? new Date(now).getTime() : Date.now());
93
+ const cutoff = nowMs - windowMs;
94
+ const window = [];
95
+ const historical = [];
96
+ for (const r of rows) {
97
+ const t = Date.parse(r.ts);
98
+ if (!Number.isFinite(t)) { historical.push(r); continue; }
99
+ if (t >= cutoff && t <= nowMs) window.push(r);
100
+ else if (t < cutoff) historical.push(r);
101
+ // rows in the future (t > nowMs) are skipped — they're either clock skew
102
+ // or rows we shouldn't be evaluating yet
103
+ }
104
+ return { window, historical, windowStartMs: cutoff, windowEndMs: nowMs };
105
+ }
106
+
107
+ // ── Runner ───────────────────────────────────────────────────────────────────
108
+ function runDetectors({
109
+ chainFile = DEFAULT_CHAIN,
110
+ windowMs = DEFAULT_WINDOW_MS,
111
+ now = null,
112
+ detectors = null,
113
+ // For tests: pass rows directly instead of reading from disk.
114
+ rows = null,
115
+ } = {}) {
116
+ const allRows = rows || loadChainRows(chainFile);
117
+ const split = splitByWindow(allRows, windowMs, now);
118
+ const list = detectors || builtinDetectors();
119
+
120
+ const alerts = [];
121
+ const errors = [];
122
+ for (const d of list) {
123
+ try {
124
+ const out = d.evaluate(split.window, split.historical, {
125
+ windowMs, windowStartMs: split.windowStartMs, windowEndMs: split.windowEndMs,
126
+ });
127
+ if (!Array.isArray(out)) continue;
128
+ for (const a of out) {
129
+ if (!a) continue;
130
+ alerts.push({
131
+ detector_id: d.id,
132
+ severity: a.severity || 'medium',
133
+ observed: a.observed,
134
+ baseline: a.baseline,
135
+ message: a.message || `${d.label}: anomaly detected`,
136
+ rows_implicated: (a.rows_implicated || []).slice(0, 5),
137
+ });
138
+ }
139
+ } catch (e) {
140
+ // A crashed detector is a bug, not an anomaly. Surface it on the
141
+ // errors channel so operators / CI see it without contaminating
142
+ // the alerts stream that compliance reviews.
143
+ errors.push({
144
+ detector_id: d.id,
145
+ label: d.label,
146
+ error: e.message || String(e),
147
+ stack: e.stack || null,
148
+ });
149
+ }
150
+ }
151
+
152
+ return {
153
+ alerts,
154
+ errors,
155
+ window_start: new Date(split.windowStartMs).toISOString(),
156
+ window_end: new Date(split.windowEndMs).toISOString(),
157
+ window_rows: split.window.length,
158
+ history_rows: split.historical.length,
159
+ };
160
+ }
161
+
162
+ module.exports = {
163
+ runDetectors,
164
+ loadChainRows,
165
+ splitByWindow,
166
+ builtinDetectors,
167
+ DEFAULT_CHAIN,
168
+ DEFAULT_WINDOW_MS,
169
+ };
@@ -0,0 +1,97 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * canonicalize.js — deterministic JSON serialisation for predicate/payload
5
+ * byte-equivalence comparisons.
6
+ *
7
+ * This is a pragmatic subset of RFC 8785 (JSON Canonicalization Scheme):
8
+ * - object keys sorted lexicographically (UTF-16 code units; equivalent to
9
+ * default Array.prototype.sort on strings)
10
+ * - keys whose value is `undefined` are omitted (matches JSON.stringify)
11
+ * - arrays preserve order; `undefined` elements become `null`
12
+ * - numbers must be integers (see below); JSON.stringify form is used
13
+ * - strings use V8's JSON.stringify escaping
14
+ * - rejects: undefined at top level, non-finite numbers, NON-INTEGER
15
+ * numbers (see below), functions, symbols
16
+ *
17
+ * Why non-integer numbers are rejected (enforced cross-language invariant)
18
+ * JavaScript has a single `number` type. JSON.parse('1.0') yields the
19
+ * integer 1; JSON.stringify(1) emits '1'. Python distinguishes int from
20
+ * float: json.loads('1.0') yields float(1.0); json.dumps(1.0) emits
21
+ * '1.0'. If we silently accepted floats, the JS verifier and the Python
22
+ * verifier would canonicalize the same JSON file to different bytes —
23
+ * silent byte-equivalence breakage and the schema is no longer language-
24
+ * independent. We reject non-integer numbers explicitly on both sides,
25
+ * matching error messages, so the failure mode is loud and identical.
26
+ *
27
+ * Integer-valued floats (1.0 parsed by Python as float(1.0)) ARE
28
+ * accepted: both implementations coerce to the integer representation
29
+ * so a round-trip through either side produces the same canonical bytes.
30
+ *
31
+ * If a future schema requires decimal precision (it should not — counts
32
+ * and timestamps don't need it), encode as a string ("1.5") and parse
33
+ * numerically in the consumer. The canonicalize boundary stays integer-
34
+ * only.
35
+ *
36
+ * Where this deviates from strict RFC 8785:
37
+ * - Float serialisation: RFC 8785 mandates a specific ECMAScript form
38
+ * for non-integer floats. We sidestep this entirely by rejecting them
39
+ * above. The deviation is intentional and load-bearing for cross-
40
+ * language byte-equivalence; remove it only when adopting a vetted JCS
41
+ * library that handles ECMAScript Number.prototype.toString exactly.
42
+ * - Lone surrogate handling: JSON.stringify produces \uXXXX escapes which
43
+ * are valid RFC 8259. JCS specifies the same. Identical in practice.
44
+ *
45
+ * Why a tiny in-tree implementation instead of a dependency:
46
+ * - Must run unchanged in three places: Node (sign/verify), GitHub Action
47
+ * (no extra npm install before this runs), and the browser viewer
48
+ * (no build step). A copy-paste-ready ~30 lines is the cheapest path
49
+ * to "same bytes everywhere".
50
+ *
51
+ * IMPORTANT: keep this function logically identical to the inline copy in
52
+ * integrations/attest-view/viewer.js. Any change here must be mirrored there.
53
+ */
54
+
55
+ function canonicalize(value) {
56
+ if (value === null) return 'null';
57
+ switch (typeof value) {
58
+ case 'boolean':
59
+ return value ? 'true' : 'false';
60
+ case 'number':
61
+ if (!Number.isFinite(value)) {
62
+ throw new Error('canonicalize: non-finite number');
63
+ }
64
+ if (!Number.isInteger(value)) {
65
+ // See the header comment "Why non-integer numbers are rejected".
66
+ // If you hit this, encode the field as a string in the schema
67
+ // instead of changing this guard.
68
+ throw new Error(
69
+ 'canonicalize: non-integer number ' + value +
70
+ ' — cross-language byte-equivalence requires schema fields be ' +
71
+ 'integers or strings. Encode decimal values as strings.'
72
+ );
73
+ }
74
+ return JSON.stringify(value);
75
+ case 'string':
76
+ return JSON.stringify(value);
77
+ case 'object': {
78
+ if (Array.isArray(value)) {
79
+ return '[' + value.map(v =>
80
+ canonicalize(v === undefined ? null : v)
81
+ ).join(',') + ']';
82
+ }
83
+ const keys = Object.keys(value)
84
+ .filter(k => value[k] !== undefined)
85
+ .sort();
86
+ return '{' + keys.map(k =>
87
+ JSON.stringify(k) + ':' + canonicalize(value[k])
88
+ ).join(',') + '}';
89
+ }
90
+ case 'undefined':
91
+ throw new Error('canonicalize: undefined at top level');
92
+ default:
93
+ throw new Error('canonicalize: unsupported type ' + typeof value);
94
+ }
95
+ }
96
+
97
+ module.exports = { canonicalize };
@@ -0,0 +1,355 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * occasio attest — produce an AI-Agent Behavioral Attestation (v1) for a
5
+ * single Occasio session (run_id). The output is a self-contained JSON file
6
+ * that *commits* (via first_hash/last_hash) to a contiguous slice of the
7
+ * tamper-evident audit chain and summarises what happened during the run.
8
+ *
9
+ * Phase 0: unsigned attestations (`signature: null`). Phase 1 will wrap this
10
+ * object in an in-toto Statement envelope and Sigstore-sign it.
11
+ *
12
+ * Usage:
13
+ * occasio attest --run-id <uuid> [--out <path>] [--log <path>]
14
+ * [--policy <path>] [--stdout]
15
+ *
16
+ * Reads (all read-only — never mutates the chain):
17
+ * ~/.occasio/pipeline-events.jsonl (or --log)
18
+ * ~/.occasio/policy.yml (or --policy)
19
+ *
20
+ * Writes:
21
+ * ./attestation.json (or --out, or stdout with --stdout)
22
+ *
23
+ * Exit codes:
24
+ * 0 — success
25
+ * 1 — no events found for the given run_id (user error)
26
+ * 2 — invalid arguments / I/O error
27
+ */
28
+
29
+ const fs = require('fs');
30
+ const path = require('path');
31
+ const os = require('os');
32
+
33
+ const { loadRunSlice } = require('./run-slice');
34
+ const { computePolicyHash } = require('../policy/loader');
35
+
36
+ const SCHEMA_VERSION = '1.0.0';
37
+ const PREDICATE_TYPE =
38
+ 'https://github.com/occasiolabs/occasio/spec/agent-attestation/v1';
39
+ const GENESIS = '0'.repeat(64);
40
+ const VERIFIER_URL =
41
+ 'https://github.com/occasiolabs/occasio/blob/main/docs/audit_walker.py';
42
+
43
+ const DEFAULT_LOG = path.join(os.homedir(), '.occasio', 'pipeline-events.jsonl');
44
+ const DEFAULT_POLICY = path.join(os.homedir(), '.occasio', 'policy.yml');
45
+
46
+ // ── helpers ──────────────────────────────────────────────────────────────────
47
+
48
+ function isoSeconds(a, b) {
49
+ const ai = new Date(a).getTime();
50
+ const bi = new Date(b).getTime();
51
+ if (!Number.isFinite(ai) || !Number.isFinite(bi)) return 0;
52
+ return Math.max(0, Math.round((bi - ai) / 1000));
53
+ }
54
+
55
+ function offsetSeconds(start, then) {
56
+ const ai = new Date(start).getTime();
57
+ const bi = new Date(then).getTime();
58
+ if (!Number.isFinite(ai) || !Number.isFinite(bi)) return 0;
59
+ return Math.max(0, Math.round((bi - ai) / 1000));
60
+ }
61
+
62
+ // Best-effort rules digest from a parsed policy.yml. We deliberately do NOT
63
+ // require ../policy/loader.load() (which mutates module-level cache + fires
64
+ // change listeners) — `attest` is a pure read.
65
+ function readPolicyRulesDigest(policyFile) {
66
+ let text;
67
+ try { text = fs.readFileSync(policyFile, 'utf8'); }
68
+ catch { return null; }
69
+
70
+ // Tiny line-level scan — full parsing is overkill for a digest. Counts only.
71
+ let denyPaths = 0, denyPatterns = 0, blockSecrets = null;
72
+ let inDenyPaths = false, inDenyPatterns = false;
73
+ for (const raw of text.split('\n')) {
74
+ const line = raw.replace(/#.*$/, ''); // strip comments
75
+ if (/^\s*deny_paths\s*:/.test(line)) { inDenyPaths = true; inDenyPatterns = false; continue; }
76
+ if (/^\s*allow_paths\s*:/.test(line)) { inDenyPaths = false; inDenyPatterns = false; continue; }
77
+ if (/^\s*deny_patterns\s*:/.test(line)){ inDenyPatterns = true; inDenyPaths = false; continue; }
78
+ if (/^\s*tools\s*:/.test(line)) { inDenyPaths = false; inDenyPatterns = false; continue; }
79
+
80
+ if (/^\s*block_secrets_in_tool_results\s*:\s*(true|false)\b/.test(line)) {
81
+ blockSecrets = /true/.test(line);
82
+ }
83
+
84
+ if (inDenyPaths && /^\s*-\s+\S/.test(line)) denyPaths++;
85
+ if (inDenyPatterns && /^\s{2,}\S+\s*:/.test(line)) denyPatterns++;
86
+ }
87
+ return {
88
+ deny_paths_count: denyPaths,
89
+ deny_patterns_count: denyPatterns,
90
+ block_secrets: blockSecrets === null ? true : blockSecrets, // schema default
91
+ };
92
+ }
93
+
94
+ // Extract platform + model from the slice. Most rows don't carry model directly;
95
+ // model is best-effort sourced from the first row that has one.
96
+ function pickAgentMeta(events) {
97
+ let platform = null, model = null, session_id = null;
98
+ for (const e of events) {
99
+ if (!platform && typeof e.agent === 'string' && e.agent !== 'occasio') platform = e.agent;
100
+ if (!model && typeof e.model === 'string') model = e.model;
101
+ if (!session_id && typeof e.session_id === 'string') session_id = e.session_id;
102
+ if (platform && model && session_id) break;
103
+ }
104
+ return {
105
+ platform: platform || 'claude-code', // adapter default
106
+ model: model || null,
107
+ session_id: session_id || null,
108
+ };
109
+ }
110
+
111
+ function summariseExecution(events, startedAt) {
112
+ const summary = {
113
+ tool_calls: 0, local: 0, passed: 0, blocked: 0,
114
+ transformed: 0, secrets_redacted: 0,
115
+ blocked_events: [],
116
+ };
117
+ for (const e of events) {
118
+ if (e.kind !== 'tool_call') continue;
119
+ summary.tool_calls++;
120
+ switch (e.action) {
121
+ case 'LOCAL': summary.local++; break;
122
+ case 'PASS': summary.passed++; break;
123
+ case 'BLOCK': summary.blocked++; break;
124
+ case 'TRANSFORM': summary.transformed++; break;
125
+ }
126
+ if (typeof e.secrets_redacted === 'number' && e.secrets_redacted > 0) {
127
+ summary.secrets_redacted += e.secrets_redacted;
128
+ }
129
+ if (e.action === 'BLOCK') {
130
+ const target = (e.tool_inputs && (e.tool_inputs.path || e.tool_inputs.pattern)) || null;
131
+ summary.blocked_events.push({
132
+ tool: e.tool_name || 'unknown',
133
+ target,
134
+ rule: e.reason || 'unknown',
135
+ at_offset_s: offsetSeconds(startedAt, e.ts || e.iso),
136
+ });
137
+ }
138
+ }
139
+ return summary;
140
+ }
141
+
142
+ // ── public API ───────────────────────────────────────────────────────────────
143
+
144
+ /**
145
+ * buildAttestation({ runId, logFile, policyFile, gitCommit, filesChanged })
146
+ * → attestation object.
147
+ *
148
+ * `gitCommit` / `filesChanged` are optional v1.1 subject extensions —
149
+ * populated by the GitHub Action (Phase 2) to bind a run to a specific
150
+ * commit. Schema already permits both (declared in v1).
151
+ *
152
+ * Throws if runId is missing. Returns null if no events match the run_id.
153
+ */
154
+ function buildAttestation({ runId, logFile, policyFile, gitCommit, filesChanged } = {}) {
155
+ if (!runId || typeof runId !== 'string') {
156
+ throw new Error('buildAttestation: runId is required');
157
+ }
158
+ const log = logFile || DEFAULT_LOG;
159
+ const pol = policyFile || DEFAULT_POLICY;
160
+
161
+ const slice = loadRunSlice(log, runId);
162
+ if (slice.event_count === 0) return null;
163
+
164
+ // Look for a policy_loaded row inside the slice — it tells us exactly which
165
+ // policy hash was active during the run. Fall back to the file's current
166
+ // hash if no such row exists.
167
+ let polHash = null, polPath = null, polSource = 'unknown', polVersion = null;
168
+ for (const e of slice.events) {
169
+ if (e.kind === 'policy_loaded' && e.tool_inputs) {
170
+ polHash = e.tool_inputs.policy_hash || polHash;
171
+ polPath = e.tool_inputs.policy_path || polPath;
172
+ polVersion = (typeof e.tool_inputs.version === 'number') ? e.tool_inputs.version : polVersion;
173
+ polSource = e.policy_source || (polPath ? 'user' : 'default');
174
+ break;
175
+ }
176
+ }
177
+ if (!polHash) {
178
+ // No policy_loaded row inside this run slice — we cannot prove which
179
+ // policy was active during the run. Mark the source as 'inferred' so a
180
+ // downstream verifier knows the hash came from the file's *current*
181
+ // bytes (which may have been edited after the run ended).
182
+ polHash = computePolicyHash(pol);
183
+ polPath = pol;
184
+ polSource = 'inferred';
185
+ }
186
+
187
+ const agent = pickAgentMeta(slice.events);
188
+ const first = slice.events[0];
189
+ const last = slice.events[slice.events.length - 1];
190
+ const startedAt = first.ts || first.iso || new Date().toISOString();
191
+ const endedAt = last.ts || last.iso || startedAt;
192
+
193
+ return {
194
+ schema_version: SCHEMA_VERSION,
195
+ predicate_type: PREDICATE_TYPE,
196
+ subject: Object.assign(
197
+ { run_id: runId },
198
+ (gitCommit && typeof gitCommit === 'string') ? { git_commit: gitCommit } : {},
199
+ (Array.isArray(filesChanged) && filesChanged.length) ? { files_changed: filesChanged.slice() } : {}
200
+ ),
201
+ agent: {
202
+ platform: agent.platform,
203
+ model: agent.model,
204
+ session_id: agent.session_id,
205
+ started_at: startedAt,
206
+ ended_at: endedAt,
207
+ wall_time_s: isoSeconds(startedAt, endedAt),
208
+ },
209
+ policy: {
210
+ file_hash: polHash,
211
+ file_path: polPath,
212
+ source: polSource,
213
+ version: polVersion,
214
+ rules_digest: readPolicyRulesDigest(pol) || {
215
+ deny_paths_count: 0, deny_patterns_count: 0, block_secrets: true,
216
+ },
217
+ },
218
+ execution_summary: summariseExecution(slice.events, startedAt),
219
+ audit_chain: {
220
+ genesis: GENESIS,
221
+ first_hash: slice.first_hash,
222
+ last_hash: slice.last_hash,
223
+ event_count: slice.event_count,
224
+ chain_file: log,
225
+ verifier_url: VERIFIER_URL,
226
+ },
227
+ signature: null,
228
+ generated_at: new Date().toISOString(),
229
+ };
230
+ }
231
+
232
+ // ── CLI ──────────────────────────────────────────────────────────────────────
233
+
234
+ function flag(args, name) {
235
+ const i = args.indexOf(name);
236
+ return i >= 0 ? args[i + 1] : undefined;
237
+ }
238
+ function bool(args, name) { return args.indexOf(name) >= 0; }
239
+
240
+ function runAttestCli(args) {
241
+ args = args || [];
242
+
243
+ // Subcommand: `occasio attest verify <file>` → delegate.
244
+ if (args[0] === 'verify') {
245
+ const { runAttestVerifyCli } = require('./verify');
246
+ return runAttestVerifyCli(args.slice(1)).catch(e => {
247
+ process.stderr.write(`[Occasio] attest verify: ${e.message}\n`);
248
+ process.exit(2);
249
+ });
250
+ }
251
+
252
+ if (bool(args, '--help') || bool(args, '-h')) {
253
+ process.stdout.write(
254
+ 'Usage:\n' +
255
+ ' occasio attest --run-id <uuid> [--out <path>] [--log <path>] [--policy <path>] [--stdout]\n' +
256
+ ' [--sign] [--sign-mode ci|token] [--oidc-token <jwt>] [--bundle-out <path>]\n' +
257
+ ' [--git-commit <sha>] [--files-changed <a,b,c>]\n' +
258
+ ' occasio attest verify <attestation.json> [--bundle <path>]\n'
259
+ );
260
+ return;
261
+ }
262
+
263
+ const runId = flag(args, '--run-id');
264
+ if (!runId) {
265
+ process.stderr.write('[Occasio] attest: --run-id <uuid> is required\n');
266
+ process.exit(2);
267
+ }
268
+ const logFile = flag(args, '--log') || DEFAULT_LOG;
269
+ const policyFile = flag(args, '--policy') || DEFAULT_POLICY;
270
+ const out = flag(args, '--out') || path.resolve(process.cwd(), 'attestation.json');
271
+ const toStdout = bool(args, '--stdout');
272
+ const sign = bool(args, '--sign');
273
+ const signMode = flag(args, '--sign-mode'); // 'ci' | 'token' | undefined
274
+ const oidcToken = flag(args, '--oidc-token'); // explicit JWT (token-mode)
275
+ const bundleOut = flag(args, '--bundle-out')
276
+ || out.replace(/\.json$/i, '') + '.sigstore.json';
277
+ const gitCommit = flag(args, '--git-commit');
278
+ const filesRaw = flag(args, '--files-changed');
279
+ const filesChanged = filesRaw
280
+ ? filesRaw.split(',').map(s => s.trim()).filter(Boolean)
281
+ : undefined;
282
+
283
+ let attestation;
284
+ try {
285
+ attestation = buildAttestation({ runId, logFile, policyFile, gitCommit, filesChanged });
286
+ } catch (e) {
287
+ process.stderr.write(`[Occasio] attest: ${e.message}\n`);
288
+ process.exit(2);
289
+ }
290
+ if (!attestation) {
291
+ process.stderr.write(`[Occasio] attest: no events found for run_id ${runId}\n`);
292
+ process.stderr.write(` Checked: ${logFile}\n`);
293
+ process.exit(1);
294
+ }
295
+
296
+ if (!sign) {
297
+ emitUnsigned(attestation, out, toStdout, runId);
298
+ return;
299
+ }
300
+
301
+ // Signing path — async. Lazy-require sign.js to keep cold-start cheap.
302
+ const { signAttestation } = require('./sign');
303
+ return signAttestation(attestation, { oidcToken, signMode })
304
+ .then(({ bundle, signatureBlock }) => {
305
+ attestation.signature = signatureBlock;
306
+ const json = JSON.stringify(attestation, null, 2);
307
+ if (toStdout) {
308
+ process.stdout.write(json + '\n');
309
+ return;
310
+ }
311
+ fs.writeFileSync(out, json + '\n', 'utf8');
312
+ fs.writeFileSync(bundleOut, JSON.stringify(bundle, null, 2) + '\n', 'utf8');
313
+ process.stdout.write(`✓ Wrote signed attestation: ${out}\n`);
314
+ process.stdout.write(` bundle: ${bundleOut}\n`);
315
+ process.stdout.write(
316
+ ` run_id: ${runId} · events: ${attestation.audit_chain.event_count}` +
317
+ ` · blocked: ${attestation.execution_summary.blocked}` +
318
+ ` · secrets_redacted: ${attestation.execution_summary.secrets_redacted}\n`
319
+ );
320
+ process.stdout.write(` identity: ${signatureBlock.identity}\n`);
321
+ if (signatureBlock.rekor_entry) process.stdout.write(` rekor: ${signatureBlock.rekor_entry}\n`);
322
+ })
323
+ .catch(e => {
324
+ process.stderr.write(`[Occasio] attest: signing failed: ${e.message}\n`);
325
+ process.exit(2);
326
+ });
327
+ }
328
+
329
+ function emitUnsigned(attestation, out, toStdout, runId) {
330
+ const json = JSON.stringify(attestation, null, 2);
331
+ if (toStdout) { process.stdout.write(json + '\n'); return; }
332
+ try {
333
+ fs.writeFileSync(out, json + '\n', 'utf8');
334
+ process.stdout.write(`✓ Wrote attestation: ${out}\n`);
335
+ process.stdout.write(
336
+ ` run_id: ${runId} · events: ${attestation.audit_chain.event_count}` +
337
+ ` · blocked: ${attestation.execution_summary.blocked}` +
338
+ ` · secrets_redacted: ${attestation.execution_summary.secrets_redacted}\n`
339
+ );
340
+ process.stdout.write(' signature: null · use --sign to Sigstore-sign\n');
341
+ } catch (e) {
342
+ process.stderr.write(`[Occasio] attest: cannot write ${out}: ${e.message}\n`);
343
+ process.exit(2);
344
+ }
345
+ }
346
+
347
+ module.exports = {
348
+ buildAttestation,
349
+ runAttestCli,
350
+ SCHEMA_VERSION,
351
+ PREDICATE_TYPE,
352
+ GENESIS,
353
+ // exported for tests
354
+ _readPolicyRulesDigest: readPolicyRulesDigest,
355
+ };
@@ -0,0 +1,57 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * run-slice.js — load a tamper-evident audit chain and return the slice of rows
5
+ * belonging to a single run_id, preserving the original on-disk order.
6
+ *
7
+ * Returns:
8
+ * {
9
+ * events: [row, …] // sorted by file order (which is chain order)
10
+ * event_count: integer // events.length
11
+ * first_hash: string|null // hash field of the first row in the slice
12
+ * last_hash: string|null // hash field of the last row in the slice
13
+ * }
14
+ *
15
+ * Why we don't sort by ISO timestamp here (unlike replay.groupByRun): the
16
+ * chain's hash linkage is the canonical order. If two rows share an iso
17
+ * second, the on-disk order is the only correct sequence — any other sort
18
+ * would produce a `first_hash`/`last_hash` pair that does not correspond
19
+ * to a contiguous block of `prev_hash → hash` links.
20
+ *
21
+ * Legacy rows (no `hash` field — pre-hash-chain) are included if their
22
+ * `run_id` matches, but their `hash` will be undefined, so `first_hash`/
23
+ * `last_hash` may be null. Callers that need cryptographic commitments
24
+ * should check `first_hash !== null` and gate accordingly.
25
+ */
26
+
27
+ const fs = require('fs');
28
+
29
+ function readJsonlFile(filePath) {
30
+ try {
31
+ return fs.readFileSync(filePath, 'utf8')
32
+ .split('\n')
33
+ .filter(Boolean)
34
+ .map(line => { try { return JSON.parse(line); } catch { return null; } })
35
+ .filter(Boolean);
36
+ } catch {
37
+ return [];
38
+ }
39
+ }
40
+
41
+ function loadRunSlice(logFile, runId) {
42
+ if (!runId || typeof runId !== 'string') {
43
+ return { events: [], event_count: 0, first_hash: null, last_hash: null };
44
+ }
45
+ const all = readJsonlFile(logFile);
46
+ const events = all.filter(e => e && e.run_id === runId);
47
+ const first = events[0];
48
+ const last = events[events.length - 1];
49
+ return {
50
+ events,
51
+ event_count: events.length,
52
+ first_hash: (first && typeof first.hash === 'string') ? first.hash : null,
53
+ last_hash: (last && typeof last.hash === 'string') ? last.hash : null,
54
+ };
55
+ }
56
+
57
+ module.exports = { loadRunSlice, readJsonlFile };