@sweny-ai/core 0.1.178 → 0.1.179
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 +5 -3
- package/dist/agent-env.js +12 -7
- package/dist/cli/final-output.js +4 -1
- package/dist/cli/main.js +3 -2
- package/dist/cli/resume.d.ts +10 -1
- package/dist/cli/resume.js +76 -7
- package/dist/executor.d.ts +1 -1
- package/dist/executor.js +77 -4
- package/dist/harness/acp.js +2 -0
- package/dist/harness/claude-code.d.ts +18 -0
- package/dist/harness/claude-code.js +51 -6
- package/dist/harness/codex.js +2 -0
- package/dist/harness/pi.js +2 -0
- package/dist/harness/sandbox-wrapper.js +8 -8
- package/dist/journal.d.ts +225 -70
- package/dist/journal.js +713 -202
- package/dist/skills/github.js +39 -0
- package/dist/types.d.ts +6 -0
- package/package.json +1 -1
package/dist/agent-env.d.ts
CHANGED
|
@@ -373,9 +373,11 @@ export declare function checkSandboxSupport(platform?: NodeJS.Platform, pathEnv?
|
|
|
373
373
|
/**
|
|
374
374
|
* The SDK sandbox settings used when the sandbox is on.
|
|
375
375
|
*
|
|
376
|
-
* - `failIfUnavailable`: true
|
|
377
|
-
*
|
|
378
|
-
*
|
|
376
|
+
* - `failIfUnavailable`: true whenever settings are built, `auto` included:
|
|
377
|
+
* the preflight already passed, so a sandbox that then fails to start
|
|
378
|
+
* fails the node instead of silently running the agent unsandboxed (which
|
|
379
|
+
* the run would otherwise report as contained). `auto` on a host whose
|
|
380
|
+
* preflight fails builds no settings and runs unsandboxed, reported as such.
|
|
379
381
|
* - `allowUnsandboxedCommands: false`: the model cannot escape via the
|
|
380
382
|
* `dangerouslyDisableSandbox` parameter. Required, because nodes run under
|
|
381
383
|
* `bypassPermissions` (#365) which would otherwise auto-approve the escape.
|
package/dist/agent-env.js
CHANGED
|
@@ -29,7 +29,7 @@ import { execFileSync } from "node:child_process";
|
|
|
29
29
|
import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
|
30
30
|
import { homedir, tmpdir } from "node:os";
|
|
31
31
|
import * as path from "node:path";
|
|
32
|
-
import {
|
|
32
|
+
import { runStateRoot } from "./journal.js";
|
|
33
33
|
// ─── Scoped env ──────────────────────────────────────────────────
|
|
34
34
|
/**
|
|
35
35
|
* Exact variable names always passed to the agent subprocess.
|
|
@@ -938,9 +938,11 @@ function defaultProbe() {
|
|
|
938
938
|
/**
|
|
939
939
|
* The SDK sandbox settings used when the sandbox is on.
|
|
940
940
|
*
|
|
941
|
-
* - `failIfUnavailable`: true
|
|
942
|
-
*
|
|
943
|
-
*
|
|
941
|
+
* - `failIfUnavailable`: true whenever settings are built, `auto` included:
|
|
942
|
+
* the preflight already passed, so a sandbox that then fails to start
|
|
943
|
+
* fails the node instead of silently running the agent unsandboxed (which
|
|
944
|
+
* the run would otherwise report as contained). `auto` on a host whose
|
|
945
|
+
* preflight fails builds no settings and runs unsandboxed, reported as such.
|
|
944
946
|
* - `allowUnsandboxedCommands: false`: the model cannot escape via the
|
|
945
947
|
* `dangerouslyDisableSandbox` parameter. Required, because nodes run under
|
|
946
948
|
* `bypassPermissions` (#365) which would otherwise auto-approve the escape.
|
|
@@ -963,10 +965,12 @@ export function buildSandboxSettings(domains, opts = {}) {
|
|
|
963
965
|
envVars: AGENT_AUTH_VARS.map((name) => ({ name, mode: "deny" })),
|
|
964
966
|
files: [
|
|
965
967
|
{ path: path.join(opts.home ?? homedir(), ".claude", ".credentials.json"), mode: "deny" },
|
|
966
|
-
// Run
|
|
967
|
-
{ path:
|
|
968
|
+
// Run journals and their keys: an agent that could read a key could forge records.
|
|
969
|
+
{ path: runStateRoot(), mode: "deny" },
|
|
968
970
|
],
|
|
969
971
|
},
|
|
972
|
+
// Nor may it write there: cut a journal, delete a lock, or plant a run.
|
|
973
|
+
filesystem: { denyRead: [runStateRoot()], denyWrite: [runStateRoot()] },
|
|
970
974
|
};
|
|
971
975
|
}
|
|
972
976
|
/**
|
|
@@ -1003,7 +1007,8 @@ export function resolveAgentSandbox(opts) {
|
|
|
1003
1007
|
return {
|
|
1004
1008
|
mode,
|
|
1005
1009
|
settings: buildSandboxSettings([...DEFAULT_SANDBOX_DOMAINS, ...(opts.nodeDomains ?? []), ...extra], {
|
|
1006
|
-
|
|
1010
|
+
// Preflight passed: from here the sandbox is enforced or the node fails.
|
|
1011
|
+
failIfUnavailable: true,
|
|
1007
1012
|
}),
|
|
1008
1013
|
};
|
|
1009
1014
|
}
|
package/dist/cli/final-output.js
CHANGED
|
@@ -193,7 +193,10 @@ export function outputRelPath(runId) {
|
|
|
193
193
|
export function writeFinalOutput(runId, markdown, cwd = process.cwd()) {
|
|
194
194
|
try {
|
|
195
195
|
const rel = outputRelPath(runId);
|
|
196
|
-
|
|
196
|
+
const file = path.join(cwd, rel);
|
|
197
|
+
// Node output: keep the run dir out of commits (an agent's `git add -A` included).
|
|
198
|
+
writeFileNoFollow(path.join(path.dirname(file), ".gitignore"), "*\n", { root: cwd, mode: 0o644 });
|
|
199
|
+
writeFileNoFollow(file, markdown, { root: cwd, mode: 0o600 });
|
|
197
200
|
return rel;
|
|
198
201
|
}
|
|
199
202
|
catch {
|
package/dist/cli/main.js
CHANGED
|
@@ -1143,8 +1143,9 @@ resume) {
|
|
|
1143
1143
|
progress.stop();
|
|
1144
1144
|
console.error(formatCrashError(err));
|
|
1145
1145
|
runLogger.flush();
|
|
1146
|
+
const resumable = journal?.active === true;
|
|
1146
1147
|
journal?.end("crashed");
|
|
1147
|
-
if (
|
|
1148
|
+
if (resumable)
|
|
1148
1149
|
console.error(c.subtle(` resume with: sweny workflow resume ${runId}`));
|
|
1149
1150
|
recordHistory(nodeTimer.lastResults, undefined, true);
|
|
1150
1151
|
const crashSummary = summarizeRun(new Map(), Date.now() - runStart, true);
|
|
@@ -1255,7 +1256,7 @@ for (const [flags, description] of WORKFLOW_RUN_OPTIONS) {
|
|
|
1255
1256
|
}
|
|
1256
1257
|
workflowRunCmd.option("--no-history", "Do not record this run or save its output.md in .sweny/runs/ (or set `history: off` in .sweny.yml)");
|
|
1257
1258
|
workflowRunCmd.option("--show-output", "Print the final answer in CI too (off by default in CI: only the path to output.md is printed)");
|
|
1258
|
-
workflowRunCmd.option("--no-journal", "Do not write a run journal
|
|
1259
|
+
workflowRunCmd.option("--no-journal", "Do not write a run journal (kept in the sweny state dir; or set `journal: off` in .sweny.yml); the run cannot be resumed");
|
|
1259
1260
|
const workflowResumeCmd = workflowCmd
|
|
1260
1261
|
.command("resume <run-id>")
|
|
1261
1262
|
.description("Resume a killed or failed `workflow run` from its journal: finished nodes are replayed without calling the agent, " +
|
package/dist/cli/resume.d.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* Lives outside main.ts so tests can import it (main.ts parses argv on import).
|
|
7
7
|
*/
|
|
8
8
|
import type { Logger, Workflow } from "../types.js";
|
|
9
|
-
import { RunJournal, type ResumePlan } from "../journal.js";
|
|
9
|
+
import { RunJournal, type LockHooks, type ResumePlan } from "../journal.js";
|
|
10
10
|
/** `sweny workflow resume` options (commander camelCases the flags). */
|
|
11
11
|
export interface ResumeOptions {
|
|
12
12
|
force?: boolean;
|
|
@@ -48,9 +48,18 @@ export interface PrepareResumeDeps {
|
|
|
48
48
|
swenyVersion?: string;
|
|
49
49
|
env?: Record<string, string | undefined>;
|
|
50
50
|
logger?: Logger;
|
|
51
|
+
/** Where run journals live (default: {@link runStateRoot} of `env`). */
|
|
52
|
+
stateRoot?: string;
|
|
53
|
+
/** Test seam for the run lock. */
|
|
54
|
+
lockHooks?: LockHooks;
|
|
51
55
|
}
|
|
52
56
|
/** One line per planned visit, for humans. */
|
|
53
57
|
export declare function formatResumePlan(plan: ResumePlan, mayRepeat: string[]): string[];
|
|
58
|
+
/**
|
|
59
|
+
* Check a run journal and build the resume. The run's lock is taken first,
|
|
60
|
+
* before anything is read or repaired, and is kept by the returned journal
|
|
61
|
+
* (released on every other outcome, `--plan` included).
|
|
62
|
+
*/
|
|
54
63
|
export declare function prepareResume(ref: string, opts: ResumeOptions, deps: PrepareResumeDeps): PrepareResumeResult;
|
|
55
64
|
/** Journal is on unless `--no-journal` (options.journal === false) or `.sweny.yml` has `journal: off`. */
|
|
56
65
|
export declare function journalDisabled(optionJournal: unknown, configJournal: unknown): boolean;
|
package/dist/cli/resume.js
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import path from "node:path";
|
|
9
9
|
import { validateRuntimeInput } from "../inputs.js";
|
|
10
|
-
import {
|
|
10
|
+
import { existsSync } from "node:fs";
|
|
11
|
+
import { JournalKeyError, JournalLocationError, assertStateOutsideWorkspace, JournalLockedError, JournalRollbackError, JournalVersionError, RunJournal, buildResumePlan, canonicalHash, checkJournalAgainstWorkflow, findJournalRun, findLegacyJournal, journalFile, lockRun, mayRepeatWrites, readJournal, runStateRoot, workflowHashOf, } from "../journal.js";
|
|
11
12
|
import { parseInputFlag } from "./workflow-input.js";
|
|
12
13
|
/** Resume-only flags; the rest are shared with `workflow run`. */
|
|
13
14
|
export const WORKFLOW_RESUME_OPTIONS = [
|
|
@@ -69,23 +70,80 @@ export function formatResumePlan(plan, mayRepeat) {
|
|
|
69
70
|
lines.push(" every node finished; the resume only closes the run");
|
|
70
71
|
return lines;
|
|
71
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* Check a run journal and build the resume. The run's lock is taken first,
|
|
75
|
+
* before anything is read or repaired, and is kept by the returned journal
|
|
76
|
+
* (released on every other outcome, `--plan` included).
|
|
77
|
+
*/
|
|
72
78
|
export function prepareResume(ref, opts, deps) {
|
|
73
79
|
const cwd = deps.cwd ?? process.cwd();
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
80
|
+
const root = deps.stateRoot ?? runStateRoot(deps.env ?? process.env);
|
|
81
|
+
// A state dir that resolves into the workspace is agent-writable: nothing in it can be trusted.
|
|
82
|
+
if (existsSync(root)) {
|
|
83
|
+
try {
|
|
84
|
+
assertStateOutsideWorkspace(root, cwd);
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
if (err instanceof JournalLocationError)
|
|
88
|
+
return { ok: false, lines: [], error: err.message };
|
|
89
|
+
throw err;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const runId = findJournalRun(ref, cwd, root);
|
|
77
93
|
if (!runId) {
|
|
78
|
-
|
|
94
|
+
const legacy = findLegacyJournal(ref, cwd);
|
|
95
|
+
if (legacy) {
|
|
96
|
+
return {
|
|
97
|
+
ok: false,
|
|
98
|
+
lines: [],
|
|
99
|
+
error: `run ${ref} has a journal in the workspace (${path.relative(cwd, legacy)}), written by an older sweny. ` +
|
|
100
|
+
`Journals now live in the sweny state dir, where agents cannot edit them; a workspace journal cannot ` +
|
|
101
|
+
`be resumed safely. Start a new run.`,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
ok: false,
|
|
106
|
+
lines: [],
|
|
107
|
+
error: `no run journal matches "${ref}" for this workspace in ${root}. Runs started with --no-journal ` +
|
|
108
|
+
`(or by another user or state dir) cannot be resumed.`,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
let lock;
|
|
112
|
+
try {
|
|
113
|
+
lock = lockRun(cwd, runId, root, deps.lockHooks);
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
117
|
+
return {
|
|
118
|
+
ok: false,
|
|
119
|
+
lines: [],
|
|
120
|
+
error: err instanceof JournalLockedError ? error : `cannot lock run ${runId}: ${error}`,
|
|
121
|
+
};
|
|
79
122
|
}
|
|
80
|
-
|
|
123
|
+
let result;
|
|
124
|
+
try {
|
|
125
|
+
result = prepareLocked(runId, journalFile(cwd, runId, root), lock, opts, deps, cwd, root);
|
|
126
|
+
}
|
|
127
|
+
catch (err) {
|
|
128
|
+
lock.release();
|
|
129
|
+
throw err;
|
|
130
|
+
}
|
|
131
|
+
if (!(result.ok && "ctx" in result))
|
|
132
|
+
lock.release();
|
|
133
|
+
return result;
|
|
134
|
+
}
|
|
135
|
+
function prepareLocked(runId, file, lock, opts, deps, cwd, root) {
|
|
136
|
+
const lines = [];
|
|
137
|
+
const fail = (error) => ({ ok: false, error, lines });
|
|
81
138
|
let read;
|
|
82
139
|
try {
|
|
83
140
|
// --plan stays read-only: a torn tail is reported, not cut.
|
|
84
141
|
read = readJournal(file, { repair: !opts.plan });
|
|
85
142
|
}
|
|
86
143
|
catch (err) {
|
|
87
|
-
if (err instanceof JournalVersionError || err instanceof JournalKeyError)
|
|
144
|
+
if (err instanceof JournalVersionError || err instanceof JournalKeyError || err instanceof JournalRollbackError) {
|
|
88
145
|
return fail(err.message);
|
|
146
|
+
}
|
|
89
147
|
return fail(`cannot read ${file}: ${err instanceof Error ? err.message : String(err)}`);
|
|
90
148
|
}
|
|
91
149
|
if (read.forgedAtLine !== undefined) {
|
|
@@ -101,6 +159,11 @@ export function prepareResume(ref, opts, deps) {
|
|
|
101
159
|
? `note: the journal ends in a torn record (${read.truncatedBytes} bytes); resuming drops it`
|
|
102
160
|
: `note: dropped a torn record at the end of the journal (${read.truncatedBytes} bytes)`);
|
|
103
161
|
}
|
|
162
|
+
if (read.restored) {
|
|
163
|
+
lines.push(opts.plan
|
|
164
|
+
? `note: the journal's last record was not appended before the crash; resuming restores it from the head`
|
|
165
|
+
: `note: restored the journal's last record from the head (the run stopped before appending it)`);
|
|
166
|
+
}
|
|
104
167
|
let plan;
|
|
105
168
|
try {
|
|
106
169
|
plan = buildResumePlan(read.records);
|
|
@@ -154,6 +217,10 @@ export function prepareResume(ref, opts, deps) {
|
|
|
154
217
|
}
|
|
155
218
|
const mayRepeat = mayRepeatWrites(plan, workflow, input);
|
|
156
219
|
lines.push(...formatResumePlan(plan, mayRepeat));
|
|
220
|
+
if (plan.unsandboxed.length > 0) {
|
|
221
|
+
lines.push(`warning: an earlier attempt ran ${plan.unsandboxed.join(", ")} without an enforced sandbox, so that agent ` +
|
|
222
|
+
`could have read the run key and edited this run's journaled spend and write records (journal_unsandboxed)`);
|
|
223
|
+
}
|
|
157
224
|
if (opts.plan)
|
|
158
225
|
return { ok: true, planOnly: true, lines };
|
|
159
226
|
if (mayRepeat.length > 0 && !opts.allowRepeatWrites) {
|
|
@@ -170,8 +237,10 @@ export function prepareResume(ref, opts, deps) {
|
|
|
170
237
|
swenyVersion: deps.swenyVersion,
|
|
171
238
|
env: deps.env,
|
|
172
239
|
logger: deps.logger,
|
|
240
|
+
stateRoot: root,
|
|
173
241
|
read,
|
|
174
242
|
plan,
|
|
243
|
+
lock,
|
|
175
244
|
force: opts.force === true,
|
|
176
245
|
allowRepeatWrites: opts.allowRepeatWrites === true,
|
|
177
246
|
});
|
package/dist/executor.d.ts
CHANGED
|
@@ -18,7 +18,7 @@ import type { AgentHarness } from "./harness/types.js";
|
|
|
18
18
|
import type { DeciderOperatorConfig } from "./decider.js";
|
|
19
19
|
import type { Budget } from "./budget.js";
|
|
20
20
|
import { type ActorInfo } from "./safe-outputs.js";
|
|
21
|
-
import type
|
|
21
|
+
import { type ExecutionJournal } from "./journal.js";
|
|
22
22
|
export interface ExecuteOptions {
|
|
23
23
|
/** Registered skills (id → Skill) */
|
|
24
24
|
skills: Map<string, Skill>;
|
package/dist/executor.js
CHANGED
|
@@ -34,6 +34,7 @@ import { createRunDecider, wantsDecider } from "./decider.js";
|
|
|
34
34
|
import { redact, runSecretValues } from "./journal.js";
|
|
35
35
|
import { buildNodePolicy, resolveNodePermissions } from "./node-policy.js";
|
|
36
36
|
import { applySafeOutputs, createEmitOutputTool, createWriteStageState, resolveActor, safeOutputsInstruction, unresolvedOutputs, SAFE_OUTPUT_SCREEN_INSTRUCTION, } from "./safe-outputs.js";
|
|
37
|
+
import { JournalWriteError } from "./journal.js";
|
|
37
38
|
/**
|
|
38
39
|
* Default workflow-level step budget. Counts total node executions across the
|
|
39
40
|
* whole run (loop iterations included). Generous enough for legitimate deep
|
|
@@ -59,6 +60,43 @@ function throwIfAborted(signal) {
|
|
|
59
60
|
* - `trace`: full ordered execution trace including loops and routing decisions
|
|
60
61
|
*/
|
|
61
62
|
export async function execute(workflow, input, options) {
|
|
63
|
+
const results = new Map();
|
|
64
|
+
const trace = { steps: [], edges: [], sources: {} };
|
|
65
|
+
const at = { node: null, iteration: 0 };
|
|
66
|
+
// Resume of a journal an uncontained agent could have edited: the receipt says so.
|
|
67
|
+
const journalDegraded = () => {
|
|
68
|
+
const extra = options.journal?.degraded?.() ?? [];
|
|
69
|
+
if (extra.length === 0)
|
|
70
|
+
return;
|
|
71
|
+
for (const [id, r] of results)
|
|
72
|
+
results.set(id, { ...r, degraded: [...new Set([...(r.degraded ?? []), ...extra])] });
|
|
73
|
+
};
|
|
74
|
+
try {
|
|
75
|
+
const out = await executeRun(workflow, input, options, results, trace, at);
|
|
76
|
+
journalDegraded();
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
// Resume (#363): a journal that cannot record the run stops it. The node
|
|
81
|
+
// fails with the reason; nothing after it runs (no model call, no write).
|
|
82
|
+
if (!(err instanceof JournalWriteError) || at.node === null)
|
|
83
|
+
throw err;
|
|
84
|
+
const logger = options.logger ?? consoleLogger;
|
|
85
|
+
const result = {
|
|
86
|
+
...(results.get(at.node) ?? { toolCalls: [] }),
|
|
87
|
+
status: "failed",
|
|
88
|
+
data: { ...(results.get(at.node)?.data ?? {}), error: err.message, journal_failed: true },
|
|
89
|
+
};
|
|
90
|
+
results.set(at.node, result);
|
|
91
|
+
trace.steps.push({ node: at.node, status: "failed", iteration: at.iteration });
|
|
92
|
+
safeObserve(options.observer, { type: "node:exit", node: at.node, result }, logger);
|
|
93
|
+
logger.error(` ${err.message}`, { node: at.node });
|
|
94
|
+
journalDegraded();
|
|
95
|
+
safeObserve(options.observer, { type: "workflow:end", results: Object.fromEntries(results) }, logger);
|
|
96
|
+
return { results, trace };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
async function executeRun(workflow, input, options, results, trace, at) {
|
|
62
100
|
const { observer, signal, timeoutMs, journal } = options;
|
|
63
101
|
// `harness` is the seam; a legacy `claude` object is used as-is (what `asClaude(claudeCompat(claude))` yields).
|
|
64
102
|
const claude = options.harness ? asClaude(options.harness) : options.claude;
|
|
@@ -74,12 +112,10 @@ export async function execute(workflow, input, options) {
|
|
|
74
112
|
const skills = mergeInlineSkills(options.skills, workflow.skills);
|
|
75
113
|
const config = resolveConfig(skills, options.config, options.env ?? process.env);
|
|
76
114
|
const logger = options.logger ?? consoleLogger;
|
|
77
|
-
const results = new Map();
|
|
78
115
|
const edgeCounts = new Map(); // "from→to" → times followed
|
|
79
116
|
const nodeRunCounts = new Map(); // node → times executed
|
|
80
117
|
const maxSteps = options.max_steps ?? DEFAULT_MAX_STEPS;
|
|
81
118
|
let stepCount = 0; // total node executions across the run (loop iterations included)
|
|
82
|
-
const trace = { steps: [], edges: [], sources: {} };
|
|
83
119
|
validate(workflow, skills);
|
|
84
120
|
// Safe outputs (#365): run-wide caps and dedupe, the staged switch and the
|
|
85
121
|
// triggering actor, resolved once per run.
|
|
@@ -226,6 +262,8 @@ export async function execute(workflow, input, options) {
|
|
|
226
262
|
}
|
|
227
263
|
const iteration = (nodeRunCounts.get(currentId) ?? 0) + 1;
|
|
228
264
|
nodeRunCounts.set(currentId, iteration);
|
|
265
|
+
at.node = currentId;
|
|
266
|
+
at.iteration = iteration;
|
|
229
267
|
const resolvedInstruction = resolvedSources[`nodes.${currentId}.instruction`].content;
|
|
230
268
|
safeObserve(observer, { type: "node:enter", node: currentId, instruction: resolvedInstruction }, logger);
|
|
231
269
|
logger.info(`→ ${node.name}`, { node: currentId });
|
|
@@ -506,10 +544,30 @@ export async function execute(workflow, input, options) {
|
|
|
506
544
|
// The repair call (at most one) shifts later attempts by one, so every agent call has its own key.
|
|
507
545
|
const usageAttempt = attempt + (repaired ? 1 : 0);
|
|
508
546
|
let liveUsage;
|
|
547
|
+
// A live usage record the journal cannot write stops the agent at once:
|
|
548
|
+
// its spend would be invisible to a resume.
|
|
549
|
+
let journalFault;
|
|
550
|
+
const journalStop = journal?.usage ? new AbortController() : undefined;
|
|
551
|
+
const parentSignal = attemptBudget?.signal ?? signal;
|
|
552
|
+
const onParentAbort = () => journalStop?.abort(parentSignal?.reason);
|
|
553
|
+
if (journalStop && parentSignal) {
|
|
554
|
+
if (parentSignal.aborted)
|
|
555
|
+
journalStop.abort(parentSignal.reason);
|
|
556
|
+
else
|
|
557
|
+
parentSignal.addEventListener("abort", onParentAbort, { once: true });
|
|
558
|
+
}
|
|
509
559
|
const journalUsage = journal?.usage
|
|
510
560
|
? (u) => {
|
|
511
561
|
liveUsage = { ...liveUsage, ...u };
|
|
512
|
-
|
|
562
|
+
if (journalFault)
|
|
563
|
+
return;
|
|
564
|
+
try {
|
|
565
|
+
journal.usage(usageNode, iteration, usageAttempt, liveUsage, false);
|
|
566
|
+
}
|
|
567
|
+
catch (err) {
|
|
568
|
+
journalFault = err;
|
|
569
|
+
journalStop?.abort(err);
|
|
570
|
+
}
|
|
513
571
|
}
|
|
514
572
|
: undefined;
|
|
515
573
|
const onUsage = attemptBudget || journalUsage
|
|
@@ -529,7 +587,7 @@ export async function execute(workflow, input, options) {
|
|
|
529
587
|
disallowedTools: node.disallowed_tools,
|
|
530
588
|
...(denyClasses.length > 0 ? { deny: denyClasses } : {}),
|
|
531
589
|
model: nodeModel,
|
|
532
|
-
signal:
|
|
590
|
+
signal: journalStop?.signal ?? parentSignal,
|
|
533
591
|
timeoutMs,
|
|
534
592
|
agentAccess,
|
|
535
593
|
...(nodePolicy ? { policy: nodePolicy } : {}),
|
|
@@ -541,9 +599,24 @@ export async function execute(workflow, input, options) {
|
|
|
541
599
|
},
|
|
542
600
|
});
|
|
543
601
|
}
|
|
602
|
+
catch (err) {
|
|
603
|
+
if (journalFault)
|
|
604
|
+
throw journalFault;
|
|
605
|
+
// The attempt ended abnormally (abort, timeout, harness error): whether its agent was
|
|
606
|
+
// contained is unknown, so the journal says it was not.
|
|
607
|
+
journal?.uncontained?.(usageNode, iteration);
|
|
608
|
+
throw err;
|
|
609
|
+
}
|
|
544
610
|
finally {
|
|
611
|
+
parentSignal?.removeEventListener("abort", onParentAbort);
|
|
545
612
|
attemptBudget?.dispose();
|
|
546
613
|
}
|
|
614
|
+
if (journalFault)
|
|
615
|
+
throw journalFault;
|
|
616
|
+
// Key secrecy (and so the journal's spend and write records) holds only for a contained agent.
|
|
617
|
+
if (result.contained !== true && result.data?.refused !== true) {
|
|
618
|
+
journal?.uncontained?.(usageNode, iteration);
|
|
619
|
+
}
|
|
547
620
|
previous = result;
|
|
548
621
|
// The attempt's final spend: the larger of the result's usage and the last live report, per unit.
|
|
549
622
|
if (journal?.usage) {
|
package/dist/harness/acp.js
CHANGED
|
@@ -713,6 +713,8 @@ export class AcpHarness {
|
|
|
713
713
|
const extraDegraded = [];
|
|
714
714
|
const tag = (r) => ({
|
|
715
715
|
...r,
|
|
716
|
+
// Only the process wrapper keeps the agent out of the sweny state dir.
|
|
717
|
+
contained: wrapper !== undefined,
|
|
716
718
|
harness: this.info(),
|
|
717
719
|
degraded: [...degraded, ...extraDegraded],
|
|
718
720
|
});
|
|
@@ -51,6 +51,24 @@ export declare const CLAUDE_TOOLS_BY_CLASS: Readonly<Record<ToolClass, readonly
|
|
|
51
51
|
* `denyRead` (merged with these rules) covers Bash.
|
|
52
52
|
*/
|
|
53
53
|
export declare function claudeReadDenyRule(absPath: string): string;
|
|
54
|
+
/**
|
|
55
|
+
* Deny rules keeping every built-in file tool out of the run journals' state
|
|
56
|
+
* dir ({@link runStateRoot}): an agent that could read a run key could forge
|
|
57
|
+
* journal records, and one that could write there could cut a journal or
|
|
58
|
+
* plant a run.
|
|
59
|
+
*
|
|
60
|
+
* Mechanism: scoped deny rules on `disallowedTools`. Per the Agent SDK's
|
|
61
|
+
* permission evaluation order (code.claude.com/docs/en/agent-sdk/permissions,
|
|
62
|
+
* "Deny rules"), a matching deny rule from `disallowed_tools` blocks the tool
|
|
63
|
+
* "even in `bypassPermissions` mode", which every node runs under (#365);
|
|
64
|
+
* `canUseTool` and allow rules are never reached in that mode. `Read(path)`
|
|
65
|
+
* rules govern the built-in file readers (Read, and Grep/Glob), and
|
|
66
|
+
* `Edit(path)` rules "govern all built-in tools that write files, including
|
|
67
|
+
* `Write` and `NotebookEdit`" (a `Write(path)` rule is never matched, so none
|
|
68
|
+
* is emitted). `//` anchors an absolute filesystem path. Both the path as
|
|
69
|
+
* configured and its real path are denied (macOS `/var` is `/private/var`).
|
|
70
|
+
*/
|
|
71
|
+
export declare function claudeStateDirDenyRules(root?: string): string[];
|
|
54
72
|
/** Native `disallowedTools` for a policy: legacy names, compiled classes, and the read-only set. */
|
|
55
73
|
export declare function compileClaudeCodeDeny(policy: NodePolicy, legacy?: readonly string[]): string[];
|
|
56
74
|
/** How sweny resolves which credentials reach the Claude Code subprocess. */
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { query, createSdkMcpServer, tool as sdkTool } from "@anthropic-ai/claude-agent-sdk";
|
|
12
12
|
import { createRequire } from "node:module";
|
|
13
|
+
import { realpathSync } from "node:fs";
|
|
13
14
|
import { consoleLogger } from "../types.js";
|
|
14
15
|
import { AGENT_AUTH_VARS, finishAgentEnv, reportWithheldEnv, parseList, resolveEnvScope, scopeAgentEnv, withPushBlocked, mcpWithheld, resolveAgentSandbox, } from "../agent-env.js";
|
|
15
16
|
import { gitCredentialGap, policyGate } from "./policy.js";
|
|
@@ -20,6 +21,7 @@ import { jsonSchemaToZodShape, toolErrorToMcpResult, toolOutputToMcpResult } fro
|
|
|
20
21
|
import { startToolBridge } from "./tool-bridge/server.js";
|
|
21
22
|
import { parseToolResultContent, summarizeToolError, tryParseJSON } from "./parse.js";
|
|
22
23
|
import { makeAbort } from "./abort.js";
|
|
24
|
+
import { runStateRoot } from "../journal.js";
|
|
23
25
|
import { claudeCodeAuth } from "./auth.js";
|
|
24
26
|
export { buildEvaluatePrompt, CLAUDE_CODE_CAPABILITIES };
|
|
25
27
|
export { parseToolResultContent, summarizeToolError, makeAbort };
|
|
@@ -96,6 +98,39 @@ export const CLAUDE_TOOLS_BY_CLASS = {
|
|
|
96
98
|
export function claudeReadDenyRule(absPath) {
|
|
97
99
|
return `Read(/${absPath.startsWith("/") ? absPath : `/${absPath}`})`;
|
|
98
100
|
}
|
|
101
|
+
/**
|
|
102
|
+
* Deny rules keeping every built-in file tool out of the run journals' state
|
|
103
|
+
* dir ({@link runStateRoot}): an agent that could read a run key could forge
|
|
104
|
+
* journal records, and one that could write there could cut a journal or
|
|
105
|
+
* plant a run.
|
|
106
|
+
*
|
|
107
|
+
* Mechanism: scoped deny rules on `disallowedTools`. Per the Agent SDK's
|
|
108
|
+
* permission evaluation order (code.claude.com/docs/en/agent-sdk/permissions,
|
|
109
|
+
* "Deny rules"), a matching deny rule from `disallowed_tools` blocks the tool
|
|
110
|
+
* "even in `bypassPermissions` mode", which every node runs under (#365);
|
|
111
|
+
* `canUseTool` and allow rules are never reached in that mode. `Read(path)`
|
|
112
|
+
* rules govern the built-in file readers (Read, and Grep/Glob), and
|
|
113
|
+
* `Edit(path)` rules "govern all built-in tools that write files, including
|
|
114
|
+
* `Write` and `NotebookEdit`" (a `Write(path)` rule is never matched, so none
|
|
115
|
+
* is emitted). `//` anchors an absolute filesystem path. Both the path as
|
|
116
|
+
* configured and its real path are denied (macOS `/var` is `/private/var`).
|
|
117
|
+
*/
|
|
118
|
+
export function claudeStateDirDenyRules(root = runStateRoot()) {
|
|
119
|
+
let real = root;
|
|
120
|
+
try {
|
|
121
|
+
real = realpathSync(root);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
// not created yet: the configured path is the one tools would use
|
|
125
|
+
}
|
|
126
|
+
const abs = (p) => (p.startsWith("/") ? p : `/${p}`);
|
|
127
|
+
return [...new Set([root, real])].flatMap((dir) => [
|
|
128
|
+
`Read(/${abs(dir)})`,
|
|
129
|
+
`Read(/${abs(dir)}/**)`,
|
|
130
|
+
`Edit(/${abs(dir)})`,
|
|
131
|
+
`Edit(/${abs(dir)}/**)`,
|
|
132
|
+
]);
|
|
133
|
+
}
|
|
99
134
|
/** Native `disallowedTools` for a policy: legacy names, compiled classes, and the read-only set. */
|
|
100
135
|
export function compileClaudeCodeDeny(policy, legacy = []) {
|
|
101
136
|
return [
|
|
@@ -344,6 +379,8 @@ export class ClaudeCodeHarness {
|
|
|
344
379
|
harness: this.info(),
|
|
345
380
|
degraded,
|
|
346
381
|
...(this.lastPolicyFacts ? { policy: this.lastPolicyFacts } : {}),
|
|
382
|
+
// The state dir deny rules hold for the file tools either way; Bash is held only by the sandbox.
|
|
383
|
+
contained: this.lastPolicyFacts?.sandboxStarted === true,
|
|
347
384
|
};
|
|
348
385
|
}
|
|
349
386
|
async runQuery(opts) {
|
|
@@ -354,6 +391,9 @@ export class ClaudeCodeHarness {
|
|
|
354
391
|
let disallowedTools = readOnly
|
|
355
392
|
? [...new Set([...(opts.disallowedTools ?? []), ...READ_ONLY_DISALLOWED_TOOLS])]
|
|
356
393
|
: opts.disallowedTools;
|
|
394
|
+
// Run journals, their keys and locks live in the sweny state dir: no built-in
|
|
395
|
+
// file tool may read or write there (Bash is held by the sandbox's filesystem deny).
|
|
396
|
+
disallowedTools = [...new Set([...(disallowedTools ?? []), ...claudeStateDirDenyRules()])];
|
|
357
397
|
const effectiveModel = model ?? this.model;
|
|
358
398
|
// #360: run agent commands in the SDK sandbox. `auto` (default in CI) falls
|
|
359
399
|
// back to unsandboxed with one loud warning when the host cannot sandbox;
|
|
@@ -517,12 +557,17 @@ export class ClaudeCodeHarness {
|
|
|
517
557
|
...(abort ? { abortController: abort.controller } : {}),
|
|
518
558
|
...(effectiveModel ? { model: effectiveModel } : {}),
|
|
519
559
|
...(Object.keys(allMcpServers).length > 0 ? { mcpServers: allMcpServers } : {}),
|
|
520
|
-
//
|
|
521
|
-
//
|
|
522
|
-
//
|
|
523
|
-
//
|
|
524
|
-
//
|
|
525
|
-
|
|
560
|
+
// Only what sweny passes runs: an ambient MCP server or plugin (user
|
|
561
|
+
// settings, a repo's .mcp.json or .claude/settings.json, which are
|
|
562
|
+
// repo content) could read the run journals' state dir around the
|
|
563
|
+
// sandbox and the deny rules. SDK Options (sdk.d.ts):
|
|
564
|
+
// `strictMcpConfig` "Only use MCP servers passed via the `mcpServers`
|
|
565
|
+
// option ... ignoring ... project `.mcp.json`, user settings,
|
|
566
|
+
// plugins"; `settingSources` "Pass `[]` to disable filesystem
|
|
567
|
+
// settings (SDK isolation mode)", so no user/project/local
|
|
568
|
+
// settings, hooks or settings-enabled plugins load either.
|
|
569
|
+
strictMcpConfig: true,
|
|
570
|
+
settingSources: [],
|
|
526
571
|
...(disallowedTools && disallowedTools.length > 0 ? { disallowedTools } : {}),
|
|
527
572
|
// CC-08: ask the SDK to produce validated structured output when the
|
|
528
573
|
// node declares an output schema. The SDK then returns the parsed
|
package/dist/harness/codex.js
CHANGED
|
@@ -758,6 +758,8 @@ export class CodexHarness {
|
|
|
758
758
|
let degraded = gate.degraded;
|
|
759
759
|
const tag = (r, extra = []) => ({
|
|
760
760
|
...r,
|
|
761
|
+
// Only the process wrapper keeps Codex out of the sweny state dir (its own sandbox allows reads).
|
|
762
|
+
contained: wrapper !== undefined,
|
|
761
763
|
harness: this.info(),
|
|
762
764
|
degraded: [...degraded, ...extra],
|
|
763
765
|
});
|
package/dist/harness/pi.js
CHANGED
|
@@ -756,6 +756,8 @@ export class PiHarness {
|
|
|
756
756
|
const extraDegraded = [];
|
|
757
757
|
const tag = (r) => ({
|
|
758
758
|
...r,
|
|
759
|
+
// Only the process wrapper keeps the agent out of the sweny state dir.
|
|
760
|
+
contained: wrapper !== undefined,
|
|
759
761
|
harness: this.info(),
|
|
760
762
|
degraded: [...degraded, ...extraDegraded],
|
|
761
763
|
});
|
|
@@ -48,7 +48,7 @@ import { lstat, mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises
|
|
|
48
48
|
import { homedir, tmpdir } from "node:os";
|
|
49
49
|
import * as path from "node:path";
|
|
50
50
|
import { DEFAULT_SANDBOX_DOMAINS, parseList, resolveSandboxMode } from "../agent-env.js";
|
|
51
|
-
import {
|
|
51
|
+
import { runStateRoot } from "../journal.js";
|
|
52
52
|
import { policyGate } from "./policy.js";
|
|
53
53
|
// ─── srt settings ────────────────────────────────────────────────
|
|
54
54
|
/**
|
|
@@ -132,13 +132,13 @@ export function buildSrtSettings(req, opts) {
|
|
|
132
132
|
// cannot rename one out from under its read deny.
|
|
133
133
|
const masked = [...new Set((req.denyRead ?? []).map(real))].filter((p) => exists(p));
|
|
134
134
|
denyRead.push(...masked.filter((p) => !denyRead.includes(p)));
|
|
135
|
-
// Run
|
|
136
|
-
//
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
const denyWrite =
|
|
135
|
+
// Run journals, their keys and locks (the sweny state dir, outside the
|
|
136
|
+
// workspace) are neither readable nor writable by the agent.
|
|
137
|
+
const journals = real(runStateRoot());
|
|
138
|
+
const journalsExist = exists(journals);
|
|
139
|
+
if (journalsExist && !denyRead.includes(journals))
|
|
140
|
+
denyRead.push(journals);
|
|
141
|
+
const denyWrite = journalsExist ? [...masked, journals] : masked;
|
|
142
142
|
const allowWrite = req.readOnly ? [opts.home] : [real(req.cwd), opts.home];
|
|
143
143
|
return {
|
|
144
144
|
network: { allowedDomains, deniedDomains: [], strictAllowlist: true, allowLocalBinding: false },
|