@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,135 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Extract file-read operand paths from a shell command so the policy engine
5
+ * can apply deny_paths / allow_paths to shell-mediated reads (cat, head, tail,
6
+ * type, bat, Get-Content) the same way it does for the typed `read_file` tool.
7
+ *
8
+ * Closes the bypass where `Bash { command: "cat <denied-path>" }` and the
9
+ * PowerShell equivalent skipped path policy because the canonical tool name
10
+ * is `shell_bash` / `shell_powershell` and not in PATH_BEARING_TOOLS.
11
+ *
12
+ * Scope is intentionally narrow: only the shapes the native handler in
13
+ * src/interceptor.js actually executes locally. Unrecognised commands return
14
+ * an empty array — the existing classifier still gates them.
15
+ */
16
+
17
+ const path = require('path');
18
+
19
+ // Read-content primitives that the native handler reads from disk.
20
+ // Each entry: command head (lowercased) → parser(parts) → relative file path or null.
21
+ //
22
+ // Keep these parsers byte-compatible with interceptor.nativeHandle() so we
23
+ // never block a path the native handler would not actually read.
24
+ function parseSimpleRead(parts) {
25
+ // cat|bat|type|head|tail [-flags] <file>
26
+ let i = 1;
27
+ while (i < parts.length && parts[i].startsWith('-')) {
28
+ if ((parts[i] === '-n' || parts[i] === '--lines') && /^\d+$/.test(parts[i + 1] || '')) i++;
29
+ i++;
30
+ }
31
+ const file = stripQuotes(parts.slice(i).join(' '));
32
+ return file || null;
33
+ }
34
+
35
+ function parseGetContent(parts) {
36
+ // Get-Content [-Path|-LiteralPath] <file>
37
+ let idx = 1;
38
+ if (/^-(?:path|literalpath)$/i.test(parts[1] || '')) idx = 2;
39
+ const file = stripQuotes(parts.slice(idx).join(' '));
40
+ return file || null;
41
+ }
42
+
43
+ const READ_HEADS = new Map([
44
+ ['cat', parseSimpleRead],
45
+ ['bat', parseSimpleRead],
46
+ ['type', parseSimpleRead],
47
+ ['head', parseSimpleRead],
48
+ ['tail', parseSimpleRead],
49
+ ['get-content', parseGetContent],
50
+ ]);
51
+
52
+ function stripQuotes(s) {
53
+ if (!s) return s;
54
+ return s.trim().replace(/^(['"])(.*)\1$/, '$2');
55
+ }
56
+
57
+ /** Extract the directory operand from a `cd <dir>` / `Set-Location <dir>` segment. */
58
+ function extractCwdFromCdSegment(seg) {
59
+ const m = /^(?:cd|Set-Location)\s+(.+)$/i.exec(seg.trim());
60
+ if (!m) return null;
61
+ return stripQuotes(m[1]);
62
+ }
63
+
64
+ /**
65
+ * Try to extract a relative-or-absolute file path from a single command
66
+ * segment that reads file content. Returns null if the segment is not a
67
+ * recognised read shape.
68
+ */
69
+ function readPathFromSegment(seg) {
70
+ const trimmed = seg.trim().replace(/\s+2>&1\s*$/, '');
71
+ if (!trimmed) return null;
72
+ const parts = trimmed.split(/\s+/);
73
+ const head = parts[0];
74
+ if (!head) return null;
75
+ const parser = READ_HEADS.get(head.toLowerCase());
76
+ if (!parser) return null;
77
+ return parser(parts);
78
+ }
79
+
80
+ /**
81
+ * Walk a shell command and return every file path the native handler would
82
+ * actually read, resolved to absolute form. Handles three shapes:
83
+ *
84
+ * 1. Bare read: `cat <file>`
85
+ * 2. cd-prefixed Bash chain: `cd <dir> && cat <file>`
86
+ * 3. Set-Location PowerShell: `Set-Location <dir>; Get-Content <file>`
87
+ *
88
+ * The cwd tracked across cd/Set-Location segments only affects path
89
+ * resolution for subsequent segments — process.cwd() is never mutated. The
90
+ * caller (policy.engine) resolves each returned path against deny_paths /
91
+ * allow_paths exactly like it does for a typed `read_file` tool.
92
+ *
93
+ * Returns an array of absolute path strings (deduplicated, order-preserving).
94
+ */
95
+ function extractShellReadPaths(command, baseCwd) {
96
+ if (!command || typeof command !== 'string') return [];
97
+ const cwdStart = baseCwd || process.cwd();
98
+
99
+ // Split into segments. Bash `&&`, PowerShell `;`. A command with neither
100
+ // separator is a single segment.
101
+ const segments = command.includes(' && ')
102
+ ? command.split(' && ')
103
+ : command.includes(';')
104
+ ? command.split(';').map(s => s.trim()).filter(Boolean)
105
+ : [command];
106
+
107
+ const results = [];
108
+ const seen = new Set();
109
+ let cwd = cwdStart;
110
+
111
+ for (const seg of segments) {
112
+ const s = seg.trim();
113
+ if (!s) continue;
114
+
115
+ const cdTarget = extractCwdFromCdSegment(s);
116
+ if (cdTarget) {
117
+ // Resolve cd target against the previously tracked cwd so chained
118
+ // relative cd's work (mirrors interceptor.runCompound).
119
+ cwd = path.resolve(cwd, cdTarget);
120
+ continue;
121
+ }
122
+
123
+ const filePart = readPathFromSegment(s);
124
+ if (!filePart) continue;
125
+
126
+ const abs = path.resolve(cwd, filePart);
127
+ if (!seen.has(abs)) {
128
+ seen.add(abs);
129
+ results.push(abs);
130
+ }
131
+ }
132
+ return results;
133
+ }
134
+
135
+ module.exports = { extractShellReadPaths };
@@ -0,0 +1,196 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * occasio policy show — display the active policy in human-readable form.
5
+ *
6
+ * Shows global flags and per-tool routing/transform decisions, annotating
7
+ * each value as either the default or a user override from policy.yml.
8
+ *
9
+ * Usage:
10
+ * occasio policy [show] Full view of the active policy
11
+ * occasio policy show --diff Only values that differ from defaults
12
+ */
13
+
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+
17
+ // Transforms currently implemented in the dispatcher.
18
+ const KNOWN_TRANSFORMS = new Set(['redact-secrets', 'distill-output']);
19
+
20
+ // Global flag keys, in display order.
21
+ const GLOBAL_FLAG_KEYS = [
22
+ 'block_secrets_in_tool_results',
23
+ 'redact_secrets_in_tool_results',
24
+ 'distill_tool_results',
25
+ 'block_requests_over_budget',
26
+ ];
27
+
28
+ const col = {
29
+ r: s => `\x1b[31m${s}\x1b[0m`,
30
+ g: s => `\x1b[32m${s}\x1b[0m`,
31
+ y: s => `\x1b[33m${s}\x1b[0m`,
32
+ c: s => `\x1b[36m${s}\x1b[0m`,
33
+ d: s => `\x1b[2m${s}\x1b[0m`,
34
+ b: s => `\x1b[1m${s}\x1b[0m`,
35
+ };
36
+
37
+ function pad(s, n) { return String(s).padEnd(n); }
38
+
39
+ /**
40
+ * Format one tool entry for display.
41
+ * Returns { line: string, warn: string|null }
42
+ */
43
+ function formatToolEntry(entry) {
44
+ if (!entry) {
45
+ return { line: col.y('PASS') + col.d(' (absent from tools: block → cloud only)'), warn: null };
46
+ }
47
+ const a = entry.action;
48
+ if (a === 'LOCAL') {
49
+ return { line: col.g('LOCAL'), warn: null };
50
+ }
51
+ if (a === 'TRANSFORM') {
52
+ const tf = entry.transform || '(none)';
53
+ const known = KNOWN_TRANSFORMS.has(tf);
54
+ const tfStr = known ? col.c(tf) : col.r(tf);
55
+ const warn = known ? null : `unknown transform '${tf}' — not implemented in dispatcher`;
56
+ return { line: `${col.c('TRANSFORM')} → ${tfStr}`, warn };
57
+ }
58
+ if (a === 'PASS') {
59
+ return { line: col.d('PASS') + col.d(' (cloud only)'), warn: null };
60
+ }
61
+ return { line: col.r(`${a} (unrecognized action)`), warn: `unrecognized action '${a}'` };
62
+ }
63
+
64
+ /**
65
+ * Determine if an active tool entry differs from the default entry.
66
+ */
67
+ function isToolOverride(actEntry, defEntry) {
68
+ if (!actEntry && !defEntry) return false;
69
+ if (!actEntry || !defEntry) return true; // one is absent — different
70
+ return actEntry.action !== defEntry.action || actEntry.transform !== defEntry.transform;
71
+ }
72
+
73
+ /**
74
+ * Main display function.
75
+ *
76
+ * @param {string[]} args Remaining CLI args after 'policy [show]'
77
+ * @param {object} [opts] { loader } — injectable for tests; defaults to real loader
78
+ */
79
+ function runPolicyCli(args, opts = {}) {
80
+ const loader = opts.loader || require('./loader');
81
+ const diffMode = (args || []).includes('--diff');
82
+
83
+ // ── Source detection ──────────────────────────────────────────────────────
84
+ const filePath = loader.DEFAULT_PATH;
85
+ let fileExists = false;
86
+ let userParsed = {};
87
+ try {
88
+ const text = fs.readFileSync(filePath, 'utf8');
89
+ fileExists = true;
90
+ userParsed = loader.parse(text);
91
+ } catch {}
92
+
93
+ const active = loader.load();
94
+ const defaults = loader.DEFAULT_POLICY;
95
+
96
+ // ── Header ────────────────────────────────────────────────────────────────
97
+ console.log(col.b('\n⚡ Occasio — Active Policy\n'));
98
+
99
+ const fileStatus = fileExists
100
+ ? `${filePath} ${col.g('(loaded)')}`
101
+ : `${col.d(filePath)} ${col.d('(not found — defaults apply)')}`;
102
+ console.log(` File: ${fileStatus}`);
103
+
104
+ if (diffMode && !fileExists) {
105
+ console.log(col.d('\n No overrides — no policy.yml found; all defaults apply.\n'));
106
+ return;
107
+ }
108
+
109
+ // ── Global flags ──────────────────────────────────────────────────────────
110
+ const globalOverrides = GLOBAL_FLAG_KEYS.filter(k => active[k] !== defaults[k]);
111
+
112
+ if (!diffMode || globalOverrides.length > 0) {
113
+ console.log(col.b('\n Global flags:'));
114
+ const keyWidth = Math.max(...GLOBAL_FLAG_KEYS.map(k => k.length)) + 2;
115
+ for (const k of GLOBAL_FLAG_KEYS) {
116
+ const isOverride = active[k] !== defaults[k];
117
+ if (diffMode && !isOverride) continue;
118
+ const val = active[k];
119
+ const valStr = String(val);
120
+ const valCol = val === true ? col.g(valStr)
121
+ : val === false ? col.d(valStr)
122
+ : col.y(valStr);
123
+ const ann = isOverride ? col.c(' ← override') : col.d(' (default)');
124
+ console.log(` ${pad(k, keyWidth)} ${valCol}${ann}`);
125
+ }
126
+ }
127
+
128
+ // ── Tool routing ──────────────────────────────────────────────────────────
129
+ const defaultTools = defaults.tools || {};
130
+ const activeTools = active.tools || {};
131
+
132
+ // Unified tool name list: all default tools + any user-defined extras
133
+ const allNames = [...new Set([
134
+ ...Object.keys(defaultTools),
135
+ ...Object.keys(activeTools),
136
+ ])];
137
+
138
+ // Detect which tools have a user-declared tools: block at all
139
+ const userHasToolsBlock = fileExists &&
140
+ userParsed.tools && typeof userParsed.tools === 'object' &&
141
+ Object.keys(userParsed.tools).length > 0;
142
+
143
+ // Tools removed by a user tools: block (default tools now absent from activeTools)
144
+ const removedByUserBlock = userHasToolsBlock
145
+ ? Object.keys(defaultTools).filter(k => !activeTools[k])
146
+ : [];
147
+
148
+ // Filter to display
149
+ const namesToShow = allNames.filter(name => {
150
+ if (!diffMode) return true;
151
+ return isToolOverride(activeTools[name], defaultTools[name]);
152
+ });
153
+
154
+ if (!diffMode || namesToShow.length > 0 || removedByUserBlock.length > 0) {
155
+ const userExtra = allNames.filter(n => !defaultTools[n] && activeTools[n]);
156
+ const countNote = userExtra.length
157
+ ? col.d(` (${Object.keys(defaultTools).length} defaults + ${userExtra.length} user-defined)`)
158
+ : col.d(` (${allNames.length} tools)`);
159
+ console.log(col.b('\n Tool routing:') + countNote);
160
+
161
+ // Warn if user's tools: block removed defaults
162
+ if (removedByUserBlock.length > 0) {
163
+ console.log(col.y('\n ⚠ tools: block replaces all defaults.') +
164
+ col.d(' Tools not listed will PASS to cloud:'));
165
+ console.log(col.y(` ${removedByUserBlock.join(', ')}`));
166
+ }
167
+
168
+ const nameWidth = Math.max(...allNames.map(n => n.length), 16) + 2;
169
+
170
+ for (const name of allNames) {
171
+ const actEntry = activeTools[name];
172
+ const defEntry = defaultTools[name];
173
+ const isOverride = isToolOverride(actEntry, defEntry);
174
+
175
+ if (diffMode && !isOverride) continue;
176
+
177
+ // For user tools: block — absent defaults show as override (removed)
178
+ const entryToShow = actEntry || null;
179
+ const { line, warn } = formatToolEntry(entryToShow);
180
+ const ann = isOverride ? col.c(' ← override') : col.d(' (default)');
181
+ const extra = !defEntry ? col.d(' (user-defined)') : '';
182
+ const warnStr = warn ? col.y(` ⚠ ${warn}`) : '';
183
+
184
+ console.log(` ${pad(name, nameWidth)} ${line}${ann}${extra}${warnStr}`);
185
+ }
186
+ }
187
+
188
+ if (diffMode && !globalOverrides.length && !namesToShow.length && !removedByUserBlock.length) {
189
+ console.log(col.d('\n No overrides — active policy matches defaults.\n'));
190
+ return;
191
+ }
192
+
193
+ console.log('');
194
+ }
195
+
196
+ module.exports = { runPolicyCli, KNOWN_TRANSFORMS, formatToolEntry, isToolOverride };
@@ -0,0 +1,310 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * occasio policy validate — parse ~/.occasio/policy.yml and report
5
+ * every issue that would cause silent failures at runtime.
6
+ *
7
+ * Two severity levels:
8
+ * error — field will be silently dropped or mis-applied at runtime
9
+ * warning — field is valid but suspicious (unknown name, unused entry, etc.)
10
+ *
11
+ * Exit code via the caller (index.js):
12
+ * 0 — no errors (clean or warnings-only)
13
+ * 1 — at least one error
14
+ *
15
+ * Usage:
16
+ * occasio policy validate
17
+ * occasio policy validate --file /path/to/policy.yml
18
+ */
19
+
20
+ const fs = require('fs');
21
+ const path = require('path');
22
+
23
+ // ── Validation constants ───────────────────────────────────────────────────────
24
+
25
+ const KNOWN_TOP_LEVEL = new Set([
26
+ 'version',
27
+ 'block_secrets_in_tool_results',
28
+ 'redact_secrets_in_tool_results',
29
+ 'distill_tool_results',
30
+ 'block_requests_over_budget',
31
+ 'tools',
32
+ 'deny_paths',
33
+ 'allow_paths',
34
+ 'deny_patterns',
35
+ ]);
36
+
37
+ const BOOLEAN_FLAGS = [
38
+ 'block_secrets_in_tool_results',
39
+ 'redact_secrets_in_tool_results',
40
+ 'distill_tool_results',
41
+ 'block_requests_over_budget',
42
+ ];
43
+
44
+ const VALID_ACTIONS = new Set(['PASS', 'LOCAL', 'TRANSFORM']);
45
+ const KNOWN_TRANSFORMS = new Set(['redact-secrets', 'distill-output']);
46
+
47
+ // Sourced from policy/built-in-classifiers.js — kept as a plain Set here so
48
+ // validate.js has no require-time dependency on the runtime classifier code.
49
+ const KNOWN_CLASSIFIERS = new Set([
50
+ 'read-input-validator',
51
+ 'glob-input-validator',
52
+ 'grep-input-validator',
53
+ 'todo-write-validator',
54
+ 'todo-read-validator',
55
+ 'bash-allowlist',
56
+ 'powershell-allowlist',
57
+ ]);
58
+
59
+ const KNOWN_TOOL_ENTRY_KEYS = new Set(['action', 'transform', 'executor', 'classifier', 'reason', 'max_output_tokens']);
60
+
61
+ // ── ANSI colors ────────────────────────────────────────────────────────────────
62
+
63
+ const col = {
64
+ r: s => `\x1b[31m${s}\x1b[0m`,
65
+ g: s => `\x1b[32m${s}\x1b[0m`,
66
+ y: s => `\x1b[33m${s}\x1b[0m`,
67
+ d: s => `\x1b[2m${s}\x1b[0m`,
68
+ b: s => `\x1b[1m${s}\x1b[0m`,
69
+ };
70
+
71
+ function pad(s, n) { return String(s).padEnd(n); }
72
+
73
+ // ── Core validation ────────────────────────────────────────────────────────────
74
+
75
+ /**
76
+ * Validate a single tool entry object. Pushes issues into the shared arrays.
77
+ */
78
+ function validateToolEntry(toolPath, entry, errors, warnings) {
79
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
80
+ errors.push({ path: toolPath, message: 'entry must be a mapping (e.g. action: LOCAL)' });
81
+ return;
82
+ }
83
+
84
+ // Unknown fields in the tool entry
85
+ for (const key of Object.keys(entry)) {
86
+ if (!KNOWN_TOOL_ENTRY_KEYS.has(key)) {
87
+ warnings.push({ path: `${toolPath}.${key}`, message: 'unknown field — will be ignored at runtime' });
88
+ }
89
+ }
90
+
91
+ const action = entry.action;
92
+ if (action === undefined || action === null || action === '') {
93
+ errors.push({ path: `${toolPath}.action`, message: 'missing required field "action"' });
94
+ return;
95
+ }
96
+ if (!VALID_ACTIONS.has(action)) {
97
+ errors.push({
98
+ path: `${toolPath}.action`,
99
+ message: `"${action}" is not a valid action — must be PASS, LOCAL, or TRANSFORM`,
100
+ });
101
+ return;
102
+ }
103
+
104
+ if (action === 'TRANSFORM') {
105
+ if (!entry.transform || typeof entry.transform !== 'string' || !entry.transform.trim()) {
106
+ errors.push({
107
+ path: `${toolPath}.transform`,
108
+ message: 'TRANSFORM requires a non-empty "transform" field (e.g. transform: distill-output)',
109
+ });
110
+ } else if (!KNOWN_TRANSFORMS.has(entry.transform.trim())) {
111
+ warnings.push({
112
+ path: `${toolPath}.transform`,
113
+ message: `"${entry.transform}" is not a built-in transform — ensure it is implemented in the dispatcher`,
114
+ });
115
+ }
116
+ }
117
+
118
+ if (action === 'LOCAL' && entry.classifier) {
119
+ if (!KNOWN_CLASSIFIERS.has(entry.classifier)) {
120
+ warnings.push({
121
+ path: `${toolPath}.classifier`,
122
+ message: `"${entry.classifier}" is not a known classifier — entry will fall back to PASS at runtime`,
123
+ });
124
+ }
125
+ }
126
+
127
+ if ('max_output_tokens' in entry) {
128
+ const v = entry.max_output_tokens;
129
+ if (typeof v !== 'number' || !Number.isFinite(v) || !Number.isInteger(v) || v <= 0) {
130
+ errors.push({
131
+ path: `${toolPath}.max_output_tokens`,
132
+ message: `expected a positive integer (tokens), got ${JSON.stringify(v)}`,
133
+ });
134
+ }
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Validate a parsed policy object (output of loader.parse()).
140
+ *
141
+ * @param {object} parsed Raw parsed policy (not yet normalized)
142
+ * @returns {{ errors: Array<{path, message}>, warnings: Array<{path, message}> }}
143
+ */
144
+ function validatePolicy(parsed) {
145
+ const errors = [];
146
+ const warnings = [];
147
+
148
+ if (!parsed || typeof parsed !== 'object') {
149
+ errors.push({ path: '(document)', message: 'policy must be a YAML mapping at the top level' });
150
+ return { errors, warnings };
151
+ }
152
+
153
+ // Unknown top-level keys
154
+ for (const key of Object.keys(parsed)) {
155
+ if (!KNOWN_TOP_LEVEL.has(key)) {
156
+ warnings.push({ path: key, message: 'unknown key — will be ignored at runtime' });
157
+ }
158
+ }
159
+
160
+ // version type
161
+ if ('version' in parsed && typeof parsed.version !== 'number') {
162
+ errors.push({ path: 'version', message: `expected a number, got ${JSON.stringify(parsed.version)}` });
163
+ }
164
+
165
+ // Boolean flags
166
+ for (const flag of BOOLEAN_FLAGS) {
167
+ if (flag in parsed && typeof parsed[flag] !== 'boolean') {
168
+ errors.push({
169
+ path: flag,
170
+ message: `expected true or false, got ${JSON.stringify(parsed[flag])}`,
171
+ });
172
+ }
173
+ }
174
+
175
+ // deny_paths / allow_paths — error severity because a silently skipped deny
176
+ // entry is a silent security gap, not just a misconfiguration warning.
177
+ for (const listKey of ['deny_paths', 'allow_paths']) {
178
+ if (listKey in parsed) {
179
+ const val = parsed[listKey];
180
+ if (!Array.isArray(val)) {
181
+ errors.push({ path: listKey, message: 'must be a list (each entry on a new line starting with "- ")' });
182
+ } else {
183
+ val.forEach((entry, i) => {
184
+ if (typeof entry !== 'string' || !entry.trim()) {
185
+ errors.push({
186
+ path: `${listKey}[${i}]`,
187
+ message: 'entry is not a non-empty string — will be silently skipped at runtime',
188
+ });
189
+ }
190
+ });
191
+ }
192
+ }
193
+ }
194
+
195
+ // deny_patterns — error severity for invalid RegExp values
196
+ if ('deny_patterns' in parsed) {
197
+ const val = parsed.deny_patterns;
198
+ if (!val || typeof val !== 'object' || Array.isArray(val)) {
199
+ errors.push({ path: 'deny_patterns', message: 'must be a mapping of label: "regex-string" entries' });
200
+ } else {
201
+ for (const [label, rawPattern] of Object.entries(val)) {
202
+ if (typeof rawPattern !== 'string') {
203
+ errors.push({
204
+ path: `deny_patterns.${label}`,
205
+ message: `value must be a regex string, got ${JSON.stringify(rawPattern)}`,
206
+ });
207
+ } else {
208
+ try { new RegExp(rawPattern); } catch (e) {
209
+ errors.push({
210
+ path: `deny_patterns.${label}`,
211
+ message: `invalid RegExp "${rawPattern}" — ${e.message}`,
212
+ });
213
+ }
214
+ }
215
+ }
216
+ }
217
+ }
218
+
219
+ // tools block
220
+ if ('tools' in parsed) {
221
+ const t = parsed.tools;
222
+ if (!t || typeof t !== 'object' || Array.isArray(t)) {
223
+ errors.push({ path: 'tools', message: 'must be a mapping of tool names to entries' });
224
+ } else {
225
+ for (const [name, entry] of Object.entries(t)) {
226
+ validateToolEntry(`tools.${name}`, entry, errors, warnings);
227
+ }
228
+ }
229
+ }
230
+
231
+ return { errors, warnings };
232
+ }
233
+
234
+ // ── CLI display ────────────────────────────────────────────────────────────────
235
+
236
+ /**
237
+ * @param {string[]} args Remaining CLI args after 'policy validate'
238
+ * @param {object} [opts] { loader, fs } — injectable for tests
239
+ * @returns {{ ok: boolean }} ok=false when errors exist; caller should exit(1)
240
+ */
241
+ function runValidateCli(args, opts = {}) {
242
+ const loader = opts.loader || require('./loader');
243
+ const fsMod = opts.fs || fs;
244
+
245
+ // Determine file path — support --file override
246
+ const fileArgIdx = (args || []).indexOf('--file');
247
+ const filePath = (fileArgIdx >= 0 && args[fileArgIdx + 1])
248
+ ? path.resolve(args[fileArgIdx + 1])
249
+ : loader.DEFAULT_PATH;
250
+
251
+ // Attempt to read the file
252
+ let text = null;
253
+ let fileExists = false;
254
+ try {
255
+ text = fsMod.readFileSync(filePath, 'utf8');
256
+ fileExists = true;
257
+ } catch { /* file absent or unreadable */ }
258
+
259
+ // Header
260
+ console.log(col.b('\n⚡ Occasio — Policy Validate\n'));
261
+ const fileStatus = fileExists
262
+ ? `${filePath} ${col.g('(loaded)')}`
263
+ : `${col.d(filePath)} ${col.d('(not found)')}`;
264
+ console.log(` File: ${fileStatus}`);
265
+
266
+ if (!fileExists) {
267
+ console.log(col.g('\n ✓ No policy file — built-in defaults apply.\n'));
268
+ return { ok: true };
269
+ }
270
+
271
+ // Parse the raw text and validate
272
+ const parsed = loader.parse(text);
273
+ const { errors, warnings } = validatePolicy(parsed);
274
+
275
+ if (errors.length === 0 && warnings.length === 0) {
276
+ console.log(col.g('\n ✓ Policy is valid — no issues found.\n'));
277
+ return { ok: true };
278
+ }
279
+
280
+ const pathWidth = (items) =>
281
+ items.length ? Math.max(...items.map(i => i.path.length)) + 2 : 0;
282
+
283
+ if (errors.length) {
284
+ const pw = pathWidth(errors);
285
+ console.log(col.r(`\n ✗ ${errors.length} error${errors.length !== 1 ? 's' : ''}:`));
286
+ for (const e of errors) {
287
+ console.log(` ${pad(e.path, pw)} ${col.r(e.message)}`);
288
+ }
289
+ }
290
+
291
+ if (warnings.length) {
292
+ const pw = pathWidth(warnings);
293
+ console.log(col.y(`\n ⚠ ${warnings.length} warning${warnings.length !== 1 ? 's' : ''}:`));
294
+ for (const w of warnings) {
295
+ console.log(` ${pad(w.path, pw)} ${col.y(w.message)}`);
296
+ }
297
+ }
298
+
299
+ console.log('');
300
+
301
+ if (errors.length) {
302
+ console.log(col.r(' Errors found — policy will not apply as written.\n'));
303
+ return { ok: false };
304
+ }
305
+
306
+ console.log(col.y(' Warnings present — review the issues above.\n'));
307
+ return { ok: true };
308
+ }
309
+
310
+ module.exports = { validatePolicy, runValidateCli, KNOWN_TOP_LEVEL, KNOWN_TRANSFORMS, KNOWN_CLASSIFIERS, VALID_ACTIONS };