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
62 changes: 55 additions & 7 deletions src/adapters/backend/scratch-credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@
*
* Paths that don't exist are skipped (the mask compiler existence-filters).
*/
import { existsSync, lstatSync, readdirSync } from 'node:fs';
import { join, dirname, basename } from 'node:path';
import { existsSync, lstatSync, readdirSync, realpathSync } from 'node:fs';
import { join, dirname, basename, resolve, sep } from 'node:path';
import { homedir as osHomedir } from 'node:os';
import { resolveLarkCliLinuxStoreDir } from '../cli/fs-policy.js';

Expand All @@ -44,13 +44,18 @@ export interface ScratchSecretInput {
* <sid>.<tool>.env/.bin/.turn files are returned as read-only carve paths
* (the governed CLI + `botmux send` need them). */
sessionId?: string;
/** Current session's private cache tree. It is the only writable carve under
* the otherwise sealed shared session-tmp parent. */
sessionScratchDir?: string;
}

export interface ScratchSecrets {
/** Paths denied read+write in the sandbox. */
denyPaths: string[];
/** Paths re-opened READ-ONLY inside an otherwise denied dir. */
readOnlyCarvePaths: string[];
/** Paths re-opened READ+WRITE inside an otherwise denied shared dir. */
writableCarvePaths: string[];
}

// Trigger-user CLI identity lives in ONE per-session directory
Expand All @@ -73,14 +78,36 @@ function isDir(p: string): boolean {
try { return lstatSync(p).isDirectory(); } catch { return false; }
}

/** Resolve symlink aliases when the path exists, while retaining a stable
* lexical fallback for optional paths that have not been created yet. */
function canonical(p: string): string {
try { return realpathSync(p); } catch { return resolve(p); }
}

export function enumerateScratchSecretPaths(input: ScratchSecretInput): ScratchSecrets {
const out = new Set<string>();
const readOnlyCarve = new Set<string>();
const writableCarve = new Set<string>();
const homeDir = input.homeDir || osHomedir();
const addFile = (p: string | undefined): void => {
if (p && isFileOrLink(p)) out.add(p);
};

// Session-local lark-cli HOME snapshots live below one shared cache parent.
// Scratch otherwise exposes the real filesystem for reads, so seal the whole
// parent and re-open only this session's own scratch tree. This is independent
// of triggerUserAuth: a non-participating session must not read a sibling's
// snapshot created by a participating bot.
const sessionTempRoot = canonical(resolve(homeDir, '.cache', 'botmux', 'session-tmp'));
if (isDir(sessionTempRoot)) out.add(sessionTempRoot);
if (input.sessionScratchDir) {
const ownScratchInput = resolve(input.sessionScratchDir);
const ownScratch = canonical(ownScratchInput);
if (ownScratch.startsWith(`${sessionTempRoot}${sep}`) && isDir(ownScratchInput)) {
writableCarve.add(ownScratch);
}
}

for (const home of input.botmuxHomes) {
let entries: string[];
try { entries = readdirSync(home); } catch { continue; }
Expand Down Expand Up @@ -137,10 +164,15 @@ export function enumerateScratchSecretPaths(input: ScratchSecretInput): ScratchS
}
} catch { /* */ }

// Per-person secret subdirectories (whole dirs): VC daemon auth tokens
// and each person's bytedcli login HOME. The CLI never needs these inside
// a scratch turn (they are daemon-side / per-owner).
for (const secretDir of ['vc-meeting-daemon-auth', 'bytedcli-home']) {
// Per-person secret subdirectories (whole dirs): VC daemon auth tokens,
// bytedcli logins, and lark-cli personal apps/logins. The CLI receives only
// its own per-turn wrapper identity and must not enumerate another sender's
// HOME.
for (const secretDir of [
'vc-meeting-daemon-auth',
'bytedcli-home',
'lark-cli-home',
]) {
const p = join(dataDir, secretDir);
if (isDir(p)) out.add(p);
}
Expand Down Expand Up @@ -175,7 +207,23 @@ export function enumerateScratchSecretPaths(input: ScratchSecretInput): ScratchS
if (existsSync(p)) out.add(p);
}

return { denyPaths: [...out], readOnlyCarvePaths: [...readOnlyCarve] };
// larkdev's ByteCloud SDK keeps refresh/login state here. A governed wrapper
// always overrides LARKDEV_AUTH_DIR with an empty per-invocation directory,
// but scratch exposes the host tree for reads; mask both the default and an
// operator-configured auth root so the agent cannot bypass the wrapper by
// reading or invoking against the machine login directly.
for (const p of new Set<string>([
join(homeDir, '.larkdev', 'auth'),
...(process.env.LARKDEV_AUTH_DIR?.startsWith('/') ? [process.env.LARKDEV_AUTH_DIR] : []),
])) {
if (existsSync(p)) out.add(p);
}

return {
denyPaths: [...out],
readOnlyCarvePaths: [...readOnlyCarve],
writableCarvePaths: [...writableCarve],
};
}

/** Back-compat flat list (deny + ro-carve are all "secret-adjacent"); used by
Expand Down
39 changes: 37 additions & 2 deletions src/adapters/backend/scratch-sandbox-darwin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,14 @@ export type ScratchStorage = 'disk'; // macOS: always APFS-COW-backed; the Linux
const CLONE_TIMEOUT_CORE_MS = 5 * 60_000;
const CLONE_TIMEOUT_BESTEFFORT_MS = 20_000;

/** Whether a broad denied root contains a trusted per-session carve. */
export function macScratchDenyHostsWritableCarve(
root: string,
writableCarvePaths: readonly string[],
): boolean {
return writableCarvePaths.some(carve => carve !== root && carve.startsWith(`${root}${sep}`));
}

/** CLI state dirs cloned REQUIRED (fail-closed): sessions/auth/config of the
* supported CLIs must be writable COW for scratch to function. */
const CORE_CLONE_HOME_DIRS = new Set([
Expand Down Expand Up @@ -196,6 +204,8 @@ export function buildMacScratchProfile(input: {
/** Final WRITE-only denies (subtrees degraded from clone to symlink must be
* read-native but never writable, so writes can't silently reach the host). */
fileWriteDenyPaths?: readonly string[];
/** Final trusted write carves below a sealed shared root. */
writableCarvePaths?: readonly string[];
/** REAL host cache areas granted write (Foundation/cfprefsd ignore HOME). */
hostWritable?: readonly string[];
mcpSocket?: string;
Expand Down Expand Up @@ -245,6 +255,13 @@ export function buildMacScratchProfile(input: {
if (typeof raw !== 'string' || !raw || !isAbsolute(raw)) continue;
lines.push(`(deny file-write* (subpath "${escSb(raw)}"))`);
}
// Stage 6: trusted per-session carves below a shared deny root. These must
// come after final write seals such as ~/.cache symlink degradation.
for (const raw of input.writableCarvePaths ?? []) {
if (typeof raw !== 'string' || !raw || !isAbsolute(raw)) continue;
lines.push(`(allow file-read* (subpath "${escSb(raw)}"))`);
lines.push(`(allow file-write* (subpath "${escSb(raw)}"))`);
}
if (!input.net) lines.push('(deny network*)');
return lines;
}
Expand All @@ -258,6 +275,8 @@ export interface PrepareMacScratchOpts {
cliArgs: string[];
/** Credential/secret REAL host paths (read+write denied in the child). */
denyPaths?: readonly string[];
/** Current session paths re-opened below a shared denied parent. */
writableCarvePaths?: readonly string[];
mcpGatewaySocketPath?: string;
net?: boolean;
}
Expand Down Expand Up @@ -286,6 +305,10 @@ export function prepareMacScratchSandbox(opts: PrepareMacScratchOpts): MacScratc

const homeReal = canonical(opts.home);
const cwdReal = canonical(opts.chdir);
const writableCarvePaths = (opts.writableCarvePaths ?? [])
.filter((p): p is string => typeof p === 'string' && !!p && isAbsolute(p))
.map(p => canonical(p))
.filter(p => existsSync(p));
if (!sameVolume(homeReal, homeCloneRoot)) {
console.error(`[scratch-darwin] HOME ${homeReal} not on the same volume as ${homeCloneRoot}; cross-volume scratch refused.`);
rmSync(sessionRoot, { recursive: true, force: true });
Expand Down Expand Up @@ -347,6 +370,10 @@ export function prepareMacScratchSandbox(opts: PrepareMacScratchOpts): MacScratc
for (const p of opts.denyPaths ?? []) {
if (typeof p !== 'string' || !p) continue;
let cur = canonical(p);
// Keep a white-in-black root precise. Promoting session-tmp to its
// top-level ~/.cache ancestor would unnecessarily deny reads to every
// unrelated cache and would obscure the exact sibling-session boundary.
if (macScratchDenyHostsWritableCarve(cur, writableCarvePaths)) continue;
while (dirname(cur) !== homeReal && cur !== dirname(cur) && cur.startsWith(homeReal)) cur = dirname(cur);
if (dirname(cur) === homeReal) authorityRoots.add(cur);
}
Expand Down Expand Up @@ -530,11 +557,17 @@ export function prepareMacScratchSandbox(opts: PrepareMacScratchOpts): MacScratc
const claudeMcpCache = join(homeReal, 'Library', 'Caches', 'claude-cli-nodejs');
// File-level caller-supplied denies outside the authority roots / session
// tree (the sessionRoot is NOT denied on macOS: clones/outbox live under it).
const callerFileDenies = (opts.denyPaths ?? [])
const callerDenies = (opts.denyPaths ?? [])
.filter((p): p is string => typeof p === 'string' && !!p && isAbsolute(p))
.map(p => canonical(p))
.filter(p => !p.startsWith(sessionRoot + sep) && p !== sessionRoot)
.filter(p => ![...authorityRoots].some(root => p === root || p.startsWith(root.endsWith(sep) ? root : `${root}${sep}`)));
// A broad deny that contains a trusted carve belongs in stage 2, before the
// deeper stage-6 re-open. Leaving it in the final file-deny stage would mask
// this session's own lark-cli HOME together with its siblings.
const callerRootDenies = callerDenies.filter(root =>
macScratchDenyHostsWritableCarve(root, writableCarvePaths));
const callerFileDenies = callerDenies.filter(p => !callerRootDenies.includes(p));

// macOS lark-cli keystores. These are NOT under ~/.botmux and the shared
// enumerator only resolves Linux store paths, so the mac module must seal
Expand All @@ -545,12 +578,14 @@ export function prepareMacScratchSandbox(opts: PrepareMacScratchOpts): MacScratc
const profilePath = join(sessionRoot, 'scratch.sb');
const lines = buildMacScratchProfile({
net: opts.net !== false,
authorityRootDenies: [...authorityRoots].sort((a, b) => b.length - a.length),
authorityRootDenies: [...new Set([...authorityRoots, ...callerRootDenies])]
.sort((a, b) => b.length - a.length),
writable: [homeCloneRoot, workCloneRoot, tmp, outbox, shimBin],
hostWritable,
fileDenyPaths: [...new Set([...callerFileDenies, ...macLarkDenies])],
fileWriteDenyPaths: [...degradedWriteDeny, claudeMcpCache]
.filter(p => !authorityRoots.has(p)),
writableCarvePaths,
mcpSocket: sandboxMcpSocket,
});
writeFileSync(profilePath, lines.join('\n') + '\n', { mode: 0o600 });
Expand Down
31 changes: 29 additions & 2 deletions src/adapters/backend/scratch-sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,20 @@ function canonical(p: string): string {
try { return realpathSync(p); } catch { return resolve(p); }
}

/** Compile the later-wins RW binds for trusted paths below a sealed root. */
export function compileScratchWritableCarveArgs(
denies: readonly string[],
writableCarves: readonly string[],
): string[] {
const args: string[] = [];
for (const p of writableCarves) {
if (denies.some(d => d === p || isWithin(d, p))) {
args.push('--bind', p, p);
}
}
return args;
}

function hasCmd(cmd: string): boolean {
return spawnSync('sh', ['-c', `command -v ${cmd}`], { stdio: 'ignore' }).status === 0;
}
Expand Down Expand Up @@ -332,7 +346,7 @@ export interface PrepareScratchOpts {
* files under the otherwise-sealed cli-identity/ dir). Bound READ-ONLY. */
readOnlyCarvePaths?: readonly string[];
/** Host paths that must be READ+WRITABLE inside even under a denied dir
* (the outbox is handled internally; reserved for future carve-outs). */
* (the outbox is handled internally). */
writableCarvePaths?: readonly string[];
/** Env keys the WORKER may repoint at the host-side merged tree for its own
* transcript reads (CODEX_HOME / TRAE_HOME). The container child must keep
Expand Down Expand Up @@ -509,7 +523,15 @@ export function prepareScratchSandbox(opts: PrepareScratchOpts): ScratchSandboxS
// Real-host passthrough carve-outs layered AFTER deny masks (later mount
// wins). The outbox MUST stay host-real: the daemon watcher reads it without
// any merged-tree awareness.
const carveTargets = new Set<string>([outbox]);
const writableCarves = new Set<string>();
for (const raw of opts.writableCarvePaths ?? []) {
if (typeof raw !== 'string' || !raw) continue;
try {
const p = canonical(raw);
if (isAbsolute(p) && existsSync(p)) writableCarves.add(p);
} catch { /* missing/invalid carve */ }
}
const carveTargets = new Set<string>([outbox, ...writableCarves]);

// ── bwrap argv ───────────────────────────────────────────────────────────
const args: string[] = [];
Expand Down Expand Up @@ -641,6 +663,11 @@ export function prepareScratchSandbox(opts: PrepareScratchOpts): ScratchSandboxS
}
}

// Writable carve-outs use host-real binds after the broad empty/tmpfs mask.
// Only emit a carve that is actually below a denied root; a standalone bind
// would unnecessarily bypass scratch COW semantics.
args.push(...compileScratchWritableCarveArgs(denies, [...writableCarves]));

// fnm/nvm/volta bin farms under /run get masked by the fresh /run tmpfs.
args.push(...reexposeRunBinArgs([opts.cliBin, process.execPath]));

Expand Down
4 changes: 3 additions & 1 deletion src/adapters/cli/shared-hints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,8 @@ export function buildCredentialBoundaryBlock(policy: TriggerUserAuthConfig | und
if (!policy?.enabled || policy.tools.length === 0) return '';
const lark = triggerUserAuthApplies(policy, 'lark-cli');
const bytedcli = triggerUserAuthApplies(policy, 'bytedcli');
const larkdev = triggerUserAuthApplies(policy, 'larkdev');
const bytecloud = bytedcli || larkdev;
const line = (key: string, params?: Record<string, string>): string => ` ${escapeXmlTagLikeTokens(t(key, params, locale))}`;
return [
'<botmux_credentials>',
Expand All @@ -203,7 +205,7 @@ export function buildCredentialBoundaryBlock(policy: TriggerUserAuthConfig | und
line('ai.credentials.never_read_others'),
line('ai.credentials.never_forward'),
...(lark ? [line('ai.credentials.on_auth_failure')] : []),
...(bytedcli ? [line('ai.credentials.on_bytedcli_auth_failure')] : []),
...(bytecloud ? [line('ai.credentials.on_bytedcli_auth_failure')] : []),
line('ai.credentials.on_auth_link'),
...(lark ? [line('ai.credentials.on_missing_scope')] : []),
'</botmux_credentials>',
Expand Down
Loading