lynkr 9.0.2 → 9.1.2

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.
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Preflight Checks
3
+ *
4
+ * Runs user-supplied commands before invoking the model. If they all
5
+ * exit 0, the work is already done — we skip the LLM call entirely
6
+ * and return a synthetic "preflight_satisfied" response at zero cost.
7
+ *
8
+ * Typical use case: a fix-the-failing-test request that arrives after
9
+ * the test already passes (CI lag, retry-after-fix, idempotent agent
10
+ * retries).
11
+ *
12
+ * The request opts in by including a top-level `preflight_commands`
13
+ * array on the Anthropic-format payload, e.g.:
14
+ *
15
+ * {
16
+ * "model": "...",
17
+ * "messages": [...],
18
+ * "preflight_commands": ["pnpm test -- user-service"]
19
+ * }
20
+ *
21
+ * Disabled by default — gated on LYNKR_PREFLIGHT_ENABLED=true. The
22
+ * commands run with the same permissions as the Lynkr server, so
23
+ * operators should only enable this on workspaces where that is OK.
24
+ *
25
+ * @module orchestrator/preflight
26
+ */
27
+
28
+ const { spawnSync } = require('child_process');
29
+ const path = require('path');
30
+ const config = require('../config');
31
+ const logger = require('../logger');
32
+
33
+ const MAX_COMMANDS = 10;
34
+ const MAX_OUTPUT_BYTES = 4000;
35
+
36
+ /**
37
+ * Extract the preflight command list from a request payload.
38
+ * Accepts either `preflight_commands` (Lynkr-specific) or
39
+ * `metadata.lynkr_preflight_commands` (for clients that strip unknown
40
+ * top-level fields).
41
+ *
42
+ * @param {object} payload
43
+ * @returns {string[]}
44
+ */
45
+ function extractCommands(payload) {
46
+ if (!payload) return [];
47
+ const raw =
48
+ payload.preflight_commands ||
49
+ payload.metadata?.lynkr_preflight_commands ||
50
+ [];
51
+ if (!Array.isArray(raw)) return [];
52
+ return raw
53
+ .filter(cmd => typeof cmd === 'string' && cmd.trim().length > 0)
54
+ .slice(0, MAX_COMMANDS);
55
+ }
56
+
57
+ /**
58
+ * Resolve the workspace path for command execution. Falls back to
59
+ * process.cwd() if no workspace is supplied (the caller should usually
60
+ * pass one explicitly).
61
+ *
62
+ * @param {string|null|undefined} cwd
63
+ * @returns {string|null} absolute path, or null if invalid
64
+ */
65
+ function resolveCwd(cwd) {
66
+ if (!cwd || typeof cwd !== 'string') return null;
67
+ if (!path.isAbsolute(cwd)) return null;
68
+ return cwd;
69
+ }
70
+
71
+ /**
72
+ * Run a single command, returning a structured result.
73
+ *
74
+ * @param {string} command
75
+ * @param {string} cwd
76
+ * @param {number} timeoutMs
77
+ * @returns {{ command: string, exit_code: number|null, stdout: string, stderr: string, timed_out: boolean }}
78
+ */
79
+ function runCommand(command, cwd, timeoutMs) {
80
+ const result = spawnSync(command, {
81
+ cwd,
82
+ shell: true,
83
+ encoding: 'utf8',
84
+ timeout: timeoutMs,
85
+ maxBuffer: 10 * 1024 * 1024,
86
+ });
87
+ return {
88
+ command,
89
+ exit_code: result.status,
90
+ stdout: (result.stdout || '').slice(-MAX_OUTPUT_BYTES),
91
+ stderr: (result.stderr || '').slice(-MAX_OUTPUT_BYTES),
92
+ timed_out: result.signal === 'SIGTERM',
93
+ };
94
+ }
95
+
96
+ /**
97
+ * Try the preflight pass. Returns null when preflight should be
98
+ * skipped (disabled, no commands, missing cwd). Returns a result
99
+ * object otherwise.
100
+ *
101
+ * @param {object} args
102
+ * @param {object} args.payload - Anthropic-format request payload
103
+ * @param {string} [args.cwd] - Workspace cwd (absolute path)
104
+ * @returns {null | {
105
+ * satisfied: boolean,
106
+ * results: object[],
107
+ * failedCommand: string|null,
108
+ * reason: string,
109
+ * }}
110
+ */
111
+ function tryPreflight({ payload, cwd }) {
112
+ if (!config.routing?.preflightEnabled) return null;
113
+ const commands = extractCommands(payload);
114
+ if (commands.length === 0) return null;
115
+ const workspaceCwd = resolveCwd(cwd);
116
+ if (!workspaceCwd) {
117
+ logger.debug({ cwd }, '[Preflight] No valid cwd, skipping');
118
+ return null;
119
+ }
120
+
121
+ const timeoutMs = config.routing?.preflightTimeoutMs || 120000;
122
+ const results = [];
123
+ for (const command of commands) {
124
+ const r = runCommand(command, workspaceCwd, timeoutMs);
125
+ results.push(r);
126
+ if (r.exit_code !== 0) {
127
+ return {
128
+ satisfied: false,
129
+ results,
130
+ failedCommand: command,
131
+ reason: r.timed_out
132
+ ? `Preflight command timed out: ${command}`
133
+ : `Preflight command exited ${r.exit_code}: ${command}`,
134
+ };
135
+ }
136
+ }
137
+ return {
138
+ satisfied: true,
139
+ results,
140
+ failedCommand: null,
141
+ reason: 'All preflight commands passed.',
142
+ };
143
+ }
144
+
145
+ /**
146
+ * Build a synthetic "preflight satisfied" Anthropic Message response
147
+ * that processMessage can return without hitting the model.
148
+ *
149
+ * @param {object} args
150
+ * @param {string} args.model
151
+ * @param {object} args.preflightResult
152
+ * @returns {object} The full processMessage return value.
153
+ */
154
+ function buildSatisfiedResponse({ model, preflightResult }) {
155
+ const summary = `Preflight satisfied — work appears already complete (${preflightResult.results.length} command${preflightResult.results.length === 1 ? '' : 's'} passed).`;
156
+ return {
157
+ response: {
158
+ json: {
159
+ id: `msg_preflight_${Date.now()}`,
160
+ type: 'message',
161
+ role: 'assistant',
162
+ content: [{ type: 'text', text: summary }],
163
+ model,
164
+ stop_reason: 'end_turn',
165
+ stop_sequence: null,
166
+ usage: { input_tokens: 0, output_tokens: 0 },
167
+ lynkr_preflight: {
168
+ satisfied: true,
169
+ reason: preflightResult.reason,
170
+ results: preflightResult.results,
171
+ },
172
+ },
173
+ ok: true,
174
+ status: 200,
175
+ },
176
+ steps: 0,
177
+ durationMs: 0,
178
+ terminationReason: 'preflight_satisfied',
179
+ };
180
+ }
181
+
182
+ module.exports = {
183
+ tryPreflight,
184
+ buildSatisfiedResponse,
185
+ extractCommands,
186
+ // Exposed for tests
187
+ resolveCwd,
188
+ };
@@ -22,6 +22,7 @@ const {
22
22
  const { getAgenticDetector, AGENT_TYPES } = require('./agentic-detector');
23
23
  const { getModelTierSelector, TIER_DEFINITIONS } = require('./model-tiers');
24
24
  const { getCostOptimizer } = require('./cost-optimizer');
25
+ const { analyzeRisk } = require('./risk-analyzer');
25
26
 
26
27
  // Telemetry modules
27
28
  const telemetry = require('./telemetry');
@@ -97,6 +98,18 @@ function getBestLocalProvider() {
97
98
  async function determineProviderSmart(payload, options = {}) {
98
99
  const primaryProvider = config.modelProvider?.type ?? 'databricks';
99
100
 
101
+ // Risk analysis runs orthogonally to complexity. We compute it once
102
+ // up-front so it can short-circuit force_local and feed the tier
103
+ // selector below. Even when tier routing is disabled we still surface
104
+ // the signal for telemetry.
105
+ let risk = null;
106
+ try {
107
+ risk = analyzeRisk(payload);
108
+ } catch (err) {
109
+ logger.debug({ err: err.message }, '[Routing] Risk analysis failed, ignoring');
110
+ risk = null;
111
+ }
112
+
100
113
  // If tier routing is disabled, use static configuration
101
114
  if (!config.modelTiers?.enabled) {
102
115
  return {
@@ -104,9 +117,39 @@ async function determineProviderSmart(payload, options = {}) {
104
117
  model: null,
105
118
  method: 'static',
106
119
  reason: 'tier_routing_disabled',
120
+ risk,
107
121
  };
108
122
  }
109
123
 
124
+ // High-risk requests jump straight to COMPLEX and skip the rest of
125
+ // the analysis. This is independent of complexity score — a one-line
126
+ // edit to auth/middleware.ts should never go to a local model.
127
+ if (risk?.level === 'high' && isFallbackEnabled()) {
128
+ try {
129
+ const selector = getModelTierSelector();
130
+ const modelSelection = selector.selectModel('COMPLEX', null);
131
+ const decision = {
132
+ provider: modelSelection.provider,
133
+ model: modelSelection.model,
134
+ tier: 'COMPLEX',
135
+ method: 'risk',
136
+ reason: 'high_risk_forced_tier',
137
+ score: 100,
138
+ risk,
139
+ };
140
+ routingMetrics.record(decision);
141
+ logger.debug({
142
+ tier: 'COMPLEX',
143
+ provider: decision.provider,
144
+ instructionHits: risk.instructionHits,
145
+ pathHits: risk.pathHits,
146
+ }, '[Routing] High risk → forcing tier');
147
+ return decision;
148
+ } catch (err) {
149
+ logger.debug({ err: err.message }, '[Routing] Risk-forced tier selection failed, falling through');
150
+ }
151
+ }
152
+
110
153
  // Quick check for force patterns
111
154
  if (shouldForceLocal(payload)) {
112
155
  // When tier routing is enabled, respect TIER_SIMPLE instead of blindly choosing local
@@ -121,6 +164,7 @@ async function determineProviderSmart(payload, options = {}) {
121
164
  method: 'force',
122
165
  reason: 'force_local_pattern',
123
166
  score: 0,
167
+ risk,
124
168
  };
125
169
  routingMetrics.record(decision);
126
170
  return decision;
@@ -135,6 +179,7 @@ async function determineProviderSmart(payload, options = {}) {
135
179
  method: 'force',
136
180
  reason: 'force_local_pattern',
137
181
  score: 0,
182
+ risk,
138
183
  };
139
184
  routingMetrics.record(decision);
140
185
  return decision;
@@ -148,6 +193,7 @@ async function determineProviderSmart(payload, options = {}) {
148
193
  method: 'force',
149
194
  reason: 'force_cloud_pattern',
150
195
  score: 100,
196
+ risk,
151
197
  };
152
198
  routingMetrics.record(decision);
153
199
  return decision;
@@ -201,6 +247,7 @@ async function determineProviderSmart(payload, options = {}) {
201
247
  reason: 'autonomous_workflow',
202
248
  score: analysis.score,
203
249
  agenticResult,
250
+ risk,
204
251
  };
205
252
  routingMetrics.record(decision);
206
253
  return decision;
@@ -263,6 +310,7 @@ async function determineProviderSmart(payload, options = {}) {
263
310
  embeddingsResult,
264
311
  agenticResult,
265
312
  costOptimized: false,
313
+ risk,
266
314
  };
267
315
 
268
316
  // Phase 3: Record metrics
@@ -322,6 +370,18 @@ function getRoutingHeaders(decision) {
322
370
  headers['X-Lynkr-Cost-Optimized'] = 'true';
323
371
  }
324
372
 
373
+ if (decision.risk?.level) {
374
+ headers['X-Lynkr-Risk'] = decision.risk.level;
375
+ const hits = Array.from(new Set([
376
+ ...(decision.risk.instructionHits || []),
377
+ ...(decision.risk.pathHits || []),
378
+ ]));
379
+ if (hits.length > 0) {
380
+ // Header values are ASCII-only; comma-join the first few hits.
381
+ headers['X-Lynkr-Risk-Hits'] = hits.slice(0, 8).join(',');
382
+ }
383
+ }
384
+
325
385
  return headers;
326
386
  }
327
387
 
@@ -350,6 +410,7 @@ module.exports = {
350
410
 
351
411
  // Re-export analyzer for direct access
352
412
  analyzeComplexity: require('./complexity-analyzer').analyzeComplexity,
413
+ analyzeRisk,
353
414
 
354
415
  // Intelligent routing modules
355
416
  getAgenticDetector,
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Routing Interaction Block
3
+ *
4
+ * Builds an "interaction" block that explains, in plain text, what
5
+ * Lynkr decided to do with a request — which tier, which provider,
6
+ * why it routed there, and what (if anything) the user should do next.
7
+ *
8
+ * Lynkr already surfaces this information via X-Lynkr-* response
9
+ * headers, but headers are invisible to most users in Claude Code /
10
+ * Cursor / Codex. The interaction block lives in the response body
11
+ * so it shows up alongside the model's reply when the visible-routing
12
+ * env flag is on (LYNKR_VISIBLE_ROUTING=true).
13
+ *
14
+ * @module routing/interaction
15
+ */
16
+
17
+ /**
18
+ * Rough estimate of cost savings vs always-COMPLEX baseline. Not
19
+ * invoice-grade, just a reproducible number for users to glance at.
20
+ *
21
+ * @param {string|null} tier
22
+ * @param {string|null} provider
23
+ * @returns {number} 0-100
24
+ */
25
+ function estimateSavingsPercent(tier, provider) {
26
+ if (!tier) return 0;
27
+ const t = tier.toUpperCase();
28
+ // Local providers carry the same savings band as their tier.
29
+ const isLocal = provider && ['ollama', 'llamacpp', 'lmstudio'].includes(provider);
30
+ if (t === 'SIMPLE') return isLocal ? 100 : 70;
31
+ if (t === 'MEDIUM') return isLocal ? 90 : 45;
32
+ if (t === 'COMPLEX') return 10;
33
+ if (t === 'REASONING') return 0;
34
+ return 0;
35
+ }
36
+
37
+ /**
38
+ * Choose a mode label that describes what happened.
39
+ *
40
+ * @param {object} decision
41
+ * @returns {string}
42
+ */
43
+ function modeFor(decision) {
44
+ if (decision.method === 'risk') return 'risk_forced_tier';
45
+ if (decision.method === 'agentic') return 'agentic_workflow';
46
+ if (decision.method === 'force' && decision.reason === 'force_local_pattern') return 'force_local';
47
+ if (decision.method === 'force' && decision.reason === 'force_cloud_pattern') return 'force_cloud';
48
+ if (decision.method === 'static') return 'static';
49
+ return 'tier_routed';
50
+ }
51
+
52
+ /**
53
+ * Produce a one-line, terminal-friendly route label, e.g.
54
+ * "[Lynkr] tier=COMPLEX provider=databricks risk=high score=78"
55
+ *
56
+ * @param {object} decision
57
+ * @returns {string}
58
+ */
59
+ function routeLabel(decision) {
60
+ const parts = ['[Lynkr]'];
61
+ if (decision.tier) parts.push(`tier=${decision.tier}`);
62
+ if (decision.provider) parts.push(`provider=${decision.provider}`);
63
+ if (decision.model) parts.push(`model=${decision.model}`);
64
+ if (decision.risk?.level) parts.push(`risk=${decision.risk.level}`);
65
+ if (typeof decision.score === 'number') parts.push(`score=${decision.score}`);
66
+ return parts.join(' ');
67
+ }
68
+
69
+ /**
70
+ * Headline + next_step are model-facing prose. We keep them terse so
71
+ * they don't pollute the user's view when the model echoes them back.
72
+ *
73
+ * @param {object} decision
74
+ * @returns {{ headline: string, next_step: string }}
75
+ */
76
+ function copyFor(decision) {
77
+ const mode = modeFor(decision);
78
+ if (mode === 'risk_forced_tier') {
79
+ return {
80
+ headline: `Lynkr routed to ${decision.tier} tier because the request touches a protected domain.`,
81
+ next_step: 'Review the response carefully — sensitive logic was involved.',
82
+ };
83
+ }
84
+ if (mode === 'agentic_workflow') {
85
+ return {
86
+ headline: `Lynkr detected an agentic workflow and routed to ${decision.provider || decision.tier}.`,
87
+ next_step: 'No action needed — autonomous workflows always use cloud providers.',
88
+ };
89
+ }
90
+ if (mode === 'force_local') {
91
+ return {
92
+ headline: 'Lynkr routed to the local tier (greeting or trivial request).',
93
+ next_step: 'No action needed.',
94
+ };
95
+ }
96
+ if (mode === 'force_cloud') {
97
+ return {
98
+ headline: `Lynkr forced cloud routing (${decision.provider || 'cloud'}) for this request.`,
99
+ next_step: 'No action needed.',
100
+ };
101
+ }
102
+ if (mode === 'static') {
103
+ return {
104
+ headline: `Lynkr used the static provider ${decision.provider}.`,
105
+ next_step: 'Tier routing is disabled — set TIER_* env vars to enable.',
106
+ };
107
+ }
108
+ return {
109
+ headline: `Lynkr routed to the ${decision.tier || 'default'} tier (${decision.provider || 'unknown'}).`,
110
+ next_step: 'No action needed.',
111
+ };
112
+ }
113
+
114
+ /**
115
+ * Build the full interaction block.
116
+ *
117
+ * @param {object} decision - The routing decision (from determineProviderSmart
118
+ * or the pre-route in api/router.js). Must at least have `provider`; ideally
119
+ * includes `tier`, `model`, `method`, `reason`, `score`, and `risk`.
120
+ * @returns {object}
121
+ */
122
+ function buildInteractionBlock(decision) {
123
+ if (!decision || typeof decision !== 'object') return null;
124
+ const { headline, next_step } = copyFor(decision);
125
+ return {
126
+ tool: 'lynkr.route',
127
+ mode: modeFor(decision),
128
+ headline,
129
+ route_label: routeLabel(decision),
130
+ reason: decision.reason || 'unspecified',
131
+ tier: decision.tier || null,
132
+ provider: decision.provider || null,
133
+ model: decision.model || null,
134
+ risk: decision.risk?.level || 'low',
135
+ risk_hits: Array.from(new Set([
136
+ ...(decision.risk?.instructionHits || []),
137
+ ...(decision.risk?.pathHits || []),
138
+ ])),
139
+ complexity_score: typeof decision.score === 'number' ? decision.score : null,
140
+ estimated_savings_percent: estimateSavingsPercent(decision.tier, decision.provider),
141
+ next_step,
142
+ };
143
+ }
144
+
145
+ /**
146
+ * Attach an interaction block to an Anthropic-format response body.
147
+ * Mutates and returns the body.
148
+ *
149
+ * Anthropic clients ignore unknown top-level fields, so this is safe.
150
+ *
151
+ * @param {object} body
152
+ * @param {object} interaction
153
+ * @returns {object}
154
+ */
155
+ function attachToAnthropicResponse(body, interaction) {
156
+ if (!body || !interaction) return body;
157
+ body.lynkr_interaction = interaction;
158
+ return body;
159
+ }
160
+
161
+ /**
162
+ * Attach an interaction block to an OpenAI chat-completions response.
163
+ * Mutates and returns the body.
164
+ *
165
+ * @param {object} body
166
+ * @param {object} interaction
167
+ * @returns {object}
168
+ */
169
+ function attachToOpenAIResponse(body, interaction) {
170
+ if (!body || !interaction) return body;
171
+ body.lynkr_interaction = interaction;
172
+ return body;
173
+ }
174
+
175
+ module.exports = {
176
+ buildInteractionBlock,
177
+ attachToAnthropicResponse,
178
+ attachToOpenAIResponse,
179
+ // Exposed for tests
180
+ estimateSavingsPercent,
181
+ modeFor,
182
+ routeLabel,
183
+ };
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Risk Analyzer
3
+ *
4
+ * Scores a request along a risk axis that is orthogonal to complexity.
5
+ * A trivially short edit to `auth/middleware.ts` is still high risk and
6
+ * should not be served by a cheap local model.
7
+ *
8
+ * @module routing/risk-analyzer
9
+ */
10
+
11
+ const { extractContent } = require('./complexity-analyzer');
12
+
13
+ // Substring keywords found in file paths or instruction text.
14
+ // Matched case-insensitively as raw substrings, so "auth" hits
15
+ // "src/auth/login.ts" and "authentication".
16
+ const PROTECTED_PATH_KEYWORDS = [
17
+ 'auth', 'oauth', 'jwt', 'session', 'security', 'permission', 'rbac',
18
+ 'payment', 'payments', 'billing', 'invoice', 'subscription',
19
+ 'migration', 'migrations', 'schema',
20
+ 'infra', 'terraform', 'kustomize', 'helm', 'kubernetes',
21
+ '.github/workflows', '.env', 'secret', 'credential',
22
+ 'api-key', 'api_key', 'apikey', 'token',
23
+ 'webhook', 'admin',
24
+ ];
25
+
26
+ // Whole-word instruction keywords that signal sensitive intent regardless
27
+ // of which files are involved. Higher signal than path keywords because
28
+ // they reflect what the user is *asking for*.
29
+ const HIGH_RISK_INSTRUCTION_KEYWORDS = [
30
+ 'authentication', 'authorization', 'permission', 'security',
31
+ 'payment', 'billing', 'migration', 'database schema',
32
+ 'encrypt', 'decrypt', 'secret', 'credential', 'api key',
33
+ 'production', 'deploy', 'rollout', 'rollback',
34
+ ];
35
+
36
+ // Path-extracting patterns. We look at:
37
+ // 1. Anything that looks like a file path inside the instruction text.
38
+ // 2. Explicit path-like fields in tool inputs (e.g. tool_use blocks).
39
+ const PATH_LIKE_RE = /(?:^|[\s`'"([])([./a-zA-Z0-9_-]+\.[a-zA-Z0-9]{1,8})(?=[\s`'")\]:,;]|$)/g;
40
+ const SLASHED_PATH_RE = /(?:^|[\s`'"([])((?:[a-zA-Z0-9_.-]+\/)+[a-zA-Z0-9_.-]+)(?=[\s`'")\]:,;]|$)/g;
41
+
42
+ /**
43
+ * Pull every path-shaped substring out of free-form text.
44
+ * @param {string} text
45
+ * @returns {string[]}
46
+ */
47
+ function extractPathsFromText(text) {
48
+ if (!text) return [];
49
+ const out = new Set();
50
+ let m;
51
+ while ((m = PATH_LIKE_RE.exec(text)) !== null) {
52
+ out.add(m[1]);
53
+ }
54
+ while ((m = SLASHED_PATH_RE.exec(text)) !== null) {
55
+ out.add(m[1]);
56
+ }
57
+ return Array.from(out);
58
+ }
59
+
60
+ /**
61
+ * Walk every tool_use block in the conversation and collect any string
62
+ * inputs that look like paths. Catches cases where the model already
63
+ * called an Edit/Read tool on a sensitive file.
64
+ * @param {object} payload
65
+ * @returns {string[]}
66
+ */
67
+ function extractPathsFromToolUses(payload) {
68
+ const out = new Set();
69
+ const messages = payload?.messages;
70
+ if (!Array.isArray(messages)) return [];
71
+
72
+ for (const msg of messages) {
73
+ if (!Array.isArray(msg?.content)) continue;
74
+ for (const block of msg.content) {
75
+ if (block?.type !== 'tool_use' || !block.input) continue;
76
+ const stack = [block.input];
77
+ while (stack.length) {
78
+ const node = stack.pop();
79
+ if (typeof node === 'string') {
80
+ if (node.includes('/') || node.includes('.')) {
81
+ // Treat short tool-input strings that look path-y as paths.
82
+ if (node.length <= 200) out.add(node);
83
+ }
84
+ } else if (Array.isArray(node)) {
85
+ for (const v of node) stack.push(v);
86
+ } else if (node && typeof node === 'object') {
87
+ for (const v of Object.values(node)) stack.push(v);
88
+ }
89
+ }
90
+ }
91
+ }
92
+ return Array.from(out);
93
+ }
94
+
95
+ /**
96
+ * Find which keywords from `keywords` appear (case-insensitively) inside
97
+ * any of `haystack`. Substring match — by design — so "auth" matches
98
+ * both "src/auth/login.ts" and the word "authorization".
99
+ * @param {string[]} keywords
100
+ * @param {string[]} haystack
101
+ * @returns {string[]} hit keywords, sorted
102
+ */
103
+ function findHits(keywords, haystack) {
104
+ const hits = new Set();
105
+ const joined = haystack.join('\n').toLowerCase();
106
+ for (const kw of keywords) {
107
+ if (joined.includes(kw.toLowerCase())) hits.add(kw);
108
+ }
109
+ return Array.from(hits).sort();
110
+ }
111
+
112
+ /**
113
+ * Analyze the risk level of a request.
114
+ *
115
+ * Risk is orthogonal to complexity:
116
+ * - low → no protected paths or sensitive keywords detected
117
+ * - medium → protected paths *or* a read-only task on a protected area
118
+ * - high → instruction explicitly names sensitive domain logic,
119
+ * or protected paths combined with a write-intent task
120
+ *
121
+ * @param {object} payload - Anthropic-format request payload
122
+ * @returns {{ level: 'low'|'medium'|'high',
123
+ * reason: string,
124
+ * pathHits: string[],
125
+ * instructionHits: string[],
126
+ * paths: string[] }}
127
+ */
128
+ function analyzeRisk(payload) {
129
+ const instructionText = extractContent(payload) || '';
130
+ const lowText = instructionText.toLowerCase();
131
+
132
+ const textPaths = extractPathsFromText(instructionText);
133
+ const toolPaths = extractPathsFromToolUses(payload);
134
+ const allPaths = Array.from(new Set([...textPaths, ...toolPaths]));
135
+
136
+ // Instruction-level hits scan the raw text. Path-level hits scan only
137
+ // the extracted path strings so phrases like "authentication is hard"
138
+ // don't double-fire as a path hit.
139
+ const instructionHits = findHits(HIGH_RISK_INSTRUCTION_KEYWORDS, [instructionText]);
140
+ const pathHits = findHits(PROTECTED_PATH_KEYWORDS, allPaths.length ? allPaths : []);
141
+ // Also let path keywords match against the instruction text — covers
142
+ // "update the auth flow" with no path mentioned.
143
+ const textPathHits = findHits(PROTECTED_PATH_KEYWORDS, [instructionText]);
144
+ const mergedPathHits = Array.from(new Set([...pathHits, ...textPathHits])).sort();
145
+
146
+ if (instructionHits.length > 0) {
147
+ return {
148
+ level: 'high',
149
+ reason: 'High-risk instruction keyword detected.',
150
+ pathHits: mergedPathHits,
151
+ instructionHits,
152
+ paths: allPaths,
153
+ };
154
+ }
155
+
156
+ if (mergedPathHits.length > 0) {
157
+ // Read-only intent on a protected area is medium, not high.
158
+ // Heuristic: presence of explain/summarize/read verbs.
159
+ const readOnly = /\b(explain|summarize|describe|what does|walk me through|read|show|list|search|find|grep|locate)\b/i.test(lowText);
160
+ if (readOnly) {
161
+ return {
162
+ level: 'medium',
163
+ reason: 'Protected paths involved but task appears read-only.',
164
+ pathHits: mergedPathHits,
165
+ instructionHits: [],
166
+ paths: allPaths,
167
+ };
168
+ }
169
+ return {
170
+ level: 'high',
171
+ reason: 'Protected path referenced with write-capable intent.',
172
+ pathHits: mergedPathHits,
173
+ instructionHits: [],
174
+ paths: allPaths,
175
+ };
176
+ }
177
+
178
+ return {
179
+ level: 'low',
180
+ reason: 'No risk signals detected.',
181
+ pathHits: [],
182
+ instructionHits: [],
183
+ paths: allPaths,
184
+ };
185
+ }
186
+
187
+ module.exports = {
188
+ analyzeRisk,
189
+ PROTECTED_PATH_KEYWORDS,
190
+ HIGH_RISK_INSTRUCTION_KEYWORDS,
191
+ // Exposed for tests
192
+ extractPathsFromText,
193
+ extractPathsFromToolUses,
194
+ };