@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,139 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* mcp-normalize.js — Input normalization shims for Occasio MCP tools.
|
|
5
|
+
*
|
|
6
|
+
* Adapts the parameter shapes Claude actually sends (matching the lao server's
|
|
7
|
+
* observed schemas) to the shapes expected by handleReadTool, handleGlobTool,
|
|
8
|
+
* and handleGrepTool in interceptor.js.
|
|
9
|
+
*
|
|
10
|
+
* All three functions are pure (no I/O) and exported for unit testing.
|
|
11
|
+
*
|
|
12
|
+
* Real Claude call shapes observed:
|
|
13
|
+
* read_file — { path, start_line?, end_line? }
|
|
14
|
+
* find_files — { pattern, path?, extension?, max_results? }
|
|
15
|
+
* grep — { pattern, path?, case_sensitive?, file_glob?, max_results? }
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Normalize read_file arguments.
|
|
20
|
+
*
|
|
21
|
+
* Claude sends: { path, start_line?, end_line? }
|
|
22
|
+
* Legacy accepted: { file_path, offset?, limit? }
|
|
23
|
+
* handleReadTool expects: { file_path, offset?, limit? }
|
|
24
|
+
*
|
|
25
|
+
* start_line / end_line are 1-based line numbers (inclusive).
|
|
26
|
+
* offset is 0-based; limit is a line count.
|
|
27
|
+
*
|
|
28
|
+
* @param {object} args
|
|
29
|
+
* @returns {{ file_path: string, offset?: number, limit?: number }}
|
|
30
|
+
*/
|
|
31
|
+
function normalizeReadInput(args) {
|
|
32
|
+
if (!args || typeof args !== 'object') return { file_path: '' };
|
|
33
|
+
|
|
34
|
+
const file_path = (args.path || args.file_path || '').trim();
|
|
35
|
+
const result = { file_path };
|
|
36
|
+
|
|
37
|
+
if (args.start_line != null) {
|
|
38
|
+
// 1-based start_line → 0-based offset
|
|
39
|
+
const sl = parseInt(args.start_line, 10) || 0;
|
|
40
|
+
result.offset = Math.max(0, sl - 1);
|
|
41
|
+
if (args.end_line != null) {
|
|
42
|
+
const el = parseInt(args.end_line, 10) || sl;
|
|
43
|
+
result.limit = Math.max(1, el - sl + 1);
|
|
44
|
+
}
|
|
45
|
+
} else {
|
|
46
|
+
if (args.offset != null) result.offset = args.offset;
|
|
47
|
+
if (args.limit != null) result.limit = args.limit;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return result;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Normalize find_files arguments.
|
|
55
|
+
*
|
|
56
|
+
* Claude sends: { pattern, path?, extension?, max_results? }
|
|
57
|
+
* handleGlobTool expects: { pattern, path? }
|
|
58
|
+
*
|
|
59
|
+
* Conversions:
|
|
60
|
+
* plain name fragment (no glob chars) → **\/*fragment*
|
|
61
|
+
* extension only → **\/*<ext>
|
|
62
|
+
* name fragment + extension → **\/*fragment*<ext>
|
|
63
|
+
* full glob passthrough → unchanged
|
|
64
|
+
* max_results → ignored (handleGlobTool caps at 500)
|
|
65
|
+
*
|
|
66
|
+
* @param {object} args
|
|
67
|
+
* @returns {{ pattern: string, path?: string }}
|
|
68
|
+
*/
|
|
69
|
+
function normalizeFindInput(args) {
|
|
70
|
+
if (!args || typeof args !== 'object') return { pattern: '**/*' };
|
|
71
|
+
|
|
72
|
+
const rawPattern = (args.pattern || '').trim();
|
|
73
|
+
const ext = args.extension
|
|
74
|
+
? (args.extension.startsWith('.') ? args.extension : '.' + args.extension)
|
|
75
|
+
: '';
|
|
76
|
+
const isGlob = /[*?{[]/.test(rawPattern);
|
|
77
|
+
|
|
78
|
+
let pattern;
|
|
79
|
+
if (ext) {
|
|
80
|
+
if (!rawPattern) {
|
|
81
|
+
pattern = `**/*${ext}`;
|
|
82
|
+
} else if (isGlob) {
|
|
83
|
+
// Glob already present — append extension only when not already there
|
|
84
|
+
pattern = rawPattern.endsWith(ext) ? rawPattern : `${rawPattern}${ext}`;
|
|
85
|
+
} else {
|
|
86
|
+
// Plain fragment + extension: "analyzer" + ".js" → "**/*analyzer*.js"
|
|
87
|
+
pattern = `**/*${rawPattern}*${ext}`;
|
|
88
|
+
}
|
|
89
|
+
} else if (!rawPattern) {
|
|
90
|
+
pattern = '**/*';
|
|
91
|
+
} else if (isGlob) {
|
|
92
|
+
pattern = rawPattern;
|
|
93
|
+
} else {
|
|
94
|
+
// Plain name fragment: "interceptor" → "**/*interceptor*"
|
|
95
|
+
pattern = `**/*${rawPattern}*`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const result = { pattern };
|
|
99
|
+
if (args.path && args.path !== '.') result.path = args.path;
|
|
100
|
+
|
|
101
|
+
return result;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Normalize grep arguments.
|
|
106
|
+
*
|
|
107
|
+
* Claude sends: { pattern, path?, case_sensitive?, file_glob?, max_results?, output_mode? }
|
|
108
|
+
* handleGrepTool expects: { pattern, path?, glob?, '-i'?, head_limit?, output_mode? }
|
|
109
|
+
*
|
|
110
|
+
* lao schema default: case_sensitive = false → case-insensitive.
|
|
111
|
+
* We preserve that: -i is true unless case_sensitive is explicitly true.
|
|
112
|
+
*
|
|
113
|
+
* @param {object} args
|
|
114
|
+
* @returns {object}
|
|
115
|
+
*/
|
|
116
|
+
function normalizeGrepInput(args) {
|
|
117
|
+
if (!args || typeof args !== 'object') return { pattern: '', '-i': true };
|
|
118
|
+
|
|
119
|
+
const result = { pattern: args.pattern || '' };
|
|
120
|
+
|
|
121
|
+
if (args.path && args.path !== '.') result.path = args.path;
|
|
122
|
+
if (args.file_glob) result.glob = args.file_glob;
|
|
123
|
+
if (args.type) result.type = args.type;
|
|
124
|
+
|
|
125
|
+
// lao default: case_sensitive = false → case-insensitive search
|
|
126
|
+
result['-i'] = args.case_sensitive !== true;
|
|
127
|
+
|
|
128
|
+
if (args.max_results != null && args.max_results > 0) result.head_limit = args.max_results;
|
|
129
|
+
if (args.output_mode) result.output_mode = args.output_mode;
|
|
130
|
+
|
|
131
|
+
// Pass through context flags when present
|
|
132
|
+
if (args['-C'] != null) result['-C'] = args['-C'];
|
|
133
|
+
if (args['-A'] != null) result['-A'] = args['-A'];
|
|
134
|
+
if (args['-B'] != null) result['-B'] = args['-B'];
|
|
135
|
+
|
|
136
|
+
return result;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
module.exports = { normalizeReadInput, normalizeFindInput, normalizeGrepInput };
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* mcp-server.js — Occasio MCP server (registered as "lf").
|
|
5
|
+
*
|
|
6
|
+
* Exposes three tools under the mcp__lf__* namespace:
|
|
7
|
+
* read_file — reads a local file, returns cat-n formatted content
|
|
8
|
+
* find_files — finds files matching a glob pattern or name fragment
|
|
9
|
+
* grep — searches file content with a regex
|
|
10
|
+
*
|
|
11
|
+
* Input schemas match the shapes Claude actually sends (lao server convention).
|
|
12
|
+
* Normalization from wire shapes to internal handleR* shapes is done in
|
|
13
|
+
* mcp-normalize.js before execution.
|
|
14
|
+
*
|
|
15
|
+
* Every call is logged to ~/.occasio/mcp-experiment.jsonl for adoption
|
|
16
|
+
* measurement via `occasio mcp-experiment`.
|
|
17
|
+
*
|
|
18
|
+
* Runs on stdio (JSON-RPC 2.0). Registered in .claude/settings.json as "lf".
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const fs = require('fs');
|
|
22
|
+
const path = require('path');
|
|
23
|
+
const os = require('os');
|
|
24
|
+
const { normalizeReadInput, normalizeFindInput, normalizeGrepInput } = require('./mcp-normalize');
|
|
25
|
+
const { incrementSessionMcpCount } = require('./session');
|
|
26
|
+
const pipeline = require('./core/pipeline');
|
|
27
|
+
const { makeBoundaryEvent } = require('./core/boundary-event');
|
|
28
|
+
const { createAuditor } = require('./audit/jsonl-auditor');
|
|
29
|
+
|
|
30
|
+
const LOG_FILE = path.join(os.homedir(), '.occasio', 'mcp-experiment.jsonl');
|
|
31
|
+
|
|
32
|
+
// v0.6.5 (Step 2): MCP traffic is now governed by the same canonical pipeline
|
|
33
|
+
// as the Claude Code adapter. Tool calls are evaluated by policy.engine,
|
|
34
|
+
// dispatched by executor.dispatcher, and recorded by audit.jsonl-auditor —
|
|
35
|
+
// the same components, the same policy.yml, the same hash-chained log.
|
|
36
|
+
//
|
|
37
|
+
// Concurrency note: mcp-server.js runs as a child process of the agent (e.g.
|
|
38
|
+
// Claude Desktop) and writes to the same ~/.occasio/pipeline-events.jsonl
|
|
39
|
+
// as the Claude Code proxy. v0.6.5 ASSUMES single-writer discipline — only
|
|
40
|
+
// one of (Claude Code proxy, MCP server) appends to the file at a time. If
|
|
41
|
+
// both are running concurrently with audit traffic, an interleaved append on
|
|
42
|
+
// Windows can corrupt the chain. A dedicated slice (deferred) will harden
|
|
43
|
+
// concurrent audit writing; until then, run one or the other.
|
|
44
|
+
// Audit-file override via env var (symmetric with the Claude Code proxy
|
|
45
|
+
// in src/index.js). Used by `occasio harness --scenario mcp-*` to
|
|
46
|
+
// keep MCP test traffic out of the user's real ~/.occasio chain.
|
|
47
|
+
let mcpAuditor = createAuditor(process.env.LOCALFIRST_AUDIT_FILE || undefined);
|
|
48
|
+
|
|
49
|
+
// v0.6.6: emit a policy_loaded row on first policy load and on every
|
|
50
|
+
// hot-reload that changes the policy file's bytes. The MCP server is a
|
|
51
|
+
// separate process from the Claude Code proxy; each process emits its
|
|
52
|
+
// own policy_loaded row when it observes the change. Both rows are
|
|
53
|
+
// correct and attributed by their own session_id (absent on these rows;
|
|
54
|
+
// the policy-loaded event is process-scoped, not session-scoped).
|
|
55
|
+
{
|
|
56
|
+
const policyLoaderM = require('./policy/loader');
|
|
57
|
+
policyLoaderM.onPolicyChange((change) => {
|
|
58
|
+
const status = mcpAuditor.recordPolicyLoaded(change);
|
|
59
|
+
if (status && status.ok === false) {
|
|
60
|
+
const dropped = status.droppedRow ? JSON.stringify(status.droppedRow) : '(no row attached)';
|
|
61
|
+
process.stderr.write(`\n[occasio-mcp][audit-fatal] policy_loaded write failed: ${status.error?.message}\n`);
|
|
62
|
+
process.stderr.write(`[occasio-mcp][audit-fatal] dropped row: ${dropped}\n`);
|
|
63
|
+
process.stderr.write(`[occasio-mcp][audit-fatal] aborting MCP server.\n`);
|
|
64
|
+
setTimeout(() => process.exit(1), 250);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
// Cold-start trigger so the row lands before the first tool call.
|
|
68
|
+
try { policyLoaderM.load(); } catch (e) {
|
|
69
|
+
process.stderr.write(`[occasio-mcp] policy load failed at startup: ${e.message}\n`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Captured from the MCP `initialize` request's clientInfo.name. Falls back to
|
|
74
|
+
// the literal 'mcp-client' if the client did not provide one. Used as the
|
|
75
|
+
// `agent` field on every BoundaryEvent / audit row from this server.
|
|
76
|
+
let mcpClientName = 'mcp-client';
|
|
77
|
+
|
|
78
|
+
// Test hook: respond() writes JSON-RPC frames to stdout in production, but
|
|
79
|
+
// tests need to capture them without interleaving with the test runner's
|
|
80
|
+
// output. Default is process.stdout.write; tests override via the exported
|
|
81
|
+
// __setRespondHookForTests.
|
|
82
|
+
let respondHook = (frame) => process.stdout.write(frame + '\n');
|
|
83
|
+
|
|
84
|
+
function logCall(entry) {
|
|
85
|
+
try {
|
|
86
|
+
const dir = path.dirname(LOG_FILE);
|
|
87
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
88
|
+
fs.appendFileSync(LOG_FILE, JSON.stringify(entry) + '\n');
|
|
89
|
+
} catch {}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── Tool definitions (lao-compatible schemas) ──────────────────────────────────
|
|
93
|
+
|
|
94
|
+
const TOOLS = [
|
|
95
|
+
{
|
|
96
|
+
name: 'read_file',
|
|
97
|
+
description: 'Read a file from the local filesystem. Returns contents with line numbers.',
|
|
98
|
+
inputSchema: {
|
|
99
|
+
type: 'object',
|
|
100
|
+
properties: {
|
|
101
|
+
path: { type: 'string', description: 'Path to the file to read' },
|
|
102
|
+
start_line: { anyOf: [{ type: 'integer' }, { type: 'null' }], default: null,
|
|
103
|
+
description: 'First line to read (1-based, inclusive)' },
|
|
104
|
+
end_line: { anyOf: [{ type: 'integer' }, { type: 'null' }], default: null,
|
|
105
|
+
description: 'Last line to read (1-based, inclusive)' },
|
|
106
|
+
},
|
|
107
|
+
required: ['path'],
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
name: 'find_files',
|
|
112
|
+
description: 'Find files by glob pattern, name fragment, or extension.',
|
|
113
|
+
inputSchema: {
|
|
114
|
+
type: 'object',
|
|
115
|
+
properties: {
|
|
116
|
+
pattern: { type: 'string', description: 'Glob pattern (e.g. "**/*.js") or name fragment (e.g. "interceptor")' },
|
|
117
|
+
path: { type: 'string', default: '.', description: 'Directory to search in' },
|
|
118
|
+
extension: { anyOf: [{ type: 'string' }, { type: 'null' }], default: null,
|
|
119
|
+
description: 'File extension filter, e.g. ".js" or "ts"' },
|
|
120
|
+
max_results: { type: 'integer', default: 50, description: 'Maximum results (capped at 500)' },
|
|
121
|
+
},
|
|
122
|
+
required: ['pattern'],
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
name: 'grep',
|
|
127
|
+
description: 'Search file contents with a regular expression. Case-insensitive by default.',
|
|
128
|
+
inputSchema: {
|
|
129
|
+
type: 'object',
|
|
130
|
+
properties: {
|
|
131
|
+
pattern: { type: 'string', description: 'Regular expression to search for' },
|
|
132
|
+
path: { type: 'string', default: '.', description: 'File or directory to search in' },
|
|
133
|
+
case_sensitive: { type: 'boolean', default: false, description: 'Use case-sensitive matching (default: false)' },
|
|
134
|
+
file_glob: { anyOf: [{ type: 'string' }, { type: 'null' }], default: null,
|
|
135
|
+
description: 'Glob to filter files, e.g. "*.ts"' },
|
|
136
|
+
max_results: { type: 'integer', default: 50, description: 'Maximum results' },
|
|
137
|
+
output_mode: { type: 'string', enum: ['content', 'files_with_matches', 'count'],
|
|
138
|
+
description: 'Output format (default: content)' },
|
|
139
|
+
},
|
|
140
|
+
required: ['pattern'],
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
];
|
|
144
|
+
|
|
145
|
+
// ── JSON-RPC dispatch ──────────────────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
function respond(id, result) {
|
|
148
|
+
respondHook(JSON.stringify({ jsonrpc: '2.0', id, result }));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function respondError(id, code, message) {
|
|
152
|
+
respondHook(JSON.stringify({ jsonrpc: '2.0', id, error: { code, message } }));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function handleRequest(req) {
|
|
156
|
+
const { id, method, params } = req;
|
|
157
|
+
|
|
158
|
+
if (method === 'initialize') {
|
|
159
|
+
// v0.6.5: capture clientInfo.name so audit rows are attributed to the
|
|
160
|
+
// calling agent (e.g. 'claude-ai', 'cursor', 'continue'). Falls back to
|
|
161
|
+
// 'mcp-client' if the client omitted clientInfo or its name field.
|
|
162
|
+
const ci = params && params.clientInfo;
|
|
163
|
+
if (ci && typeof ci.name === 'string' && ci.name.trim()) {
|
|
164
|
+
mcpClientName = ci.name.trim();
|
|
165
|
+
}
|
|
166
|
+
respond(id, {
|
|
167
|
+
protocolVersion: '2024-11-05',
|
|
168
|
+
capabilities: { tools: {} },
|
|
169
|
+
serverInfo: { name: 'lf', version: '0.6.5' },
|
|
170
|
+
});
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (method === 'notifications/initialized') return;
|
|
175
|
+
|
|
176
|
+
if (method === 'tools/list') {
|
|
177
|
+
respond(id, { tools: TOOLS });
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (method === 'tools/call') {
|
|
182
|
+
const toolName = params?.name;
|
|
183
|
+
const rawInput = params?.arguments || {};
|
|
184
|
+
const ts = new Date().toISOString();
|
|
185
|
+
|
|
186
|
+
try {
|
|
187
|
+
let normalizedInput, logExtra = {};
|
|
188
|
+
|
|
189
|
+
if (toolName === 'read_file') {
|
|
190
|
+
normalizedInput = normalizeReadInput(rawInput);
|
|
191
|
+
logExtra = { file: normalizedInput.file_path };
|
|
192
|
+
} else if (toolName === 'find_files') {
|
|
193
|
+
normalizedInput = normalizeFindInput(rawInput);
|
|
194
|
+
logExtra = { pattern: normalizedInput.pattern };
|
|
195
|
+
} else if (toolName === 'grep') {
|
|
196
|
+
normalizedInput = normalizeGrepInput(rawInput);
|
|
197
|
+
logExtra = { pattern: rawInput.pattern };
|
|
198
|
+
} else {
|
|
199
|
+
respondError(id, -32601, `Unknown tool: ${toolName}`);
|
|
200
|
+
logCall({ ts, tool: toolName, error: 'unknown tool', source: 'mcp_lf' });
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// v0.6.5: route through the canonical governance pipeline.
|
|
205
|
+
// Tool names are already canonical (read_file, find_files, grep) by
|
|
206
|
+
// construction in this MCP server's TOOLS list, so no name translation
|
|
207
|
+
// is needed. The same policy.yml that governs Claude Code's Read also
|
|
208
|
+
// governs this read_file call.
|
|
209
|
+
const event = makeBoundaryEvent({
|
|
210
|
+
direction: 'inbound',
|
|
211
|
+
kind: 'tool_call',
|
|
212
|
+
agent: mcpClientName,
|
|
213
|
+
protocol: 'mcp',
|
|
214
|
+
toolName: toolName,
|
|
215
|
+
toolInput: normalizedInput,
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const out = await pipeline.processToolEvent(event, { auditor: mcpAuditor });
|
|
219
|
+
const r = out.result || {};
|
|
220
|
+
|
|
221
|
+
// BLOCK: policy denied this call (deny_paths, deny_patterns, etc.).
|
|
222
|
+
// Return an isError MCP response with the synthetic refusal text and
|
|
223
|
+
// do NOT increment the adoption counter — the call did not run locally.
|
|
224
|
+
if (r.blocked) {
|
|
225
|
+
logCall({ ts, tool: toolName, blocked: true, reason: r.reason, source: 'mcp_lf', ...logExtra });
|
|
226
|
+
respond(id, {
|
|
227
|
+
content: [{ type: 'text', text: '(blocked by policy)' }],
|
|
228
|
+
isError: true,
|
|
229
|
+
});
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// PASS: defensive — the three default-LOCAL tools should never PASS,
|
|
234
|
+
// but if a user policy reroutes them we surface a clear error rather
|
|
235
|
+
// than silently returning empty content.
|
|
236
|
+
if (r.passThrough) {
|
|
237
|
+
logCall({ ts, tool: toolName, passthrough: true, reason: r.reason, source: 'mcp_lf', ...logExtra });
|
|
238
|
+
respond(id, {
|
|
239
|
+
content: [{ type: 'text', text: `(not configured: ${r.reason || 'unknown'})` }],
|
|
240
|
+
isError: true,
|
|
241
|
+
});
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// LOCAL or TRANSFORM success. The dispatcher's handler returns
|
|
246
|
+
// { output, exitCode, ... }; TRANSFORM may add secretsRedacted or
|
|
247
|
+
// savedTokens, which we surface in the adoption log only.
|
|
248
|
+
const content = (typeof r.output === 'string') ? r.output : String(r.output || '');
|
|
249
|
+
|
|
250
|
+
if (r.matchCount !== undefined) logExtra.matchCount = r.matchCount;
|
|
251
|
+
if (r.distilled) logExtra.distillSaved = r.savedTokens || 0;
|
|
252
|
+
if (r.secretsRedacted?.length) logExtra.secretsFound = r.secretsRedacted.length;
|
|
253
|
+
|
|
254
|
+
incrementSessionMcpCount();
|
|
255
|
+
logCall({ ts, tool: toolName, exitCode: r.exitCode, source: 'mcp_lf', ...logExtra });
|
|
256
|
+
respond(id, {
|
|
257
|
+
content: [{ type: 'text', text: content }],
|
|
258
|
+
isError: false,
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
} catch (e) {
|
|
262
|
+
// v0.6.5: AuditWriteError from the pipeline must abort the MCP server
|
|
263
|
+
// for the same reason the Claude Code proxy aborts on it — a
|
|
264
|
+
// successful tool call cannot coexist with a missing audit row. The
|
|
265
|
+
// supervisor (or Claude Desktop's MCP-server restart logic) brings us
|
|
266
|
+
// back up.
|
|
267
|
+
if (e && e.name === 'AuditWriteError') {
|
|
268
|
+
const dropped = e.droppedRow ? JSON.stringify(e.droppedRow) : '(no row attached)';
|
|
269
|
+
process.stderr.write(`\n[occasio-mcp][audit-fatal] ${e.message}\n`);
|
|
270
|
+
process.stderr.write(`[occasio-mcp][audit-fatal] dropped row: ${dropped}\n`);
|
|
271
|
+
process.stderr.write(`[occasio-mcp][audit-fatal] aborting MCP server.\n`);
|
|
272
|
+
try {
|
|
273
|
+
respond(id, {
|
|
274
|
+
content: [{ type: 'text', text: 'audit-fatal: MCP server aborting' }],
|
|
275
|
+
isError: true,
|
|
276
|
+
});
|
|
277
|
+
} catch {}
|
|
278
|
+
setTimeout(() => process.exit(1), 250);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
logCall({ ts, tool: toolName, rawInput, error: e.message, source: 'mcp_lf' });
|
|
282
|
+
respond(id, {
|
|
283
|
+
content: [{ type: 'text', text: `Error: ${e.message}` }],
|
|
284
|
+
isError: true,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (id !== undefined) respondError(id, -32601, `Method not found: ${method}`);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ── Stdio framing ──────────────────────────────────────────────────────────────
|
|
294
|
+
|
|
295
|
+
let _buf = '';
|
|
296
|
+
process.stdin.setEncoding('utf8');
|
|
297
|
+
process.stdin.on('data', chunk => {
|
|
298
|
+
_buf += chunk;
|
|
299
|
+
const lines = _buf.split('\n');
|
|
300
|
+
_buf = lines.pop();
|
|
301
|
+
for (const line of lines) {
|
|
302
|
+
const trimmed = line.trim();
|
|
303
|
+
if (!trimmed) continue;
|
|
304
|
+
try { handleRequest(JSON.parse(trimmed)); } catch (e) { /* malformed JSON-RPC frame */ }
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
process.stdin.on('end', () => process.exit(0));
|
|
308
|
+
process.on('SIGTERM', () => process.exit(0));
|
|
309
|
+
|
|
310
|
+
// Test hooks. Exposed so test-interceptor.js can drive handleRequest
|
|
311
|
+
// directly with a temp auditor and a captured stdout, without spawning a
|
|
312
|
+
// child process. Production callers (bin/occasio-mcp.js) ignore these.
|
|
313
|
+
module.exports = {
|
|
314
|
+
handleRequest,
|
|
315
|
+
TOOLS,
|
|
316
|
+
__setAuditorForTests(a) { mcpAuditor = a || createAuditor(); },
|
|
317
|
+
__getClientName() { return mcpClientName; },
|
|
318
|
+
__setClientNameForTests(name) { mcpClientName = name || 'mcp-client'; },
|
|
319
|
+
__setRespondHookForTests(fn) { respondHook = fn || ((s) => process.stdout.write(s + '\n')); },
|
|
320
|
+
};
|