feat(sandbox-providers): native providers honor postUploadCommands (Daytona executes; Kubernetes executes) (#10347)

## Thinking Path

> - Paperclip is the control plane for autonomous AI companies, so
runtime handoffs have to preserve the exact behavior an agent asked for.
> - The sandbox-provider layer is where uploaded files and follow-up
commands become a real in-sandbox operation.
> - The provider-delegable sync-in seam already exists from the prior
PR; without this follow-up, native providers can still accept
command-bearing uploads and silently drop the commands.
> - That is a fail-open gap for native sandbox providers, because the
file transfer succeeds while the intended post-upload work never runs.
> - This pull request teaches Daytona and Kubernetes to execute
`postUploadCommands` in order, inside the sandbox, after the files land.
> - The benefit is consistent and safer sync semantics: native providers
either run the commands as requested or fail fast instead of pretending
the operation completed fully.

## Linked Issues or Issue Description

This PR builds on the previously merged provider-delegable sync-in seam
and closes the remaining gap for native providers that still dropped
`postUploadCommands`.

Problem:
- A sync operation could include ordered `postUploadCommands`, but a
native provider could finish the file upload and skip the commands
entirely.
- That creates fail-open behavior for command-bearing uploads,
especially when the caller relies on the provider to execute the
follow-up action in the sandbox.

Proposed fix:
- Execute `postUploadCommands` inside the sandbox after file placement.
- Preserve the provided command order.
- Fail fast on the first non-zero exit or timeout.
- Keep command execution verbatim and confine any provided `cwd` under
the workspace root.

Related public PR:
- Refs: #10340

## What Changed

- Daytona `performSyncIn` now executes ordered `postUploadCommands`
through the existing `executeCommand` seam.
- Kubernetes `performSyncIn` now executes ordered `postUploadCommands`
through its streaming pod exec path.
- Added workspace confinement for provided `cwd` values and defaulted
missing `cwd` to the remote root.
- Added tests covering the new post-upload command execution behavior in
both provider packages.

## Verification

- Latest validation recorded on the handoff branch:
`@paperclipai/plugin-sdk` and `@paperclipai/plugin-kubernetes`
typechecks passed.
- Daytona Vitest: `63/63` passing in `plugin.test.ts`.
- Kubernetes Vitest: `195/195` passing, including `file-sync.test.ts`.
- `git log --oneline origin/master..HEAD` showed a single expected
commit on the branch.

## Risks

- Command execution semantics are stricter now, so malformed commands or
a bad `cwd` will fail the sync instead of being ignored.
- The change makes provider behavior more explicit, which can surface
previously hidden failures in callers that assumed commands were
optional.
- Timeout behavior may differ slightly between providers, so the failure
mode is intentionally fail-fast.

## Model Used

OpenAI Codex, GPT-5-based coding agent with tool use enabled.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Nicky Leach 2026-07-27 21:17:16 -07:00 committed by GitHub
parent 1cd09ed555
commit 0ccba45e4d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 551 additions and 0 deletions

View File

@ -7,12 +7,18 @@ import { promisify } from "node:util";
import type { FileDownloadRequest, FileDownloadResponse, FileUpload, Sandbox } from "@daytonaio/sdk";
import type {
PluginEnvironmentSyncResult,
PluginPostUploadCommand,
PluginSyncFileMapping,
PluginSyncOperation,
} from "@paperclipai/plugin-sdk";
const execFileAsync = promisify(execFile);
/** Convert a millisecond timeout to the whole-seconds value the Daytona SDK expects. */
function toTimeoutSeconds(timeoutMs: number): number {
return Math.max(1, Math.ceil(timeoutMs / 1000));
}
// Reserved scratch-name stem for staged uploads/downloads and remote tarballs.
// The runtime's base64 fallback stages to `<path>.paperclip-upload`; the native
// transport reuses the same reserved prefix so a provider temp never collides
@ -549,6 +555,65 @@ async function syncInDirectoryMapping(input: {
});
}
/**
* Execute an operation's ordered `postUploadCommands` in-sandbox AFTER its files
* have landed (Phase 3 / Security Conditions C1C4). Commands run in array order,
* fail-fast: the first non-zero exit or timeout throws and stops the rest no
* silent partial fallback (C4). Each `command` string is executed VERBATIM via the
* exec seam; the provider never rewrites, concatenates, or appends a shell fragment
* to it (C1/C3) the working directory rides `executeCommand`'s structured `cwd`
* argument, never a `cd &&` prefix on the command. Before exec, a present `cwd` is
* re-validated under the workspace remote dir with the same lexical
* ({@link assertConfinedSandboxPath}) + realpath/symlink ({@link assertSandboxPathsConfined})
* guards used for file placement (C2): `..`, absolute-escape, and symlink-escape
* are rejected fail-closed before any command runs. An absent `cwd` defaults to the
* provider-resolved remote dir never a process default cwd.
*
* Shared by the file- and directory-mapping paths: it runs once per operation,
* after every mapping of that operation has been placed.
*/
async function runPostUploadCommands(input: {
sandbox: Sandbox;
commands: PluginPostUploadCommand[];
remoteDir: string;
timeoutSeconds: number;
}): Promise<void> {
const { sandbox, commands, remoteDir, timeoutSeconds } = input;
for (const command of commands) {
// C2: re-confine the command cwd before exec. Absent → the remote dir (never a
// process default cwd); the remote dir is the confinement root itself, so only
// an explicit cwd carries untrusted input worth re-validating.
let cwd = remoteDir;
if (command.cwd != null) {
assertConfinedSandboxPath(remoteDir, command.cwd, "post-upload command cwd");
await assertSandboxPathsConfined({
sandbox,
remoteDir,
paths: [command.cwd],
timeoutSeconds,
label: "post-upload command cwd symlink-escape guard",
});
cwd = command.cwd;
}
// C1/C3: run the command VERBATIM with a structured cwd (no string rewrite).
// C4: first non-zero exit or timeout throws and aborts the remaining commands.
const commandTimeoutSeconds =
command.timeoutMs != null ? toTimeoutSeconds(command.timeoutMs) : timeoutSeconds;
const result = await sandbox.process.executeCommand(
command.command,
cwd,
undefined,
commandTimeoutSeconds,
);
if ((result.exitCode ?? 1) !== 0) {
const detail = (result.result ?? result.artifacts?.stdout ?? "").toString().trim();
throw new Error(
`Daytona post-upload command failed (exit ${result.exitCode ?? "unknown"})${detail ? `: ${detail}` : ""}`,
);
}
}
}
export async function performSyncIn(input: {
sandbox: Sandbox;
operations: PluginSyncOperation[];
@ -583,6 +648,16 @@ export async function performSyncIn(input: {
bytesTransferred += dirResult.bytesTransferred;
}
// Run the operation's ordered post-upload commands AFTER every file/directory
// mapping of this operation has landed (Phase 3 / C1C4). Absent/empty → no
// extra exec, byte-identical to a pre-contract operation.
await runPostUploadCommands({
sandbox: input.sandbox,
commands: operation.postUploadCommands ?? [],
remoteDir: input.remoteDir,
timeoutSeconds: input.timeoutSeconds,
});
operations.push({ operationId: operation.operationId, filesTransferred, bytesTransferred });
}
return { operations };

View File

@ -2762,6 +2762,209 @@ describe("daytona native file-sync hooks", () => {
expect(linkStat.isSymbolicLink()).toBe(true);
expect(await fs.readlink(path.join(restored, "shortcut"))).toBe("nested/data.txt");
});
// -------------------------------------------------------------------------
// Post-upload commands (Phase 3 / Security Conditions C1C4). Daytona runs an
// operation's ordered `postUploadCommands` in-sandbox AFTER `uploadFiles`,
// fail-fast, with the command `cwd` re-confined under the workspace remote dir.
// -------------------------------------------------------------------------
it("runs post-upload commands in array order AFTER uploadFiles, each verbatim via the exec seam", async () => {
const hostDir = await makeHostDir();
const source = path.join(hostDir, "config.txt");
await fs.writeFile(source, "plain");
const sandbox = createMockSandbox();
mockGet.mockResolvedValue(sandbox);
await plugin.definition.onEnvironmentSyncIn?.({
driverKey: "daytona",
companyId: "company-1",
environmentId: "env-1",
config: { timeoutMs: 300000, reuseLease: false },
lease: syncLease(),
operations: [
{
operationId: "op-cmd",
files: [{ sourcePath: source, targetPath: `${REMOTE_DIR}/config.txt`, kind: "file" }],
postUploadCommands: [
{ command: "codex-auth-merge --first" },
{ command: "chmod 600 config.txt" },
],
},
],
});
// Both commands ran, VERBATIM (first arg is the exact authored string — the
// provider never rewrote/concatenated a shell fragment onto it: C1/C3).
const findCall = (cmd: string) =>
sandbox.process.executeCommand.mock.calls.find(([c]: [string]) => c === cmd);
expect(findCall("codex-auth-merge --first")).toBeDefined();
expect(findCall("chmod 600 config.txt")).toBeDefined();
// Ordered: the first command's exec precedes the second's (C4 array order).
const orderOf = (cmd: string) => {
const idx = sandbox.process.executeCommand.mock.calls.findIndex(([c]: [string]) => c === cmd);
return sandbox.process.executeCommand.mock.invocationCallOrder[idx];
};
expect(orderOf("codex-auth-merge --first")).toBeLessThan(orderOf("chmod 600 config.txt"));
// Upload happened BEFORE the first command.
expect(sandbox.fs.uploadFiles.mock.invocationCallOrder[0]).toBeLessThan(
orderOf("codex-auth-merge --first"),
);
// Absent `cwd` defaults to the provider-resolved remote dir — never a process
// default cwd (C2). The command's structured cwd argument is REMOTE_DIR.
expect(findCall("codex-auth-merge --first")?.[1]).toBe(REMOTE_DIR);
});
it("aborts the operation fail-loud on a non-zero post-upload command exit, skipping the remainder (C4)", async () => {
const hostDir = await makeHostDir();
const source = path.join(hostDir, "config.txt");
await fs.writeFile(source, "plain");
const sandbox = createMockSandbox();
// The first command exits non-zero; every transfer/guard script stays green.
sandbox.process.executeCommand.mockImplementation(async (command: string) => {
if (command === "failing-command") {
return { exitCode: 7, result: "boom", artifacts: { stdout: "boom" } };
}
return { exitCode: 0, result: "", artifacts: { stdout: "" } };
});
mockGet.mockResolvedValue(sandbox);
await expect(
plugin.definition.onEnvironmentSyncIn?.({
driverKey: "daytona",
companyId: "company-1",
environmentId: "env-1",
config: { timeoutMs: 300000, reuseLease: false },
lease: syncLease(),
operations: [
{
operationId: "op-fail",
files: [{ sourcePath: source, targetPath: `${REMOTE_DIR}/config.txt`, kind: "file" }],
postUploadCommands: [{ command: "failing-command" }, { command: "should-not-run" }],
},
],
}),
).rejects.toThrow(/post-upload command failed \(exit 7\)/);
// Fail-fast: the command after the failing one never executed.
expect(
sandbox.process.executeCommand.mock.calls.some(([c]: [string]) => c === "should-not-run"),
).toBe(false);
});
it("rejects a post-upload command cwd that escapes the remote dir lexically, before any exec (C2)", async () => {
const hostDir = await makeHostDir();
const source = path.join(hostDir, "config.txt");
await fs.writeFile(source, "plain");
for (const badCwd of [`${REMOTE_DIR}/../etc`, "/etc"]) {
const sandbox = createMockSandbox();
mockGet.mockResolvedValue(sandbox);
await expect(
plugin.definition.onEnvironmentSyncIn?.({
driverKey: "daytona",
companyId: "company-1",
environmentId: "env-1",
config: { timeoutMs: 300000, reuseLease: false },
lease: syncLease(),
operations: [
{
operationId: "op-escape",
files: [{ sourcePath: source, targetPath: `${REMOTE_DIR}/config.txt`, kind: "file" }],
postUploadCommands: [{ command: "run-me", cwd: badCwd }],
},
],
}),
).rejects.toThrow(/not a confined absolute path|escapes the workspace remote dir/);
// The command never ran — lexical confinement rejected it before exec.
expect(sandbox.process.executeCommand.mock.calls.some(([c]: [string]) => c === "run-me")).toBe(
false,
);
}
});
it("rejects a post-upload command whose cwd resolves outside the root via a symlink (realpath guard, C2)", async () => {
const hostDir = await makeHostDir();
const source = path.join(hostDir, "config.txt");
await fs.writeFile(source, "plain");
const cwd = `${REMOTE_DIR}/link`;
const sandbox = createMockSandbox();
// The in-sandbox realpath symlink-escape guard for THIS cwd fails closed (exit
// 42), simulating a sandbox-planted symlink that resolves out of root.
sandbox.process.executeCommand.mockImplementation(async (command: string) => {
if (command.includes("_pc_resolve") && command.includes(cwd)) {
return { exitCode: 42, result: "ESCAPE", artifacts: { stdout: "ESCAPE" } };
}
return { exitCode: 0, result: "", artifacts: { stdout: "" } };
});
mockGet.mockResolvedValue(sandbox);
await expect(
plugin.definition.onEnvironmentSyncIn?.({
driverKey: "daytona",
companyId: "company-1",
environmentId: "env-1",
config: { timeoutMs: 300000, reuseLease: false },
lease: syncLease(),
operations: [
{
operationId: "op-symlink",
files: [{ sourcePath: source, targetPath: `${REMOTE_DIR}/config.txt`, kind: "file" }],
postUploadCommands: [{ command: "run-me", cwd }],
},
],
}),
).rejects.toThrow(/symlink-escape guard|command failed/i);
expect(sandbox.process.executeCommand.mock.calls.some(([c]: [string]) => c === "run-me")).toBe(
false,
);
});
it("issues no extra exec when an operation has no post-upload commands (backward-compat)", async () => {
const hostDir = await makeHostDir();
const source = path.join(hostDir, "config.txt");
await fs.writeFile(source, "plain");
const baseline = createMockSandbox();
mockGet.mockResolvedValue(baseline);
await plugin.definition.onEnvironmentSyncIn?.({
driverKey: "daytona",
companyId: "company-1",
environmentId: "env-1",
config: { timeoutMs: 300000, reuseLease: false },
lease: syncLease(),
operations: [
{ operationId: "op-plain", files: [{ sourcePath: source, targetPath: `${REMOTE_DIR}/config.txt`, kind: "file" }] },
],
});
const baselineExecCount = baseline.process.executeCommand.mock.calls.length;
// Same operation, now with an (empty) postUploadCommands array — must be
// byte-identical: an absent/empty command list adds zero execs.
const withEmpty = createMockSandbox();
mockGet.mockResolvedValue(withEmpty);
await plugin.definition.onEnvironmentSyncIn?.({
driverKey: "daytona",
companyId: "company-1",
environmentId: "env-1",
config: { timeoutMs: 300000, reuseLease: false },
lease: syncLease(),
operations: [
{
operationId: "op-plain",
files: [{ sourcePath: source, targetPath: `${REMOTE_DIR}/config.txt`, kind: "file" }],
postUploadCommands: [],
},
],
});
expect(withEmpty.process.executeCommand.mock.calls.length).toBe(baselineExecCount);
});
});
describe("daytona manifest memory config", () => {

View File

@ -54,6 +54,7 @@ import { execFile } from "node:child_process";
import { promisify } from "node:util";
import type {
PluginEnvironmentSyncResult,
PluginPostUploadCommand,
PluginSyncFileMapping,
PluginSyncOperation,
} from "@paperclipai/plugin-sdk";
@ -574,6 +575,70 @@ async function syncInDirectoryMapping(input: {
});
}
/**
* Execute an operation's ordered `postUploadCommands` in the pod AFTER its files
* have landed (Phase 3 / Security Conditions C1C4 + C7). Kubernetes is a native
* provider, and once the inbound-staging router stops diverting command-bearing
* (custom-provision) assets away from the native seam, a K8s deployment receives
* these operations so it EXECUTES them symmetrically with Daytona rather than
* silently dropping them (C7 fail-open).
*
* Commands run in array order, fail-fast: the first non-zero exit or timeout throws
* and stops the rest (C4). Each `command` is executed VERBATIM: it rides as a
* positional argument (`$1`) to a fixed wrapper, run by `sh -c "$1"`, and is never
* string-concatenated into the wrapper so the provider adds no shell fragment of
* its own (C1/C3), exactly as the generic fallback runs `sh -c <command>`. Before
* the command runs, the wrapper re-confines the command `cwd` under the workspace
* remote dir with the same realpath + `/proc/self/fd`-pinned open used for file
* placement (C2): a `..`, absolute-escape, or symlink-escape `cwd` is rejected
* fail-closed (exit 42) before the command runs, and the pin makes the confinement
* race-free against a post-resolve ancestor swap. `cwd` is validated lexically on
* the host first; when absent it defaults to the remote dir never a process
* default cwd.
*
* Shared by the file- and directory-mapping paths: it runs once per operation,
* after every mapping of that operation has been placed.
*/
async function runPostUploadCommands(input: {
exec: PodStreamExec;
commands: PluginPostUploadCommand[];
remoteDir: string;
timeoutMs: number;
}): Promise<void> {
const { exec, commands, remoteDir, timeoutMs } = input;
for (const command of commands) {
const cwd = command.cwd ?? remoteDir;
// C2 lexical guard on the host before the pod ever sees the cwd.
assertConfinedSandboxPath(remoteDir, cwd, "post-upload command cwd");
// The wrapper confines `cwd` ($1) through a realpath + /proc/self/fd pin, cd's
// into the pinned inode, then execs the VERBATIM command ($2). Both `cwd` and
// the command ride as positional parameters — the wrapper interpolates NEITHER
// into its own script text, so the command runs byte-for-byte as authored.
const wrapper = [
...canonicalizerPreamble(shQuote(remoteDir)),
`_pc_real=$(_pc_resolve "$1") || { echo "ESCAPE" >&2; exit 42; };`,
`case "$_pc_real/" in "$_pc_root"/*) : ;; *) echo "ESCAPE" >&2; exit 42 ;; esac;`,
`exec 9<"$_pc_real" || { echo "open failed" >&2; exit 46; };`,
`_pc_fd_real=$(_pc_resolve /proc/self/fd/9) || { echo "ESCAPE" >&2; exit 42; };`,
`case "$_pc_fd_real/" in "$_pc_root"/*) : ;; *) echo "ESCAPE" >&2; exit 42 ;; esac;`,
`cd /proc/self/fd/9 || { echo "cd failed" >&2; exit 46; };`,
`exec /bin/sh -c "$2" pc-post-upload;`,
].join("\n");
const commandTimeoutMs = command.timeoutMs ?? timeoutMs;
// Positional args: $0=pc-post-upload, $1=cwd, $2=the verbatim command string.
const result = await exec(["/bin/sh", "-c", wrapper, "pc-post-upload", cwd, command.command], {
timeoutMs: commandTimeoutMs,
maxStderrBytes: SYNC_STDERR_CAP_BYTES,
});
if (result.exitCode !== 0) {
const detail = (result.stderr || "").trim();
throw new Error(
`Kubernetes post-upload command failed (exit ${result.exitCode})${detail ? `: ${detail}` : ""}`,
);
}
}
}
export async function performSyncIn(input: {
exec: PodStreamExec;
operations: PluginSyncOperation[];
@ -608,6 +673,16 @@ export async function performSyncIn(input: {
bytesTransferred += dirResult.bytesTransferred;
}
// Run the operation's ordered post-upload commands AFTER every file/directory
// mapping of this operation has landed (Phase 3 / C1C4 + C7). Absent/empty →
// no extra exec, byte-identical to a pre-contract operation.
await runPostUploadCommands({
exec: input.exec,
commands: operation.postUploadCommands ?? [],
remoteDir: input.remoteDir,
timeoutMs: input.timeoutMs,
});
operations.push({ operationId: operation.operationId, filesTransferred, bytesTransferred });
}
return { operations };

View File

@ -559,3 +559,200 @@ describe("kubernetes onEnvironmentSyncOut (native single-exec transfer)", () =>
await expect(fs.stat(target)).rejects.toThrow();
});
});
// ---------------------------------------------------------------------------
// Post-upload commands (Phase 3 / Security Conditions C1C4 + C7). Kubernetes is
// a native provider, so it EXECUTES an operation's ordered `postUploadCommands`
// symmetrically with Daytona after `uploadFiles` — never silently dropping them
// (C7). Each command runs in the pod over its own exec, fail-fast, with the `cwd`
// re-confined under the workspace remote dir. The injected exec runs the real
// host shell, so these assertions observe true command side-effects.
// ---------------------------------------------------------------------------
describe("kubernetes onEnvironmentSyncIn (post-upload commands)", () => {
it("runs post-upload commands in array order AFTER the upload, each verbatim as a positional arg", async () => {
const remoteDir = await makeTmp("k8s-sandbox-");
const host = await makeTmp("k8s-host-");
const src = path.join(host, "config.txt");
await fs.writeFile(src, "hello");
const { exec, calls } = makeRealExec();
await performSyncIn({
exec,
remoteDir,
timeoutMs: 30_000,
operations: [
{
operationId: "op-cmd",
files: [{ sourcePath: src, targetPath: path.join(remoteDir, "config.txt"), kind: "file" }],
// First command reads the just-uploaded file (proves upload-before-command);
// second appends (proves array order). cwd absent → the remote dir.
postUploadCommands: [
{ command: "cat config.txt > out.txt" },
{ command: "printf DONE >> out.txt" },
],
},
],
});
// Upload-before-commands AND order: out.txt is the first command's read of the
// uploaded bytes, then the second command's append.
expect(await fs.readFile(path.join(remoteDir, "out.txt"), "utf-8")).toBe("helloDONE");
// One exec for the transfer, then one exec per command (single-exec-per-command).
expect(calls).toHaveLength(3);
// C1/C3: each command rides as the FINAL positional argument, byte-for-byte
// unmutated — the provider concatenated no shell fragment onto it.
const cmdCalls = calls.filter((c) => c.command.includes("cat config.txt > out.txt") || c.command.includes("printf DONE >> out.txt"));
expect(cmdCalls).toHaveLength(2);
expect(cmdCalls[0].command[cmdCalls[0].command.length - 1]).toBe("cat config.txt > out.txt");
expect(cmdCalls[1].command[cmdCalls[1].command.length - 1]).toBe("printf DONE >> out.txt");
// Absent cwd defaults to the remote dir (the penultimate positional arg), never
// a process default cwd (C2).
expect(cmdCalls[0].command[cmdCalls[0].command.length - 2]).toBe(remoteDir);
});
it("runs a command in an explicit confined cwd", async () => {
const remoteDir = await makeTmp("k8s-sandbox-");
const host = await makeTmp("k8s-host-");
const src = path.join(host, "seed.txt");
await fs.writeFile(src, "x");
const subDir = path.join(remoteDir, "sub");
const { exec } = makeRealExec();
await performSyncIn({
exec,
remoteDir,
timeoutMs: 30_000,
operations: [
{
operationId: "op-cwd",
files: [{ sourcePath: src, targetPath: path.join(subDir, "seed.txt"), kind: "file" }],
postUploadCommands: [{ command: "pwd -P > where.txt", cwd: subDir }],
},
],
});
// The command ran with its cwd = subDir: `where.txt` lands there (relative
// write), and its physical cwd (`pwd -P`) is the realpath of subDir.
expect((await fs.readFile(path.join(subDir, "where.txt"), "utf-8")).trim()).toBe(
await fs.realpath(subDir),
);
});
it("aborts the operation fail-loud on a non-zero post-upload command exit, skipping the remainder (C4)", async () => {
const remoteDir = await makeTmp("k8s-sandbox-");
const host = await makeTmp("k8s-host-");
const src = path.join(host, "config.txt");
await fs.writeFile(src, "hello");
const { exec } = makeRealExec();
await expect(
performSyncIn({
exec,
remoteDir,
timeoutMs: 30_000,
operations: [
{
operationId: "op-fail",
files: [{ sourcePath: src, targetPath: path.join(remoteDir, "config.txt"), kind: "file" }],
postUploadCommands: [
{ command: "exit 3" },
{ command: "touch should_not_exist.txt" },
],
},
],
}),
).rejects.toThrow(/post-upload command failed \(exit 3\)/);
// Fail-fast: the command after the failing one never ran.
await expect(fs.stat(path.join(remoteDir, "should_not_exist.txt"))).rejects.toThrow();
});
it("rejects a post-upload command cwd that escapes the remote dir lexically, before any exec (C2)", async () => {
const remoteDir = await makeTmp("k8s-sandbox-");
const host = await makeTmp("k8s-host-");
const src = path.join(host, "config.txt");
await fs.writeFile(src, "hello");
for (const badCwd of [`${remoteDir}/../escape`, "/etc"]) {
const { exec, calls } = makeRealExec();
await expect(
performSyncIn({
exec,
remoteDir,
timeoutMs: 30_000,
operations: [
{
operationId: "op-escape",
files: [{ sourcePath: src, targetPath: path.join(remoteDir, "config.txt"), kind: "file" }],
postUploadCommands: [{ command: "touch pwned.txt", cwd: badCwd }],
},
],
}),
).rejects.toThrow(/escapes|not a confined/);
// Rejected before the command exec: only the transfer exec ran, no command.
expect(calls.some((c) => c.command.includes("touch pwned.txt"))).toBe(false);
}
});
it("rejects a post-upload command whose cwd resolves outside the root via a symlink (realpath guard, C2)", async () => {
const remoteDir = await makeTmp("k8s-sandbox-");
const host = await makeTmp("k8s-host-");
const outside = await makeTmp("k8s-outside-");
const src = path.join(host, "config.txt");
await fs.writeFile(src, "hello");
// A symlink LEXICALLY inside the root that resolves to an out-of-root dir.
await fs.symlink(outside, path.join(remoteDir, "evil"));
const cwd = path.join(remoteDir, "evil");
const { exec } = makeRealExec();
await expect(
performSyncIn({
exec,
remoteDir,
timeoutMs: 30_000,
operations: [
{
operationId: "op-symlink",
files: [{ sourcePath: src, targetPath: path.join(remoteDir, "config.txt"), kind: "file" }],
postUploadCommands: [{ command: "touch pwned.txt", cwd }],
},
],
}),
).rejects.toThrow(/ESCAPE|exit 42/);
// The command never ran through the symlink: nothing landed in the outside dir.
await expect(fs.stat(path.join(outside, "pwned.txt"))).rejects.toThrow();
});
it("issues no extra exec when an operation has no post-upload commands (backward-compat)", async () => {
const remoteDir = await makeTmp("k8s-sandbox-");
const host = await makeTmp("k8s-host-");
const src = path.join(host, "config.txt");
await fs.writeFile(src, "hello");
const { exec: baseExec, calls: baseCalls } = makeRealExec();
await performSyncIn({
exec: baseExec,
remoteDir,
timeoutMs: 30_000,
operations: [
{ operationId: "op-plain", files: [{ sourcePath: src, targetPath: path.join(remoteDir, "config.txt"), kind: "file" }] },
],
});
const { exec: emptyExec, calls: emptyCalls } = makeRealExec();
await performSyncIn({
exec: emptyExec,
remoteDir,
timeoutMs: 30_000,
operations: [
{
operationId: "op-plain",
files: [{ sourcePath: src, targetPath: path.join(remoteDir, "config.txt"), kind: "file" }],
postUploadCommands: [],
},
],
});
// An absent/empty command list adds zero execs — byte-identical to today.
expect(emptyCalls).toHaveLength(baseCalls.length);
expect(emptyCalls).toHaveLength(1);
});
});

View File

@ -183,6 +183,7 @@ export type {
PluginEnvironmentExecuteParams,
PluginEnvironmentExecuteResult,
PluginSyncFileMapping,
PluginPostUploadCommand,
PluginSyncOperation,
PluginEnvironmentSyncInParams,
PluginEnvironmentSyncOutParams,