@sweny-ai/core 0.1.143 → 0.1.144

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,
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
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Safe outputs (#365): a deterministic write boundary for any agent.
3
+ *
4
+ * A node that declares `outputs` gets one extra tool, `emit_output`, which only
5
+ * records a typed write intent in memory. After the node succeeds, the write
6
+ * stage ({@link applySafeOutputs}) checks every intent against the node's
7
+ * declarations and the workflow's `safe_outputs` policy, then applies what
8
+ * passes by calling the owning skill's own tool handler. The agent never holds
9
+ * the write, so this holds on every harness.
10
+ *
11
+ * Order of checks per intent (all deterministic): declared type, workflow
12
+ * ceiling, actor trust, expiry, skill, target pin, shape and size, labels,
13
+ * title prefix, dedupe, node cap, run cap. An optional model screen runs last
14
+ * over the intents that passed and can only veto them.
15
+ */
16
+ import type { Logger, SafeOutputDeclaration, SafeOutputReceipt, SafeOutputsPolicy, SafeOutputType, Skill, Tool } from "./types.js";
17
+ /** Name of the intent-recording tool a node with `outputs` receives. */
18
+ export declare const EMIT_OUTPUT_TOOL = "emit_output";
19
+ /** Longest title the write stage accepts (after the prefix). */
20
+ export declare const SAFE_OUTPUT_TITLE_MAX = 256;
21
+ /** Longest body the write stage accepts (GitHub's own limit). */
22
+ export declare const SAFE_OUTPUT_BODY_MAX = 65536;
23
+ /** One write the agent asked for. Recorded by `emit_output`, never applied by it. */
24
+ export interface SafeOutputIntent {
25
+ type: string;
26
+ title?: string;
27
+ body?: string;
28
+ target?: string;
29
+ /** Issue / PR number (GitHub) or issue id (Linear) for comment and label. */
30
+ number?: string;
31
+ labels?: string[];
32
+ head?: string;
33
+ base?: string;
34
+ dedupe_key?: string;
35
+ /** ms since epoch when the intent was recorded. */
36
+ recordedAt: number;
37
+ }
38
+ /**
39
+ * Build the `emit_output` tool for one node. `access: "read"`: it changes
40
+ * nothing outside the run, so it survives dry runs and read-only nodes.
41
+ * Its checks are advisory (they tell the agent early); the write stage
42
+ * re-checks everything.
43
+ */
44
+ export declare function createEmitOutputTool(declarations: SafeOutputDeclaration[], buffer: SafeOutputIntent[], now?: () => number): Tool;
45
+ /** Instruction block that tells the agent how writes work at this node. */
46
+ export declare function safeOutputsInstruction(declarations: SafeOutputDeclaration[]): string;
47
+ export interface ActorInfo {
48
+ /** GitHub login of whoever triggered the run. */
49
+ login?: string;
50
+ /** Author association of the event's comment, review, issue or PR author. */
51
+ association?: string;
52
+ }
53
+ /** Who triggered this run: an explicit override, else GitHub Actions' env and event payload. */
54
+ export declare function resolveActor(env: Record<string, string | undefined>, override?: ActorInfo): ActorInfo;
55
+ /** True when no trust list is declared, or the actor matches one. Unknown actor = untrusted. */
56
+ export declare function actorTrusted(policy: SafeOutputsPolicy | undefined, actor: ActorInfo): boolean;
57
+ /** `30m` → 1_800_000. Undefined for anything the schema would reject. */
58
+ export declare function parseDuration(s: string | undefined): number | undefined;
59
+ /** The skill that applies a declaration: its `via`, else the first node skill (then any loaded skill) that can. */
60
+ export declare function resolveOutputSkill(decl: SafeOutputDeclaration, nodeSkills: string[], skills: Map<string, Skill>): string | undefined;
61
+ /** Declarations whose applying skill is not configured (no credentials loaded). */
62
+ export declare function unresolvedOutputs(declarations: SafeOutputDeclaration[], nodeSkills: string[], skills: Map<string, Skill>): SafeOutputType[];
63
+ /** Run-wide counters shared by every node's write stage in one execute() call. */
64
+ export interface WriteStageState {
65
+ /** `${nodeId}:${type}` → writes accepted (applied or staged). */
66
+ counts: Map<string, number>;
67
+ /** Writes accepted across the run. */
68
+ total: number;
69
+ /** Dedupe keys already accepted this run. */
70
+ seen: Set<string>;
71
+ }
72
+ export declare function createWriteStageState(): WriteStageState;
73
+ export interface ApplySafeOutputsOptions {
74
+ nodeId: string;
75
+ declarations: SafeOutputDeclaration[];
76
+ intents: SafeOutputIntent[];
77
+ policy?: SafeOutputsPolicy;
78
+ nodeSkills: string[];
79
+ skills: Map<string, Skill>;
80
+ /** Resolved skill config (credentials), handed to the skill's tool handler. */
81
+ config: Record<string, string>;
82
+ env: Record<string, string | undefined>;
83
+ actor: ActorInfo;
84
+ /** Preview only: print what would be written, write nothing. */
85
+ staged: boolean;
86
+ state: WriteStageState;
87
+ logger: Logger;
88
+ /**
89
+ * Model screen (only called when `policy.screen` is on and at least one write
90
+ * passed every deterministic check). Anything but `ALLOW` is a veto.
91
+ */
92
+ screen?: (writes: Record<string, unknown>[]) => Promise<string | null>;
93
+ now?: () => number;
94
+ }
95
+ export interface ApplySafeOutputsResult {
96
+ receipts: SafeOutputReceipt[];
97
+ /** Set when an apply call failed; the node fails with it. */
98
+ error?: string;
99
+ }
100
+ /** Check, then apply (or stage) one node's intents. See the module comment for the order of checks. */
101
+ export declare function applySafeOutputs(o: ApplySafeOutputsOptions): Promise<ApplySafeOutputsResult>;
102
+ /** Instruction for the optional screen. The writes arrive as fenced, untrusted context. */
103
+ export declare const SAFE_OUTPUT_SCREEN_INSTRUCTION: string;