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
57 changes: 57 additions & 0 deletions src/adapters/backend/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,11 @@ export function buildCredentialOnlySandboxArgs(input: {
hideFiles: string[];
readonlyPaths?: string[];
privateReadonlyDirectories?: Array<{ parent: string; directory: string }>;
/** Host-owned relay outbox, bound read-WRITE so the confined `botmux send`
* can drop its request for the host watcher. Everything else this flavour
* binds is read-only; see prepareCredentialOnlySandbox for why the relay
* (not an in-pane file proof) is the only sound channel here. */
writableOutbox?: string;
workingDir: string;
cliBin: string;
cliArgs: string[];
Expand Down Expand Up @@ -124,6 +129,13 @@ export function buildCredentialOnlySandboxArgs(input: {
}
for (const directory of hideDirectories.sort()) args.push('--tmpfs', directory);
for (const file of hideFiles.sort()) args.push('--ro-bind', '/dev/null', file);
// AFTER the masks on purpose: `--tmpfs` over an ancestor of the outbox would
// otherwise shadow this bind away and the confined `botmux send` would write
// its request into a throwaway tmpfs no host watcher is reading.
if (input.writableOutbox !== undefined) {
const outbox = assertCredentialIsolationPath(input.writableOutbox, 'writable outbox');
args.push('--bind', outbox, outbox);
}
args.push(
'--unshare-user',
'--unshare-pid',
Expand Down Expand Up @@ -209,11 +221,56 @@ export function credentialOnlySandboxAvailable(): boolean {
return false;
}

/** Host-owned relay outbox for a credential-only pane, provisioned in the SAME
* per-session tree the full sandbox uses so close/exit cleanup and the orphan
* sweep already cover it.
*
* WHY THIS FLAVOUR NEEDS A RELAY AT ALL (all four points MEASURED on bwrap
* 0.8.0, and each one independently breaks the in-pane alternative):
*
* 1. `--unshare-pid` gives the child pid=2/ppid=1 with only 2 visible /proc
* entries, so `findAncestorSessionContext`'s process-tree marker walk can
* NEVER resolve — the marker directory is readable, the host pids simply
* are not in this namespace. `cmdSend` therefore classifies the pane via
* the isolation MARKER arm and demands a data-root locator.
* 2. That locator is only written on the `sandboxRequested && darwin` path, so
* it does not exist here.
* 3. Writing it does not help: its basename is `.dashboard-secret.origin-root-
* <hash>.json`, which this flavour's own mask enumeration matches, so it is
* ro-bound to /dev/null and reads back as a 0-byte char device (EACCES).
* 4. Even past that, the follow-up gate wants the data-root probe to read
* EPERM, but the `--tmpfs` over `read-isolation/` makes it ENOENT.
*
* And an in-pane proof cannot substitute: with `--unshare-user` the child can
* `mount --bind` a writable directory OVER a host ro-bind (measured: succeeds),
* so neither "this path is read-only" nor "this file exists" is a trust root
* inside the pane. Only a host-side channel is sound — which is exactly the
* relay the full sandbox already uses, with the daemon-side watcher performing
* the authoritative policy check. */
export function prepareCredentialOnlyRelayOutbox(opts: {
sessionId: string;
dataDir: string;
}): { outbox: string; cleanup: () => void } | null {
if (process.platform !== 'linux') return null;
const sessionRoot = join(canonical(opts.dataDir), 'sandboxes', opts.sessionId);
const outbox = join(sessionRoot, 'outbox');
try { mkdirSync(outbox, { recursive: true, mode: 0o700 }); }
catch { return null; }
return {
outbox,
// No deny-mask mountpoints are created on this flavour (it masks in-place
// with --tmpfs/--ro-bind rather than carving empties), so a plain remove of
// the session tree is the whole teardown.
cleanup: () => { try { rmSync(sessionRoot, { recursive: true, force: true }); } catch { /* */ } },
};
}

export function prepareCredentialOnlySandbox(input: {
hideDirectories: string[];
hideFiles: string[];
readonlyPaths?: string[];
privateReadonlyDirectories?: Array<{ parent: string; directory: string }>;
writableOutbox?: string;
workingDir: string;
cliBin: string;
cliArgs: string[];
Expand Down
10 changes: 9 additions & 1 deletion src/adapters/cli/read-isolation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -361,7 +361,15 @@ export function buildSeatbeltProfile(
// adds the host CA bundle startup env a sandboxed Codex pane needs — a pane spawned
// before it keeps its ORIGINAL environment, so it would never see SSL_CERT_FILE and
// would keep failing TLS on UnknownIssuer with no output at all.
export const ISOLATION_PANE_MARKER_VERSION = 13;
// · 13 → 14: a Linux credential-only pane now gets a read-WRITE relay outbox
// bind plus BOTMUX_SEND_RELAY in its startup env, because `botmux send` had
// no satisfiable authority inside that flavour at all (the pane's own
// `--unshare-pid` makes the marker walk unresolvable, and the data-root
// locator it then demands is both never written AND masked to /dev/null by
// this flavour's own `.dashboard-secret.*` enumeration). A v13 pane carries
// neither the mount nor the env, so a warm reattach would keep failing every
// send; it must cold-spawn once to obtain them.
export const ISOLATION_PANE_MARKER_VERSION = 14;

export type IsolationCapability = 'credential' | 'read' | 'write';

Expand Down
39 changes: 38 additions & 1 deletion src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,7 @@ import {
prepareDirectSandbox,
prepareCredentialOnlySandbox,
credentialOnlySandboxAvailable,
prepareCredentialOnlyRelayOutbox,
probeHostCredentialIsolationMechanism,
attachSandboxOutbox,
startOutboxWatcher,
Expand Down Expand Up @@ -15718,7 +15719,23 @@ async function spawnCli(
}
let credentialCliBin = spawnBin;
try { credentialCliBin = realpathSync(spawnBin); } catch { /* spawn will fail closed if unresolved */ }
// A credential-only pane has no sound in-pane send authority (see
// prepareCredentialOnlyRelayOutbox for the four measured reasons), so give it
// the SAME host-side relay the full sandbox uses. Without this the confined
// `botmux send` fails closed on the data-root locator gate and the agent
// cannot answer the user at all.
const credentialRelay = prepareCredentialOnlyRelayOutbox({
sessionId: cfg.sessionId,
dataDir: isolationRuntimeDataDir,
});
if (!credentialRelay) {
throw new Error(
`[device-credential-isolation] refusing to start session ${cfg.sessionId}: `
+ 'credential-only relay outbox could not be prepared',
);
}
const credentialSandbox = prepareCredentialOnlySandbox({
writableOutbox: credentialRelay.outbox,
hideDirectories: [...hideDirectories],
hideFiles: [...hideFiles],
privateReadonlyDirectories: [
Expand Down Expand Up @@ -15751,9 +15768,29 @@ async function spawnCli(
}
spawnBin = credentialSandbox.bin;
spawnArgs = credentialSandbox.args;
// Same contract as the full sandbox: the child sees only the outbox path and
// never a Feishu credential; the host watcher re-execs the send outside the
// pane and performs the authoritative origin check.
childEnv.BOTMUX_SEND_RELAY = credentialRelay.outbox;
if (sandboxStopWatcher) { try { sandboxStopWatcher(); } catch { /* */ } }
if (sandboxCleanup) { try { sandboxCleanup(); } catch { /* */ } }
sandboxCleanup = credentialRelay.cleanup;
sandboxRelayOutbox = credentialRelay.outbox;
// session-id is FORCED so a relayed send cannot target another session.
sandboxStopWatcher = startOutboxWatcher(
credentialRelay.outbox,
childEnv,
cfg.sessionId,
{ authorize: authorizeManagedSend },
);
// Re-publish so the outbox capability leaf exists too: the earlier
// credential-only publish ran before an outbox existed and therefore wrote
// only the managed-origin copy.
publishSandboxRelayCapability({ failClosed: true });
log(
`[device-credential-isolation] wrapping ${cliAdapter.id} in credential-only bwrap `
+ `(${hideDirectories.size} authority dir(s), ${hideFiles.size} exact file(s))`,
+ `(${hideDirectories.size} authority dir(s), ${hideFiles.size} exact file(s), `
+ `relay outbox=${credentialRelay.outbox})`,
);
}

Expand Down
Loading