SLOPSHOPPER

hands-off

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…

newpaneguardcommandtoastprocess
★ 1v0.6.0MITupdated 2026-10-14Br4inergy/claude-hands-off
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · hands-off
│ ┃ Hands Off ✕ › fix the failing auth test and add an audit log call │ ┃ 🛡 Hands Off · read yes, write no │ ┃ 0 repos · 0 Railway · 0 Vercel · 0 Neon · 0 ⏺ Read(src/auth.ts) │ ┃ Supabase · 0 active passes · 0 blocks in ⎿ Read 6 lines │ ┃ this session ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ Protected ⏺ Bash(bun test) │ ┃ The list is empty: nothing is protected. ⎿ 3 pass, 1 fail │ ┃ Add │ ┃ Repo : owner/repo or GitHub URL ⏎ add ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Railway : project name ⏎ add │ ┃ Vercel : project name or id (prj_…) ⏎ add ✻ Worked for 42s · done 4:20 PM │ ┃ Neon : project id (e.g. proud-cloud-12345678 │ ┃ Supabase : project ref (20 letters) ⏎ add › /handsoff │ ┃ │ ┃ Activity this session │ ┃ Nothing blocked yet. │ ┃ │ ┃ [ ↻ Reload list ] │ ┃ The list lives in ~/.claude/hands-off.json │ ┃ (kept when the mod updates). Only a person │ ┃ can press the buttons in this panel. │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Hands Off
🛡 Hands Off · read yes, write no 0 repos · 0 Railway · 0 Vercel · 0 Neon · 0 Supabase · 0 active passes · 0 blocks in this session Protected The list is empty: nothing is protected. Add Repo : owner/repo or GitHub URL ⏎ add Railway : project name ⏎ add Vercel : project name or id (prj_…) ⏎ add Neon : project id (e.g. proud-cloud-12345678) ⏎ add Supabase : project ref (20 letters) ⏎ add Activity this session Nothing blocked yet. [ ↻ Reload list ] The list lives in ~/.claude/hands-off.json (kept when the mod updates). Only a person can press the buttons in this panel.
README

✋ Hands Off

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

Install

/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.

What it protects

WhereWhat counts as a writeStill 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_… iddeploy, link, env add/rm/pull, promote… when the folder is linked to the project (.vercel/project.json) or the command names itls, inspect, logs, whoami, env ls, dev
Railway projectsup, redeploy, down, variables --set, service, link… when linked or named with -pstatus, logs, variables, list
Neon projects, by idneon / neonctl (and npx neonctl): branch, role, database and project changes, set-context… by --project-id or the folder's .neon contextbranches list/get, connection-string, projects list/get
Supabase projects, by refdb push, db reset --linked, migration repair, functions deploy, secrets set, link, config push… by --project-ref or the linked supabase/.temp/project-refstatus, 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 projecttools 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.

The pass: only a person can give it

  • /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.
  • Or press One-time pass (10 min) on a card in the panel, or Give pass and retry after a block.

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.

The panel

/handsoff opens it (terminal, desktop and phone):

  • what is protected, one card per repo or project, with how many times it was blocked this session and what;
  • active passes with their minutes left; One-time pass (10 min) and Revoke pass;
  • fields to add a GitHub repo (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);
  • the session's activity: every block and every pass used.

When the panel is showing, /handsoff leaves nothing in the conversation. /handsoff status prints a text summary.

Your list

~/.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.

Designed to stay out of your way

  • Silent when nothing is protected is touched. No tokens, no prompts, no questions; one line to the agent only when it blocks.
  • Fails open on its own errors. If its own check fails or takes too long, the call goes through and you get one warning; it never locks you out of your machine.
  • Almost nothing over the network. Links are read from the files the CLIs themselves write; the only commands it runs are 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.

Known limits

  • For git, only the origin remote is checked (plus an explicit git push target).
  • In your own fork of a protected repo, opening a PR between your fork's branches is also stopped, because 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.
  • Writes hidden inside a program (python -c "open(...)", node -e) or an xargs are not seen.
  • A raw psql (or any client) with a connection string is not seen.
  • An MCP connection bound to a single project that doesn't send its project id can't be matched. GitHub's MCP tools are not covered.

Language

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).

Development

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.

License

MIT. See LICENSE.

Source 4 files
hooks/register.tsx 836 lines
1/**
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}
836
hooks/i18n.ts 240 lines
1/**
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}
240
hooks/rules.ts 1328 lines
1import 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 lines
1// 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