Skip to content

Commit 8a291ce

Browse files
authored
feat(demos): add an opt-in sandboxed shell to page recordings (#534)
1 parent edebd17 commit 8a291ce

1 file changed

Lines changed: 62 additions & 24 deletions

File tree

‎scripts/record-skill-demo.ts‎

Lines changed: 62 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
* pnpm demo:record owner/repo/skill --makes landing-page --prompt "Build ... Save it as index.html." --output index.html
55
* pnpm demo:record owner/repo/skill --makes film --prompt "Make ... Render it as film.mp4." --output film.mp4
66
* pnpm demo:record owner/repo/skill --makes film --prompt "..." --output launch.mp4 --seed ./site --setup "The folder held ..."
7+
* pnpm demo:record owner/repo/skill --makes diagram --prompt "..." --output diagram.html --shell
78
*
89
* `--makes` is the demo's group on /skills/demos: one of DEMO_MAKES in
910
* shared/demo-groups.ts.
@@ -24,9 +25,13 @@
2425
* The account comes from `CLOUDFLARE_ACCOUNT_ID`, else `wrangler.jsonc`.
2526
*
2627
* A page Skill gets only Read, Write and Edit. A video Skill has to run its
27-
* renderer, so its Bash runs in the Claude Code sandbox: writes stay in the temp
28-
* folder, secrets stay unreadable, and the network reaches only package and
29-
* code hosts. Third-party Skill text never gets an open shell on this machine.
28+
* renderer, so it gets a shell. `--shell` gives a page Skill the same shell, for
29+
* a Skill that checks its own output with a command; without it, that check is
30+
* skipped and a broken page can ship. Every shell runs in the Claude Code
31+
* sandbox: writes stay in the temp folder, secrets and agent sockets stay
32+
* unreadable, the environment carries no keys, and the network reaches only
33+
* package and code hosts. Third-party Skill text never gets an open shell on
34+
* this machine.
3035
*
3136
* `--reshoot` retakes the screenshots of a page demo from its kept output,
3237
* without running the Agent again. `--resume` finishes a run whose Agent
@@ -43,7 +48,7 @@ import { createHash } from 'node:crypto'
4348
import { existsSync, readdirSync } from 'node:fs'
4449
import { copyFile, cp, mkdir, mkdtemp, open, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
4550
import { homedir, tmpdir } from 'node:os'
46-
import { extname, join, relative, resolve, sep } from 'node:path'
51+
import { dirname, extname, join, relative, resolve, sep } from 'node:path'
4752
import process from 'node:process'
4853
import { fileURLToPath, pathToFileURL } from 'node:url'
4954
import { parseArgs, promisify } from 'node:util'
@@ -73,7 +78,7 @@ const VIEWPORTS = [
7378
{ viewport: 'mobile', width: 390, height: 844 },
7479
] as const
7580

76-
/** Hosts a video Skill may reach from its sandboxed shell: packages, code, fonts, browsers. */
81+
/** Hosts a sandboxed shell may reach: packages, code, fonts, browsers. */
7782
const SANDBOX_DOMAINS = [
7883
'skilld.dev',
7984
'*.skilld.dev',
@@ -98,13 +103,36 @@ const SECRET_PATHS = ['~/.ssh', '~/.aws', '~/.config', '~/.gnupg', '~/.netrc', '
98103
/** The folders in ~/.local/share a run needs: Node and npx live under pnpm, and renderers read fonts. */
99104
const SHARED_DATA_READABLE = new Set(['pnpm', 'fonts'])
100105

101-
/** Every secret path, plus each other folder in ~/.local/share, where tools keep their tokens. */
106+
/**
107+
* The folders that hold this session's agent sockets. The SSH agent, the GPG
108+
* agent, the keyring and the session bus answer anyone who reaches the socket,
109+
* so reaching one is using the secret behind it.
110+
*/
111+
function socketFolders(env: NodeJS.ProcessEnv): string[] {
112+
const runtime = env.XDG_RUNTIME_DIR
113+
const ssh = env.SSH_AUTH_SOCK ? dirname(env.SSH_AUTH_SOCK) : null
114+
// An SSH agent outside the runtime folder, such as one in `/tmp/ssh-XXXX`, needs its own entry.
115+
const sshOutside = ssh && !(runtime && `${ssh}/`.startsWith(`${runtime}/`)) ? ssh : null
116+
return [runtime, sshOutside].filter((path): path is string => Boolean(path))
117+
}
118+
119+
/** Every secret path, each other folder in ~/.local/share, where tools keep their tokens, and the agent sockets. */
102120
function deniedPaths(): string[] {
103121
const shared = join(homedir(), '.local/share')
104122
const sharedData = existsSync(shared)
105123
? readdirSync(shared).filter(entry => !SHARED_DATA_READABLE.has(entry)).map(entry => `~/.local/share/${entry}`)
106124
: []
107-
return [...SECRET_PATHS, ...sharedData]
125+
return [...SECRET_PATHS, ...sharedData, ...socketFolders(process.env)]
126+
}
127+
128+
/**
129+
* The only variables the Agent and its shell inherit. The rest of this shell's
130+
* environment can hold API keys and socket paths that a Skill command could use.
131+
*/
132+
const AGENT_ENV = ['HOME', 'PATH', 'USER', 'LOGNAME', 'SHELL', 'LANG', 'LC_ALL', 'TMPDIR']
133+
134+
function agentEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
135+
return Object.fromEntries(AGENT_ENV.flatMap(key => env[key] ? [[key, env[key]]] : []))
108136
}
109137

110138
interface Shot {
@@ -147,8 +175,15 @@ interface DemoEntry {
147175

148176
type OutputKind = 'page' | 'video'
149177

178+
/**
179+
* What the Agent's Bash may run. `skilld-run` allows only `npx skilld run`, the
180+
* page default. `sandboxed` allows any command inside the sandbox: a video always
181+
* gets it, and a page gets it with `--shell`.
182+
*/
183+
type Shell = { _tag: 'skilld-run' } | { _tag: 'sandboxed' }
184+
150185
type Parsed
151-
= | { _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 }
186+
= | { _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 }
152187
| { _tag: 'reshoot', owner: string, repo: string, name: string }
153188
| { _tag: 'usage', message: string }
154189

@@ -165,6 +200,7 @@ function parseInput(argv: string[]): Parsed {
165200
model: { type: 'string' },
166201
setup: { type: 'string' },
167202
reshoot: { type: 'boolean', default: false },
203+
shell: { type: 'boolean', default: false },
168204
},
169205
})
170206
const [ref] = positionals
@@ -187,7 +223,8 @@ function parseInput(argv: string[]): Parsed {
187223
if (values.resume && !values.model)
188224
return { _tag: 'usage', message: 'A --resume folder needs --model: the model the interrupted run used.' }
189225
const resumed = values.resume && values.model ? { dir: resolve(values.resume), model: values.model } : null
190-
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 }
226+
const shell: Shell = kind === 'video' || values.shell ? { _tag: 'sandboxed' } : { _tag: 'skilld-run' }
227+
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 }
191228
}
192229

193230
interface SkillSource {
@@ -213,15 +250,16 @@ async function skillSource(owner: string, repo: string, name: string): Promise<S
213250
}
214251

215252
/**
216-
* The sandbox every recorded run works in. A film Skill may run any command in
217-
* it; a page Skill only its allowed one, since nothing else is auto-approved.
253+
* The sandbox every recorded run works in. With the sandboxed shell, the Agent
254+
* may run any command in it; without, only `npx skilld run`, since nothing else
255+
* is auto-approved.
218256
*/
219-
function sandboxSettings(kind: OutputKind): string {
257+
function sandboxSettings(shell: Shell): string {
220258
const denied = deniedPaths()
221259
return JSON.stringify({
222260
sandbox: {
223261
enabled: true,
224-
autoAllowBashIfSandboxed: kind === 'video',
262+
autoAllowBashIfSandboxed: shell._tag === 'sandboxed',
225263
allowUnsandboxedCommands: false,
226264
filesystem: {
227265
// The temp folder is the working directory; renderers cache browsers here too.
@@ -230,9 +268,9 @@ function sandboxSettings(kind: OutputKind): string {
230268
},
231269
network: { allowedDomains: SANDBOX_DOMAINS },
232270
},
233-
// A rule path starting `~/` is in the home folder; a bare `/home/...` path would be read as relative.
271+
// A rule path starting `~/` is in the home folder and one starting `//` is absolute; a bare `/run/...` path would be read as relative.
234272
permissions: {
235-
deny: denied.flatMap(path => [`Read(${path}/**)`, `Edit(${path}/**)`]),
273+
deny: denied.map(path => path.startsWith('/') ? `/${path}` : path).flatMap(path => [`Read(${path}/**)`, `Edit(${path}/**)`]),
236274
},
237275
})
238276
}
@@ -252,20 +290,20 @@ async function pinnedRun(skillRef: string): Promise<{ ref: string, commit: strin
252290
return { ref: match[1], commit: match[2] }
253291
}
254292

255-
async function record(cwd: string, pinnedRef: string, prompt: string, kind: OutputKind): Promise<{ model: string, version: string }> {
293+
async function record(cwd: string, pinnedRef: string, prompt: string, kind: OutputKind, shell: Shell): Promise<{ model: string, version: string }> {
256294
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}`
257-
// A page Skill gets no shell beyond loading Skills; a film Skill gets a shell. Both run in the sandbox.
295+
// Without the sandboxed shell, Bash runs nothing beyond loading Skills. Either way it runs in the sandbox.
258296
// No user or project settings: they would load this machine's CLAUDE.md, Skills and allow rules, and a
259297
// global allow rule then reaches past --allowedTools, which only pre-approves. Only these four tools exist.
260-
const allowed = kind === 'video' ? 'Bash,Read,Write,Edit' : 'Bash(npx skilld run:*),Read,Write,Edit'
298+
const allowed = shell._tag === 'sandboxed' ? 'Bash,Read,Write,Edit' : 'Bash(npx skilld run:*),Read,Write,Edit'
261299
const access = [
262300
'--setting-sources',
263301
'',
264302
'--strict-mcp-config',
265303
'--tools',
266304
'Bash,Read,Write,Edit',
267305
'--settings',
268-
sandboxSettings(kind),
306+
sandboxSettings(shell),
269307
'--allowedTools',
270308
allowed,
271309
]
@@ -277,7 +315,7 @@ async function record(cwd: string, pinnedRef: string, prompt: string, kind: Outp
277315
'--permission-mode',
278316
'acceptEdits',
279317
...access,
280-
], { cwd, timeout: kind === 'video' ? VIDEO_RECORD_TIMEOUT_MS : RECORD_TIMEOUT_MS, maxBuffer: 64 * 1024 * 1024 })
318+
], { cwd, env: agentEnv(process.env), timeout: kind === 'video' ? VIDEO_RECORD_TIMEOUT_MS : RECORD_TIMEOUT_MS, maxBuffer: 64 * 1024 * 1024 })
281319
const result = JSON.parse(stdout) as { is_error?: boolean, result?: string, modelUsage?: Record<string, unknown> }
282320
if (result.is_error)
283321
throw new Error(`Claude Code reported an error: ${result.result ?? 'no message'}`)
@@ -496,15 +534,15 @@ async function reshoot(owner: string, repo: string, name: string): Promise<void>
496534
async function main(): Promise<void> {
497535
const input = parseInput(process.argv.slice(2))
498536
if (input._tag === 'usage') {
499-
console.error(`${input.message}\nUsage: pnpm demo:record owner/repo/skill --makes <group> --prompt "..." [--output index.html|film.mp4] [--seed dir --setup "..."]\n pnpm demo:record owner/repo/skill --reshoot`)
537+
console.error(`${input.message}\nUsage: pnpm demo:record owner/repo/skill --makes <group> --prompt "..." [--output index.html|film.mp4] [--shell] [--seed dir --setup "..."]\n pnpm demo:record owner/repo/skill --reshoot`)
500538
process.exitCode = 2
501539
return
502540
}
503541
if (input._tag === 'reshoot') {
504542
await reshoot(input.owner, input.repo, input.name)
505543
return
506544
}
507-
const { owner, repo, name, makes, prompt, output, kind, seed, setup } = input
545+
const { owner, repo, name, makes, prompt, output, kind, shell, seed, setup } = input
508546
const skillRef = `${owner}/${repo}/${name}`
509547
const source = await skillSource(owner, repo, name)
510548
const pinned = await pinnedRun(skillRef)
@@ -513,10 +551,10 @@ async function main(): Promise<void> {
513551
const cwd = resumed ? resumed.dir : await mkdtemp(join(tmpdir(), 'skilld-demo-'))
514552
if (seed && !resumed)
515553
await cp(seed, cwd, { recursive: true })
516-
console.log(`Recording ${skillRef} at ${pinned.commit.slice(0, 7)} in ${cwd}`)
554+
console.log(`Recording ${skillRef} at ${pinned.commit.slice(0, 7)} in ${cwd}${shell._tag === 'sandboxed' ? ' with the sandboxed shell' : ''}`)
517555
const agent = resumed
518556
? { model: resumed.model, version: (await run('claude', ['--version'])).stdout.trim().split(' ')[0] ?? 'unknown' }
519-
: await record(cwd, pinned.ref, prompt, kind)
557+
: await record(cwd, pinned.ref, prompt, kind, shell)
520558

521559
const produced = await findOutput(cwd, output)
522560
if (!produced)

0 commit comments

Comments
 (0)