Pick a role for Claude on this machine (UI/UX, Backend, Dev, QA, Docs, PM); edits and shell commands outside the role are denied, and the Claude crab dressed…

Sets the role Claude works for on this machine. Each persona adds a role prompt to the system prompt and denies edits and shell commands outside its scope. A denied call never runs. Claude is told to explain the change that's needed instead of working around the block.

| Id | Name | May edit | Blocked |
|---|---|---|---|
ui-ux | UI/UX Designer | Components, pages, styles, assets, stories, Tailwind and PostCSS config | API, server, database, migrations, .sql/.prisma/.graphql, secrets, infra, DB and deploy commands |
backend | Backend Developer | Everything except UI | .tsx/.jsx/.vue/.svelte, stylesheets, components/, public/, secrets, deploy commands |
dev | Developer | All application code | Secrets, CI workflows, Docker, Terraform, Kubernetes, Helm, deploy commands |
qa | QA Engineer | Tests, specs, fixtures, mocks, snapshots, test config | Application code, secrets, migrations, deploy commands |
docs | Technical Writer | Markdown, MDX, docs/, READMEs, changelogs | Code, secrets, DB and deploy commands |
pm | Product Manager | Nothing in the project | Every write, git commit/push/reset, package installs, DB and deploy commands |
/persona # status and list
/persona ui-ux # switch (names work too: /persona designer)
/persona off # clear
The choice is saved on this machine and applies to every session and project. The persona shows at the end of the hint line under the prompt.
The mod finds out which by trying once: it draws the picture, and when the terminal shows the picture's placeholder text instead, it switches to the name.
kitty and Ghostty show the crab with no setup. WezTerm, and herdr in front of it, need three settings. persona sets them for you:
| Command | What it does |
|---|---|
/persona pictures | Lists what this machine needs, file by file. Changes nothing. |
/persona pictures fix | Makes those changes, keeping a backup of each file, and says what to restart. |
The changes:
config.enable_kitty_graphics = true.[experimental] kitty_graphics = true. From 0.9, herdr has this on by default.CLAUDE_CODE_FORCE_TERMINAL_IMAGES=1. herdr calls itself tmux, and its terminal (libghostty) is not on Claude Code's list of terminals that show pictures, so Claude Code needs telling.After the fix, quit WezTerm and open it again, then start Claude Code from a new herdr pane.
When the crab cannot show but the terminal can be set up for it, persona says so once, in a notification. /persona status says whether the picture showed, and why not when it did not.
persona never changes these files on its own. They are your terminal's and shell's settings, so they change only when you run /persona pictures fix.
| Persona | The crab |
|---|---|
| Developer | headphones, a laptop in front |
| UI/UX Designer | a beret, a brush held up and a palette |
| Backend Developer | a hard hat with a lamp, a server rack |
| QA Engineer | a detective's cap, a magnifying glass |
| Technical Writer | a pencil behind the ear, an open book |
| Product Manager | a clipboard |
A persona you define in a file gets the crab alone. The crab is 16×8 pixel art, drawn as a picture four cells wide and one row tall.
To pin a persona without the command, set CLAUDE_PERSONA in ~/.claude/settings.json:
{ "env": { "CLAUDE_PERSONA": "ui-ux" } }
CLAUDE_PERSONA wins over the saved choice. CLAUDE_PERSONA=off disables personas for that shell.
Edit, Write and NotebookEdit: the target path, and the real path when it is a symbolic link.Bash: each persona's blocked command patterns, plus the files a command visibly writes: redirects (>, >>), tee, sed -i, perl -i, rm, mv, touch, mkdir, copy destinations, and git checkout/restore/rm/mv. patch and git apply are denied under a restricted persona because their target files can't be checked.The Bash check is best effort. A script that writes files on its own (python -c, node -e, a build step) isn't read.
Add a JSON file per persona in ~/.claude/personas/ for this machine, or in <project>/.claude/personas/ for one repository. Both are read at session start and on every /persona call, and the project file wins. A file with a built-in id changes that persona. Any other id adds a new persona.
{
"id": "ui-ux",
"extraAllow": ["src/hooks/use*.ts"],
"extraDeny": ["src/lib/api-client/**"],
"extraBashDeny": ["\\bnpm\\s+run\\s+db:"]
}
| Field | Meaning |
|---|---|
id, name, summary, prompt | Identity and the text added to the system prompt |
writes | "none", or { "allow": [globs], "deny": [globs] }. Without allow, any path that isn't denied may be written. A deny match always wins. |
bashDeny | Regular expressions for blocked commands |
extraAllow, extraDeny, extraBashDeny | Added to the existing lists instead of replacing them |
Globs are matched against the path relative to the project. ** spans folders, * and ? match within one folder, and {a,b} matches either.
claude plugin validate mods/persona
claude plugin test mods/personahooks/register.ts 371 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { crabPicture, crabSvg } from './crabs'
4import { planPictures, profileOf } from './pictures'
5import type { Plan, Setup } from './pictures'
6import { BUILTIN, absolute, bashVerdict, findPersona, mergePersonas, writeVerdict } from './personas'
7import type { Persona, PersonaFile } from './personas'
8
9const STORE_KEY = 'active'
10const ENV_KEY = 'CLAUDE_PERSONA'
11
12let personas: Persona[] = BUILTIN
13
14async function readFolder($: EngineInterface, folder: string): Promise<PersonaFile[]> {
15 const entries = await $.fs.list(folder).catch(() => [])
16 const files: PersonaFile[] = []
17 for (const entry of entries) {
18 if (entry.kind !== 'file' || !entry.name.endsWith('.json')) continue
19 try {
20 const parsed = JSON.parse(await $.fs.read(`${folder}/${entry.name}`)) as PersonaFile
21 files.push({ ...parsed, id: parsed.id ?? entry.name.replace(/\.json$/, '') })
22 } catch {
23 $.ui.toast(`skipped ${folder}/${entry.name}, not valid JSON`)
24 }
25 }
26
27 return files
28}
29
30/** Builtins, then `~/.claude/personas/*.json`, then the project's `.claude/personas/*.json`. */
31async function loadPersonas($: EngineInterface) {
32 const home = await $.env.get('HOME')
33 const cwd = await $.session.cwd()
34 const machine = home ? await readFolder($, `${home}/.claude/personas`) : []
35 const project = await readFolder($, `${cwd}/.claude/personas`)
36 personas = mergePersonas(BUILTIN, [machine, project])
37}
38
39type Active = { persona?: Persona; source?: 'env' | 'store'; unknown?: string }
40
41/** `CLAUDE_PERSONA` wins over the persona saved on this machine with `/persona`. */
42async function activePersona($: EngineInterface): Promise<Active> {
43 const fromEnv = (await $.env.get('CLAUDE_PERSONA'))?.trim()
44 if (fromEnv && fromEnv !== 'off') {
45 const persona = findPersona(personas, fromEnv)
46 return persona ? { persona, source: 'env' } : { persona: undefined, unknown: fromEnv }
47 }
48 if (fromEnv === 'off') return { persona: undefined }
49 const saved = await $.store.get(STORE_KEY)
50 if (typeof saved !== 'string') return { persona: undefined }
51 const persona = findPersona(personas, saved)
52
53 return persona ? { persona, source: 'store' } : { persona: undefined, unknown: saved }
54}
55
56/** Redraws the hint line, where the active persona's name is shown. */
57function showStatus($: EngineInterface, _active: Active) {
58 $.ui.invalidate('ui.render')
59}
60
61function denial(persona: Persona, reason: string): string {
62 return `persona "${persona.name}": ${reason}. Do not try another way to make this change; tell the person what change is needed and who should make it. The person can switch personas with /persona.`
63}
64
65async function checkWrite($: EngineInterface, path: string): Promise<string | undefined> {
66 const active = await activePersona($)
67 if (active.unknown) return `persona "${active.unknown}" is set but not defined; run /persona list. Writes are blocked until it is fixed`
68 if (!active.persona) return undefined
69 const cwd = await $.session.cwd()
70 const target = absolute(path, cwd)
71 const verdict = writeVerdict(active.persona, target, cwd)
72 if (verdict) return denial(active.persona, verdict)
73 // A link inside an allowed folder may lead into a forbidden one.
74 const stat = await $.fs.stat(target, { resolve: true }).catch(() => undefined)
75 const real = stat?.realPath
76 const realVerdict = real && real !== target ? writeVerdict(active.persona, real, cwd) : undefined
77
78 return realVerdict ? denial(active.persona, realVerdict) : undefined
79}
80
81const GUARD_FAILED = 'persona: the guard could not check this call, so it is blocked. Run /persona status.'
82
83function listText(active: Active): string {
84 const rows = personas.map(
85 persona => `${persona.id === active.persona?.id ? '●' : '○'} ${persona.id.padEnd(8)} ${persona.name}: ${persona.summary}`,
86 )
87
88 return [...rows, '', 'Use /persona <id> to switch, /persona off to clear.'].join('\n')
89}
90
91function statusText(active: Active): string {
92 if (active.unknown) return `persona "${active.unknown}" is set but not defined. Run /persona list.`
93 if (!active.persona) return 'No persona active. Claude can edit any file. Run /persona list.'
94 const from = active.source === 'env' ? `from $${ENV_KEY}` : 'saved on this machine'
95 const writes = active.persona.writes
96 const scope =
97 writes === 'none'
98 ? 'read-only'
99 : [writes.allow ? `may edit ${writes.allow.length} file patterns` : 'may edit any file', writes.deny ? `blocks ${writes.deny.length} patterns` : '']
100 .filter(Boolean)
101 .join(', ')
102
103 return `Persona: ${active.persona.name} (${active.persona.id}, ${from}). ${active.persona.summary} Rules: ${scope}.`
104}
105
106/**
107 * The probe repaints the first picture to learn whether pictures show; some terminals (herdr before 0.9)
108 * drop a picture that is replaced in place. So once the probe passes, the crab is drawn under a new key:
109 * a new picture, sent whole, never the replaced one.
110 */
111const PROBE_PICTURE_KEY = 'crab-probe'
112const PICTURE_KEY = 'crab'
113const PROBE_KEY = 'pictureProbe'
114/** The crab is 16×8 pixels: four cells wide by one tall keeps its pixels square. */
115const PICTURE_COLUMNS = 4
116
117/** Whether this terminal shows pictures: unknown until the first one is drawn and tried. */
118let pictures: 'unknown' | 'yes' | 'no' = 'unknown'
119let isProbing = false
120
121/**
122 * Asks the terminal once whether it shows pictures: repainting a drawn picture is refused where the
123 * terminal shows its alt text instead. Where it does not, the name alone is drawn from then on.
124 */
125/** Keeps why pictures did or did not show, with the terminal as the session sees it, for `/persona status`. */
126async function recordProbe($: EngineInterface, outcome: string) {
127 const env = [
128 `TERM=${(await $.env.get('TERM')) ?? ''}`,
129 `TERM_PROGRAM=${(await $.env.get('TERM_PROGRAM')) ?? ''}`,
130 `WEZTERM=${(await $.env.get('WEZTERM_EXECUTABLE')) ? 'yes' : 'no'}`,
131 `HERDR=${(await $.env.get('HERDR_ENV')) ? 'yes' : 'no'}`,
132 `TMUX=${(await $.env.get('TMUX')) ? 'yes' : 'no'}`,
133 ]
134 await $.store.set(PROBE_KEY, `${outcome} (${env.join(' ')})`)
135}
136
137function probePictures($: EngineInterface, requestId: string, source: ReturnType<typeof crabPicture>) {
138 if (isProbing) return
139 isProbing = true
140 $.clock.after(300, () => {
141 void $.ui
142 .blit({ requestId, key: PROBE_PICTURE_KEY, source, columns: PICTURE_COLUMNS, rows: 1 })
143 .then(async result => {
144 const deny = 'deny' in result && result.deny ? String(result.deny) : ''
145 pictures = deny ? 'no' : 'yes'
146 await recordProbe($, deny || 'pictures shown')
147 if (deny) await hintPictures($)
148 })
149 .catch(async (error: unknown) => {
150 pictures = 'no'
151 await recordProbe($, `blit failed: ${error instanceof Error ? error.message : String(error)}`)
152 })
153 .finally(() => {
154 isProbing = false
155 $.ui.invalidate('ui.render')
156 })
157 })
158}
159
160const HINTED_KEY = 'picturesHinted'
161
162/** This machine's terminal chain and shell, as the session sees them. */
163async function readSetup($: EngineInterface): Promise<Setup> {
164 const home = (await $.env.get('HOME')) ?? ''
165 const shellPath = (await $.env.get('SHELL')) ?? ''
166 const isHerdr = Boolean(await $.env.get('HERDR_ENV'))
167 const version = isHerdr ? await $.process.run(['herdr', '--version'], { timeoutMs: 5_000 }).catch(() => null) : null
168
169 return {
170 home,
171 shell: shellPath.split('/').pop() ?? '',
172 isHerdr,
173 herdrVersion: version?.exitCode === 0 ? version.stdout.trim() : null,
174 isWezterm: Boolean(await $.env.get('WEZTERM_EXECUTABLE')) || (await $.env.get('TERM_PROGRAM')) === 'WezTerm',
175 weztermConfig: (await $.env.get('WEZTERM_CONFIG_FILE')) ?? null,
176 isForced: Boolean(await $.env.get('CLAUDE_CODE_FORCE_TERMINAL_IMAGES')),
177 }
178}
179
180/** The plan for this machine, reading each file it may change. */
181async function planFor($: EngineInterface): Promise<Plan> {
182 const setup = await readSetup($)
183 const paths = [
184 setup.weztermConfig ?? `${setup.home}/.config/wezterm/wezterm.lua`,
185 `${setup.home}/.config/herdr/config.toml`,
186 profileOf(setup.home, setup.shell),
187 ].filter((path): path is string => path !== null)
188 const files: Record<string, string | undefined> = {}
189 for (const path of paths) files[path] = await $.fs.read(path).then(text => String(text)).catch(() => undefined)
190
191 return planPictures(setup, files)
192}
193
194/** `/persona pictures` describes what this machine needs for the crab; `/persona pictures fix` does it. */
195async function picturesCommand($: EngineInterface, isFix: boolean): Promise<string> {
196 const plan = await planFor($)
197 const probe = await $.store.get(PROBE_KEY)
198 const now = typeof probe === 'string' ? `Now: ${probe}\n\n` : ''
199 if (plan.edits.length === 0 && plan.manual.length === 0) {
200 return `${now}Nothing to change: this machine is set up for the crab picture.`
201 }
202 if (!isFix) {
203 const edits = plan.edits.map(edit => `- ${edit.label} (${edit.path})`)
204 const manual = plan.manual.map(line => `- ${line}`)
205 return [
206 now.trim(),
207 edits.length > 0 ? `\`/persona pictures fix\` would change, keeping a backup of each file:\n${edits.join('\n')}` : '',
208 manual.length > 0 ? `To do by hand:\n${manual.join('\n')}` : '',
209 ]
210 .filter(Boolean)
211 .join('\n\n')
212 }
213
214 const stamp = new Date((await $.clock.now()) || Date.now()).toISOString().replace(/[-:]/g, '').slice(0, 15)
215 const done: string[] = []
216 for (const edit of plan.edits) {
217 const old = await $.fs.read(edit.path).then(text => String(text)).catch(() => undefined)
218 if (old !== undefined) await $.fs.write(`${edit.path}.bak-${stamp}`, old)
219 await $.fs.write(edit.path, edit.text)
220 done.push(`- ${edit.label}: ${edit.path}${old !== undefined ? ` (backup: ${edit.path}.bak-${stamp})` : ''}`)
221 }
222 // herdr reads its config again on request; the rest takes a restart.
223 if (plan.edits.some(edit => edit.path.endsWith('/herdr/config.toml'))) {
224 await $.process.run(['herdr', 'server', 'reload-config'], { timeoutMs: 10_000 }).catch(() => undefined)
225 }
226 pictures = 'unknown'
227 const restart = plan.restart.filter(line => !line.startsWith('Run `herdr server'))
228
229 return [
230 `Changed:\n${done.join('\n')}`,
231 plan.manual.length > 0 ? `Still to do by hand:\n${plan.manual.map(line => `- ${line}`).join('\n')}` : '',
232 restart.length > 0 ? `Then:\n${restart.map(line => `- ${line}`).join('\n')}` : '',
233 ]
234 .filter(Boolean)
235 .join('\n\n')
236}
237
238/** Once per machine, when the crab cannot show but this setup can be fixed, says how. */
239async function hintPictures($: EngineInterface) {
240 if ((await $.store.get(HINTED_KEY)) === true) return
241 const plan = await planFor($)
242 if (plan.edits.length === 0) return
243 await $.store.set(HINTED_KEY, true)
244 $.ui.toast('This terminal can show the role crab. Run /persona pictures to see what it needs.', { timeoutMs: 12_000 })
245}
246
247export const register: Register = on => {
248 // The desktop app shows no hint line; there the role rides the footer's mode labels instead.
249 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
250 if (e.surface !== 'desktop') return next(e)
251 const active = await activePersona($)
252 if (!active.persona) return next(e)
253
254 return next({ ...e, props: { ...e.props, modes: [...e.props.modes, active.persona.name] } })
255 })
256
257 // Under the prompt, after the hint line: the persona's crab as a one-row picture, then its name. Only
258 // terminals with the kitty graphics protocol show pictures; everywhere else the name alone rides the
259 // engine's own hint line, so its pills stay live.
260 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
261 const active = await activePersona($)
262 if (!active.persona) return next(e)
263 const name = active.persona.name
264 // The desktop app draws vectors: the crab as SVG, crisp at any size.
265 if (e.surface === 'desktop') {
266 const { Box, Svg, Text } = $.ui.resolve(e)
267 return h(
268 Box,
269 { flexDirection: 'row', alignItems: 'center', columnGap: 1 },
270 e.props.hint === '' ? null : h(Text, { dimColor: true }, `${e.props.hint} · `),
271 h(Svg, { source: crabSvg(active.persona.id), width: 32, height: 16, alt: `${name} crab` }),
272 h(Text, {}, name),
273 ) as never
274 }
275 if (pictures === 'no' || e.surface !== 'terminal') {
276 return next({ ...e, props: { ...e.props, tail: e.props.tail ? `${e.props.tail} · ${name}` : name } })
277 }
278 const { Box, Image, Text } = $.ui.resolve(e)
279 const source = crabPicture(active.persona.id)
280 if (pictures === 'unknown') probePictures($, e.requestId, source)
281
282 return h(
283 Box,
284 { flexDirection: 'row' },
285 e.props.hint === '' ? null : h(Text, { dimColor: true }, `${e.props.hint} · `),
286 h(Image, { key: pictures === 'yes' ? PICTURE_KEY : PROBE_PICTURE_KEY, source, columns: PICTURE_COLUMNS, rows: 1, alt: ' ' }),
287 h(Text, {}, ` ${name}`),
288 ) as never
289 })
290
291 on('session.start', async ($, e, next) => {
292 // Not awaited: registering makes Claude Code re-describe every command (~0.7 s), which would hold startup.
293 // Typed as /persona in the terminal; the command file is what the desktop app lists in its menu.
294 void $.command.register({
295 name: 'persona',
296 description: "Set the role Claude works for on this machine: what it may edit and how it answers",
297 argumentHint: '[ui-ux|backend|dev|qa|docs|pm|off|list|status|pictures]',
298 }).catch(() => undefined)
299 await loadPersonas($).catch(() => undefined)
300 showStatus($, await activePersona($))
301
302 return next(e)
303 })
304
305 on('command.run', { command: ['persona', 'persona:persona'] }, async ($, e) => {
306 await loadPersonas($).catch(() => undefined)
307 const wanted = e.args.trim()
308 const before = await activePersona($)
309 if (wanted === '' || wanted === 'status') {
310 const probe = await $.store.get(PROBE_KEY)
311 const pictureLine = typeof probe === 'string' ? `\nCrab picture: ${probe}` : ''
312 return { text: `${statusText(before)}${pictureLine}\n\n${listText(before)}` }
313 }
314 if (wanted === 'list') return { text: listText(before) }
315 if (wanted === 'pictures' || wanted === 'pictures fix') return { text: await picturesCommand($, wanted === 'pictures fix') }
316 const envNote = before.source === 'env' ? `\nNote: $${ENV_KEY} is set and overrides this until it is unset.` : ''
317 if (wanted === 'off') {
318 await $.store.delete(STORE_KEY)
319 const after = await activePersona($)
320 showStatus($, after)
321 return { text: `Persona cleared on this machine.${envNote}` }
322 }
323 const persona = findPersona(personas, wanted)
324 if (!persona) return { text: `No persona "${wanted}".\n\n${listText(before)}` }
325 await $.store.set(STORE_KEY, persona.id)
326 const after = await activePersona($)
327 showStatus($, after)
328
329 return {
330 text: `Persona set to ${persona.name} on this machine. ${persona.summary}${envNote}`,
331 context: [`The active persona is now ${persona.name}. ${persona.prompt}`],
332 }
333 })
334
335 on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
336 const reason = await checkWrite($, e.file_path)
337 return reason ? { deny: reason } : next(e)
338 }).catch(($, e, next) => (next.called ? next(e) : { deny: GUARD_FAILED }))
339
340 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
341 const reason = await checkWrite($, e.file_path)
342 return reason ? { deny: reason } : next(e)
343 }).catch(($, e, next) => (next.called ? next(e) : { deny: GUARD_FAILED }))
344
345 on('tool.call', { tool: 'NotebookEdit' }, async ($, e, next) => {
346 const reason = await checkWrite($, e.notebook_path)
347 return reason ? { deny: reason } : next(e)
348 }).catch(($, e, next) => (next.called ? next(e) : { deny: GUARD_FAILED }))
349
350 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
351 const active = await activePersona($)
352 if (!active.persona) return next(e)
353 const cwd = await $.session.cwd()
354 const verdict = bashVerdict(active.persona, e.command, cwd, cwd)
355 return verdict ? { deny: denial(active.persona, verdict) } : next(e)
356 }).catch(($, e, next) => (next.called ? next(e) : { deny: GUARD_FAILED }))
357
358 on('prompt.compose', async ($, e, next) => {
359 const composed = await next(e)
360 const { persona } = await activePersona($)
361 if (!persona) return composed
362 const text = [
363 `# Active persona: ${persona.name}`,
364 persona.prompt,
365 `A hook enforces this persona: edits and shell commands outside its scope are denied. When one is denied, do not work around it (no other tool, script or path); explain what change is needed and who should make it.`,
366 ].join('\n\n')
367
368 return { sections: [...composed.sections, { id: 'persona:active', text, scope: 'session' }] }
369 })
370}
371hooks/crabs.ts 209 lines1/**
2 * The Claude crab for each persona: pixel art in half blocks, four rows tall. Each cell is two pixels,
3 * the upper half block's color over its background, so any two colors can share a cell (a hat on the
4 * head, a laptop in front of the body) and the pixels come out square. Half blocks are in every font,
5 * so the crab draws the same in every terminal.
6 */
7
8const COLORS: Record<string, string> = {
9 o: '#D4826A', // the crab
10 e: '#1B1B1B', // its eyes
11 h: '#3A3A3F', // headphone band
12 u: '#8C86E8', // headphone cups
13 L: '#C9CCD3', // laptop
14 p: '#8C86E8', // laptop logo
15 r: '#C9505A', // beret
16 b: '#B08A5A', // brush handle
17 B: '#4E9BE8', // brush tip
18 P: '#E8DCC0', // palette
19 d: '#E05F5F', // paint
20 y: '#E8B931', // hard hat
21 a: '#C98500', // its lamp
22 s: '#4A4F57', // server rack
23 g: '#4FD17A', // server lights
24 k: '#8A6A4A', // detective cap
25 l: '#5BC0DE', // lens
26 m: '#9AA0A6', // magnifier handle
27 n: '#E8C547', // pencil
28 w: '#F2EFE6', // paper
29 c: '#B07A48', // clipboard
30 t: '#4E8DE0', // book
31}
32
33/** The crab, 16×8 pixels: room for a hat in row 0, the head with its eyes, arms out, four legs. */
34const CRAB = [
35 '................',
36 '...oooooooooo...',
37 '...ooeooooeoo...',
38 '...oooooooooo...',
39 '.oooooooooooooo.',
40 '...oooooooooo...',
41 '....o.o..o.o....',
42 '....o.o..o.o....',
43]
44
45/** Each persona's hat and prop over the crab: `.` leaves the crab as it is. */
46const DRESS: Record<string, string[]> = {
47 // Headphones, and a laptop held in front.
48 dev: [
49 '...hhhhhhhhhh...',
50 '..h..........h..',
51 '..u..........u..',
52 '..u..........u..',
53 '....LLLLLLLL....',
54 '....LLLpLLLL....',
55 '....LLLLLLLL....',
56 '................',
57 ],
58 // A beret; a brush held up, a palette in the other hand.
59 'ui-ux': [
60 '.....rrrrrr....B',
61 '...rrrrrrrrrr.bB',
62 '..............b.',
63 '..............b.',
64 'Pd..............',
65 'PP..............',
66 '................',
67 '................',
68 ],
69 // A hard hat with its lamp, and a server rack with its lights on.
70 backend: [
71 '.....yyayyy.....',
72 '..yyyyyyyyyyyy..',
73 '..............ss',
74 '..............gs',
75 '..............ss',
76 '..............gs',
77 '..............ss',
78 '................',
79 ],
80 // A detective's cap, and a magnifying glass.
81 qa: [
82 '....kkkkkkk.....',
83 '...kkkkkkkkkk...',
84 '.............lll',
85 '.............l.l',
86 '............mlll',
87 '................',
88 '................',
89 '................',
90 ],
91 // A pencil behind the ear, and an open book.
92 docs: [
93 '...............n',
94 '..............n.',
95 '.............n..',
96 '................',
97 '....tttttttt....',
98 '....twwwtwwt....',
99 '....tttttttt....',
100 '................',
101 ],
102 // A clipboard with a checklist.
103 pm: [
104 '................',
105 '................',
106 '................',
107 '.....cccccc.....',
108 '.....cwwwwc.....',
109 '.....cwccwc.....',
110 '.....cwwwwc.....',
111 '................',
112 ],
113}
114
115/** One cell of a drawn row: its glyph, the glyph's color and, when both halves are painted, the background. */
116export type Run = { text: string; color?: string; background?: string }
117
118/** The crab dressed for a persona; a persona from a file gets the crab alone. */
119export function dressedArt(id: string): string[] {
120 const dress = DRESS[id]
121 if (!dress) return CRAB
122
123 return CRAB.map((line, row) =>
124 [...line].map((pixel, column) => {
125 const over = dress[row]?.[column] ?? '.'
126 return over === '.' ? pixel : over
127 }).join(''),
128 )
129}
130
131/** Pixel art to rows of cells: each cell is a column's two pixels, top and bottom. */
132export function toRuns(art: string[], colors: Record<string, string>): Run[][] {
133 const width = Math.max(...art.map(line => line.length))
134 const rows: Run[][] = []
135 for (let top = 0; top < art.length; top += 2) {
136 const runs: Run[] = []
137 for (let column = 0; column < width; column++) {
138 const upper = colors[art[top]?.[column] ?? '.']
139 const lower = colors[art[top + 1]?.[column] ?? '.']
140 if (!upper && !lower) runs.push({ text: ' ' })
141 else if (!upper) runs.push({ text: '▄', color: lower })
142 else if (!lower) runs.push({ text: '▀', color: upper })
143 else if (upper === lower) runs.push({ text: '█', color: upper })
144 else runs.push({ text: '▀', color: upper, background: lower })
145 }
146 rows.push(runs)
147 }
148 return rows
149}
150
151/** The persona's crab as four rows of cells. */
152export function crabFor(id: string): Run[][] {
153 return toRuns(dressedArt(id), COLORS)
154}
155
156const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
157
158function toBase64(bytes: Uint8Array): string {
159 let out = ''
160 for (let index = 0; index < bytes.length; index += 3) {
161 const a = bytes[index]!
162 const b = bytes[index + 1]
163 const c = bytes[index + 2]
164 out += ALPHABET[a >> 2]
165 out += ALPHABET[((a & 3) << 4) | ((b ?? 0) >> 4)]
166 out += b === undefined ? '=' : ALPHABET[((b & 15) << 2) | ((c ?? 0) >> 6)]
167 out += c === undefined ? '=' : ALPHABET[c & 63]
168 }
169 return out
170}
171
172/** The persona's crab as a picture: 16×8 RGBA pixels, transparent around it, for an `Image`. */
173export function crabPicture(id: string): { rgba: string; width: number; height: number } {
174 const art = dressedArt(id)
175 const width = Math.max(...art.map(line => line.length))
176 const height = art.length
177 const bytes = new Uint8Array(width * height * 4)
178 art.forEach((line, row) =>
179 [...line].forEach((pixel, column) => {
180 const color = COLORS[pixel]
181 if (!color) return
182 const at = (row * width + column) * 4
183 bytes[at] = Number.parseInt(color.slice(1, 3), 16)
184 bytes[at + 1] = Number.parseInt(color.slice(3, 5), 16)
185 bytes[at + 2] = Number.parseInt(color.slice(5, 7), 16)
186 bytes[at + 3] = 255
187 }),
188 )
189
190 return { rgba: toBase64(bytes), width, height }
191}
192
193/**
194 * The persona's crab as SVG, for surfaces without pictures over the terminal (the desktop app): the same
195 * 16×8 pixels, one square each, drawn crisp at any size.
196 */
197export function crabSvg(id: string): string {
198 const art = dressedArt(id)
199 const width = Math.max(...art.map(line => line.length))
200 const squares = art.flatMap((line, row) =>
201 [...line].map((pixel, column) => {
202 const color = COLORS[pixel]
203 return color ? `<rect x="${column}" y="${row}" width="1" height="1" fill="${color}"/>` : ''
204 }),
205 )
206
207 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${art.length}" shape-rendering="crispEdges">${squares.join('')}</svg>`
208}
209hooks/pictures.ts 127 lines1/**
2 * What a machine needs before Claude Code draws pictures (the persona crab), and the edits that give it.
3 *
4 * The chain that works: WezTerm with kitty graphics on, optionally herdr between it and Claude Code with
5 * kitty graphics passed through, and inside herdr Claude Code told to draw pictures anyway: herdr calls
6 * itself tmux and its terminal (libghostty) is not on Claude Code's list. kitty and Ghostty need nothing.
7 */
8
9export type Setup = {
10 home: string
11 shell: string
12 isHerdr: boolean
13 herdrVersion: string | null
14 isWezterm: boolean
15 weztermConfig: string | null
16 isForced: boolean
17}
18
19/** One edit: the file, what it does, and its whole new text. */
20export type Edit = { path: string; label: string; text: string }
21
22/** What to change, what the person must do themselves, and what is fine already. */
23export type Plan = { edits: Edit[]; manual: string[]; restart: string[] }
24
25export const FORCE_VARIABLE = 'CLAUDE_CODE_FORCE_TERMINAL_IMAGES'
26
27/** WezTerm's config with `enable_kitty_graphics = true`, or null when it is on or the file cannot be read for it. */
28export function weztermEdit(text: string): string | null {
29 if (/enable_kitty_graphics\s*=\s*true/.test(text)) return null
30 if (/enable_kitty_graphics\s*=\s*false/.test(text)) return text.replace(/enable_kitty_graphics\s*=\s*false/, 'enable_kitty_graphics = true')
31 // The usual shape: a config table returned at the end; set the field just before.
32 const returned = /^return\s+([A-Za-z_][A-Za-z0-9_]*)\s*$/m.exec(text)
33 if (!returned) return null
34 const line = `-- Pictures over the kitty graphics protocol (the persona crab in Claude Code, among others).\n${returned[1]}.enable_kitty_graphics = true\n\n`
35
36 return text.slice(0, returned.index) + line + text.slice(returned.index)
37}
38
39/** herdr's config with `[experimental] kitty_graphics = true`; null when on already or herdr turns it on itself. */
40export function herdrEdit(text: string, version: string | null): string | null {
41 if (version !== null && isAtLeast(version, [0, 9])) return null
42 if (/^\s*kitty_graphics\s*=\s*true/m.test(text)) return null
43 if (/^\s*kitty_graphics\s*=\s*false/m.test(text)) return text.replace(/^(\s*kitty_graphics\s*=\s*)false/m, '$1true')
44 const note = '# Pictures over the kitty graphics protocol, passed through to the terminal (on by default from herdr 0.9).\n'
45 if (/^\[experimental\]\s*$/m.test(text)) return text.replace(/^\[experimental\]\s*$/m, `[experimental]\n${note}kitty_graphics = true`)
46
47 return `${text.replace(/\n*$/, '\n')}\n[experimental]\n${note}kitty_graphics = true\n`
48}
49
50/** The shell profile with Claude Code told to draw pictures inside herdr only; null when it is there already. */
51export function shellEdit(text: string, shell: string): string | null {
52 if (text.includes(FORCE_VARIABLE)) return null
53 const why =
54 "# herdr's terminal (libghostty) draws pictures but is not on Claude Code's list of picture terminals;\n# tell Claude Code to draw them there (the persona crab, among others).\n"
55 const line = shell === 'fish' ? `if set -q HERDR_ENV; set -gx ${FORCE_VARIABLE} 1; end` : `[[ -n $HERDR_ENV ]] && export ${FORCE_VARIABLE}=1`
56
57 return `${text.replace(/\n*$/, '\n')}\n${why}${line}\n`
58}
59
60/** The profile a shell reads for interactive sessions. */
61export function profileOf(home: string, shell: string): string | null {
62 if (shell === 'zsh') return `${home}/.zshrc`
63 if (shell === 'bash') return `${home}/.bashrc`
64 if (shell === 'fish') return `${home}/.config/fish/config.fish`
65 return null
66}
67
68function isAtLeast(version: string, wanted: number[]): boolean {
69 const parts = (/(\d+(?:\.\d+)*)/.exec(version)?.[1] ?? '0').split('.').map(Number)
70 for (let index = 0; index < wanted.length; index++) {
71 const have = parts[index] ?? 0
72 if (have !== wanted[index]) return have > wanted[index]!
73 }
74 return true
75}
76
77/**
78 * The plan for this machine, from its setup and the current text of each file (undefined: missing).
79 * Only the chain known to work is changed; anything else is described.
80 */
81export function planPictures(setup: Setup, files: Record<string, string | undefined>): Plan {
82 const plan: Plan = { edits: [], manual: [], restart: [] }
83 if (!setup.isWezterm && !setup.isHerdr) {
84 plan.manual.push(
85 'This terminal is neither WezTerm nor herdr. kitty and Ghostty show the crab as they are; other terminals show the role name.',
86 )
87 return plan
88 }
89
90 if (setup.isWezterm) {
91 const path = setup.weztermConfig ?? `${setup.home}/.config/wezterm/wezterm.lua`
92 const text = files[path]
93 if (text === undefined) {
94 plan.manual.push(`Add \`config.enable_kitty_graphics = true\` to your WezTerm config (none found at ${path}).`)
95 } else {
96 const next = weztermEdit(text)
97 if (next !== null) {
98 plan.edits.push({ path, label: 'WezTerm: turn on kitty graphics', text: next })
99 plan.restart.push('Quit WezTerm (Cmd+Q) and open it again, so it starts with graphics on.')
100 } else if (!/enable_kitty_graphics\s*=\s*true/.test(text)) {
101 plan.manual.push(`Add \`enable_kitty_graphics = true\` to the config table in ${path}; its shape is not one this command edits.`)
102 }
103 }
104 }
105
106 if (setup.isHerdr) {
107 const path = `${setup.home}/.config/herdr/config.toml`
108 const next = herdrEdit(files[path] ?? '', setup.herdrVersion)
109 if (next !== null) {
110 plan.edits.push({ path, label: 'herdr: pass pictures through to the terminal', text: next })
111 plan.restart.push('Run `herdr server reload-config`, or restart herdr.')
112 }
113 const profile = profileOf(setup.home, setup.shell)
114 if (profile === null) {
115 plan.manual.push(`Set ${FORCE_VARIABLE}=1 for Claude Code inside herdr in your shell's profile (${setup.shell} is not one this command edits).`)
116 } else {
117 const next = shellEdit(files[profile] ?? '', setup.shell)
118 if (next !== null) {
119 plan.edits.push({ path: profile, label: `${setup.shell}: let Claude Code draw pictures inside herdr`, text: next })
120 if (!setup.isForced) plan.restart.push('Start Claude Code again from a new herdr pane, so it reads the new setting.')
121 }
122 }
123 }
124
125 return plan
126}
127hooks/personas.ts 445 lines1// Persona definitions and the pure checks the hooks run against them.
2
3export type Persona = {
4 id: string
5 name: string
6 /** Shown in `/persona list`. */
7 summary: string
8 /** Added to the system prompt while the persona is active. */
9 prompt: string
10 /** `none`: no file inside the project may be written. */
11 writes: 'none' | { allow?: string[]; deny?: string[] }
12 /** Regular expressions; a Bash command matching any of them is denied. */
13 bashDeny?: string[]
14}
15
16/** A persona file under `~/.claude/personas/` or `<project>/.claude/personas/`. */
17export type PersonaFile = Partial<Persona> & {
18 extraAllow?: string[]
19 extraDeny?: string[]
20 extraBashDeny?: string[]
21}
22
23const SECRETS = ['**/.env', '**/.env.*', '**/*.{pem,key,p12,pfx}', '**/{secrets,.secrets}/**']
24
25const INFRA = [
26 '**/{infra,infrastructure,terraform,k8s,kubernetes,helm,deploy,deployment,ops}/**',
27 '**/*.tf',
28 '**/Dockerfile*',
29 '**/docker-compose*.{yml,yaml}',
30 '**/compose*.{yml,yaml}',
31 '.github/workflows/**',
32 '.gitlab-ci.yml',
33]
34
35const BACKEND = [
36 '**/{api,apis,server,backend,services,controllers,routes,handlers,resolvers,graphql,trpc,middleware,middlewares}/**',
37 '**/{db,database,models,entities,repositories,migrations,migration,prisma,drizzle,seeds,seeders,sql,queries}/**',
38 '**/*.{sql,prisma,graphql,gql}',
39 '**/route.{ts,js}',
40 '**/*.server.*',
41 '**/server.{ts,js,mjs,cjs}',
42]
43
44const UI = [
45 '**/*.{tsx,jsx,vue,svelte,astro,css,scss,sass,less,styl}',
46 '**/{components,styles,theme,themes,public,static,assets}/**',
47 '**/tailwind.config.*',
48 '**/postcss.config.*',
49]
50
51const DB_COMMANDS = [
52 '\\bprisma\\s+(migrate|db\\s+(push|execute|seed))',
53 '\\bdrizzle-kit\\s+(push|migrate|drop)',
54 '\\balembic\\s+(upgrade|downgrade|revision)',
55 '\\b(rails|rake)\\s+db:',
56 '\\bknex\\s+(migrate|seed)',
57 '\\bsequelize(-cli)?\\s+db:',
58 '\\bsupabase\\s+(db|migration)\\b',
59 '\\bflyway\\b',
60 '\\b(psql|mysql|mongosh|mongo|sqlite3|redis-cli)\\b',
61 '\\bmanage\\.py\\s+(migrate|makemigrations|flush|loaddata)',
62]
63
64const RELEASE_COMMANDS = ['\\b(terraform|tofu)\\s+(apply|destroy|import)', '\\bkubectl\\s+(apply|delete|edit|patch|scale|rollout)', '\\bhelm\\s+(install|upgrade|uninstall|rollback)']
65
66const NO_API =
67 'Never change API routes, request or response shapes, database schema, migrations, queries, server code or auth. When a design needs a backend change, stop and write the change you need as a note for a developer instead.'
68
69export const BUILTIN: Persona[] = [
70 {
71 id: 'ui-ux',
72 name: 'UI/UX Designer',
73 summary: 'Components, styles, layout and assets only. API, server and database are off limits.',
74 prompt: `The person you are working with is a UI/UX designer. Focus on visual design, layout, interaction states, copy, responsiveness and accessibility. ${NO_API} Explain changes in design terms, with the visible result first.`,
75 writes: {
76 allow: [
77 '**/*.{tsx,jsx,vue,svelte,astro,html,css,scss,sass,less,styl}',
78 '**/*.{svg,png,jpg,jpeg,gif,webp,avif,ico,woff,woff2,ttf,otf}',
79 '**/{components,ui,styles,theme,themes,tokens,assets,public,static,pages,app,views,layouts,icons,fonts,stories}/**',
80 '**/*.stories.*',
81 '**/tailwind.config.*',
82 '**/postcss.config.*',
83 ],
84 deny: [...BACKEND, ...SECRETS, ...INFRA],
85 },
86 bashDeny: [...DB_COMMANDS, ...RELEASE_COMMANDS],
87 },
88 {
89 id: 'backend',
90 name: 'Backend Developer',
91 summary: 'API, server, database and business logic. UI files are off limits.',
92 prompt:
93 'The person you are working with is a backend developer. Focus on APIs, data models, migrations, performance and correctness. Do not change UI components, styles or frontend assets; when an API change affects the UI, describe what the frontend needs instead of editing it.',
94 writes: { deny: [...UI, ...SECRETS] },
95 bashDeny: RELEASE_COMMANDS,
96 },
97 {
98 id: 'dev',
99 name: 'Developer',
100 summary: 'All application code, frontend and backend. Secrets, CI and infrastructure are off limits.',
101 prompt:
102 'The person you are working with is a full-stack developer. Work on any application code, tests and docs. Do not edit secrets, CI pipelines, deployment or infrastructure files, and do not run deploy or infrastructure-changing commands; describe those changes for whoever owns them instead.',
103 writes: { deny: [...SECRETS, ...INFRA] },
104 bashDeny: RELEASE_COMMANDS,
105 },
106 {
107 id: 'qa',
108 name: 'QA Engineer',
109 summary: 'Tests, fixtures and test config only. Application code is off limits.',
110 prompt:
111 'The person you are working with is a QA engineer. Focus on test plans, test cases, reproduction steps and coverage. Write and fix tests, fixtures and test config only. When a test exposes a bug in application code, report it with a reproduction instead of fixing the code.',
112 writes: {
113 allow: [
114 '**/*.{test,spec}.*',
115 '**/*_{test,spec}.*',
116 '**/test_*.py',
117 '**/{test,tests,__tests__,e2e,cypress,playwright,spec,specs,fixtures,__fixtures__,__mocks__,mocks,__snapshots__}/**',
118 '**/{jest,vitest,playwright,cypress}.config.*',
119 '**/*.snap',
120 ],
121 deny: SECRETS,
122 },
123 bashDeny: [...DB_COMMANDS.filter(command => !command.includes('psql|mysql')), ...RELEASE_COMMANDS],
124 },
125 {
126 id: 'docs',
127 name: 'Technical Writer',
128 summary: 'Documentation and Markdown only. Code is off limits.',
129 prompt:
130 'The person you are working with writes documentation. Focus on clear, accurate docs, READMEs, guides and changelogs. Read code to understand it, but never change code; when the docs reveal a bug or a missing feature, describe it instead.',
131 writes: {
132 allow: ['**/*.{md,mdx,rst,txt,adoc}', '**/{docs,doc,documentation}/**', '**/README*', '**/CHANGELOG*'],
133 deny: SECRETS,
134 },
135 bashDeny: [...DB_COMMANDS, ...RELEASE_COMMANDS],
136 },
137 {
138 id: 'pm',
139 name: 'Product Manager',
140 summary: 'Read-only. Explains, explores and plans; changes no project file.',
141 prompt:
142 'The person you are working with is a product manager. Answer questions about how the product and code behave, explore the codebase, estimate scope and write plans, specs and tickets in the conversation. Do not change any file in the project and do not run commands that change state; when work is needed, describe it as a task for the team.',
143 writes: 'none',
144 bashDeny: [...DB_COMMANDS, ...RELEASE_COMMANDS, '\\bgit\\s+(commit|push|merge|rebase|reset|revert|cherry-pick|stash|tag|branch\\s+-[dDmM]|clean)', '\\b(npm|pnpm|yarn|bun)\\s+(i|install|add|remove|uninstall|update|upgrade|publish)\\b'],
145 },
146]
147
148/** Builtins with machine-level then project-level files laid over them, matched by id. */
149export function mergePersonas(builtins: Persona[], layers: PersonaFile[][]): Persona[] {
150 const byId = new Map(builtins.map(persona => [persona.id, persona]))
151 for (const files of layers) {
152 for (const file of files) {
153 if (!file.id) continue
154 const base: Persona = byId.get(file.id) ?? {
155 id: file.id,
156 name: file.name ?? file.id,
157 summary: '',
158 prompt: '',
159 writes: {},
160 }
161 const { extraAllow, extraDeny, extraBashDeny, ...fields } = file
162 const merged: Persona = { ...base, ...fields } as Persona
163 if (merged.writes !== 'none' && (extraAllow || extraDeny)) {
164 merged.writes = {
165 allow: merged.writes.allow || extraAllow ? [...(merged.writes.allow ?? []), ...(extraAllow ?? [])] : undefined,
166 deny: [...(merged.writes.deny ?? []), ...(extraDeny ?? [])],
167 }
168 }
169 if (extraBashDeny) merged.bashDeny = [...(merged.bashDeny ?? []), ...extraBashDeny]
170 byId.set(merged.id, merged)
171 }
172 }
173
174 return [...byId.values()]
175}
176
177/** Accepts `ui-ux`, `UI/UX Designer`, `uiux`, `designer`. */
178export function findPersona(personas: Persona[], wanted: string): Persona | undefined {
179 const key = squash(wanted)
180 if (!key) return undefined
181
182 return (
183 personas.find(persona => squash(persona.id) === key) ??
184 personas.find(persona => squash(persona.name) === key) ??
185 personas.find(persona => squash(persona.name).includes(key))
186 )
187}
188
189function squash(text: string): string {
190 return text.toLowerCase().replace(/[^a-z0-9]/g, '')
191}
192
193const globCache = new Map<string, RegExp>()
194
195/** `**` spans folders, `*` and `?` stay inside one, `{a,b}` picks one. */
196export function globToRegExp(glob: string): RegExp {
197 const cached = globCache.get(glob)
198 if (cached) return cached
199 let out = ''
200 let depth = 0
201 for (let i = 0; i < glob.length; i++) {
202 const char = glob[i]!
203 if (char === '*') {
204 if (glob[i + 1] === '*') {
205 const isFolder = glob[i + 2] === '/'
206 out += isFolder ? '(?:.*/)?' : '.*'
207 i += isFolder ? 2 : 1
208 } else {
209 out += '[^/]*'
210 }
211 } else if (char === '?') out += '[^/]'
212 else if (char === '{') {
213 out += '(?:'
214 depth++
215 } else if (char === '}' && depth > 0) {
216 out += ')'
217 depth--
218 } else if (char === ',' && depth > 0) out += '|'
219 else out += /[.+^$()|[\]\\]/.test(char) ? `\\${char}` : char
220 }
221 const regExp = new RegExp(`^${out}$`)
222 globCache.set(glob, regExp)
223
224 return regExp
225}
226
227function matchesAny(globs: string[] | undefined, path: string): boolean {
228 return (globs ?? []).some(glob => globToRegExp(glob).test(path))
229}
230
231/** The path relative to the project, or undefined when it lies outside it. */
232export function inProject(path: string, root: string): string | undefined {
233 const clean = path.replace(/\/+$/, '')
234 if (clean === root) return ''
235 const base = root.endsWith('/') ? root : `${root}/`
236
237 return clean.startsWith(base) ? clean.slice(base.length) : undefined
238}
239
240export function absolute(path: string, cwd: string): string {
241 if (path.startsWith('/')) return normalize(path)
242
243 return normalize(`${cwd}/${path}`)
244}
245
246function normalize(path: string): string {
247 const parts: string[] = []
248 for (const part of path.split('/')) {
249 if (part === '' || part === '.') continue
250 if (part === '..') parts.pop()
251 else parts.push(part)
252 }
253
254 return `/${parts.join('/')}`
255}
256
257/**
258 * Why the persona may not write `path`, or undefined when it may. Inside the
259 * project every rule applies; outside it (scratch files, memory, plans) only
260 * the deny list does.
261 */
262export function writeVerdict(persona: Persona, path: string, root: string): string | undefined {
263 const relative = inProject(path, root)
264 const writes = persona.writes
265 if (writes === 'none') return relative === undefined ? undefined : 'this persona is read-only'
266 const shown = relative ?? path
267 if (matchesAny(writes.deny, shown) || (relative !== undefined && matchesAny(writes.deny, path))) {
268 return `${shown} is off limits for this persona`
269 }
270 if (relative !== undefined && writes.allow && !matchesAny(writes.allow, relative)) {
271 return `${shown} is outside what this persona may edit`
272 }
273
274 return undefined
275}
276
277const REWRITERS = new Set(['rm', 'rmdir', 'mv', 'touch', 'mkdir', 'truncate', 'chmod', 'chown', 'unlink', 'shred'])
278const COPIERS = new Set(['cp', 'ln', 'install', 'rsync', 'ditto', 'scp'])
279const OPAQUE = /^(?:patch|git\s+apply|git\s+am)\b/
280
281/** A word as the shell reads it; `isRedirect` marks an unquoted `>`/`>>` (with its fd, if any). */
282type Word = { text: string; isRedirect: boolean }
283
284/**
285 * Splits a command into simple commands of words, as the shell would: quotes and backslashes keep
286 * operators literal (a `|` inside `'get|patch'` splits nothing), and heredoc bodies are data, not commands.
287 */
288function segments(command: string): Word[][] {
289 const all: Word[][] = []
290 let words: Word[] = []
291 let text = ''
292 let hasWord = false
293 let isRedirect = false
294 const heredocs: { tag: string; isIndented: boolean }[] = []
295
296 const endWord = () => {
297 if (hasWord) words.push({ text, isRedirect })
298 text = ''
299 hasWord = false
300 isRedirect = false
301 }
302 const endCommand = () => {
303 endWord()
304 if (words.length > 0) all.push(words)
305 words = []
306 }
307
308 let i = 0
309 while (i < command.length) {
310 const char = command[i]!
311 if (char === '\\') {
312 if (command[i + 1] !== '\n') {
313 text += command[i + 1] ?? ''
314 hasWord = true
315 }
316 i += 2
317 } else if (char === "'") {
318 const close = command.indexOf("'", i + 1)
319 const stop = close < 0 ? command.length : close
320 text += command.slice(i + 1, stop)
321 hasWord = true
322 i = stop + 1
323 } else if (char === '"') {
324 i++
325 while (i < command.length && command[i] !== '"') {
326 if (command[i] === '\\' && i + 1 < command.length) i++
327 text += command[i]
328 i++
329 }
330 hasWord = true
331 i++
332 } else if (char === '#' && !hasWord) {
333 while (i < command.length && command[i] !== '\n') i++
334 } else if (char === '\n') {
335 endCommand()
336 i++
337 // Heredoc bodies start on the next line and run to their tag: skip them.
338 for (const { tag, isIndented } of heredocs.splice(0)) {
339 while (i < command.length) {
340 const lineEnd = command.indexOf('\n', i)
341 const stop = lineEnd < 0 ? command.length : lineEnd
342 const line = command.slice(i, stop)
343 i = stop + 1
344 if ((isIndented ? line.replace(/^\t+/, '') : line) === tag) break
345 }
346 }
347 } else if (char === '<' && command[i + 1] === '<' && command[i + 2] !== '<') {
348 endWord()
349 i += 2
350 const isIndented = command[i] === '-'
351 if (isIndented) i++
352 while (command[i] === ' ' || command[i] === '\t') i++
353 const tag = command.slice(i).match(/^(['"]?)([\w.-]+)\1/)
354 if (tag) {
355 heredocs.push({ tag: tag[2]!, isIndented })
356 i += tag[0].length
357 }
358 } else if (char === '>') {
359 // A redirect: its fd (`2>`) or `&>` sticks to it; the target is the next word or the rest of this one.
360 const fd = /^\d+$/.test(text) || text === '&' ? text : ''
361 if (fd === '') endWord()
362 text = ''
363 hasWord = false
364 i += command[i + 1] === '>' || command[i + 1] === '|' ? 2 : 1
365 if (command[i] === '&') {
366 // `>&2`, `2>&1`: a duplicated fd, not a file.
367 while (i < command.length && /[&\d-]/.test(command[i]!)) i++
368 continue
369 }
370 while (command[i] === ' ' || command[i] === '\t') i++
371 words.push({ text: '>', isRedirect: true })
372 } else if (char === ';' || char === '|' || char === '&' || char === '(' || char === ')') {
373 endCommand()
374 i++
375 } else if (char === ' ' || char === '\t') {
376 endWord()
377 i++
378 } else {
379 text += char
380 hasWord = true
381 i++
382 }
383 }
384 endCommand()
385
386 return all
387}
388
389export type BashTargets = { paths: string[]; isOpaque: boolean }
390
391/** The files a command visibly writes: redirect targets, `tee`, `sed -i`, `rm`, `mv`, copy destinations. */
392export function bashWriteTargets(command: string): BashTargets {
393 const paths: string[] = []
394 let isOpaque = false
395 for (const segment of segments(command)) {
396 const plain: string[] = []
397 for (let i = 0; i < segment.length; i++) {
398 if (!segment[i]!.isRedirect) {
399 plain.push(segment[i]!.text)
400 continue
401 }
402 const target = segment[i + 1]
403 if (target && !target.isRedirect) {
404 if (target.text !== '/dev/null') paths.push(target.text)
405 i++
406 }
407 }
408 let words = plain
409 while (words.length > 0 && /^\w+=/.test(words[0]!)) words = words.slice(1)
410 if (words[0] === 'sudo' || words[0] === 'command' || words[0] === 'env') words = words.slice(1)
411 const [head, ...rest] = words
412 if (!head) continue
413 const args = rest.filter(word => !word.startsWith('-'))
414 if (OPAQUE.test(words.join(' '))) isOpaque = true
415 else if (REWRITERS.has(head) || head === 'tee') paths.push(...args)
416 else if (COPIERS.has(head) && args.length > 1) paths.push(args[args.length - 1]!)
417 else if ((head === 'sed' || head === 'perl') && rest.some(word => /^-[a-zA-Z]*i/.test(word))) {
418 const operands = rest.filter((word, i) => !word.startsWith('-') && !(head === 'sed' && rest[i - 1] === '-i' && word === ''))
419 const hasScriptFlag = rest.includes('-e')
420 paths.push(...operands.slice(hasScriptFlag ? 0 : 1).filter(word => word !== ''))
421 } else if (head === 'git' && ['checkout', 'restore', 'rm', 'mv'].includes(args[0] ?? '')) {
422 const dashes = rest.indexOf('--')
423 paths.push(...(dashes >= 0 ? rest.slice(dashes + 1) : args.slice(1)))
424 }
425 }
426
427 return { paths, isOpaque }
428}
429
430/** Why the persona may not run `command`, or undefined when it may. */
431export function bashVerdict(persona: Persona, command: string, cwd: string, root: string): string | undefined {
432 const pattern = (persona.bashDeny ?? []).find(source => new RegExp(source).test(command))
433 if (pattern) return 'this command is off limits for this persona'
434 const isRestricted = persona.writes === 'none' || Boolean(persona.writes.allow?.length || persona.writes.deny?.length)
435 if (!isRestricted) return undefined
436 const { paths, isOpaque } = bashWriteTargets(command)
437 if (isOpaque) return 'it applies changes whose files the persona cannot check; use Edit or Write instead'
438 for (const path of paths) {
439 const verdict = writeVerdict(persona, absolute(path, cwd), root)
440 if (verdict) return verdict
441 }
442
443 return undefined
444}
445