Hands Off: keep your AI agent from writing to repos, deploys and databases that aren't yours. Reading stays free; writes to what you protect (GitHub repos…

Your AI agent can't push to a repo, deploy a project or drop a table that isn't yours.
You open a client's repo to read how something works. Twenty minutes later the agent has "fixed" a bug in it, committed, pushed and redeployed. Or it runs a migration against the production database you only meant to inspect. It wasn't malicious: it was helpful, in the wrong place.
Today that is one of the most common ways AI-assisted work goes wrong: agents work across many repos, deploys and databases at once, and nothing tells them which ones are read-only for you: a teammate's service, a client's codebase, a vendor's starter kit, the shared production project.
Hands Off is that line. You list what isn't yours; the agent can still read everything, but every write to it stops until a person says yes, once.
⏺ Bash(git push origin main)
⎿ Hands Off: acme/billing-api is protected — read yes, write no. If it needs a change, ask its owner.
For a one-time exception a person must type /handsoff pass. Do not work around this with another command.
[blocked: git push]
Hands Off blocked git push on acme/billing-api. To allow it once: /handsoff pass
/plugin install hands-off --marketplace Br4inergy/claude-hands-off
Then type /handsoff to open the panel and add what you want to protect. Nothing is protected until you do.
| Where | What counts as a write | Still allowed |
|---|---|---|
GitHub repos, recognised by their origin remote, not the folder name (a clone under another name is still protected; your own fork is not; in a fork, gh commands that write go to the original by default: Hands Off checks every remote and, if needed, asks GitHub which repo it was forked from) | git commit, push, add, rm, reset, rebase, merge, tag, branch create/delete, stash, clean, cherry-pick…; gh pr/issue/release writes, repo edit/delete, workflow run, api POST/PUT/PATCH/DELETE; file edits (Edit, Write, sed -i, rm, mv, > redirects…) | log, diff, status, show, fetch, pull, clone, blame, gh pr view/list, any read |
Vercel projects, by name or prj_… id | deploy, link, env add/rm/pull, promote… when the folder is linked to the project (.vercel/project.json) or the command names it | ls, inspect, logs, whoami, env ls, dev |
| Railway projects | up, redeploy, down, variables --set, service, link… when linked or named with -p | status, logs, variables, list |
| Neon projects, by id | neon / neonctl (and npx neonctl): branch, role, database and project changes, set-context… by --project-id or the folder's .neon context | branches list/get, connection-string, projects list/get |
| Supabase projects, by ref | db push, db reset --linked, migration repair, functions deploy, secrets set, link, config push… by --project-ref or the linked supabase/.temp/project-ref | status, db pull/diff/dump, gen types, functions list, local db reset |
MCP tools (Neon, Supabase, any server that sends a projectId / project_ref) | run_sql, execute_sql, apply_migration, deploy_edge_function, create_*, delete_*, update_*… on a protected project | tools whose name starts with get_, list_, describe_, search, fetch, inspect_… |
Commands are read the way a shell reads them (quotes, heredocs, $(...), cd, git -C, bash -c, sudo, npx), so a repo name inside a commit message or a grep never triggers it. The remote is only looked up when a command has the shape of a write.
/handsoff pass: allows the next blocked call on what was just blocked, within 10 minutes, then it's spent./handsoff pass acme/billing-api (or vercel:<name>, neon:<id>, supabase:<ref>): a pass in advance.The agent can't give itself a pass, and neither can another plugin: only a prompt a person typed, or a button a person pressed, counts. A command that touches two protected things needs a pass for each.
/handsoff opens it (terminal, desktop and phone):
owner/repo or its URL, checked with gh repo view), a Vercel, Railway, Neon or Supabase project; and to remove any (it asks you to confirm);When the panel is showing, /handsoff leaves nothing in the conversation. /handsoff status prints a text summary.
~/.claude/hands-off.json, created empty the first time and never overwritten by an update:
{
"repos": ["acme/billing-api", "acme/infra"],
"vercelProjects": ["acme-web"],
"railwayProjects": ["billing-worker"],
"neonProjects": ["proud-cloud-12345678"],
"supabaseProjects": ["abcdefghijklmnopqrst"]
}
If the file breaks, Hands Off keeps protecting the last list it read whole and tells you once.
git remote get-url and git config --get-regexp (to know a repo and its remotes), gh repo view when you add a repo, and one gh repo view per repo (cached for a week) when a gh command that writes runs in a repo you didn't protect, to learn whether it is a fork of one you did. If you protect no repo, that lookup never happens.git, only the origin remote is checked (plus an explicit git push target).gh aims at the original: use a pass or gh pr create -R you/your-fork. The fork check asks GitHub once per repo (cached a week) and lets the command through if GitHub can't be reached.python -c "open(...)", node -e) or an xargs are not seen.psql (or any client) with a connection string is not seen.English or Spanish, following Claude Code's language setting (then your system's locale). In Spanish it's called Intocables and /intocables works too (/intocables pase, /intocables estado).
claude plugin validate <folder>
claude plugin test <folder>
hooks/rules.ts is pure (shell lexer, rules, passes, list edits) and tested in tests/; hooks/register.tsx wires the disk, git, state and panel; hooks/i18n.ts holds every string in both languages.
MIT. See LICENSE.
hooks/register.tsx 836 lines1/**
2 * Hands Off — keep your agent from writing to repos, deploys and databases that are not yours: read yes, write no.
3 *
4 * One guardrail (tool.call) on Bash, Edit, Write and NotebookEdit, and another on the MCP tools (`mcp__…`) that name a
5 * protected Neon or Supabase project (projectId / project_id). Silent when all is well: it denies only on a positive
6 * match (the repo's `origin` remote is in the list in ~/.claude/hands-off.json and the command has the shape of a
7 * write) and lets the call through on any error of its own (fail-open, with one notice).
8 *
9 * The override is human and one-time: `/handsoff pass [owner/repo]` typed by a person (terminal, desktop app through
10 * the SDK, or phone), or the "One-time pass" button of the panel. It covers the next blocked call on that target within
11 * the following 10 minutes. A plugin or the model cannot give itself a pass (the panel's buttons are only pressed by a
12 * person).
13 *
14 * `/handsoff` opens the panel: what is protected, active passes, this session's blocks and the list management.
15 * In Spanish sessions the same command is also registered as `/intocables`, and `pase` / `estado` work as verbs.
16 *
17 * The pure rules live in ./rules.ts and the words in ./i18n.ts; this file is only the wiring and everything that
18 * touches the disk.
19 */
20import { atom, read, update } from 'claude-code'
21import type { EngineInterface, Register } from 'claude-code'
22
23import type { ConfigView, Pass } from '../types'
24import { pickLang, words, type Lang, type Words } from './i18n'
25import {
26 DEFAULT_CONFIG,
27 HUMAN_ORIGINS,
28 PASS_TTL_MS,
29 ago,
30 checkBash,
31 checkMcp,
32 checkPath,
33 cleanSlug,
34 defaultConfigText,
35 denyText,
36 dirname,
37 editConfig,
38 isLive,
39 migrateConfig,
40 minutesLeft,
41 parseConfig,
42 spendPasses,
43 type Config,
44 type ConfigEdit,
45 type Deps,
46 type Hit,
47} from './rules'
48
49const passes = atom({ plugin: 'hands-off', key: 'passes' } as const, [])
50const lastDenied = atom({ plugin: 'hands-off', key: 'lastDenied' } as const, null)
51const history = atom({ plugin: 'hands-off', key: 'history' } as const, [])
52const view = atom({ plugin: 'hands-off', key: 'view' } as const, null)
53const expanded = atom({ plugin: 'hands-off', key: 'expanded' } as const, null)
54const confirm = atom({ plugin: 'hands-off', key: 'confirm' } as const, null)
55const note = atom({ plugin: 'hands-off', key: 'note' } as const, null)
56const tick = atom({ plugin: 'hands-off', key: 'tick' } as const, 0)
57
58const PANE = 'hands-off'
59const HISTORY_MAX = 30
60/** Time cap for the analysis: if git or the disk hang, the call goes through (fail-open). */
61const BUDGET_MS = 9000
62const CACHE_TTL_MS = 15_000
63/** How long GitHub's answer about a fork's parent is kept (the parent of a repo practically never changes). */
64const FORK_TTL_MS = 7 * 24 * 60 * 60 * 1000
65/** Time cap for the one `gh repo view` per repo. */
66const GH_TIMEOUT_MS = 6000
67
68// ── language ────────────────────────────────────────────────────────────────
69
70let lang: Lang = 'en'
71let langKnown = false
72
73/** Claude Code's `language` setting, then LC_ALL/LANG; English by default. Looked up at session.start. */
74async function detectLang($: EngineInterface): Promise<Lang> {
75 const settings = (await $.settings.read().catch(() => ({}))) as { language?: unknown }
76 const locale = (await $.env.get('LC_ALL').catch(() => null)) || (await $.env.get('LANG').catch(() => null)) || null
77 lang = pickLang(undefined, settings.language, locale)
78 langKnown = true
79 return lang
80}
81
82/** The words in the session's language (looks the language up once if session.start has not yet). */
83async function wordsOf($: EngineInterface): Promise<Words> {
84 if (!langKnown) await detectLang($).catch(() => undefined)
85 return words(lang)
86}
87
88// ── config and disk ─────────────────────────────────────────────────────────
89
90async function homeOf($: EngineInterface) {
91 return (await $.env.get('HOME')) ?? ''
92}
93
94const configPath = (home: string) => `${home}/.claude/hands-off.json`
95/** The previous version's list: imported once when there is no hands-off.json yet (and left untouched). */
96const legacyPath = (home: string) => `${home}/.claude/intocables.json`
97
98let badConfigWarned = false
99let lastViewJson = ''
100
101/** Copies the list to the state the panel reads, only when it changed (so it does not redraw on every call). */
102async function syncView($: EngineInterface, config: Config, isDefault: boolean) {
103 const next: ConfigView = { ...config, isDefault }
104 const json = JSON.stringify(next)
105 if (json === lastViewJson) return
106 lastViewJson = json
107 await update($, view, () => next)
108}
109
110/**
111 * Reads ~/.claude/hands-off.json. If it does not exist it is created: from the previous version's
112 * ~/.claude/intocables.json when that exists (read only), else with empty lists. Never throws.
113 */
114/** The last list read whole: a file broken later never leaves you unprotected (kept across sessions in the store). */
115let lastGood: Config | null = null
116let lastGoodJson = ''
117
118async function rememberGood($: EngineInterface, config: Config) {
119 const json = JSON.stringify(config)
120 if (json === lastGoodJson) return
121 lastGood = config
122 lastGoodJson = json
123 await $.store.set('lastGoodConfig', config).catch(() => undefined)
124}
125
126async function fallbackConfig($: EngineInterface): Promise<Config> {
127 if (lastGood) return lastGood
128 const stored = parseConfig(JSON.stringify((await $.store.get('lastGoodConfig').catch(() => undefined)) ?? null))
129 return stored ?? DEFAULT_CONFIG
130}
131
132async function loadConfig($: EngineInterface, home: string): Promise<Config> {
133 if (!home) return DEFAULT_CONFIG
134 const path = configPath(home)
135 try {
136 if (!(await $.fs.exists(path))) {
137 let text = defaultConfigText()
138 let config = DEFAULT_CONFIG
139 const old = legacyPath(home)
140 if (await $.fs.exists(old).catch(() => false)) {
141 const migrated = migrateConfig(await $.fs.read(old).catch(() => ''))
142 if (migrated) {
143 text = migrated.text
144 config = migrated.config
145 } else {
146 $.ui.toast((await wordsOf($)).legacyUnreadable)
147 }
148 }
149 let written = true
150 await $.fs.write(path, text).catch(() => {
151 written = false
152 })
153 await syncView($, config, !written).catch(() => undefined)
154 await rememberGood($, config)
155 return config
156 }
157 const parsed = parseConfig(await $.fs.read(path))
158 if (parsed) {
159 await syncView($, parsed, false).catch(() => undefined)
160 await rememberGood($, parsed)
161 return parsed
162 }
163 } catch {
164 // falls back to the empty list
165 }
166 if (!badConfigWarned) {
167 badConfigWarned = true
168 $.ui.toast((await wordsOf($)).configUnreadable)
169 }
170 const fallback = await fallbackConfig($)
171 await syncView($, fallback, true).catch(() => undefined)
172 return fallback
173}
174
175async function git($: EngineInterface, dir: string, args: string[]) {
176 try {
177 const run = await $.process.run(['git', '-C', dir, ...args], { timeoutMs: 5000 })
178 return run.exitCode === 0 ? run.stdout.trim() : ''
179 } catch {
180 return ''
181 }
182}
183
184// Module caches (a reload empties them): they only save git calls. Short-lived, because a remote can change.
185const originCache = new Map<string, { at: number; url: string }>()
186
187/** Real directory that contains `path`: the nearest existing ancestor, with symlinks resolved. */
188async function containingDir($: EngineInterface, path: string) {
189 let at = path
190 for (let i = 0; i < 40 && at !== '/' && !(await $.fs.exists(at).catch(() => false)); i++) at = dirname(at)
191 const st = await $.fs.stat(at, { resolve: true }).catch(() => null)
192 if (!st) return at
193 const real = st.realPath ?? at
194 return st.kind === 'dir' ? real : dirname(real)
195}
196
197async function originOfPath($: EngineInterface, path: string) {
198 const dir = await containingDir($, path)
199 const now = await $.clock.now()
200 const cached = originCache.get(dir)
201 if (cached && now - cached.at < CACHE_TTL_MS) return cached.url
202 const url = await git($, dir, ['remote', 'get-url', 'origin'])
203 originCache.set(dir, { at: now, url })
204 return url
205}
206
207/** All the remote URLs of the repo that contains `dir` (local, no network); [] on any error. */
208async function remotesOfPath($: EngineInterface, dir: string) {
209 const out = await git($, await containingDir($, dir), ['config', '--get-regexp', '^remote\\..*\\.url'])
210 const urls = out
211 .split('\n')
212 .map(line => line.replace(/^\S+\s+/, '').trim())
213 .filter(Boolean)
214 return [...new Set(urls)]
215}
216
217// Parent of a fork according to GitHub: per owner/repo, in memory and in the store (a week). `undefined` is never kept.
218type ForkEntry = { parent: string | null; at: number }
219const forkCache = new Map<string, ForkEntry>()
220let forkWarned = false
221
222const isForkEntry = (x: unknown): x is ForkEntry => {
223 const e = x as ForkEntry | null
224 return !!e && typeof e === 'object' && typeof e.at === 'number' && (e.parent === null || typeof e.parent === 'string')
225}
226
227/** `owner/repo` this repo was forked from; null = not a fork; undefined = could not be checked. */
228async function parentOfSlug($: EngineInterface, slug: string): Promise<string | null | undefined> {
229 if (!/^[A-Za-z0-9_][\w.-]*\/[\w.-]+$/.test(slug)) return null
230 const key = slug.toLowerCase()
231 const now = await $.clock.now()
232 const fresh = (e: ForkEntry | undefined): e is ForkEntry => !!e && now >= e.at && now - e.at < FORK_TTL_MS
233 const memory = forkCache.get(key)
234 if (fresh(memory)) return memory.parent
235 const stored = (await $.store.get('forkParents').catch(() => undefined)) as Record<string, unknown> | undefined
236 const saved = stored && typeof stored === 'object' ? stored[key] : undefined
237 if (isForkEntry(saved) && fresh(saved)) {
238 forkCache.set(key, saved)
239 return saved.parent
240 }
241 let parent: string | null
242 try {
243 const run = await $.process.run(
244 ['gh', 'repo', 'view', slug, '--json', 'parent', '-q', 'if .parent then .parent.owner.login + "/" + .parent.name else "" end'],
245 { timeoutMs: GH_TIMEOUT_MS },
246 )
247 if (run.exitCode !== 0) return undefined
248 const out = run.stdout.trim()
249 if (out !== '' && !/^[\w.-]+\/[\w.-]+$/.test(out)) return undefined
250 parent = out === '' ? null : out
251 } catch {
252 return undefined
253 }
254 const entry = { parent, at: now }
255 forkCache.set(key, entry)
256 await $.store
257 .set('forkParents', {
258 ...Object.fromEntries(Object.entries(stored && typeof stored === 'object' ? stored : {}).filter(([, v]) => isForkEntry(v) && fresh(v))),
259 [key]: entry,
260 })
261 .catch(() => undefined)
262 return parent
263}
264
265/** Railway project linked to the directory, from ~/.railway/config.json (no network): the nearest link going up. */
266async function railwayLinked($: EngineInterface, home: string, dir: string) {
267 try {
268 const text = await $.fs.read(`${home}/.railway/config.json`)
269 const projects = (JSON.parse(text) as { projects?: Record<string, { name?: string }> }).projects ?? {}
270 let at = dir
271 for (let i = 0; i < 40; i++) {
272 const name = projects[at]?.name
273 if (name) return name
274 if (at === '/') break
275 at = dirname(at)
276 }
277 } catch {
278 // no Railway config: no known link
279 }
280 return ''
281}
282
283/** Vercel project linked to the directory: the nearest `.vercel/project.json` going up. */
284async function vercelLinked($: EngineInterface, dir: string) {
285 let at = dir
286 for (let i = 0; i < 40; i++) {
287 const file = `${at === '/' ? '' : at}/.vercel/project.json`
288 if (await $.fs.exists(file).catch(() => false)) {
289 try {
290 const json = JSON.parse(await $.fs.read(file)) as { projectName?: unknown; projectId?: unknown }
291 return {
292 name: typeof json.projectName === 'string' ? json.projectName : '',
293 id: typeof json.projectId === 'string' ? json.projectId : '',
294 }
295 } catch {
296 return { name: '', id: '' }
297 }
298 }
299 if (at === '/') break
300 at = dirname(at)
301 }
302 return { name: '', id: '' }
303}
304
305/** Neon project id of a context file (`.neon`, JSON with `projectId`); '' if it cannot be read. */
306async function readNeonContext($: EngineInterface, file: string) {
307 try {
308 const json = JSON.parse(await $.fs.read(file)) as { projectId?: unknown }
309 return typeof json.projectId === 'string' ? json.projectId.trim() : ''
310 } catch {
311 return ''
312 }
313}
314
315/** Neon project `dir` points to: the nearest `.neon` going up, or only `file` (--context-file). */
316async function neonContext($: EngineInterface, dir: string, file?: string) {
317 if (file) return (await $.fs.exists(file).catch(() => false)) ? readNeonContext($, file) : ''
318 let at = dir
319 for (let i = 0; i < 40; i++) {
320 const candidate = `${at === '/' ? '' : at}/.neon`
321 if (await $.fs.exists(candidate).catch(() => false)) return readNeonContext($, candidate)
322 if (at === '/') break
323 at = dirname(at)
324 }
325 return ''
326}
327
328/** Linked Supabase project ref: the nearest `supabase/.temp/project-ref` going up (or only in `dir` with `exact`, from --workdir). */
329async function supabaseLinked($: EngineInterface, dir: string, exact = false) {
330 let at = dir
331 for (let i = 0; i < 40; i++) {
332 const file = `${at === '/' ? '' : at}/supabase/.temp/project-ref`
333 if (await $.fs.exists(file).catch(() => false)) return (await $.fs.read(file).catch(() => '')).trim()
334 if (exact || at === '/') break
335 at = dirname(at)
336 }
337 return ''
338}
339
340async function depsFor($: EngineInterface): Promise<Deps> {
341 const home = await homeOf($)
342 const config = await loadConfig($, home)
343 return {
344 cwd: await $.session.cwd(),
345 home,
346 config,
347 words: await wordsOf($),
348 originOf: path => originOfPath($, path),
349 remoteUrl: async (dir, name) => git($, await containingDir($, dir), ['remote', 'get-url', name]),
350 remotesOf: dir => remotesOfPath($, dir),
351 parentOf: slug => parentOfSlug($, slug),
352 warn: async key => {
353 if (key !== 'forkUnchecked' || forkWarned) return
354 forkWarned = true
355 $.ui.toast((await wordsOf($)).forkUnchecked)
356 },
357 railwayLinked: dir => railwayLinked($, home, dir),
358 vercelLinked: dir => vercelLinked($, dir),
359 neonContext: (dir, file) => neonContext($, dir, file),
360 supabaseLinked: (dir, exact) => supabaseLinked($, dir, exact),
361 }
362}
363
364// ── fail-open ───────────────────────────────────────────────────────────────
365
366let failWarned = false
367
368async function failOpen($: EngineInterface, error: unknown) {
369 if (failWarned) return
370 failWarned = true
371 const why = error instanceof Error ? error.message : String(error)
372 $.ui.toast((await wordsOf($)).ruleFailed(why.slice(0, 80)))
373}
374
375async function withBudget<T>($: EngineInterface, work: Promise<T>): Promise<T> {
376 let timer: { cancel: () => void } | undefined
377 const w = await wordsOf($)
378 const timeout = new Promise<never>((_, reject) => {
379 timer = $.clock.after(BUDGET_MS, () => reject(new Error(w.tookTooLong)))
380 })
381 try {
382 return await Promise.race([work, timeout])
383 } finally {
384 timer?.cancel()
385 }
386}
387
388// ── passes and blocks ────────────────────────────────────────────────────────
389
390async function remember($: EngineInterface, hits: Hit[], now: number, passed: boolean) {
391 const entry = { keys: hits.map(h => h.key), labels: hits.map(h => h.label), detail: hits[0]!.detail, at: now, passed }
392 await update($, history, list => [...list, entry].slice(-HISTORY_MAX))
393}
394
395/** Lets the call through if a person gave a pass for ALL the targets of the block (it is spent); otherwise denies with the reason. */
396async function gate($: EngineInterface, hits: Hit[]) {
397 const w = await wordsOf($)
398 const keys = hits.map(h => h.key)
399 const now = await $.clock.now()
400 let ok = false
401 await update($, passes, list => {
402 const spent = spendPasses(list, keys, now)
403 ok = spent.ok
404 return spent.left
405 })
406 await remember($, hits, now, ok).catch(() => undefined)
407 if (ok) {
408 $.ui.toast(w.passUsed(hits.map(h => h.label).join(', ')))
409 return null
410 }
411 await update($, lastDenied, () => ({ keys, labels: hits.map(h => h.label), at: now }))
412 $.ui.toast(w.blockedToast(hits[0]!.detail, hits.map(h => h.label).join(', ')))
413 return { deny: denyText(hits, w) }
414}
415
416let ticking = false
417
418/** While there are live passes, the panel redraws every 30 s (countdown and expiry). */
419function keepTicking($: EngineInterface) {
420 if (ticking) return
421 ticking = true
422 const step = () => {
423 $.clock.after(30_000, () => {
424 void (async () => {
425 const now = await $.clock.now()
426 const live = (await read($, passes)).filter(p => isLive(p, now))
427 await update($, tick, n => n + 1)
428 if (live.length > 0) step()
429 else ticking = false
430 })().catch(() => {
431 ticking = false
432 })
433 })
434 }
435 step()
436}
437
438async function grant($: EngineInterface, hits: { key: string; label: string }[]) {
439 const now = await $.clock.now()
440 const fresh: Pass[] = hits.map(h => ({ key: h.key, label: h.label, at: now }))
441 await update($, passes, list => [...list.filter(p => isLive(p, now) && !hits.some(h => h.key === p.key)), ...fresh])
442 keepTicking($)
443}
444
445async function revoke($: EngineInterface, key: string) {
446 await update($, passes, list => list.filter(p => p.key !== key))
447}
448
449const repoKey = (slug: string) => slug.toLowerCase()
450const railwayKey = (name: string) => `railway:${name.toLowerCase()}`
451const vercelKey = (name: string) => `vercel:${name.toLowerCase()}`
452const neonKey = (id: string) => `neon:${id.toLowerCase()}`
453const supabaseKey = (ref: string) => `supabase:${ref.toLowerCase()}`
454
455async function statusText($: EngineInterface) {
456 const w = await wordsOf($)
457 const config = await loadConfig($, await homeOf($))
458 const now = await $.clock.now()
459 const live = (await read($, passes)).filter(p => isLive(p, now))
460 const nothing = !(config.repos.length || config.railwayProjects.length || config.vercelProjects.length || config.neonProjects.length || config.supabaseProjects.length)
461 const lines = [
462 w.statusIntro,
463 nothing ? w.statusNothing : '',
464 config.repos.length ? w.statusRepos(config.repos.length, config.repos.join(', ')) : '',
465 config.railwayProjects.length ? w.statusProjects(w.kind.railway, config.railwayProjects.join(', ')) : '',
466 config.vercelProjects.length ? w.statusProjects(w.kind.vercel, config.vercelProjects.join(', ')) : '',
467 config.neonProjects.length ? w.statusProjects(w.kind.neon, config.neonProjects.join(', ')) : '',
468 config.supabaseProjects.length ? w.statusProjects(w.kind.supabase, config.supabaseProjects.join(', ')) : '',
469 live.length ? w.statusPasses(live.map(p => w.statusMinutes(p.label, minutesLeft(p, now))).join(', ')) : w.statusNoPasses,
470 w.statusFooter,
471 ]
472 return lines.filter(Boolean).join('\n')
473}
474
475// ── list management (panel) ─────────────────────────────────────────────────
476
477async function say($: EngineInterface, text: string, isError = false) {
478 await update($, note, () => ({ text, isError }))
479}
480
481/** Applies a change to ~/.claude/hands-off.json without overwriting anything else; an unreadable file is left alone. */
482async function applyEdit($: EngineInterface, edit: ConfigEdit, suffix = '') {
483 const w = await wordsOf($)
484 const home = await homeOf($)
485 if (!home) return say($, w.noHome, true)
486 const path = configPath(home)
487 try {
488 const text = (await $.fs.exists(path)) ? await $.fs.read(path) : null
489 const out = editConfig(text, edit, w)
490 if ('error' in out) return say($, w.nothingChanged(out.error), true)
491 await $.fs.write(path, out.text)
492 await syncView($, out.config, false)
493 await update($, confirm, () => null)
494 return say($, `${out.changed}${suffix}.`)
495 } catch (error) {
496 return say($, w.writeFailed(error instanceof Error ? error.message : String(error)), true)
497 }
498}
499
500/** Adds a repo; if `gh` confirms it, its spelling is used, otherwise it is added anyway with a warning (it may be a typo). */
501async function addRepo($: EngineInterface, input: string) {
502 const w = await wordsOf($)
503 const slug = cleanSlug(input)
504 if (!slug) return say($, w.notRepoInput(input.trim()), true)
505 let canonical = ''
506 try {
507 const run = await $.process.run(['gh', 'repo', 'view', slug, '--json', 'nameWithOwner', '-q', '.nameWithOwner'], { timeoutMs: 8000 })
508 if (run.exitCode === 0) canonical = cleanSlug(run.stdout) ?? ''
509 } catch {
510 // no gh or no network: it is added as typed
511 }
512 return applyEdit($, { op: 'addRepo', slug: canonical || slug }, canonical ? w.verified : w.unverified)
513}
514
515/** Opens the panel; true when a surface shows it now (then the command leaves no text in the conversation). */
516async function openPane($: EngineInterface) {
517 await loadConfig($, await homeOf($)).catch(() => undefined)
518 await update($, note, () => null)
519 const opened = await $.ui.open({ id: PANE, title: (await wordsOf($)).brand, closeOnEscape: true })
520 return opened.isPlaced
521}
522
523// ── commands ────────────────────────────────────────────────────────────────
524
525/**
526 * `/handsoff` (and `/intocables` in Spanish sessions). No verb → the panel, and text only where no panel shows, so a
527 * click on Open leaves nothing in the conversation. `status` / `estado` → always the summary as text.
528 * `pass` / `pase` [target] → a one-time pass (only a person, from the prompt).
529 */
530async function runCommand($: EngineInterface, e: { args?: string; origin: { kind: string } }): Promise<{ text?: string }> {
531 const [rawVerb, slug] = (e.args ?? '').trim().split(/\s+/)
532 const verb = rawVerb?.toLowerCase()
533 if (verb === 'status' || verb === 'estado') return { text: await statusText($) }
534 if (verb !== 'pass' && verb !== 'pase') {
535 const shown = await openPane($).catch(() => false)
536 return shown ? {} : { text: await statusText($) }
537 }
538 const w = await wordsOf($)
539 if (!HUMAN_ORIGINS.has(e.origin.kind)) return { text: w.onlyHuman }
540
541 const config = await loadConfig($, await homeOf($))
542 const now = await $.clock.now()
543 if (slug) {
544 const wanted = slug.toLowerCase()
545 const named = (prefix: string, name: string) => `${prefix}:${name.toLowerCase()}` === wanted || name.toLowerCase() === wanted
546 const found: { key: string; label: string } | undefined =
547 config.repos.filter(r => r.toLowerCase() === wanted).map(r => ({ key: repoKey(r), label: r }))[0] ??
548 config.railwayProjects.filter(r => named('railway', r)).map(r => ({ key: railwayKey(r), label: `Railway ${r}` }))[0] ??
549 config.vercelProjects.filter(r => named('vercel', r)).map(r => ({ key: vercelKey(r), label: `Vercel ${r}` }))[0] ??
550 config.neonProjects.filter(r => named('neon', r)).map(r => ({ key: neonKey(r), label: `Neon ${r}` }))[0] ??
551 config.supabaseProjects.filter(r => named('supabase', r)).map(r => ({ key: supabaseKey(r), label: `Supabase ${r}` }))[0]
552 if (!found) {
553 const all = [
554 ...config.repos,
555 ...config.railwayProjects.map(r => `Railway ${r}`),
556 ...config.vercelProjects.map(r => `Vercel ${r}`),
557 ...config.neonProjects.map(r => `Neon ${r}`),
558 ...config.supabaseProjects.map(r => `Supabase ${r}`),
559 ]
560 return { text: w.notProtected(slug, all.length ? all.join(', ') : w.none) }
561 }
562 await grant($, [found])
563 return { text: w.passGranted(found.label) }
564 }
565 const last = await read($, lastDenied)
566 if (!last || now - last.at > PASS_TTL_MS) return { text: w.noRecentBlock }
567 await grant($, last.keys.map((key, i) => ({ key, label: last.labels[i] ?? key })))
568 await update($, lastDenied, () => null)
569 return { text: w.passGrantedLast(last.labels.join(', ')) }
570}
571
572// ── registration ────────────────────────────────────────────────────────────
573
574export const register: Register = on => {
575 on('session.start', async ($, e, next) => {
576 lastViewJson = ''
577 forkWarned = false
578 const l = await detectLang($)
579 const w = words(l)
580 await $.command.register({ name: 'handsoff', description: w.commandDescription, argumentHint: w.argumentHint })
581 // Spanish sessions also get the original name.
582 if (l === 'es') await $.command.register({ name: 'intocables', description: w.commandDescription, argumentHint: w.argumentHint })
583 await loadConfig($, await homeOf($)).catch(() => undefined)
584 return next(e)
585 })
586
587 // /clear: new conversation, no passes, blocks or pending notices.
588 on('session.end', async ($, e, next) => {
589 if (e.reason === 'clear') {
590 await update($, passes, () => [])
591 await update($, lastDenied, () => null)
592 await update($, history, () => [])
593 await update($, note, () => null)
594 }
595 return next(e)
596 })
597
598 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
599 let hits: Hit[] = []
600 try {
601 const deps = await depsFor($)
602 hits = await withBudget($, checkBash(e.command, deps))
603 } catch (error) {
604 await failOpen($, error)
605 return next(e)
606 }
607 if (hits.length > 0) {
608 const denied = await gate($, hits)
609 if (denied) return denied
610 }
611 return next(e)
612 })
613
614 on('tool.call', { tool: ['Edit', 'Write', 'NotebookEdit'] }, async ($, e, next) => {
615 let hits: Hit[] = []
616 try {
617 const deps = await depsFor($)
618 hits = await withBudget($, checkPath(e.tool === 'NotebookEdit' ? e.notebook_path : e.file_path, deps))
619 } catch (error) {
620 await failOpen($, error)
621 return next(e)
622 }
623 if (hits.length > 0) {
624 const denied = await gate($, hits)
625 if (denied) return denied
626 }
627 return next(e)
628 })
629
630 // MCP tools (Neon, Supabase…): only the ones that name a protected project and do not read. The rest goes through
631 // at once (a single name comparison), without touching the disk.
632 on('tool.call', async ($, e, next) => {
633 if (!e.tool.startsWith('mcp__')) return next(e)
634 let hits: Hit[] = []
635 try {
636 const config = await withBudget($, homeOf($).then(home => loadConfig($, home)))
637 hits = checkMcp(e.tool, e as unknown as Record<string, unknown>, config)
638 } catch (error) {
639 await failOpen($, error)
640 return next(e)
641 }
642 if (hits.length > 0) {
643 const denied = await gate($, hits)
644 if (denied) return denied
645 }
646 return next(e)
647 })
648
649 // /handsoff (and /intocables in Spanish sessions) → panel · status · pass.
650 on('command.run', { command: 'handsoff' }, ($, e) => runCommand($, e))
651 on('command.run', { command: 'intocables' }, ($, e) => runCommand($, e))
652
653 // The panel: what is protected, passes, this session's blocks and the list management. Its buttons are only pressed by
654 // a person (the model has no way to), so passes and changes from here are a person's.
655 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
656 const { Box, Button, Text } = $.ui.resolve(e)
657 const Input = e.surface === 'mobile' ? null : $.ui.resolve(e).Input
658 const w = await wordsOf($)
659 await read($, tick)
660 const cfg = (await read($, view)) ?? { ...DEFAULT_CONFIG, isDefault: true }
661 const now = await $.clock.now()
662 const live = (await read($, passes)).filter(p => isLive(p, now))
663 const blocks = await read($, history)
664 const open = await read($, expanded)
665 const pending = await read($, confirm)
666 const msg = await read($, note)
667 const last = await read($, lastDenied)
668 const denied = blocks.filter(b => !b.passed)
669
670 type Row = { id: string; key: string; label: string; owner: string; remove: ConfigEdit }
671 const rows: Row[] = [
672 ...cfg.repos.map((slug, i) => ({ id: `repo-${i}`, key: repoKey(slug), label: slug, owner: w.kind.github, remove: { op: 'removeRepo', slug } as ConfigEdit })),
673 ...cfg.railwayProjects.map((name, i) => ({ id: `rail-${i}`, key: railwayKey(name), label: `Railway ${name}`, owner: w.kind.railway, remove: { op: 'removeRailway', name } as ConfigEdit })),
674 ...cfg.vercelProjects.map((name, i) => ({ id: `vercel-${i}`, key: vercelKey(name), label: `Vercel ${name}`, owner: w.kind.vercel, remove: { op: 'removeVercel', name } as ConfigEdit })),
675 ...cfg.neonProjects.map((id, i) => ({ id: `neon-${i}`, key: neonKey(id), label: `Neon ${id}`, owner: w.kind.neon, remove: { op: 'removeNeon', id } as ConfigEdit })),
676 ...cfg.supabaseProjects.map((ref, i) => ({ id: `supabase-${i}`, key: supabaseKey(ref), label: `Supabase ${ref}`, owner: w.kind.supabase, remove: { op: 'removeSupabase', ref } as ConfigEdit })),
677 ]
678
679 const card = (r: Row) => {
680 const pass = live.find(p => p.key === r.key)
681 const mine = denied.filter(b => b.keys.includes(r.key))
682 const lastHit = mine[mine.length - 1]
683 const isOpen = open === r.id
684 return (
685 <Box
686 key={`card-${r.id}`}
687 flexDirection="column"
688 borderStyle="round"
689 borderColor={pass ? 'warning' : 'promptBorder'}
690 paddingX={1}
691 marginBottom={1}
692 overflow="hidden"
693 >
694 <Box justifyContent="space-between" alignItems="center" gap={1} minWidth={0}>
695 <Box gap={1} flexShrink={1} minWidth={0} overflow="hidden">
696 <Text color="claude">🔒</Text>
697 <Text bold wrap="truncate-end">{r.label}</Text>
698 <Text dimColor>{r.owner}</Text>
699 </Box>
700 {pass ? <Text color="warning" bold>{w.passTag(minutesLeft(pass, now))}</Text> : <Text color="success">{w.protectedTag}</Text>}
701 </Box>
702 <Text dimColor wrap="wrap">
703 {mine.length === 0 ? w.noBlocksYet : w.blockSummary(mine.length, ago(now - lastHit!.at, w), lastHit!.detail)}
704 </Text>
705 <Box gap={1} marginTop={1} flexWrap="wrap">
706 {pass ? (
707 <Button key={`${r.id}-revoke`} label={w.revoke} onPress={() => void revoke($, r.key)} />
708 ) : (
709 <Button key={`${r.id}-pass`} label={w.onePass} onPress={() => void grant($, [{ key: r.key, label: r.label }])} />
710 )}
711 <Button
712 key={`${r.id}-more`}
713 dimColor
714 label={isOpen ? w.less : w.manage}
715 onPress={() => void update($, expanded, x => (x === r.id ? null : r.id)).then(() => update($, confirm, () => null))}
716 />
717 </Box>
718 {isOpen && (
719 <Box gap={1} marginTop={1} flexWrap="wrap">
720 {pending === r.id ? (
721 <Button key={`${r.id}-remove`} variant="primary" label={w.confirmRemove(r.label)} onPress={() => void applyEdit($, r.remove).then(() => revoke($, r.key))} />
722 ) : (
723 <Button key={`${r.id}-remove`} dimColor label={w.removeFromList} onPress={() => void update($, confirm, () => r.id)} />
724 )}
725 </Box>
726 )}
727 {isOpen && pending === r.id && <Text color="warning" wrap="wrap">{w.removeWarning}</Text>}
728 </Box>
729 )
730 }
731
732 const recent = blocks.slice(-8).reverse()
733
734 return (
735 <Box flexDirection="column">
736 <Box flexDirection="column" marginBottom={1}>
737 <Text bold>{`🛡 ${w.brand} · ${w.tagline}`}</Text>
738 <Text dimColor wrap="wrap">
739 {w.summary({ repos: cfg.repos.length, railway: cfg.railwayProjects.length, vercel: cfg.vercelProjects.length, neon: cfg.neonProjects.length, supabase: cfg.supabaseProjects.length, live: live.length, denied: denied.length })}
740 </Text>
741 {cfg.isDefault && <Text color="warning" wrap="wrap">{w.defaultListWarning}</Text>}
742 </Box>
743
744 {msg && (
745 <Box marginBottom={1}>
746 <Text color={msg.isError ? 'error' : 'success'} wrap="wrap">{msg.text}</Text>
747 </Box>
748 )}
749
750 {last && now - last.at <= PASS_TTL_MS && !live.some(p => last.keys.includes(p.key)) && (
751 <Box flexDirection="column" borderStyle="round" borderColor="warning" paddingX={1} marginBottom={1}>
752 <Text color="warning" bold wrap="wrap">{w.recentBlock(last.labels.join(', '), ago(now - last.at, w))}</Text>
753 <Box gap={1} marginTop={1}>
754 <Button
755 key="last-pass"
756 variant="primary"
757 label={w.givePass}
758 onPress={() =>
759 void grant($, last.keys.map((key, i) => ({ key, label: last.labels[i] ?? key })))
760 .then(() => update($, lastDenied, () => null))
761 .then(() => say($, w.passGivenNote(last.labels.join(', '))))
762 }
763 />
764 </Box>
765 </Box>
766 )}
767
768 <Text bold>{w.headingProtected}</Text>
769 {rows.length === 0 && <Text dimColor>{w.emptyList}</Text>}
770 {rows.map(card)}
771
772 <Text bold>{w.headingAdd}</Text>
773 {Input ? (
774 <Box flexDirection="column" marginBottom={1}>
775 <Input key="add-repo" label={w.inputRepo} placeholder={w.placeholderRepo} submitLabel={w.submit} onSubmit={value => void addRepo($, value)} />
776 <Input
777 key="add-railway"
778 label={w.inputRailway}
779 placeholder={w.placeholderRailway}
780 submitLabel={w.submit}
781 onSubmit={value => void applyEdit($, { op: 'addRailway', name: value })}
782 />
783 <Input
784 key="add-vercel"
785 label={w.inputVercel}
786 placeholder={w.placeholderVercel}
787 submitLabel={w.submit}
788 onSubmit={value => void applyEdit($, { op: 'addVercel', name: value })}
789 />
790 <Input
791 key="add-neon"
792 label={w.inputNeon}
793 placeholder={w.placeholderNeon}
794 submitLabel={w.submit}
795 onSubmit={value => void applyEdit($, { op: 'addNeon', id: value })}
796 />
797 <Input
798 key="add-supabase"
799 label={w.inputSupabase}
800 placeholder={w.placeholderSupabase}
801 submitLabel={w.submit}
802 onSubmit={value => void applyEdit($, { op: 'addSupabase', ref: value })}
803 />
804 </Box>
805 ) : (
806 <Box marginBottom={1}>
807 <Text dimColor wrap="wrap">{w.mobileNote}</Text>
808 </Box>
809 )}
810
811 <Text bold>{w.headingActivity}</Text>
812 {recent.length === 0 ? (
813 <Text dimColor>{w.nothingBlocked}</Text>
814 ) : (
815 <Box flexDirection="column" marginBottom={1}>
816 {recent.map((b, i) => (
817 <Text key={`act-${i}`} wrap="truncate-end">
818 <Text color={b.passed ? 'warning' : 'error'}>{b.passed ? w.activityPass : w.activityBlock}</Text>
819 <Text dimColor>{` ${ago(now - b.at, w)} · `}</Text>
820 <Text>{b.labels.join(', ')}</Text>
821 <Text dimColor>{` · ${b.detail}`}</Text>
822 </Text>
823 ))}
824 </Box>
825 )}
826
827 <Box gap={1} marginTop={1} flexWrap="wrap">
828 <Button key="reload" hotkey="r" dimColor label={w.reload} onPress={() => void homeOf($).then(home => loadConfig($, home)).then(() => say($, w.reloaded))} />
829 {blocks.length > 0 && <Button key="clear-history" dimColor label={w.clearActivity} onPress={() => void update($, history, () => [])} />}
830 </Box>
831 <Text dimColor wrap="wrap">{w.footer}</Text>
832 </Box>
833 )
834 })
835}
836hooks/i18n.ts 240 lines1/**
2 * Hands Off's words in English and Spanish, picked by Claude Code's `language` setting, then the locale (nothing to
3 * configure). Every user-facing string lives here; the rules and the wiring only call these.
4 */
5export type Lang = 'en' | 'es'
6
7const EN = {
8 brand: 'Hands Off',
9 /** The command a person types to give a one-time pass (used in hints and in the reason the model sees). */
10 passCmd: '/handsoff pass',
11 commandDescription:
12 'Keep your agent off what is not yours: read yes, write no · /handsoff opens the panel · /handsoff status · /handsoff pass [owner/repo]',
13 argumentHint: '[status | pass [owner/repo]]',
14
15 // ── rules ─────────────────────────────────────────────────────────────────
16 /** The reason the model sees when a call is denied. `also` = the other protected things in the same command. */
17 deny: (label: string, also: string, detail: string) =>
18 `Hands Off: ${label} is protected — read yes, write no${also ? ` (also: ${also})` : ''}. ` +
19 'If it needs a change, ask its owner. For a one-time exception a person must type /handsoff pass. ' +
20 `Do not work around this with another command. [blocked: ${detail}]`,
21 redirectTo: (name: string) => `redirect to ${name}`,
22 editOf: (name: string) => `edit of ${name}`,
23 ago: {
24 now: 'just now',
25 min: (n: number) => `${n} min ago`,
26 hours: (n: number) => `${n} h ago`,
27 days: (n: number) => `${n} d ago`,
28 },
29
30 // editConfig
31 invalidJson: 'the file is not valid JSON; fix it by hand before editing from the panel',
32 badShape: 'the file has an unexpected shape; fix it by hand before editing from the panel',
33 notRepo: (input: string) => `"${input}" doesn't look like a repo (owner/repo)`,
34 notRailway: (input: string) => `"${input}" is not a valid Railway project name`,
35 notVercel: (input: string) => `"${input}" is not a valid Vercel project name or id`,
36 notNeon: (input: string) => `"${input}" is not a valid Neon project id (lowercase letters, numbers and dashes, e.g. proud-cloud-12345678)`,
37 notSupabase: (input: string) => `"${input}" is not a valid Supabase project ref (20 lowercase letters)`,
38 added: (what: string) => `${what} added`,
39 removed: (what: string) => `${what} removed`,
40 already: (what: string) => `${what} is already in the list`,
41 absent: (what: string) => `${what} is not in the list`,
42
43 // ── toasts ────────────────────────────────────────────────────────────────
44 configUnreadable: 'Hands Off: ~/.claude/hands-off.json could not be read; still protecting the last list it read whole. Fix the file to change it',
45 legacyUnreadable: 'Hands Off: ~/.claude/intocables.json could not be read, so it was not imported; starting with an empty list',
46 ruleFailed: (why: string) => `Hands Off: the rule failed and the call was let through (${why})`,
47 tookTooLong: 'the analysis took too long',
48 forkUnchecked: "Hands Off couldn't check on GitHub whether this repo is a fork of a protected one (offline or gh not signed in); let it through.",
49 passUsed: (labels: string) => `Hands Off: pass used on ${labels}`,
50 blockedToast: (detail: string, labels: string) => `Hands Off blocked ${detail} on ${labels}. To allow it once: /handsoff pass`,
51
52 // ── command replies ───────────────────────────────────────────────────────
53 statusIntro: 'Hands Off: read yes, write no.',
54 statusNothing: 'Nothing is protected yet. Add repos and projects in the panel (/handsoff) or in ~/.claude/hands-off.json.',
55 statusRepos: (n: number, list: string) => `Protected repos (${n}): ${list}.`,
56 statusProjects: (kind: string, list: string) => `${kind} projects: ${list}.`,
57 statusPasses: (list: string) => `Active passes (one-time, 10 min): ${list}.`,
58 statusMinutes: (label: string, min: number) => `${label} (${min} min)`,
59 statusNoPasses: 'No active passes.',
60 statusFooter: 'For an exception: /handsoff pass [owner/repo | neon:id | supabase:ref]. Editable list in ~/.claude/hands-off.json or in the panel.',
61 onlyHuman: 'Hands Off: Only a person can give a pass, typing it at the prompt.',
62 none: 'none',
63 notProtected: (slug: string, all: string) => `Hands Off: "${slug}" is not in the protected list (${all}).`,
64 passGranted: (label: string) => `Hands Off: one-time pass for ${label} (10 min). The next blocked call on it goes through.`,
65 noRecentBlock: 'Hands Off: there is no recent block (10 min). To grant a pass in advance: /handsoff pass owner/repo.',
66 passGrantedLast: (labels: string) => `Hands Off: one-time pass for ${labels} (10 min). Retry the blocked action.`,
67
68 // ── panel ─────────────────────────────────────────────────────────────────
69 tagline: 'read yes, write no',
70 kind: { github: 'GitHub', railway: 'Railway', vercel: 'Vercel', neon: 'Neon', supabase: 'Supabase' },
71 protectedTag: 'protected',
72 passTag: (min: number) => `pass · ${min} min`,
73 noBlocksYet: 'No blocks in this session',
74 blockSummary: (n: number, ago: string, detail: string) => `${n} ${n === 1 ? 'block' : 'blocks'} · last ${ago}: ${detail}`,
75 revoke: 'Revoke pass',
76 onePass: 'One-time pass (10 min)',
77 less: 'Less ▴',
78 manage: 'Manage ▾',
79 confirmRemove: (label: string) => `Yes, remove ${label}`,
80 removeFromList: 'Remove from list…',
81 removeWarning: 'It will no longer be protected: Claude will be able to write there without a pass.',
82 summary: (c: { repos: number; railway: number; vercel: number; neon: number; supabase: number; live: number; denied: number }) =>
83 `${c.repos} ${c.repos === 1 ? 'repo' : 'repos'} · ${c.railway} Railway · ${c.vercel} Vercel · ${c.neon} Neon · ${c.supabase} Supabase · ` +
84 `${c.live} ${c.live === 1 ? 'active pass' : 'active passes'} · ${c.denied} ${c.denied === 1 ? 'block' : 'blocks'} in this session`,
85 defaultListWarning: 'Using an empty list (~/.claude/hands-off.json could not be read).',
86 recentBlock: (labels: string, ago: string) => `Recent block on ${labels} (${ago})`,
87 givePass: 'Give pass and retry',
88 passGivenNote: (labels: string) => `Pass given for ${labels}. Ask Claude to retry.`,
89 headingProtected: 'Protected',
90 emptyList: 'The list is empty: nothing is protected.',
91 headingAdd: 'Add',
92 inputRepo: 'Repo ',
93 inputRailway: 'Railway ',
94 inputVercel: 'Vercel ',
95 inputNeon: 'Neon ',
96 inputSupabase: 'Supabase ',
97 placeholderRepo: 'owner/repo or GitHub URL',
98 placeholderRailway: 'project name',
99 placeholderVercel: 'project name or id (prj_…)',
100 placeholderNeon: 'project id (e.g. proud-cloud-12345678)',
101 placeholderSupabase: 'project ref (20 letters)',
102 submit: 'add',
103 mobileNote: 'To add entries, use the panel in the terminal or the desktop app (or edit ~/.claude/hands-off.json).',
104 headingActivity: 'Activity this session',
105 nothingBlocked: 'Nothing blocked yet.',
106 activityPass: '↷ pass ',
107 activityBlock: '✕ block',
108 reload: '↻ Reload list',
109 reloaded: 'List reloaded.',
110 clearActivity: 'Clear activity',
111 footer: 'The list lives in ~/.claude/hands-off.json (kept when the mod updates). Only a person can press the buttons in this panel.',
112
113 // ── adding from the panel ─────────────────────────────────────────────────
114 noHome: "Hands Off: I don't know where your HOME is; edit the list by hand.",
115 nothingChanged: (error: string) => `Nothing changed: ${error}.`,
116 writeFailed: (why: string) => `Could not write the list (${why}).`,
117 notRepoInput: (input: string) => `"${input}" doesn't look like a repo (owner/repo or GitHub URL).`,
118 verified: ' (verified on GitHub)',
119 unverified: ' — could not confirm it on GitHub: check that the name is exact',
120}
121
122export type Words = typeof EN
123
124const ES: Words = {
125 brand: 'Intocables',
126 passCmd: '/intocables pase',
127 commandDescription:
128 'Que tu agente no toque lo que no es tuyo: leer sí, escribir no · /intocables abre el panel · /intocables estado · /intocables pase [owner/repo]',
129 argumentHint: '[estado | pase [owner/repo]]',
130
131 deny: (label, also, detail) =>
132 `Intocables: ${label} está protegido — leer sí, escribir no${also ? ` (también: ${also})` : ''}. ` +
133 'Si hay que cambiarlo, pídeselo a su dueño. Para una excepción puntual una persona debe teclear /intocables pase. ' +
134 `No lo rodees con otro comando. [bloqueado: ${detail}]`,
135 redirectTo: name => `redirección a ${name}`,
136 editOf: name => `edición de ${name}`,
137 ago: {
138 now: 'ahora',
139 min: n => `hace ${n} min`,
140 hours: n => `hace ${n} h`,
141 days: n => `hace ${n} d`,
142 },
143
144 invalidJson: 'el archivo no es JSON válido; arréglalo a mano antes de editar desde el panel',
145 badShape: 'el archivo tiene una forma inesperada; arréglalo a mano antes de editar desde el panel',
146 notRepo: input => `«${input}» no parece un repo (owner/repo)`,
147 notRailway: input => `«${input}» no es un nombre de proyecto Railway válido`,
148 notVercel: input => `«${input}» no es un nombre o id de proyecto Vercel válido`,
149 notNeon: input => `«${input}» no es un id de proyecto Neon válido (minúsculas, números y guiones, p. ej. proud-cloud-12345678)`,
150 notSupabase: input => `«${input}» no es un project ref de Supabase válido (20 letras minúsculas)`,
151 added: what => `${what} agregado`,
152 removed: what => `${what} quitado`,
153 already: what => `${what} ya está en la lista`,
154 absent: what => `${what} no está en la lista`,
155
156 configUnreadable: 'Intocables: ~/.claude/hands-off.json no se pudo leer; sigo protegiendo la última lista que leí completa. Arregla el archivo para cambiarla',
157 legacyUnreadable: 'Intocables: ~/.claude/intocables.json no se pudo leer, así que no se importó; empiezo con la lista vacía',
158 ruleFailed: why => `Intocables: la regla falló y dejé pasar la llamada (${why})`,
159 tookTooLong: 'el análisis tardó demasiado',
160 forkUnchecked: 'Intocables no pudo revisar en GitHub si este repo es fork de uno protegido (sin red o gh sin sesión); lo dejé pasar.',
161 passUsed: labels => `Intocables: pase usado en ${labels}`,
162 blockedToast: (detail, labels) => `Intocables bloqueó ${detail} en ${labels}. Para permitirlo una vez: /intocables pase`,
163
164 statusIntro: 'Intocables: leer sí, escribir no.',
165 statusNothing: 'Todavía no hay nada protegido. Agrega repos y proyectos en el panel (/intocables) o en ~/.claude/hands-off.json.',
166 statusRepos: (n, list) => `Repos protegidos (${n}): ${list}.`,
167 statusProjects: (kind, list) => `Proyectos ${kind}: ${list}.`,
168 statusPasses: list => `Pases activos (una vez, 10 min): ${list}.`,
169 statusMinutes: (label, min) => `${label} (${min} min)`,
170 statusNoPasses: 'Sin pases activos.',
171 statusFooter: 'Para una excepción: /intocables pase [owner/repo | neon:id | supabase:ref]. Lista editable en ~/.claude/hands-off.json o en el panel.',
172 onlyHuman: 'Intocables: los pases solo los da una persona, tecleándolos en el prompt.',
173 none: 'ninguno',
174 notProtected: (slug, all) => `Intocables: «${slug}» no está en la lista protegida (${all}).`,
175 passGranted: label => `Intocables: pase de una vez para ${label} (10 min). La próxima llamada bloqueada ahí pasa.`,
176 noRecentBlock: 'Intocables: no hay un bloqueo reciente (10 min). Para adelantar un pase: /intocables pase owner/repo.',
177 passGrantedLast: labels => `Intocables: pase de una vez para ${labels} (10 min). Reintenta la acción bloqueada.`,
178
179 tagline: 'leer sí, escribir no',
180 kind: { github: 'GitHub', railway: 'Railway', vercel: 'Vercel', neon: 'Neon', supabase: 'Supabase' },
181 protectedTag: 'protegido',
182 passTag: min => `pase · ${min} min`,
183 noBlocksYet: 'Sin bloqueos en esta sesión',
184 blockSummary: (n, ago, detail) => `${n} ${n === 1 ? 'bloqueo' : 'bloqueos'} · último ${ago}: ${detail}`,
185 revoke: 'Revocar pase',
186 onePass: 'Pase de una vez (10 min)',
187 less: 'Menos ▴',
188 manage: 'Gestionar ▾',
189 confirmRemove: label => `Sí, quitar ${label}`,
190 removeFromList: 'Quitar de la lista…',
191 removeWarning: 'Dejará de estar protegido: Claude podrá escribir ahí sin pase.',
192 summary: c =>
193 `${c.repos} repos · ${c.railway} Railway · ${c.vercel} Vercel · ${c.neon} Neon · ${c.supabase} Supabase · ` +
194 `${c.live} ${c.live === 1 ? 'pase activo' : 'pases activos'} · ${c.denied} ${c.denied === 1 ? 'bloqueo' : 'bloqueos'} en esta sesión`,
195 defaultListWarning: 'Usando una lista vacía (no se pudo leer ~/.claude/hands-off.json).',
196 recentBlock: (labels, ago) => `Bloqueo reciente en ${labels} (${ago})`,
197 givePass: 'Dar pase y reintentar',
198 passGivenNote: labels => `Pase dado para ${labels}. Pídele a Claude que reintente.`,
199 headingProtected: 'Protegidos',
200 emptyList: 'La lista está vacía: nada está protegido.',
201 headingAdd: 'Agregar',
202 inputRepo: 'Repo ',
203 inputRailway: 'Railway ',
204 inputVercel: 'Vercel ',
205 inputNeon: 'Neon ',
206 inputSupabase: 'Supabase ',
207 placeholderRepo: 'owner/repo o URL de GitHub',
208 placeholderRailway: 'nombre del proyecto',
209 placeholderVercel: 'nombre o id (prj_…) del proyecto',
210 placeholderNeon: 'id del proyecto (p. ej. proud-cloud-12345678)',
211 placeholderSupabase: 'project ref (20 letras)',
212 submit: 'agregar',
213 mobileNote: 'Para agregar, usa el panel en la terminal o el escritorio (o edita ~/.claude/hands-off.json).',
214 headingActivity: 'Actividad de esta sesión',
215 nothingBlocked: 'Nada bloqueado todavía.',
216 activityPass: '↷ pase ',
217 activityBlock: '✕ bloqueo',
218 reload: '↻ Releer lista',
219 reloaded: 'Lista releída.',
220 clearActivity: 'Limpiar actividad',
221 footer: 'La lista vive en ~/.claude/hands-off.json (no se pisa al actualizar el mod). Los botones de este panel solo los presiona una persona.',
222
223 noHome: 'Intocables: no sé dónde está tu HOME; edita la lista a mano.',
224 nothingChanged: error => `No se cambió nada: ${error}.`,
225 writeFailed: why => `No pude escribir la lista (${why}).`,
226 notRepoInput: input => `«${input}» no parece un repo (owner/repo o URL de GitHub).`,
227 verified: ' (verificado en GitHub)',
228 unverified: ' — no pude confirmarlo en GitHub: revisa que el nombre sea exacto',
229}
230
231export const words = (lang: Lang): Words => (lang === 'es' ? ES : EN)
232
233/** Claude Code's `language` setting, then LC_ALL/LANG; English otherwise. An explicit 'en' or 'es' first wins (tests). */
234export function pickLang(option: unknown, settingsLanguage: unknown, locale: string | null): Lang {
235 if (option === 'en' || option === 'es') return option
236 const s = typeof settingsLanguage === 'string' ? settingsLanguage.toLowerCase() : ''
237 if (s) return s.startsWith('es') || s.startsWith('span') || s.startsWith('espa') ? 'es' : 'en'
238 return (locale ?? '').toLowerCase().startsWith('es') ? 'es' : 'en'
239}
240hooks/rules.ts 1328 lines1import type { Words } from './i18n'
2
3/**
4 * Pure rules of Hands Off: no `$`, no side effects. The dependencies that touch the disk (a directory's origin,
5 * remotes, the linked Railway project) come in as functions, so everything is tested without git or network.
6 * Every user-facing string comes from the `Words` the caller passes in (see ./i18n.ts).
7 *
8 * Idea: a protected repo is recognised by its `origin` remote (never by the folder name). A Bash command is split with
9 * a shell lexer (quotes, heredocs, $(...), redirections) and each simple command is analysed by its argv: a slug that
10 * only appears inside a commit message or a heredoc is not an argument of any command, so it does not block. The origin
11 * is only looked up when the command has the shape of a write.
12 */
13
14// ── configuration ───────────────────────────────────────────────────────────
15
16export type Config = {
17 /** Protected GitHub slugs (owner/repo). */
18 repos: string[]
19 /** Protected Railway project names. */
20 railwayProjects: string[]
21 /** Protected Vercel projects: name or id (`prj_…`). */
22 vercelProjects: string[]
23 /** Protected Neon projects: id (e.g. `proud-cloud-12345678`). */
24 neonProjects: string[]
25 /** Protected Supabase projects: project ref (20 lowercase letters). */
26 supabaseProjects: string[]
27}
28
29/** Nothing is protected until the person adds it. */
30export const DEFAULT_CONFIG: Config = {
31 repos: [],
32 railwayProjects: [],
33 vercelProjects: [],
34 neonProjects: [],
35 supabaseProjects: [],
36}
37
38export const CONFIG_NOTE =
39 'Hands Off: repos and projects that are not yours (read yes, write no). A repo is matched by its origin remote, not by its folder name. ' +
40 'Add GitHub slugs (owner/repo), Railway and Vercel project names, Neon project ids and Supabase project refs to the lists. ' +
41 'This file is NOT overwritten when the mod updates.'
42
43export function defaultConfigText() {
44 return `${JSON.stringify({ _note: CONFIG_NOTE, ...DEFAULT_CONFIG }, null, 2)}\n`
45}
46
47const LIST_KEYS = ['repos', 'railwayProjects', 'vercelProjects', 'neonProjects', 'supabaseProjects'] as const
48/** Keys the mod owns in the config file: everything else is the person's and is kept as it is. */
49const OWN_KEYS = new Set<string>(['_note', '_nota', ...LIST_KEYS])
50
51const strings = (v: unknown): string[] | null =>
52 Array.isArray(v) && v.every(x => typeof x === 'string') ? v.map(x => (x as string).trim()).filter(Boolean) : null
53
54/**
55 * null = invalid JSON or the wrong shape (the caller falls back to the empty list and warns). A missing list is empty;
56 * keys it does not know (the note, an old key from a previous version, the person's own) are ignored.
57 */
58export function parseConfig(text: string): Config | null {
59 try {
60 const raw: unknown = JSON.parse(text)
61 if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return null
62 const o = raw as Record<string, unknown>
63 const list = (key: (typeof LIST_KEYS)[number]) => (o[key] === undefined ? [] : strings(o[key]))
64 const repos = list('repos')
65 const railwayProjects = list('railwayProjects')
66 const vercelProjects = list('vercelProjects')
67 const neonProjects = list('neonProjects')
68 const supabaseProjects = list('supabaseProjects')
69 if (!repos || !railwayProjects || !vercelProjects || !neonProjects || !supabaseProjects) return null
70 return { repos, railwayProjects, vercelProjects, neonProjects, supabaseProjects }
71 } catch {
72 return null
73 }
74}
75
76/**
77 * The text of a new config file built from the previous version's file (`~/.claude/intocables.json`): its lists carry
78 * over, its note becomes the new `_note`, and any other key the person had is kept. null = it cannot be read.
79 */
80export function migrateConfig(oldText: string): { text: string; config: Config } | null {
81 const config = parseConfig(oldText)
82 if (!config) return null
83 const old = JSON.parse(oldText) as Record<string, unknown>
84 const extras = Object.fromEntries(Object.entries(old).filter(([key]) => !OWN_KEYS.has(key)))
85 return { text: `${JSON.stringify({ _note: CONFIG_NOTE, ...config, ...extras }, null, 2)}\n`, config }
86}
87
88// ── slugs ───────────────────────────────────────────────────────────────────
89
90/** "owner/repo" of a remote (https, ssh, scp-like, with a token) or of a bare slug; null if there is none. */
91export function slugFromUrl(url: string): string | null {
92 const u = url.trim().replace(/\.git$/i, '').replace(/\/+$/, '')
93 const m = u.match(/[/:]([^/:\s@]+)\/([^/:\s]+)$/) ?? u.match(/^([^/:\s@]+)\/([^/:\s]+)$/)
94 return m ? `${m[1]}/${m[2]}` : null
95}
96
97/** The protected slug (spelled as in the config) this remote points to, or null. */
98export function matchSlug(url: string, config: Config): string | null {
99 const slug = slugFromUrl(url)?.toLowerCase()
100 return slug ? (config.repos.find(r => r.toLowerCase() === slug) ?? null) : null
101}
102
103// ── shell lexer ──────────────────────────────────────────────────────────
104
105export type Redirect = { op: string; target: string }
106/** A simple command. `subs` = nested commands ($(...), `...`, <(...), subshells), which run separately. */
107export type Simple = { argv: string[]; redirects: Redirect[]; subs: Simple[][] }
108
109const MAX_LEX_DEPTH = 6
110const MAX_CHARS = 100_000
111const SUB = '$(…)'
112
113function skipHeredoc(s: string, from: number, strip: boolean, delim: string) {
114 let i = from
115 while (i < s.length) {
116 const nl = s.indexOf('\n', i)
117 let line = s.slice(i, nl === -1 ? s.length : nl).replace(/\r$/, '')
118 if (strip) line = line.replace(/^\t+/, '')
119 i = nl === -1 ? s.length : nl + 1
120 if (line === delim) break
121 }
122 return i
123}
124
125function lexList(s: string, start: number, closer: ')' | '`' | null, out: Simple[], depth: number): number {
126 let i = start
127 let argv: string[] = []
128 let redirects: Redirect[] = []
129 let subs: Simple[][] = []
130 let buf = ''
131 let inWord = false
132 let redir: string | null = null
133 const heredocs: { delim: string; strip: boolean }[] = []
134
135 const sub = (from: number, close: ')' | '`') => {
136 if (depth >= MAX_LEX_DEPTH) return s.length
137 const inner: Simple[] = []
138 const next = lexList(s, from, close, inner, depth + 1)
139 subs.push(inner)
140 return next
141 }
142 const endWord = () => {
143 if (!inWord) return
144 const w = buf
145 buf = ''
146 inWord = false
147 if (redir === null) {
148 argv.push(w)
149 return
150 }
151 const op = redir
152 redir = null
153 if (op === '<<' || op === '<<-') heredocs.push({ delim: w, strip: op === '<<-' })
154 else if (op === '<' || op === '<<<' || op === '<&' || op === '<>') return
155 else if (op === '>&' && /^(\d+|-)$/.test(w)) return
156 else redirects.push({ op, target: w })
157 }
158 const endSimple = () => {
159 endWord()
160 redir = null
161 if (argv.length || redirects.length || subs.length) out.push({ argv, redirects, subs })
162 argv = []
163 redirects = []
164 subs = []
165 }
166
167 while (i < s.length) {
168 const c = s[i]!
169 if (c === '\n') {
170 endSimple()
171 i++
172 for (const h of heredocs) i = skipHeredoc(s, i, h.strip, h.delim)
173 heredocs.length = 0
174 continue
175 }
176 if (c === ' ' || c === '\t' || c === '\r') {
177 endWord()
178 i++
179 continue
180 }
181 if (c === '\\') {
182 if (s[i + 1] === '\n') {
183 i += 2
184 continue
185 }
186 inWord = true
187 buf += s[i + 1] ?? ''
188 i += 2
189 continue
190 }
191 if (c === "'") {
192 const j = s.indexOf("'", i + 1)
193 const end = j === -1 ? s.length : j
194 inWord = true
195 buf += s.slice(i + 1, end)
196 i = end + 1
197 continue
198 }
199 if (c === '"') {
200 inWord = true
201 i++
202 while (i < s.length && s[i] !== '"') {
203 const d = s[i]!
204 if (d === '\\') {
205 const n = s[i + 1] ?? ''
206 buf += '$"\\`'.includes(n) ? n : d + n
207 i += 2
208 } else if (d === '$' && s[i + 1] === '(') {
209 buf += SUB
210 i = sub(i + 2, ')')
211 } else if (d === '`') {
212 buf += SUB
213 i = sub(i + 1, '`')
214 } else {
215 buf += d
216 i++
217 }
218 }
219 i++
220 continue
221 }
222 if (c === '$' && s[i + 1] === '(') {
223 inWord = true
224 buf += SUB
225 i = sub(i + 2, ')')
226 continue
227 }
228 if (c === '`') {
229 if (closer === '`') {
230 endSimple()
231 return i + 1
232 }
233 inWord = true
234 buf += SUB
235 i = sub(i + 1, '`')
236 continue
237 }
238 if ((c === '<' || c === '>') && s[i + 1] === '(') {
239 inWord = true
240 buf += SUB
241 i = sub(i + 2, ')')
242 continue
243 }
244 if (c === '>' || c === '<' || (c === '&' && s[i + 1] === '>')) {
245 if (inWord && /^\d+$/.test(buf)) {
246 buf = ''
247 inWord = false
248 } else endWord()
249 let op: string
250 if (c === '&') {
251 i += 2
252 op = '&>'
253 if (s[i] === '>') {
254 op = '&>>'
255 i++
256 }
257 } else if (c === '>') {
258 i++
259 op = '>'
260 if (s[i] === '>') (op = '>>'), i++
261 else if (s[i] === '|') (op = '>|'), i++
262 else if (s[i] === '&') (op = '>&'), i++
263 } else {
264 i++
265 op = '<'
266 if (s[i] === '<') {
267 i++
268 op = '<<'
269 if (s[i] === '<') (op = '<<<'), i++
270 else if (s[i] === '-') (op = '<<-'), i++
271 } else if (s[i] === '&') (op = '<&'), i++
272 else if (s[i] === '>') (op = '<>'), i++
273 }
274 redir = op
275 continue
276 }
277 if (c === ';' || c === '|' || c === '&') {
278 endSimple()
279 i++
280 if ((c === '&' || c === '|') && s[i] === c) i++
281 else if (c === '|' && s[i] === '&') i++
282 else if (c === ';' && s[i] === ';') i++
283 continue
284 }
285 if (c === '(' && !inWord) {
286 endSimple()
287 i = sub(i + 1, ')')
288 const group = subs.pop()
289 if (group) out.push({ argv: [], redirects: [], subs: [group] })
290 continue
291 }
292 if (c === ')' && (closer === ')' || !inWord)) {
293 endSimple()
294 i++
295 if (closer === ')') return i
296 continue
297 }
298 if (c === '#' && !inWord) {
299 const nl = s.indexOf('\n', i)
300 i = nl === -1 ? s.length : nl
301 continue
302 }
303 inWord = true
304 buf += c
305 i++
306 }
307 endSimple()
308 return s.length
309}
310
311export function lex(src: string): Simple[] {
312 const out: Simple[] = []
313 lexList(src.slice(0, MAX_CHARS), 0, null, out, 0)
314 return out
315}
316
317// ── commands: wrappers (sudo, env, npx…), shells (-c) and arguments ─────────
318
319export const basename = (p: string) => p.replace(/\/+$/, '').split('/').pop() ?? p
320export const dirname = (p: string) => p.replace(/\/+$/, '').replace(/\/[^/]*$/, '') || '/'
321
322const ENV_ASSIGN = /^[A-Za-z_][A-Za-z0-9_]*=/
323const KEYWORDS = new Set(['if', 'then', 'else', 'elif', 'fi', 'do', 'done', 'while', 'until', '{', '}', '!', 'time', 'coproc'])
324const SHELLS = new Set(['bash', 'sh', 'zsh', 'dash', 'ksh'])
325const SKIP_ONE = new Set(['sudo', 'doas', 'command', 'builtin', 'exec', 'nohup', 'time', 'setsid', 'nice', 'ionice', 'caffeinate', 'timeout', 'stdbuf', 'unbuffer', 'env'])
326const WRAP_VALUE: Record<string, string[]> = {
327 sudo: ['-u', '-g', '-h', '-p', '-C', '-D', '-R', '-T', '-U'],
328 doas: ['-u', '-C'],
329 nice: ['-n'],
330 ionice: ['-c', '-n', '-p'],
331 timeout: ['-s', '-k', '--signal', '--kill-after'],
332 stdbuf: ['-i', '-o', '-e'],
333 caffeinate: ['-t', '-w'],
334 env: ['-u', '-C', '-S'],
335}
336const RUNNERS = new Set(['npx', 'bunx', 'pnpx'])
337const PKG = new Set(['npm', 'pnpm', 'yarn', 'bun'])
338
339/** `neonctl@latest` / `@scope/pkg@1.2` → the package without the version (what `npx` and `dlx` accept). */
340const unversioned = (pkg: string) => pkg.replace(/(?<=.)@[^/@]*$/, '')
341
342export type Cmd = { name: string; args: string[]; env: Record<string, string> }
343export type Resolved = { cmd: Cmd } | { inner: string } | null
344
345/** Strips assignments, keywords and wrappers; recognises `bash -c "…"` and `eval`. */
346export function resolveCommand(argv: string[]): Resolved {
347 const env: Record<string, string> = {}
348 let a = [...argv]
349 for (let guard = 0; guard < 24; guard++) {
350 const w = a[0]
351 if (w === undefined) return null
352 if (KEYWORDS.has(w)) {
353 a.shift()
354 continue
355 }
356 if (ENV_ASSIGN.test(w)) {
357 const eq = w.indexOf('=')
358 env[w.slice(0, eq)] = w.slice(eq + 1)
359 a.shift()
360 continue
361 }
362 const base = basename(w)
363 if (SKIP_ONE.has(base)) {
364 a.shift()
365 const values = WRAP_VALUE[base] ?? []
366 while (a[0] !== undefined) {
367 const x = a[0]
368 if (base === 'env' && ENV_ASSIGN.test(x)) {
369 env[x.slice(0, x.indexOf('='))] = x.slice(x.indexOf('=') + 1)
370 a.shift()
371 } else if (x.startsWith('-') && x.length > 1) {
372 a.shift()
373 if (values.includes(x)) a.shift()
374 } else break
375 }
376 if (base === 'timeout' && a[0] !== undefined && /^\d/.test(a[0])) a.shift()
377 continue
378 }
379 if (RUNNERS.has(base)) {
380 a.shift()
381 while (a[0] !== undefined && a[0].startsWith('-')) {
382 const f = a.shift()!
383 if (f === '-p' || f === '--package') a.shift()
384 }
385 if (a[0] !== undefined) a[0] = unversioned(a[0])
386 continue
387 }
388 if (PKG.has(base) && a[1] !== undefined && ['dlx', 'exec', 'x', 'vercel', 'changeset', 'neonctl', 'neon', 'supabase'].includes(a[1])) {
389 a.shift()
390 if (a[0] === 'dlx' || a[0] === 'exec' || a[0] === 'x') a.shift()
391 while (a[0] !== undefined && a[0].startsWith('-')) a.shift()
392 if (a[0] !== undefined) a[0] = unversioned(a[0])
393 continue
394 }
395 if (SHELLS.has(base)) {
396 const k = a.findIndex((x, n) => n > 0 && /^-[A-Za-z]*c[A-Za-z]*$/.test(x))
397 const script = k === -1 ? undefined : a[k + 1]
398 return script === undefined ? { cmd: { name: base, args: a.slice(1), env } } : { inner: script }
399 }
400 if (base === 'eval') return { inner: a.slice(1).join(' ') }
401 return { cmd: { name: base, args: a.slice(1), env } }
402 }
403 return null
404}
405
406export type Parsed = {
407 pos: string[]
408 flags: Map<string, string | true>
409 has: (...names: string[]) => boolean
410 /** There is a grouped short flag (-rf, -ni) with this letter. */
411 short: (letter: string) => boolean
412 value: (...names: string[]) => string | undefined
413}
414
415/** Splits positionals and flags; `valueFlags` are the ones that take the next argument. */
416export function parseArgs(args: string[], valueFlags: readonly string[] = []): Parsed {
417 const values = new Set(valueFlags)
418 const pos: string[] = []
419 const flags = new Map<string, string | true>()
420 for (let k = 0; k < args.length; k++) {
421 const a = args[k]!
422 if (a === '--') {
423 pos.push(...args.slice(k + 1))
424 flags.set('--', true)
425 break
426 }
427 if (a.startsWith('--')) {
428 const eq = a.indexOf('=')
429 if (eq > 0) flags.set(a.slice(0, eq), a.slice(eq + 1))
430 else if (values.has(a)) flags.set(a, args[++k] ?? '')
431 else flags.set(a, true)
432 continue
433 }
434 if (a.length > 1 && a.startsWith('-')) {
435 const f = a.slice(0, 2)
436 if (values.has(f)) flags.set(f, a.length > 2 ? a.slice(2).replace(/^=/, '') : (args[++k] ?? ''))
437 else flags.set(a, true)
438 continue
439 }
440 pos.push(a)
441 }
442 return {
443 pos,
444 flags,
445 has: (...names) => names.some(n => flags.has(n)),
446 short: letter => [...flags.keys()].some(k => /^-[^-]/.test(k) && k.slice(1).includes(letter)),
447 value: (...names) => {
448 for (const n of names) {
449 const v = flags.get(n)
450 if (typeof v === 'string') return v
451 }
452 return undefined
453 },
454 }
455}
456
457// ── rutas ───────────────────────────────────────────────────────────────────
458
459export function normalizePath(p: string) {
460 const out: string[] = []
461 for (const part of p.split('/')) {
462 if (part === '' || part === '.') continue
463 if (part === '..') out.pop()
464 else out.push(part)
465 }
466 return `/${out.join('/')}`
467}
468
469/** Absolute path of a shell argument, or null if it depends on something unknowable ($VAR, $(...)). */
470export function resolvePath(p: string, dir: string | null, home: string): string | null {
471 const q = p.replace(/^~(?=\/|$)/, home).replace(/\$\{HOME\}|\$HOME(?![A-Za-z0-9_])/g, home)
472 if (q.includes('$') || q.includes('`')) return null
473 if (q.startsWith('/')) return normalizePath(q)
474 return dir === null ? null : normalizePath(`${dir}/${q}`)
475}
476
477// ── Bash analysis ────────────────────────────────────────────────────────
478
479export type Hit = { key: string; label: string; detail: string }
480
481/** Notices the rules can raise through `Deps.warn`. */
482export type Warning = 'forkUnchecked'
483
484export type Deps = {
485 cwd: string
486 home: string
487 config: Config
488 words: Words
489 /** URL of the origin remote of the repo that contains `path` (nearest existing ancestor); '' if there is no repo. */
490 originOf: (path: string) => Promise<string>
491 /** URL of the remote `name` of the repo of `dir`; ''. */
492 remoteUrl: (dir: string, name: string) => Promise<string>
493 /** URLs of ALL the remotes of the repo that contains `dir` (local, no network); [] if there is no repo or on any error. */
494 remotesOf: (dir: string) => Promise<string[]>
495 /**
496 * The repo the GitHub repo `slug` (owner/repo) was forked from, asked to GitHub (network, cached by the caller):
497 * its `owner/repo`; `null` = GitHub says it is not a fork; `undefined` = it could not be checked (offline, no gh, no login).
498 */
499 parentOf: (slug: string) => Promise<string | null | undefined>
500 /** Raises a notice the caller shows to the person (once per session); the rules stay pure. */
501 warn: (key: Warning) => void | Promise<void>
502 /** Name of the Railway project linked to `dir`; ''. */
503 railwayLinked: (dir: string) => Promise<string>
504 /** Vercel project linked to `dir` (.vercel/project.json); empty strings if none. */
505 vercelLinked: (dir: string) => Promise<{ name: string; id: string }>
506 /**
507 * Id of the Neon project the context file points to (`.neon`, JSON, field `projectId`); ''.
508 * Without `file`: the nearest `.neon` going up from `dir` (up to 40 levels). With `file` (`--context-file`,
509 * ya resuelta): solo ese archivo.
510 */
511 neonContext: (dir: string, file?: string) => Promise<string>
512 /**
513 * Linked Supabase project ref (`supabase/.temp/project-ref`, trimmed); ''.
514 * Without `exact`: the nearest one going up from `dir` (up to 40 levels). With `exact` (`--workdir`): only in `dir`.
515 */
516 supabaseLinked: (dir: string, exact?: boolean) => Promise<string>
517}
518
519const MAX_ORIGIN_LOOKUPS = 24
520const MAX_DEPTH = 5
521
522const FS_OPS = new Set(['rm', 'rmdir', 'unlink', 'shred', 'trash', 'touch', 'mkdir', 'truncate', 'tee', 'chmod', 'chown', 'chgrp', 'mv', 'cp', 'install', 'rsync', 'ln', 'dd', 'find', 'sed', 'perl'])
523const FIND_WRITERS = new Set(['rm', 'mv', 'cp', 'chmod', 'chown', 'tee', 'touch', 'truncate', 'unlink', 'rmdir', 'shred'])
524const OUT_OPS = new Set(['>', '>>', '>|', '&>', '&>>', '>&'])
525
526const GH_VALUE = ['-R', '--repo', '-X', '--method', '-H', '--header', '-f', '-F', '--field', '--raw-field', '--input', '-q', '--jq', '-t', '--template', '--hostname', '--cache', '-b', '--body', '-B', '--base', '--head', '--title', '-a', '--assignee', '-l', '--label', '-m', '--milestone', '-p', '--project', '-r', '--reviewer', '--body-file', '-F', '--notes', '--notes-file', '--target']
527const GH_WRITE: Record<string, readonly string[]> = {
528 pr: ['create', 'merge', 'edit', 'close', 'comment', 'review', 'ready', 'reopen', 'update-branch', 'lock', 'unlock'],
529 issue: ['create', 'edit', 'close', 'comment', 'reopen', 'delete', 'transfer', 'pin', 'unpin', 'lock', 'unlock', 'develop'],
530 release: ['create', 'edit', 'delete', 'upload', 'delete-asset'],
531 repo: ['edit', 'delete', 'archive', 'unarchive', 'rename', 'sync', 'deploy-key'],
532 workflow: ['disable', 'enable', 'run'],
533 run: ['rerun', 'cancel', 'delete'],
534 label: ['create', 'edit', 'delete', 'clone'],
535 secret: ['set', 'delete', 'remove'],
536 variable: ['set', 'delete'],
537 cache: ['delete'],
538}
539
540const VERCEL_VALUE = ['--cwd', '-t', '--token', '-S', '--scope', '-A', '--local-config', '-Q', '--global-config', '-e', '--env', '-b', '--build-env', '-m', '--meta', '--target', '--regions', '-n', '--name', '--org', '--project']
541const VERCEL_READ = new Set(['ls', 'list', 'inspect', 'logs', 'whoami', 'help', 'login', 'logout', 'teams', 'switch', 'bisect', 'curl', 'httpstat', 'telemetry', 'dev'])
542const VERCEL_READ_SUB = new Set(['ls', 'list', 'inspect', 'get', 'view'])
543
544const RAILWAY_VALUE = ['-s', '--service', '-e', '--environment', '-p', '--project', '-t', '--team', '-w', '--workspace', '--set', '--set-from-stdin']
545const RAILWAY_TOP_WRITE = new Set(['up', 'redeploy', 'down', 'deploy', 'delete', 'rm', 'remove', 'init', 'new', 'add', 'link', 'unlink', 'restart', 'scale', 'rollback'])
546const RAILWAY_GROUPS = new Set(['environment', 'env', 'service', 'domain', 'deployment', 'deployments', 'project', 'projects', 'volume', 'bucket', 'buckets', 'cdn', 'config', 'postgres', 'mysql', 'private-network', 'outbound-network', 'flag', 'flags'])
547const RAILWAY_READ_SUB = new Set(['list', 'ls', 'status', 'logs', 'show', 'get', 'info', 'view', 'inspect', 'help'])
548
549const NEON_VALUE = ['--project-id', '--context-file', '--org-id', '--api-key', '-o', '--output', '--config-dir', '--branch', '--parent', '--name', '-n', '--cu', '--region-id', '--role', '--role-name', '--database', '--database-name', '--owner-name', '--type', '--endpoint', '--suspend-timeout', '--compute-unit', '--vpc-endpoint-id', '--vpc-project-id']
550const NEON_READ_SUB: Record<string, ReadonlySet<string>> = {
551 projects: new Set(['list', 'get', 'help']),
552 project: new Set(['list', 'get', 'help']),
553 branches: new Set(['list', 'get', 'schema-diff', 'help']),
554 branch: new Set(['list', 'get', 'schema-diff', 'help']),
555 databases: new Set(['list', 'get', 'help']),
556 database: new Set(['list', 'get', 'help']),
557 roles: new Set(['list', 'help']),
558 role: new Set(['list', 'help']),
559 'ip-allow': new Set(['list', 'help']),
560}
561
562const SUPABASE_VALUE = ['--workdir', '--project-ref', '--db-url', '--password', '-p', '--network-id', '--dns-resolver', '--profile', '-o', '--output', '--schema', '-s', '--file', '-f', '--org-id', '--name', '--region', '--size', '--import-map', '--env-file', '--status']
563const SUPABASE_WRITE_SUB: Record<string, readonly string[]> = {
564 functions: ['deploy', 'delete'],
565 secrets: ['set', 'unset'],
566 projects: ['delete', 'create'],
567 config: ['push'],
568 branches: ['create', 'delete', 'update', 'disable'],
569 'postgres-config': ['update', 'delete'],
570 domains: ['create', 'activate', 'delete', 'reverify'],
571 'vanity-subdomains': ['activate', 'delete'],
572 'network-restrictions': ['update'],
573 'ssl-enforcement': ['update'],
574 sso: ['add', 'update', 'remove'],
575 backups: ['restore'],
576}
577
578const NPM_VALUE = ['-C', '--dir', '--prefix', '--cwd', '-w', '--workspace', '--filter', '-F', '--tag', '--registry', '--otp', '--access']
579
580/** Does `git <sub> …` write to the repo? (conservative: when in doubt about a read, it does not block). */
581export function gitWrites(sub: string, rest: string[]): boolean {
582 const p = parseArgs(rest, ['-m', '-F', '-u', '--set-upstream-to', '--contains', '--points-at', '--merged', '--no-merged', '--sort', '--format'])
583 const flagNames = [...p.flags.keys()].map(k => k.split('=')[0]!)
584 const any = (...f: string[]) => f.some(x => flagNames.includes(x))
585 switch (sub) {
586 case 'commit': case 'rm': case 'mv': case 'reset': case 'rebase': case 'merge': case 'cherry-pick': case 'revert': case 'am':
587 case 'restore': case 'update-ref': case 'filter-branch': case 'fast-import': case 'commit-tree': case 'replace':
588 return true
589 case 'add':
590 return !any('-n', '--dry-run')
591 case 'push':
592 return !any('-n', '--dry-run')
593 case 'clean':
594 return !(any('-n', '--dry-run') || p.short('n'))
595 case 'apply':
596 return !any('--check', '--stat', '--numstat', '--summary')
597 case 'tag': {
598 if (any('-l', '--list', '-v', '--verify', '--contains', '--points-at', '--merged', '--no-merged')) return false
599 if (any('-d', '--delete', '-a', '--annotate', '-s', '--sign', '-f', '--force', '-m', '-F', '-u')) return true
600 return p.pos.length > 0
601 }
602 case 'branch': {
603 if (any('-d', '-D', '--delete', '-m', '-M', '--move', '-c', '-C', '--copy', '-u', '--set-upstream-to', '--set-upstream', '--unset-upstream', '-f', '--force', '--edit-description', '-t', '--track')) return true
604 if (any('-l', '--list', '-a', '--all', '-r', '--remotes', '-v', '-vv', '--verbose', '--show-current', '--contains', '--no-contains', '--merged', '--no-merged', '--points-at', '--sort', '--format', '--column')) return false
605 return p.pos.length > 0
606 }
607 case 'checkout':
608 return any('-b', '-B', '--orphan', '--') || p.pos.includes('.') || rest.some(a => /^-[bB]\S/.test(a))
609 case 'switch':
610 return any('-c', '-C', '--create', '--force-create', '--orphan') || rest.some(a => /^-[cC]\S/.test(a) && !a.startsWith('--'))
611 case 'stash':
612 return !['list', 'show'].includes(p.pos[0] ?? '')
613 case 'remote':
614 return ['add', 'set-url', 'set-head', 'set-branches', 'remove', 'rm', 'rename', 'prune'].includes(p.pos[0] ?? '')
615 case 'config': {
616 if (any('--get', '--get-all', '--get-regexp', '--get-urlmatch', '--list', '-l', '--show-origin', '--show-scope', '--global', '--system')) return false
617 return p.pos.length >= 2 || any('--unset', '--unset-all', '--add', '--replace-all', '--edit', '-e', '--rename-section', '--remove-section')
618 }
619 case 'worktree':
620 return ['add', 'remove', 'move', 'prune', 'lock', 'unlock', 'repair'].includes(p.pos[0] ?? '')
621 case 'submodule':
622 return ['add', 'update', 'init', 'deinit', 'sync', 'set-url', 'set-branch', 'absorbgitdirs'].includes(p.pos[0] ?? '')
623 case 'notes':
624 return ['add', 'append', 'copy', 'edit', 'merge', 'remove', 'prune'].includes(p.pos[0] ?? '')
625 case 'reflog':
626 return ['expire', 'delete'].includes(p.pos[0] ?? '')
627 default:
628 return false
629 }
630}
631
632function vercelWrites(p: Parsed): boolean {
633 if (p.has('--version', '-v', '--help', '-h')) return false
634 const first = p.pos[0]
635 if (first === undefined) return true
636 if (/^[./~]/.test(first)) return true
637 if (VERCEL_READ.has(first)) return false
638 return !VERCEL_READ_SUB.has(p.pos[1] ?? '')
639}
640
641function railwayWrites(p: Parsed): boolean {
642 const top = p.pos[0]
643 const sub = p.pos[1]
644 if (top === undefined || p.has('--help', '-h', '--version', '-V')) return false
645 if (RAILWAY_TOP_WRITE.has(top)) return true
646 if (['variable', 'variables', 'vars', 'var'].includes(top)) {
647 return p.has('--set', '--set-from-stdin') || ['set', 'delete', 'rm', 'remove', 'edit'].includes(sub ?? '')
648 }
649 if (RAILWAY_GROUPS.has(top)) return sub === undefined ? ['service', 'environment', 'env', 'domain'].includes(top) : !RAILWAY_READ_SUB.has(sub)
650 if (top === 'api') return [...p.pos.slice(1), ...[...p.flags.values()].filter((v): v is string => typeof v === 'string')].some(x => /\bmutation\b/.test(x))
651 return false
652}
653
654/** Does `neon <group> <sub>` change anything? (`nx` is a common wrapper for the Neon CLI). An unknown subcommand of a write group: yes. */
655function neonWrites(p: Parsed): boolean {
656 if (p.has('--help', '-h', '--version')) return false
657 const [group, sub] = p.pos
658 if (group === undefined) return false
659 if (group === 'set-context') return true
660 if (group === 'vpc') return sub !== undefined && !p.pos.slice(1).some(x => x === 'list' || x === 'status')
661 const reads = NEON_READ_SUB[group]
662 return reads !== undefined && sub !== undefined && !reads.has(sub)
663}
664
665/** Does `supabase …` change the remote project? `db reset` only if it points at one (`--linked` / `--db-url`). */
666function supabaseWrites(p: Parsed): boolean {
667 if (p.has('--help', '-h')) return false
668 const [top, sub] = p.pos
669 if (top === undefined) return false
670 // Without --local (or with --linked / --db-url) the target is the remote project.
671 const remote = !p.has('--local') || p.has('--linked', '--db-url')
672 switch (top) {
673 case 'link': case 'unlink':
674 return true
675 case 'db':
676 if (sub === 'push') return remote && !p.has('--dry-run')
677 return sub === 'reset' && p.has('--linked', '--db-url')
678 case 'migration':
679 if (sub === 'repair') return remote
680 return sub === 'up' && p.has('--linked', '--db-url')
681 case 'storage': {
682 if (sub === 'rm') return remote
683 if (sub === 'cp' || sub === 'mv') return remote && /^ss:\/\//.test(p.pos.length >= 4 ? p.pos.at(-1)! : '')
684 return false
685 }
686 default:
687 return SUPABASE_WRITE_SUB[top]?.includes(sub ?? '') ?? false
688 }
689}
690
691export class Scan {
692 hits: Hit[] = []
693 private lookups = 0
694 private cache = new Map<string, string | null>()
695 private forks = new Map<string, string | null>()
696
697 constructor(private deps: Deps) {}
698
699 private get config() {
700 return this.deps.config
701 }
702
703 private hit(slug: string, detail: string) {
704 const key = slug.toLowerCase()
705 if (!this.hits.some(h => h.key === key)) this.hits.push({ key, label: slug, detail })
706 }
707
708 /** The protected slug of the repo that contains `path`, or null (also once the lookup cap is exceeded). */
709 async slugAt(path: string | null): Promise<string | null> {
710 if (path === null) return null
711 if (this.cache.has(path)) return this.cache.get(path) ?? null
712 if (++this.lookups > MAX_ORIGIN_LOOKUPS) return null
713 const url = await this.deps.originOf(path).catch(() => '')
714 const slug = url ? matchSlug(url, this.config) : null
715 this.cache.set(path, slug)
716 return slug
717 }
718
719 /**
720 * The protected repo a `gh` WRITE reaches from `dir` when the command names no repo. `gh` aims at the PARENT of a fork,
721 * not at its `origin`, so besides the origin it looks at (1) every remote of the repo (local) and (2) only if those
722 * find nothing, at the repo `origin` was forked from, asked to GitHub (skipped when nothing is protected).
723 */
724 private async ghTarget(dir: string | null): Promise<string | null> {
725 const origin = await this.slugAt(dir)
726 if (origin || dir === null) return origin
727 if (this.forks.has(dir)) return this.forks.get(dir) ?? null
728 const slug = await this.forkTarget(dir)
729 this.forks.set(dir, slug)
730 return slug
731 }
732
733 private async forkTarget(dir: string): Promise<string | null> {
734 if (++this.lookups > MAX_ORIGIN_LOOKUPS) return null
735 for (const url of await this.deps.remotesOf(dir).catch(() => [] as string[])) {
736 const slug = matchSlug(url, this.config)
737 if (slug) return slug
738 }
739 if (this.config.repos.length === 0) return null
740 const origin = await this.deps.originOf(dir).catch(() => '')
741 const own = /github\.com[/:]/i.test(origin) ? slugFromUrl(origin) : null
742 if (!own) return null
743 const parent = await this.deps.parentOf(own).catch(() => undefined)
744 if (parent === undefined) {
745 await Promise.resolve(this.deps.warn('forkUnchecked')).catch(() => undefined)
746 return null
747 }
748 return parent ? matchSlug(parent, this.config) : null
749 }
750
751 private async hitPath(path: string | null, detail: string) {
752 const slug = await this.slugAt(path)
753 if (slug) this.hit(slug, detail)
754 }
755
756 async seq(list: Simple[], start: string | null, depth: number): Promise<void> {
757 let dir = start
758 for (const sp of list) {
759 for (const inner of sp.subs) if (depth < MAX_DEPTH) await this.seq(inner, dir, depth + 1)
760 for (const r of sp.redirects) {
761 if (!OUT_OPS.has(r.op)) continue
762 const target = resolvePath(r.target, dir, this.deps.home)
763 if (target !== null && !target.startsWith('/dev/')) await this.hitPath(target, this.deps.words.redirectTo(basename(target)))
764 }
765 const res = resolveCommand(sp.argv)
766 if (res === null) continue
767 if ('inner' in res) {
768 if (depth < MAX_DEPTH) await this.seq(lex(res.inner), dir, depth + 1)
769 continue
770 }
771 dir = await this.run(res.cmd, dir)
772 }
773 }
774
775 private async run(cmd: Cmd, dir: string | null): Promise<string | null> {
776 const { name, args } = cmd
777 switch (name) {
778 case 'cd': case 'pushd': case 'chdir': {
779 const target = args.find(a => !a.startsWith('-') || a === '-')
780 if (target === undefined) return this.deps.home
781 // A destination that cannot be known ($VAR, $(...), cd -) leaves the directory where it was: the safe thing is
782 // to keep treating the command as if it ran where it already was.
783 return resolvePath(target, dir, this.deps.home) ?? dir
784 }
785 case 'popd':
786 return dir
787 case 'git':
788 await this.git(args, dir)
789 break
790 case 'gh':
791 await this.gh(args, dir, cmd.env)
792 break
793 case 'vercel':
794 await this.vercel(args, dir)
795 break
796 case 'railway':
797 await this.railway(args, dir)
798 break
799 case 'neon': case 'neonctl': case 'nx':
800 await this.neon(args, dir)
801 break
802 case 'supabase':
803 await this.supabase(args, dir)
804 break
805 case 'npm': case 'pnpm': case 'yarn': case 'bun': case 'changeset':
806 await this.publish(name, args, dir)
807 break
808 default:
809 if (FS_OPS.has(name)) await this.files(name, args, dir)
810 }
811 return dir
812 }
813
814 // ── git ───────────────────────────────────────────────────────────────────
815
816 private async git(args: string[], start: string | null) {
817 let dir = start
818 let i = 0
819 for (; i < args.length; i++) {
820 const a = args[i]!
821 if (!a.startsWith('-')) break
822 if (a === '-C') dir = resolvePath(args[++i] ?? '', dir, this.deps.home) ?? dir
823 else if (a === '-c' || a === '--namespace' || a === '--exec-path' || a === '--super-prefix' || a === '--config-env') i++
824 else if (a === '--work-tree' || a === '--git-dir') dir = resolvePath(args[++i] ?? '', dir, this.deps.home) ?? dir
825 else if (a.startsWith('--work-tree=') || a.startsWith('--git-dir=')) dir = resolvePath(a.slice(a.indexOf('=') + 1), dir, this.deps.home) ?? dir
826 }
827 const sub = args[i]
828 if (sub === undefined || dir === null) return
829 const rest = args.slice(i + 1)
830 if (!gitWrites(sub, rest)) return
831 await this.hitPath(dir, `git ${sub}`)
832 if (sub !== 'push') return
833 const p = parseArgs(rest, ['-o', '--push-option', '--receive-pack', '--exec', '--repo'])
834 for (const target of [p.value('--repo'), p.pos[0]]) {
835 if (!target) continue
836 const url = /[/:]/.test(target) ? target : await this.deps.remoteUrl(dir, target).catch(() => '')
837 const slug = url ? matchSlug(url, this.config) : null
838 if (slug) this.hit(slug, 'git push')
839 }
840 }
841
842 // ── gh ────────────────────────────────────────────────────────────────────
843
844 private async gh(args: string[], dir: string | null, env: Record<string, string>) {
845 const p = parseArgs(args, GH_VALUE)
846 const [group, sub] = p.pos
847 if (group === undefined) return
848 const repoFlag = p.value('-R', '--repo') ?? env.GH_REPO
849 // `gh api` ignores -R: a repo flag or GH_REPO there keeps the origin-only check it always had.
850 const current = async (named = false) => (repoFlag ? matchSlug(repoFlag, this.config) : named ? this.slugAt(dir) : this.ghTarget(dir))
851
852 if (group === 'api') {
853 const endpoint = p.pos[1] ?? ''
854 const hasBody = p.has('-f', '-F', '--field', '--raw-field', '--input')
855 const method = (p.value('-X', '--method') ?? (hasBody ? 'POST' : 'GET')).toUpperCase()
856 if (!['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) return
857 if (endpoint === 'graphql') {
858 if (!args.some(a => /\bmutation\b/.test(a))) return
859 const mentioned = this.config.repos.find(r => args.some(a => a.toLowerCase().includes(r.toLowerCase())))
860 // gh fills the repo of a mutation from -R / GH_REPO when given, else from the folder (and its fork parent).
861 const slug = mentioned ?? (repoFlag ? matchSlug(repoFlag, this.config) : await this.ghTarget(dir))
862 if (slug) this.hit(slug, 'gh api graphql (mutation)')
863 return
864 }
865 const m = endpoint.match(/^\/?repos\/([^/]+)\/([^/?#]+)/i)
866 if (!m) return
867 // `{owner}/{repo}` is filled by gh from -R / GH_REPO when given, else from the folder (and its fork parent).
868 const slug = m[1]!.includes('{') ? (repoFlag ? matchSlug(repoFlag, this.config) : await this.ghTarget(dir)) : matchSlug(`${m[1]}/${m[2]}`, this.config)
869 if (slug) this.hit(slug, `gh api -X ${method}`)
870 return
871 }
872
873 // A PR/issue by URL, or `gh repo edit owner/repo`, names the repo without -R.
874 const rest = p.pos.slice(2)
875 const named = rest.map(x => {
876 const url = x.match(/github\.com[/:]([^/\s]+)\/([^/\s#?]+)/i)
877 return url ? matchSlug(`${url[1]}/${url[2]}`, this.config) : group === 'repo' ? matchSlug(x, this.config) : null
878 })
879 const writes = GH_WRITE[group]
880 if (!writes || sub === undefined || !writes.includes(sub)) return
881 // A repo named by URL or position decides as before (the fork layers apply only when nothing names the repo).
882 const mentionsRepo = rest.some(x => /github\.com[/:]/i.test(x) || group === 'repo')
883 const slug = named.find(Boolean) ?? (await current(mentionsRepo))
884 if (slug) this.hit(slug, `gh ${group} ${sub}`)
885 }
886
887 // ── vercel, railway, publish ──────────────────────────────────────────────
888
889 private async vercel(args: string[], dir: string | null) {
890 const p = parseArgs(args, VERCEL_VALUE)
891 if (!vercelWrites(p)) return
892 let at = dir
893 const cwdFlag = p.value('--cwd')
894 if (cwdFlag) at = resolvePath(cwdFlag, dir, this.deps.home) ?? dir
895 const first = p.pos[0]
896 if (first && /^[./~]/.test(first)) at = resolvePath(first, at, this.deps.home) ?? at
897 const sub = first && !/^[./~]/.test(first) ? first : 'deploy'
898 const protectedEntry = (n: string) => (n ? this.config.vercelProjects.find(x => x.toLowerCase() === n.toLowerCase()) : undefined)
899 const named = [p.value('--project'), first === 'link' ? p.pos[1] : undefined].filter((x): x is string => !!x)
900 for (const n of named) {
901 const hit = protectedEntry(n)
902 if (hit) this.hitVercel(hit, sub)
903 }
904 if (at !== null && this.config.vercelProjects.length > 0) {
905 const linked = await this.deps.vercelLinked(at).catch(() => ({ name: '', id: '' }))
906 const hit = protectedEntry(linked.name) ?? protectedEntry(linked.id)
907 if (hit) this.hitVercel(hit, sub)
908 }
909 await this.hitPath(at, `vercel ${sub}`)
910 }
911
912 private hitVercel(name: string, sub: string) {
913 const key = `vercel:${name.toLowerCase()}`
914 if (!this.hits.some(h => h.key === key)) this.hits.push({ key, label: `Vercel ${name}`, detail: `vercel ${sub}` })
915 }
916
917 private async railway(args: string[], dir: string | null) {
918 const p = parseArgs(args, RAILWAY_VALUE)
919 if (!railwayWrites(p)) return
920 const sub = p.pos[0]!
921 const protectedName = (n: string) => this.config.railwayProjects.find(x => x.toLowerCase() === n.toLowerCase())
922 const named = [p.value('-p', '--project'), sub === 'link' ? p.pos[1] : undefined].filter((x): x is string => !!x)
923 for (const n of named) {
924 const hit = protectedName(n)
925 if (hit) this.hitRailway(hit, sub)
926 }
927 if (named.length > 0 || sub === 'link' || sub === 'unlink') return
928 if (dir !== null) {
929 const linked = protectedName(await this.deps.railwayLinked(dir).catch(() => ''))
930 if (linked) this.hitRailway(linked, sub)
931 await this.hitPath(dir, `railway ${sub}`)
932 }
933 }
934
935 private hitRailway(name: string, sub: string) {
936 const key = `railway:${name.toLowerCase()}`
937 if (!this.hits.some(h => h.key === key)) this.hits.push({ key, label: `Railway ${name}`, detail: `railway ${sub}` })
938 }
939
940 private async neon(args: string[], dir: string | null) {
941 const p = parseArgs(args, NEON_VALUE)
942 if (!neonWrites(p)) return
943 const sub = `neon ${p.pos.slice(0, 2).join(' ')}`
944 const list = this.config.neonProjects
945 const protectedId = (id: string) => (id ? list.find(x => x.toLowerCase() === id.toLowerCase()) : undefined)
946 const id = p.value('--project-id')
947 if (id) {
948 const hit = protectedId(id)
949 if (hit) this.hitNeon(hit, sub)
950 } else if (dir !== null && list.length > 0) {
951 const flag = p.value('--context-file')
952 const file = flag ? resolvePath(flag, dir, this.deps.home) : undefined
953 // A --context-file that cannot be resolved ($VAR) is not guessed.
954 if (file !== null) {
955 const hit = protectedId(await this.deps.neonContext(dir, file).catch(() => ''))
956 if (hit) this.hitNeon(hit, sub)
957 }
958 }
959 await this.hitPath(dir, sub)
960 }
961
962 private hitNeon(id: string, detail: string) {
963 const key = `neon:${id.toLowerCase()}`
964 if (!this.hits.some(h => h.key === key)) this.hits.push({ key, label: `Neon ${id}`, detail })
965 }
966
967 private async supabase(args: string[], dir: string | null) {
968 const p = parseArgs(args, SUPABASE_VALUE)
969 if (!supabaseWrites(p)) return
970 const sub = `supabase ${p.pos.slice(0, 2).join(' ')}`
971 const list = this.config.supabaseProjects
972 const protectedRef = (ref: string) => (ref ? list.find(x => x.toLowerCase() === ref.toLowerCase()) : undefined)
973 const workdir = p.value('--workdir')
974 const at = workdir ? resolvePath(workdir, dir, this.deps.home) : dir
975 const named = [p.value('--project-ref'), p.pos[0] === 'projects' && p.pos[1] === 'delete' ? p.pos[2] : undefined].filter((x): x is string => !!x)
976 for (const n of named) {
977 const hit = protectedRef(n)
978 if (hit) this.hitSupabase(hit, sub)
979 }
980 // A database URL names the project (db.<ref>.supabase.co, postgres.<ref>@…pooler).
981 const dbUrl = p.value('--db-url')?.toLowerCase()
982 if (dbUrl) for (const x of list) if (dbUrl.includes(x.toLowerCase())) this.hitSupabase(x, sub)
983 // A local URL (the development database) is not the linked project; any other one without the ref falls back to the link.
984 const localUrl = !!dbUrl && /(localhost|127\.0\.0\.1|\[::1\]|host\.docker\.internal)/.test(dbUrl)
985 if (named.length === 0 && !localUrl && at !== null && list.length > 0) {
986 const hit = protectedRef(await this.deps.supabaseLinked(at, !!workdir).catch(() => ''))
987 if (hit) this.hitSupabase(hit, sub)
988 }
989 await this.hitPath(at, sub)
990 }
991
992 private hitSupabase(ref: string, detail: string) {
993 const key = `supabase:${ref.toLowerCase()}`
994 if (!this.hits.some(h => h.key === key)) this.hits.push({ key, label: `Supabase ${ref}`, detail })
995 }
996
997 private async publish(name: string, args: string[], dir: string | null) {
998 const p = parseArgs(args, NPM_VALUE)
999 const pos = [...p.pos]
1000 if (name === 'yarn' && pos[0] === 'npm') pos.shift()
1001 if (pos[0] !== 'publish' || p.has('--dry-run')) return
1002 let at = dir
1003 const prefix = p.value('--prefix', '-C', '--dir', '--cwd')
1004 if (prefix) at = resolvePath(prefix, dir, this.deps.home) ?? dir
1005 const operand = pos[1]
1006 if (operand && /^[./~]/.test(operand)) at = resolvePath(operand, at, this.deps.home) ?? at
1007 await this.hitPath(at, `${name} publish`)
1008 }
1009
1010 // ── archivos ──────────────────────────────────────────────────────────────
1011
1012 private async files(name: string, args: string[], dir: string | null) {
1013 const paths = this.writtenPaths(name, args, dir)
1014 for (const w of paths.slice(0, 12)) {
1015 const path = resolvePath(w, dir, this.deps.home)
1016 await this.hitPath(path, `${name} ${basename(path ?? w)}`)
1017 }
1018 }
1019
1020 /** Arguments the command writes, deletes or moves (the raw paths; they are resolved separately). */
1021 private writtenPaths(name: string, args: string[], dir: string | null): string[] {
1022 switch (name) {
1023 case 'rm': case 'rmdir': case 'unlink': case 'shred': case 'trash': case 'mkdir': case 'tee':
1024 return parseArgs(args).pos
1025 case 'touch':
1026 return parseArgs(args, ['-t', '-d', '-r', '--date', '--reference']).pos
1027 case 'truncate':
1028 return parseArgs(args, ['-s', '--size', '-r', '--reference']).pos
1029 case 'chmod': case 'chown': case 'chgrp': {
1030 const p = parseArgs(args)
1031 return p.has('--reference') ? p.pos : p.pos.slice(1)
1032 }
1033 case 'mv': {
1034 const p = parseArgs(args, ['-t', '--target-directory', '-S', '--suffix'])
1035 const t = p.value('-t', '--target-directory')
1036 return t ? [t, ...p.pos] : p.pos
1037 }
1038 case 'cp': case 'install': case 'ln': {
1039 const p = parseArgs(args, ['-t', '--target-directory', '-S', '--suffix', '-m', '-o', '-g', '--mode', '--owner', '--group'])
1040 const t = p.value('-t', '--target-directory')
1041 if (t) return [t]
1042 if (name === 'ln' && p.pos.length < 2) return dir === null ? [] : ['.']
1043 return p.pos.length >= 2 ? [p.pos.at(-1)!] : []
1044 }
1045 case 'rsync': {
1046 const p = parseArgs(args, ['-e', '--rsh', '--exclude', '--include', '--exclude-from', '--include-from', '--port', '--bwlimit'])
1047 if (p.has('--dry-run') || p.short('n')) return []
1048 const last = p.pos.at(-1)
1049 return p.pos.length >= 2 && last && !/^[\w.@-]+:/.test(last) ? [last] : []
1050 }
1051 case 'dd':
1052 return args.filter(a => a.startsWith('of=')).map(a => a.slice(3))
1053 case 'find': {
1054 const start: string[] = []
1055 for (const a of args) {
1056 if (a.startsWith('-') || a === '(' || a === '!') break
1057 start.push(a)
1058 }
1059 const k = args.findIndex(a => ['-exec', '-execdir', '-ok', '-okdir'].includes(a))
1060 const writes = args.includes('-delete') || (k !== -1 && FIND_WRITERS.has(basename(args[k + 1] ?? '')))
1061 return writes ? (start.length ? start : ['.']) : []
1062 }
1063 case 'sed': {
1064 const p = parseArgs(args, ['-e', '--expression', '-f', '--file', '-l', '--line-length'])
1065 const inPlace = args.some(a => /^-[nEersuz]*i/.test(a) || a === '--in-place' || a.startsWith('--in-place='))
1066 if (!inPlace) return []
1067 // BSD: `sed -i '' …` leaves the empty string (the suffix) as the first positional.
1068 const pos = p.pos[0] === '' ? p.pos.slice(1) : p.pos
1069 const files = p.has('-e', '--expression', '-f', '--file') ? pos : pos.slice(1)
1070 return files.filter(f => f !== '')
1071 }
1072 case 'perl': {
1073 const p = parseArgs(args, ['-e', '-E', '-I', '-M', '-m', '-F', '-x'])
1074 const inPlace = args.some(a => /^-[A-Za-z]*i/.test(a) && !a.startsWith('--'))
1075 if (!inPlace) return []
1076 return p.has('-e', '-E') ? p.pos : p.pos.slice(1)
1077 }
1078 default:
1079 return []
1080 }
1081 }
1082}
1083
1084/** Everything protected that this Bash command would write (empty = nothing). */
1085export async function checkBash(cmd: string, deps: Deps): Promise<Hit[]> {
1086 const scan = new Scan(deps)
1087 await scan.seq(lex(cmd), deps.cwd, 0)
1088 return scan.hits
1089}
1090
1091/** Edit/Write/NotebookEdit: does the file fall inside a protected repo? */
1092export async function checkPath(path: string, deps: Deps): Promise<Hit[]> {
1093 const scan = new Scan(deps)
1094 const abs = resolvePath(path, deps.cwd, deps.home)
1095 const slug = await scan.slugAt(abs)
1096 return slug ? [{ key: slug.toLowerCase(), label: slug, detail: deps.words.editOf(basename(path)) }] : []
1097}
1098
1099/** Keys of an MCP input that name the project (Neon: projectId; Supabase: project_id). */
1100const MCP_PROJECT_KEYS = ['projectId', 'project_id', 'projectRef', 'project_ref']
1101const MCP_READ_PREFIX = ['get_', 'list_', 'describe_', 'search', 'fetch', 'explain_', 'compare_', 'inspect_', 'query_logs', 'read_', 'generate_typescript_types']
1102
1103/** Short name of an MCP tool (`mcp__Neon__run_sql` → `run_sql`). */
1104export const mcpShortName = (tool: string) => tool.split('__').pop() ?? tool
1105
1106/** An MCP tool that only reads (short, explicit list; when in doubt, it writes). */
1107export function mcpReads(tool: string): boolean {
1108 const short = mcpShortName(tool).toLowerCase()
1109 return MCP_READ_PREFIX.some(p => short.startsWith(p)) || short === 'get_advisors' || short === 'get_logs'
1110}
1111
1112/**
1113 * MCP tool (Neon, Supabase…): does it write to a protected project? The project comes from `projectId` / `project_id` /
1114 * `projectRef` / `project_ref`; an id can be in both lists. Without an id (a single-project connection) there is no match.
1115 */
1116export function checkMcp(tool: string, input: Record<string, unknown>, config: Config): Hit[] {
1117 // `explain_…` with `analyze` really runs the statement: a write, like run_sql.
1118 const runs = mcpShortName(tool).toLowerCase().startsWith('explain_') && input.analyze === true
1119 if (mcpReads(tool) && !runs) return []
1120 const ids = new Set<string>()
1121 for (const k of MCP_PROJECT_KEYS) {
1122 const v = input[k]
1123 if (typeof v === 'string' && v.trim()) ids.add(v.trim().toLowerCase())
1124 }
1125 if (ids.size === 0) return []
1126 const detail = `mcp ${mcpShortName(tool)}`
1127 const hits: Hit[] = []
1128 for (const [list, kind, name] of [
1129 [config.neonProjects, 'neon', 'Neon'],
1130 [config.supabaseProjects, 'supabase', 'Supabase'],
1131 ] as const) {
1132 for (const entry of list) {
1133 if (ids.has(entry.toLowerCase())) hits.push({ key: `${kind}:${entry.toLowerCase()}`, label: `${name} ${entry}`, detail })
1134 }
1135 }
1136 return hits
1137}
1138
1139// ── messages ────────────────────────────────────────────────────────────────
1140
1141/** The reason the model sees when a call is denied. */
1142export function denyText(hits: Hit[], w: Words): string {
1143 const first = hits[0]!
1144 return w.deny(first.label, hits.slice(1).map(h => h.label).join(', '), first.detail)
1145}
1146
1147// ── passes (one-time, humans only) ────────────────────────────────────────
1148
1149export const PASS_TTL_MS = 10 * 60 * 1000
1150export type PassLike = { key: string; at: number }
1151
1152export const isLive = (p: PassLike, now: number) => now - p.at <= PASS_TTL_MS && now >= p.at
1153
1154/**
1155 * Spends the passes that cover ALL the keys of the block. If any is missing, it spends none.
1156 * Expired passes are always dropped.
1157 */
1158export function spendPasses<T extends PassLike>(passes: T[], keys: string[], now: number): { ok: boolean; left: T[] } {
1159 const live = passes.filter(p => isLive(p, now))
1160 const ok = keys.length > 0 && keys.every(k => live.some(p => p.key === k))
1161 return { ok, left: ok ? live.filter(p => !keys.includes(p.key)) : live }
1162}
1163
1164/** Who can give a pass: a person typing (terminal, desktop app through the SDK, or phone), never a plugin or the model. */
1165export const HUMAN_ORIGINS = new Set(['composer', 'bridge', 'sdk'])
1166
1167// ── list management (panel) ─────────────────────────────────────────────
1168
1169export type ConfigEdit =
1170 | { op: 'addRepo'; slug: string }
1171 | { op: 'removeRepo'; slug: string }
1172 | { op: 'addRailway'; name: string }
1173 | { op: 'removeRailway'; name: string }
1174 | { op: 'addVercel'; name: string }
1175 | { op: 'removeVercel'; name: string }
1176 | { op: 'addNeon'; id: string }
1177 | { op: 'removeNeon'; id: string }
1178 | { op: 'addSupabase'; ref: string }
1179 | { op: 'removeSupabase'; ref: string }
1180
1181const SLUG = /^[A-Za-z0-9_.][A-Za-z0-9_.-]*\/[A-Za-z0-9_.][A-Za-z0-9_.-]*$/
1182const same = (a: string, b: string) => a.toLowerCase() === b.toLowerCase()
1183const without = (list: string[], x: string) => list.filter(v => !same(v, x))
1184
1185/** Clean "owner/repo" from whatever the person pastes (slug, https or ssh URL); null if it does not look like a GitHub repo. */
1186export function cleanSlug(input: string): string | null {
1187 const t = input.trim()
1188 if (!t || /\s/.test(t)) return null
1189 const slug = SLUG.test(t) ? t : slugFromUrl(t)
1190 return slug && SLUG.test(slug) ? slug : null
1191}
1192
1193/** Acceptable Railway project name: one line, no "/", up to 100 characters. */
1194export function cleanRailway(input: string): string | null {
1195 const t = input.trim()
1196 return t && t.length <= 100 && !/[/\n\r\t]/.test(t) ? t : null
1197}
1198
1199/** Acceptable Vercel project: a name or a `prj_…` id (one line, no "/", up to 100 characters). */
1200export function cleanVercel(input: string): string | null {types/index.d.ts 32 lines1// State contract of Hands Off: every value the mod keeps in $.state.
2
3/** A pass given by a person: covers the next blocked call on that target, up to 10 minutes after it is given. */
4export type Pass = { key: string; label: string; at: number }
5
6/** The last block (so `/handsoff pass` with no arguments knows which target it means). */
7export type LastDenied = { keys: string[]; labels: string[]; at: number }
8
9/** A block (or a spent pass) of this session, for the panel. */
10export type Block = { keys: string[]; labels: string[]; detail: string; at: number; passed: boolean }
11
12/** The list as the panel shows it (a copy of ~/.claude/hands-off.json). */
13export type ConfigView = { repos: string[]; railwayProjects: string[]; vercelProjects: string[]; neonProjects: string[]; supabaseProjects: string[]; isDefault: boolean }
14
15/** Notice in the panel after an action (added, removed, error). */
16export type Note = { text: string; isError: boolean }
17
18declare module 'claude-code' {
19 interface PluginState {
20 'hands-off': {
21 passes: Pass[]
22 lastDenied: LastDenied | null
23 history: Block[]
24 view: ConfigView | null
25 expanded: string | null
26 confirm: string | null
27 note: Note | null
28 tick: number
29 }
30 }
31}
32