@sweny-ai/core 0.1.176 → 0.1.178

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 (49) hide show
  1. package/dist/agent-env.d.ts +9 -0
  2. package/dist/agent-env.js +4 -0
  3. package/dist/cli/comment-output.js +4 -5
  4. package/dist/cli/config-file.d.ts +37 -2
  5. package/dist/cli/config-file.js +68 -3
  6. package/dist/cli/final-output.d.ts +4 -1
  7. package/dist/cli/final-output.js +6 -8
  8. package/dist/cli/main.d.ts +2 -1
  9. package/dist/cli/main.js +18 -10
  10. package/dist/cli/resume.js +1 -0
  11. package/dist/cli/run-history.d.ts +3 -7
  12. package/dist/cli/run-history.js +5 -12
  13. package/dist/cli/run-output.d.ts +16 -10
  14. package/dist/cli/run-output.js +30 -16
  15. package/dist/cli/ticket.js +3 -2
  16. package/dist/cli/try.js +2 -3
  17. package/dist/decider.d.ts +176 -64
  18. package/dist/decider.js +475 -155
  19. package/dist/executor.d.ts +55 -7
  20. package/dist/executor.js +511 -99
  21. package/dist/harness/acp.js +2 -2
  22. package/dist/harness/claude-code.js +6 -4
  23. package/dist/harness/codex.js +2 -2
  24. package/dist/harness/pi.js +2 -2
  25. package/dist/index.d.ts +2 -2
  26. package/dist/index.js +2 -2
  27. package/dist/journal.d.ts +34 -5
  28. package/dist/journal.js +42 -13
  29. package/dist/safe-file.d.ts +74 -0
  30. package/dist/safe-file.js +211 -0
  31. package/dist/safe-outputs.d.ts +7 -1
  32. package/dist/safe-outputs.js +7 -1
  33. package/dist/schema.d.ts +189 -241
  34. package/dist/schema.js +56 -36
  35. package/dist/skills/git-push.d.ts +99 -10
  36. package/dist/skills/git-push.js +413 -52
  37. package/dist/skills/github.d.ts +19 -15
  38. package/dist/skills/github.js +91 -15
  39. package/dist/startup-env.d.ts +30 -0
  40. package/dist/startup-env.js +48 -0
  41. package/dist/types.d.ts +86 -4
  42. package/dist/types.js +22 -0
  43. package/dist/when.d.ts +8 -1
  44. package/dist/when.js +8 -1
  45. package/dist/workflow-builder.js +3 -1
  46. package/dist/workflows/browser.js +2 -2
  47. package/dist/workflows/implement.yml +37 -5
  48. package/dist/workflows/triage.yml +40 -6
  49. package/package.json +1 -1
@@ -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/index.d.ts CHANGED
@@ -27,8 +27,8 @@ export { EVALUATOR_KINDS, EVAL_POLICIES, REQUIRES_ON_FAIL, NODE_ON_FAIL, MCP_TRA
27
27
  export { consoleLogger } from "./types.js";
28
28
  export { execute, RouteEvaluationError } from "./executor.js";
29
29
  export type { ExecuteOptions } from "./executor.js";
30
- export { systemOneProvider, DecideError, gateVerdict, DECIDER_MIN_CONFIDENCE, DECIDER_MIN_MARGIN } from "./decider.js";
31
- export type { DecisionProvider, DeciderConfig, DeciderMode, DeciderRecord, SystemOneOptions } from "./decider.js";
30
+ export { systemOneProvider } from "./decider.js";
31
+ export type { DeciderConfig, DeciderOperatorConfig } from "./decider.js";
32
32
  export { BudgetGuard, describeOverrun, minLimits, toLimits } from "./budget.js";
33
33
  export type { Budget, BudgetOverrun, BudgetUnit, SpendLimits } from "./budget.js";
34
34
  export { resolveNodePermissions, buildNodePolicy } from "./node-policy.js";
package/dist/index.js CHANGED
@@ -27,8 +27,8 @@ export { EVALUATOR_KINDS, EVAL_POLICIES, REQUIRES_ON_FAIL, NODE_ON_FAIL, MCP_TRA
27
27
  export { consoleLogger } from "./types.js";
28
28
  // Executor
29
29
  export { execute, RouteEvaluationError } from "./executor.js";
30
- // Decision models, shadow mode (#357)
31
- export { systemOneProvider, DecideError, gateVerdict, DECIDER_MIN_CONFIDENCE, DECIDER_MIN_MARGIN } from "./decider.js";
30
+ // Decision models (#357): config types and the provider adapter only
31
+ export { systemOneProvider } from "./decider.js";
32
32
  // Least-privilege nodes and safe outputs (#365)
33
33
  export { BudgetGuard, describeOverrun, minLimits, toLimits } from "./budget.js";
34
34
  export { resolveNodePermissions, buildNodePolicy } from "./node-policy.js";
package/dist/journal.d.ts CHANGED
@@ -29,7 +29,7 @@
29
29
  * output:intent a write is about to be applied (idempotency key + tool)
30
30
  * output:applied the write returned (key + the ids it produced)
31
31
  * node:end final result of the visit and the write-stage counters
32
- * route the edge taken after the visit (null = the run ended)
32
+ * route the edge taken after the visit (null = the run ended), and the rung that chose it
33
33
  * usage spend of one agent attempt (cumulative; live while it runs, final when it returns)
34
34
  * run:end success | failed | crashed
35
35
  *
@@ -52,9 +52,10 @@
52
52
  * (the process died between the API call and the receipt) is looked up on the
53
53
  * provider by a marker embedded in the body before anything is re-sent.
54
54
  */
55
- import type { Logger, NodeResult, NodeUsage, Skill, Workflow } from "./types.js";
55
+ import type { Logger, NodeResult, NodeUsage, RouteRung, Skill, Workflow } from "./types.js";
56
56
  import type { SafeOutputIntent, WriteStageState } from "./safe-outputs.js";
57
57
  import { type Spend } from "./budget.js";
58
+ import type { DeciderCounters } from "./decider.js";
58
59
  /** v2: records are authenticated with a per-run HMAC key (v1 used a public checksum and is refused). */
59
60
  export declare const JOURNAL_SCHEMA_VERSION = 2;
60
61
  export declare const JOURNAL_DIR: string;
@@ -139,6 +140,10 @@ export interface RunStartRecord extends JournalRecord {
139
140
  sweny_version?: string;
140
141
  input: unknown;
141
142
  input_redacted: boolean;
143
+ decider?: {
144
+ mode: "on" | "off";
145
+ reason?: string;
146
+ };
142
147
  }
143
148
  export interface WriteStateSnapshot {
144
149
  counts: Array<[string, number]>;
@@ -218,6 +223,8 @@ export interface PlannedVisit {
218
223
  status?: NodeResult["status"];
219
224
  /** Route taken after the visit: a node id, null for run end, undefined when not journaled. */
220
225
  next?: string | null;
226
+ /** Who chose that route, when it was a decision (#357). */
227
+ rung?: RouteRung;
221
228
  /** Writes the checkpointed write stage already applied (receipts in the journal). */
222
229
  applied: number;
223
230
  /** Writes with an intent but no receipt: checked on the provider before anything is re-sent. */
@@ -243,6 +250,8 @@ export interface ResumePlan {
243
250
  receipts: Map<string, OutputRecord>;
244
251
  /** Spend every earlier attempt journaled: the run budget starts here on resume. */
245
252
  priorSpend: Spend;
253
+ /** The decider's mode at run start and its counters after the last journaled route (#357). */
254
+ decider?: JournalDeciderState;
246
255
  }
247
256
  /**
248
257
  * Check that the records are a sequence the executor can write: one run:start
@@ -281,6 +290,21 @@ export interface JournalBeginInfo {
281
290
  config: Record<string, string>;
282
291
  writeState: WriteStageState;
283
292
  harnessId?: string;
293
+ /** Whether the run's decider ran (#357), journaled so a resume keeps the same mode. */
294
+ decider?: {
295
+ mode: "on" | "off";
296
+ reason?: string;
297
+ };
298
+ }
299
+ /**
300
+ * The decider as an earlier attempt left it (#357): its mode at run start and
301
+ * the breaker / cap counters after the last journaled route. Cap and breaker
302
+ * are per logical run, so a resume continues them.
303
+ */
304
+ export interface JournalDeciderState {
305
+ mode: "on" | "off";
306
+ reason?: string;
307
+ counters?: DeciderCounters;
284
308
  }
285
309
  export interface JournalCheckpoint {
286
310
  result: NodeResult;
@@ -292,6 +316,7 @@ export type JournalReplay = {
292
316
  kind: "complete";
293
317
  result: NodeResult;
294
318
  next?: string | null;
319
+ rung?: RouteRung;
295
320
  } | ({
296
321
  kind: "checkpoint";
297
322
  } & JournalCheckpoint);
@@ -308,7 +333,8 @@ export interface ExecutionJournal {
308
333
  /** The skill map the write stage applies through: write tools journaled and made idempotent. */
309
334
  wrapWrites(node: string, iteration: number, skills: Map<string, Skill>): Map<string, Skill>;
310
335
  nodeEnd(node: string, iteration: number, result: NodeResult, writeState: WriteStageState): void;
311
- route(from: string, to: string | null): void;
336
+ /** The edge taken after a visit, and for a decision the rung that chose it (#357), so resume replays it. */
337
+ route(from: string, to: string | null, rung?: RouteRung, decider?: DeciderCounters): void;
312
338
  /**
313
339
  * Spend of one agent attempt, cumulative for the attempt: `final` when the
314
340
  * attempt returned, otherwise a live report (the journal may throttle those).
@@ -316,6 +342,8 @@ export interface ExecutionJournal {
316
342
  usage?(node: string, iteration: number, attempt: number, usage: NodeUsage, final: boolean): void;
317
343
  /** Spend earlier attempts of this run already journaled (resume); the run budget starts from it. */
318
344
  priorSpend?(): Spend | undefined;
345
+ /** The decider state earlier attempts of this run journaled (resume); undefined otherwise. */
346
+ deciderResume?(): JournalDeciderState | undefined;
319
347
  }
320
348
  /** Minimum gap between two live usage records of one attempt. Final reports are always written. */
321
349
  export declare const USAGE_JOURNAL_INTERVAL_MS = 2000;
@@ -382,18 +410,19 @@ export declare class RunJournal implements ExecutionJournal {
382
410
  /** False once an append failed: the rest of the run is not journaled. */
383
411
  get active(): boolean;
384
412
  private takeLock;
385
- /** The run dir is private (0700); its parents are created as usual. */
413
+ /** The run dir is private (0700); no link on the path from cwd is followed (safe-file.ts). */
386
414
  private makeDir;
387
415
  private releaseLock;
388
416
  private append;
389
417
  private closeFd;
390
418
  private clean;
391
419
  begin(info: JournalBeginInfo): void;
420
+ deciderResume(): JournalDeciderState | undefined;
392
421
  replay(node: string, iteration: number): JournalReplay | undefined;
393
422
  nodeStart(node: string, iteration: number): void;
394
423
  checkpoint(node: string, iteration: number, cp: JournalCheckpoint): void;
395
424
  nodeEnd(node: string, iteration: number, result: NodeResult, writeState: WriteStageState): void;
396
- route(from: string, to: string | null): void;
425
+ route(from: string, to: string | null, rung?: RouteRung, decider?: DeciderCounters): void;
397
426
  usage(node: string, iteration: number, attempt: number, usage: NodeUsage, final: boolean): void;
398
427
  priorSpend(): Spend | undefined;
399
428
  /** Close the run: `run:end`, then release the lock. Safe to call twice (one run:end). */
package/dist/journal.js CHANGED
@@ -29,7 +29,7 @@
29
29
  * output:intent a write is about to be applied (idempotency key + tool)
30
30
  * output:applied the write returned (key + the ids it produced)
31
31
  * node:end final result of the visit and the write-stage counters
32
- * route the edge taken after the visit (null = the run ended)
32
+ * route the edge taken after the visit (null = the run ended), and the rung that chose it
33
33
  * usage spend of one agent attempt (cumulative; live while it runs, final when it returns)
34
34
  * run:end success | failed | crashed
35
35
  *
@@ -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");
@@ -292,13 +293,15 @@ export function redact(value, secrets = []) {
292
293
  return v.map((x) => walk(x, depth + 1));
293
294
  if (v && typeof v === "object") {
294
295
  const out = {};
296
+ // defineProperty, not assignment: a key named `__proto__` stays a plain key.
297
+ const put = (k, x) => Object.defineProperty(out, k, { value: x, enumerable: true, writable: true, configurable: true });
295
298
  for (const [k, x] of Object.entries(v)) {
296
299
  if (typeof x === "string" && isSecretKey(k) && x.length > 0) {
297
- out[k] = REDACTED;
300
+ put(k, REDACTED);
298
301
  redacted = true;
299
302
  }
300
303
  else if (x !== undefined && typeof x !== "function") {
301
- out[k] = walk(x, depth + 1);
304
+ put(k, walk(x, depth + 1));
302
305
  }
303
306
  }
304
307
  return out;
@@ -466,6 +469,13 @@ const RECORD_TYPES = new Set([
466
469
  const isPosInt = (v) => typeof v === "number" && Number.isInteger(v) && v > 0;
467
470
  const isName = (v) => typeof v === "string" && v.length > 0;
468
471
  const isAmount = (v) => typeof v === "number" && Number.isFinite(v) && v >= 0;
472
+ const isRung = (v) => v === "expr" || v === "decider" || v === "agent";
473
+ const isCount = (v) => typeof v === "number" && Number.isInteger(v) && v >= 0;
474
+ const isCounters = (v) => typeof v === "object" &&
475
+ v !== null &&
476
+ isCount(v.calls) &&
477
+ isCount(v.failures) &&
478
+ typeof v.open === "boolean";
469
479
  /**
470
480
  * Check that the records are a sequence the executor can write: one run:start
471
481
  * first; nothing but run:resume after run:end; a visit starts only where the
@@ -582,6 +592,10 @@ export function checkRecordSequence(records) {
582
592
  bad(r, `routes from ${String(r.from)}, but the visit that ended is ${cur.node}`);
583
593
  if (r.to !== null && !isName(r.to))
584
594
  bad(r, "has no valid target");
595
+ if (r.rung !== undefined && !isRung(r.rung))
596
+ bad(r, "has an unknown rung");
597
+ if (r.decider !== undefined && !isCounters(r.decider))
598
+ bad(r, "has invalid decider counters");
585
599
  cur.routed = true;
586
600
  next = r.to;
587
601
  break;
@@ -662,6 +676,7 @@ export function buildResumePlan(records) {
662
676
  const receipts = new Map();
663
677
  let attempts = 1;
664
678
  let lastStatus;
679
+ let deciderCounters;
665
680
  for (const r of records) {
666
681
  switch (r.type) {
667
682
  case "run:resume":
@@ -680,6 +695,7 @@ export function buildResumePlan(records) {
680
695
  // Re-run of a visit an earlier attempt did not finish (or finished failed).
681
696
  delete v.end;
682
697
  delete v.next;
698
+ delete v.rung;
683
699
  }
684
700
  break;
685
701
  }
@@ -698,8 +714,14 @@ export function buildResumePlan(records) {
698
714
  case "route": {
699
715
  const from = r.from;
700
716
  const v = [...order].reverse().find((x) => x.node === from);
701
- if (v)
717
+ if (v) {
702
718
  v.next = r.to ?? null;
719
+ if (isRung(r.rung))
720
+ v.rung = r.rung;
721
+ }
722
+ // Calls already made count even if the visit later re-runs.
723
+ if (isCounters(r.decider))
724
+ deciderCounters = r.decider;
703
725
  break;
704
726
  }
705
727
  case "output:intent":
@@ -732,6 +754,7 @@ export function buildResumePlan(records) {
732
754
  action: "replay",
733
755
  status: v.end.result.status,
734
756
  next: v.next,
757
+ ...(v.rung ? { rung: v.rung } : {}),
735
758
  applied,
736
759
  unconfirmed,
737
760
  });
@@ -775,6 +798,9 @@ export function buildResumePlan(records) {
775
798
  intents,
776
799
  receipts,
777
800
  priorSpend: journaledSpend(records),
801
+ ...(start.decider
802
+ ? { decider: { ...start.decider, ...(deciderCounters ? { counters: { ...deciderCounters } } : {}) } }
803
+ : {}),
778
804
  };
779
805
  }
780
806
  /**
@@ -1005,12 +1031,11 @@ export class RunJournal {
1005
1031
  throw err;
1006
1032
  }
1007
1033
  this.makeDir();
1008
- fs.writeFileSync(lock, String(process.pid), { mode: 0o600 });
1034
+ writeFileNoFollow(lock, String(process.pid), { root: this.cwd, mode: 0o600 });
1009
1035
  }
1010
- /** The run dir is private (0700); its parents are created as usual. */
1036
+ /** The run dir is private (0700); no link on the path from cwd is followed (safe-file.ts). */
1011
1037
  makeDir() {
1012
- fs.mkdirSync(path.dirname(this.dir), { recursive: true });
1013
- fs.mkdirSync(this.dir, { recursive: true, mode: 0o700 });
1038
+ ensureDirNoFollow(this.dir, this.cwd);
1014
1039
  }
1015
1040
  releaseLock() {
1016
1041
  try {
@@ -1052,8 +1077,7 @@ export class RunJournal {
1052
1077
  try {
1053
1078
  if (this.fd === undefined) {
1054
1079
  this.makeDir();
1055
- this.fd = fs.openSync(this.file, "a", 0o600);
1056
- fs.fchmodSync(this.fd, 0o600);
1080
+ this.fd = openNoFollow(this.file, { root: this.cwd, append: true, mode: 0o600 });
1057
1081
  }
1058
1082
  fs.writeSync(this.fd, JSON.stringify(record) + "\n");
1059
1083
  fs.fsyncSync(this.fd);
@@ -1126,7 +1150,7 @@ export class RunJournal {
1126
1150
  try {
1127
1151
  this.makeDir();
1128
1152
  // 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");
1153
+ writeFileNoFollow(path.join(this.dir, ".gitignore"), "*\n", { root: this.cwd, mode: 0o644 });
1130
1154
  this.takeLock();
1131
1155
  }
1132
1156
  catch {
@@ -1154,8 +1178,12 @@ export class RunJournal {
1154
1178
  ...(this.opts.swenyVersion ? { sweny_version: this.opts.swenyVersion } : {}),
1155
1179
  input: input.value,
1156
1180
  input_redacted: input.redacted,
1181
+ ...(info.decider ? { decider: info.decider } : {}),
1157
1182
  });
1158
1183
  }
1184
+ deciderResume() {
1185
+ return this.resume?.plan.decider;
1186
+ }
1159
1187
  replay(node, iteration) {
1160
1188
  const plan = this.resume?.plan;
1161
1189
  if (!plan)
@@ -1173,6 +1201,7 @@ export class RunJournal {
1173
1201
  kind: "complete",
1174
1202
  result: structuredClone(end.result),
1175
1203
  ...(visit.next !== undefined ? { next: visit.next } : {}),
1204
+ ...(visit.rung ? { rung: visit.rung } : {}),
1176
1205
  };
1177
1206
  }
1178
1207
  if (visit.action === "write-stage") {
@@ -1210,8 +1239,8 @@ export class RunJournal {
1210
1239
  write_state: { counts: [...writeState.counts], total: writeState.total, seen: [...writeState.seen] },
1211
1240
  });
1212
1241
  }
1213
- route(from, to) {
1214
- this.append("route", { from, to });
1242
+ route(from, to, rung, decider) {
1243
+ this.append("route", { from, to, ...(rung ? { rung } : {}), ...(decider ? { decider } : {}) });
1215
1244
  }
1216
1245
  usage(node, iteration, attempt, usage, final) {
1217
1246
  const s = spendOf(usage);
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Files the sweny process writes into an agent-writable workspace.
3
+ *
4
+ * An agent can leave a symlink or a hard link where sweny is about to write
5
+ * (`.sweny/runs/<id>/output.md` -> `~/.bashrc`), or swap a directory on the
6
+ * path for a link. These helpers never follow a link: every directory between
7
+ * the trusted root and the file must be a real directory owned by this user,
8
+ * the final component is opened `O_NOFOLLOW`, and the file written must be a
9
+ * regular file with one link (a replaced link or special file is unlinked and
10
+ * created fresh with `O_EXCL`). Data goes through the descriptor that was
11
+ * checked; the mode is set on it.
12
+ *
13
+ * Race with a process that swaps a checked directory for a link (TOCTOU):
14
+ * Node has no `openat(2)`, so the parent chain is checked by pathname and the
15
+ * file opened by pathname. After the open, {@link assertOpenedUnder} re-checks
16
+ * the chain (realpath equals the lexical path, every component a real owned
17
+ * directory) and requires that the pathname now names the very inode the
18
+ * descriptor holds (dev and ino). A swap before the open either leaves a link
19
+ * on the path or moves the opened inode off it; both fail closed before a byte
20
+ * is written. Residual risk: an `O_CREAT` through a swapped directory can leave
21
+ * an empty file outside the workspace before the check fails, and the unlink of
22
+ * a planted link or special file is by pathname (checked just before, not
23
+ * atomic). A regular file is never unlinked or truncated by pathname.
24
+ *
25
+ * Node only.
26
+ */
27
+ /**
28
+ * Create `dir` and its missing parents below `root`, checking each component
29
+ * under `root` with lstat: a real directory, owned by this user. `root` itself
30
+ * is trusted. A `dir` outside `root` is created as usual and only its last
31
+ * component is checked.
32
+ */
33
+ export declare function ensureDirNoFollow(dir: string, root: string, mode?: number): void;
34
+ export interface NoFollowOptions {
35
+ /**
36
+ * Trusted base dir (the workspace root): every component below it, and the
37
+ * file, must be owned by this user. Default: the file's own dir, with no
38
+ * ownership check (a runner-owned GITHUB_STEP_SUMMARY, say).
39
+ */
40
+ root?: string;
41
+ /** File mode, set on the descriptor. Default 0o600. */
42
+ mode?: number;
43
+ /** Mode for directories created on the way. Default 0o700. */
44
+ dirMode?: number;
45
+ /** Append to an existing regular file instead of replacing it. */
46
+ append?: boolean;
47
+ /** Create missing directories. Default true. */
48
+ mkdirs?: boolean;
49
+ }
50
+ /**
51
+ * Open `file` for writing without following a link. Replace mode unlinks what
52
+ * is there and creates a fresh file; append mode refuses anything but a
53
+ * regular, singly linked file (owned by this user when under `root`).
54
+ * Returns the descriptor; the caller closes it.
55
+ */
56
+ /**
57
+ * Fail unless `fd` is the file `file` names now, below `root`, with no link
58
+ * anywhere between them: the directory's realpath is the realpath of `root`
59
+ * plus the same relative path, each component under `root` is a real
60
+ * directory (owned by this user when `owned`), and `lstat(file)` has the
61
+ * descriptor's dev and ino. See the module comment for the race it closes.
62
+ */
63
+ export declare function assertOpenedUnder(fd: number, file: string, root: string, owned?: boolean): void;
64
+ export declare function openNoFollow(file: string, opts?: NoFollowOptions): number;
65
+ /**
66
+ * `{ root: cwd }` when `file` is inside `cwd` (the workspace, where the agent
67
+ * can write), else `{}`: an operator path elsewhere is only opened without
68
+ * following a final link.
69
+ */
70
+ export declare function workspaceRoot(file: string, cwd?: string): {
71
+ root?: string;
72
+ };
73
+ /** Write (or append) `data` to `file` through {@link openNoFollow}. Throws on any refusal. */
74
+ export declare function writeFileNoFollow(file: string, data: string | Uint8Array, opts?: NoFollowOptions): void;