@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,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 };
|