@sweny-ai/core 0.1.178 → 0.1.179

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.
@@ -373,9 +373,11 @@ export declare function checkSandboxSupport(platform?: NodeJS.Platform, pathEnv?
373
373
  /**
374
374
  * The SDK sandbox settings used when the sandbox is on.
375
375
  *
376
- * - `failIfUnavailable`: true under `strict` (the SDK errors instead of
377
- * running unsandboxed); false under `auto`, so a host that passes the
378
- * preflight but still cannot start the sandbox degrades instead of failing.
376
+ * - `failIfUnavailable`: true whenever settings are built, `auto` included:
377
+ * the preflight already passed, so a sandbox that then fails to start
378
+ * fails the node instead of silently running the agent unsandboxed (which
379
+ * the run would otherwise report as contained). `auto` on a host whose
380
+ * preflight fails builds no settings and runs unsandboxed, reported as such.
379
381
  * - `allowUnsandboxedCommands: false`: the model cannot escape via the
380
382
  * `dangerouslyDisableSandbox` parameter. Required, because nodes run under
381
383
  * `bypassPermissions` (#365) which would otherwise auto-approve the escape.
package/dist/agent-env.js CHANGED
@@ -29,7 +29,7 @@ import { execFileSync } from "node:child_process";
29
29
  import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
30
30
  import { homedir, tmpdir } from "node:os";
31
31
  import * as path from "node:path";
32
- import { runKeyDir } from "./journal.js";
32
+ import { runStateRoot } from "./journal.js";
33
33
  // ─── Scoped env ──────────────────────────────────────────────────
34
34
  /**
35
35
  * Exact variable names always passed to the agent subprocess.
@@ -938,9 +938,11 @@ function defaultProbe() {
938
938
  /**
939
939
  * The SDK sandbox settings used when the sandbox is on.
940
940
  *
941
- * - `failIfUnavailable`: true under `strict` (the SDK errors instead of
942
- * running unsandboxed); false under `auto`, so a host that passes the
943
- * preflight but still cannot start the sandbox degrades instead of failing.
941
+ * - `failIfUnavailable`: true whenever settings are built, `auto` included:
942
+ * the preflight already passed, so a sandbox that then fails to start
943
+ * fails the node instead of silently running the agent unsandboxed (which
944
+ * the run would otherwise report as contained). `auto` on a host whose
945
+ * preflight fails builds no settings and runs unsandboxed, reported as such.
944
946
  * - `allowUnsandboxedCommands: false`: the model cannot escape via the
945
947
  * `dangerouslyDisableSandbox` parameter. Required, because nodes run under
946
948
  * `bypassPermissions` (#365) which would otherwise auto-approve the escape.
@@ -963,10 +965,12 @@ export function buildSandboxSettings(domains, opts = {}) {
963
965
  envVars: AGENT_AUTH_VARS.map((name) => ({ name, mode: "deny" })),
964
966
  files: [
965
967
  { path: path.join(opts.home ?? homedir(), ".claude", ".credentials.json"), mode: "deny" },
966
- // Run journal keys: an agent that could read them could forge journal records.
967
- { path: runKeyDir(), mode: "deny" },
968
+ // Run journals and their keys: an agent that could read a key could forge records.
969
+ { path: runStateRoot(), mode: "deny" },
968
970
  ],
969
971
  },
972
+ // Nor may it write there: cut a journal, delete a lock, or plant a run.
973
+ filesystem: { denyRead: [runStateRoot()], denyWrite: [runStateRoot()] },
970
974
  };
971
975
  }
972
976
  /**
@@ -1003,7 +1007,8 @@ export function resolveAgentSandbox(opts) {
1003
1007
  return {
1004
1008
  mode,
1005
1009
  settings: buildSandboxSettings([...DEFAULT_SANDBOX_DOMAINS, ...(opts.nodeDomains ?? []), ...extra], {
1006
- failIfUnavailable: mode === "strict",
1010
+ // Preflight passed: from here the sandbox is enforced or the node fails.
1011
+ failIfUnavailable: true,
1007
1012
  }),
1008
1013
  };
1009
1014
  }
@@ -193,7 +193,10 @@ export function outputRelPath(runId) {
193
193
  export function writeFinalOutput(runId, markdown, cwd = process.cwd()) {
194
194
  try {
195
195
  const rel = outputRelPath(runId);
196
- writeFileNoFollow(path.join(cwd, rel), markdown, { root: cwd, mode: 0o600 });
196
+ const file = path.join(cwd, rel);
197
+ // Node output: keep the run dir out of commits (an agent's `git add -A` included).
198
+ writeFileNoFollow(path.join(path.dirname(file), ".gitignore"), "*\n", { root: cwd, mode: 0o644 });
199
+ writeFileNoFollow(file, markdown, { root: cwd, mode: 0o600 });
197
200
  return rel;
198
201
  }
199
202
  catch {
package/dist/cli/main.js CHANGED
@@ -1143,8 +1143,9 @@ resume) {
1143
1143
  progress.stop();
1144
1144
  console.error(formatCrashError(err));
1145
1145
  runLogger.flush();
1146
+ const resumable = journal?.active === true;
1146
1147
  journal?.end("crashed");
1147
- if (journal)
1148
+ if (resumable)
1148
1149
  console.error(c.subtle(` resume with: sweny workflow resume ${runId}`));
1149
1150
  recordHistory(nodeTimer.lastResults, undefined, true);
1150
1151
  const crashSummary = summarizeRun(new Map(), Date.now() - runStart, true);
@@ -1255,7 +1256,7 @@ for (const [flags, description] of WORKFLOW_RUN_OPTIONS) {
1255
1256
  }
1256
1257
  workflowRunCmd.option("--no-history", "Do not record this run or save its output.md in .sweny/runs/ (or set `history: off` in .sweny.yml)");
1257
1258
  workflowRunCmd.option("--show-output", "Print the final answer in CI too (off by default in CI: only the path to output.md is printed)");
1258
- workflowRunCmd.option("--no-journal", "Do not write a run journal to .sweny/runs/<run-id>/ (or set `journal: off` in .sweny.yml); the run cannot be resumed");
1259
+ workflowRunCmd.option("--no-journal", "Do not write a run journal (kept in the sweny state dir; or set `journal: off` in .sweny.yml); the run cannot be resumed");
1259
1260
  const workflowResumeCmd = workflowCmd
1260
1261
  .command("resume <run-id>")
1261
1262
  .description("Resume a killed or failed `workflow run` from its journal: finished nodes are replayed without calling the agent, " +
@@ -6,7 +6,7 @@
6
6
  * Lives outside main.ts so tests can import it (main.ts parses argv on import).
7
7
  */
8
8
  import type { Logger, Workflow } from "../types.js";
9
- import { RunJournal, type ResumePlan } from "../journal.js";
9
+ import { RunJournal, type LockHooks, type ResumePlan } from "../journal.js";
10
10
  /** `sweny workflow resume` options (commander camelCases the flags). */
11
11
  export interface ResumeOptions {
12
12
  force?: boolean;
@@ -48,9 +48,18 @@ export interface PrepareResumeDeps {
48
48
  swenyVersion?: string;
49
49
  env?: Record<string, string | undefined>;
50
50
  logger?: Logger;
51
+ /** Where run journals live (default: {@link runStateRoot} of `env`). */
52
+ stateRoot?: string;
53
+ /** Test seam for the run lock. */
54
+ lockHooks?: LockHooks;
51
55
  }
52
56
  /** One line per planned visit, for humans. */
53
57
  export declare function formatResumePlan(plan: ResumePlan, mayRepeat: string[]): string[];
58
+ /**
59
+ * Check a run journal and build the resume. The run's lock is taken first,
60
+ * before anything is read or repaired, and is kept by the returned journal
61
+ * (released on every other outcome, `--plan` included).
62
+ */
54
63
  export declare function prepareResume(ref: string, opts: ResumeOptions, deps: PrepareResumeDeps): PrepareResumeResult;
55
64
  /** Journal is on unless `--no-journal` (options.journal === false) or `.sweny.yml` has `journal: off`. */
56
65
  export declare function journalDisabled(optionJournal: unknown, configJournal: unknown): boolean;
@@ -7,7 +7,8 @@
7
7
  */
8
8
  import path from "node:path";
9
9
  import { validateRuntimeInput } from "../inputs.js";
10
- import { JOURNAL_DIR, JOURNAL_FILE, JournalKeyError, JournalLockedError, JournalVersionError, RunJournal, buildResumePlan, canonicalHash, checkJournalAgainstWorkflow, findJournalRun, journalDir, mayRepeatWrites, readJournal, workflowHashOf, } from "../journal.js";
10
+ import { existsSync } from "node:fs";
11
+ import { JournalKeyError, JournalLocationError, assertStateOutsideWorkspace, JournalLockedError, JournalRollbackError, JournalVersionError, RunJournal, buildResumePlan, canonicalHash, checkJournalAgainstWorkflow, findJournalRun, findLegacyJournal, journalFile, lockRun, mayRepeatWrites, readJournal, runStateRoot, workflowHashOf, } from "../journal.js";
11
12
  import { parseInputFlag } from "./workflow-input.js";
12
13
  /** Resume-only flags; the rest are shared with `workflow run`. */
13
14
  export const WORKFLOW_RESUME_OPTIONS = [
@@ -69,23 +70,80 @@ export function formatResumePlan(plan, mayRepeat) {
69
70
  lines.push(" every node finished; the resume only closes the run");
70
71
  return lines;
71
72
  }
73
+ /**
74
+ * Check a run journal and build the resume. The run's lock is taken first,
75
+ * before anything is read or repaired, and is kept by the returned journal
76
+ * (released on every other outcome, `--plan` included).
77
+ */
72
78
  export function prepareResume(ref, opts, deps) {
73
79
  const cwd = deps.cwd ?? process.cwd();
74
- const lines = [];
75
- const fail = (error) => ({ ok: false, error, lines });
76
- const runId = findJournalRun(ref, cwd);
80
+ const root = deps.stateRoot ?? runStateRoot(deps.env ?? process.env);
81
+ // A state dir that resolves into the workspace is agent-writable: nothing in it can be trusted.
82
+ if (existsSync(root)) {
83
+ try {
84
+ assertStateOutsideWorkspace(root, cwd);
85
+ }
86
+ catch (err) {
87
+ if (err instanceof JournalLocationError)
88
+ return { ok: false, lines: [], error: err.message };
89
+ throw err;
90
+ }
91
+ }
92
+ const runId = findJournalRun(ref, cwd, root);
77
93
  if (!runId) {
78
- return fail(`no run journal matches "${ref}" in ${JOURNAL_DIR}/. Runs started with --no-journal (or before journals existed) cannot be resumed.`);
94
+ const legacy = findLegacyJournal(ref, cwd);
95
+ if (legacy) {
96
+ return {
97
+ ok: false,
98
+ lines: [],
99
+ error: `run ${ref} has a journal in the workspace (${path.relative(cwd, legacy)}), written by an older sweny. ` +
100
+ `Journals now live in the sweny state dir, where agents cannot edit them; a workspace journal cannot ` +
101
+ `be resumed safely. Start a new run.`,
102
+ };
103
+ }
104
+ return {
105
+ ok: false,
106
+ lines: [],
107
+ error: `no run journal matches "${ref}" for this workspace in ${root}. Runs started with --no-journal ` +
108
+ `(or by another user or state dir) cannot be resumed.`,
109
+ };
110
+ }
111
+ let lock;
112
+ try {
113
+ lock = lockRun(cwd, runId, root, deps.lockHooks);
114
+ }
115
+ catch (err) {
116
+ const error = err instanceof Error ? err.message : String(err);
117
+ return {
118
+ ok: false,
119
+ lines: [],
120
+ error: err instanceof JournalLockedError ? error : `cannot lock run ${runId}: ${error}`,
121
+ };
79
122
  }
80
- const file = path.join(journalDir(cwd, runId), JOURNAL_FILE);
123
+ let result;
124
+ try {
125
+ result = prepareLocked(runId, journalFile(cwd, runId, root), lock, opts, deps, cwd, root);
126
+ }
127
+ catch (err) {
128
+ lock.release();
129
+ throw err;
130
+ }
131
+ if (!(result.ok && "ctx" in result))
132
+ lock.release();
133
+ return result;
134
+ }
135
+ function prepareLocked(runId, file, lock, opts, deps, cwd, root) {
136
+ const lines = [];
137
+ const fail = (error) => ({ ok: false, error, lines });
81
138
  let read;
82
139
  try {
83
140
  // --plan stays read-only: a torn tail is reported, not cut.
84
141
  read = readJournal(file, { repair: !opts.plan });
85
142
  }
86
143
  catch (err) {
87
- if (err instanceof JournalVersionError || err instanceof JournalKeyError)
144
+ if (err instanceof JournalVersionError || err instanceof JournalKeyError || err instanceof JournalRollbackError) {
88
145
  return fail(err.message);
146
+ }
89
147
  return fail(`cannot read ${file}: ${err instanceof Error ? err.message : String(err)}`);
90
148
  }
91
149
  if (read.forgedAtLine !== undefined) {
@@ -101,6 +159,11 @@ export function prepareResume(ref, opts, deps) {
101
159
  ? `note: the journal ends in a torn record (${read.truncatedBytes} bytes); resuming drops it`
102
160
  : `note: dropped a torn record at the end of the journal (${read.truncatedBytes} bytes)`);
103
161
  }
162
+ if (read.restored) {
163
+ lines.push(opts.plan
164
+ ? `note: the journal's last record was not appended before the crash; resuming restores it from the head`
165
+ : `note: restored the journal's last record from the head (the run stopped before appending it)`);
166
+ }
104
167
  let plan;
105
168
  try {
106
169
  plan = buildResumePlan(read.records);
@@ -154,6 +217,10 @@ export function prepareResume(ref, opts, deps) {
154
217
  }
155
218
  const mayRepeat = mayRepeatWrites(plan, workflow, input);
156
219
  lines.push(...formatResumePlan(plan, mayRepeat));
220
+ if (plan.unsandboxed.length > 0) {
221
+ lines.push(`warning: an earlier attempt ran ${plan.unsandboxed.join(", ")} without an enforced sandbox, so that agent ` +
222
+ `could have read the run key and edited this run's journaled spend and write records (journal_unsandboxed)`);
223
+ }
157
224
  if (opts.plan)
158
225
  return { ok: true, planOnly: true, lines };
159
226
  if (mayRepeat.length > 0 && !opts.allowRepeatWrites) {
@@ -170,8 +237,10 @@ export function prepareResume(ref, opts, deps) {
170
237
  swenyVersion: deps.swenyVersion,
171
238
  env: deps.env,
172
239
  logger: deps.logger,
240
+ stateRoot: root,
173
241
  read,
174
242
  plan,
243
+ lock,
175
244
  force: opts.force === true,
176
245
  allowRepeatWrites: opts.allowRepeatWrites === true,
177
246
  });
@@ -18,7 +18,7 @@ import type { AgentHarness } from "./harness/types.js";
18
18
  import type { DeciderOperatorConfig } from "./decider.js";
19
19
  import type { Budget } from "./budget.js";
20
20
  import { type ActorInfo } from "./safe-outputs.js";
21
- import type { ExecutionJournal } from "./journal.js";
21
+ import { type ExecutionJournal } from "./journal.js";
22
22
  export interface ExecuteOptions {
23
23
  /** Registered skills (id → Skill) */
24
24
  skills: Map<string, Skill>;
package/dist/executor.js CHANGED
@@ -34,6 +34,7 @@ import { createRunDecider, wantsDecider } from "./decider.js";
34
34
  import { redact, runSecretValues } from "./journal.js";
35
35
  import { buildNodePolicy, resolveNodePermissions } from "./node-policy.js";
36
36
  import { applySafeOutputs, createEmitOutputTool, createWriteStageState, resolveActor, safeOutputsInstruction, unresolvedOutputs, SAFE_OUTPUT_SCREEN_INSTRUCTION, } from "./safe-outputs.js";
37
+ import { JournalWriteError } from "./journal.js";
37
38
  /**
38
39
  * Default workflow-level step budget. Counts total node executions across the
39
40
  * whole run (loop iterations included). Generous enough for legitimate deep
@@ -59,6 +60,43 @@ function throwIfAborted(signal) {
59
60
  * - `trace`: full ordered execution trace including loops and routing decisions
60
61
  */
61
62
  export async function execute(workflow, input, options) {
63
+ const results = new Map();
64
+ const trace = { steps: [], edges: [], sources: {} };
65
+ const at = { node: null, iteration: 0 };
66
+ // Resume of a journal an uncontained agent could have edited: the receipt says so.
67
+ const journalDegraded = () => {
68
+ const extra = options.journal?.degraded?.() ?? [];
69
+ if (extra.length === 0)
70
+ return;
71
+ for (const [id, r] of results)
72
+ results.set(id, { ...r, degraded: [...new Set([...(r.degraded ?? []), ...extra])] });
73
+ };
74
+ try {
75
+ const out = await executeRun(workflow, input, options, results, trace, at);
76
+ journalDegraded();
77
+ return out;
78
+ }
79
+ catch (err) {
80
+ // Resume (#363): a journal that cannot record the run stops it. The node
81
+ // fails with the reason; nothing after it runs (no model call, no write).
82
+ if (!(err instanceof JournalWriteError) || at.node === null)
83
+ throw err;
84
+ const logger = options.logger ?? consoleLogger;
85
+ const result = {
86
+ ...(results.get(at.node) ?? { toolCalls: [] }),
87
+ status: "failed",
88
+ data: { ...(results.get(at.node)?.data ?? {}), error: err.message, journal_failed: true },
89
+ };
90
+ results.set(at.node, result);
91
+ trace.steps.push({ node: at.node, status: "failed", iteration: at.iteration });
92
+ safeObserve(options.observer, { type: "node:exit", node: at.node, result }, logger);
93
+ logger.error(` ${err.message}`, { node: at.node });
94
+ journalDegraded();
95
+ safeObserve(options.observer, { type: "workflow:end", results: Object.fromEntries(results) }, logger);
96
+ return { results, trace };
97
+ }
98
+ }
99
+ async function executeRun(workflow, input, options, results, trace, at) {
62
100
  const { observer, signal, timeoutMs, journal } = options;
63
101
  // `harness` is the seam; a legacy `claude` object is used as-is (what `asClaude(claudeCompat(claude))` yields).
64
102
  const claude = options.harness ? asClaude(options.harness) : options.claude;
@@ -74,12 +112,10 @@ export async function execute(workflow, input, options) {
74
112
  const skills = mergeInlineSkills(options.skills, workflow.skills);
75
113
  const config = resolveConfig(skills, options.config, options.env ?? process.env);
76
114
  const logger = options.logger ?? consoleLogger;
77
- const results = new Map();
78
115
  const edgeCounts = new Map(); // "from→to" → times followed
79
116
  const nodeRunCounts = new Map(); // node → times executed
80
117
  const maxSteps = options.max_steps ?? DEFAULT_MAX_STEPS;
81
118
  let stepCount = 0; // total node executions across the run (loop iterations included)
82
- const trace = { steps: [], edges: [], sources: {} };
83
119
  validate(workflow, skills);
84
120
  // Safe outputs (#365): run-wide caps and dedupe, the staged switch and the
85
121
  // triggering actor, resolved once per run.
@@ -226,6 +262,8 @@ export async function execute(workflow, input, options) {
226
262
  }
227
263
  const iteration = (nodeRunCounts.get(currentId) ?? 0) + 1;
228
264
  nodeRunCounts.set(currentId, iteration);
265
+ at.node = currentId;
266
+ at.iteration = iteration;
229
267
  const resolvedInstruction = resolvedSources[`nodes.${currentId}.instruction`].content;
230
268
  safeObserve(observer, { type: "node:enter", node: currentId, instruction: resolvedInstruction }, logger);
231
269
  logger.info(`→ ${node.name}`, { node: currentId });
@@ -506,10 +544,30 @@ export async function execute(workflow, input, options) {
506
544
  // The repair call (at most one) shifts later attempts by one, so every agent call has its own key.
507
545
  const usageAttempt = attempt + (repaired ? 1 : 0);
508
546
  let liveUsage;
547
+ // A live usage record the journal cannot write stops the agent at once:
548
+ // its spend would be invisible to a resume.
549
+ let journalFault;
550
+ const journalStop = journal?.usage ? new AbortController() : undefined;
551
+ const parentSignal = attemptBudget?.signal ?? signal;
552
+ const onParentAbort = () => journalStop?.abort(parentSignal?.reason);
553
+ if (journalStop && parentSignal) {
554
+ if (parentSignal.aborted)
555
+ journalStop.abort(parentSignal.reason);
556
+ else
557
+ parentSignal.addEventListener("abort", onParentAbort, { once: true });
558
+ }
509
559
  const journalUsage = journal?.usage
510
560
  ? (u) => {
511
561
  liveUsage = { ...liveUsage, ...u };
512
- journal.usage(usageNode, iteration, usageAttempt, liveUsage, false);
562
+ if (journalFault)
563
+ return;
564
+ try {
565
+ journal.usage(usageNode, iteration, usageAttempt, liveUsage, false);
566
+ }
567
+ catch (err) {
568
+ journalFault = err;
569
+ journalStop?.abort(err);
570
+ }
513
571
  }
514
572
  : undefined;
515
573
  const onUsage = attemptBudget || journalUsage
@@ -529,7 +587,7 @@ export async function execute(workflow, input, options) {
529
587
  disallowedTools: node.disallowed_tools,
530
588
  ...(denyClasses.length > 0 ? { deny: denyClasses } : {}),
531
589
  model: nodeModel,
532
- signal: attemptBudget?.signal ?? signal,
590
+ signal: journalStop?.signal ?? parentSignal,
533
591
  timeoutMs,
534
592
  agentAccess,
535
593
  ...(nodePolicy ? { policy: nodePolicy } : {}),
@@ -541,9 +599,24 @@ export async function execute(workflow, input, options) {
541
599
  },
542
600
  });
543
601
  }
602
+ catch (err) {
603
+ if (journalFault)
604
+ throw journalFault;
605
+ // The attempt ended abnormally (abort, timeout, harness error): whether its agent was
606
+ // contained is unknown, so the journal says it was not.
607
+ journal?.uncontained?.(usageNode, iteration);
608
+ throw err;
609
+ }
544
610
  finally {
611
+ parentSignal?.removeEventListener("abort", onParentAbort);
545
612
  attemptBudget?.dispose();
546
613
  }
614
+ if (journalFault)
615
+ throw journalFault;
616
+ // Key secrecy (and so the journal's spend and write records) holds only for a contained agent.
617
+ if (result.contained !== true && result.data?.refused !== true) {
618
+ journal?.uncontained?.(usageNode, iteration);
619
+ }
547
620
  previous = result;
548
621
  // The attempt's final spend: the larger of the result's usage and the last live report, per unit.
549
622
  if (journal?.usage) {
@@ -713,6 +713,8 @@ export class AcpHarness {
713
713
  const extraDegraded = [];
714
714
  const tag = (r) => ({
715
715
  ...r,
716
+ // Only the process wrapper keeps the agent out of the sweny state dir.
717
+ contained: wrapper !== undefined,
716
718
  harness: this.info(),
717
719
  degraded: [...degraded, ...extraDegraded],
718
720
  });
@@ -51,6 +51,24 @@ export declare const CLAUDE_TOOLS_BY_CLASS: Readonly<Record<ToolClass, readonly
51
51
  * `denyRead` (merged with these rules) covers Bash.
52
52
  */
53
53
  export declare function claudeReadDenyRule(absPath: string): string;
54
+ /**
55
+ * Deny rules keeping every built-in file tool out of the run journals' state
56
+ * dir ({@link runStateRoot}): an agent that could read a run key could forge
57
+ * journal records, and one that could write there could cut a journal or
58
+ * plant a run.
59
+ *
60
+ * Mechanism: scoped deny rules on `disallowedTools`. Per the Agent SDK's
61
+ * permission evaluation order (code.claude.com/docs/en/agent-sdk/permissions,
62
+ * "Deny rules"), a matching deny rule from `disallowed_tools` blocks the tool
63
+ * "even in `bypassPermissions` mode", which every node runs under (#365);
64
+ * `canUseTool` and allow rules are never reached in that mode. `Read(path)`
65
+ * rules govern the built-in file readers (Read, and Grep/Glob), and
66
+ * `Edit(path)` rules "govern all built-in tools that write files, including
67
+ * `Write` and `NotebookEdit`" (a `Write(path)` rule is never matched, so none
68
+ * is emitted). `//` anchors an absolute filesystem path. Both the path as
69
+ * configured and its real path are denied (macOS `/var` is `/private/var`).
70
+ */
71
+ export declare function claudeStateDirDenyRules(root?: string): string[];
54
72
  /** Native `disallowedTools` for a policy: legacy names, compiled classes, and the read-only set. */
55
73
  export declare function compileClaudeCodeDeny(policy: NodePolicy, legacy?: readonly string[]): string[];
56
74
  /** How sweny resolves which credentials reach the Claude Code subprocess. */
@@ -10,6 +10,7 @@
10
10
  */
11
11
  import { query, createSdkMcpServer, tool as sdkTool } from "@anthropic-ai/claude-agent-sdk";
12
12
  import { createRequire } from "node:module";
13
+ import { realpathSync } from "node:fs";
13
14
  import { consoleLogger } from "../types.js";
14
15
  import { AGENT_AUTH_VARS, finishAgentEnv, reportWithheldEnv, parseList, resolveEnvScope, scopeAgentEnv, withPushBlocked, mcpWithheld, resolveAgentSandbox, } from "../agent-env.js";
15
16
  import { gitCredentialGap, policyGate } from "./policy.js";
@@ -20,6 +21,7 @@ import { jsonSchemaToZodShape, toolErrorToMcpResult, toolOutputToMcpResult } fro
20
21
  import { startToolBridge } from "./tool-bridge/server.js";
21
22
  import { parseToolResultContent, summarizeToolError, tryParseJSON } from "./parse.js";
22
23
  import { makeAbort } from "./abort.js";
24
+ import { runStateRoot } from "../journal.js";
23
25
  import { claudeCodeAuth } from "./auth.js";
24
26
  export { buildEvaluatePrompt, CLAUDE_CODE_CAPABILITIES };
25
27
  export { parseToolResultContent, summarizeToolError, makeAbort };
@@ -96,6 +98,39 @@ export const CLAUDE_TOOLS_BY_CLASS = {
96
98
  export function claudeReadDenyRule(absPath) {
97
99
  return `Read(/${absPath.startsWith("/") ? absPath : `/${absPath}`})`;
98
100
  }
101
+ /**
102
+ * Deny rules keeping every built-in file tool out of the run journals' state
103
+ * dir ({@link runStateRoot}): an agent that could read a run key could forge
104
+ * journal records, and one that could write there could cut a journal or
105
+ * plant a run.
106
+ *
107
+ * Mechanism: scoped deny rules on `disallowedTools`. Per the Agent SDK's
108
+ * permission evaluation order (code.claude.com/docs/en/agent-sdk/permissions,
109
+ * "Deny rules"), a matching deny rule from `disallowed_tools` blocks the tool
110
+ * "even in `bypassPermissions` mode", which every node runs under (#365);
111
+ * `canUseTool` and allow rules are never reached in that mode. `Read(path)`
112
+ * rules govern the built-in file readers (Read, and Grep/Glob), and
113
+ * `Edit(path)` rules "govern all built-in tools that write files, including
114
+ * `Write` and `NotebookEdit`" (a `Write(path)` rule is never matched, so none
115
+ * is emitted). `//` anchors an absolute filesystem path. Both the path as
116
+ * configured and its real path are denied (macOS `/var` is `/private/var`).
117
+ */
118
+ export function claudeStateDirDenyRules(root = runStateRoot()) {
119
+ let real = root;
120
+ try {
121
+ real = realpathSync(root);
122
+ }
123
+ catch {
124
+ // not created yet: the configured path is the one tools would use
125
+ }
126
+ const abs = (p) => (p.startsWith("/") ? p : `/${p}`);
127
+ return [...new Set([root, real])].flatMap((dir) => [
128
+ `Read(/${abs(dir)})`,
129
+ `Read(/${abs(dir)}/**)`,
130
+ `Edit(/${abs(dir)})`,
131
+ `Edit(/${abs(dir)}/**)`,
132
+ ]);
133
+ }
99
134
  /** Native `disallowedTools` for a policy: legacy names, compiled classes, and the read-only set. */
100
135
  export function compileClaudeCodeDeny(policy, legacy = []) {
101
136
  return [
@@ -344,6 +379,8 @@ export class ClaudeCodeHarness {
344
379
  harness: this.info(),
345
380
  degraded,
346
381
  ...(this.lastPolicyFacts ? { policy: this.lastPolicyFacts } : {}),
382
+ // The state dir deny rules hold for the file tools either way; Bash is held only by the sandbox.
383
+ contained: this.lastPolicyFacts?.sandboxStarted === true,
347
384
  };
348
385
  }
349
386
  async runQuery(opts) {
@@ -354,6 +391,9 @@ export class ClaudeCodeHarness {
354
391
  let disallowedTools = readOnly
355
392
  ? [...new Set([...(opts.disallowedTools ?? []), ...READ_ONLY_DISALLOWED_TOOLS])]
356
393
  : opts.disallowedTools;
394
+ // Run journals, their keys and locks live in the sweny state dir: no built-in
395
+ // file tool may read or write there (Bash is held by the sandbox's filesystem deny).
396
+ disallowedTools = [...new Set([...(disallowedTools ?? []), ...claudeStateDirDenyRules()])];
357
397
  const effectiveModel = model ?? this.model;
358
398
  // #360: run agent commands in the SDK sandbox. `auto` (default in CI) falls
359
399
  // back to unsandboxed with one loud warning when the host cannot sandbox;
@@ -517,12 +557,17 @@ export class ClaudeCodeHarness {
517
557
  ...(abort ? { abortController: abort.controller } : {}),
518
558
  ...(effectiveModel ? { model: effectiveModel } : {}),
519
559
  ...(Object.keys(allMcpServers).length > 0 ? { mcpServers: allMcpServers } : {}),
520
- // Dry run (#380): settingSources is omitted, so the SDK loads user,
521
- // project and local settings, including their MCP servers (and
522
- // project .mcp.json, plugins). strictMcpConfig limits MCP to the
523
- // servers passed above, which under readOnly is only sweny-core.
524
- // A strict policy (#365) makes it exclusive for write nodes too.
525
- ...(mcpWithheld(!!readOnly, agentAccess) || opts.strictMcp ? { strictMcpConfig: true } : {}),
560
+ // Only what sweny passes runs: an ambient MCP server or plugin (user
561
+ // settings, a repo's .mcp.json or .claude/settings.json, which are
562
+ // repo content) could read the run journals' state dir around the
563
+ // sandbox and the deny rules. SDK Options (sdk.d.ts):
564
+ // `strictMcpConfig` "Only use MCP servers passed via the `mcpServers`
565
+ // option ... ignoring ... project `.mcp.json`, user settings,
566
+ // plugins"; `settingSources` "Pass `[]` to disable filesystem
567
+ // settings (SDK isolation mode)", so no user/project/local
568
+ // settings, hooks or settings-enabled plugins load either.
569
+ strictMcpConfig: true,
570
+ settingSources: [],
526
571
  ...(disallowedTools && disallowedTools.length > 0 ? { disallowedTools } : {}),
527
572
  // CC-08: ask the SDK to produce validated structured output when the
528
573
  // node declares an output schema. The SDK then returns the parsed
@@ -758,6 +758,8 @@ export class CodexHarness {
758
758
  let degraded = gate.degraded;
759
759
  const tag = (r, extra = []) => ({
760
760
  ...r,
761
+ // Only the process wrapper keeps Codex out of the sweny state dir (its own sandbox allows reads).
762
+ contained: wrapper !== undefined,
761
763
  harness: this.info(),
762
764
  degraded: [...degraded, ...extra],
763
765
  });
@@ -756,6 +756,8 @@ export class PiHarness {
756
756
  const extraDegraded = [];
757
757
  const tag = (r) => ({
758
758
  ...r,
759
+ // Only the process wrapper keeps the agent out of the sweny state dir.
760
+ contained: wrapper !== undefined,
759
761
  harness: this.info(),
760
762
  degraded: [...degraded, ...extraDegraded],
761
763
  });
@@ -48,7 +48,7 @@ import { lstat, mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises
48
48
  import { homedir, tmpdir } from "node:os";
49
49
  import * as path from "node:path";
50
50
  import { DEFAULT_SANDBOX_DOMAINS, parseList, resolveSandboxMode } from "../agent-env.js";
51
- import { JOURNAL_DIR, runKeyDir } from "../journal.js";
51
+ import { runStateRoot } from "../journal.js";
52
52
  import { policyGate } from "./policy.js";
53
53
  // ─── srt settings ────────────────────────────────────────────────
54
54
  /**
@@ -132,13 +132,13 @@ export function buildSrtSettings(req, opts) {
132
132
  // cannot rename one out from under its read deny.
133
133
  const masked = [...new Set((req.denyRead ?? []).map(real))].filter((p) => exists(p));
134
134
  denyRead.push(...masked.filter((p) => !denyRead.includes(p)));
135
- // Run journal keys (outside the workspace) are never readable by the agent,
136
- // and the journals themselves are not writable.
137
- const keyDir = runKeyDir();
138
- if (exists(keyDir) && !denyRead.includes(keyDir))
139
- denyRead.push(keyDir);
140
- const journals = path.join(real(req.cwd), JOURNAL_DIR);
141
- const denyWrite = !req.readOnly && exists(journals) ? [...masked, journals] : masked;
135
+ // Run journals, their keys and locks (the sweny state dir, outside the
136
+ // workspace) are neither readable nor writable by the agent.
137
+ const journals = real(runStateRoot());
138
+ const journalsExist = exists(journals);
139
+ if (journalsExist && !denyRead.includes(journals))
140
+ denyRead.push(journals);
141
+ const denyWrite = journalsExist ? [...masked, journals] : masked;
142
142
  const allowWrite = req.readOnly ? [opts.home] : [real(req.cwd), opts.home];
143
143
  return {
144
144
  network: { allowedDomains, deniedDomains: [], strictAllowlist: true, allowLocalBinding: false },