@sweny-ai/core 0.1.123 → 0.1.125

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/dist/agent-env.d.ts +171 -0
  2. package/dist/agent-env.js +393 -0
  3. package/dist/browser.d.ts +2 -2
  4. package/dist/browser.js +1 -1
  5. package/dist/claude.d.ts +61 -3
  6. package/dist/claude.js +187 -27
  7. package/dist/cli/check.d.ts +42 -1
  8. package/dist/cli/check.js +130 -8
  9. package/dist/cli/cloud-lifecycle.d.ts +24 -29
  10. package/dist/cli/cloud-lifecycle.js +187 -12
  11. package/dist/cli/cloud-report.js +45 -2
  12. package/dist/cli/config-file.d.ts +8 -1
  13. package/dist/cli/config-file.js +42 -1
  14. package/dist/cli/config.js +11 -14
  15. package/dist/cli/e2e.d.ts +22 -0
  16. package/dist/cli/e2e.js +31 -4
  17. package/dist/cli/main.d.ts +1 -0
  18. package/dist/cli/main.js +138 -46
  19. package/dist/cli/marketplace.d.ts +3 -1
  20. package/dist/cli/marketplace.js +2 -2
  21. package/dist/cli/new.d.ts +15 -0
  22. package/dist/cli/new.js +139 -65
  23. package/dist/cli/publish.js +9 -0
  24. package/dist/cli/run-output.d.ts +59 -0
  25. package/dist/cli/run-output.js +189 -0
  26. package/dist/cli/skill-env.d.ts +34 -0
  27. package/dist/cli/skill-env.js +64 -0
  28. package/dist/cli/templates.js +587 -0
  29. package/dist/cli/workflow-input.d.ts +41 -0
  30. package/dist/cli/workflow-input.js +56 -0
  31. package/dist/executor.d.ts +17 -1
  32. package/dist/executor.js +119 -9
  33. package/dist/index.d.ts +3 -3
  34. package/dist/index.js +2 -2
  35. package/dist/mermaid.d.ts +6 -0
  36. package/dist/mermaid.js +10 -5
  37. package/dist/schema.d.ts +218 -188
  38. package/dist/schema.js +24 -3
  39. package/dist/skills/betterstack.js +4 -0
  40. package/dist/skills/datadog.js +3 -0
  41. package/dist/skills/github.js +8 -0
  42. package/dist/skills/linear.js +8 -0
  43. package/dist/skills/notification.js +3 -0
  44. package/dist/skills/sentry.js +4 -0
  45. package/dist/skills/slack.js +2 -0
  46. package/dist/skills/supabase.js +9 -0
  47. package/dist/testing.d.ts +3 -2
  48. package/dist/testing.js +9 -3
  49. package/dist/types.d.ts +99 -2
  50. package/dist/types.js +17 -0
  51. package/dist/untrusted.d.ts +32 -0
  52. package/dist/untrusted.js +46 -0
  53. package/dist/workflows/browser.js +2 -2
  54. package/dist/workflows/implement.yml +2 -0
  55. package/dist/workflows/triage.yml +3 -1
  56. package/package.json +1 -1
package/dist/claude.d.ts CHANGED
@@ -9,6 +9,27 @@
9
9
  * is to use headless Claude Code — never the raw Anthropic API.
10
10
  */
11
11
  import type { Claude, Tool, ToolContext, NodeResult, JSONSchema, Logger, McpServerConfig } from "./types.js";
12
+ import { type AgentAccess, type SandboxMode } from "./agent-env.js";
13
+ /**
14
+ * Built-in tools disallowed for the route-evaluation (`evaluate`) and
15
+ * reflection/judge (`ask`) calls.
16
+ *
17
+ * These are pure classification calls over prior-node data that can be
18
+ * attacker-influenceable (a prior node summarizes an untrusted issue body,
19
+ * PR diff, log line, etc.). They must never be able to mutate the workspace
20
+ * or shell out, regardless of any node's own `disallowed_tools` policy.
21
+ * `maxTurns: 1` already bounds them; this removes the powerful built-ins
22
+ * from the model's context entirely as structural defense-in-depth.
23
+ */
24
+ export declare const CLASSIFICATION_DISALLOWED_TOOLS: readonly ["Bash", "Write", "Edit", "MultiEdit", "NotebookEdit", "WebFetch", "WebSearch"];
25
+ /**
26
+ * Built-in tools disallowed for a node run with `readOnly: true` (dry run,
27
+ * #380): everything that can edit the workspace, shell out (and so reach
28
+ * `gh`, `curl`, `git push`), spawn a subagent that could, or fetch an
29
+ * arbitrary URL (a GET can still trigger a webhook or exfiltrate data).
30
+ * Read, Grep, Glob and WebSearch stay available for analysis.
31
+ */
32
+ export declare const READ_ONLY_DISALLOWED_TOOLS: readonly ["Bash", "Write", "Edit", "MultiEdit", "NotebookEdit", "Task", "Agent", "WebFetch"];
12
33
  /** How sweny resolves which credentials reach the Claude Code subprocess. */
13
34
  export type SwenyAuthMode = "auto" | "api-key" | "oauth";
14
35
  export interface ResolveAuthEnvOpts {
@@ -59,6 +80,27 @@ export interface ClaudeClientOptions {
59
80
  defaultContext?: ToolContext;
60
81
  /** External MCP servers (GitHub, Linear, Sentry, etc.) — merged with core skill tools */
61
82
  mcpServers?: Record<string, McpServerConfig>;
83
+ /**
84
+ * Extra env var names passed to the agent subprocess on top of the
85
+ * allowlist (see agent-env.ts). `"*"` inherits the full environment.
86
+ * Default: `SWENY_ENV_PASSTHROUGH` (comma-separated).
87
+ */
88
+ envPassthrough?: string[];
89
+ /**
90
+ * Scope the agent env to the allowlist. Default: `SWENY_ENV_SCOPE`, else on
91
+ * when CI is truthy and off locally (full env).
92
+ */
93
+ envScope?: boolean;
94
+ /**
95
+ * SDK sandbox for agent commands: `auto` (sandbox when the host supports
96
+ * it, else warn and run unsandboxed), `strict` (sandbox or fail), `off`.
97
+ * Default: `SWENY_SANDBOX`, else `auto` when CI is truthy, `off` otherwise.
98
+ */
99
+ sandbox?: SandboxMode;
100
+ /** Extra hosts sandboxed commands may reach. Default: `SWENY_SANDBOX_ALLOWED_DOMAINS`. */
101
+ sandboxAllowedDomains?: string[];
102
+ /** Sandbox preflight probe (test seam). Returns a reason when the sandbox cannot run. */
103
+ sandboxProbe?: () => string | undefined;
62
104
  }
63
105
  /**
64
106
  * Wire an optional timeout + caller signal onto a single AbortController.
@@ -81,10 +123,22 @@ export declare class ClaudeClient implements Claude {
81
123
  private logger;
82
124
  private defaultContext;
83
125
  private mcpServers;
126
+ private envPassthrough;
127
+ private sandboxMode;
128
+ private sandboxAllowedDomains;
129
+ private sandboxProbe;
130
+ private sandboxWarned;
131
+ private envScope;
132
+ private envWarned;
84
133
  constructor(opts?: ClaudeClientOptions);
85
134
  /**
86
- * Build env for the Claude Code subprocess. Snapshots process.env (dropping
87
- * nullish values) and applies auth precedence via {@link resolveAuthEnv}.
135
+ * Build env for the Claude Code subprocess (#360). Applies auth precedence
136
+ * via {@link resolveAuthEnv} on the full process env (it reads
137
+ * `SWENY_AUTH`). When env scoping is on (default in CI, see
138
+ * {@link resolveEnvScope}) the result is narrowed to the allowlist plus
139
+ * `extraVars` (the node's declared skill env vars) and the operator
140
+ * passthrough list, and the withheld names (never values) are warned once
141
+ * per client. When off, the full env passes through as before.
88
142
  */
89
143
  private buildEnv;
90
144
  run(opts: {
@@ -100,6 +154,10 @@ export declare class ClaudeClient implements Claude {
100
154
  timeoutMs?: number;
101
155
  /** Caller-supplied abort signal. Aborting it interrupts the query. */
102
156
  signal?: AbortSignal;
157
+ /** Dry run (#380): no external MCP servers, no write-capable built-ins. */
158
+ readOnly?: boolean;
159
+ /** Env var names + sandbox hosts this node's skills need (#360). */
160
+ agentAccess?: AgentAccess;
103
161
  }): Promise<NodeResult>;
104
162
  evaluate(opts: {
105
163
  question: string;
@@ -112,7 +170,7 @@ export declare class ClaudeClient implements Claude {
112
170
  timeoutMs?: number;
113
171
  /** Caller-supplied abort signal. Aborting it interrupts the query. */
114
172
  signal?: AbortSignal;
115
- }): Promise<string>;
173
+ }): Promise<string | null>;
116
174
  ask(opts: {
117
175
  instruction: string;
118
176
  context: Record<string, unknown>;
package/dist/claude.js CHANGED
@@ -11,7 +11,46 @@
11
11
  import { query, createSdkMcpServer, tool as sdkTool } from "@anthropic-ai/claude-agent-sdk";
12
12
  import { z } from "zod";
13
13
  import { consoleLogger } from "./types.js";
14
+ import { formatWithheldWarning, parseList, resolveEnvScope, scopeAgentEnv, resolveAgentSandbox, } from "./agent-env.js";
15
+ import { fenceUntrustedJson } from "./untrusted.js";
14
16
  const SYSTEM_PROMPT = `You are a step in an automated workflow. Execute the instruction precisely using the tools available to you. Be thorough but concise. When you're done, summarize your findings and results.`;
17
+ /**
18
+ * Built-in tools disallowed for the route-evaluation (`evaluate`) and
19
+ * reflection/judge (`ask`) calls.
20
+ *
21
+ * These are pure classification calls over prior-node data that can be
22
+ * attacker-influenceable (a prior node summarizes an untrusted issue body,
23
+ * PR diff, log line, etc.). They must never be able to mutate the workspace
24
+ * or shell out, regardless of any node's own `disallowed_tools` policy.
25
+ * `maxTurns: 1` already bounds them; this removes the powerful built-ins
26
+ * from the model's context entirely as structural defense-in-depth.
27
+ */
28
+ export const CLASSIFICATION_DISALLOWED_TOOLS = [
29
+ "Bash",
30
+ "Write",
31
+ "Edit",
32
+ "MultiEdit",
33
+ "NotebookEdit",
34
+ "WebFetch",
35
+ "WebSearch",
36
+ ];
37
+ /**
38
+ * Built-in tools disallowed for a node run with `readOnly: true` (dry run,
39
+ * #380): everything that can edit the workspace, shell out (and so reach
40
+ * `gh`, `curl`, `git push`), spawn a subagent that could, or fetch an
41
+ * arbitrary URL (a GET can still trigger a webhook or exfiltrate data).
42
+ * Read, Grep, Glob and WebSearch stay available for analysis.
43
+ */
44
+ export const READ_ONLY_DISALLOWED_TOOLS = [
45
+ "Bash",
46
+ "Write",
47
+ "Edit",
48
+ "MultiEdit",
49
+ "NotebookEdit",
50
+ "Task",
51
+ "Agent",
52
+ "WebFetch",
53
+ ];
15
54
  /**
16
55
  * Decide which auth credentials survive into the Claude Code subprocess env.
17
56
  *
@@ -141,6 +180,30 @@ async function interruptStream(stream) {
141
180
  /* already finished or not interruptible — nothing to clean up */
142
181
  }
143
182
  }
183
+ /**
184
+ * Extract shape-only usage/cost from an SDK `result` message.
185
+ *
186
+ * Reads `total_cost_usd`, `usage` (in/out/cache tokens), and `num_turns` off
187
+ * the terminal result. Returns undefined when the message carries none of
188
+ * them (mocks, older SDKs) so callers leave `NodeResult.usage` absent rather
189
+ * than shipping an all-zero object. NEVER touches `result`/prompt text.
190
+ */
191
+ function extractUsage(resultMsg) {
192
+ const m = resultMsg;
193
+ const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : undefined);
194
+ const u = m.usage;
195
+ const usage = {
196
+ costUsd: num(m.total_cost_usd),
197
+ inputTokens: num(u?.input_tokens),
198
+ outputTokens: num(u?.output_tokens),
199
+ cacheReadTokens: num(u?.cache_read_input_tokens),
200
+ cacheCreationTokens: num(u?.cache_creation_input_tokens),
201
+ numTurns: num(m.num_turns),
202
+ };
203
+ // Drop the object entirely when the SDK gave us nothing usable, so a
204
+ // mocked/legacy run reports no usage instead of a misleading zero-cost run.
205
+ return Object.values(usage).some((v) => v !== undefined) ? usage : undefined;
206
+ }
144
207
  export class ClaudeClient {
145
208
  model;
146
209
  maxTurns;
@@ -148,6 +211,13 @@ export class ClaudeClient {
148
211
  logger;
149
212
  defaultContext;
150
213
  mcpServers;
214
+ envPassthrough;
215
+ sandboxMode;
216
+ sandboxAllowedDomains;
217
+ sandboxProbe;
218
+ sandboxWarned = false;
219
+ envScope;
220
+ envWarned = false;
151
221
  constructor(opts = {}) {
152
222
  this.model = opts.model;
153
223
  this.maxTurns = opts.maxTurns ?? 20;
@@ -155,18 +225,68 @@ export class ClaudeClient {
155
225
  this.logger = opts.logger ?? consoleLogger;
156
226
  this.defaultContext = opts.defaultContext ?? { config: {}, logger: this.logger };
157
227
  this.mcpServers = opts.mcpServers ?? {};
228
+ this.envPassthrough = opts.envPassthrough;
229
+ this.envScope = opts.envScope;
230
+ this.sandboxMode = opts.sandbox;
231
+ this.sandboxAllowedDomains = opts.sandboxAllowedDomains;
232
+ this.sandboxProbe = opts.sandboxProbe;
158
233
  }
159
234
  /**
160
- * Build env for the Claude Code subprocess. Snapshots process.env (dropping
161
- * nullish values) and applies auth precedence via {@link resolveAuthEnv}.
235
+ * Build env for the Claude Code subprocess (#360). Applies auth precedence
236
+ * via {@link resolveAuthEnv} on the full process env (it reads
237
+ * `SWENY_AUTH`). When env scoping is on (default in CI, see
238
+ * {@link resolveEnvScope}) the result is narrowed to the allowlist plus
239
+ * `extraVars` (the node's declared skill env vars) and the operator
240
+ * passthrough list, and the withheld names (never values) are warned once
241
+ * per client. When off, the full env passes through as before.
162
242
  */
163
- buildEnv() {
164
- const env = Object.fromEntries(Object.entries(process.env).filter((e) => e[1] != null));
165
- return resolveAuthEnv(env, { logger: this.logger });
243
+ buildEnv(extraVars = []) {
244
+ const full = Object.fromEntries(Object.entries(process.env).filter((e) => e[1] != null));
245
+ const authed = resolveAuthEnv(full, { logger: this.logger });
246
+ if (!resolveEnvScope(process.env, this.envScope, this.logger))
247
+ return authed;
248
+ const { env, withheld } = scopeAgentEnv(authed, {
249
+ extraVars,
250
+ passthrough: this.envPassthrough ?? parseList(process.env.SWENY_ENV_PASSTHROUGH),
251
+ logger: this.logger,
252
+ });
253
+ if (withheld.length > 0 && !this.envWarned) {
254
+ this.envWarned = true;
255
+ const prefix = process.env.GITHUB_ACTIONS === "true" ? "::warning title=SWEny agent env::" : "";
256
+ this.logger.warn(`${prefix}${formatWithheldWarning(withheld)}`);
257
+ }
258
+ return env;
166
259
  }
167
260
  async run(opts) {
168
- const { instruction, context, tools, outputSchema, onProgress, maxTurns, disallowedTools, model, timeoutMs, signal, } = opts;
261
+ const { instruction, context, tools, outputSchema, onProgress, maxTurns, model, timeoutMs, signal, readOnly, agentAccess, } = opts;
262
+ // Dry run (#380): external MCP servers cannot be classified per tool, so
263
+ // they are unknown, and unknown means write. Drop them, and disallow the
264
+ // built-ins that can change the workspace or shell out.
265
+ const disallowedTools = readOnly
266
+ ? [...new Set([...(opts.disallowedTools ?? []), ...READ_ONLY_DISALLOWED_TOOLS])]
267
+ : opts.disallowedTools;
169
268
  const effectiveModel = model ?? this.model;
269
+ // #360: run agent commands in the SDK sandbox. `auto` (default in CI) falls
270
+ // back to unsandboxed with one loud warning when the host cannot sandbox;
271
+ // `strict` fails the node here instead.
272
+ const sandbox = resolveAgentSandbox({
273
+ env: process.env,
274
+ mode: this.sandboxMode,
275
+ allowedDomains: this.sandboxAllowedDomains,
276
+ nodeDomains: agentAccess?.domains,
277
+ probe: this.sandboxProbe,
278
+ logger: this.logger,
279
+ });
280
+ if (sandbox.error) {
281
+ this.logger.error(sandbox.error);
282
+ return { status: "failed", data: { error: sandbox.error }, toolCalls: [] };
283
+ }
284
+ if (sandbox.warning && !this.sandboxWarned) {
285
+ this.sandboxWarned = true;
286
+ // GitHub Actions renders `::warning::` as a run annotation.
287
+ const prefix = process.env.GITHUB_ACTIONS === "true" ? "::warning title=SWEny agent sandbox::" : "";
288
+ this.logger.warn(`${prefix}${sandbox.warning}`);
289
+ }
170
290
  // Tool-call accounting (Fix #1).
171
291
  //
172
292
  // Stream-driven recording, correlated by tool_use_id:
@@ -194,30 +314,37 @@ export class ClaudeClient {
194
314
  name: "sweny-core",
195
315
  tools: sdkTools,
196
316
  });
197
- // Build prompt
317
+ // Build prompt. Context (workflow input such as issues/alerts, plus prior
318
+ // node outputs) is fenced as untrusted data (#360).
198
319
  const prompt = [
199
320
  `## Instruction\n\n${instruction}`,
200
- `## Context\n\n\`\`\`json\n${JSON.stringify(context, null, 2)}\n\`\`\``,
321
+ `## Context\n\n${fenceUntrustedJson(context, "context")}`,
201
322
  outputSchema
202
323
  ? `## Required Output\n\nYou MUST end with a JSON object matching this schema:\n\`\`\`json\n${JSON.stringify(outputSchema, null, 2)}\n\`\`\``
203
324
  : "",
204
325
  ]
205
326
  .filter(Boolean)
206
327
  .join("\n\n");
207
- const env = this.buildEnv();
328
+ const env = this.buildEnv(agentAccess?.envVars);
208
329
  let response = "";
209
330
  // CC-08: when the SDK populates typed structured output (because we passed
210
331
  // an `outputFormat`), prefer it over re-parsing JSON out of the free-text
211
332
  // result. Left undefined when the SDK didn't provide it, so the
212
333
  // tryParseJSON heuristic remains the fallback (e.g. mocks, older results).
213
334
  let structuredOutput;
335
+ // Token + cost accounting. Read off the SDK's terminal `result` message
336
+ // (present on both success and error subtypes). Shape-only: counts and
337
+ // cost, never any prompt/response text. Left undefined when the SDK
338
+ // emitted no usage (mocks, older SDKs), so `usage` stays absent rather
339
+ // than shipping zeroes that read as a real (free) run.
340
+ let usage;
214
341
  // Timeout / abort wiring (back-compat: only armed when requested).
215
342
  // A single AbortController drives both an optional caller signal and an
216
343
  // optional timeout timer. The SDK aborts the subprocess when this fires.
217
344
  const abort = makeAbort(timeoutMs, signal);
218
345
  let stream;
219
346
  try {
220
- const allMcpServers = { ...this.mcpServers };
347
+ const allMcpServers = readOnly ? {} : { ...this.mcpServers };
221
348
  if (sdkTools.length > 0)
222
349
  allMcpServers["sweny-core"] = mcpServer;
223
350
  stream = query({
@@ -229,10 +356,16 @@ export class ClaudeClient {
229
356
  env,
230
357
  permissionMode: "bypassPermissions",
231
358
  allowDangerouslySkipPermissions: true,
359
+ ...(sandbox.settings ? { sandbox: sandbox.settings } : {}),
232
360
  stderr: (data) => this.logger.debug(`[claude-code] ${data}`),
233
361
  ...(abort ? { abortController: abort.controller } : {}),
234
362
  ...(effectiveModel ? { model: effectiveModel } : {}),
235
363
  ...(Object.keys(allMcpServers).length > 0 ? { mcpServers: allMcpServers } : {}),
364
+ // Dry run (#380): settingSources is omitted, so the SDK loads user,
365
+ // project and local settings, including their MCP servers (and
366
+ // project .mcp.json, plugins). strictMcpConfig limits MCP to the
367
+ // servers passed above, which under readOnly is only sweny-core.
368
+ ...(readOnly ? { strictMcpConfig: true } : {}),
236
369
  ...(disallowedTools && disallowedTools.length > 0 ? { disallowedTools } : {}),
237
370
  // CC-08: ask the SDK to produce validated structured output when the
238
371
  // node declares an output schema. The SDK then returns the parsed
@@ -314,6 +447,10 @@ export class ClaudeClient {
314
447
  }
315
448
  else if (message.type === "result") {
316
449
  const resultMsg = message;
450
+ // Capture token/cost accounting off the terminal result. Present on
451
+ // both success and error subtypes; shape-only. Attached to every
452
+ // return below (including early-termination and error paths).
453
+ usage = extractUsage(resultMsg);
317
454
  if (resultMsg.subtype === "success" && "result" in resultMsg) {
318
455
  // terminal_reason was added in @anthropic-ai/claude-agent-sdk v0.2.91.
319
456
  // When the turn budget is exhausted the SDK still emits subtype='success'
@@ -334,6 +471,7 @@ export class ClaudeClient {
334
471
  ...(partial !== "" ? { summary: partial } : {}),
335
472
  },
336
473
  toolCalls,
474
+ ...(usage ? { usage } : {}),
337
475
  };
338
476
  }
339
477
  response = resultMsg.result;
@@ -354,6 +492,7 @@ export class ClaudeClient {
354
492
  status: "failed",
355
493
  data: { error: prefix + (errors?.join("\n") ?? "Execution failed") },
356
494
  toolCalls,
495
+ ...(usage ? { usage } : {}),
357
496
  };
358
497
  }
359
498
  }
@@ -389,6 +528,7 @@ export class ClaudeClient {
389
528
  status: "success",
390
529
  data: { summary: response, ...parsedData },
391
530
  toolCalls,
531
+ ...(usage ? { usage } : {}),
392
532
  };
393
533
  }
394
534
  async evaluate(opts) {
@@ -408,6 +548,15 @@ export class ClaudeClient {
408
548
  env,
409
549
  permissionMode: "bypassPermissions",
410
550
  allowDangerouslySkipPermissions: true,
551
+ // Route evaluation is a pure classification call over
552
+ // possibly-attacker-influenceable prior-node data. Never let it
553
+ // shell out or mutate, regardless of the node's own policy.
554
+ // Disable ALL built-in tools (SDK `tools: []`); no MCP servers are passed.
555
+ // The disallow list below stays as a second layer.
556
+ tools: [],
557
+ mcpServers: {},
558
+ strictMcpConfig: true,
559
+ disallowedTools: [...CLASSIFICATION_DISALLOWED_TOOLS],
411
560
  stderr: (data) => this.logger.debug(`[claude-code] ${data}`),
412
561
  ...(abort ? { abortController: abort.controller } : {}),
413
562
  ...(this.model ? { model: this.model } : {}),
@@ -420,34 +569,33 @@ export class ClaudeClient {
420
569
  response = resultMsg.result;
421
570
  }
422
571
  else {
423
- // Distinguish an SDK-level failure from a genuinely ambiguous
424
- // model answer. Without this, both fall through to validIds[0]
425
- // and log the same "Could not parse route choice" warning, so an
426
- // operator cannot tell them apart. Short-circuit below so the
427
- // ambiguous-answer warning never fires on top of this one.
572
+ // Fail closed. An SDK-level failure (non-success subtype) is NOT a
573
+ // routing decision. Signal it to the caller (null) so the executor
574
+ // takes an explicit default edge or terminates — never the old
575
+ // fall-through to the first choice, which silently routed an outage
576
+ // down the first conditional edge (e.g. filing a real issue/PR).
428
577
  sdkFailed = true;
429
- this.logger.warn(`claude.evaluate: SDK returned non-success subtype "${resultMsg.subtype}" — falling back to first choice.`);
578
+ this.logger.warn(`claude.evaluate: SDK returned non-success subtype "${resultMsg.subtype}" — failing closed (no route decision).`);
430
579
  }
431
580
  }
432
581
  }
433
582
  }
434
583
  catch (err) {
435
584
  if (abort?.reason() === "timeout") {
436
- this.logger.warn(`Evaluate query timed out after ${timeoutMs}ms. Falling back to first choice.`);
585
+ this.logger.warn(`Evaluate query timed out after ${timeoutMs}ms. Failing closed (no route decision).`);
437
586
  }
438
587
  else {
439
- this.logger.warn(`Evaluate query failed: ${err.message}. Falling back to first choice.`);
588
+ this.logger.warn(`Evaluate query failed: ${err.message}. Failing closed (no route decision).`);
440
589
  }
441
- return choices[0].id;
590
+ return null;
442
591
  }
443
592
  finally {
444
593
  abort?.clear();
445
594
  await interruptStream(stream);
446
595
  }
447
- // An SDK-level failure already logged a distinct message; do not also emit
448
- // the ambiguous-answer warning, which would misattribute the cause.
596
+ // An SDK-level failure already logged a distinct message; fail closed.
449
597
  if (sdkFailed)
450
- return choices[0].id;
598
+ return null;
451
599
  const text = response.trim().replace(/^["']|["']$/g, "");
452
600
  const validIds = choices.map((c) => c.id);
453
601
  // Exact match
@@ -457,15 +605,18 @@ export class ClaudeClient {
457
605
  const match = validIds.find((id) => text.includes(id));
458
606
  if (match)
459
607
  return match;
460
- // Fallback
461
- this.logger.warn(`Could not parse route choice from: "${text.slice(0, 100)}". Falling back to first choice.`);
462
- return validIds[0];
608
+ // Fail closed. An unparseable answer is not a decision. Returning
609
+ // validIds[0] here is the fail-open bug: on a node with a single
610
+ // conditional out-edge, the "first choice" is always that edge, so a
611
+ // garbled model answer would always take it. Signal no-decision instead.
612
+ this.logger.warn(`Could not parse route choice from: "${text.slice(0, 100)}". Failing closed (no route decision).`);
613
+ return null;
463
614
  }
464
615
  async ask(opts) {
465
616
  const { instruction, context, model, timeoutMs, signal } = opts;
466
617
  const prompt = [
467
618
  instruction,
468
- Object.keys(context).length > 0 ? `\nContext:\n\`\`\`json\n${JSON.stringify(context, null, 2)}\n\`\`\`` : "",
619
+ Object.keys(context).length > 0 ? `\nContext:\n${fenceUntrustedJson(context, "context")}` : "",
469
620
  ]
470
621
  .filter(Boolean)
471
622
  .join("\n");
@@ -483,6 +634,15 @@ export class ClaudeClient {
483
634
  env,
484
635
  permissionMode: "bypassPermissions",
485
636
  allowDangerouslySkipPermissions: true,
637
+ // Reflection and judge scoring are pure classification calls over
638
+ // possibly-attacker-influenceable prior-node data. Deny the powerful
639
+ // built-ins so they can never shell out or mutate the workspace.
640
+ // Disable ALL built-in tools (SDK `tools: []`); no MCP servers are passed.
641
+ // The disallow list below stays as a second layer.
642
+ tools: [],
643
+ mcpServers: {},
644
+ strictMcpConfig: true,
645
+ disallowedTools: [...CLASSIFICATION_DISALLOWED_TOOLS],
486
646
  stderr: (data) => this.logger.debug(`[claude-code] ${data}`),
487
647
  ...(abort ? { abortController: abort.controller } : {}),
488
648
  ...(effectiveModel ? { model: effectiveModel } : {}),
@@ -582,7 +742,7 @@ export function buildEvaluatePrompt(question, context, choices) {
582
742
  const choiceList = choices.map((c) => `- "${c.id}": ${c.description}`).join("\n");
583
743
  return [
584
744
  question,
585
- `\nContext:\n\`\`\`json\n${JSON.stringify(context, null, 2)}\n\`\`\``,
745
+ `\nContext:\n${fenceUntrustedJson(context, "context")}`,
586
746
  `\nChoices:\n${choiceList}`,
587
747
  `\nEvaluation rules:`,
588
748
  `1. Read each choice's condition literally and match against the structured fields in the context (e.g. status, counts, enum values, boolean flags).`,
@@ -8,7 +8,17 @@ export interface CheckResult {
8
8
  * Run lightweight connectivity checks for all configured providers.
9
9
  * Uses raw fetch — does NOT import provider packages.
10
10
  */
11
- export declare function checkProviderConnectivity(config: CliConfig): Promise<CheckResult[]>;
11
+ export interface CheckOptions {
12
+ /**
13
+ * Skill ids the configured workflows use. When set, only providers those
14
+ * skills need are checked; everything else is omitted. When undefined
15
+ * (no workflows found), the legacy config-driven checks run.
16
+ */
17
+ scope?: ReadonlySet<string>;
18
+ /** A local Claude Code login exists, so no API key is required. */
19
+ claudeCodeLogin?: boolean;
20
+ }
21
+ export declare function checkProviderConnectivity(config: CliConfig, opts?: CheckOptions): Promise<CheckResult[]>;
12
22
  /** Which credential `sweny check` should validate, mirroring resolveAuthEnv intent. */
13
23
  export type CheckAuthMode = "oauth" | "api-key" | "auth-token" | "none";
14
24
  export declare function resolveCheckAuthMode(config: Pick<CliConfig, "anthropicApiKey" | "anthropicAuthToken" | "claudeOauthToken" | "swenyAuth">): CheckAuthMode;
@@ -16,3 +26,34 @@ export declare function resolveCheckAuthMode(config: Pick<CliConfig, "anthropicA
16
26
  export declare function redactUrl(raw: string): string;
17
27
  export declare function checkAnthropicGateway(base: string, config: Pick<CliConfig, "anthropicApiKey" | "anthropicAuthToken" | "claudeOauthToken">, mode: CheckAuthMode): Promise<CheckResult>;
18
28
  export declare function checkDatadog(creds: Record<string, string>): Promise<CheckResult>;
29
+ /**
30
+ * Detect a local Claude Code login the way `sweny workflow run` benefits from
31
+ * it: the spawned Claude Code agent finds its own login, so no env credential
32
+ * is needed. Checks `~/.claude/.credentials.json` (or CLAUDE_CONFIG_DIR) and,
33
+ * on macOS, the Keychain entry (attributes only, the secret is never read).
34
+ */
35
+ export declare function detectClaudeCodeLogin(opts?: {
36
+ env?: Record<string, string | undefined>;
37
+ home?: string;
38
+ platform?: NodeJS.Platform;
39
+ keychainHasLogin?: () => boolean;
40
+ }): boolean;
41
+ /**
42
+ * Skill ids used by the workflows in `.sweny/workflows/`. `found` is false when
43
+ * there are no workflow files, in which case callers keep the legacy checks.
44
+ */
45
+ export declare function discoverWorkflowSkillIds(cwd: string): {
46
+ found: boolean;
47
+ skillIds: Set<string>;
48
+ };
49
+ /**
50
+ * Errors `sweny check` should report. With a workflow scope, only credentials
51
+ * those workflows need (plus agent auth) are required. A local Claude Code
52
+ * login satisfies agent auth.
53
+ */
54
+ export declare function validateCheckInputs(config: CliConfig, opts?: {
55
+ scope?: ReadonlySet<string>;
56
+ claudeCodeLogin?: boolean;
57
+ env?: Record<string, string | undefined>;
58
+ cwd?: string;
59
+ }): string[];