@sweny-ai/core 0.1.176 → 0.1.177

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.
@@ -322,7 +322,16 @@ export interface AgentAccess {
322
322
  * hooks; only a sandbox stops a deliberate agent from undoing it.
323
323
  */
324
324
  noPush?: boolean;
325
+ /**
326
+ * The run is staged or a dry run: no skill or external MCP server reaches
327
+ * the node (an MCP server cannot be classified per tool, so it may write).
328
+ * Only sweny's own tool bridge, already filtered to read tools, remains,
329
+ * and Claude Code runs with `strictMcpConfig`. Same MCP rule as `readOnly`.
330
+ */
331
+ noMcp?: boolean;
325
332
  }
333
+ /** True when a run request may load no MCP server but sweny's own bridge (read-only or staged). */
334
+ export declare function mcpWithheld(readOnly: boolean, access: Pick<AgentAccess, "noMcp"> | undefined): boolean;
326
335
  /** Hosts reachable by sandboxed commands in every node: source hosting + package registries. */
327
336
  export declare const DEFAULT_SANDBOX_DOMAINS: readonly string[];
328
337
  /** Provider hosts added when a node references the built-in skill. */
package/dist/agent-env.js CHANGED
@@ -814,6 +814,10 @@ export function grantedAgentEnv(agentEnv, opts) {
814
814
  return { grant: [], dropped: `agent_env withheld in a staged or dry run: ${names.join(", ")}` };
815
815
  return { grant: names };
816
816
  }
817
+ /** True when a run request may load no MCP server but sweny's own bridge (read-only or staged). */
818
+ export function mcpWithheld(readOnly, access) {
819
+ return readOnly || access?.noMcp === true;
820
+ }
817
821
  /** Hosts reachable by sandboxed commands in every node: source hosting + package registries. */
818
822
  export const DEFAULT_SANDBOX_DOMAINS = [
819
823
  "github.com",
@@ -6,8 +6,7 @@
6
6
  * METADATA ONLY. Node names, statuses, counts, and durations. Never prompts,
7
7
  * tool inputs, node outputs, errors, or model prose.
8
8
  */
9
- import fs from "node:fs";
10
- import path from "node:path";
9
+ import { workspaceRoot, writeFileNoFollow } from "../safe-file.js";
11
10
  import { toMermaidBlock } from "../mermaid.js";
12
11
  import { formatReceiptDuration, nodeStates } from "./run-output.js";
13
12
  import { formatTicketText } from "./ticket.js";
@@ -77,10 +76,10 @@ export function formatCrashComment(workflow, summary, runId) {
77
76
  /** Write the comment markdown to `file`. Never throws; returns true only when written. */
78
77
  export function writeRunComment(file, workflow, results, summary, opts = {}) {
79
78
  try {
80
- fs.mkdirSync(path.dirname(file), { recursive: true });
81
- fs.writeFileSync(file, opts.crashed
79
+ // The file may sit in the agent-writable workspace: no link on the path is followed.
80
+ writeFileNoFollow(file, opts.crashed
82
81
  ? formatCrashComment(workflow, summary, opts.runId)
83
- : formatRunComment(workflow, results, summary, opts));
82
+ : formatRunComment(workflow, results, summary, opts), { ...workspaceRoot(file), mode: 0o644, dirMode: 0o755 });
84
83
  return true;
85
84
  }
86
85
  catch (err) {
@@ -1,6 +1,20 @@
1
+ /**
2
+ * Keys a workspace `.env` may never set. The workspace is agent-writable, and
3
+ * these name runtime or platform state sweny trusts as the operator's: CI and
4
+ * runner identity and the files the runner hands sweny to write
5
+ * (GITHUB_STEP_SUMMARY, GITHUB_OUTPUT, ...), where binaries are found (PATH),
6
+ * how Node and git behave (NODE_*, GIT_*), where traffic goes and which CAs it
7
+ * trusts (proxies, SSL/CA vars), and the home and temp dirs. A `.env` value
8
+ * for one of them is skipped with a warning. GITHUB_TOKEN is a credential the
9
+ * `.env` template documents, not platform state, so it stays allowed.
10
+ */
11
+ export declare const DOTENV_DENIED_KEYS: RegExp;
12
+ /** True when a workspace `.env` may not set `key` (see {@link DOTENV_DENIED_KEYS}). */
13
+ export declare function isDotenvDenied(key: string): boolean;
1
14
  /**
2
15
  * Auto-load a `.env` file from the given directory.
3
- * Sets `process.env[KEY]` only if not already defined (real env vars win).
16
+ * Sets `process.env[KEY]` only if not already defined (real env vars win),
17
+ * and never a runtime or platform key ({@link DOTENV_DENIED_KEYS}).
4
18
  */
5
19
  export declare function loadDotenv(cwd?: string): void;
6
20
  /**
@@ -1,9 +1,27 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { parse as parseYaml } from "yaml";
4
+ // Imported for its side effect too: the startup env snapshot is taken before loadDotenv runs.
5
+ import { markWorkspaceEnv } from "../startup-env.js";
6
+ /**
7
+ * Keys a workspace `.env` may never set. The workspace is agent-writable, and
8
+ * these name runtime or platform state sweny trusts as the operator's: CI and
9
+ * runner identity and the files the runner hands sweny to write
10
+ * (GITHUB_STEP_SUMMARY, GITHUB_OUTPUT, ...), where binaries are found (PATH),
11
+ * how Node and git behave (NODE_*, GIT_*), where traffic goes and which CAs it
12
+ * trusts (proxies, SSL/CA vars), and the home and temp dirs. A `.env` value
13
+ * for one of them is skipped with a warning. GITHUB_TOKEN is a credential the
14
+ * `.env` template documents, not platform state, so it stays allowed.
15
+ */
16
+ export const DOTENV_DENIED_KEYS = /^(GITHUB_(?!TOKEN$)[A-Za-z0-9_]*|RUNNER_[A-Za-z0-9_]*|ACTIONS_[A-Za-z0-9_]*|CI|PATH|Path|NODE_[A-Za-z0-9_]*|GIT_[A-Za-z0-9_]*|(HTTPS?|ALL|NO|FTP)_PROXY|(https?|all|no|ftp)_proxy|SSL_CERT_FILE|SSL_CERT_DIR|CURL_CA_BUNDLE|REQUESTS_CA_BUNDLE|TMPDIR|TEMP|TMP|HOME|USERPROFILE)$/;
17
+ /** True when a workspace `.env` may not set `key` (see {@link DOTENV_DENIED_KEYS}). */
18
+ export function isDotenvDenied(key) {
19
+ return DOTENV_DENIED_KEYS.test(key);
20
+ }
4
21
  /**
5
22
  * Auto-load a `.env` file from the given directory.
6
- * Sets `process.env[KEY]` only if not already defined (real env vars win).
23
+ * Sets `process.env[KEY]` only if not already defined (real env vars win),
24
+ * and never a runtime or platform key ({@link DOTENV_DENIED_KEYS}).
7
25
  */
8
26
  export function loadDotenv(cwd = process.cwd()) {
9
27
  const envPath = path.join(cwd, ".env");
@@ -27,9 +45,14 @@ export function loadDotenv(cwd = process.cwd()) {
27
45
  if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
28
46
  value = value.slice(1, -1);
29
47
  }
30
- if (process.env[key] === undefined) {
31
- process.env[key] = value;
48
+ if (process.env[key] !== undefined)
49
+ continue;
50
+ if (isDotenvDenied(key)) {
51
+ process.stderr.write(` warning: .env sets ${key}, a runtime/platform variable; ignored\n`);
52
+ continue;
32
53
  }
54
+ process.env[key] = value;
55
+ markWorkspaceEnv(key);
33
56
  }
34
57
  }
35
58
  /**
@@ -57,5 +57,8 @@ export declare function formatFinalOutput(out: FinalOutput, opts?: {
57
57
  export declare function formatFinalMarkdown(workflow: Workflow, out: FinalOutput): string;
58
58
  /** Relative path shown to the user: `.sweny/runs/<id>/output.md`. */
59
59
  export declare function outputRelPath(runId: string): string;
60
- /** Write output.md (mode 0600). Local only. Returns the relative path, or null on failure. Never throws. */
60
+ /**
61
+ * Write output.md (mode 0600). Local only. Returns the relative path, or null on failure. Never throws.
62
+ * The run dir is agent-writable: no link on the path is followed (see safe-file.ts).
63
+ */
61
64
  export declare function writeFinalOutput(runId: string, markdown: string, cwd?: string): string | null;
@@ -12,7 +12,7 @@
12
12
  * (`--show-output`), since CI logs are often visible to more people than the
13
13
  * repo's secrets are.
14
14
  */
15
- import fs from "node:fs";
15
+ import { writeFileNoFollow } from "../safe-file.js";
16
16
  import path from "node:path";
17
17
  import { collectSecretValues, redact } from "../journal.js";
18
18
  import { RUN_HISTORY_DIR } from "./run-history.js";
@@ -186,16 +186,14 @@ export function formatFinalMarkdown(workflow, out) {
186
186
  export function outputRelPath(runId) {
187
187
  return path.posix.join(...RUN_HISTORY_DIR.split(path.sep), runId, "output.md");
188
188
  }
189
- /** Write output.md (mode 0600). Local only. Returns the relative path, or null on failure. Never throws. */
189
+ /**
190
+ * Write output.md (mode 0600). Local only. Returns the relative path, or null on failure. Never throws.
191
+ * The run dir is agent-writable: no link on the path is followed (see safe-file.ts).
192
+ */
190
193
  export function writeFinalOutput(runId, markdown, cwd = process.cwd()) {
191
194
  try {
192
195
  const rel = outputRelPath(runId);
193
- const file = path.join(cwd, rel);
194
- fs.mkdirSync(path.dirname(path.dirname(file)), { recursive: true });
195
- fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
196
- fs.writeFileSync(file, markdown, { mode: 0o600 });
197
- // mode only applies on create: a resumed run overwrites an existing file.
198
- fs.chmodSync(file, 0o600);
196
+ writeFileNoFollow(path.join(cwd, rel), markdown, { root: cwd, mode: 0o600 });
199
197
  return rel;
200
198
  }
201
199
  catch {
package/dist/cli/main.js CHANGED
@@ -2,6 +2,7 @@
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
4
  import { createRequire } from "node:module";
5
+ import { workspaceRoot, writeFileNoFollow } from "../safe-file.js";
5
6
  import { Command } from "commander";
6
7
  const _require = createRequire(import.meta.url);
7
8
  const { version } = _require("../../package.json");
@@ -57,6 +58,15 @@ import { beginCloudLifecycle, finishCloudLifecycle, createCloudStreamObserver }
57
58
  import { runUpgrade, fetchLatestFromNpm } from "./upgrade.js";
58
59
  import { maybeNudge, defaultCachePath } from "./version-check.js";
59
60
  import { spawnSync } from "node:child_process";
61
+ import { trustedEnvValue } from "../startup-env.js";
62
+ /** GITHUB_STEP_SUMMARY as the operator set it: never a value a workspace `.env` introduced. */
63
+ function stepSummaryPath() {
64
+ return trustedEnvValue(process.env, "GITHUB_STEP_SUMMARY") || undefined;
65
+ }
66
+ /** Append to GITHUB_STEP_SUMMARY without following a link (the path may sit in the workspace). */
67
+ function appendStepSummary(file, md) {
68
+ writeFileNoFollow(file, md, { ...workspaceRoot(file), append: true, mkdirs: false, mode: 0o644 });
69
+ }
60
70
  /** Secret values a run can see, for redacting everything that prints node output. */
61
71
  function outputSecrets(skills) {
62
72
  return runSecretValues([...(skills?.values() ?? []), ...builtinSkills], process.env);
@@ -467,13 +477,13 @@ triageCmd.action(async (options) => {
467
477
  console.log(formatDagResultHuman(results, durationMs, config));
468
478
  }
469
479
  // GitHub Actions step summary
470
- if (config.notificationProvider === "github-summary" && process.env.GITHUB_STEP_SUMMARY) {
480
+ if (config.notificationProvider === "github-summary" && stepSummaryPath()) {
471
481
  try {
472
482
  const md = formatDagResultMarkdown(results, durationMs, config, {
473
483
  workflow: triageWorkflow,
474
484
  trace,
475
485
  });
476
- fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, md);
486
+ appendStepSummary(stepSummaryPath(), md);
477
487
  }
478
488
  catch (err) {
479
489
  // Don't fail the run if the summary file can't be written
@@ -531,9 +541,9 @@ triageCmd.action(async (options) => {
531
541
  // silent — cloud reporting must never block or mask the crash
532
542
  }
533
543
  // Best-effort GitHub Actions step summary on crash
534
- if (config.notificationProvider === "github-summary" && process.env.GITHUB_STEP_SUMMARY) {
544
+ if (config.notificationProvider === "github-summary" && stepSummaryPath()) {
535
545
  try {
536
- fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `## ❌ SWEny Triage Crashed\n\n\`\`\`\n${crashMsg}\n\`\`\`\n`);
546
+ appendStepSummary(stepSummaryPath(), `## ❌ SWEny Triage Crashed\n\n\`\`\`\n${crashMsg}\n\`\`\`\n`);
537
547
  }
538
548
  catch {
539
549
  // ignore
@@ -14,6 +14,7 @@ import crypto from "node:crypto";
14
14
  import fs from "node:fs";
15
15
  import path from "node:path";
16
16
  import { summarizeRun } from "./run-output.js";
17
+ import { openNoFollow } from "../safe-file.js";
17
18
  export const RUN_HISTORY_SCHEMA_VERSION = 1;
18
19
  export const RUN_HISTORY_DIR = path.join(".sweny", "runs");
19
20
  export const RUN_HISTORY_KEEP = 200;
@@ -131,8 +132,9 @@ export function writeRunRecord(record, cwd = process.cwd()) {
131
132
  const final = path.join(dir, `${record.run_id}.json`);
132
133
  const tmp = path.join(dir, `.${record.run_id}.json.${process.pid}.tmp`);
133
134
  try {
134
- fs.mkdirSync(dir, { recursive: true });
135
- const fd = fs.openSync(tmp, "w");
135
+ // The workspace is agent-writable: no link on the path is followed (safe-file.ts).
136
+ // rename() replaces a link at `final` itself, never its target.
137
+ const fd = openNoFollow(tmp, { root: cwd, mode: 0o644 });
136
138
  try {
137
139
  fs.writeSync(fd, JSON.stringify(record, null, 2) + "\n");
138
140
  fs.fsyncSync(fd);
@@ -5,12 +5,13 @@
5
5
  * METADATA ONLY. Nothing here reads prompts, node outputs, tool inputs, or
6
6
  * model prose: only counts, durations, token totals, and cost.
7
7
  */
8
- import fs from "node:fs";
8
+ import { workspaceRoot, writeFileNoFollow } from "../safe-file.js";
9
9
  import { consoleLogger } from "../types.js";
10
10
  import { summarizeDecisions } from "../decider.js";
11
11
  import { toMermaidBlock } from "../mermaid.js";
12
12
  import { createPaint } from "./style.js";
13
13
  import { colorEnabled, glyphs } from "./terminal.js";
14
+ import { trustedEnvValue } from "../startup-env.js";
14
15
  /** The short key of a `degraded` entry: the text before its first colon. */
15
16
  export function degradedKey(entry) {
16
17
  const i = entry.indexOf(":");
@@ -219,11 +220,18 @@ export function formatStepSummary(workflow, results, summary, trace) {
219
220
  * Never throws; returns true only when something was written.
220
221
  */
221
222
  export function writeStepSummary(workflow, results, summary, trace, env = process.env) {
222
- const file = env.GITHUB_STEP_SUMMARY;
223
+ // The operator's value only: never one a workspace `.env` introduced.
224
+ const file = trustedEnvValue(env, "GITHUB_STEP_SUMMARY");
223
225
  if (!file)
224
226
  return false;
225
227
  try {
226
- fs.appendFileSync(file, formatStepSummary(workflow, results, summary, trace));
228
+ // Never through a link, in case the path sits in the agent-writable workspace.
229
+ writeFileNoFollow(file, formatStepSummary(workflow, results, summary, trace), {
230
+ ...workspaceRoot(file),
231
+ append: true,
232
+ mkdirs: false,
233
+ mode: 0o644,
234
+ });
227
235
  return true;
228
236
  }
229
237
  catch (err) {
package/dist/cli/try.js CHANGED
@@ -12,7 +12,7 @@
12
12
  * only). Until a real run is recorded it is labelled illustrative.
13
13
  */
14
14
  import fs from "node:fs";
15
- import path from "node:path";
15
+ import { workspaceRoot, writeFileNoFollow } from "../safe-file.js";
16
16
  import { fileURLToPath } from "node:url";
17
17
  import { parse as parseYaml } from "yaml";
18
18
  import { execute } from "../executor.js";
@@ -270,8 +270,7 @@ export async function runTry(opts = {}) {
270
270
  });
271
271
  if (opts.commentFile) {
272
272
  try {
273
- fs.mkdirSync(path.dirname(path.resolve(opts.commentFile)), { recursive: true });
274
- fs.writeFileSync(opts.commentFile, comment);
273
+ writeFileNoFollow(opts.commentFile, comment, { ...workspaceRoot(opts.commentFile), mode: 0o644, dirMode: 0o755 });
275
274
  }
276
275
  catch (err) {
277
276
  process.stderr.write(` ${g.warning} could not write comment file: ${err instanceof Error ? err.message : err}\n`);
@@ -13,7 +13,6 @@
13
13
  * This replaces ~8k lines of engine + recipe step code.
14
14
  */
15
15
  import type { Workflow, Skill, Tool, Claude, Observer, NodeResult, Logger, ExecutionResult } from "./types.js";
16
- import "./skills/git-push.js";
17
16
  import type { HarnessPolicyMode } from "./harness/policy.js";
18
17
  import type { AgentHarness } from "./harness/types.js";
19
18
  import type { DeciderMode } from "./decider.js";
@@ -150,3 +149,9 @@ export declare function buildBoundedContext(workflow: Workflow, nodeId: string,
150
149
  * with no `access` is treated as a write so new tools fail safe.
151
150
  */
152
151
  export declare function isReadTool(tool: Pick<Tool, "access">): boolean;
152
+ /**
153
+ * The tool dispatcher's staged-run gate: in a staged or dry run no write (or
154
+ * unclassified) tool handler runs, whatever reached the agent. Throws before
155
+ * any side effect.
156
+ */
157
+ export declare function guardStagedWrite(tool: Pick<Tool, "name" | "access">, staged: boolean): void;
package/dist/executor.js CHANGED
@@ -23,8 +23,9 @@ import { buildToolAliases } from "./skills/index.js";
23
23
  import { validateWorkflow } from "./schema.js";
24
24
  import { grantedAgentEnv, resolveAgentAccess } from "./agent-env.js";
25
25
  import { gitCredentialWarning, scanGitCredentials } from "./git-credentials.js";
26
- // #473: arms the sweny-side branch push in `github_create_pr` (Node only; skills/ stays browser-safe).
27
- import "./skills/git-push.js";
26
+ // #473: the sweny-side branch push in `github_create_pr` (Node only; skills/ stays browser-safe).
27
+ import { bindBranchPusher } from "./skills/git-push.js";
28
+ import { trustedEnvValue } from "./startup-env.js";
28
29
  import { fenceUntrusted } from "./untrusted.js";
29
30
  import { asClaude } from "./harness/compat.js";
30
31
  import { budgetGate, isToolClass, policyGate, resolveHarnessPolicy } from "./harness/policy.js";
@@ -96,7 +97,8 @@ export async function execute(workflow, input, options) {
96
97
  // default). Each harness masks it for read-only and staged nodes, or reports
97
98
  // the node degraded; this says so once per run. Paths and keys, never values.
98
99
  try {
99
- const credentialWarning = gitCredentialWarning(scanGitCredentials(process.cwd(), { env: runEnv }));
100
+ // The run's checkout (ExecuteOptions.cwd), not the embedding process's cwd.
101
+ const credentialWarning = gitCredentialWarning(scanGitCredentials(options.cwd ?? process.cwd(), { env: runEnv }));
100
102
  if (credentialWarning)
101
103
  logger.warn(credentialWarning);
102
104
  }
@@ -141,6 +143,11 @@ export async function execute(workflow, input, options) {
141
143
  nodeContext.forEach((s, i) => (sourceMap[`nodes.${nodeId}.context.${i}`] = s));
142
144
  }
143
145
  const resolveCwd = options.cwd ?? process.cwd();
146
+ // #473: the PR head push runs in this run's checkout, never process.cwd() of
147
+ // an embedding process. Tools get it through ToolContext.
148
+ const pushBranch = bindBranchPusher(resolveCwd, runEnv);
149
+ // GHES: the REST base for the github skill, never a value the workspace .env introduced.
150
+ const githubApiUrl = trustedEnvValue(runEnv, "GITHUB_API_URL") || undefined;
144
151
  const resolvedSources = await resolveSources(sourceMap, {
145
152
  cwd: resolveCwd,
146
153
  env: options.env ?? process.env,
@@ -239,10 +246,16 @@ export async function execute(workflow, input, options) {
239
246
  const permissions = resolveNodePermissions(node, workflow);
240
247
  const declaresPolicy = node.permissions !== undefined || workflow.permissions !== undefined || (node.outputs?.length ?? 0) > 0;
241
248
  const readOnlyNode = dryRun || permissions.access === "read";
242
- const readTools = readOnlyNode ? filteredTools.filter(isReadTool) : filteredTools;
243
- const skippedWrites = dryRun ? filteredTools.filter((t) => !isReadTool(t)).map((t) => t.name) : [];
249
+ // A staged run (--stage, safe_outputs.staged) writes nothing either: its
250
+ // write tools are withheld too, and the dispatcher below refuses any that
251
+ // still reach a handler.
252
+ const readTools = readOnlyNode || stageOutputs ? filteredTools.filter(isReadTool) : filteredTools;
253
+ // A staged write node records what it lost, like a dry run.
254
+ const stagedWriteNode = stageOutputs && !readOnlyNode;
255
+ const skippedWrites = dryRun || stagedWriteNode ? filteredTools.filter((t) => !isReadTool(t)).map((t) => t.name) : [];
244
256
  if (skippedWrites.length > 0) {
245
- logger.info(` dry run: withheld write tools: ${skippedWrites.join(", ")}`, { node: currentId });
257
+ const why = dryRun ? "dry run" : "staged";
258
+ logger.info(` ${why}: withheld write tools: ${skippedWrites.join(", ")}`, { node: currentId });
246
259
  }
247
260
  else if (readOnlyNode && readTools.length < filteredTools.length) {
248
261
  const withheld = filteredTools.filter((t) => !isReadTool(t)).map((t) => t.name);
@@ -332,7 +345,15 @@ export async function execute(workflow, input, options) {
332
345
  ...t,
333
346
  handler: async (toolInput) => {
334
347
  safeObserve(observer, { type: "tool:call", node: currentId, tool: t.name, input: toolInput }, logger);
335
- const toolCtx = { config, logger };
348
+ guardStagedWrite(t, stageOutputs);
349
+ const toolCtx = {
350
+ config,
351
+ logger,
352
+ cwd: resolveCwd,
353
+ staged: stageOutputs,
354
+ pushBranch,
355
+ ...(githubApiUrl ? { githubApiUrl } : {}),
356
+ };
336
357
  const output = await t.handler(toolInput, toolCtx);
337
358
  safeObserve(observer, { type: "tool:result", node: currentId, tool: t.name, output }, logger);
338
359
  return output;
@@ -345,7 +366,11 @@ export async function execute(workflow, input, options) {
345
366
  const nodeInstruction = outputDecls.length > 0
346
367
  ? `${baseInstruction}\n\n${safeOutputsInstruction(outputDecls, runInput)}`
347
368
  : baseInstruction;
348
- const instruction = dryRun ? `${dryRunNotice(skippedWrites)}\n\n---\n\n${nodeInstruction}` : nodeInstruction;
369
+ const instruction = dryRun
370
+ ? `${dryRunNotice(skippedWrites)}\n\n---\n\n${nodeInstruction}`
371
+ : skippedWrites.length > 0
372
+ ? `${stagedNotice(skippedWrites)}\n\n---\n\n${nodeInstruction}`
373
+ : nodeInstruction;
349
374
  // Run Claude on this node, with optional eval-failure retry loop.
350
375
  let attempt = 0;
351
376
  let result;
@@ -373,7 +398,8 @@ export async function execute(workflow, input, options) {
373
398
  logger.warn(` ${granted.dropped}`, { node: currentId });
374
399
  const agentAccess = {
375
400
  ...resolveAgentAccess(node.skills, skills, granted.grant),
376
- ...(stageOutputs ? { noPush: true } : {}),
401
+ // A staged run loads no skill or external MCP server either: it may write.
402
+ ...(stageOutputs ? { noPush: true, noMcp: true } : {}),
377
403
  };
378
404
  // #365: a node or workflow that declares `permissions` or `outputs` gets
379
405
  // one portable policy, compiled by each adapter. The gate also runs here so
@@ -474,7 +500,7 @@ export async function execute(workflow, input, options) {
474
500
  timeoutMs,
475
501
  agentAccess,
476
502
  ...(nodePolicy ? { policy: nodePolicy } : {}),
477
- ...(readOnlyNode ? {} : { mcpServers: skillMcpServers }),
503
+ ...(readOnlyNode || stageOutputs ? {} : { mcpServers: skillMcpServers }),
478
504
  ...(readOnlyNode ? { readOnly: true } : {}),
479
505
  ...(onUsage ? { onUsage } : {}),
480
506
  onProgress: (message) => {
@@ -652,6 +678,9 @@ export async function execute(workflow, input, options) {
652
678
  env: runEnv,
653
679
  actor,
654
680
  staged: stageOutputs,
681
+ cwd: resolveCwd,
682
+ pushBranch,
683
+ ...(githubApiUrl ? { githubApiUrl } : {}),
655
684
  state: writeState,
656
685
  logger,
657
686
  input: runInput,
@@ -1110,6 +1139,16 @@ function isDryRunInput(input) {
1110
1139
  export function isReadTool(tool) {
1111
1140
  return tool.access === "read";
1112
1141
  }
1142
+ /**
1143
+ * The tool dispatcher's staged-run gate: in a staged or dry run no write (or
1144
+ * unclassified) tool handler runs, whatever reached the agent. Throws before
1145
+ * any side effect.
1146
+ */
1147
+ export function guardStagedWrite(tool, staged) {
1148
+ if (staged && !isReadTool(tool)) {
1149
+ throw new Error(`${tool.name} is a write tool and this run is staged: nothing was written`);
1150
+ }
1151
+ }
1113
1152
  /** Instruction section prepended to every node under dry-run. */
1114
1153
  function dryRunNotice(skippedWrites) {
1115
1154
  const withheld = skippedWrites.length > 0 ? `These write tools were withheld from this step: ${skippedWrites.join(", ")}. ` : "";
@@ -1117,6 +1156,12 @@ function dryRunNotice(skippedWrites) {
1117
1156
  `Do not create, modify, post, or send anything. Do the analysis, and where this step would ` +
1118
1157
  `normally write, describe exactly what it would have written instead.`);
1119
1158
  }
1159
+ /** Instruction section prepended to a staged write node whose write tools were withheld. */
1160
+ function stagedNotice(skippedWrites) {
1161
+ return (`## Staged run\n\nThis run is staged: nothing is written outside the workspace. These write tools were ` +
1162
+ `withheld from this step: ${skippedWrites.join(", ")}. Where this step would call one, describe ` +
1163
+ `exactly what it would have written instead.`);
1164
+ }
1120
1165
  /**
1121
1166
  * Only this node's resolved skills contribute external servers. A stdio
1122
1167
  * server gets its skill's declared credentials in its own `env` (explicit
@@ -48,7 +48,7 @@ import { spawn } from "node:child_process";
48
48
  import * as fs from "node:fs";
49
49
  import * as path from "node:path";
50
50
  import { consoleLogger } from "../types.js";
51
- import { finishAgentEnv, heldCredentials, parseList, reportWithheldEnv, resolveEnvScope, resolveSandboxMode, scopeAgentEnv, withPushBlocked, } from "../agent-env.js";
51
+ import { finishAgentEnv, heldCredentials, parseList, reportWithheldEnv, resolveEnvScope, resolveSandboxMode, scopeAgentEnv, withPushBlocked, mcpWithheld, } from "../agent-env.js";
52
52
  import { ACP_CAPABILITIES } from "./capabilities.js";
53
53
  import { policyGate, resolveHarnessPolicy, TOOL_CLASSES } from "./policy.js";
54
54
  import { gitCredentialPolicy } from "../git-credentials.js";
@@ -739,7 +739,7 @@ export class AcpHarness {
739
739
  try {
740
740
  // Dry run: external MCP servers cannot be classified per tool, so only
741
741
  // sweny's own (already read-filtered) skill tools remain.
742
- const servers = readOnly
742
+ const servers = mcpWithheld(readOnly, req.agentAccess)
743
743
  ? {}
744
744
  : { ...this.defaultMcpServers, ...req.mcpServers, ...this.mcpServers };
745
745
  if (req.tools.length > 0) {
@@ -11,7 +11,7 @@
11
11
  import { query, createSdkMcpServer, tool as sdkTool } from "@anthropic-ai/claude-agent-sdk";
12
12
  import { createRequire } from "node:module";
13
13
  import { consoleLogger } from "../types.js";
14
- import { AGENT_AUTH_VARS, finishAgentEnv, reportWithheldEnv, parseList, resolveEnvScope, scopeAgentEnv, withPushBlocked, resolveAgentSandbox, } from "../agent-env.js";
14
+ import { AGENT_AUTH_VARS, finishAgentEnv, reportWithheldEnv, parseList, resolveEnvScope, scopeAgentEnv, withPushBlocked, mcpWithheld, resolveAgentSandbox, } from "../agent-env.js";
15
15
  import { gitCredentialGap, policyGate } from "./policy.js";
16
16
  import { gitCredentialPolicy } from "../git-credentials.js";
17
17
  import { CLAUDE_CODE_CAPABILITIES } from "./capabilities.js";
@@ -324,13 +324,14 @@ export class ClaudeCodeHarness {
324
324
  ...req,
325
325
  readOnly,
326
326
  disallowedTools: disallowedTools.length > 0 ? disallowedTools : undefined,
327
- strictMcp: readOnly || policy.strict || policy.exclusiveMcp === true,
327
+ strictMcp: mcpWithheld(readOnly, req.agentAccess) || policy.strict || policy.exclusiveMcp === true,
328
328
  sandboxMode: policy.sandbox,
329
329
  agentAccess: {
330
330
  envVars: req.agentAccess?.envVars ?? [],
331
331
  domains: policy.egress,
332
332
  withhold: req.agentAccess?.withhold,
333
333
  noPush: req.agentAccess?.noPush,
334
+ noMcp: req.agentAccess?.noMcp,
334
335
  },
335
336
  stagedWrite: !!req.agentAccess?.noPush && !readOnly,
336
337
  strict: policy.strict || this.policyMode === "strict",
@@ -483,7 +484,8 @@ export class ClaudeCodeHarness {
483
484
  const abort = makeAbort(timeoutMs, signal);
484
485
  let stream;
485
486
  try {
486
- const allMcpServers = readOnly
487
+ // Read-only and staged runs: no skill or external MCP server (it may write).
488
+ const allMcpServers = mcpWithheld(!!readOnly, agentAccess)
487
489
  ? {}
488
490
  : { ...this.defaultMcpServers, ...opts.mcpServers, ...this.mcpServers };
489
491
  if (useBridge) {
@@ -520,7 +522,7 @@ export class ClaudeCodeHarness {
520
522
  // project .mcp.json, plugins). strictMcpConfig limits MCP to the
521
523
  // servers passed above, which under readOnly is only sweny-core.
522
524
  // A strict policy (#365) makes it exclusive for write nodes too.
523
- ...(readOnly || opts.strictMcp ? { strictMcpConfig: true } : {}),
525
+ ...(mcpWithheld(!!readOnly, agentAccess) || opts.strictMcp ? { strictMcpConfig: true } : {}),
524
526
  ...(disallowedTools && disallowedTools.length > 0 ? { disallowedTools } : {}),
525
527
  // CC-08: ask the SDK to produce validated structured output when the
526
528
  // node declares an output schema. The SDK then returns the parsed
@@ -28,7 +28,7 @@ import * as fs from "node:fs";
28
28
  import * as os from "node:os";
29
29
  import * as path from "node:path";
30
30
  import { consoleLogger } from "../types.js";
31
- import { CODEX_AUTH_VARS, CODEX_ENV_PREFIXES, checkSandboxSupport, finishAgentEnv, heldCredentials, parseList, reportWithheldEnv, resolveCodexAuthEnv, resolveEnvScope, resolveSandboxMode, scopeAgentEnv, withPushBlocked, } from "../agent-env.js";
31
+ import { CODEX_AUTH_VARS, CODEX_ENV_PREFIXES, checkSandboxSupport, finishAgentEnv, heldCredentials, parseList, reportWithheldEnv, resolveCodexAuthEnv, resolveEnvScope, resolveSandboxMode, scopeAgentEnv, withPushBlocked, mcpWithheld, } from "../agent-env.js";
32
32
  import { CODEX_CAPABILITIES } from "./capabilities.js";
33
33
  import { policyGate, resolveHarnessPolicy } from "./policy.js";
34
34
  import { gitCredentialPolicy } from "../git-credentials.js";
@@ -816,7 +816,7 @@ export class CodexHarness {
816
816
  try {
817
817
  // Dry run: external MCP servers cannot be classified per tool, so only
818
818
  // sweny's own (already read-filtered) skill tools remain.
819
- const servers = readOnly
819
+ const servers = mcpWithheld(readOnly, req.agentAccess)
820
820
  ? {}
821
821
  : { ...this.defaultMcpServers, ...req.mcpServers, ...this.mcpServers };
822
822
  const { args: mcpArgs, unsupported } = this.mcpArgs(servers);
@@ -34,7 +34,7 @@ import * as fs from "node:fs";
34
34
  import * as os from "node:os";
35
35
  import * as path from "node:path";
36
36
  import { consoleLogger } from "../types.js";
37
- import { PI_AUTH_VARS, PI_ENV_PREFIXES, finishAgentEnv, heldCredentials, parseList, reportWithheldEnv, resolveEnvScope, resolvePiProvider, resolveSandboxMode, scopeAgentEnv, withPushBlocked, } from "../agent-env.js";
37
+ import { PI_AUTH_VARS, PI_ENV_PREFIXES, finishAgentEnv, heldCredentials, parseList, reportWithheldEnv, resolveEnvScope, resolvePiProvider, resolveSandboxMode, scopeAgentEnv, withPushBlocked, mcpWithheld, } from "../agent-env.js";
38
38
  import { PI_CAPABILITIES } from "./capabilities.js";
39
39
  import { policyGate, resolveHarnessPolicy } from "./policy.js";
40
40
  import { gitCredentialPolicy } from "../git-credentials.js";
@@ -788,7 +788,7 @@ export class PiHarness {
788
788
  // Dry run: external MCP servers cannot be classified per tool (and a
789
789
  // `--tools` allowlist cannot name tools it has not seen), so only sweny's
790
790
  // own (already read-filtered) skill tools remain.
791
- const servers = readOnly
791
+ const servers = mcpWithheld(readOnly, req.agentAccess)
792
792
  ? {}
793
793
  : { ...this.defaultMcpServers, ...req.mcpServers, ...this.mcpServers };
794
794
  const { entries, unsupported } = piMcpServers(servers);
package/dist/journal.d.ts CHANGED
@@ -382,7 +382,7 @@ export declare class RunJournal implements ExecutionJournal {
382
382
  /** False once an append failed: the rest of the run is not journaled. */
383
383
  get active(): boolean;
384
384
  private takeLock;
385
- /** The run dir is private (0700); its parents are created as usual. */
385
+ /** The run dir is private (0700); no link on the path from cwd is followed (safe-file.ts). */
386
386
  private makeDir;
387
387
  private releaseLock;
388
388
  private append;
package/dist/journal.js CHANGED
@@ -59,6 +59,7 @@ import path from "node:path";
59
59
  import { resolveNodePermissions } from "./node-policy.js";
60
60
  import { CURRENT_SPEC_VERSION } from "./migrations.js";
61
61
  import { spendOf } from "./budget.js";
62
+ import { ensureDirNoFollow, openNoFollow, writeFileNoFollow } from "./safe-file.js";
62
63
  /** v2: records are authenticated with a per-run HMAC key (v1 used a public checksum and is refused). */
63
64
  export const JOURNAL_SCHEMA_VERSION = 2;
64
65
  export const JOURNAL_DIR = path.join(".sweny", "runs");
@@ -1005,12 +1006,11 @@ export class RunJournal {
1005
1006
  throw err;
1006
1007
  }
1007
1008
  this.makeDir();
1008
- fs.writeFileSync(lock, String(process.pid), { mode: 0o600 });
1009
+ writeFileNoFollow(lock, String(process.pid), { root: this.cwd, mode: 0o600 });
1009
1010
  }
1010
- /** The run dir is private (0700); its parents are created as usual. */
1011
+ /** The run dir is private (0700); no link on the path from cwd is followed (safe-file.ts). */
1011
1012
  makeDir() {
1012
- fs.mkdirSync(path.dirname(this.dir), { recursive: true });
1013
- fs.mkdirSync(this.dir, { recursive: true, mode: 0o700 });
1013
+ ensureDirNoFollow(this.dir, this.cwd);
1014
1014
  }
1015
1015
  releaseLock() {
1016
1016
  try {
@@ -1052,8 +1052,7 @@ export class RunJournal {
1052
1052
  try {
1053
1053
  if (this.fd === undefined) {
1054
1054
  this.makeDir();
1055
- this.fd = fs.openSync(this.file, "a", 0o600);
1056
- fs.fchmodSync(this.fd, 0o600);
1055
+ this.fd = openNoFollow(this.file, { root: this.cwd, append: true, mode: 0o600 });
1057
1056
  }
1058
1057
  fs.writeSync(this.fd, JSON.stringify(record) + "\n");
1059
1058
  fs.fsyncSync(this.fd);
@@ -1126,7 +1125,7 @@ export class RunJournal {
1126
1125
  try {
1127
1126
  this.makeDir();
1128
1127
  // Journals hold node output: keep them out of commits (an agent's `git add -A` included).
1129
- fs.writeFileSync(path.join(this.dir, ".gitignore"), "*\n");
1128
+ writeFileNoFollow(path.join(this.dir, ".gitignore"), "*\n", { root: this.cwd, mode: 0o644 });
1130
1129
  this.takeLock();
1131
1130
  }
1132
1131
  catch {