@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 +1 -0
- package/dist/cli/run-output.js +4 -0
- package/dist/executor.d.ts +12 -0
- package/dist/executor.js +108 -6
- package/dist/harness/claude-code.d.ts +12 -4
- package/dist/harness/claude-code.js +41 -8
- package/dist/harness/codex.js +11 -7
- package/dist/harness/sandbox-wrapper.d.ts +16 -3
- package/dist/harness/sandbox-wrapper.js +44 -4
- package/dist/index.d.ts +6 -2
- package/dist/index.js +4 -1
- package/dist/node-policy.d.ts +31 -0
- package/dist/node-policy.js +42 -0
- package/dist/safe-outputs.d.ts +103 -0
- package/dist/safe-outputs.js +460 -0
- package/dist/schema.d.ts +489 -1
- package/dist/schema.js +203 -1
- package/dist/skills/github.js +18 -0
- package/dist/types.d.ts +99 -0
- package/dist/types.js +29 -0
- package/package.json +1 -1
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");
|
package/dist/cli/run-output.js
CHANGED
|
@@ -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)"],
|
package/dist/executor.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
262
|
-
...(
|
|
263
|
-
...(
|
|
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.
|
|
169
|
-
*
|
|
170
|
-
*
|
|
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.
|
|
257
|
-
*
|
|
258
|
-
*
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
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
|
package/dist/harness/codex.js
CHANGED
|
@@ -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
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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.
|
|
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
|
|
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
|
-
/**
|
|
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.
|
|
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
|
|
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
|
+
}
|