From 217f51e1d6007a40e2252f0fad42865f84a52ba5 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 7 Oct 2026 13:59:28 +1100 Subject: [PATCH 1/2] feat(demos): give page recordings an opt-in sandboxed shell Some page Skills gate their own output with a CLI step. Without a shell, the Agent skips that step and a broken page can ship, as the archify diagram did. `--shell` gives a page recording the sandboxed Bash a video recording already gets. The default stays no shell. --- scripts/record-skill-demo.ts | 50 +++++++++++++++++++++++------------- 1 file changed, 32 insertions(+), 18 deletions(-) diff --git a/scripts/record-skill-demo.ts b/scripts/record-skill-demo.ts index 91baf5e4..50f7e48f 100644 --- a/scripts/record-skill-demo.ts +++ b/scripts/record-skill-demo.ts @@ -4,6 +4,7 @@ * pnpm demo:record owner/repo/skill --makes landing-page --prompt "Build ... Save it as index.html." --output index.html * pnpm demo:record owner/repo/skill --makes film --prompt "Make ... Render it as film.mp4." --output film.mp4 * pnpm demo:record owner/repo/skill --makes film --prompt "..." --output launch.mp4 --seed ./site --setup "The folder held ..." + * pnpm demo:record owner/repo/skill --makes diagram --prompt "..." --output diagram.html --shell * * `--makes` is the demo's group on /skills/demos: one of DEMO_MAKES in * shared/demo-groups.ts. @@ -24,9 +25,12 @@ * The account comes from `CLOUDFLARE_ACCOUNT_ID`, else `wrangler.jsonc`. * * A page Skill gets only Read, Write and Edit. A video Skill has to run its - * renderer, so its Bash runs in the Claude Code sandbox: writes stay in the temp - * folder, secrets stay unreadable, and the network reaches only package and - * code hosts. Third-party Skill text never gets an open shell on this machine. + * renderer, so it gets a shell. `--shell` gives a page Skill the same shell, for + * a Skill that checks its own output with a command; without it, that check is + * skipped and a broken page can ship. Every shell runs in the Claude Code + * sandbox: writes stay in the temp folder, secrets stay unreadable, and the + * network reaches only package and code hosts. Third-party Skill text never + * gets an open shell on this machine. * * `--reshoot` retakes the screenshots of a page demo from its kept output, * without running the Agent again. `--resume` finishes a run whose Agent @@ -73,7 +77,7 @@ const VIEWPORTS = [ { viewport: 'mobile', width: 390, height: 844 }, ] as const -/** Hosts a video Skill may reach from its sandboxed shell: packages, code, fonts, browsers. */ +/** Hosts a sandboxed shell may reach: packages, code, fonts, browsers. */ const SANDBOX_DOMAINS = [ 'skilld.dev', '*.skilld.dev', @@ -147,8 +151,15 @@ interface DemoEntry { type OutputKind = 'page' | 'video' +/** + * What the Agent's Bash may run. `skilld-run` allows only `npx skilld run`, the + * page default. `sandboxed` allows any command inside the sandbox: a video always + * gets it, and a page gets it with `--shell`. + */ +type Shell = { _tag: 'skilld-run' } | { _tag: 'sandboxed' } + type Parsed - = | { _tag: 'record', owner: string, repo: string, name: string, makes: DemoMakes, prompt: string, output: string, kind: OutputKind, seed: string | null, setup: string | null, resume: { dir: string, model: string } | null } + = | { _tag: 'record', owner: string, repo: string, name: string, makes: DemoMakes, prompt: string, output: string, kind: OutputKind, shell: Shell, seed: string | null, setup: string | null, resume: { dir: string, model: string } | null } | { _tag: 'reshoot', owner: string, repo: string, name: string } | { _tag: 'usage', message: string } @@ -165,6 +176,7 @@ function parseInput(argv: string[]): Parsed { model: { type: 'string' }, setup: { type: 'string' }, reshoot: { type: 'boolean', default: false }, + shell: { type: 'boolean', default: false }, }, }) const [ref] = positionals @@ -187,7 +199,8 @@ function parseInput(argv: string[]): Parsed { if (values.resume && !values.model) return { _tag: 'usage', message: 'A --resume folder needs --model: the model the interrupted run used.' } const resumed = values.resume && values.model ? { dir: resolve(values.resume), model: values.model } : null - return { _tag: 'record', owner, repo, name, makes, prompt: values.prompt, output: values.output, kind, seed: values.seed ? resolve(values.seed) : null, setup: values.setup ?? null, resume: resumed } + const shell: Shell = kind === 'video' || values.shell ? { _tag: 'sandboxed' } : { _tag: 'skilld-run' } + return { _tag: 'record', owner, repo, name, makes, prompt: values.prompt, output: values.output, kind, shell, seed: values.seed ? resolve(values.seed) : null, setup: values.setup ?? null, resume: resumed } } interface SkillSource { @@ -213,15 +226,16 @@ async function skillSource(owner: string, repo: string, name: string): Promise { +async function record(cwd: string, pinnedRef: string, prompt: string, kind: OutputKind, shell: Shell): Promise<{ model: string, version: string }> { const instruction = `First run \`npx skilld run '${pinnedRef}'\` and follow the Skill it prints. Then do this task in the current folder:\n\n${prompt}` - // A page Skill gets no shell beyond loading Skills; a film Skill gets a shell. Both run in the sandbox. + // Without the sandboxed shell, Bash runs nothing beyond loading Skills. Either way it runs in the sandbox. // No user or project settings: they would load this machine's CLAUDE.md, Skills and allow rules, and a // global allow rule then reaches past --allowedTools, which only pre-approves. Only these four tools exist. - const allowed = kind === 'video' ? 'Bash,Read,Write,Edit' : 'Bash(npx skilld run:*),Read,Write,Edit' + const allowed = shell._tag === 'sandboxed' ? 'Bash,Read,Write,Edit' : 'Bash(npx skilld run:*),Read,Write,Edit' const access = [ '--setting-sources', '', @@ -265,7 +279,7 @@ async function record(cwd: string, pinnedRef: string, prompt: string, kind: Outp '--tools', 'Bash,Read,Write,Edit', '--settings', - sandboxSettings(kind), + sandboxSettings(shell), '--allowedTools', allowed, ] @@ -496,7 +510,7 @@ async function reshoot(owner: string, repo: string, name: string): Promise async function main(): Promise { const input = parseInput(process.argv.slice(2)) if (input._tag === 'usage') { - console.error(`${input.message}\nUsage: pnpm demo:record owner/repo/skill --makes --prompt "..." [--output index.html|film.mp4] [--seed dir --setup "..."]\n pnpm demo:record owner/repo/skill --reshoot`) + console.error(`${input.message}\nUsage: pnpm demo:record owner/repo/skill --makes --prompt "..." [--output index.html|film.mp4] [--shell] [--seed dir --setup "..."]\n pnpm demo:record owner/repo/skill --reshoot`) process.exitCode = 2 return } @@ -504,7 +518,7 @@ async function main(): Promise { await reshoot(input.owner, input.repo, input.name) return } - const { owner, repo, name, makes, prompt, output, kind, seed, setup } = input + const { owner, repo, name, makes, prompt, output, kind, shell, seed, setup } = input const skillRef = `${owner}/${repo}/${name}` const source = await skillSource(owner, repo, name) const pinned = await pinnedRun(skillRef) @@ -513,10 +527,10 @@ async function main(): Promise { const cwd = resumed ? resumed.dir : await mkdtemp(join(tmpdir(), 'skilld-demo-')) if (seed && !resumed) await cp(seed, cwd, { recursive: true }) - console.log(`Recording ${skillRef} at ${pinned.commit.slice(0, 7)} in ${cwd}`) + console.log(`Recording ${skillRef} at ${pinned.commit.slice(0, 7)} in ${cwd}${shell._tag === 'sandboxed' ? ' with the sandboxed shell' : ''}`) const agent = resumed ? { model: resumed.model, version: (await run('claude', ['--version'])).stdout.trim().split(' ')[0] ?? 'unknown' } - : await record(cwd, pinned.ref, prompt, kind) + : await record(cwd, pinned.ref, prompt, kind, shell) const produced = await findOutput(cwd, output) if (!produced) From 54fd5ba7444ee2cf98fbb1e379688f0f93caf8df Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 7 Oct 2026 13:59:38 +1100 Subject: [PATCH 2/2] fix(demos): keep env keys and agent sockets out of the sandbox A probe showed the sandboxed shell inherited the recorder's environment, including API keys, and could reach the SSH agent, GPG agent, keyring and session bus sockets under the runtime folder. Any Skill text that gets a shell could use them. The Agent now gets an allowlisted environment, and the sandbox denies the socket folders. --- scripts/record-skill-demo.ts | 42 ++++++++++++++++++++++++++++-------- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/scripts/record-skill-demo.ts b/scripts/record-skill-demo.ts index 50f7e48f..663efc83 100644 --- a/scripts/record-skill-demo.ts +++ b/scripts/record-skill-demo.ts @@ -28,9 +28,10 @@ * renderer, so it gets a shell. `--shell` gives a page Skill the same shell, for * a Skill that checks its own output with a command; without it, that check is * skipped and a broken page can ship. Every shell runs in the Claude Code - * sandbox: writes stay in the temp folder, secrets stay unreadable, and the - * network reaches only package and code hosts. Third-party Skill text never - * gets an open shell on this machine. + * sandbox: writes stay in the temp folder, secrets and agent sockets stay + * unreadable, the environment carries no keys, and the network reaches only + * package and code hosts. Third-party Skill text never gets an open shell on + * this machine. * * `--reshoot` retakes the screenshots of a page demo from its kept output, * without running the Agent again. `--resume` finishes a run whose Agent @@ -47,7 +48,7 @@ import { createHash } from 'node:crypto' import { existsSync, readdirSync } from 'node:fs' import { copyFile, cp, mkdir, mkdtemp, open, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises' import { homedir, tmpdir } from 'node:os' -import { extname, join, relative, resolve, sep } from 'node:path' +import { dirname, extname, join, relative, resolve, sep } from 'node:path' import process from 'node:process' import { fileURLToPath, pathToFileURL } from 'node:url' import { parseArgs, promisify } from 'node:util' @@ -102,13 +103,36 @@ const SECRET_PATHS = ['~/.ssh', '~/.aws', '~/.config', '~/.gnupg', '~/.netrc', ' /** The folders in ~/.local/share a run needs: Node and npx live under pnpm, and renderers read fonts. */ const SHARED_DATA_READABLE = new Set(['pnpm', 'fonts']) -/** Every secret path, plus each other folder in ~/.local/share, where tools keep their tokens. */ +/** + * The folders that hold this session's agent sockets. The SSH agent, the GPG + * agent, the keyring and the session bus answer anyone who reaches the socket, + * so reaching one is using the secret behind it. + */ +function socketFolders(env: NodeJS.ProcessEnv): string[] { + const runtime = env.XDG_RUNTIME_DIR + const ssh = env.SSH_AUTH_SOCK ? dirname(env.SSH_AUTH_SOCK) : null + // An SSH agent outside the runtime folder, such as one in `/tmp/ssh-XXXX`, needs its own entry. + const sshOutside = ssh && !(runtime && `${ssh}/`.startsWith(`${runtime}/`)) ? ssh : null + return [runtime, sshOutside].filter((path): path is string => Boolean(path)) +} + +/** Every secret path, each other folder in ~/.local/share, where tools keep their tokens, and the agent sockets. */ function deniedPaths(): string[] { const shared = join(homedir(), '.local/share') const sharedData = existsSync(shared) ? readdirSync(shared).filter(entry => !SHARED_DATA_READABLE.has(entry)).map(entry => `~/.local/share/${entry}`) : [] - return [...SECRET_PATHS, ...sharedData] + return [...SECRET_PATHS, ...sharedData, ...socketFolders(process.env)] +} + +/** + * The only variables the Agent and its shell inherit. The rest of this shell's + * environment can hold API keys and socket paths that a Skill command could use. + */ +const AGENT_ENV = ['HOME', 'PATH', 'USER', 'LOGNAME', 'SHELL', 'LANG', 'LC_ALL', 'TMPDIR'] + +function agentEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + return Object.fromEntries(AGENT_ENV.flatMap(key => env[key] ? [[key, env[key]]] : [])) } interface Shot { @@ -244,9 +268,9 @@ function sandboxSettings(shell: Shell): string { }, network: { allowedDomains: SANDBOX_DOMAINS }, }, - // A rule path starting `~/` is in the home folder; a bare `/home/...` path would be read as relative. + // A rule path starting `~/` is in the home folder and one starting `//` is absolute; a bare `/run/...` path would be read as relative. permissions: { - deny: denied.flatMap(path => [`Read(${path}/**)`, `Edit(${path}/**)`]), + deny: denied.map(path => path.startsWith('/') ? `/${path}` : path).flatMap(path => [`Read(${path}/**)`, `Edit(${path}/**)`]), }, }) } @@ -291,7 +315,7 @@ async function record(cwd: string, pinnedRef: string, prompt: string, kind: Outp '--permission-mode', 'acceptEdits', ...access, - ], { cwd, timeout: kind === 'video' ? VIDEO_RECORD_TIMEOUT_MS : RECORD_TIMEOUT_MS, maxBuffer: 64 * 1024 * 1024 }) + ], { cwd, env: agentEnv(process.env), timeout: kind === 'video' ? VIDEO_RECORD_TIMEOUT_MS : RECORD_TIMEOUT_MS, maxBuffer: 64 * 1024 * 1024 }) const result = JSON.parse(stdout) as { is_error?: boolean, result?: string, modelUsage?: Record } if (result.is_error) throw new Error(`Claude Code reported an error: ${result.result ?? 'no message'}`)