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.
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'
4348import { existsSync , readdirSync } from 'node:fs'
4449import { copyFile , cp , mkdir , mkdtemp , open , readdir , readFile , rm , stat , writeFile } from 'node:fs/promises'
4550import { 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'
4752import process from 'node:process'
4853import { fileURLToPath , pathToFileURL } from 'node:url'
4954import { 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. */
7782const 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. */
99104const 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. */
102120function 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
110138interface Shot {
@@ -147,8 +175,15 @@ interface DemoEntry {
147175
148176type 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+
150185type 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
193230interface 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>
496534async 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