@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,226 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * occasio policy doctor — cross-reference live session logs with the
5
+ * active policy and surface actionable improvement suggestions.
6
+ *
7
+ * Usage:
8
+ * occasio policy doctor Analyse last 7 days of logs
9
+ * occasio policy doctor --days 14 Analyse last N days
10
+ */
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const os = require('os');
15
+
16
+ const col = {
17
+ r: s => `\x1b[31m${s}\x1b[0m`,
18
+ g: s => `\x1b[32m${s}\x1b[0m`,
19
+ y: s => `\x1b[33m${s}\x1b[0m`,
20
+ c: s => `\x1b[36m${s}\x1b[0m`,
21
+ d: s => `\x1b[2m${s}\x1b[0m`,
22
+ b: s => `\x1b[1m${s}\x1b[0m`,
23
+ };
24
+
25
+ const LOG_DIR = path.join(os.homedir(), '.occasio');
26
+
27
+ // Maps agent-protocol tool names (as logged in tools[].tool) to policy
28
+ // canonical names used in policy.yml tools: blocks.
29
+ const TOOL_TO_CANONICAL = {
30
+ Read: 'read_file',
31
+ Glob: 'find_files',
32
+ Grep: 'grep',
33
+ Bash: 'shell_bash',
34
+ PowerShell: 'shell_powershell',
35
+ TodoWrite: 'todo_write',
36
+ TodoRead: 'todo_read',
37
+ // Cline names (canonical = logged name for Cline)
38
+ read_file: 'read_file',
39
+ find_files: 'find_files',
40
+ grep: 'grep',
41
+ shell_bash: 'shell_bash',
42
+ };
43
+
44
+ // Tools where distillation is not meaningful (structured outputs, tiny results).
45
+ const SKIP_DISTILL_SUGGEST = new Set(['TodoWrite', 'TodoRead', 'todo_write', 'todo_read']);
46
+
47
+ function readRecentLogs(days, logsDir) {
48
+ const dir = logsDir || path.join(LOG_DIR, 'logs');
49
+ const entries = [];
50
+ try {
51
+ const files = fs.readdirSync(dir)
52
+ .filter(f => f.endsWith('.jsonl'))
53
+ .sort()
54
+ .reverse()
55
+ .slice(0, Math.min(days, 30));
56
+ for (const f of files) {
57
+ for (const line of fs.readFileSync(path.join(dir, f), 'utf8').split('\n')) {
58
+ if (!line.trim()) continue;
59
+ try { entries.push(JSON.parse(line)); } catch {}
60
+ }
61
+ }
62
+ } catch {}
63
+ return entries;
64
+ }
65
+
66
+ function aggregate(entries) {
67
+ const fallbackCounts = {};
68
+ const toolLocalCounts = {}; // logged tool name → # local runs
69
+ const toolDistilledCounts= {}; // logged tool name → # with distillation
70
+ const toolShapedCounts = {}; // logged tool name → # with any TRANSFORM
71
+ let totalAttempted = 0;
72
+ let totalLocal = 0;
73
+
74
+ for (const e of entries) {
75
+ totalAttempted += e.tools_attempted || 0;
76
+ totalLocal += (e.tools_local_count || 0) + (e.tools_mcp_count || 0);
77
+
78
+ for (const reason of (e.fallback_reasons || [])) {
79
+ fallbackCounts[reason] = (fallbackCounts[reason] || 0) + 1;
80
+ }
81
+
82
+ for (const t of (e.tools || [])) {
83
+ const name = t.tool || 'unknown';
84
+ toolLocalCounts[name] = (toolLocalCounts[name] || 0) + 1;
85
+ if (t.distilled) toolDistilledCounts[name] = (toolDistilledCounts[name] || 0) + 1;
86
+ if (t.transformed) toolShapedCounts[name] = (toolShapedCounts[name] || 0) + 1;
87
+ }
88
+ }
89
+
90
+ return { fallbackCounts, toolLocalCounts, toolDistilledCounts, toolShapedCounts,
91
+ totalAttempted, totalLocal };
92
+ }
93
+
94
+ function buildSuggestions(agg, policy) {
95
+ const suggestions = [];
96
+ const { fallbackCounts, toolLocalCounts, toolDistilledCounts, toolShapedCounts } = agg;
97
+
98
+ // ── Suggestion 1: secrets blocking local intercepts ───────────────────────
99
+ const secretBlockCount = fallbackCounts['secret_in_tool_result'] || 0;
100
+ if (secretBlockCount > 0 && policy.block_secrets_in_tool_results && !policy.redact_secrets_in_tool_results) {
101
+ suggestions.push({
102
+ severity: 'warn',
103
+ title: `${secretBlockCount} local intercept${secretBlockCount > 1 ? 's' : ''} blocked because a tool result contained a secret`,
104
+ detail: 'The interceptor ran the tool locally, found a secret in the output, and fell back to cloud. ' +
105
+ 'Redact mode keeps the result local with the secret replaced.',
106
+ fix: 'redact_secrets_in_tool_results: true\nblock_secrets_in_tool_results: false',
107
+ });
108
+ }
109
+
110
+ // ── Suggestion 2: local tools running without output shaping ─────────────
111
+ if (!policy.distill_tool_results) {
112
+ const candidates = Object.entries(toolLocalCounts)
113
+ .filter(([name]) => !SKIP_DISTILL_SUGGEST.has(name))
114
+ .map(([name, count]) => {
115
+ const shaped = (toolDistilledCounts[name] || 0) + (toolShapedCounts[name] || 0);
116
+ const unshaped = count - shaped;
117
+ return { name, count, unshaped };
118
+ })
119
+ .filter(t => t.unshaped > 2)
120
+ .sort((a, b) => b.unshaped - a.unshaped);
121
+
122
+ if (candidates.length > 0) {
123
+ const totalUnshaped = candidates.reduce((s, t) => s + t.unshaped, 0);
124
+ const top = candidates[0];
125
+ const canonicalHint = TOOL_TO_CANONICAL[top.name] || top.name.toLowerCase();
126
+ suggestions.push({
127
+ severity: 'info',
128
+ title: `${totalUnshaped} local tool result${totalUnshaped > 1 ? 's' : ''} sent to the model without output shaping`,
129
+ detail: `Heaviest: ${top.name} (${top.unshaped} unshaped runs). Full output is forwarded to the model — distillation removes boilerplate and saves tokens.`,
130
+ fix: candidates.length >= 2
131
+ ? `Global (all tools):\n distill_tool_results: true\n\nOr per-tool:\n tools:\n ${canonicalHint}:\n action: TRANSFORM\n transform: distill-output`
132
+ : `tools:\n ${canonicalHint}:\n action: TRANSFORM\n transform: distill-output`,
133
+ });
134
+ }
135
+ }
136
+
137
+ // ── Suggestion 3: shell commands with complex syntax (informational) ──────
138
+ const shellFallbacks = (fallbackCounts['ps_shell_composition'] || 0)
139
+ + (fallbackCounts['bash_shell_meta'] || 0)
140
+ + (fallbackCounts['ps_not_native'] || 0)
141
+ + (fallbackCounts['bash_unrecognized'] || 0);
142
+ if (shellFallbacks > 5) {
143
+ suggestions.push({
144
+ severity: 'info',
145
+ title: `${shellFallbacks} shell command${shellFallbacks > 1 ? 's' : ''} passed to cloud (complex syntax)`,
146
+ detail: 'Piped commands, redirects, and non-native executables cannot run locally — this is expected. No policy change needed.',
147
+ fix: null,
148
+ });
149
+ }
150
+
151
+ // ── Suggestion 4: max rounds exceeded (informational) ────────────────────
152
+ const maxRounds = fallbackCounts['max_rounds_exceeded'] || 0;
153
+ if (maxRounds > 2) {
154
+ suggestions.push({
155
+ severity: 'info',
156
+ title: `${maxRounds} session${maxRounds > 1 ? 's' : ''} hit the interceptor round limit`,
157
+ detail: 'Long tool chains can exhaust the interceptor\'s multi-round budget. The agent falls back to cloud for the remaining rounds — this is expected for complex tasks.',
158
+ fix: null,
159
+ });
160
+ }
161
+
162
+ if (suggestions.length === 0) {
163
+ suggestions.push({ severity: 'ok', title: 'No policy improvements found', detail: null, fix: null });
164
+ }
165
+
166
+ return suggestions;
167
+ }
168
+
169
+ function runDoctorCli(args, opts = {}) {
170
+ const loader = opts.loader || require('./loader');
171
+ const logsDir = opts.logsDir || null;
172
+
173
+ const daysArg = (args || []).indexOf('--days');
174
+ const days = daysArg >= 0 && args[daysArg + 1] ? (parseInt(args[daysArg + 1], 10) || 7) : 7;
175
+
176
+ console.log(col.b('\n⚡ Occasio — Policy Doctor\n'));
177
+
178
+ const entries = readRecentLogs(days, logsDir);
179
+ if (!entries.length) {
180
+ console.log(col.d(` No session logs found (last ${days} day${days > 1 ? 's' : ''}).`));
181
+ console.log(col.d(' Run a session first, then re-run occasio policy doctor.\n'));
182
+ return { ok: true, suggestions: [] };
183
+ }
184
+
185
+ const policy = loader.load();
186
+ const agg = aggregate(entries);
187
+ const suggestions = buildSuggestions(agg, policy);
188
+
189
+ // ── Summary line ───────────────────────────────────────────────────────────
190
+ const { totalAttempted, totalLocal } = agg;
191
+ const denom = Math.max(totalAttempted, totalLocal);
192
+ const localPct = denom > 0 ? Math.round((totalLocal / denom) * 100) : 0;
193
+
194
+ console.log(
195
+ ` Analysed ${col.b(entries.length)} log entr${entries.length > 1 ? 'ies' : 'y'}` +
196
+ ` · ${col.b(totalAttempted)} tool call${totalAttempted !== 1 ? 's' : ''}` +
197
+ ` · ${col.b(localPct + '%')} handled locally\n`,
198
+ );
199
+
200
+ // ── Suggestions ────────────────────────────────────────────────────────────
201
+ for (const s of suggestions) {
202
+ if (s.severity === 'ok') {
203
+ console.log(` ${col.g('✓')} ${s.title}\n`);
204
+ continue;
205
+ }
206
+ const icon = s.severity === 'warn' ? col.y('⚠') : col.c('ℹ');
207
+ console.log(` ${icon} ${col.b(s.title)}`);
208
+ if (s.detail) console.log(` ${col.d(s.detail)}`);
209
+ if (s.fix) {
210
+ console.log(`\n ${col.c('Suggested fix (policy.yml):')}`);
211
+ for (const line of s.fix.split('\n')) {
212
+ console.log(` ${col.c(line)}`);
213
+ }
214
+ }
215
+ console.log('');
216
+ }
217
+
218
+ const hasActionable = suggestions.some(s => s.fix);
219
+ if (hasActionable) {
220
+ console.log(col.d(' Edit ~/.occasio/policy.yml, then run occasio policy validate\n'));
221
+ }
222
+
223
+ return { ok: true, suggestions };
224
+ }
225
+
226
+ module.exports = { runDoctorCli, readRecentLogs, aggregate, buildSuggestions };
@@ -0,0 +1,339 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * PolicyEngine — evaluates a BoundaryEvent and returns a Decision.
5
+ *
6
+ * Stage 1: rules are hard-coded by delegating to existing classifiers
7
+ * (interceptor.classifyBlock for tool calls; analyzer.scanSecrets for content).
8
+ * The engine emits canonical Decision objects; downstream layers (dispatcher,
9
+ * auditor) act on the Decision, not on the legacy classifyBlock shape.
10
+ *
11
+ * Stage 2 will replace the body of `evaluate` with a YAML-driven rule
12
+ * evaluator. The contract of this module — `evaluate(event) → Decision` —
13
+ * stays stable across that change.
14
+ */
15
+
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+ const os = require('os');
19
+ const adapter = require('../adapters/claude-code');
20
+ const { PASS, LOCAL, BLOCK, TRANSFORM, TRANSFORM_CHAIN } = require('../core/decision');
21
+ const loader = require('./loader');
22
+ const builtIn = require('./built-in-classifiers');
23
+ const toolNames = require('../core/tool-names');
24
+ const { scanSecrets } = require('../analyzer');
25
+ const { extractShellReadPaths } = require('./shell-path');
26
+
27
+ // ── Path-based access control (ARCH-27) ──────────────────────────────────────
28
+
29
+ const PATH_BEARING_TOOLS = new Set(['read_file', 'find_files', 'grep']);
30
+ const SHELL_TOOLS = new Set(['shell_bash', 'shell_powershell']);
31
+
32
+ /** Extract the primary filesystem path from a tool's inputs. */
33
+ function primaryInputPath(toolName, toolInput) {
34
+ if (!toolInput) return null;
35
+ if (toolName === 'read_file') return toolInput.file_path || null;
36
+ if (toolName === 'find_files') return toolInput.path || null;
37
+ if (toolName === 'grep') return toolInput.path || null;
38
+ return null;
39
+ }
40
+
41
+ /**
42
+ * Resolve an input path to its canonical absolute form, following symlinks
43
+ * where the file exists. Falls back to path.resolve() for non-existent paths.
44
+ */
45
+ function resolveInputPath(rawPath) {
46
+ if (!rawPath || typeof rawPath !== 'string') return null;
47
+ const expanded = rawPath.startsWith('~') ? os.homedir() + rawPath.slice(1) : rawPath;
48
+ try {
49
+ return fs.realpathSync(expanded);
50
+ } catch {
51
+ return path.resolve(expanded);
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Safe prefix match: prevents ~/.aws from matching ~/.awskeys.
57
+ * On Windows, comparison is case-insensitive.
58
+ */
59
+ function matchesPrefix(inputNorm, denyNorm) {
60
+ return inputNorm === denyNorm || inputNorm.startsWith(denyNorm + path.sep);
61
+ }
62
+
63
+ const normCase = process.platform === 'win32'
64
+ ? (p) => p.toLowerCase()
65
+ : (p) => p;
66
+
67
+ /**
68
+ * Apply deny_paths / allow_paths to a single absolute path string.
69
+ * Returns a BLOCK Decision if denied/not-allowed, null otherwise.
70
+ */
71
+ function evaluatePathAgainstPolicy(resolvedAbsPath, policy) {
72
+ if (!resolvedAbsPath) return null;
73
+ const inputNorm = normCase(resolvedAbsPath);
74
+
75
+ const denyPaths = policy.deny_paths || [];
76
+ const allowPaths = policy.allow_paths || [];
77
+
78
+ for (const denyPath of denyPaths) {
79
+ if (matchesPrefix(inputNorm, normCase(denyPath))) {
80
+ return BLOCK(
81
+ { type: 'policy', reason: 'path-denied' },
82
+ 'path-denied'
83
+ );
84
+ }
85
+ }
86
+
87
+ if (allowPaths.length > 0) {
88
+ const allowed = allowPaths.some(ap => matchesPrefix(inputNorm, normCase(ap)));
89
+ if (!allowed) {
90
+ return BLOCK(
91
+ { type: 'policy', reason: 'path-not-allowed' },
92
+ 'path-not-allowed'
93
+ );
94
+ }
95
+ }
96
+ return null;
97
+ }
98
+
99
+ /**
100
+ * Check a tool input path against deny_paths and allow_paths.
101
+ * Returns a BLOCK Decision if the path is denied/not-allowed, null otherwise.
102
+ */
103
+ function checkPathPolicy(toolName, toolInput, policy) {
104
+ const rawPath = primaryInputPath(toolName, toolInput);
105
+ if (!rawPath) return null;
106
+ const resolved = resolveInputPath(rawPath);
107
+ return evaluatePathAgainstPolicy(resolved, policy);
108
+ }
109
+
110
+ /**
111
+ * Shell-mediated read enforcement: close the deny_paths bypass via
112
+ * `cat` / `Get-Content` / `head` / `tail` / `type` / `bat`.
113
+ *
114
+ * Walks the shell command for every file path the native handler would
115
+ * actually read (including across `cd … && …` / `Set-Location …; …` chains)
116
+ * and applies deny_paths / allow_paths to each one. A denied operand denies
117
+ * the whole command.
118
+ */
119
+ function checkShellPathPolicy(toolInput, policy) {
120
+ const denyPaths = policy.deny_paths || [];
121
+ const allowPaths = policy.allow_paths || [];
122
+ if (denyPaths.length === 0 && allowPaths.length === 0) return null;
123
+
124
+ const command = toolInput && toolInput.command;
125
+ if (typeof command !== 'string' || !command) return null;
126
+
127
+ const paths = extractShellReadPaths(command);
128
+ for (const p of paths) {
129
+ // Re-resolve through realpath where the file exists so a symlink that
130
+ // points into a denied directory is still denied (matches read_file).
131
+ const resolved = (() => {
132
+ try { return fs.realpathSync(p); } catch { return path.resolve(p); }
133
+ })();
134
+ const verdict = evaluatePathAgainstPolicy(resolved, policy);
135
+ if (verdict) return verdict;
136
+ }
137
+ return null;
138
+ }
139
+
140
+ /**
141
+ * Map a tool-call BoundaryEvent to a Decision using the legacy classifier.
142
+ *
143
+ * Stage 1 mapping:
144
+ * handled === true → LOCAL (executor='native', reason from classifyBlock)
145
+ * handled === false → PASS (reason from classifyBlock)
146
+ *
147
+ * Stage 2 will introduce TRANSFORM (redact / distill) and BLOCK
148
+ * (secret / budget) decisions natively; today those still happen inside
149
+ * legacy interceptToolUse and are not surfaced as Decisions.
150
+ */
151
+ function evaluate(event) {
152
+ if (!event) throw new Error('policy.evaluate requires a BoundaryEvent');
153
+
154
+ if (event.kind !== 'tool_call') {
155
+ return PASS('non-tool-call-event-passthrough');
156
+ }
157
+
158
+ const policy = loader.load();
159
+
160
+ // Path enforcement (ARCH-27): must run before any routing decision so that
161
+ // deny_paths / allow_paths block access regardless of tool routing config.
162
+ if (PATH_BEARING_TOOLS.has(event.toolName)) {
163
+ const pathBlock = checkPathPolicy(event.toolName, event.toolInput, policy);
164
+ if (pathBlock) return pathBlock;
165
+ }
166
+
167
+ // Shell-mediated read enforcement: deny_paths / allow_paths also gate
168
+ // `Bash { cat … }`, `PowerShell { Get-Content … }`, and the same shapes
169
+ // inside cd-prefixed compound chains. Closes the bypass where shell tools
170
+ // were unguarded because they are not in PATH_BEARING_TOOLS.
171
+ if (SHELL_TOOLS.has(event.toolName)) {
172
+ const shellBlock = checkShellPathPolicy(event.toolInput, policy);
173
+ if (shellBlock) return shellBlock;
174
+ }
175
+
176
+ // Stage 3: tool routing is policy-driven. Read ~/.occasio/policy.yml's
177
+ // `tools:` block. Default tools entries reproduce the previous hardcoded
178
+ // routing exactly (see DEFAULT_TOOLS in loader.js).
179
+ const tools = (policy && policy.tools) || {};
180
+
181
+ // Direct lookup by canonical name. If the event's toolName is an
182
+ // agent-specific alias (e.g., test code passes 'Read' instead of
183
+ // 'read_file'), reverse-resolve via the registry.
184
+ let entry = tools[event.toolName];
185
+ if (!entry && !toolNames.isCanonical(event.toolName)) {
186
+ const canonical = toolNames.firstCanonicalFor(event.toolName);
187
+ if (canonical) entry = tools[canonical];
188
+ }
189
+
190
+ // Unknown tool — preserve legacy 'tool_not_handled' reason code so dashboard
191
+ // / fallback_reasons surfaces are unchanged.
192
+ if (!entry) {
193
+ return PASS('tool_not_handled');
194
+ }
195
+
196
+ if (entry.action === 'PASS') {
197
+ return PASS(entry.reason || 'tool_not_handled');
198
+ }
199
+
200
+ // Per-tool TRANSFORM: explicit policy override. When the other global flag is
201
+ // also active, chain the two known transforms in security-first order
202
+ // (redact-secrets → distill-output). Unknown per-tool transforms pass through
203
+ // without chaining — only the two built-in names participate in auto-chain.
204
+ if (entry.action === 'TRANSFORM') {
205
+ if (entry.transform === 'redact-secrets' && policy.distill_tool_results) {
206
+ return TRANSFORM_CHAIN(['redact-secrets', 'distill-output'], entry.reason || 'per-tool-chain');
207
+ }
208
+ if (entry.transform === 'distill-output' && policy.redact_secrets_in_tool_results) {
209
+ return TRANSFORM_CHAIN(['redact-secrets', 'distill-output'], entry.reason || 'per-tool-chain');
210
+ }
211
+ return TRANSFORM(entry.transform, entry.reason || 'per-tool-policy');
212
+ }
213
+
214
+ if (entry.action === 'LOCAL') {
215
+ if (entry.classifier) {
216
+ const cls = builtIn.lookup(entry.classifier);
217
+ if (!cls) {
218
+ // Policy references an unknown classifier — fail safe (PASS) and
219
+ // surface a clear reason in fallback_reasons.
220
+ return PASS(`unknown-classifier:${entry.classifier}`);
221
+ }
222
+ const result = cls(event);
223
+ const handled = !!(result && result.handled);
224
+ const reason = (result && result.reason) || 'classifier-rejected';
225
+ if (!handled) return PASS(reason);
226
+ if (policy.redact_secrets_in_tool_results && policy.distill_tool_results) {
227
+ return TRANSFORM_CHAIN(['redact-secrets', 'distill-output'], reason);
228
+ }
229
+ if (policy.redact_secrets_in_tool_results) {
230
+ return TRANSFORM('redact-secrets', reason);
231
+ }
232
+ if (policy.distill_tool_results) {
233
+ return TRANSFORM('distill-output', reason);
234
+ }
235
+ return LOCAL(entry.executor || 'native', reason);
236
+ }
237
+ // No classifier specified → unconditional LOCAL (caller's executor must
238
+ // handle invalid input gracefully).
239
+ if (policy.redact_secrets_in_tool_results && policy.distill_tool_results) {
240
+ return TRANSFORM_CHAIN(['redact-secrets', 'distill-output'], entry.reason || 'ok');
241
+ }
242
+ if (policy.redact_secrets_in_tool_results) {
243
+ return TRANSFORM('redact-secrets', entry.reason || 'ok');
244
+ }
245
+ if (policy.distill_tool_results) {
246
+ return TRANSFORM('distill-output', entry.reason || 'ok');
247
+ }
248
+ return LOCAL(entry.executor || 'native', entry.reason || 'ok');
249
+ }
250
+
251
+ // Unknown action — fail safe.
252
+ return PASS(`unknown-action:${entry.action}`);
253
+ }
254
+
255
+ /**
256
+ * Evaluate a batch of tool_results against the secret-scan policy.
257
+ * Returns:
258
+ * { action: 'PASS', secrets: [...] } - scan ran but policy says don't block
259
+ * { action: 'BLOCK', secrets, syntheticResponse, reason }
260
+ *
261
+ * Stage 2 wiring:
262
+ * - Reads `block_secrets_in_tool_results` from ~/.occasio/policy.yml.
263
+ * - In legacy `block_secrets` mode, the policy's BLOCK is honored if
264
+ * enabled; in normal `intercept` mode, scan still runs (callers can
265
+ * surface warnings) but no block.
266
+ *
267
+ * @param {Array} toolResults tool_result content blocks
268
+ * @param {object} ctx { mode } the runToolLoop mode
269
+ * @returns {object} Decision shape with extra `secrets` field for the caller.
270
+ */
271
+ function evaluateToolResults(toolResults, ctx = {}) {
272
+ const policy = loader.load();
273
+ const extraPatterns = (policy.deny_patterns && policy.deny_patterns.length)
274
+ ? policy.deny_patterns : undefined;
275
+ const secrets = [];
276
+ for (const r of toolResults || []) {
277
+ if (typeof r?.content !== 'string') continue;
278
+ for (const hit of scanSecrets(r.content, extraPatterns ? { extraPatterns } : undefined)) {
279
+ secrets.push({ ...hit, tool_use_id: r.tool_use_id });
280
+ }
281
+ }
282
+ if (secrets.length === 0) {
283
+ return { action: 'PASS', reason: 'no-secrets-detected', secrets };
284
+ }
285
+ // Secrets present. Honor the policy + legacy mode contract:
286
+ // block_secrets_in_tool_results === true AND mode === 'block_secrets'
287
+ // → BLOCK with synthetic refusal
288
+ // Otherwise: PASS but surface secrets to caller for logging.
289
+ const blockEnabled = policy.block_secrets_in_tool_results !== false;
290
+ if (blockEnabled && ctx.mode === 'block_secrets') {
291
+ return Object.assign(
292
+ BLOCK(
293
+ { type: 'fallback', reason: `secret in tool result: ${secrets[0].label}` },
294
+ `secret in tool result: ${secrets[0].label}`
295
+ ),
296
+ { secrets },
297
+ );
298
+ }
299
+ return { action: 'PASS', reason: 'secrets-detected-not-blocked', secrets };
300
+ }
301
+
302
+ /**
303
+ * Evaluate an outbound request against the budget policy.
304
+ *
305
+ * @param {object} ctx { sessionCost, budget }
306
+ * @returns {object} Decision: PASS or BLOCK with synthetic 402 response.
307
+ */
308
+ function evaluateRequest(ctx = {}) {
309
+ const policy = loader.load();
310
+ const { sessionCost, budget } = ctx;
311
+ if (
312
+ policy.block_requests_over_budget !== false &&
313
+ typeof budget === 'number' && budget > 0 &&
314
+ typeof sessionCost === 'number' && sessionCost >= budget
315
+ ) {
316
+ // syntheticResponse mirrors the legacy 402 body so the proxy can write it
317
+ // to the wire unchanged. Stage 3 may enrich the body with policy metadata.
318
+ return Object.assign(
319
+ BLOCK(
320
+ {
321
+ status: 402,
322
+ body: {
323
+ error: {
324
+ type: 'budget_exceeded',
325
+ budget,
326
+ spent: sessionCost,
327
+ by: 'Occasio',
328
+ },
329
+ },
330
+ },
331
+ 'budget-exceeded'
332
+ ),
333
+ { sessionCost, budget },
334
+ );
335
+ }
336
+ return { action: 'PASS', reason: 'within-budget' };
337
+ }
338
+
339
+ module.exports = { evaluate, evaluateToolResults, evaluateRequest };