@sweny-ai/core 0.1.176 → 0.1.177
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/agent-env.d.ts +9 -0
- package/dist/agent-env.js +4 -0
- package/dist/cli/comment-output.js +4 -5
- package/dist/cli/config-file.d.ts +15 -1
- package/dist/cli/config-file.js +26 -3
- package/dist/cli/final-output.d.ts +4 -1
- package/dist/cli/final-output.js +6 -8
- package/dist/cli/main.js +14 -4
- package/dist/cli/run-history.js +4 -2
- package/dist/cli/run-output.js +11 -3
- package/dist/cli/try.js +2 -3
- package/dist/executor.d.ts +6 -1
- package/dist/executor.js +55 -10
- package/dist/harness/acp.js +2 -2
- package/dist/harness/claude-code.js +6 -4
- package/dist/harness/codex.js +2 -2
- package/dist/harness/pi.js +2 -2
- package/dist/journal.d.ts +1 -1
- package/dist/journal.js +6 -7
- package/dist/safe-file.d.ts +74 -0
- package/dist/safe-file.js +211 -0
- package/dist/safe-outputs.d.ts +7 -1
- package/dist/safe-outputs.js +7 -1
- package/dist/skills/git-push.d.ts +99 -10
- package/dist/skills/git-push.js +413 -52
- package/dist/skills/github.d.ts +19 -15
- package/dist/skills/github.js +91 -15
- package/dist/startup-env.d.ts +30 -0
- package/dist/startup-env.js +48 -0
- package/dist/types.d.ts +37 -1
- package/package.json +1 -1
package/dist/agent-env.d.ts
CHANGED
|
@@ -322,7 +322,16 @@ export interface AgentAccess {
|
|
|
322
322
|
* hooks; only a sandbox stops a deliberate agent from undoing it.
|
|
323
323
|
*/
|
|
324
324
|
noPush?: boolean;
|
|
325
|
+
/**
|
|
326
|
+
* The run is staged or a dry run: no skill or external MCP server reaches
|
|
327
|
+
* the node (an MCP server cannot be classified per tool, so it may write).
|
|
328
|
+
* Only sweny's own tool bridge, already filtered to read tools, remains,
|
|
329
|
+
* and Claude Code runs with `strictMcpConfig`. Same MCP rule as `readOnly`.
|
|
330
|
+
*/
|
|
331
|
+
noMcp?: boolean;
|
|
325
332
|
}
|
|
333
|
+
/** True when a run request may load no MCP server but sweny's own bridge (read-only or staged). */
|
|
334
|
+
export declare function mcpWithheld(readOnly: boolean, access: Pick<AgentAccess, "noMcp"> | undefined): boolean;
|
|
326
335
|
/** Hosts reachable by sandboxed commands in every node: source hosting + package registries. */
|
|
327
336
|
export declare const DEFAULT_SANDBOX_DOMAINS: readonly string[];
|
|
328
337
|
/** Provider hosts added when a node references the built-in skill. */
|
package/dist/agent-env.js
CHANGED
|
@@ -814,6 +814,10 @@ export function grantedAgentEnv(agentEnv, opts) {
|
|
|
814
814
|
return { grant: [], dropped: `agent_env withheld in a staged or dry run: ${names.join(", ")}` };
|
|
815
815
|
return { grant: names };
|
|
816
816
|
}
|
|
817
|
+
/** True when a run request may load no MCP server but sweny's own bridge (read-only or staged). */
|
|
818
|
+
export function mcpWithheld(readOnly, access) {
|
|
819
|
+
return readOnly || access?.noMcp === true;
|
|
820
|
+
}
|
|
817
821
|
/** Hosts reachable by sandboxed commands in every node: source hosting + package registries. */
|
|
818
822
|
export const DEFAULT_SANDBOX_DOMAINS = [
|
|
819
823
|
"github.com",
|
|
@@ -6,8 +6,7 @@
|
|
|
6
6
|
* METADATA ONLY. Node names, statuses, counts, and durations. Never prompts,
|
|
7
7
|
* tool inputs, node outputs, errors, or model prose.
|
|
8
8
|
*/
|
|
9
|
-
import
|
|
10
|
-
import path from "node:path";
|
|
9
|
+
import { workspaceRoot, writeFileNoFollow } from "../safe-file.js";
|
|
11
10
|
import { toMermaidBlock } from "../mermaid.js";
|
|
12
11
|
import { formatReceiptDuration, nodeStates } from "./run-output.js";
|
|
13
12
|
import { formatTicketText } from "./ticket.js";
|
|
@@ -77,10 +76,10 @@ export function formatCrashComment(workflow, summary, runId) {
|
|
|
77
76
|
/** Write the comment markdown to `file`. Never throws; returns true only when written. */
|
|
78
77
|
export function writeRunComment(file, workflow, results, summary, opts = {}) {
|
|
79
78
|
try {
|
|
80
|
-
|
|
81
|
-
|
|
79
|
+
// The file may sit in the agent-writable workspace: no link on the path is followed.
|
|
80
|
+
writeFileNoFollow(file, opts.crashed
|
|
82
81
|
? formatCrashComment(workflow, summary, opts.runId)
|
|
83
|
-
: formatRunComment(workflow, results, summary, opts));
|
|
82
|
+
: formatRunComment(workflow, results, summary, opts), { ...workspaceRoot(file), mode: 0o644, dirMode: 0o755 });
|
|
84
83
|
return true;
|
|
85
84
|
}
|
|
86
85
|
catch (err) {
|
|
@@ -1,6 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keys a workspace `.env` may never set. The workspace is agent-writable, and
|
|
3
|
+
* these name runtime or platform state sweny trusts as the operator's: CI and
|
|
4
|
+
* runner identity and the files the runner hands sweny to write
|
|
5
|
+
* (GITHUB_STEP_SUMMARY, GITHUB_OUTPUT, ...), where binaries are found (PATH),
|
|
6
|
+
* how Node and git behave (NODE_*, GIT_*), where traffic goes and which CAs it
|
|
7
|
+
* trusts (proxies, SSL/CA vars), and the home and temp dirs. A `.env` value
|
|
8
|
+
* for one of them is skipped with a warning. GITHUB_TOKEN is a credential the
|
|
9
|
+
* `.env` template documents, not platform state, so it stays allowed.
|
|
10
|
+
*/
|
|
11
|
+
export declare const DOTENV_DENIED_KEYS: RegExp;
|
|
12
|
+
/** True when a workspace `.env` may not set `key` (see {@link DOTENV_DENIED_KEYS}). */
|
|
13
|
+
export declare function isDotenvDenied(key: string): boolean;
|
|
1
14
|
/**
|
|
2
15
|
* Auto-load a `.env` file from the given directory.
|
|
3
|
-
* Sets `process.env[KEY]` only if not already defined (real env vars win)
|
|
16
|
+
* Sets `process.env[KEY]` only if not already defined (real env vars win),
|
|
17
|
+
* and never a runtime or platform key ({@link DOTENV_DENIED_KEYS}).
|
|
4
18
|
*/
|
|
5
19
|
export declare function loadDotenv(cwd?: string): void;
|
|
6
20
|
/**
|
package/dist/cli/config-file.js
CHANGED
|
@@ -1,9 +1,27 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { parse as parseYaml } from "yaml";
|
|
4
|
+
// Imported for its side effect too: the startup env snapshot is taken before loadDotenv runs.
|
|
5
|
+
import { markWorkspaceEnv } from "../startup-env.js";
|
|
6
|
+
/**
|
|
7
|
+
* Keys a workspace `.env` may never set. The workspace is agent-writable, and
|
|
8
|
+
* these name runtime or platform state sweny trusts as the operator's: CI and
|
|
9
|
+
* runner identity and the files the runner hands sweny to write
|
|
10
|
+
* (GITHUB_STEP_SUMMARY, GITHUB_OUTPUT, ...), where binaries are found (PATH),
|
|
11
|
+
* how Node and git behave (NODE_*, GIT_*), where traffic goes and which CAs it
|
|
12
|
+
* trusts (proxies, SSL/CA vars), and the home and temp dirs. A `.env` value
|
|
13
|
+
* for one of them is skipped with a warning. GITHUB_TOKEN is a credential the
|
|
14
|
+
* `.env` template documents, not platform state, so it stays allowed.
|
|
15
|
+
*/
|
|
16
|
+
export const DOTENV_DENIED_KEYS = /^(GITHUB_(?!TOKEN$)[A-Za-z0-9_]*|RUNNER_[A-Za-z0-9_]*|ACTIONS_[A-Za-z0-9_]*|CI|PATH|Path|NODE_[A-Za-z0-9_]*|GIT_[A-Za-z0-9_]*|(HTTPS?|ALL|NO|FTP)_PROXY|(https?|all|no|ftp)_proxy|SSL_CERT_FILE|SSL_CERT_DIR|CURL_CA_BUNDLE|REQUESTS_CA_BUNDLE|TMPDIR|TEMP|TMP|HOME|USERPROFILE)$/;
|
|
17
|
+
/** True when a workspace `.env` may not set `key` (see {@link DOTENV_DENIED_KEYS}). */
|
|
18
|
+
export function isDotenvDenied(key) {
|
|
19
|
+
return DOTENV_DENIED_KEYS.test(key);
|
|
20
|
+
}
|
|
4
21
|
/**
|
|
5
22
|
* Auto-load a `.env` file from the given directory.
|
|
6
|
-
* Sets `process.env[KEY]` only if not already defined (real env vars win)
|
|
23
|
+
* Sets `process.env[KEY]` only if not already defined (real env vars win),
|
|
24
|
+
* and never a runtime or platform key ({@link DOTENV_DENIED_KEYS}).
|
|
7
25
|
*/
|
|
8
26
|
export function loadDotenv(cwd = process.cwd()) {
|
|
9
27
|
const envPath = path.join(cwd, ".env");
|
|
@@ -27,9 +45,14 @@ export function loadDotenv(cwd = process.cwd()) {
|
|
|
27
45
|
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
28
46
|
value = value.slice(1, -1);
|
|
29
47
|
}
|
|
30
|
-
if (process.env[key]
|
|
31
|
-
|
|
48
|
+
if (process.env[key] !== undefined)
|
|
49
|
+
continue;
|
|
50
|
+
if (isDotenvDenied(key)) {
|
|
51
|
+
process.stderr.write(` warning: .env sets ${key}, a runtime/platform variable; ignored\n`);
|
|
52
|
+
continue;
|
|
32
53
|
}
|
|
54
|
+
process.env[key] = value;
|
|
55
|
+
markWorkspaceEnv(key);
|
|
33
56
|
}
|
|
34
57
|
}
|
|
35
58
|
/**
|
|
@@ -57,5 +57,8 @@ export declare function formatFinalOutput(out: FinalOutput, opts?: {
|
|
|
57
57
|
export declare function formatFinalMarkdown(workflow: Workflow, out: FinalOutput): string;
|
|
58
58
|
/** Relative path shown to the user: `.sweny/runs/<id>/output.md`. */
|
|
59
59
|
export declare function outputRelPath(runId: string): string;
|
|
60
|
-
/**
|
|
60
|
+
/**
|
|
61
|
+
* Write output.md (mode 0600). Local only. Returns the relative path, or null on failure. Never throws.
|
|
62
|
+
* The run dir is agent-writable: no link on the path is followed (see safe-file.ts).
|
|
63
|
+
*/
|
|
61
64
|
export declare function writeFinalOutput(runId: string, markdown: string, cwd?: string): string | null;
|
package/dist/cli/final-output.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* (`--show-output`), since CI logs are often visible to more people than the
|
|
13
13
|
* repo's secrets are.
|
|
14
14
|
*/
|
|
15
|
-
import
|
|
15
|
+
import { writeFileNoFollow } from "../safe-file.js";
|
|
16
16
|
import path from "node:path";
|
|
17
17
|
import { collectSecretValues, redact } from "../journal.js";
|
|
18
18
|
import { RUN_HISTORY_DIR } from "./run-history.js";
|
|
@@ -186,16 +186,14 @@ export function formatFinalMarkdown(workflow, out) {
|
|
|
186
186
|
export function outputRelPath(runId) {
|
|
187
187
|
return path.posix.join(...RUN_HISTORY_DIR.split(path.sep), runId, "output.md");
|
|
188
188
|
}
|
|
189
|
-
/**
|
|
189
|
+
/**
|
|
190
|
+
* Write output.md (mode 0600). Local only. Returns the relative path, or null on failure. Never throws.
|
|
191
|
+
* The run dir is agent-writable: no link on the path is followed (see safe-file.ts).
|
|
192
|
+
*/
|
|
190
193
|
export function writeFinalOutput(runId, markdown, cwd = process.cwd()) {
|
|
191
194
|
try {
|
|
192
195
|
const rel = outputRelPath(runId);
|
|
193
|
-
|
|
194
|
-
fs.mkdirSync(path.dirname(path.dirname(file)), { recursive: true });
|
|
195
|
-
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
196
|
-
fs.writeFileSync(file, markdown, { mode: 0o600 });
|
|
197
|
-
// mode only applies on create: a resumed run overwrites an existing file.
|
|
198
|
-
fs.chmodSync(file, 0o600);
|
|
196
|
+
writeFileNoFollow(path.join(cwd, rel), markdown, { root: cwd, mode: 0o600 });
|
|
199
197
|
return rel;
|
|
200
198
|
}
|
|
201
199
|
catch {
|
package/dist/cli/main.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import { createRequire } from "node:module";
|
|
5
|
+
import { workspaceRoot, writeFileNoFollow } from "../safe-file.js";
|
|
5
6
|
import { Command } from "commander";
|
|
6
7
|
const _require = createRequire(import.meta.url);
|
|
7
8
|
const { version } = _require("../../package.json");
|
|
@@ -57,6 +58,15 @@ import { beginCloudLifecycle, finishCloudLifecycle, createCloudStreamObserver }
|
|
|
57
58
|
import { runUpgrade, fetchLatestFromNpm } from "./upgrade.js";
|
|
58
59
|
import { maybeNudge, defaultCachePath } from "./version-check.js";
|
|
59
60
|
import { spawnSync } from "node:child_process";
|
|
61
|
+
import { trustedEnvValue } from "../startup-env.js";
|
|
62
|
+
/** GITHUB_STEP_SUMMARY as the operator set it: never a value a workspace `.env` introduced. */
|
|
63
|
+
function stepSummaryPath() {
|
|
64
|
+
return trustedEnvValue(process.env, "GITHUB_STEP_SUMMARY") || undefined;
|
|
65
|
+
}
|
|
66
|
+
/** Append to GITHUB_STEP_SUMMARY without following a link (the path may sit in the workspace). */
|
|
67
|
+
function appendStepSummary(file, md) {
|
|
68
|
+
writeFileNoFollow(file, md, { ...workspaceRoot(file), append: true, mkdirs: false, mode: 0o644 });
|
|
69
|
+
}
|
|
60
70
|
/** Secret values a run can see, for redacting everything that prints node output. */
|
|
61
71
|
function outputSecrets(skills) {
|
|
62
72
|
return runSecretValues([...(skills?.values() ?? []), ...builtinSkills], process.env);
|
|
@@ -467,13 +477,13 @@ triageCmd.action(async (options) => {
|
|
|
467
477
|
console.log(formatDagResultHuman(results, durationMs, config));
|
|
468
478
|
}
|
|
469
479
|
// GitHub Actions step summary
|
|
470
|
-
if (config.notificationProvider === "github-summary" &&
|
|
480
|
+
if (config.notificationProvider === "github-summary" && stepSummaryPath()) {
|
|
471
481
|
try {
|
|
472
482
|
const md = formatDagResultMarkdown(results, durationMs, config, {
|
|
473
483
|
workflow: triageWorkflow,
|
|
474
484
|
trace,
|
|
475
485
|
});
|
|
476
|
-
|
|
486
|
+
appendStepSummary(stepSummaryPath(), md);
|
|
477
487
|
}
|
|
478
488
|
catch (err) {
|
|
479
489
|
// Don't fail the run if the summary file can't be written
|
|
@@ -531,9 +541,9 @@ triageCmd.action(async (options) => {
|
|
|
531
541
|
// silent — cloud reporting must never block or mask the crash
|
|
532
542
|
}
|
|
533
543
|
// Best-effort GitHub Actions step summary on crash
|
|
534
|
-
if (config.notificationProvider === "github-summary" &&
|
|
544
|
+
if (config.notificationProvider === "github-summary" && stepSummaryPath()) {
|
|
535
545
|
try {
|
|
536
|
-
|
|
546
|
+
appendStepSummary(stepSummaryPath(), `## ❌ SWEny Triage Crashed\n\n\`\`\`\n${crashMsg}\n\`\`\`\n`);
|
|
537
547
|
}
|
|
538
548
|
catch {
|
|
539
549
|
// ignore
|
package/dist/cli/run-history.js
CHANGED
|
@@ -14,6 +14,7 @@ import crypto from "node:crypto";
|
|
|
14
14
|
import fs from "node:fs";
|
|
15
15
|
import path from "node:path";
|
|
16
16
|
import { summarizeRun } from "./run-output.js";
|
|
17
|
+
import { openNoFollow } from "../safe-file.js";
|
|
17
18
|
export const RUN_HISTORY_SCHEMA_VERSION = 1;
|
|
18
19
|
export const RUN_HISTORY_DIR = path.join(".sweny", "runs");
|
|
19
20
|
export const RUN_HISTORY_KEEP = 200;
|
|
@@ -131,8 +132,9 @@ export function writeRunRecord(record, cwd = process.cwd()) {
|
|
|
131
132
|
const final = path.join(dir, `${record.run_id}.json`);
|
|
132
133
|
const tmp = path.join(dir, `.${record.run_id}.json.${process.pid}.tmp`);
|
|
133
134
|
try {
|
|
134
|
-
|
|
135
|
-
|
|
135
|
+
// The workspace is agent-writable: no link on the path is followed (safe-file.ts).
|
|
136
|
+
// rename() replaces a link at `final` itself, never its target.
|
|
137
|
+
const fd = openNoFollow(tmp, { root: cwd, mode: 0o644 });
|
|
136
138
|
try {
|
|
137
139
|
fs.writeSync(fd, JSON.stringify(record, null, 2) + "\n");
|
|
138
140
|
fs.fsyncSync(fd);
|
package/dist/cli/run-output.js
CHANGED
|
@@ -5,12 +5,13 @@
|
|
|
5
5
|
* METADATA ONLY. Nothing here reads prompts, node outputs, tool inputs, or
|
|
6
6
|
* model prose: only counts, durations, token totals, and cost.
|
|
7
7
|
*/
|
|
8
|
-
import
|
|
8
|
+
import { workspaceRoot, writeFileNoFollow } from "../safe-file.js";
|
|
9
9
|
import { consoleLogger } from "../types.js";
|
|
10
10
|
import { summarizeDecisions } from "../decider.js";
|
|
11
11
|
import { toMermaidBlock } from "../mermaid.js";
|
|
12
12
|
import { createPaint } from "./style.js";
|
|
13
13
|
import { colorEnabled, glyphs } from "./terminal.js";
|
|
14
|
+
import { trustedEnvValue } from "../startup-env.js";
|
|
14
15
|
/** The short key of a `degraded` entry: the text before its first colon. */
|
|
15
16
|
export function degradedKey(entry) {
|
|
16
17
|
const i = entry.indexOf(":");
|
|
@@ -219,11 +220,18 @@ export function formatStepSummary(workflow, results, summary, trace) {
|
|
|
219
220
|
* Never throws; returns true only when something was written.
|
|
220
221
|
*/
|
|
221
222
|
export function writeStepSummary(workflow, results, summary, trace, env = process.env) {
|
|
222
|
-
|
|
223
|
+
// The operator's value only: never one a workspace `.env` introduced.
|
|
224
|
+
const file = trustedEnvValue(env, "GITHUB_STEP_SUMMARY");
|
|
223
225
|
if (!file)
|
|
224
226
|
return false;
|
|
225
227
|
try {
|
|
226
|
-
|
|
228
|
+
// Never through a link, in case the path sits in the agent-writable workspace.
|
|
229
|
+
writeFileNoFollow(file, formatStepSummary(workflow, results, summary, trace), {
|
|
230
|
+
...workspaceRoot(file),
|
|
231
|
+
append: true,
|
|
232
|
+
mkdirs: false,
|
|
233
|
+
mode: 0o644,
|
|
234
|
+
});
|
|
227
235
|
return true;
|
|
228
236
|
}
|
|
229
237
|
catch (err) {
|
package/dist/cli/try.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* only). Until a real run is recorded it is labelled illustrative.
|
|
13
13
|
*/
|
|
14
14
|
import fs from "node:fs";
|
|
15
|
-
import
|
|
15
|
+
import { workspaceRoot, writeFileNoFollow } from "../safe-file.js";
|
|
16
16
|
import { fileURLToPath } from "node:url";
|
|
17
17
|
import { parse as parseYaml } from "yaml";
|
|
18
18
|
import { execute } from "../executor.js";
|
|
@@ -270,8 +270,7 @@ export async function runTry(opts = {}) {
|
|
|
270
270
|
});
|
|
271
271
|
if (opts.commentFile) {
|
|
272
272
|
try {
|
|
273
|
-
|
|
274
|
-
fs.writeFileSync(opts.commentFile, comment);
|
|
273
|
+
writeFileNoFollow(opts.commentFile, comment, { ...workspaceRoot(opts.commentFile), mode: 0o644, dirMode: 0o755 });
|
|
275
274
|
}
|
|
276
275
|
catch (err) {
|
|
277
276
|
process.stderr.write(` ${g.warning} could not write comment file: ${err instanceof Error ? err.message : err}\n`);
|
package/dist/executor.d.ts
CHANGED
|
@@ -13,7 +13,6 @@
|
|
|
13
13
|
* This replaces ~8k lines of engine + recipe step code.
|
|
14
14
|
*/
|
|
15
15
|
import type { Workflow, Skill, Tool, Claude, Observer, NodeResult, Logger, ExecutionResult } from "./types.js";
|
|
16
|
-
import "./skills/git-push.js";
|
|
17
16
|
import type { HarnessPolicyMode } from "./harness/policy.js";
|
|
18
17
|
import type { AgentHarness } from "./harness/types.js";
|
|
19
18
|
import type { DeciderMode } from "./decider.js";
|
|
@@ -150,3 +149,9 @@ export declare function buildBoundedContext(workflow: Workflow, nodeId: string,
|
|
|
150
149
|
* with no `access` is treated as a write so new tools fail safe.
|
|
151
150
|
*/
|
|
152
151
|
export declare function isReadTool(tool: Pick<Tool, "access">): boolean;
|
|
152
|
+
/**
|
|
153
|
+
* The tool dispatcher's staged-run gate: in a staged or dry run no write (or
|
|
154
|
+
* unclassified) tool handler runs, whatever reached the agent. Throws before
|
|
155
|
+
* any side effect.
|
|
156
|
+
*/
|
|
157
|
+
export declare function guardStagedWrite(tool: Pick<Tool, "name" | "access">, staged: boolean): void;
|
package/dist/executor.js
CHANGED
|
@@ -23,8 +23,9 @@ import { buildToolAliases } from "./skills/index.js";
|
|
|
23
23
|
import { validateWorkflow } from "./schema.js";
|
|
24
24
|
import { grantedAgentEnv, resolveAgentAccess } from "./agent-env.js";
|
|
25
25
|
import { gitCredentialWarning, scanGitCredentials } from "./git-credentials.js";
|
|
26
|
-
// #473:
|
|
27
|
-
import "./skills/git-push.js";
|
|
26
|
+
// #473: the sweny-side branch push in `github_create_pr` (Node only; skills/ stays browser-safe).
|
|
27
|
+
import { bindBranchPusher } from "./skills/git-push.js";
|
|
28
|
+
import { trustedEnvValue } from "./startup-env.js";
|
|
28
29
|
import { fenceUntrusted } from "./untrusted.js";
|
|
29
30
|
import { asClaude } from "./harness/compat.js";
|
|
30
31
|
import { budgetGate, isToolClass, policyGate, resolveHarnessPolicy } from "./harness/policy.js";
|
|
@@ -96,7 +97,8 @@ export async function execute(workflow, input, options) {
|
|
|
96
97
|
// default). Each harness masks it for read-only and staged nodes, or reports
|
|
97
98
|
// the node degraded; this says so once per run. Paths and keys, never values.
|
|
98
99
|
try {
|
|
99
|
-
|
|
100
|
+
// The run's checkout (ExecuteOptions.cwd), not the embedding process's cwd.
|
|
101
|
+
const credentialWarning = gitCredentialWarning(scanGitCredentials(options.cwd ?? process.cwd(), { env: runEnv }));
|
|
100
102
|
if (credentialWarning)
|
|
101
103
|
logger.warn(credentialWarning);
|
|
102
104
|
}
|
|
@@ -141,6 +143,11 @@ export async function execute(workflow, input, options) {
|
|
|
141
143
|
nodeContext.forEach((s, i) => (sourceMap[`nodes.${nodeId}.context.${i}`] = s));
|
|
142
144
|
}
|
|
143
145
|
const resolveCwd = options.cwd ?? process.cwd();
|
|
146
|
+
// #473: the PR head push runs in this run's checkout, never process.cwd() of
|
|
147
|
+
// an embedding process. Tools get it through ToolContext.
|
|
148
|
+
const pushBranch = bindBranchPusher(resolveCwd, runEnv);
|
|
149
|
+
// GHES: the REST base for the github skill, never a value the workspace .env introduced.
|
|
150
|
+
const githubApiUrl = trustedEnvValue(runEnv, "GITHUB_API_URL") || undefined;
|
|
144
151
|
const resolvedSources = await resolveSources(sourceMap, {
|
|
145
152
|
cwd: resolveCwd,
|
|
146
153
|
env: options.env ?? process.env,
|
|
@@ -239,10 +246,16 @@ export async function execute(workflow, input, options) {
|
|
|
239
246
|
const permissions = resolveNodePermissions(node, workflow);
|
|
240
247
|
const declaresPolicy = node.permissions !== undefined || workflow.permissions !== undefined || (node.outputs?.length ?? 0) > 0;
|
|
241
248
|
const readOnlyNode = dryRun || permissions.access === "read";
|
|
242
|
-
|
|
243
|
-
|
|
249
|
+
// A staged run (--stage, safe_outputs.staged) writes nothing either: its
|
|
250
|
+
// write tools are withheld too, and the dispatcher below refuses any that
|
|
251
|
+
// still reach a handler.
|
|
252
|
+
const readTools = readOnlyNode || stageOutputs ? filteredTools.filter(isReadTool) : filteredTools;
|
|
253
|
+
// A staged write node records what it lost, like a dry run.
|
|
254
|
+
const stagedWriteNode = stageOutputs && !readOnlyNode;
|
|
255
|
+
const skippedWrites = dryRun || stagedWriteNode ? filteredTools.filter((t) => !isReadTool(t)).map((t) => t.name) : [];
|
|
244
256
|
if (skippedWrites.length > 0) {
|
|
245
|
-
|
|
257
|
+
const why = dryRun ? "dry run" : "staged";
|
|
258
|
+
logger.info(` ${why}: withheld write tools: ${skippedWrites.join(", ")}`, { node: currentId });
|
|
246
259
|
}
|
|
247
260
|
else if (readOnlyNode && readTools.length < filteredTools.length) {
|
|
248
261
|
const withheld = filteredTools.filter((t) => !isReadTool(t)).map((t) => t.name);
|
|
@@ -332,7 +345,15 @@ export async function execute(workflow, input, options) {
|
|
|
332
345
|
...t,
|
|
333
346
|
handler: async (toolInput) => {
|
|
334
347
|
safeObserve(observer, { type: "tool:call", node: currentId, tool: t.name, input: toolInput }, logger);
|
|
335
|
-
|
|
348
|
+
guardStagedWrite(t, stageOutputs);
|
|
349
|
+
const toolCtx = {
|
|
350
|
+
config,
|
|
351
|
+
logger,
|
|
352
|
+
cwd: resolveCwd,
|
|
353
|
+
staged: stageOutputs,
|
|
354
|
+
pushBranch,
|
|
355
|
+
...(githubApiUrl ? { githubApiUrl } : {}),
|
|
356
|
+
};
|
|
336
357
|
const output = await t.handler(toolInput, toolCtx);
|
|
337
358
|
safeObserve(observer, { type: "tool:result", node: currentId, tool: t.name, output }, logger);
|
|
338
359
|
return output;
|
|
@@ -345,7 +366,11 @@ export async function execute(workflow, input, options) {
|
|
|
345
366
|
const nodeInstruction = outputDecls.length > 0
|
|
346
367
|
? `${baseInstruction}\n\n${safeOutputsInstruction(outputDecls, runInput)}`
|
|
347
368
|
: baseInstruction;
|
|
348
|
-
const instruction = dryRun
|
|
369
|
+
const instruction = dryRun
|
|
370
|
+
? `${dryRunNotice(skippedWrites)}\n\n---\n\n${nodeInstruction}`
|
|
371
|
+
: skippedWrites.length > 0
|
|
372
|
+
? `${stagedNotice(skippedWrites)}\n\n---\n\n${nodeInstruction}`
|
|
373
|
+
: nodeInstruction;
|
|
349
374
|
// Run Claude on this node, with optional eval-failure retry loop.
|
|
350
375
|
let attempt = 0;
|
|
351
376
|
let result;
|
|
@@ -373,7 +398,8 @@ export async function execute(workflow, input, options) {
|
|
|
373
398
|
logger.warn(` ${granted.dropped}`, { node: currentId });
|
|
374
399
|
const agentAccess = {
|
|
375
400
|
...resolveAgentAccess(node.skills, skills, granted.grant),
|
|
376
|
-
|
|
401
|
+
// A staged run loads no skill or external MCP server either: it may write.
|
|
402
|
+
...(stageOutputs ? { noPush: true, noMcp: true } : {}),
|
|
377
403
|
};
|
|
378
404
|
// #365: a node or workflow that declares `permissions` or `outputs` gets
|
|
379
405
|
// one portable policy, compiled by each adapter. The gate also runs here so
|
|
@@ -474,7 +500,7 @@ export async function execute(workflow, input, options) {
|
|
|
474
500
|
timeoutMs,
|
|
475
501
|
agentAccess,
|
|
476
502
|
...(nodePolicy ? { policy: nodePolicy } : {}),
|
|
477
|
-
...(readOnlyNode ? {} : { mcpServers: skillMcpServers }),
|
|
503
|
+
...(readOnlyNode || stageOutputs ? {} : { mcpServers: skillMcpServers }),
|
|
478
504
|
...(readOnlyNode ? { readOnly: true } : {}),
|
|
479
505
|
...(onUsage ? { onUsage } : {}),
|
|
480
506
|
onProgress: (message) => {
|
|
@@ -652,6 +678,9 @@ export async function execute(workflow, input, options) {
|
|
|
652
678
|
env: runEnv,
|
|
653
679
|
actor,
|
|
654
680
|
staged: stageOutputs,
|
|
681
|
+
cwd: resolveCwd,
|
|
682
|
+
pushBranch,
|
|
683
|
+
...(githubApiUrl ? { githubApiUrl } : {}),
|
|
655
684
|
state: writeState,
|
|
656
685
|
logger,
|
|
657
686
|
input: runInput,
|
|
@@ -1110,6 +1139,16 @@ function isDryRunInput(input) {
|
|
|
1110
1139
|
export function isReadTool(tool) {
|
|
1111
1140
|
return tool.access === "read";
|
|
1112
1141
|
}
|
|
1142
|
+
/**
|
|
1143
|
+
* The tool dispatcher's staged-run gate: in a staged or dry run no write (or
|
|
1144
|
+
* unclassified) tool handler runs, whatever reached the agent. Throws before
|
|
1145
|
+
* any side effect.
|
|
1146
|
+
*/
|
|
1147
|
+
export function guardStagedWrite(tool, staged) {
|
|
1148
|
+
if (staged && !isReadTool(tool)) {
|
|
1149
|
+
throw new Error(`${tool.name} is a write tool and this run is staged: nothing was written`);
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1113
1152
|
/** Instruction section prepended to every node under dry-run. */
|
|
1114
1153
|
function dryRunNotice(skippedWrites) {
|
|
1115
1154
|
const withheld = skippedWrites.length > 0 ? `These write tools were withheld from this step: ${skippedWrites.join(", ")}. ` : "";
|
|
@@ -1117,6 +1156,12 @@ function dryRunNotice(skippedWrites) {
|
|
|
1117
1156
|
`Do not create, modify, post, or send anything. Do the analysis, and where this step would ` +
|
|
1118
1157
|
`normally write, describe exactly what it would have written instead.`);
|
|
1119
1158
|
}
|
|
1159
|
+
/** Instruction section prepended to a staged write node whose write tools were withheld. */
|
|
1160
|
+
function stagedNotice(skippedWrites) {
|
|
1161
|
+
return (`## Staged run\n\nThis run is staged: nothing is written outside the workspace. These write tools were ` +
|
|
1162
|
+
`withheld from this step: ${skippedWrites.join(", ")}. Where this step would call one, describe ` +
|
|
1163
|
+
`exactly what it would have written instead.`);
|
|
1164
|
+
}
|
|
1120
1165
|
/**
|
|
1121
1166
|
* Only this node's resolved skills contribute external servers. A stdio
|
|
1122
1167
|
* server gets its skill's declared credentials in its own `env` (explicit
|
package/dist/harness/acp.js
CHANGED
|
@@ -48,7 +48,7 @@ import { spawn } from "node:child_process";
|
|
|
48
48
|
import * as fs from "node:fs";
|
|
49
49
|
import * as path from "node:path";
|
|
50
50
|
import { consoleLogger } from "../types.js";
|
|
51
|
-
import { finishAgentEnv, heldCredentials, parseList, reportWithheldEnv, resolveEnvScope, resolveSandboxMode, scopeAgentEnv, withPushBlocked, } from "../agent-env.js";
|
|
51
|
+
import { finishAgentEnv, heldCredentials, parseList, reportWithheldEnv, resolveEnvScope, resolveSandboxMode, scopeAgentEnv, withPushBlocked, mcpWithheld, } from "../agent-env.js";
|
|
52
52
|
import { ACP_CAPABILITIES } from "./capabilities.js";
|
|
53
53
|
import { policyGate, resolveHarnessPolicy, TOOL_CLASSES } from "./policy.js";
|
|
54
54
|
import { gitCredentialPolicy } from "../git-credentials.js";
|
|
@@ -739,7 +739,7 @@ export class AcpHarness {
|
|
|
739
739
|
try {
|
|
740
740
|
// Dry run: external MCP servers cannot be classified per tool, so only
|
|
741
741
|
// sweny's own (already read-filtered) skill tools remain.
|
|
742
|
-
const servers = readOnly
|
|
742
|
+
const servers = mcpWithheld(readOnly, req.agentAccess)
|
|
743
743
|
? {}
|
|
744
744
|
: { ...this.defaultMcpServers, ...req.mcpServers, ...this.mcpServers };
|
|
745
745
|
if (req.tools.length > 0) {
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
import { query, createSdkMcpServer, tool as sdkTool } from "@anthropic-ai/claude-agent-sdk";
|
|
12
12
|
import { createRequire } from "node:module";
|
|
13
13
|
import { consoleLogger } from "../types.js";
|
|
14
|
-
import { AGENT_AUTH_VARS, finishAgentEnv, reportWithheldEnv, parseList, resolveEnvScope, scopeAgentEnv, withPushBlocked, resolveAgentSandbox, } from "../agent-env.js";
|
|
14
|
+
import { AGENT_AUTH_VARS, finishAgentEnv, reportWithheldEnv, parseList, resolveEnvScope, scopeAgentEnv, withPushBlocked, mcpWithheld, resolveAgentSandbox, } from "../agent-env.js";
|
|
15
15
|
import { gitCredentialGap, policyGate } from "./policy.js";
|
|
16
16
|
import { gitCredentialPolicy } from "../git-credentials.js";
|
|
17
17
|
import { CLAUDE_CODE_CAPABILITIES } from "./capabilities.js";
|
|
@@ -324,13 +324,14 @@ export class ClaudeCodeHarness {
|
|
|
324
324
|
...req,
|
|
325
325
|
readOnly,
|
|
326
326
|
disallowedTools: disallowedTools.length > 0 ? disallowedTools : undefined,
|
|
327
|
-
strictMcp: readOnly || policy.strict || policy.exclusiveMcp === true,
|
|
327
|
+
strictMcp: mcpWithheld(readOnly, req.agentAccess) || policy.strict || policy.exclusiveMcp === true,
|
|
328
328
|
sandboxMode: policy.sandbox,
|
|
329
329
|
agentAccess: {
|
|
330
330
|
envVars: req.agentAccess?.envVars ?? [],
|
|
331
331
|
domains: policy.egress,
|
|
332
332
|
withhold: req.agentAccess?.withhold,
|
|
333
333
|
noPush: req.agentAccess?.noPush,
|
|
334
|
+
noMcp: req.agentAccess?.noMcp,
|
|
334
335
|
},
|
|
335
336
|
stagedWrite: !!req.agentAccess?.noPush && !readOnly,
|
|
336
337
|
strict: policy.strict || this.policyMode === "strict",
|
|
@@ -483,7 +484,8 @@ export class ClaudeCodeHarness {
|
|
|
483
484
|
const abort = makeAbort(timeoutMs, signal);
|
|
484
485
|
let stream;
|
|
485
486
|
try {
|
|
486
|
-
|
|
487
|
+
// Read-only and staged runs: no skill or external MCP server (it may write).
|
|
488
|
+
const allMcpServers = mcpWithheld(!!readOnly, agentAccess)
|
|
487
489
|
? {}
|
|
488
490
|
: { ...this.defaultMcpServers, ...opts.mcpServers, ...this.mcpServers };
|
|
489
491
|
if (useBridge) {
|
|
@@ -520,7 +522,7 @@ export class ClaudeCodeHarness {
|
|
|
520
522
|
// project .mcp.json, plugins). strictMcpConfig limits MCP to the
|
|
521
523
|
// servers passed above, which under readOnly is only sweny-core.
|
|
522
524
|
// A strict policy (#365) makes it exclusive for write nodes too.
|
|
523
|
-
...(readOnly || opts.strictMcp ? { strictMcpConfig: true } : {}),
|
|
525
|
+
...(mcpWithheld(!!readOnly, agentAccess) || opts.strictMcp ? { strictMcpConfig: true } : {}),
|
|
524
526
|
...(disallowedTools && disallowedTools.length > 0 ? { disallowedTools } : {}),
|
|
525
527
|
// CC-08: ask the SDK to produce validated structured output when the
|
|
526
528
|
// node declares an output schema. The SDK then returns the parsed
|
package/dist/harness/codex.js
CHANGED
|
@@ -28,7 +28,7 @@ import * as fs from "node:fs";
|
|
|
28
28
|
import * as os from "node:os";
|
|
29
29
|
import * as path from "node:path";
|
|
30
30
|
import { consoleLogger } from "../types.js";
|
|
31
|
-
import { CODEX_AUTH_VARS, CODEX_ENV_PREFIXES, checkSandboxSupport, finishAgentEnv, heldCredentials, parseList, reportWithheldEnv, resolveCodexAuthEnv, resolveEnvScope, resolveSandboxMode, scopeAgentEnv, withPushBlocked, } from "../agent-env.js";
|
|
31
|
+
import { CODEX_AUTH_VARS, CODEX_ENV_PREFIXES, checkSandboxSupport, finishAgentEnv, heldCredentials, parseList, reportWithheldEnv, resolveCodexAuthEnv, resolveEnvScope, resolveSandboxMode, scopeAgentEnv, withPushBlocked, mcpWithheld, } from "../agent-env.js";
|
|
32
32
|
import { CODEX_CAPABILITIES } from "./capabilities.js";
|
|
33
33
|
import { policyGate, resolveHarnessPolicy } from "./policy.js";
|
|
34
34
|
import { gitCredentialPolicy } from "../git-credentials.js";
|
|
@@ -816,7 +816,7 @@ export class CodexHarness {
|
|
|
816
816
|
try {
|
|
817
817
|
// Dry run: external MCP servers cannot be classified per tool, so only
|
|
818
818
|
// sweny's own (already read-filtered) skill tools remain.
|
|
819
|
-
const servers = readOnly
|
|
819
|
+
const servers = mcpWithheld(readOnly, req.agentAccess)
|
|
820
820
|
? {}
|
|
821
821
|
: { ...this.defaultMcpServers, ...req.mcpServers, ...this.mcpServers };
|
|
822
822
|
const { args: mcpArgs, unsupported } = this.mcpArgs(servers);
|
package/dist/harness/pi.js
CHANGED
|
@@ -34,7 +34,7 @@ import * as fs from "node:fs";
|
|
|
34
34
|
import * as os from "node:os";
|
|
35
35
|
import * as path from "node:path";
|
|
36
36
|
import { consoleLogger } from "../types.js";
|
|
37
|
-
import { PI_AUTH_VARS, PI_ENV_PREFIXES, finishAgentEnv, heldCredentials, parseList, reportWithheldEnv, resolveEnvScope, resolvePiProvider, resolveSandboxMode, scopeAgentEnv, withPushBlocked, } from "../agent-env.js";
|
|
37
|
+
import { PI_AUTH_VARS, PI_ENV_PREFIXES, finishAgentEnv, heldCredentials, parseList, reportWithheldEnv, resolveEnvScope, resolvePiProvider, resolveSandboxMode, scopeAgentEnv, withPushBlocked, mcpWithheld, } from "../agent-env.js";
|
|
38
38
|
import { PI_CAPABILITIES } from "./capabilities.js";
|
|
39
39
|
import { policyGate, resolveHarnessPolicy } from "./policy.js";
|
|
40
40
|
import { gitCredentialPolicy } from "../git-credentials.js";
|
|
@@ -788,7 +788,7 @@ export class PiHarness {
|
|
|
788
788
|
// Dry run: external MCP servers cannot be classified per tool (and a
|
|
789
789
|
// `--tools` allowlist cannot name tools it has not seen), so only sweny's
|
|
790
790
|
// own (already read-filtered) skill tools remain.
|
|
791
|
-
const servers = readOnly
|
|
791
|
+
const servers = mcpWithheld(readOnly, req.agentAccess)
|
|
792
792
|
? {}
|
|
793
793
|
: { ...this.defaultMcpServers, ...req.mcpServers, ...this.mcpServers };
|
|
794
794
|
const { entries, unsupported } = piMcpServers(servers);
|
package/dist/journal.d.ts
CHANGED
|
@@ -382,7 +382,7 @@ export declare class RunJournal implements ExecutionJournal {
|
|
|
382
382
|
/** False once an append failed: the rest of the run is not journaled. */
|
|
383
383
|
get active(): boolean;
|
|
384
384
|
private takeLock;
|
|
385
|
-
/** The run dir is private (0700);
|
|
385
|
+
/** The run dir is private (0700); no link on the path from cwd is followed (safe-file.ts). */
|
|
386
386
|
private makeDir;
|
|
387
387
|
private releaseLock;
|
|
388
388
|
private append;
|
package/dist/journal.js
CHANGED
|
@@ -59,6 +59,7 @@ import path from "node:path";
|
|
|
59
59
|
import { resolveNodePermissions } from "./node-policy.js";
|
|
60
60
|
import { CURRENT_SPEC_VERSION } from "./migrations.js";
|
|
61
61
|
import { spendOf } from "./budget.js";
|
|
62
|
+
import { ensureDirNoFollow, openNoFollow, writeFileNoFollow } from "./safe-file.js";
|
|
62
63
|
/** v2: records are authenticated with a per-run HMAC key (v1 used a public checksum and is refused). */
|
|
63
64
|
export const JOURNAL_SCHEMA_VERSION = 2;
|
|
64
65
|
export const JOURNAL_DIR = path.join(".sweny", "runs");
|
|
@@ -1005,12 +1006,11 @@ export class RunJournal {
|
|
|
1005
1006
|
throw err;
|
|
1006
1007
|
}
|
|
1007
1008
|
this.makeDir();
|
|
1008
|
-
|
|
1009
|
+
writeFileNoFollow(lock, String(process.pid), { root: this.cwd, mode: 0o600 });
|
|
1009
1010
|
}
|
|
1010
|
-
/** The run dir is private (0700);
|
|
1011
|
+
/** The run dir is private (0700); no link on the path from cwd is followed (safe-file.ts). */
|
|
1011
1012
|
makeDir() {
|
|
1012
|
-
|
|
1013
|
-
fs.mkdirSync(this.dir, { recursive: true, mode: 0o700 });
|
|
1013
|
+
ensureDirNoFollow(this.dir, this.cwd);
|
|
1014
1014
|
}
|
|
1015
1015
|
releaseLock() {
|
|
1016
1016
|
try {
|
|
@@ -1052,8 +1052,7 @@ export class RunJournal {
|
|
|
1052
1052
|
try {
|
|
1053
1053
|
if (this.fd === undefined) {
|
|
1054
1054
|
this.makeDir();
|
|
1055
|
-
this.fd =
|
|
1056
|
-
fs.fchmodSync(this.fd, 0o600);
|
|
1055
|
+
this.fd = openNoFollow(this.file, { root: this.cwd, append: true, mode: 0o600 });
|
|
1057
1056
|
}
|
|
1058
1057
|
fs.writeSync(this.fd, JSON.stringify(record) + "\n");
|
|
1059
1058
|
fs.fsyncSync(this.fd);
|
|
@@ -1126,7 +1125,7 @@ export class RunJournal {
|
|
|
1126
1125
|
try {
|
|
1127
1126
|
this.makeDir();
|
|
1128
1127
|
// Journals hold node output: keep them out of commits (an agent's `git add -A` included).
|
|
1129
|
-
|
|
1128
|
+
writeFileNoFollow(path.join(this.dir, ".gitignore"), "*\n", { root: this.cwd, mode: 0o644 });
|
|
1130
1129
|
this.takeLock();
|
|
1131
1130
|
}
|
|
1132
1131
|
catch {
|