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