@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,153 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * occasio policy init — write a starter ~/.occasio/policy.yml.
5
+ *
6
+ * Safe by default: refuses to overwrite an existing file unless --force is
7
+ * passed explicitly. After writing, prints the file path and suggests the
8
+ * next commands so the user can inspect and validate immediately.
9
+ *
10
+ * Usage:
11
+ * occasio policy init Write the dev-default starter
12
+ * occasio policy init --template strict Locked-down posture
13
+ * occasio policy init --template finance Finance-oriented deny_patterns
14
+ * occasio policy init --force Overwrite an existing file
15
+ * occasio policy init --file <path> Write to a custom path
16
+ *
17
+ * v0.6.6: starter content is sourced from policy-templates/<name>.yml so the
18
+ * shipped templates are the same files the user can read, copy, and review
19
+ * outside the CLI. Each template carries a $schema directive pointing at the
20
+ * published JSON Schema (schemas/occasio-policy.schema.json).
21
+ */
22
+
23
+ const fs = require('fs');
24
+ const path = require('path');
25
+
26
+ // Templates ship as real files under policy-templates/. Lifting the
27
+ // content out of a JS string makes the templates inspectable and reviewable
28
+ // in source control as the durable artefacts they are.
29
+ const TEMPLATES_DIR = path.join(__dirname, '..', '..', 'policy-templates');
30
+ const VALID_TEMPLATES = ['dev-default', 'strict', 'finance'];
31
+ const DEFAULT_TEMPLATE = 'dev-default';
32
+
33
+ function readTemplate(name) {
34
+ return fs.readFileSync(path.join(TEMPLATES_DIR, `${name}.yml`), 'utf8');
35
+ }
36
+
37
+ // ── Starter file ───────────────────────────────────────────────────────────────
38
+
39
+ // STARTER_POLICY is the bytes of the default template (dev-default.yml). It
40
+ // is read once at module load so existing callers and tests that import
41
+ // `STARTER_POLICY` continue to see the same string contract. The same file
42
+ // is also addressable directly via readTemplate('dev-default').
43
+ const STARTER_POLICY = readTemplate(DEFAULT_TEMPLATE);
44
+
45
+ // ── ANSI colors ────────────────────────────────────────────────────────────────
46
+
47
+ const col = {
48
+ r: s => `\x1b[31m${s}\x1b[0m`,
49
+ g: s => `\x1b[32m${s}\x1b[0m`,
50
+ y: s => `\x1b[33m${s}\x1b[0m`,
51
+ c: s => `\x1b[36m${s}\x1b[0m`,
52
+ d: s => `\x1b[2m${s}\x1b[0m`,
53
+ b: s => `\x1b[1m${s}\x1b[0m`,
54
+ };
55
+
56
+ // ── CLI ────────────────────────────────────────────────────────────────────────
57
+
58
+ /**
59
+ * @param {string[]} args Remaining CLI args after 'policy init'
60
+ * @param {object} [opts] { loader, fs } — injectable for tests
61
+ * @returns {{ ok: boolean }}
62
+ */
63
+ function runInitCli(args, opts = {}) {
64
+ const loader = opts.loader || require('./loader');
65
+ const fsMod = opts.fs || fs;
66
+
67
+ const force = (args || []).includes('--force');
68
+ const fileArgIdx = (args || []).indexOf('--file');
69
+ const filePath = (fileArgIdx >= 0 && args[fileArgIdx + 1])
70
+ ? path.resolve(args[fileArgIdx + 1])
71
+ : loader.DEFAULT_PATH;
72
+
73
+ // v0.6.6: --template <name> picks one of the shipped policy templates
74
+ // under policy-templates/. Unknown names exit non-zero with the list of
75
+ // valid names so the failure mode is self-explaining.
76
+ const tplArgIdx = (args || []).indexOf('--template');
77
+ const templateName = (tplArgIdx >= 0 && args[tplArgIdx + 1])
78
+ ? args[tplArgIdx + 1]
79
+ : DEFAULT_TEMPLATE;
80
+
81
+ console.log(col.b('\n⚡ Occasio — Policy Init\n'));
82
+
83
+ if (!VALID_TEMPLATES.includes(templateName)) {
84
+ console.log(col.r(` Unknown template: "${templateName}"\n`));
85
+ console.log(col.d(' Valid templates:'));
86
+ for (const t of VALID_TEMPLATES) {
87
+ const tag = t === DEFAULT_TEMPLATE ? col.d(' (default)') : '';
88
+ console.log(` ${col.c(t)}${tag}`);
89
+ }
90
+ console.log('');
91
+ return { ok: false };
92
+ }
93
+
94
+ let templateBody;
95
+ try {
96
+ templateBody = readTemplate(templateName);
97
+ } catch (e) {
98
+ console.log(col.r(` Could not read template "${templateName}": ${e.message}\n`));
99
+ return { ok: false };
100
+ }
101
+
102
+ // Guard: refuse to overwrite without --force
103
+ let exists = false;
104
+ try { fsMod.statSync(filePath); exists = true; } catch {}
105
+
106
+ if (exists && !force) {
107
+ console.log(` File: ${filePath} ${col.y('(already exists)')}\n`);
108
+ console.log(col.y(' Policy file already exists — not overwriting.'));
109
+ console.log(col.d(' Use --force to replace it, or edit the file directly.\n'));
110
+ console.log(col.d(' Next steps:'));
111
+ console.log(col.d(` occasio policy show — view the current policy`));
112
+ console.log(col.d(` occasio policy validate — check it for errors`));
113
+ console.log('');
114
+ return { ok: false };
115
+ }
116
+
117
+ // Ensure parent directory exists
118
+ try {
119
+ fsMod.mkdirSync(path.dirname(filePath), { recursive: true });
120
+ } catch (e) {
121
+ console.log(col.r(` Cannot create directory: ${e.message}\n`));
122
+ return { ok: false };
123
+ }
124
+
125
+ // Write the starter file
126
+ try {
127
+ fsMod.writeFileSync(filePath, templateBody, 'utf8');
128
+ } catch (e) {
129
+ console.log(col.r(` Cannot write file: ${e.message}\n`));
130
+ return { ok: false };
131
+ }
132
+
133
+ const verb = (exists && force) ? 'Replaced' : 'Created';
134
+ console.log(` File: ${col.g(filePath)} ${col.g(`(${verb.toLowerCase()})`)}\n`);
135
+ console.log(col.g(` ✓ ${verb} ${templateName} policy.`));
136
+ console.log(col.d(' The file references the published JSON Schema for IDE autocomplete.\n'));
137
+ console.log(col.d(' Next steps:'));
138
+ console.log(col.c(` occasio policy show — view the active policy with annotations`));
139
+ console.log(col.c(` occasio policy validate — check the file for errors`));
140
+ console.log('');
141
+ return { ok: true };
142
+ }
143
+
144
+ module.exports = {
145
+ runInitCli,
146
+ STARTER_POLICY,
147
+ // v0.6.6: exposed for tests and tooling that needs to enumerate or load
148
+ // templates programmatically (e.g. the schema/template parity test).
149
+ VALID_TEMPLATES,
150
+ DEFAULT_TEMPLATE,
151
+ TEMPLATES_DIR,
152
+ readTemplate,
153
+ };
@@ -0,0 +1,448 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Policy loader — reads ~/.occasio/policy.yml and returns a typed policy
5
+ * object. Falls back to DEFAULT_POLICY when the file is missing or malformed.
6
+ *
7
+ * Stage 2 schema (intentionally minimal; Stage 3 will widen to rule lists):
8
+ *
9
+ * version: 1
10
+ * block_secrets_in_tool_results: true # BLOCK when scanner finds a secret
11
+ * block_requests_over_budget: true # BLOCK requests when session.cost ≥ budget
12
+ *
13
+ * Comments (#) and blank lines are ignored. No nesting, no lists, no flow
14
+ * style — just `key: value` lines. Adding more keys does not change the
15
+ * schema; unknown keys are silently ignored (forward-compatible).
16
+ */
17
+
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+ const os = require('os');
21
+ const crypto = require('crypto');
22
+
23
+ /** Expand ~ and resolve to absolute path for policy file entries. */
24
+ function resolveConfigPath(p) {
25
+ const expanded = p.startsWith('~') ? os.homedir() + p.slice(1) : p;
26
+ return path.resolve(expanded);
27
+ }
28
+
29
+ // Default path can be overridden via LOCALFIRST_POLICY_FILE — used by the
30
+ // harness/redteam commands to point the proxy at a scratch policy.yml so
31
+ // the user's real ~/.occasio/policy.yml is never read.
32
+ const DEFAULT_PATH = process.env.LOCALFIRST_POLICY_FILE
33
+ || path.join(os.homedir(), '.occasio', 'policy.yml');
34
+
35
+ // Default tool routing matches the pre-Stage-3 hardcoded behavior.
36
+ // Stage 3: keys are CANONICAL tool names (agent-agnostic). Adapters map
37
+ // their agent-specific names (Claude's "Read") to these canonical keys
38
+ // (`read_file`) when they emit BoundaryEvents.
39
+ const DEFAULT_TOOLS = Object.freeze({
40
+ read_file: Object.freeze({ action: 'LOCAL', executor: 'native', classifier: 'read-input-validator' }),
41
+ find_files: Object.freeze({ action: 'LOCAL', executor: 'native', classifier: 'glob-input-validator' }),
42
+ grep: Object.freeze({ action: 'LOCAL', executor: 'native', classifier: 'grep-input-validator' }),
43
+ todo_write: Object.freeze({ action: 'LOCAL', executor: 'native', classifier: 'todo-write-validator' }),
44
+ todo_read: Object.freeze({ action: 'LOCAL', executor: 'native', classifier: 'todo-read-validator' }),
45
+ shell_bash: Object.freeze({ action: 'LOCAL', executor: 'native', classifier: 'bash-allowlist' }),
46
+ shell_powershell: Object.freeze({ action: 'LOCAL', executor: 'native', classifier: 'powershell-allowlist' }),
47
+ });
48
+
49
+ const DEFAULT_POLICY = Object.freeze({
50
+ version: 1,
51
+ block_secrets_in_tool_results: true,
52
+ redact_secrets_in_tool_results: false,
53
+ distill_tool_results: false,
54
+ block_requests_over_budget: true,
55
+ tools: DEFAULT_TOOLS,
56
+ deny_paths: Object.freeze([]),
57
+ allow_paths: Object.freeze([]),
58
+ deny_patterns: Object.freeze([]),
59
+ });
60
+
61
+ /**
62
+ * Parse a YAML subset string into an object.
63
+ *
64
+ * Supports:
65
+ * - Top-level `key: value` scalars (true/false/null/int/float/quoted/bare string)
66
+ * - One level of block-style nesting:
67
+ * parent:
68
+ * child: value
69
+ * - A second level of block-style nesting (used for `tools.<Name>.<field>`):
70
+ * parent:
71
+ * child:
72
+ * grandchild: value
73
+ * - Comments (`#`) and blank lines
74
+ *
75
+ * Not supported (reject silently): inline flow `{ ... }`, lists `- ...`,
76
+ * tabs, multiline strings, anchors, aliases. Stage 3+ may extend.
77
+ */
78
+ function parseValue(val) {
79
+ if (val === '') return true; // bare key
80
+ if (val === 'true') return true;
81
+ if (val === 'false') return false;
82
+ if (val === 'null') return null;
83
+ if (/^-?\d+$/.test(val)) return parseInt(val, 10);
84
+ if (/^-?\d+\.\d+$/.test(val)) return parseFloat(val);
85
+ const m = /^(['"])(.*)\1$/.exec(val);
86
+ return m ? m[2] : val;
87
+ }
88
+
89
+ function parse(text) {
90
+ const out = {};
91
+ if (typeof text !== 'string') return out;
92
+
93
+ // State: when we see a key with empty value at indent N, the next lines at
94
+ // indent > N belong to that key as nested children. We track up to two
95
+ // levels of block-nesting (L1 = direct child object, L2 = grandchild object).
96
+ //
97
+ // List support (ARCH-27): a sequence of `- item` lines under a top-level key
98
+ // causes that key's value to become an array rather than a nested object.
99
+ // l1Container / l1ContKey track the outer object and key so we can replace
100
+ // the initially-created empty object with an array on the first `- item` line.
101
+ let l1Parent = null; // the L1 nested object (or null)
102
+ let l1Indent = -1; // indent of L1 entries (set on first child)
103
+ let l1Container = null; // outer object whose key holds l1Parent (for list replacement)
104
+ let l1ContKey = null; // key in l1Container pointing to l1Parent
105
+ let l2Parent = null; // the L2 nested object (or null)
106
+ let l2Indent = -1; // indent of L2 entries (set on first grandchild)
107
+
108
+ for (const rawLine of text.split('\n')) {
109
+ let line = rawLine;
110
+ const hash = line.indexOf('#');
111
+ if (hash >= 0) line = line.slice(0, hash);
112
+ if (!line.trim()) continue;
113
+
114
+ const indent = (line.match(/^( *)/) || ['', ''])[1].length;
115
+ const trimmed = line.trim();
116
+
117
+ // List item: `- value` at the L1 indent level under a parent that declared
118
+ // an empty value. Handled before the colon check because list values (e.g.
119
+ // paths) may not contain a colon, or the colon may be inside the value.
120
+ if (trimmed.startsWith('- ') && l1Container !== null && l1ContKey !== null) {
121
+ if (l1Indent === -1) l1Indent = indent;
122
+ if (indent === l1Indent) {
123
+ const item = trimmed.slice(2).trim();
124
+ if (item) {
125
+ if (!Array.isArray(l1Container[l1ContKey])) {
126
+ l1Container[l1ContKey] = [];
127
+ }
128
+ l1Container[l1ContKey].push(parseValue(item));
129
+ }
130
+ continue;
131
+ }
132
+ }
133
+
134
+ const colon = trimmed.indexOf(':');
135
+ if (colon < 0) continue;
136
+ const key = trimmed.slice(0, colon).trim();
137
+ const val = trimmed.slice(colon + 1).trim();
138
+ if (!key) continue;
139
+
140
+ if (indent === 0) {
141
+ // Top-level
142
+ if (val === '') {
143
+ out[key] = {};
144
+ l1Container = out;
145
+ l1ContKey = key;
146
+ l1Parent = out[key];
147
+ l1Indent = -1;
148
+ l2Parent = null;
149
+ l2Indent = -1;
150
+ } else {
151
+ out[key] = parseValue(val);
152
+ l1Parent = null;
153
+ l1Container = null;
154
+ l1ContKey = null;
155
+ l2Parent = null;
156
+ }
157
+ } else if (l1Parent !== null) {
158
+ if (l1Indent === -1) l1Indent = indent;
159
+
160
+ if (indent === l1Indent) {
161
+ // L1 entry
162
+ if (val === '') {
163
+ l1Parent[key] = {};
164
+ l2Parent = l1Parent[key];
165
+ l2Indent = -1;
166
+ } else {
167
+ l1Parent[key] = parseValue(val);
168
+ l2Parent = null;
169
+ }
170
+ } else if (indent > l1Indent && l2Parent !== null) {
171
+ if (l2Indent === -1) l2Indent = indent;
172
+ if (indent === l2Indent) {
173
+ l2Parent[key] = parseValue(val);
174
+ }
175
+ // Deeper levels ignored.
176
+ }
177
+ }
178
+ }
179
+ return out;
180
+ }
181
+
182
+ // Per-tool entry validation. `action` must be PASS, LOCAL, or TRANSFORM.
183
+ // For TRANSFORM, `transform` (a non-empty string) names the operation and is
184
+ // required — entries missing it are silently dropped. Returns null for any
185
+ // invalid entry so callers can safely skip it.
186
+ const VALID_ACTIONS = new Set(['PASS', 'LOCAL', 'TRANSFORM']);
187
+ function normalizeToolEntry(raw) {
188
+ if (!raw || typeof raw !== 'object') return null;
189
+ const action = raw.action;
190
+ if (!VALID_ACTIONS.has(action)) return null;
191
+ // max_output_tokens: per-tool context budget. Positive integer or null.
192
+ // Applied AFTER any transform/distill as a final clip before the tool result
193
+ // re-enters the model's next request. Same field on every action so a LOCAL
194
+ // tool with no transform can still carry a budget.
195
+ let maxOutputTokens = null;
196
+ if (raw.max_output_tokens !== undefined && raw.max_output_tokens !== null) {
197
+ const n = Number(raw.max_output_tokens);
198
+ if (Number.isFinite(n) && Number.isInteger(n) && n > 0) maxOutputTokens = n;
199
+ }
200
+ if (action === 'TRANSFORM') {
201
+ if (typeof raw.transform !== 'string' || !raw.transform.trim()) return null;
202
+ const out = { action, transform: raw.transform };
203
+ if (typeof raw.reason === 'string') out.reason = raw.reason;
204
+ if (maxOutputTokens !== null) out.max_output_tokens = maxOutputTokens;
205
+ return Object.freeze(out);
206
+ }
207
+ const out = { action };
208
+ if (typeof raw.executor === 'string') out.executor = raw.executor;
209
+ if (typeof raw.classifier === 'string') out.classifier = raw.classifier;
210
+ if (typeof raw.reason === 'string') out.reason = raw.reason;
211
+ if (maxOutputTokens !== null) out.max_output_tokens = maxOutputTokens;
212
+ return Object.freeze(out);
213
+ }
214
+
215
+ /**
216
+ * Merge a parsed policy onto the defaults, validating types. Anything missing
217
+ * or wrong-type falls back to the default. Returns a frozen object.
218
+ *
219
+ * Tools merge semantics: if the parsed policy has a `tools:` block, it
220
+ * REPLACES the defaults entirely (no per-key fallback). This is the simplest
221
+ * mental model: the user's tools block is authoritative when present.
222
+ * Stage 4+ can introduce per-tool override semantics if needed.
223
+ */
224
+ function normalize(parsed) {
225
+ const merged = { ...DEFAULT_POLICY };
226
+ if (typeof parsed.version === 'number') merged.version = parsed.version;
227
+ if (typeof parsed.block_secrets_in_tool_results === 'boolean') {
228
+ merged.block_secrets_in_tool_results = parsed.block_secrets_in_tool_results;
229
+ }
230
+ if (typeof parsed.redact_secrets_in_tool_results === 'boolean') {
231
+ merged.redact_secrets_in_tool_results = parsed.redact_secrets_in_tool_results;
232
+ }
233
+ if (typeof parsed.distill_tool_results === 'boolean') {
234
+ merged.distill_tool_results = parsed.distill_tool_results;
235
+ }
236
+ if (typeof parsed.block_requests_over_budget === 'boolean') {
237
+ merged.block_requests_over_budget = parsed.block_requests_over_budget;
238
+ }
239
+ // deny_paths / allow_paths: arrays of pre-resolved absolute path strings.
240
+ // Invalid entries emit a stderr warning and are skipped — silent drops of
241
+ // governance entries are a security gap, not a minor inconvenience.
242
+ for (const listKey of ['deny_paths', 'allow_paths']) {
243
+ if (Array.isArray(parsed[listKey])) {
244
+ const resolved = [];
245
+ parsed[listKey].forEach((entry, i) => {
246
+ if (typeof entry !== 'string' || !entry.trim()) {
247
+ process.stderr.write(`[Occasio] policy.yml: ${listKey}[${i}] — not a string, entry skipped\n`);
248
+ return;
249
+ }
250
+ resolved.push(resolveConfigPath(entry.trim()));
251
+ });
252
+ merged[listKey] = Object.freeze(resolved);
253
+ }
254
+ }
255
+
256
+ // deny_patterns: object of label → regex string, compiled at load time.
257
+ if (parsed.deny_patterns && typeof parsed.deny_patterns === 'object' && !Array.isArray(parsed.deny_patterns)) {
258
+ const patterns = [];
259
+ for (const [label, rawPattern] of Object.entries(parsed.deny_patterns)) {
260
+ if (typeof rawPattern !== 'string') {
261
+ process.stderr.write(`[Occasio] policy.yml: deny_patterns.${label} — value must be a string, entry skipped\n`);
262
+ continue;
263
+ }
264
+ try {
265
+ const regex = new RegExp(rawPattern);
266
+ patterns.push(Object.freeze({ label, regex }));
267
+ } catch (e) {
268
+ process.stderr.write(`[Occasio] policy.yml: deny_patterns.${label} — invalid RegExp "${rawPattern}", entry skipped\n`);
269
+ }
270
+ }
271
+ merged.deny_patterns = Object.freeze(patterns);
272
+ }
273
+
274
+ if (parsed.tools && typeof parsed.tools === 'object') {
275
+ // Lazy-require the registry to avoid an unconditional dependency at
276
+ // module load (loader.js is imported by other code paths that don't
277
+ // need the registry).
278
+ let toolNames;
279
+ try { toolNames = require('../core/tool-names'); } catch {}
280
+ const tools = {};
281
+ for (const name of Object.keys(parsed.tools)) {
282
+ const entry = normalizeToolEntry(parsed.tools[name]);
283
+ if (!entry) continue;
284
+ // Stage 3: keys are canonical. If the user wrote an agent-specific
285
+ // alias (e.g., 'Read'), translate to canonical. Unknown names are
286
+ // kept as-is (no agent claims them; lookup will miss safely).
287
+ let key = name;
288
+ if (toolNames && !toolNames.isCanonical(name)) {
289
+ const canonical = toolNames.firstCanonicalFor(name);
290
+ if (canonical) key = canonical;
291
+ }
292
+ tools[key] = entry;
293
+ }
294
+ merged.tools = Object.freeze(tools);
295
+ }
296
+ return Object.freeze(merged);
297
+ }
298
+
299
+ let _cached = null;
300
+ let _cachedPath = null;
301
+ let _cachedMtime = null; // mtimeMs when the file was last read (null = file was absent)
302
+ let _override = null; // tests-only: forces load() to return this regardless of path
303
+
304
+ // v0.6.6: policy hash + change-callback. The hash is a SHA-256 of the
305
+ // raw policy file bytes (or an empty string when absent), recomputed on
306
+ // every cache miss. It is what lands in the audit log's policy_loaded row.
307
+ //
308
+ // Hashing the file bytes (not the normalized object) keeps the contract
309
+ // predictable: byte-identical files produce identical hashes; comments
310
+ // and whitespace count, just like they do for the YAML editor. The
311
+ // audit row is therefore directly traceable to a specific file content
312
+ // the corp can keep under source control.
313
+ let _cachedHash = null;
314
+ let _changeListeners = [];
315
+
316
+ function computePolicyHash(filePath) {
317
+ try {
318
+ const bytes = fs.readFileSync(filePath);
319
+ return crypto.createHash('sha256').update(bytes).digest('hex');
320
+ } catch {
321
+ // Absent/unreadable file → all-zeros sentinel so the listener still
322
+ // fires once on first load, distinguishing "no file" from "no listener".
323
+ return '0'.repeat(64);
324
+ }
325
+ }
326
+
327
+ /**
328
+ * Register a callback that fires whenever load() observes a transition
329
+ * to a new policy hash. Fires once on first load (cold cache), and once
330
+ * per subsequent edit (mtime change → re-read → new hash).
331
+ *
332
+ * The callback receives:
333
+ * { hash, path, version, source }
334
+ * where `source` is 'user' if the file existed, 'default' otherwise.
335
+ *
336
+ * Used by the proxy and the MCP server to emit a `policy_loaded` audit
337
+ * row exactly when a change is observed (not on every load() call).
338
+ */
339
+ function onPolicyChange(cb) {
340
+ if (typeof cb === 'function') _changeListeners.push(cb);
341
+ }
342
+
343
+ function _firePolicyChange(filePath, policy, hash, fileWasPresent) {
344
+ for (const cb of _changeListeners) {
345
+ try {
346
+ cb({
347
+ hash,
348
+ path: filePath,
349
+ version: policy && typeof policy.version === 'number' ? policy.version : 1,
350
+ source: fileWasPresent ? 'user' : 'default',
351
+ });
352
+ } catch (e) {
353
+ // Listener crash must not break the proxy — surface to stderr only.
354
+ try { process.stderr.write(`[occasio] policy-change listener threw: ${e.message}\n`); } catch {}
355
+ }
356
+ }
357
+ }
358
+
359
+ /**
360
+ * Load ~/.occasio/policy.yml (or another path), with mtime-based reload.
361
+ *
362
+ * On every call we do one `statSync` to read the file's modification time.
363
+ * If the mtime matches the last read — or the file was absent then and is
364
+ * still absent now — the cached policy is returned immediately without any
365
+ * file I/O. If the mtime changed, the file appeared, or the file was
366
+ * deleted, we re-read and re-normalize. Deleted/unreadable files fall back
367
+ * to DEFAULT_POLICY exactly as before.
368
+ *
369
+ * Choosing statSync over fs.watch:
370
+ * - No persistent watcher to clean up; no background thread keeping the
371
+ * process alive after the proxy stops.
372
+ * - Deterministic: the reload happens on the very next load() call after
373
+ * the file changes, not asynchronously via an event queue.
374
+ * - The stat syscall is sub-millisecond; load() is called once per tool
375
+ * call (not in a hot loop), so the overhead is negligible.
376
+ *
377
+ * @param {string} [filePath] override path (used in tests)
378
+ * @returns {object} frozen policy object
379
+ */
380
+ function load(filePath = DEFAULT_PATH) {
381
+ if (_override) return _override;
382
+
383
+ // Read the current mtime. null means the file is absent or unreadable.
384
+ let currentMtime = null;
385
+ try { currentMtime = fs.statSync(filePath).mtimeMs; } catch { /* absent */ }
386
+
387
+ // Cache hit: same path and mtime unchanged (covers absent→absent as null===null).
388
+ if (_cached && _cachedPath === filePath && _cachedMtime === currentMtime) {
389
+ return _cached;
390
+ }
391
+
392
+ // Cache miss: file changed, appeared, or disappeared — re-read.
393
+ let parsed = {};
394
+ let fileWasPresent = false;
395
+ try {
396
+ const text = fs.readFileSync(filePath, 'utf8');
397
+ parsed = parse(text);
398
+ fileWasPresent = true;
399
+ } catch { /* file missing or unreadable → defaults */ }
400
+ _cached = normalize(parsed);
401
+ _cachedPath = filePath;
402
+ _cachedMtime = currentMtime;
403
+
404
+ // v0.6.6: compute the file-bytes hash and fire the change callback iff
405
+ // the hash transitioned. This is the only place that observes policy
406
+ // changes, so it is also the only place that triggers policy_loaded
407
+ // audit rows. Idempotent by construction.
408
+ const newHash = computePolicyHash(filePath);
409
+ if (newHash !== _cachedHash) {
410
+ _cachedHash = newHash;
411
+ _firePolicyChange(filePath, _cached, newHash, fileWasPresent);
412
+ }
413
+
414
+ return _cached;
415
+ }
416
+
417
+ /**
418
+ * Test helper: set an override that load() returns regardless of path.
419
+ * Pass null to clear. Production code never calls this.
420
+ */
421
+ function _setOverrideForTests(policyObj) {
422
+ _override = policyObj ? normalize(policyObj) : null;
423
+ }
424
+
425
+ /**
426
+ * Reset the cached policy. Used by tests; not normally called by production.
427
+ */
428
+ function _resetCache() {
429
+ _cached = null;
430
+ _cachedPath = null;
431
+ _cachedMtime = null;
432
+ _override = null;
433
+ _cachedHash = null;
434
+ _changeListeners = [];
435
+ }
436
+
437
+ module.exports = {
438
+ DEFAULT_PATH,
439
+ DEFAULT_POLICY,
440
+ parse,
441
+ normalize,
442
+ normalizeToolEntry,
443
+ load,
444
+ computePolicyHash,
445
+ onPolicyChange,
446
+ _resetCache,
447
+ _setOverrideForTests,
448
+ };
@@ -0,0 +1,36 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Default rule set — Stage 2.
5
+ *
6
+ * In Stage 2 the policy file at ~/.occasio/policy.yml supplies two
7
+ * named decisions; everything else is still produced by the legacy
8
+ * classifier (`interceptor.classifyBlock`).
9
+ *
10
+ * 1. tool_call where adapter.classify(event).handled === true
11
+ * → LOCAL (executor='native')
12
+ * Source: `policy.engine::evaluate` delegating to classifyBlock.
13
+ * (Stage 3 will move tool routing into the YAML rule set.)
14
+ *
15
+ * 2. tool_call where adapter.classify(event).handled === false
16
+ * → PASS (forwarded to Anthropic for cloud-side execution)
17
+ *
18
+ * 3. tool_result content matching SECRET_PATTERNS
19
+ * → policy.evaluateToolResults reads `block_secrets_in_tool_results`
20
+ * → BLOCK in `block_secrets` mode, PASS+surface-secrets otherwise
21
+ * Implemented in `policy.engine::evaluateToolResults`. STAGE 2.
22
+ *
23
+ * 4. session.cost ≥ budget
24
+ * → policy.evaluateRequest reads `block_requests_over_budget`
25
+ * → BLOCK with HTTP 402 synthetic response
26
+ * Implemented in `policy.engine::evaluateRequest`. STAGE 2.
27
+ *
28
+ * Default policy values (DEFAULT_POLICY in `policy/loader.js`):
29
+ *
30
+ * block_secrets_in_tool_results: true
31
+ * block_requests_over_budget: true
32
+ *
33
+ * Users override by writing ~/.occasio/policy.yml.
34
+ */
35
+
36
+ module.exports = {};