Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion packages/core/src/fakes/sandbox-fake.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,11 @@ export const makeSandboxFake = (
? Effect.fail(
new ExecTimeout({
timeoutSec: canned.timeoutSec ?? opts.timeoutSec ?? 600,
command,
// Scrubbed, as the live layer does. `ExecTimeout.message`
// inlines the command and Workflows persists it, so a fake that
// left it raw would let a run-level test pass on a property the
// live layer establishes and the fake contradicts.
command: redact(command, opts.redactValues),
}),
)
: Effect.fail(
Expand Down
7 changes: 5 additions & 2 deletions packages/core/src/services/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,11 @@ export type ExecOpts = {
readonly container?: Container;
/**
* Plaintext values (typically resolved Worker-secret values injected via
* `env`) to scrub from the captured stdout/stderr before EITHER the inline
* `ExecResult` tail or the full log streamed to R2 is persisted. A command
* `env`) to scrub from every durable surface this exec produces: the inline
* `ExecResult` tail, the full log streamed to R2, the COMMAND recorded in
* that log's meta line, and `ExecTimeout.command` (which Workflows persists
* as the attempt record). An implementation that scrubs only the streams does
* not satisfy this. A command
* that echoes an injected credential — deliberately or via a misbehaving
* tool — must not leak it through the log artifact's signed URL. Exact-
* substring match, each occurrence replaced with `"***"`. Empty/omitted is
Expand Down
53 changes: 53 additions & 0 deletions packages/runtime-cf/src/sandbox-cf.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -570,6 +570,59 @@ describe("makeSandboxCloudflareLive — exec result folding (D)", () => {
}),
);

it.effect("the COMMAND is scrubbed before it lands in the R2 meta line", () =>
Effect.gen(function* () {
// `writeLog` writes `{stream:"meta", command}` into the durable log. A
// command that inlines a credential reached R2 in the clear, beside the
// streams that had just been scrubbed.
currentBox = makeFakeBox({ proc: null });
currentBox.exec = vi.fn(async () => ({
exitCode: 0,
duration: 1,
stdout: "",
stderr: "",
}));
const { bucket, puts } = makeBucket();
const layer = makeSandboxCloudflareLive(ns, bucket, "exec-1");
yield* Effect.flatMap(SandboxTag, (s) =>
s.exec({
command: 'curl -H "Authorization: Bearer tok-abc123" https://api.example.com',
cwd: "/w",
env: {},
redactValues: ["tok-abc123"],
}),
).pipe(Effect.provide(layer));
const logBody = puts.map((p) => String(p.body)).join("");
expect(logBody).not.toContain("tok-abc123");
expect(logBody).toContain("***");
}),
);

it.effect("a timed-out exec does not carry its raw command into the Workflow record", () =>
Effect.gen(function* () {
// `ExecTimeout.message` inlines the command, and Workflows persists that
// as the attempt record — a second durable surface beside the R2 log.
currentBox = makeFakeBox({ proc: null });
currentBox.exec = vi.fn(async () => {
throw new Error("Command timeout after 30000ms");
});
const { bucket } = makeBucket();
const layer = makeSandboxCloudflareLive(ns, bucket, "exec-1");
const exit = yield* Effect.flatMap(SandboxTag, (s) =>
s.exec({
command: 'curl -H "Authorization: Bearer tok-abc123" https://api.example.com',
cwd: "/w",
env: {},
timeoutSec: 30,
redactValues: ["tok-abc123"],
}),
).pipe(Effect.provide(layer), Effect.exit);
const failure = failureOf(exit);
expect(failure?._tag).toBe("ExecTimeout");
expect(JSON.stringify(failure)).not.toContain("tok-abc123");
}),
);

it.effect("redacts before truncating, so a secret on the 4KB cut leaves no fragment", () =>
Effect.gen(function* () {
const secret = "SECRET_TOKEN_VALUE";
Expand Down
12 changes: 10 additions & 2 deletions packages/runtime-cf/src/sandbox-cf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -667,7 +667,12 @@ export const makeSandboxCloudflareLive = (
// FULL output → R2 (the durable log the artifact step promotes). Kept
// even on the working-dir-missing path below, so the failure is
// diagnosable from the log viewer.
await writeLog(logPath, cmd, stdout, stderr);
// The command rides the meta line into R2, so it is a persisted
// surface too. Scrubbing the streams beside it and leaving it in the
// clear only holds while no command inlines a credential, and
// `curl -H "Authorization: Bearer …"` is an ordinary thing for a
// consumer's own CI to run (adr/0006 § never-log list).
await writeLog(logPath, redact(cmd, redactValues), stdout, stderr);
// A command whose shell could not even enter its working directory
// never ran — the checkout did not survive to this exec (container
// recycled between durable steps). Raise a real ExecFailed rather than
Expand Down Expand Up @@ -700,9 +705,12 @@ export const makeSandboxCloudflareLive = (
// what Workflows persists via ExecFailed.message (#88).
const message = cause instanceof Error ? cause.message : String(cause);
if (/timed?\s*out|timeout/i.test(message)) {
// `ExecTimeout.message` inlines the command and Workflows persists
// that as the attempt record — a second durable surface, so it
// takes the same scrub as the R2 log.
return new ExecTimeout({
timeoutSec: timeoutSec ?? 0,
command: cmd,
command: redact(cmd, redactValues),
});
}
return new ExecFailed({
Expand Down
6 changes: 5 additions & 1 deletion packages/runtime-cf/src/sandbox-facade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -343,7 +343,11 @@ export const makeSandboxFacadeLive = (opts: SandboxFacadeOptions): Layer.Layer<S

const tail = redact(outcome.receipt.tail, redactValues);
const logPath = nextLogKey();
await writeLog(logPath, cmd, tail);
// Scrubbed for the same reason the tail above is: the meta line
// is a persisted R2 surface. Note this is the LOG's copy — the
// command handed to `execUnderGrant` above stays verbatim,
// because that one has to run.
await writeLog(logPath, redact(cmd, redactValues), tail);
const file = logPath.slice(logPath.lastIndexOf("/") + 1);
const viewerUrl =
opts.logsViewerBase !== undefined ? `${opts.logsViewerBase}#${file}` : undefined;
Expand Down
161 changes: 161 additions & 0 deletions runs/offload-test.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -627,6 +627,102 @@ describe("offload-test", () => {
},
);

it.effect("does NOT dispatch self-heal when the failing command inlines a secret value", () => {
// Neither version of such a command can honestly reach the healer: raw, it
// smuggles a live credential into the credential-free agent sandbox and
// into a pack an injection-steerable LLM reads; scrubbed, its `***` makes
// the verify re-run fail after the agent spend is already paid.
const { layer, handles } = makeCFRuntimeTest({
secrets: { DEPLOY_TOKEN: "tok-abc123" },
sandboxProgram: {
"curl -H 'Authorization: Bearer tok-abc123' https://x": { exitCode: 7 },
},
config: { "self-heal.ci.enabled": "true" },
});
return Effect.gen(function* () {
yield* Effect.exit(
offloadTest.run({
...baseInput,
command: "curl -H 'Authorization: Bearer tok-abc123' https://x",
secrets: ["DEPLOY_TOKEN"],
}),
);
expect(handles.childRuns.spawned).toHaveLength(0);
}).pipe(Effect.provide(layer));
});

it.effect("scrubs the value the container actually received, not the shadowed store value", () => {
// Per-dispatch `env` wins over a same-named store secret. Scrubbing
// `Object.values(secretEnv)` alone would keep the LIVE credential in the
// rendered summary and redact a string that was never printed.
const { layer } = makeCFRuntimeTest({
secrets: { DEPLOY_TOKEN: "shadowed-store-value" },
sandboxProgram: {
"deploy --token live-dispatch-value": { exitCode: 3 },
},
config: {
"offload-test.stages:owner/name": "a",
"offload-test.command:owner/name:a": "deploy --token live-dispatch-value",
},
});
return Effect.gen(function* () {
const exit = yield* Effect.exit(
offloadTest.run({
repo: "owner/name",
sha: "abc123",
failOnNonZeroExit: true,
secrets: ["DEPLOY_TOKEN"],
env: { DEPLOY_TOKEN: "live-dispatch-value" },
}),
);
expect(Exit.isFailure(exit)).toBe(true);
const failure = Exit.isFailure(exit)
? Option.getOrUndefined(Cause.failureOption(exit.cause))
: undefined;
const rendered = `${(failure as { summaryMd?: string })?.summaryMd ?? ""} ${
(failure as { cause?: unknown })?.cause ?? ""
}`;
expect(rendered).not.toContain("live-dispatch-value");
expect(rendered).toContain("***");
}).pipe(Effect.provide(layer));
});

it.effect("the shadowed store value stays scrubbed too — it is still a live credential", () => {
// The mirror image of the shadow case: env overrides the key, but the
// command inlines the STORE value. Scrubbing only the effective value would
// put a live store credential in the rendered summary.
const { layer } = makeCFRuntimeTest({
secrets: { DEPLOY_TOKEN: "store-secret-value" },
sandboxProgram: {
"deploy --token store-secret-value": { exitCode: 3 },
},
config: {
"offload-test.stages:owner/name": "a",
"offload-test.command:owner/name:a": "deploy --token store-secret-value",
},
});
return Effect.gen(function* () {
const exit = yield* Effect.exit(
offloadTest.run({
repo: "owner/name",
sha: "abc123",
failOnNonZeroExit: true,
secrets: ["DEPLOY_TOKEN"],
env: { DEPLOY_TOKEN: "override-value" },
}),
);
expect(Exit.isFailure(exit)).toBe(true);
const failure = Exit.isFailure(exit)
? Option.getOrUndefined(Cause.failureOption(exit.cause))
: undefined;
const rendered = `${(failure as { summaryMd?: string })?.summaryMd ?? ""} ${
(failure as { cause?: unknown })?.cause ?? ""
}`;
expect(rendered).not.toContain("store-secret-value");
expect(rendered).toContain("***");
}).pipe(Effect.provide(layer));
});

it.effect("does NOT dispatch self-heal when the gate is unset", () => {
const { layer, handles } = makeCFRuntimeTest({
sandboxProgram: { "pnpm test": { exitCode: 1 } },
Expand Down Expand Up @@ -866,6 +962,71 @@ describe("offload-test staged mode", () => {
},
);

it.effect("a secret inlined in a stage command never reaches the check-run summary", () => {
// `redactValues` on the exec covers what the sandbox layer persists. It
// does not reach the command string this run embeds in `summaryMd`, which
// `stepFailedMd` renders as UNFENCED markdown into the GitHub check-run
// summary — public on a public repo, and the loudest of the three surfaces.
const { layer } = makeCFRuntimeTest({
secrets: { DEPLOY_TOKEN: "tok-abc123" },
sandboxProgram: {
"curl -H 'Authorization: Bearer tok-abc123' https://x": { exitCode: 3 },
},
config: {
"offload-test.stages:owner/name": "a",
"offload-test.command:owner/name:a": "curl -H 'Authorization: Bearer tok-abc123' https://x",
},
});

return Effect.gen(function* () {
const exit = yield* Effect.exit(
offloadTest.run({ ...webhookInput, secrets: ["DEPLOY_TOKEN"] }),
);
expect(Exit.isFailure(exit)).toBe(true);
const failure = Exit.isFailure(exit)
? Option.getOrUndefined(Cause.failureOption(exit.cause))
: undefined;
const rendered = `${(failure as { summaryMd?: string })?.summaryMd ?? ""} ${
(failure as { cause?: unknown })?.cause ?? ""
}`;
expect(rendered).not.toContain("tok-abc123");
expect(rendered).toContain("***");
}).pipe(Effect.provide(layer));
});

it.effect("a DYING stage's command is scrubbed too — the path that skips ExecTimeout", () => {
// The non-obvious half, and the one the red-path test above does not reach.
// A stage that dies is caught and replaced by `deadFailure`, so the raw
// `ExecTimeout` (whose `command` the sandbox layer scrubs) never surfaces —
// the run renders `stage.command` itself into `cause` and `summaryMd`.
const { layer } = makeCFRuntimeTest({
secrets: { DEPLOY_TOKEN: "tok-abc123" },
sandboxProgram: {
"deploy --token tok-abc123": { fail: "ExecTimeout", timeoutSec: 600 },
},
config: {
"offload-test.stages:owner/name": "a",
"offload-test.command:owner/name:a": "deploy --token tok-abc123",
},
});

return Effect.gen(function* () {
const exit = yield* Effect.exit(
offloadTest.run({ ...webhookInput, secrets: ["DEPLOY_TOKEN"] }),
);
expect(Exit.isFailure(exit)).toBe(true);
const failure = Exit.isFailure(exit)
? Option.getOrUndefined(Cause.failureOption(exit.cause))
: undefined;
expect((failure as { _tag?: string })?._tag).toBe("StepFailed");
const rendered = `${(failure as { summaryMd?: string })?.summaryMd ?? ""} ${
(failure as { cause?: unknown })?.cause ?? ""
}`;
expect(rendered).not.toContain("tok-abc123");
expect(rendered).toContain("***");
}).pipe(Effect.provide(layer));
});

it.effect(
"a non-zero stage stops the sequence — later stages skipped, failure names the stage, earlier logs uploaded",
() => {
Expand Down
Loading
Loading