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