@sweny-ai/core 0.1.143 → 0.1.145

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.
package/dist/cli/main.js CHANGED
@@ -964,6 +964,7 @@ export async function workflowRunAction(file, options) {
964
964
  fileRoot: config.fileRoot || undefined,
965
965
  signal,
966
966
  max_steps: wfMaxSteps,
967
+ stageOutputs: options.stage === true,
967
968
  }), wfTimeoutMs, `Workflow ${workflow.name}`);
968
969
  const wfDurationMs = Date.now() - runStart;
969
970
  const wfHasFailed = [...results.values()].some((r) => r.status === "failed");
@@ -198,6 +198,10 @@ export const WORKFLOW_RUN_OPTIONS = [
198
198
  "--dry-run",
199
199
  "Run with read-only tools only: write tools, external MCP servers, and shell/file-edit tools are withheld, so nothing is created, posted, or sent. Stops at the first conditional edge. To inspect nodes without running, use --list-nodes.",
200
200
  ],
201
+ [
202
+ "--stage",
203
+ "Run normally, but preview every safe output (nodes with outputs:) instead of writing it: print what would be written and write nothing",
204
+ ],
201
205
  ["--list-nodes", "Validate, print nodes and skills, and exit without running"],
202
206
  ["--json", "Output result as JSON on stdout; suppress progress output"],
203
207
  ["--stream", "Stream NDJSON events to stdout (for Studio / automation)"],
@@ -14,6 +14,7 @@
14
14
  */
15
15
  import type { Workflow, Skill, Tool, Claude, Observer, Logger, ExecutionResult } from "./types.js";
16
16
  import type { AgentHarness } from "./harness/types.js";
17
+ import { type ActorInfo } from "./safe-outputs.js";
17
18
  export interface ExecuteOptions {
18
19
  /** Registered skills (id → Skill) */
19
20
  skills: Map<string, Skill>;
@@ -73,6 +74,17 @@ export interface ExecuteOptions {
73
74
  * whole-run budget. Default: no timeout (back-compat).
74
75
  */
75
76
  timeoutMs?: number;
77
+ /**
78
+ * Safe outputs (#365): preview every write and apply none (CLI `--stage`).
79
+ * A dry run always stages. Default: false.
80
+ */
81
+ stageOutputs?: boolean;
82
+ /**
83
+ * Who triggered the run, for `safe_outputs.trusted_actors` /
84
+ * `trusted_associations`. Default: `GITHUB_ACTOR` and the author
85
+ * association in the `GITHUB_EVENT_PATH` payload.
86
+ */
87
+ actor?: ActorInfo;
76
88
  }
77
89
  /**
78
90
  * Default workflow-level step budget. Counts total node executions across the
package/dist/executor.js CHANGED
@@ -23,7 +23,9 @@ import { validateWorkflow } from "./schema.js";
23
23
  import { resolveAgentAccess } from "./agent-env.js";
24
24
  import { fenceUntrusted } from "./untrusted.js";
25
25
  import { asClaude } from "./harness/compat.js";
26
- import { isToolClass } from "./harness/policy.js";
26
+ import { isToolClass, policyGate } from "./harness/policy.js";
27
+ import { buildNodePolicy, resolveNodePermissions } from "./node-policy.js";
28
+ import { applySafeOutputs, createEmitOutputTool, createWriteStageState, resolveActor, safeOutputsInstruction, unresolvedOutputs, SAFE_OUTPUT_SCREEN_INSTRUCTION, } from "./safe-outputs.js";
27
29
  /**
28
30
  * Default workflow-level step budget. Counts total node executions across the
29
31
  * whole run (loop iterations included). Generous enough for legitimate deep
@@ -71,6 +73,13 @@ export async function execute(workflow, input, options) {
71
73
  let stepCount = 0; // total node executions across the run (loop iterations included)
72
74
  const trace = { steps: [], edges: [], sources: {} };
73
75
  validate(workflow, skills);
76
+ // Safe outputs (#365): run-wide caps and dedupe, the staged switch and the
77
+ // triggering actor, resolved once per run.
78
+ const writeState = createWriteStageState();
79
+ const stageOutputs = dryRun || options.stageOutputs === true || workflow.safe_outputs?.staged === true;
80
+ const runEnv = options.env ?? process.env;
81
+ const usesOutputs = Object.values(workflow.nodes).some((n) => (n.outputs?.length ?? 0) > 0);
82
+ const actor = usesOutputs ? resolveActor(runEnv, options.actor) : {};
74
83
  // Build an eval-time alias table from the loaded skills. Each skill owns
75
84
  // its own mapping between skill-tool names and equivalent MCP names. Core
76
85
  // stays vendor-neutral; this call just unions what the skills declare.
@@ -150,11 +159,32 @@ export async function execute(workflow, input, options) {
150
159
  // Dry run (#380): only `access: "read"` tools reach the node. Write and
151
160
  // unclassified tools are withheld (never registered, so never callable)
152
161
  // and recorded on the node result as `skippedWrites`.
153
- const tools = dryRun ? filteredTools.filter(isReadTool) : filteredTools;
162
+ // #365: `permissions: read` (explicit, inherited, or implied by `outputs`)
163
+ // gets the same tool gate as a dry run.
164
+ const permissions = resolveNodePermissions(node, workflow);
165
+ const declaresPolicy = node.permissions !== undefined || workflow.permissions !== undefined || (node.outputs?.length ?? 0) > 0;
166
+ const readOnlyNode = dryRun || permissions.access === "read";
167
+ const readTools = readOnlyNode ? filteredTools.filter(isReadTool) : filteredTools;
154
168
  const skippedWrites = dryRun ? filteredTools.filter((t) => !isReadTool(t)).map((t) => t.name) : [];
155
169
  if (skippedWrites.length > 0) {
156
170
  logger.info(` dry run: withheld write tools: ${skippedWrites.join(", ")}`, { node: currentId });
157
171
  }
172
+ else if (readOnlyNode && readTools.length < filteredTools.length) {
173
+ const withheld = filteredTools.filter((t) => !isReadTool(t)).map((t) => t.name);
174
+ logger.info(` permissions: read: withheld write tools: ${withheld.join(", ")}`, { node: currentId });
175
+ }
176
+ // Safe outputs (#365): the agent records write intents with emit_output;
177
+ // the write stage after the node applies them. One buffer per attempt.
178
+ const outputDecls = node.outputs ?? [];
179
+ const intents = [];
180
+ const tools = outputDecls.length > 0 ? [...readTools, createEmitOutputTool(outputDecls, intents)] : readTools;
181
+ if (outputDecls.length > 0 && !stageOutputs) {
182
+ const missing = unresolvedOutputs(outputDecls, node.skills, skills);
183
+ if (missing.length > 0) {
184
+ throw new Error(`Node "${currentId}" declares outputs [${missing.join(", ")}] but no configured skill can apply them ` +
185
+ `(github or linear). Set the skill's environment variables, or run with --stage to preview.`);
186
+ }
187
+ }
158
188
  const skillInstructions = resolveSkillInstructions(node.skills, skills);
159
189
  const skillMcpServers = resolveSkillMcpServers(node.skills, skills);
160
190
  // Runtime guard: if this node declares skills but none resolved, the node
@@ -227,7 +257,8 @@ export async function execute(workflow, input, options) {
227
257
  const effectiveRules = assembleCascaded("rules", currentId, workflow.nodes[currentId], inputRules.length, (workflow.rules ?? []).length, resolvedSources);
228
258
  const effectiveContext = assembleCascaded("context", currentId, workflow.nodes[currentId], inputContext.length, (workflow.context ?? []).length, resolvedSources);
229
259
  const baseInstruction = buildNodeInstruction(resolvedInstruction, effectiveRules, effectiveContext, input, skillInstructions);
230
- const instruction = dryRun ? `${dryRunNotice(skippedWrites)}\n\n---\n\n${baseInstruction}` : baseInstruction;
260
+ const nodeInstruction = outputDecls.length > 0 ? `${baseInstruction}\n\n${safeOutputsInstruction(outputDecls)}` : baseInstruction;
261
+ const instruction = dryRun ? `${dryRunNotice(skippedWrites)}\n\n---\n\n${nodeInstruction}` : nodeInstruction;
231
262
  // Run Claude on this node, with optional eval-failure retry loop.
232
263
  let attempt = 0;
233
264
  let result;
@@ -246,7 +277,38 @@ export async function execute(workflow, input, options) {
246
277
  // compiles them or reports them in `degraded`.
247
278
  const denyClasses = (node.tools?.deny ?? []).filter(isToolClass);
248
279
  let degradedLogged = false;
280
+ const agentAccess = resolveAgentAccess(node.skills, skills);
281
+ // #365: a node or workflow that declares `permissions` or `outputs` gets
282
+ // one portable policy, compiled by each adapter. The gate also runs here so
283
+ // a strict refusal holds on every harness, before any spend. Nodes that
284
+ // declare neither pass no policy, so each harness keeps its own defaults.
285
+ const nodePolicy = declaresPolicy
286
+ ? buildNodePolicy({
287
+ permissions: { ...permissions, deny: [...new Set([...permissions.deny, ...denyClasses])] },
288
+ dryRun,
289
+ disallowedTools: node.disallowed_tools,
290
+ egress: agentAccess.domains,
291
+ })
292
+ : undefined;
293
+ // Egress is left to the adapter: only it knows whether a process sandbox
294
+ // wrapper covers what its native sandbox cannot.
295
+ const gate = nodePolicy && options.harness
296
+ ? policyGate(options.harness.capabilities, { ...nodePolicy, egress: [] })
297
+ : undefined;
249
298
  while (true) {
299
+ if (gate?.refuse) {
300
+ // Not an agent failure: fail_soft never softens a policy refusal.
301
+ logger.warn(` harness refused the node: ${gate.refuse}`, { node: currentId });
302
+ result = {
303
+ status: "failed",
304
+ data: { error: gate.refuse, refused: true },
305
+ toolCalls: [],
306
+ degraded: gate.degraded,
307
+ };
308
+ break;
309
+ }
310
+ // Only the final attempt's intents may be applied.
311
+ intents.length = 0;
250
312
  result = await claude.run({
251
313
  instruction: currentInstruction,
252
314
  context,
@@ -258,9 +320,10 @@ export async function execute(workflow, input, options) {
258
320
  model: nodeModel,
259
321
  signal,
260
322
  timeoutMs,
261
- agentAccess: resolveAgentAccess(node.skills, skills),
262
- ...(dryRun ? {} : { mcpServers: skillMcpServers }),
263
- ...(dryRun ? { readOnly: true } : {}),
323
+ agentAccess,
324
+ ...(nodePolicy ? { policy: nodePolicy } : {}),
325
+ ...(readOnlyNode ? {} : { mcpServers: skillMcpServers }),
326
+ ...(readOnlyNode ? { readOnly: true } : {}),
264
327
  onProgress: (message) => {
265
328
  safeObserve(observer, { type: "node:progress", node: currentId, message }, logger);
266
329
  },
@@ -381,6 +444,45 @@ export async function execute(workflow, input, options) {
381
444
  data: { ...(result.data ?? {}), fail_soft: true },
382
445
  };
383
446
  }
447
+ // Safe outputs write stage (#365). Runs only after the agent finished and
448
+ // every eval passed; deterministic checks first, an optional model screen
449
+ // may veto, and only then does sweny call the skill's own write handler.
450
+ if (outputDecls.length > 0) {
451
+ if (result.status === "success" && !agentRunFailed) {
452
+ const stage = await applySafeOutputs({
453
+ nodeId: currentId,
454
+ declarations: outputDecls,
455
+ intents,
456
+ policy: workflow.safe_outputs,
457
+ nodeSkills: node.skills,
458
+ skills,
459
+ config,
460
+ env: runEnv,
461
+ actor,
462
+ staged: stageOutputs,
463
+ state: writeState,
464
+ logger,
465
+ screen: async (writes) => claude.ask({
466
+ instruction: SAFE_OUTPUT_SCREEN_INSTRUCTION,
467
+ context: { writes },
468
+ model: nodeModel,
469
+ signal,
470
+ timeoutMs,
471
+ }),
472
+ });
473
+ result = { ...result, outputs: stage.receipts };
474
+ if (stage.error) {
475
+ logger.warn(` ${stage.error}`, { node: currentId });
476
+ result = { ...result, status: "failed", data: { ...result.data, error: stage.error } };
477
+ }
478
+ }
479
+ else if (intents.length > 0) {
480
+ result = {
481
+ ...result,
482
+ outputs: intents.map((i) => ({ type: i.type, status: "skipped", reason: "node did not succeed" })),
483
+ };
484
+ }
485
+ }
384
486
  if (skippedWrites.length > 0)
385
487
  result = { ...result, skippedWrites };
386
488
  results.set(currentId, result);
@@ -10,7 +10,7 @@
10
10
  */
11
11
  import type { Claude, Tool, ToolContext, Logger, McpServerConfig } from "../types.js";
12
12
  import { type SandboxMode } from "../agent-env.js";
13
- import type { AgentHarness, HarnessCapabilities, HarnessCompleteRequest, HarnessInfo, HarnessRunRequest, HarnessRunResult, ToolClass } from "./types.js";
13
+ import type { AgentHarness, HarnessCapabilities, HarnessCompleteRequest, HarnessInfo, HarnessRunRequest, HarnessRunResult, NodePolicy, ToolClass } from "./types.js";
14
14
  import { CLAUDE_CODE_CAPABILITIES } from "./capabilities.js";
15
15
  import { buildEvaluatePrompt } from "./prompts.js";
16
16
  import { parseToolResultContent, summarizeToolError } from "./parse.js";
@@ -42,6 +42,8 @@ export declare const READ_ONLY_DISALLOWED_TOOLS: readonly ["Bash", "Write", "Edi
42
42
  * `tools.deny` classes compile to these names on `disallowedTools`.
43
43
  */
44
44
  export declare const CLAUDE_TOOLS_BY_CLASS: Readonly<Record<ToolClass, readonly string[]>>;
45
+ /** Native `disallowedTools` for a policy: legacy names, compiled classes, and the read-only set. */
46
+ export declare function compileClaudeCodeDeny(policy: NodePolicy, legacy?: readonly string[]): string[];
45
47
  /** How sweny resolves which credentials reach the Claude Code subprocess. */
46
48
  export type SwenyAuthMode = "auto" | "api-key" | "oauth";
47
49
  export interface ResolveAuthEnvOpts {
@@ -165,9 +167,15 @@ export declare class ClaudeCodeHarness implements Claude, AgentHarness {
165
167
  version: string;
166
168
  }>;
167
169
  /**
168
- * Run a node. The query itself is unchanged by the harness seam; this only
169
- * runs {@link policyGate} (always `degraded: []` for Claude Code) and tags
170
- * the result with the harness id and version.
170
+ * Run a node. Runs {@link policyGate} (always `degraded: []` for Claude
171
+ * Code), compiles the policy to native options, and tags the result with the
172
+ * harness id and version.
173
+ *
174
+ * Policy compile (#365): `policy.readOnly` (or the legacy `readOnly` flag)
175
+ * is a read-only run; `policy.deny` classes become native `disallowedTools`
176
+ * names, merged with `nativeDeny` and the legacy `disallowedTools`; and
177
+ * `policy.strict` makes MCP exclusive (`strictMcpConfig`) even for a
178
+ * write-capable node.
171
179
  */
172
180
  run(req: HarnessRunRequest): Promise<HarnessRunResult>;
173
181
  private runQuery;
@@ -85,6 +85,17 @@ export const CLAUDE_TOOLS_BY_CLASS = {
85
85
  net: ["WebFetch", "WebSearch"],
86
86
  subagent: ["Task", "Agent"],
87
87
  };
88
+ /** Native `disallowedTools` for a policy: legacy names, compiled classes, and the read-only set. */
89
+ export function compileClaudeCodeDeny(policy, legacy = []) {
90
+ return [
91
+ ...new Set([
92
+ ...legacy,
93
+ ...(policy.nativeDeny ?? []),
94
+ ...policy.deny.flatMap((c) => CLAUDE_TOOLS_BY_CLASS[c] ?? []),
95
+ ...(policy.readOnly ? READ_ONLY_DISALLOWED_TOOLS : []),
96
+ ]),
97
+ ];
98
+ }
88
99
  /**
89
100
  * Decide which auth credentials survive into the Claude Code subprocess env.
90
101
  *
@@ -253,9 +264,15 @@ export class ClaudeCodeHarness {
253
264
  return { ok: true, version: claudeSdkVersion() };
254
265
  }
255
266
  /**
256
- * Run a node. The query itself is unchanged by the harness seam; this only
257
- * runs {@link policyGate} (always `degraded: []` for Claude Code) and tags
258
- * the result with the harness id and version.
267
+ * Run a node. Runs {@link policyGate} (always `degraded: []` for Claude
268
+ * Code), compiles the policy to native options, and tags the result with the
269
+ * harness id and version.
270
+ *
271
+ * Policy compile (#365): `policy.readOnly` (or the legacy `readOnly` flag)
272
+ * is a read-only run; `policy.deny` classes become native `disallowedTools`
273
+ * names, merged with `nativeDeny` and the legacy `disallowedTools`; and
274
+ * `policy.strict` makes MCP exclusive (`strictMcpConfig`) even for a
275
+ * write-capable node.
259
276
  */
260
277
  async run(req) {
261
278
  const policy = req.policy ?? {
@@ -266,10 +283,25 @@ export class ClaudeCodeHarness {
266
283
  strict: false,
267
284
  };
268
285
  const gate = policyGate(this.capabilities, policy);
269
- // Portable deny classes (`tools.deny: [write]`) compile to Claude Code tool names.
270
- const classNames = policy.deny.flatMap((c) => CLAUDE_TOOLS_BY_CLASS[c] ?? []);
271
- const disallowedTools = classNames.length > 0 ? [...new Set([...(req.disallowedTools ?? []), ...classNames])] : req.disallowedTools;
272
- const result = await this.runQuery({ ...req, disallowedTools });
286
+ if (gate.refuse) {
287
+ this.logger.error(gate.refuse);
288
+ return {
289
+ status: "failed",
290
+ // `refused` keeps fail_soft from softening a policy refusal (executor.ts).
291
+ data: { error: gate.refuse, refused: true },
292
+ toolCalls: [],
293
+ harness: this.info(),
294
+ degraded: gate.degraded,
295
+ };
296
+ }
297
+ const readOnly = !!req.readOnly || policy.readOnly;
298
+ const disallowedTools = compileClaudeCodeDeny(policy, req.disallowedTools ?? []);
299
+ const result = await this.runQuery({
300
+ ...req,
301
+ readOnly,
302
+ disallowedTools: disallowedTools.length > 0 ? disallowedTools : undefined,
303
+ strictMcp: readOnly || policy.strict,
304
+ });
273
305
  return { ...result, harness: this.info(), degraded: gate.degraded };
274
306
  }
275
307
  async runQuery(opts) {
@@ -396,7 +428,8 @@ export class ClaudeCodeHarness {
396
428
  // project and local settings, including their MCP servers (and
397
429
  // project .mcp.json, plugins). strictMcpConfig limits MCP to the
398
430
  // servers passed above, which under readOnly is only sweny-core.
399
- ...(readOnly ? { strictMcpConfig: true } : {}),
431
+ // A strict policy (#365) makes it exclusive for write nodes too.
432
+ ...(readOnly || opts.strictMcp ? { strictMcpConfig: true } : {}),
400
433
  ...(disallowedTools && disallowedTools.length > 0 ? { disallowedTools } : {}),
401
434
  // CC-08: ask the SDK to produce validated structured output when the
402
435
  // node declares an output schema. The SDK then returns the parsed
@@ -338,13 +338,17 @@ export class CodexHarness {
338
338
  }
339
339
  /** The node policy after `disallowed_tools` names are translated into Codex classes. */
340
340
  compilePolicy(req, maxTurns) {
341
- const base = req.policy ?? {
342
- readOnly: !!req.readOnly,
343
- deny: req.deny ?? [],
344
- nativeDeny: req.disallowedTools,
345
- egress: req.agentAccess?.domains ?? [],
346
- strict: this.policyMode === "strict",
347
- };
341
+ // A node policy (#365) can make the run stricter, never looser than the
342
+ // harness policy mode.
343
+ const base = req.policy
344
+ ? { ...req.policy, strict: req.policy.strict || this.policyMode === "strict" }
345
+ : {
346
+ readOnly: !!req.readOnly,
347
+ deny: req.deny ?? [],
348
+ nativeDeny: req.disallowedTools,
349
+ egress: req.agentAccess?.domains ?? [],
350
+ strict: this.policyMode === "strict",
351
+ };
348
352
  const { classes, unknown } = translateDenyNames(base.nativeDeny);
349
353
  return {
350
354
  ...base,
@@ -19,7 +19,10 @@
19
19
  * host network except through that proxy.
20
20
  * - filesystem: the workspace (cwd) and a scratch HOME are writable; the rest
21
21
  * of the filesystem is read-only; the operator's credential files are
22
- * unreadable. Dry runs (`readOnly`) leave the workspace read-only too.
22
+ * unreadable. Sibling homes under the same configured scratch root are
23
+ * hidden; only this run's HOME is exposed. Dry runs (`readOnly`) leave the
24
+ * workspace read-only too. All concurrent credential-bearing adapters must
25
+ * use the same scratch root; different roots are not mutually isolated.
23
26
  * - env: exactly the env the adapter passes (already scoped by agent-env.ts),
24
27
  * with HOME, XDG dirs and TMPDIR pointed into the scratch HOME. On Linux the
25
28
  * process also gets its own PID namespace, so it cannot read another
@@ -55,7 +58,7 @@ export interface SandboxWrapRequest extends AgentSpawn {
55
58
  }
56
59
  /** The spawn to run instead, plus the scratch it owns. */
57
60
  export interface WrappedSpawn extends AgentSpawn {
58
- /** Scratch HOME. Adapters may write config here (generated MCP config, copied auth) before spawning. */
61
+ /** Scratch HOME. Adapters sharing one scratch root may copy scoped auth/config here before spawning. */
59
62
  home: string;
60
63
  /** Remove the scratch HOME and the sandbox settings. Idempotent. */
61
64
  cleanup(): Promise<void>;
@@ -104,6 +107,8 @@ export declare function buildSrtSettings(req: Pick<SandboxWrapRequest, "cwd" | "
104
107
  credentialHome: string;
105
108
  exists?: (p: string) => boolean;
106
109
  credentialPaths?: readonly string[];
110
+ /** Shared wrapper storage to hide, with only `home` re-exposed. */
111
+ isolationRoot?: string;
107
112
  }): SrtSettings;
108
113
  /** Env for the wrapped process: the adapter's env with HOME, XDG and temp dirs moved into the scratch HOME. */
109
114
  export declare function scratchEnv(env: Record<string, string>, home: string): Record<string, string>;
@@ -114,7 +119,15 @@ export interface SrtWrapperOptions {
114
119
  credentialHome?: string;
115
120
  /** Override {@link CREDENTIAL_PATHS} (relative to `credentialHome`, or absolute). */
116
121
  credentialPaths?: readonly string[];
117
- /** Where scratch dirs are created. Default: `os.tmpdir()`. */
122
+ /**
123
+ * Storage parent, default `os.tmpdir()`. Runs share a per-user child under
124
+ * this directory, hidden except for their own HOME. Concurrent adapters
125
+ * carrying credentials must use the same parent; separate roots are not
126
+ * mutually isolated. The existing parent is never removed. Its canonical
127
+ * hierarchy must be owned by this user or root; other-writable ancestors
128
+ * must be sticky (like /tmp). The shared child must be private and owned
129
+ * by this user. Unsafe existing paths are rejected, never repaired.
130
+ */
118
131
  scratchRoot?: string;
119
132
  }
120
133
  export declare class SrtSandboxWrapper implements SandboxWrapper {
@@ -19,7 +19,10 @@
19
19
  * host network except through that proxy.
20
20
  * - filesystem: the workspace (cwd) and a scratch HOME are writable; the rest
21
21
  * of the filesystem is read-only; the operator's credential files are
22
- * unreadable. Dry runs (`readOnly`) leave the workspace read-only too.
22
+ * unreadable. Sibling homes under the same configured scratch root are
23
+ * hidden; only this run's HOME is exposed. Dry runs (`readOnly`) leave the
24
+ * workspace read-only too. All concurrent credential-bearing adapters must
25
+ * use the same scratch root; different roots are not mutually isolated.
23
26
  * - env: exactly the env the adapter passes (already scoped by agent-env.ts),
24
27
  * with HOME, XDG dirs and TMPDIR pointed into the scratch HOME. On Linux the
25
28
  * process also gets its own PID namespace, so it cannot read another
@@ -40,7 +43,7 @@
40
43
  */
41
44
  import { execFile } from "node:child_process";
42
45
  import { existsSync, realpathSync } from "node:fs";
43
- import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
46
+ import { lstat, mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
44
47
  import { homedir, tmpdir } from "node:os";
45
48
  import * as path from "node:path";
46
49
  import { DEFAULT_SANDBOX_DOMAINS, parseList, resolveSandboxMode } from "../agent-env.js";
@@ -119,10 +122,14 @@ export function buildSrtSettings(req, opts) {
119
122
  const denyRead = (opts.credentialPaths ?? CREDENTIAL_PATHS)
120
123
  .map((rel) => (path.isAbsolute(rel) ? rel : path.join(opts.credentialHome, rel)))
121
124
  .filter((p) => exists(p));
125
+ // Deny the parent, not a snapshot of sibling homes: later-created runs
126
+ // must stay hidden too. srt allowRead carves out only our own HOME.
127
+ if (opts.isolationRoot)
128
+ denyRead.push(opts.isolationRoot);
122
129
  const allowWrite = req.readOnly ? [opts.home] : [real(req.cwd), opts.home];
123
130
  return {
124
131
  network: { allowedDomains, deniedDomains: [], strictAllowlist: true, allowLocalBinding: false },
125
- filesystem: { denyRead, allowRead: [], allowWrite, denyWrite: [] },
132
+ filesystem: { denyRead, allowRead: opts.isolationRoot ? [opts.home] : [], allowWrite, denyWrite: [] },
126
133
  };
127
134
  }
128
135
  /** Env for the wrapped process: the adapter's env with HOME, XDG and temp dirs moved into the scratch HOME. */
@@ -147,7 +154,39 @@ export class SrtSandboxWrapper {
147
154
  this.opts = opts;
148
155
  }
149
156
  async wrap(req) {
150
- const dir = await mkdtemp(path.join(real(this.opts.scratchRoot ?? tmpdir()), "sweny-sbx-"));
157
+ const uid = process.getuid?.();
158
+ if (uid === undefined)
159
+ throw new Error("Cannot validate sandbox scratch ownership on this platform");
160
+ const storageParent = await realpath(this.opts.scratchRoot ?? tmpdir());
161
+ // A private child can still be renamed by another user through a writable
162
+ // ancestor. Check the canonical hierarchy before accepting any run paths.
163
+ for (let ancestor = storageParent;; ancestor = path.dirname(ancestor)) {
164
+ const info = await lstat(ancestor);
165
+ if (!info.isDirectory() ||
166
+ (info.uid !== uid && info.uid !== 0) ||
167
+ ((info.mode & 0o022) !== 0 && (info.mode & 0o1000) === 0)) {
168
+ throw new Error(`Unsafe sandbox scratch ancestor: ${ancestor}`);
169
+ }
170
+ if (ancestor === path.dirname(ancestor))
171
+ break;
172
+ }
173
+ // Keep these components short: srt creates Unix sockets beneath HOME/tmp,
174
+ // and Linux socket paths must fit in 107 bytes (including caller parents).
175
+ const isolationRoot = path.join(storageParent, `sweny-${uid}`);
176
+ try {
177
+ await mkdir(isolationRoot, { mode: 0o700 });
178
+ }
179
+ catch (error) {
180
+ if (error.code !== "EEXIST")
181
+ throw error;
182
+ }
183
+ // Never adopt a symlink, a foreign-owned directory, or a directory other
184
+ // users can access. Validate newly created paths too; do not chmod them.
185
+ const info = await lstat(isolationRoot);
186
+ if (!info.isDirectory() || info.uid !== uid || (info.mode & 0o077) !== 0) {
187
+ throw new Error(`Unsafe sandbox scratch directory: ${isolationRoot}`);
188
+ }
189
+ const dir = await mkdtemp(path.join(isolationRoot, "r-"));
151
190
  let cleaned = false;
152
191
  const cleanup = async () => {
153
192
  if (cleaned)
@@ -162,6 +201,7 @@ export class SrtSandboxWrapper {
162
201
  home,
163
202
  credentialHome: this.opts.credentialHome ?? homedir(),
164
203
  credentialPaths: this.opts.credentialPaths,
204
+ isolationRoot,
165
205
  });
166
206
  const settingsPath = path.join(dir, "srt-settings.json");
167
207
  await writeFile(settingsPath, JSON.stringify(settings, null, 2), { mode: 0o600 });
package/dist/index.d.ts CHANGED
@@ -21,12 +21,16 @@
21
21
  * })
22
22
  * ```
23
23
  */
24
- export type { Skill, SkillCategory, SkillDefinition, Tool, ToolContext, ConfigField, JSONSchema, Workflow, Node, Edge, NodeResult, ToolCall, ExecutionEvent, Observer, Claude, Logger, TraceStep, TraceEdge, ExecutionTrace, ExecutionResult, Source, ResolvedSource, SourceKind, SourceResolutionMap, WorkflowInputs, WorkflowInputField, WorkflowInputType, InputValidationError, InputValidationResult, Evaluator, EvaluatorRule, EvalResult, EvaluatorKind, NodeRequires, NodeRetry, NodeToolFilter, OutputMatch, NodeSources, EvalPolicy, RequiresOnFail, NodeOnFail, McpTransport, WorkflowType, SkillHarnessKey, } from "./types.js";
24
+ export type { Skill, SkillCategory, SkillDefinition, Tool, ToolContext, ConfigField, JSONSchema, Workflow, Node, Edge, NodeResult, ToolCall, ExecutionEvent, Observer, Claude, Logger, TraceStep, TraceEdge, ExecutionTrace, ExecutionResult, Source, ResolvedSource, SourceKind, SourceResolutionMap, WorkflowInputs, WorkflowInputField, WorkflowInputType, InputValidationError, InputValidationResult, Evaluator, EvaluatorRule, EvalResult, EvaluatorKind, NodeRequires, NodeRetry, NodeToolFilter, OutputMatch, NodeSources, EvalPolicy, RequiresOnFail, NodeOnFail, McpTransport, WorkflowType, SkillHarnessKey, NodeAccess, NodePermissions, NodePermissionsSpec, SafeOutputType, SafeOutputDeclaration, SafeOutputsPolicy, SafeOutputReceipt, AuthorAssociation, } from "./types.js";
25
25
  export { WORKFLOW_INPUT_TYPES } from "./types.js";
26
- export { EVALUATOR_KINDS, EVAL_POLICIES, REQUIRES_ON_FAIL, NODE_ON_FAIL, MCP_TRANSPORTS, SKILL_CATEGORIES, SKILL_HARNESSES, SKILL_ID_PATTERN, SKILL_ID_MAX_LENGTH, isValidSkillId, } from "./types.js";
26
+ export { EVALUATOR_KINDS, EVAL_POLICIES, REQUIRES_ON_FAIL, NODE_ON_FAIL, MCP_TRANSPORTS, SKILL_CATEGORIES, SKILL_HARNESSES, SKILL_ID_PATTERN, SKILL_ID_MAX_LENGTH, isValidSkillId, NODE_ACCESS, TOOL_CLASSES, SAFE_OUTPUT_TYPES, SAFE_OUTPUT_APPLIERS, AUTHOR_ASSOCIATIONS, } from "./types.js";
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 { resolveNodePermissions, buildNodePolicy } from "./node-policy.js";
31
+ export type { ResolvedPermissions } from "./node-policy.js";
32
+ export { applySafeOutputs, createEmitOutputTool, resolveActor, EMIT_OUTPUT_TOOL } from "./safe-outputs.js";
33
+ export type { SafeOutputIntent, ActorInfo, WriteStageState } from "./safe-outputs.js";
30
34
  export { createHarness, claudeCompat, asClaude, policyGate, ClaudeCodeHarness, CodexHarness, SUPPORTED_AGENTS, } from "./harness/index.js";
31
35
  export type { AgentHarness, HarnessCapabilities, HarnessId, HarnessInfo, HarnessRunRequest, HarnessRunResult, NodePolicy, PolicyGateResult, PolicyWrappers, ToolClass, ClaudeCodeHarnessOptions, CodexHarnessOptions, } from "./harness/index.js";
32
36
  export { ClaudeClient, resolveAuthEnv } from "./harness/claude-code.js";
package/dist/index.js CHANGED
@@ -23,10 +23,13 @@
23
23
  */
24
24
  export { WORKFLOW_INPUT_TYPES } from "./types.js";
25
25
  // Runtime enum constants + skill-id helpers
26
- export { EVALUATOR_KINDS, EVAL_POLICIES, REQUIRES_ON_FAIL, NODE_ON_FAIL, MCP_TRANSPORTS, SKILL_CATEGORIES, SKILL_HARNESSES, SKILL_ID_PATTERN, SKILL_ID_MAX_LENGTH, isValidSkillId, } from "./types.js";
26
+ export { EVALUATOR_KINDS, EVAL_POLICIES, REQUIRES_ON_FAIL, NODE_ON_FAIL, MCP_TRANSPORTS, SKILL_CATEGORIES, SKILL_HARNESSES, SKILL_ID_PATTERN, SKILL_ID_MAX_LENGTH, isValidSkillId, NODE_ACCESS, TOOL_CLASSES, SAFE_OUTPUT_TYPES, SAFE_OUTPUT_APPLIERS, AUTHOR_ASSOCIATIONS, } from "./types.js";
27
27
  export { consoleLogger } from "./types.js";
28
28
  // Executor
29
29
  export { execute, RouteEvaluationError } from "./executor.js";
30
+ // Least-privilege nodes and safe outputs (#365)
31
+ export { resolveNodePermissions, buildNodePolicy } from "./node-policy.js";
32
+ export { applySafeOutputs, createEmitOutputTool, resolveActor, EMIT_OUTPUT_TOOL } from "./safe-outputs.js";
30
33
  // Agent harness (the seam) and its adapters: Claude Code and Codex
31
34
  export { createHarness, claudeCompat, asClaude, policyGate, ClaudeCodeHarness, CodexHarness, SUPPORTED_AGENTS, } from "./harness/index.js";
32
35
  // Claude client (back-compat: ClaudeClient is ClaudeCodeHarness, @deprecated)
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Node permissions (#365): resolve what a node's agent may do.
3
+ *
4
+ * Pure and browser-safe. The workflow's `permissions` is both the default and
5
+ * the ceiling for every node; a node may narrow it, never widen it.
6
+ */
7
+ import type { NodePolicy, ToolClass } from "./harness/types.js";
8
+ import type { Node, NodeAccess, Workflow } from "./types.js";
9
+ export interface ResolvedPermissions {
10
+ access: NodeAccess;
11
+ deny: ToolClass[];
12
+ strict: boolean;
13
+ }
14
+ /**
15
+ * Effective permissions for a node.
16
+ *
17
+ * access: the node's own, else `read` when it declares `outputs`, else the
18
+ * workflow's, else `write` (today's behavior). A workflow `read` ceiling wins
19
+ * over a node `write` (the loader rejects that combination; this is the
20
+ * runtime backstop). deny: union. strict: either.
21
+ */
22
+ export declare function resolveNodePermissions(node: Pick<Node, "permissions" | "outputs">, workflow?: Pick<Workflow, "permissions">): ResolvedPermissions;
23
+ /** True when a node asks for more than the workflow ceiling allows. */
24
+ export declare function exceedsPermissionCeiling(node: Pick<Node, "permissions">, workflow: Pick<Workflow, "permissions">): boolean;
25
+ /** The portable policy handed to the harness for one node run. */
26
+ export declare function buildNodePolicy(opts: {
27
+ permissions: ResolvedPermissions;
28
+ dryRun: boolean;
29
+ disallowedTools?: string[];
30
+ egress?: string[];
31
+ }): NodePolicy;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Node permissions (#365): resolve what a node's agent may do.
3
+ *
4
+ * Pure and browser-safe. The workflow's `permissions` is both the default and
5
+ * the ceiling for every node; a node may narrow it, never widen it.
6
+ */
7
+ function spec(p) {
8
+ if (p === undefined)
9
+ return {};
10
+ return typeof p === "string" ? { access: p } : p;
11
+ }
12
+ /**
13
+ * Effective permissions for a node.
14
+ *
15
+ * access: the node's own, else `read` when it declares `outputs`, else the
16
+ * workflow's, else `write` (today's behavior). A workflow `read` ceiling wins
17
+ * over a node `write` (the loader rejects that combination; this is the
18
+ * runtime backstop). deny: union. strict: either.
19
+ */
20
+ export function resolveNodePermissions(node, workflow = {}) {
21
+ const wf = spec(workflow.permissions);
22
+ const own = spec(node.permissions);
23
+ let access = own.access ?? (node.outputs && node.outputs.length > 0 ? "read" : (wf.access ?? "write"));
24
+ if (wf.access === "read")
25
+ access = "read";
26
+ const deny = [...new Set([...(wf.deny ?? []), ...(own.deny ?? [])])];
27
+ return { access, deny, strict: wf.strict === true || own.strict === true };
28
+ }
29
+ /** True when a node asks for more than the workflow ceiling allows. */
30
+ export function exceedsPermissionCeiling(node, workflow) {
31
+ return spec(workflow.permissions).access === "read" && spec(node.permissions).access === "write";
32
+ }
33
+ /** The portable policy handed to the harness for one node run. */
34
+ export function buildNodePolicy(opts) {
35
+ return {
36
+ readOnly: opts.dryRun || opts.permissions.access === "read",
37
+ deny: opts.permissions.deny,
38
+ ...(opts.disallowedTools && opts.disallowedTools.length > 0 ? { nativeDeny: opts.disallowedTools } : {}),
39
+ egress: opts.egress ?? [],
40
+ strict: opts.permissions.strict,
41
+ };
42
+ }