@sweny-ai/core 0.1.122 → 0.1.124

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 +191 -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 +2 -2
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
  *
@@ -127,6 +166,10 @@ export function makeAbort(timeoutMs, signal) {
127
166
  * Swallows errors: the stream may already be done, and interrupt is only
128
167
  * supported in streaming-input mode.
129
168
  */
169
+ // The return type is deliberately `Promise<unknown>`, not `Promise<void>`:
170
+ // agent-sdk 0.3 changed `Query.interrupt()` to resolve with an
171
+ // SDKControlInterruptResponse. We discard the value either way, and a `void`
172
+ // parameter type would reject any SDK that resolves with something.
130
173
  async function interruptStream(stream) {
131
174
  if (!stream || typeof stream.interrupt !== "function")
132
175
  return;
@@ -137,6 +180,30 @@ async function interruptStream(stream) {
137
180
  /* already finished or not interruptible — nothing to clean up */
138
181
  }
139
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
+ }
140
207
  export class ClaudeClient {
141
208
  model;
142
209
  maxTurns;
@@ -144,6 +211,13 @@ export class ClaudeClient {
144
211
  logger;
145
212
  defaultContext;
146
213
  mcpServers;
214
+ envPassthrough;
215
+ sandboxMode;
216
+ sandboxAllowedDomains;
217
+ sandboxProbe;
218
+ sandboxWarned = false;
219
+ envScope;
220
+ envWarned = false;
147
221
  constructor(opts = {}) {
148
222
  this.model = opts.model;
149
223
  this.maxTurns = opts.maxTurns ?? 20;
@@ -151,18 +225,68 @@ export class ClaudeClient {
151
225
  this.logger = opts.logger ?? consoleLogger;
152
226
  this.defaultContext = opts.defaultContext ?? { config: {}, logger: this.logger };
153
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;
154
233
  }
155
234
  /**
156
- * Build env for the Claude Code subprocess. Snapshots process.env (dropping
157
- * 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.
158
242
  */
159
- buildEnv() {
160
- const env = Object.fromEntries(Object.entries(process.env).filter((e) => e[1] != null));
161
- 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;
162
259
  }
163
260
  async run(opts) {
164
- 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;
165
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
+ }
166
290
  // Tool-call accounting (Fix #1).
167
291
  //
168
292
  // Stream-driven recording, correlated by tool_use_id:
@@ -190,30 +314,37 @@ export class ClaudeClient {
190
314
  name: "sweny-core",
191
315
  tools: sdkTools,
192
316
  });
193
- // Build prompt
317
+ // Build prompt. Context (workflow input such as issues/alerts, plus prior
318
+ // node outputs) is fenced as untrusted data (#360).
194
319
  const prompt = [
195
320
  `## Instruction\n\n${instruction}`,
196
- `## Context\n\n\`\`\`json\n${JSON.stringify(context, null, 2)}\n\`\`\``,
321
+ `## Context\n\n${fenceUntrustedJson(context, "context")}`,
197
322
  outputSchema
198
323
  ? `## Required Output\n\nYou MUST end with a JSON object matching this schema:\n\`\`\`json\n${JSON.stringify(outputSchema, null, 2)}\n\`\`\``
199
324
  : "",
200
325
  ]
201
326
  .filter(Boolean)
202
327
  .join("\n\n");
203
- const env = this.buildEnv();
328
+ const env = this.buildEnv(agentAccess?.envVars);
204
329
  let response = "";
205
330
  // CC-08: when the SDK populates typed structured output (because we passed
206
331
  // an `outputFormat`), prefer it over re-parsing JSON out of the free-text
207
332
  // result. Left undefined when the SDK didn't provide it, so the
208
333
  // tryParseJSON heuristic remains the fallback (e.g. mocks, older results).
209
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;
210
341
  // Timeout / abort wiring (back-compat: only armed when requested).
211
342
  // A single AbortController drives both an optional caller signal and an
212
343
  // optional timeout timer. The SDK aborts the subprocess when this fires.
213
344
  const abort = makeAbort(timeoutMs, signal);
214
345
  let stream;
215
346
  try {
216
- const allMcpServers = { ...this.mcpServers };
347
+ const allMcpServers = readOnly ? {} : { ...this.mcpServers };
217
348
  if (sdkTools.length > 0)
218
349
  allMcpServers["sweny-core"] = mcpServer;
219
350
  stream = query({
@@ -225,10 +356,16 @@ export class ClaudeClient {
225
356
  env,
226
357
  permissionMode: "bypassPermissions",
227
358
  allowDangerouslySkipPermissions: true,
359
+ ...(sandbox.settings ? { sandbox: sandbox.settings } : {}),
228
360
  stderr: (data) => this.logger.debug(`[claude-code] ${data}`),
229
361
  ...(abort ? { abortController: abort.controller } : {}),
230
362
  ...(effectiveModel ? { model: effectiveModel } : {}),
231
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 } : {}),
232
369
  ...(disallowedTools && disallowedTools.length > 0 ? { disallowedTools } : {}),
233
370
  // CC-08: ask the SDK to produce validated structured output when the
234
371
  // node declares an output schema. The SDK then returns the parsed
@@ -310,6 +447,10 @@ export class ClaudeClient {
310
447
  }
311
448
  else if (message.type === "result") {
312
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);
313
454
  if (resultMsg.subtype === "success" && "result" in resultMsg) {
314
455
  // terminal_reason was added in @anthropic-ai/claude-agent-sdk v0.2.91.
315
456
  // When the turn budget is exhausted the SDK still emits subtype='success'
@@ -330,6 +471,7 @@ export class ClaudeClient {
330
471
  ...(partial !== "" ? { summary: partial } : {}),
331
472
  },
332
473
  toolCalls,
474
+ ...(usage ? { usage } : {}),
333
475
  };
334
476
  }
335
477
  response = resultMsg.result;
@@ -350,6 +492,7 @@ export class ClaudeClient {
350
492
  status: "failed",
351
493
  data: { error: prefix + (errors?.join("\n") ?? "Execution failed") },
352
494
  toolCalls,
495
+ ...(usage ? { usage } : {}),
353
496
  };
354
497
  }
355
498
  }
@@ -385,6 +528,7 @@ export class ClaudeClient {
385
528
  status: "success",
386
529
  data: { summary: response, ...parsedData },
387
530
  toolCalls,
531
+ ...(usage ? { usage } : {}),
388
532
  };
389
533
  }
390
534
  async evaluate(opts) {
@@ -404,6 +548,15 @@ export class ClaudeClient {
404
548
  env,
405
549
  permissionMode: "bypassPermissions",
406
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],
407
560
  stderr: (data) => this.logger.debug(`[claude-code] ${data}`),
408
561
  ...(abort ? { abortController: abort.controller } : {}),
409
562
  ...(this.model ? { model: this.model } : {}),
@@ -416,34 +569,33 @@ export class ClaudeClient {
416
569
  response = resultMsg.result;
417
570
  }
418
571
  else {
419
- // Distinguish an SDK-level failure from a genuinely ambiguous
420
- // model answer. Without this, both fall through to validIds[0]
421
- // and log the same "Could not parse route choice" warning, so an
422
- // operator cannot tell them apart. Short-circuit below so the
423
- // 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).
424
577
  sdkFailed = true;
425
- 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).`);
426
579
  }
427
580
  }
428
581
  }
429
582
  }
430
583
  catch (err) {
431
584
  if (abort?.reason() === "timeout") {
432
- 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).`);
433
586
  }
434
587
  else {
435
- 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).`);
436
589
  }
437
- return choices[0].id;
590
+ return null;
438
591
  }
439
592
  finally {
440
593
  abort?.clear();
441
594
  await interruptStream(stream);
442
595
  }
443
- // An SDK-level failure already logged a distinct message; do not also emit
444
- // the ambiguous-answer warning, which would misattribute the cause.
596
+ // An SDK-level failure already logged a distinct message; fail closed.
445
597
  if (sdkFailed)
446
- return choices[0].id;
598
+ return null;
447
599
  const text = response.trim().replace(/^["']|["']$/g, "");
448
600
  const validIds = choices.map((c) => c.id);
449
601
  // Exact match
@@ -453,15 +605,18 @@ export class ClaudeClient {
453
605
  const match = validIds.find((id) => text.includes(id));
454
606
  if (match)
455
607
  return match;
456
- // Fallback
457
- this.logger.warn(`Could not parse route choice from: "${text.slice(0, 100)}". Falling back to first choice.`);
458
- 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;
459
614
  }
460
615
  async ask(opts) {
461
616
  const { instruction, context, model, timeoutMs, signal } = opts;
462
617
  const prompt = [
463
618
  instruction,
464
- 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")}` : "",
465
620
  ]
466
621
  .filter(Boolean)
467
622
  .join("\n");
@@ -479,6 +634,15 @@ export class ClaudeClient {
479
634
  env,
480
635
  permissionMode: "bypassPermissions",
481
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],
482
646
  stderr: (data) => this.logger.debug(`[claude-code] ${data}`),
483
647
  ...(abort ? { abortController: abort.controller } : {}),
484
648
  ...(effectiveModel ? { model: effectiveModel } : {}),
@@ -578,7 +742,7 @@ export function buildEvaluatePrompt(question, context, choices) {
578
742
  const choiceList = choices.map((c) => `- "${c.id}": ${c.description}`).join("\n");
579
743
  return [
580
744
  question,
581
- `\nContext:\n\`\`\`json\n${JSON.stringify(context, null, 2)}\n\`\`\``,
745
+ `\nContext:\n${fenceUntrustedJson(context, "context")}`,
582
746
  `\nChoices:\n${choiceList}`,
583
747
  `\nEvaluation rules:`,
584
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[];