SLOPSHOPPER

shell-guard

Blocks shell commands that wipe your home folder, the project, disks, protected branches or databases, and snapshots uncommitted work to a git ref before…

newguardcommandtoastprocess
v0.1.0MITupdated 2026-10-06Jvrd97/claude-shell-guard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · shell-guard
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ shell-guard │ ⏺ Read(src/auth.ts) │ shell-guard blocked: git push --force │ ⎿ Read 6 lines │ origin main │ ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ ⎿ Added 2 lines, removed 1 line ⏺ Bash(rm -rf build && git push --force origin main) ⎿ Denied by shell-guard: shell-guard blocked this command: force-pushing to the protected branch main rewrit ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /shell-guard ⎿ shell-guard: shell-guard is on. No snapshots in /work/app yet. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

shell-guard

A Claude Code mod that refuses shell commands which wipe your home folder, the project, a disk, a protected branch or a database, and saves your uncommitted work to a git ref before commands that would destroy it.

shell-guard blocked: rm -rf ~/shell-guard-smoke-nonexistent

Error: shell-guard blocked this command: the target ~/shell-guard-smoke-nonexistent is outside the
project (~/proj); recursive or forced deletes are allowed only inside the project and temp folders.
If the user really wants this, ask them to run it themselves.

snapshot saved: refs/shell-guard/20261006-120304-567

> /shell-guard
shell-guard is on. 1 snapshot in ~/proj, newest first.
Restoring overwrites the same files in the working tree; files created since stay.

1. 2026-10-06 14:03  git reset --hard HEAD~1
   see:      git diff --stat refs/shell-guard/20261006-120304-567^1 refs/shell-guard/20261006-120304-567
   restore:  git checkout refs/shell-guard/20261006-120304-567 -- .
   one file: git checkout refs/shell-guard/20261006-120304-567 -- <path>

Why

Claude Code runs a destructive command now and then, and /rewind does not undo what Bash did:

Guards already exist, for example dcg and cc-safety-net. shell-guard differs in two ways:

  • It knows where the project is. rm -rf dist inside the project runs; rm -rf ~/anything, rm -rf .., rm -rf * at the project root and cd ~ && rm -rf * are refused. Paths are resolved against the working directory, cd included.
  • It snapshots before it lets work go. git reset --hard, git checkout -- ., git clean -fd, git stash drop and rm -r inside the project still run, but first the whole working tree, untracked files included, is committed to refs/shell-guard/<time>. /shell-guard prints the commands that bring it back.

What it does

CommandWhat happens
rm -r/-f of /, ~, $HOME, /*, the project root, * or . at the root, anything outside the projectRefused
git push --force / -f / --force-with-lease / +ref to a protected branch, git push --mirror, deleting a protected branchRefused
dd of=/dev/…, > /dev/sdb, mkfs*, shred, wipefs, diskutil erase*Refused
chmod -R / chown -R outside the project, find / … -delete, find … -exec rm outside the projectRefused
terraform destroy, kubectl delete of namespaces or --all, docker system prune --volumes, docker volume rm/prune, docker compose down -vRefused
DROP DATABASE, DROP SCHEMA, DROP TABLE, TRUNCATE through psql -c, mysql -e, sqlite3, or a heredoc; redis-cli FLUSHALLRefused
git reset --hard, git checkout -- <path>, git checkout ., git restore (not --staged alone), git clean -f, git switch --discard-changes, git stash drop/clear, rm -r and find -delete inside the projectSnapshot, then run
rm -rf node_modules dist, rm file.txt, git push --force to a feature branch, git reset --soft, docker compose down, kubectl delete pod x, rm -rf /tmp/xRun

The parser splits a command on &&, ||, ;, |, &, newlines and parentheses, and looks inside bash -c/sh -c, eval, $(…), backticks, xargs and shell heredocs. It strips sudo, env, nice, timeout and VAR=value prefixes. Quotes are respected: grep "rm -rf /" and echo 'git push --force' run.

A refusal shows a toast and tells the model what to do instead. A snapshot shows snapshot saved: refs/shell-guard/….

Install

You need Claude Code with mods (function hooks); tested on 2.1.285 and 2.1.291. Mods are in early access: if the CLI says hooks modules are not turned on, start it as CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude.

From the marketplace in this repo:

/plugin marketplace add Jvrd97/claude-shell-guard
/plugin install shell-guard@shell-guard

Or straight from disk, for one session:

git clone https://github.com/Jvrd97/claude-shell-guard.git
claude --plugin-dir ./claude-shell-guard

/shell-guard off turns the guard off for the session and /shell-guard on turns it back on. Only a person can turn it off: the command refuses a run that did not come from the prompt, the bridge or the SDK host.

Settings

Change them in /config under the plugin, or in settings.json under pluginConfigs:

FieldDefaultMeaning
modedenydeny refuses a dangerous command. ask sends it to the permission dialog with the reason instead
protectedBranchesmain,masterBranches that may not be force-pushed, deleted on the remote or deleted with git branch -D
snapshotstrueSnapshot uncommitted work before commands that destroy it
extraDenyemptyRegular expressions, one per line, matched against each command of a Bash call with prefixes like sudo stripped, for example ^npm publish

ask hands the decision to your permission mode. In bypassPermissions, auto or a headless run, that mode may let the command through without asking you, which is why deny is the default.

How it works

  • tool.call on Bash classifies the command. A dangerous command is answered with a deny, so it never runs. In ask mode, tool.check turns the same verdict into an ask.
  • The project root is the session's root ($.session.root()), where Claude Code started or /cd moved it. Recursive or forced deletes are allowed beneath it and beneath /tmp, /private/tmp, /var/tmp and $TMPDIR.
  • A snapshot runs in the repo of the command's working directory: git status --porcelain first (a clean tree is skipped silently, as is a folder outside git), then git add -A into a temporary index at .git/shell-guard-index (a copy of the real one, deleted afterwards), git write-tree, git commit-tree with HEAD as parent and git update-ref refs/shell-guard/<time>. The real index, HEAD and your branches are not touched. git stash drop/clear also adds every stash as a parent, so a dropped stash can be stored back.
  • The newest 100 snapshots per repo are kept; older refs are deleted.
  • If a snapshot fails, the command is refused rather than run unprotected.
  • The mod writes nothing outside your repos' .git folders and sends nothing anywhere.

What it cannot do:

  • It reads the command text, not what a script does. ./cleanup.sh, make clean, python -c "shutil.rmtree(...)" and ssh host 'rm -rf /' run unchecked.
  • It does not follow symbolic links. A link inside the project that points outside it counts as inside.
  • A target built from a variable it cannot resolve (rm -rf "$DIR", a loop variable) is refused; the model is told to repeat the command with a literal path. $HOME, $PWD and $(pwd) are resolved.
  • xargs rm -rf with no targets of its own is treated as a delete inside the project, because the paths arrive on stdin.
  • Snapshots respect .gitignore: git clean -fdx deletes ignored files (an .env, local databases) that no snapshot holds.
  • It does not catch SQL piped from another command (echo "DROP TABLE x" | psql), only -c/-e, sqlite3 arguments and heredocs.
  • False positives it accepts: DROP TABLE against a throwaway local database, rm -f of a single file outside the project, any shred.

Develop

Inside Claude Code run /plugin-types .claude/types once: it writes the API types tsc reads. Then:

tsc -p .
claude plugin validate .claude-plugin/plugin.json
claude plugin test .

По-русски

Мод не даёт Claude Code выполнить команды, которые стирают домашнюю папку, весь проект, диск, защищённую ветку или базу данных. Модель получает отказ с объяснением, что сделать вместо этого.

Мод знает границы проекта: rm -rf dist внутри проекта пройдёт, rm -rf ~/что-угодно и rm -rf * в корне — нет.

Перед git reset --hard, git clean -f, git checkout -- . и rm -r внутри проекта мод сохраняет все незакоммиченные файлы, включая неотслеживаемые, в ref refs/shell-guard/<время>. /rewind такие изменения не откатывает, а /shell-guard печатает готовые команды восстановления.

License

MIT

Source 5 files
hooks/register.ts 293 lines
1import { atom, read, update } from 'claude-code'
2import type { CommandRunInput, EngineInterface, ProcessRunResult, Register } from 'claude-code'
3
4import type { ShellGuardMode } from '../types'
5import {
6  classify,
7  DEFAULT_PROTECTED,
8  formatSnapshots,
9  MAX_SNAPSHOTS,
10  parseBranches,
11  parsePatterns,
12  parseSnapshots,
13  SNAPSHOT_FORMAT,
14  SNAPSHOT_PREFIX,
15  snapshotMessage,
16  snapshotRef,
17  type Context,
18  type Verdict,
19} from './logic'
20import { tildify } from './paths'
21
22const COMMAND = 'shell-guard'
23const DENY_TOAST_MS = 8_000
24const SNAPSHOT_TOAST_MS = 6_000
25/** `git add -A` hashes every changed file; a big repo needs more than the default 30 s. */
26const GIT_TIMEOUT_MS = 120_000
27const SYSTEM_TEMP_DIRS = ['/tmp', '/private/tmp', '/var/tmp', '/private/var/tmp']
28/** Who may turn the guard off: a person, never the model or another agent. */
29const HUMAN_ORIGINS = new Set(['composer', 'bridge', 'sdk'])
30const TEMP_INDEX = 'shell-guard-index'
31const GIT_ENV: Record<string, string> = {
32  GIT_OPTIONAL_LOCKS: '0',
33  GIT_AUTHOR_NAME: 'shell-guard',
34  GIT_AUTHOR_EMAIL: 'shell-guard@localhost',
35  GIT_COMMITTER_NAME: 'shell-guard',
36  GIT_COMMITTER_EMAIL: 'shell-guard@localhost',
37}
38
39const isOff = atom({ plugin: 'shell-guard', key: 'isOff' } as const, false)
40
41type Settings = {
42  mode: ShellGuardMode
43  protectedBranches: string[]
44  hasSnapshots: boolean
45  extraDeny: RegExp[]
46  invalidPatterns: string[]
47}
48
49type Run = { ok: true; out: string } | { ok: false; error: string }
50
51/** Runs git and never rejects: a missing folder or binary comes back as an error. */
52async function runGit($: EngineInterface, args: readonly string[], cwd: string, env: Record<string, string> = {}): Promise<Run> {
53  let result: ProcessRunResult
54
55  try {
56    result = await $.process.run(['git', ...args], { cwd, env: { ...GIT_ENV, ...env }, timeoutMs: GIT_TIMEOUT_MS })
57  } catch (error) {
58    return { ok: false, error: error instanceof Error ? error.message : String(error) }
59  }
60
61  return result.exitCode === 0 ? { ok: true, out: result.stdout.trim() } : { ok: false, error: result.stderr.trim() || `git ${args[0] ?? ''} exited ${result.exitCode}` }
62}
63
64async function contextOf($: EngineInterface, command: string, settings: Settings): Promise<Context> {
65  const cwd = await $.session.cwd()
66  const root = await $.session.root()
67  const home = (await $.env.get('HOME')) ?? ''
68  const tmp = await $.env.get('TMPDIR')
69  const branch = /\bpush\b/.test(command) ? await runGit($, ['rev-parse', '--abbrev-ref', 'HEAD'], cwd) : null
70  const tempDirs = tmp === undefined || tmp === '' ? SYSTEM_TEMP_DIRS : [...SYSTEM_TEMP_DIRS, tmp.replace(/\/+$/, '')]
71
72  return {
73    cwd,
74    root,
75    home,
76    tempDirs,
77    branch: branch !== null && branch.ok && branch.out !== 'HEAD' ? branch.out : null,
78    protectedBranches: settings.protectedBranches,
79    extraDeny: settings.extraDeny,
80  }
81}
82
83type Saved = { kind: 'saved'; ref: string } | { kind: 'skipped' } | { kind: 'failed'; error: string }
84
85/** Drops the oldest snapshots past MAX_SNAPSHOTS. */
86async function prune($: EngineInterface, top: string): Promise<void> {
87  const listed = await runGit($, ['for-each-ref', '--sort=-refname', '--format=%(refname)', SNAPSHOT_PREFIX], top)
88
89  if (!listed.ok) {
90    return
91  }
92
93  for (const ref of listed.out.split('\n').slice(MAX_SNAPSHOTS)) {
94    await runGit($, ['update-ref', '-d', ref], top)
95  }
96}
97
98/**
99 * Commits the working tree, untracked files included, to a ref of its own
100 * without touching the real index, HEAD or any branch.
101 */
102async function snapshot($: EngineInterface, dir: string, command: string, withStashes: boolean): Promise<Saved> {
103  const found = await runGit($, ['rev-parse', '--show-toplevel', '--absolute-git-dir'], dir)
104
105  if (!found.ok) {
106    return { kind: 'skipped' }
107  }
108
109  const [top = dir, gitDir = `${dir}/.git`] = found.out.split('\n')
110  const stashList = withStashes ? await runGit($, ['stash', 'list', '--format=%H'], top) : null
111  const stashes = stashList !== null && stashList.ok ? stashList.out.split('\n').filter(sha => sha !== '') : []
112  const status = await runGit($, ['status', '--porcelain'], top)
113
114  if (!status.ok) {
115    return { kind: 'failed', error: status.error }
116  }
117
118  if (status.out === '' && stashes.length === 0) {
119    return { kind: 'skipped' }
120  }
121
122  const index = `${gitDir}/${TEMP_INDEX}`
123  const withIndex = { GIT_INDEX_FILE: index }
124  // Starting from a copy of the real index lets `git add` reuse its stat cache instead of hashing every file.
125  await $.process.run(['cp', `${gitDir}/index`, index]).catch(() => undefined)
126
127  try {
128    const added = await runGit($, ['add', '-A'], top, withIndex)
129
130    if (!added.ok) {
131      return { kind: 'failed', error: added.error }
132    }
133
134    const tree = await runGit($, ['write-tree'], top, withIndex)
135
136    if (!tree.ok) {
137      return { kind: 'failed', error: tree.error }
138    }
139
140    const head = await runGit($, ['rev-parse', '--verify', '--quiet', 'HEAD'], top)
141    const parents = [...(head.ok ? [head.out] : []), ...stashes].flatMap(sha => ['-p', sha])
142    const commit = await runGit($, ['commit-tree', tree.out, ...parents, '-m', snapshotMessage(command, stashes)], top)
143
144    if (!commit.ok) {
145      return { kind: 'failed', error: commit.error }
146    }
147
148    const ref = snapshotRef(await $.clock.now())
149    const stored = await runGit($, ['update-ref', '-m', 'shell-guard snapshot', ref, commit.out], top)
150
151    if (!stored.ok) {
152      return { kind: 'failed', error: stored.error }
153    }
154
155    await prune($, top)
156
157    return { kind: 'saved', ref }
158  } finally {
159    await $.process.run(['rm', '-f', index]).catch(() => undefined)
160  }
161}
162
163async function refuse($: EngineInterface, verdict: Extract<Verdict, { kind: 'deny' }>): Promise<{ deny: string }> {
164  $.ui.toast(`shell-guard blocked: ${verdict.summary}`, { timeoutMs: DENY_TOAST_MS })
165
166  return { deny: verdict.reason }
167}
168
169/** The tool.call decision: null lets the call run. */
170async function guard($: EngineInterface, command: string, settings: Settings): Promise<{ deny: string } | null> {
171  if (await read($, isOff)) {
172    return null
173  }
174
175  const verdict = classify(command, await contextOf($, command, settings))
176
177  if (verdict.kind === 'deny') {
178    return settings.mode === 'deny' ? refuse($, verdict) : null
179  }
180
181  if (verdict.kind !== 'snapshot' || !settings.hasSnapshots) {
182    return null
183  }
184
185  for (const dir of verdict.dirs) {
186    const saved = await snapshot($, dir, command, verdict.withStashes)
187
188    if (saved.kind === 'saved') {
189      $.ui.toast(`snapshot saved: ${saved.ref}`, { timeoutMs: SNAPSHOT_TOAST_MS })
190    }
191
192    if (saved.kind === 'failed') {
193      return refuse($, {
194        kind: 'deny',
195        summary: verdict.summary,
196        reason: `shell-guard could not snapshot the uncommitted work this command would destroy (${saved.error}). Commit or stash the work first, then run it again.`,
197      })
198    }
199  }
200
201  return null
202}
203
204/** The tool.check decision in ask mode: a command the guard would refuse goes to the permission dialog. */
205async function askVerdict($: EngineInterface, input: unknown, settings: Settings): Promise<string | null> {
206  const command = typeof input === 'object' && input !== null && 'command' in input ? input.command : undefined
207
208  if (typeof command !== 'string' || (await read($, isOff))) {
209    return null
210  }
211
212  const verdict = classify(command, await contextOf($, command, settings))
213
214  return verdict.kind === 'deny' ? verdict.reason.replace('shell-guard blocked this command: ', 'shell-guard: ') : null
215}
216
217async function runCommand($: EngineInterface, e: CommandRunInput): Promise<{ text: string }> {
218  const arg = e.args.trim().toLowerCase()
219
220  if (arg === 'off' || arg === 'on') {
221    if (arg === 'off' && !HUMAN_ORIGINS.has(e.origin.kind)) {
222      return { text: 'shell-guard: only the user can turn the guard off, by typing /shell-guard off.' }
223    }
224
225    await update($, isOff, () => arg === 'off')
226    $.ui.toast(`shell-guard ${arg} for this session`)
227
228    return { text: arg === 'off' ? 'shell-guard is off until /shell-guard on or the end of the session.' : 'shell-guard is on.' }
229  }
230
231  if (arg !== '') {
232    return { text: 'Usage: /shell-guard (list snapshots), /shell-guard off, /shell-guard on' }
233  }
234
235  const cwd = await $.session.cwd()
236  const home = (await $.env.get('HOME')) ?? ''
237  const isOn = !(await read($, isOff))
238  const top = await runGit($, ['rev-parse', '--show-toplevel'], cwd)
239
240  if (!top.ok) {
241    return { text: `shell-guard is ${isOn ? 'on' : 'off'}. ${tildify(cwd, home)} is not a git repository, so it has no snapshots.` }
242  }
243
244  const listed = await runGit($, ['for-each-ref', '--sort=-refname', `--format=${SNAPSHOT_FORMAT}`, SNAPSHOT_PREFIX], top.out)
245
246  return { text: formatSnapshots(listed.ok ? parseSnapshots(listed.out) : [], tildify(top.out, home), isOn) }
247}
248
249const settingsOf = (options: Readonly<Record<string, unknown>>): Settings => {
250  const branches = parseBranches(String(options.protectedBranches ?? DEFAULT_PROTECTED.join(',')))
251  const { patterns, invalid } = parsePatterns(String(options.extraDeny ?? ''))
252
253  return {
254    mode: options.mode === 'ask' ? 'ask' : 'deny',
255    protectedBranches: branches.length === 0 ? DEFAULT_PROTECTED : branches,
256    hasSnapshots: options.snapshots !== false,
257    extraDeny: patterns,
258    invalidPatterns: invalid,
259  }
260}
261
262export const register: Register = (on, options) => {
263  const settings = settingsOf(options)
264
265  on('session.start', async ($, e, next) => {
266    await $.command.register({ name: COMMAND, description: 'List shell-guard snapshots with restore commands; off / on for this session', argumentHint: '[off|on]' })
267
268    if (settings.invalidPatterns.length > 0) {
269      $.ui.toast(`shell-guard: ignored invalid extraDeny pattern ${settings.invalidPatterns.join(', ')}`, { timeoutMs: DENY_TOAST_MS })
270    }
271
272    return next(e)
273  })
274
275  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
276    const refused = await guard($, e.command, settings)
277
278    return refused ?? next(e)
279  })
280
281  on('tool.check', { tool: 'Bash' }, async ($, e, next) => {
282    if (settings.mode !== 'ask') {
283      return next(e)
284    }
285
286    const reason = await askVerdict($, e.input, settings)
287
288    return reason === null ? next(e) : { decision: 'ask', reason }
289  })
290
291  on('command.run', { command: COMMAND }, async ($, e) => runCommand($, e))
292}
293
hooks/logic.ts 876 lines
1import { basename, isBelow, isWithin, join, normalize, tildify } from './paths'
2import { parse, type Segment, type Word } from './shell'
3
4/** What the guard knows about where a command runs. */
5export type Context = {
6  /** The session's working directory, absolute. */
7  cwd: string
8  /** The project root: deletes are allowed beneath it, never of it. */
9  root: string
10  home: string
11  /** Folders whose contents may be deleted freely (`/tmp`, `$TMPDIR`). */
12  tempDirs: readonly string[]
13  /** The branch checked out in `cwd`, or null when unknown. */
14  branch: string | null
15  protectedBranches: readonly string[]
16  extraDeny: readonly RegExp[]
17}
18
19export type Verdict =
20  | { kind: 'allow' }
21  | { kind: 'deny'; reason: string; summary: string }
22  /** Runs after a snapshot of each repo in `dirs`; `withStashes` keeps stash entries too. */
23  | { kind: 'snapshot'; dirs: string[]; withStashes: boolean; summary: string }
24
25const ALLOW: Verdict = { kind: 'allow' }
26/** Nesting of `bash -c`, `eval` and `$(...)` the guard follows before it gives up and refuses. */
27const MAX_DEPTH = 6
28const SUMMARY_LENGTH = 80
29const TAIL = 'If the user really wants this, ask them to run it themselves.'
30
31export const DEFAULT_PROTECTED = ['main', 'master']
32
33const SHELLS = new Set(['bash', 'sh', 'zsh', 'dash', 'ksh', 'fish'])
34const SQL_CLIENTS = new Set(['psql', 'mysql', 'mariadb', 'sqlite3'])
35const DESTRUCTIVE_SQL = /\b(drop\s+(database|schema|table)|truncate)\b/i
36const SAFE_DEVICE = /^\/dev\/(null|zero|stdin|stdout|stderr|tty|u?random|fd\/\d+)$/
37const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/
38const GLOB = /[*?[]/
39const VARIABLE = /\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/g
40const SHELL_KEYWORDS = new Set(['if', 'then', 'else', 'elif', 'do', 'while', 'until', '!', '{', '}', 'time'])
41const DISKUTIL_ERASE = /^(erase|zero|secureerase|partitiondisk|reformat|randomdisk)/i
42const NAMESPACE_KINDS = new Set(['ns', 'namespace', 'namespaces'])
43
44/** Options of prefix commands that take a value, by command. */
45const PREFIX_VALUE_OPTIONS: Readonly<Record<string, ReadonlySet<string>>> = {
46  sudo: new Set(['-u', '-g', '-h', '-p', '-C', '-r', '-t', '-U', '-D', '-R', '-T']),
47  doas: new Set(['-u', '-C']),
48  env: new Set(['-u', '-C', '-S', '--unset', '--chdir']),
49  nice: new Set(['-n']),
50  timeout: new Set(['-s', '-k', '--signal', '--kill-after']),
51  exec: new Set(['-a']),
52  ionice: new Set(['-c', '-n', '-p']),
53  stdbuf: new Set([]),
54  nohup: new Set([]),
55  command: new Set([]),
56  builtin: new Set([]),
57}
58const XARGS_VALUE_OPTIONS = new Set(['-n', '-I', '-L', '-P', '-d', '-E', '-s', '-a', '-i', '-l'])
59
60type Place =
61  | { kind: 'system' }
62  | { kind: 'home'; path: string }
63  | { kind: 'project'; path: string }
64  | { kind: 'contains-project'; path: string }
65  | { kind: 'inside'; path: string }
66  | { kind: 'temp'; path: string }
67  | { kind: 'outside'; path: string }
68  | { kind: 'unknown'; text: string }
69
70/** Where the walk stands: `cwd` null after a `cd` the guard could not follow. */
71type Walk = { cwd: string | null; depth: number; snapshotDirs: string[]; withStashes: boolean; summary: string }
72
73const summarize = (text: string): string => {
74  const line = text.replace(/\s+/g, ' ').trim()
75
76  return line.length > SUMMARY_LENGTH ? `${line.slice(0, SUMMARY_LENGTH - 1)}…` : line
77}
78
79const deny = (reason: string, segment: readonly string[]): Verdict => ({
80  kind: 'deny',
81  reason: `shell-guard blocked this command: ${reason} ${TAIL}`,
82  summary: summarize(segment.join(' ')),
83})
84
85const isFlag = (word: string): boolean => word.startsWith('-') && word !== '-'
86
87/** True when a short-flag cluster (`-rf`) or a long flag spells one of `letters` or `longs`. */
88const hasFlag = (args: readonly string[], letters: string, longs: readonly string[]): boolean =>
89  args.some(arg => {
90    if (arg.startsWith('--')) {
91      return longs.some(long => arg === long || arg.startsWith(`${long}=`))
92    }
93
94    return arg.startsWith('-') && !arg.startsWith('--') && [...arg.slice(1)].some(ch => letters.includes(ch))
95  })
96
97const positionals = (args: readonly string[], valueOptions: ReadonlySet<string> = new Set()): string[] => {
98  const found: string[] = []
99  let i = 0
100
101  while (i < args.length) {
102    const arg = args[i] ?? ''
103
104    if (arg === '--') {
105      found.push(...args.slice(i + 1))
106      break
107    }
108
109    if (isFlag(arg)) {
110      i += valueOptions.has(arg) ? 2 : 1
111      continue
112    }
113
114    found.push(arg)
115    i += 1
116  }
117
118  return found
119}
120
121/**
122 * Strips what runs a command without being it: `VAR=x`, `sudo`, `env`,
123 * `nice`, `timeout 5` and their options. Returns the command's own words.
124 */
125const stripPrefixes = (words: readonly Word[]): Word[] => {
126  let i = 0
127
128  while (i < words.length) {
129    const text = words[i]?.text ?? ''
130
131    if (ASSIGNMENT.test(text) || SHELL_KEYWORDS.has(text)) {
132      i += 1
133      continue
134    }
135
136    const options = PREFIX_VALUE_OPTIONS[basename(text)]
137
138    if (options === undefined) {
139      break
140    }
141
142    const name = basename(text)
143    i += 1
144
145    while (i < words.length) {
146      const arg = words[i]?.text ?? ''
147
148      if (arg === '--') {
149        i += 1
150        break
151      }
152
153      if (isFlag(arg)) {
154        i += options.has(arg) ? 2 : 1
155        continue
156      }
157
158      if (name === 'env' && ASSIGNMENT.test(arg)) {
159        i += 1
160        continue
161      }
162
163      if (name === 'timeout') {
164        i += 1
165      }
166
167      break
168    }
169  }
170
171  return words.slice(i)
172}
173
174/** Expands `~`, `$HOME`, `$PWD` and `$(pwd)`; anything else unresolved stays as `$`. */
175const expand = (word: Word, cwd: string | null, ctx: Context): string => {
176  let text = word.text
177
178  if (word.isTildeLive && ctx.home !== '' && (text === '~' || text.startsWith('~/'))) {
179    text = ctx.home + text.slice(1)
180  }
181
182  return text.replace(VARIABLE, (match, name: string) => {
183    if (name === 'HOME' && ctx.home !== '') {
184      return ctx.home
185    }
186
187    if (name === 'PWD' && cwd !== null) {
188      return cwd
189    }
190
191    return match
192  })
193}
194
195/** Where an absolute path sits relative to the project, home and temp folders. */
196export const placeOfPath = (path: string, ctx: Context): Place => {
197  if (path === '/') {
198    return { kind: 'system' }
199  }
200
201  if (isWithin(ctx.home, path)) {
202    return { kind: 'home', path }
203  }
204
205  if (path === ctx.root) {
206    return { kind: 'project', path }
207  }
208
209  if (isWithin(ctx.root, path)) {
210    return { kind: 'contains-project', path }
211  }
212
213  if (isBelow(path, ctx.root)) {
214    return { kind: 'inside', path }
215  }
216
217  if (ctx.tempDirs.some(dir => isBelow(path, dir))) {
218    return { kind: 'temp', path }
219  }
220
221  return { kind: 'outside', path }
222}
223
224/**
225 * Where a delete target lands. A last component of `*`, `.*` or a variable
226 * covers its whole folder, so `rm -rf *` at the root is the root.
227 */
228export const placeOf = (word: Word, cwd: string | null, ctx: Context): Place => {
229  const text = expand(word, cwd, ctx)
230
231  if (word.isTildeLive && text.startsWith('~')) {
232    return { kind: 'unknown', text: word.text }
233  }
234
235  if (text.startsWith('$') || text.startsWith('`')) {
236    return { kind: 'unknown', text: word.text }
237  }
238
239  if (!text.startsWith('/') && cwd === null) {
240    return { kind: 'unknown', text: word.text }
241  }
242
243  const absolute = text.startsWith('/') ? text : `${cwd ?? '/'}/${text}`
244  const parts = absolute.split('/')
245  const wild = parts.findIndex(part => GLOB.test(part) || part.includes('$'))
246
247  if (wild === -1) {
248    return placeOfPath(normalize(absolute), ctx)
249  }
250
251  const folder = normalize(parts.slice(0, wild).join('/') || '/')
252  const part = parts[wild] ?? ''
253  const coversFolder = part === '*' || part === '.*' || part.startsWith('$') || /^\{.*\}$/.test(part)
254
255  return placeOfPath(coversFolder ? folder : join(folder, 'x'), ctx)
256}
257
258const describePlace = (place: Place, ctx: Context): string => {
259  switch (place.kind) {
260    case 'system':
261      return 'the target is / (the whole filesystem).'
262    case 'home':
263      return place.path === ctx.home
264        ? 'the target is your home folder.'
265        : `the target ${place.path} contains your home folder.`
266    case 'project':
267      return `the target is the project root (${tildify(ctx.root, ctx.home)}). Delete specific folders instead, for example rm -rf dist.`
268    case 'contains-project':
269      return `the target ${tildify(place.path, ctx.home)} contains the whole project.`
270    case 'outside':
271    case 'inside':
272    case 'temp':
273      return `the target ${tildify(place.path, ctx.home)} is outside the project (${tildify(ctx.root, ctx.home)}); recursive or forced deletes are allowed only inside the project and temp folders.`
274    case 'unknown':
275      return `the target ${place.text} depends on a variable, a substitution or another user's home that shell-guard cannot resolve. Repeat the command with a literal path.`
276  }
277}
278
279const addSnapshot = (walk: Walk, dir: string | null, ctx: Context, words: readonly string[]): void => {
280  const where = dir ?? ctx.cwd
281
282  if (!walk.snapshotDirs.includes(where)) {
283    walk.snapshotDirs.push(where)
284  }
285
286  if (walk.summary === '') {
287    walk.summary = summarize(words.join(' '))
288  }
289}
290
291const rm = (args: readonly Word[], walk: Walk, ctx: Context, words: readonly string[], isFed: boolean): Verdict | null => {
292  const flags: string[] = []
293  const targets: Word[] = []
294  let isLiteral = false
295
296  for (const arg of args) {
297    if (!isLiteral && arg.text === '--') {
298      isLiteral = true
299    } else if (!isLiteral && isFlag(arg.text)) {
300      flags.push(arg.text)
301    } else {
302      targets.push(arg)
303    }
304  }
305
306  if (flags.includes('--no-preserve-root')) {
307    return deny('--no-preserve-root removes the last safety net against deleting /.', words)
308  }
309
310  const isRecursive = hasFlag(flags, 'rR', ['--recursive'])
311  const isForced = hasFlag(flags, 'f', ['--force'])
312
313  if (!isRecursive && !isForced) {
314    return null
315  }
316
317  if (targets.length === 0 && isFed && isRecursive) {
318    addSnapshot(walk, walk.cwd, ctx, words)
319  }
320
321  for (const target of targets) {
322    const place = placeOf(target, walk.cwd, ctx)
323
324    if (place.kind === 'temp') {
325      continue
326    }
327
328    if (place.kind === 'inside') {
329      if (isRecursive) {
330        addSnapshot(walk, walk.cwd, ctx, words)
331      }
332
333      continue
334    }
335
336    return deny(describePlace(place, ctx), words)
337  }
338
339  return null
340}
341
342const FIND_FILTERS = new Set([
343  '-name', '-iname', '-path', '-ipath', '-wholename', '-iwholename', '-regex', '-iregex', '-type', '-mtime', '-mmin',
344  '-atime', '-amin', '-ctime', '-cmin', '-newer', '-size', '-user', '-group', '-empty', '-perm', '-lname', '-inum', '-links',
345])
346const FIND_EXEC = new Set(['-exec', '-execdir', '-ok', '-okdir'])
347const FIND_DELETERS = new Set(['rm', 'shred', 'unlink'])
348
349const find = (args: readonly Word[], walk: Walk, ctx: Context, words: readonly string[]): Verdict | null => {
350  const texts = args.map(arg => arg.text)
351  const firstExpression = texts.findIndex(text => text.startsWith('-') || text === '(' || text === '!')
352  const isDestructive =
353    texts.includes('-delete') ||
354    texts.some((text, i) => FIND_EXEC.has(text) && FIND_DELETERS.has(basename(texts[i + 1] ?? '')))
355
356  if (!isDestructive) {
357    return null
358  }
359
360  const starts = (firstExpression === -1 ? args : args.slice(0, firstExpression)).filter(arg => !['-H', '-L', '-P'].includes(arg.text))
361  const hasFilter = texts.some(text => FIND_FILTERS.has(text))
362
363  for (const start of starts.length === 0 ? [{ text: '.', isTildeLive: false }] : starts) {
364    const place = placeOf(start, walk.cwd, ctx)
365
366    if (place.kind === 'temp') {
367      continue
368    }
369
370    if (place.kind === 'inside' || (place.kind === 'project' && hasFilter)) {
371      addSnapshot(walk, walk.cwd, ctx, words)
372      continue
373    }
374
375    return deny(`find with -delete: ${describePlace(place, ctx)}`, words)
376  }
377
378  return null
379}
380
381/** `chmod -R` / `chown -R` outside the project changes system or other people's files. */
382const recursiveOwnership = (cmd: string, args: readonly Word[], walk: Walk, ctx: Context, words: readonly string[]): Verdict | null => {
383  const texts = args.map(arg => arg.text)
384
385  if (!hasFlag(texts, 'R', ['--recursive'])) {
386    return null
387  }
388
389  const operands = args.filter(arg => !isFlag(arg.text))
390  const hasReference = texts.some(text => text.startsWith('--reference'))
391  const targets = hasReference ? operands : operands.slice(1)
392
393  for (const target of targets) {
394    const place = placeOf(target, walk.cwd, ctx)
395
396    if (place.kind === 'inside' || place.kind === 'project' || place.kind === 'temp') {
397      continue
398    }
399
400    return deny(`${cmd} -R ${describePlace(place, ctx)}`, words)
401  }
402
403  return null
404}
405
406type Refspec = { isForced: boolean; isDelete: boolean; branch: string | null }
407
408const refspecOf = (spec: string, current: string | null): Refspec => {
409  const isForced = spec.startsWith('+')
410  const body = isForced ? spec.slice(1) : spec
411  const colon = body.indexOf(':')
412  const src = colon === -1 ? body : body.slice(0, colon)
413  const dst = colon === -1 ? body : body.slice(colon + 1)
414  const name = (dst === 'HEAD' ? current : dst)?.replace(/^refs\/heads\//, '') ?? null
415
416  return { isForced, isDelete: colon !== -1 && src === '', branch: name }
417}
418
419const PUSH_VALUE_OPTIONS = new Set(['-o', '--push-option', '--repo', '--receive-pack', '--exec'])
420
421const gitPush = (args: readonly string[], branch: string | null, ctx: Context, words: readonly string[]): Verdict | null => {
422  if (args.includes('--mirror')) {
423    return deny('git push --mirror overwrites and deletes every ref on the remote.', words)
424  }
425
426  const isForced = hasFlag(args, 'f', ['--force', '--force-with-lease', '--force-if-includes'])
427  const isDelete = hasFlag(args, 'd', ['--delete'])
428  const isAll = args.includes('--all') || args.includes('--branches')
429  const specs = positionals(args, PUSH_VALUE_OPTIONS).slice(1).map(spec => refspecOf(spec, branch))
430  const pushed = specs.length === 0 && !isAll ? [{ isForced: false, isDelete: false, branch }] : specs
431  const isProtected = (name: string | null): boolean => name !== null && ctx.protectedBranches.includes(name)
432  const listed = ctx.protectedBranches.join(', ')
433
434  if (isForced && isAll) {
435    return deny(`a forced push of all branches includes the protected ones (${listed}). Force-push one feature branch by name.`, words)
436  }
437
438  for (const spec of pushed) {
439    const forced = isForced || spec.isForced
440
441    if ((isDelete || spec.isDelete) && isProtected(spec.branch)) {
442      return deny(`deleting the protected branch ${spec.branch ?? ''} on the remote.`, words)
443    }
444
445    if (forced && spec.branch === null) {
446      return deny('a forced push whose branch shell-guard cannot tell. Name the branch: git push --force-with-lease origin <feature-branch>.', words)
447    }
448
449    if (forced && isProtected(spec.branch)) {
450      return deny(`force-pushing to the protected branch ${spec.branch ?? ''} rewrites shared history. Push a feature branch, or push without --force.`, words)
451    }
452  }
453
454  return null
455}
456
457const GIT_VALUE_OPTIONS = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--exec-path', '--config-env'])
458
459const git = (args: readonly Word[], walk: Walk, ctx: Context, words: readonly string[]): Verdict | null => {
460  let dir = walk.cwd
461  let i = 0
462
463  while (i < args.length && isFlag(args[i]?.text ?? '')) {
464    const option = args[i]?.text ?? ''
465
466    if (option === '-C') {
467      const target = args[i + 1]
468      dir = target === undefined || dir === null ? null : join(dir, expand(target, dir, ctx))
469    }
470
471    i += GIT_VALUE_OPTIONS.has(option) ? 2 : 1
472  }
473
474  const sub = args[i]?.text
475  const rest = args.slice(i + 1).map(arg => arg.text)
476  const snapshot = (): null => {
477    addSnapshot(walk, dir, ctx, words)
478
479    return null
480  }
481
482  switch (sub) {
483    case 'push':
484      return gitPush(rest, dir === ctx.cwd ? ctx.branch : null, ctx, words)
485    case 'branch': {
486      const isForceDelete = hasFlag(rest, 'D', []) || (hasFlag(rest, 'd', ['--delete']) && hasFlag(rest, 'f', ['--force']))
487      const victim = positionals(rest).find(name => ctx.protectedBranches.includes(name))
488
489      return isForceDelete && victim !== undefined ? deny(`git branch -D deletes the protected branch ${victim}.`, words) : null
490    }
491    case 'reset':
492      return rest.includes('--hard') ? snapshot() : null
493    case 'checkout':
494      return rest.includes('--') || rest.includes('.') || hasFlag(rest, 'f', ['--force']) ? snapshot() : null
495    case 'switch':
496      return hasFlag(rest, 'f', ['--force', '--discard-changes']) ? snapshot() : null
497    case 'restore': {
498      const isStagedOnly = hasFlag(rest, 'S', ['--staged']) && !hasFlag(rest, 'W', ['--worktree'])
499
500      return isStagedOnly ? null : snapshot()
501    }
502    case 'clean':
503      return hasFlag(rest, 'f', ['--force']) && !hasFlag(rest, 'n', ['--dry-run']) ? snapshot() : null
504    case 'stash':
505      if (rest[0] === 'drop' || rest[0] === 'clear') {
506        walk.withStashes = true
507
508        return snapshot()
509      }
510
511      return null
512    default:
513      return null
514  }
515}
516
517const kubectl = (args: readonly string[], words: readonly string[]): Verdict | null => {
518  const at = args.indexOf('delete')
519
520  if (at === -1) {
521    return null
522  }
523
524  const rest = args.slice(at + 1)
525
526  if (rest.some(arg => arg === '--all' || arg === '--all-namespaces' || arg === '-A')) {
527    return deny('kubectl delete --all removes every resource of that kind.', words)
528  }
529
530  const kinds = positionals(rest, new Set(['-n', '--namespace', '--context', '-l', '--selector', '-f', '--filename', '-o', '--output', '--field-selector']))
531  const hitsNamespace = kinds.some((kind, i) =>
532    kind.split(',').some(part => {
533      const type = part.split('/')[0] ?? ''
534
535      return NAMESPACE_KINDS.has(type) && (i === 0 || part.includes('/'))
536    }),
537  )
538
539  return hitsNamespace ? deny('kubectl delete namespace removes everything in it.', words) : null
540}
541
542const DOCKER_VALUE_OPTIONS = new Set(['--context', '-H', '--host', '-c', '--config', '-f', '--file', '-p', '--project-name', '--log-level', '--env-file', '--profile'])
543
544const docker = (cmd: string, args: readonly string[], words: readonly string[]): Verdict | null => {
545  const subs = positionals(args, DOCKER_VALUE_OPTIONS)
546  const [first, second] = cmd === 'docker-compose' ? ['compose', subs[0]] : [subs[0], subs[1]]
547
548  if (first === 'system' && second === 'prune' && args.includes('--volumes')) {
549    return deny('docker system prune --volumes deletes every unused volume, and volumes hold databases.', words)
550  }
551
552  if (first === 'volume' && (second === 'rm' || second === 'remove' || second === 'prune')) {
553    return deny(`docker volume ${second} deletes volume data, which no snapshot can bring back.`, words)
554  }
555
556  const isCompose = first === 'compose' && subs.includes('down')
557
558  if (isCompose && hasFlag(args, 'v', ['--volumes'])) {
559    return deny('docker compose down -v deletes the project\'s volumes. Use docker compose down without -v.', words)
560  }
561
562  return null
563}
564
565const SQL_VALUE_FLAGS: Readonly<Record<string, readonly string[]>> = {
566  psql: ['-c', '--command'],
567  mysql: ['-e', '--execute'],
568  mariadb: ['-e', '--execute'],
569  sqlite3: ['-cmd'],
570}
571
572const sqlTexts = (cmd: string, args: readonly string[], stdin: string | undefined): string[] => {
573  const flags = SQL_VALUE_FLAGS[cmd] ?? []
574  const texts: string[] = stdin === undefined ? [] : [stdin]
575
576  args.forEach((arg, i) => {
577    for (const flag of flags) {
578      if (arg === flag) {
579        texts.push(args[i + 1] ?? '')
580      } else if (arg.startsWith(`${flag}=`)) {
581        texts.push(arg.slice(flag.length + 1))
582      } else if (flag.length === 2 && arg.startsWith(flag) && !arg.startsWith('--')) {
583        texts.push(arg.slice(2))
584      }
585    }
586  })
587
588  if (cmd === 'sqlite3') {
589    texts.push(...positionals(args, new Set(['-cmd', '-separator', '-newline', '-nullvalue', '-init'])).slice(1))
590  }
591
592  return texts
593}
594
595/** The command `xargs` runs: its words after xargs' own options. */
596const xargsCommand = (args: readonly Word[]): Word[] => {
597  let i = 0
598
599  while (i < args.length && isFlag(args[i]?.text ?? '')) {
600    i += XARGS_VALUE_OPTIONS.has(args[i]?.text ?? '') ? 2 : 1
601  }
602
603  return args.slice(i)
604}
605
606/** Classifies one simple command; null lets the walk go on. */
607const segmentVerdict = (segment: Segment, walk: Walk, ctx: Context, isFed: boolean): Verdict | null => {
608  for (const target of segment.redirects) {
609    if (target.startsWith('/dev/') && !SAFE_DEVICE.test(target)) {
610      return deny(`writing to the device ${target} overwrites the disk.`, segment.words.map(word => word.text))
611    }
612  }
613
614  const own = stripPrefixes(segment.words)
615  const words = own.map(word => word.text)
616  const cmd = basename(words[0] ?? '')
617  const args = own.slice(1)
618  const texts = words.slice(1)
619
620  for (const pattern of ctx.extraDeny) {
621    if (words.length > 0 && pattern.test(words.join(' '))) {
622      return deny(`it matches the extraDeny rule ${pattern.source}.`, words)
623    }
624  }
625
626  switch (cmd) {
627    case '':
628      return null
629    case 'cd':
630    case 'pushd': {
631      const target = args.find(arg => !isFlag(arg.text))
632
633      if (target === undefined) {
634        walk.cwd = ctx.home
635      } else {
636        const text = expand(target, walk.cwd, ctx)
637        walk.cwd = text.includes('$') || text === '-' || (walk.cwd === null && !text.startsWith('/')) ? null : join(walk.cwd ?? '/', text)
638      }
639
640      return null
641    }
642    case 'rm':
643      return rm(args, walk, ctx, words, isFed)
644    case 'find':
645      return find(args, walk, ctx, words)
646    case 'xargs': {
647      const inner = xargsCommand(args)
648
649      return inner.length === 0 ? null : segmentVerdict({ words: inner, redirects: [] }, walk, ctx, true)
650    }
651    case 'git':
652      return git(args, walk, ctx, words)
653    case 'eval':
654      return nested(texts.join(' '), walk, ctx)
655    case 'dd':
656      return texts.some(arg => arg.startsWith('of=/dev/') && !SAFE_DEVICE.test(arg.slice(3)))
657        ? deny('dd onto a device overwrites the disk.', words)
658        : null
659    case 'shred':
660      return deny('shred destroys files beyond recovery.', words)
661    case 'wipefs':
662    case 'newfs':
663      return deny(`${cmd} erases a filesystem.`, words)
664    case 'diskutil':
665      return DISKUTIL_ERASE.test(texts[0] ?? '') ? deny(`diskutil ${texts[0] ?? ''} erases a disk.`, words) : null
666    case 'chmod':
667    case 'chown':
668    case 'chgrp':
669      return recursiveOwnership(cmd, args, walk, ctx, words)
670    case 'terraform':
671    case 'tofu':
672    case 'terragrunt': {
673      const sub = texts.find(arg => !isFlag(arg))
674      const isDestroy = sub === 'destroy' || (sub === 'apply' && texts.includes('-destroy'))
675
676      return isDestroy ? deny(`${cmd} destroy deletes real infrastructure.`, words) : null
677    }
678    case 'kubectl':
679    case 'oc':
680      return kubectl(texts, words)
681    case 'docker':
682    case 'podman':
683    case 'docker-compose':
684      return docker(cmd, texts, words)
685    case 'redis-cli':
686      return texts.some(arg => /^flush(all|db)$/i.test(arg)) ? deny('FLUSHALL / FLUSHDB empties the Redis database.', words) : null
687    default:
688      break
689  }
690
691  if (cmd.startsWith('mkfs')) {
692    return deny(`${cmd} formats a disk.`, words)
693  }
694
695  if (SQL_CLIENTS.has(cmd)) {
696    const hit = sqlTexts(cmd, texts, segment.stdin).find(sql => DESTRUCTIVE_SQL.test(sql))
697
698    return hit === undefined ? null : deny(`it runs ${summarize(hit)} against a database; DROP and TRUNCATE cannot be undone.`, words)
699  }
700
701  if (SHELLS.has(cmd)) {
702    const at = texts.findIndex(arg => /^-[a-zA-Z]*c[a-zA-Z]*$/.test(arg))
703
704    if (at !== -1) {
705      return nested(texts[at + 1] ?? '', walk, ctx)
706    }
707
708    if (segment.stdin !== undefined && positionals(texts).length === 0) {
709      return nested(segment.stdin, walk, ctx)
710    }
711  }
712
713  return null
714}
715
716/** Classifies a script nested in this one (`bash -c`, `eval`, `$(...)`). */
717const nested = (script: string, walk: Walk, ctx: Context): Verdict | null => {
718  if (walk.depth >= MAX_DEPTH) {
719    return deny('the command nests shells too deeply to check.', [script])
720  }
721
722  walk.depth += 1
723  const verdict = walkScript(script, walk, ctx)
724  walk.depth -= 1
725
726  return verdict
727}
728
729const walkScript = (script: string, walk: Walk, ctx: Context): Verdict | null => {
730  const { segments, subs } = parse(script)
731
732  for (const sub of subs) {
733    const verdict = nested(sub, walk, ctx)
734
735    if (verdict !== null) {
736      return verdict
737    }
738  }
739
740  for (const segment of segments) {
741    const verdict = segmentVerdict(segment, walk, ctx, false)
742
743    if (verdict !== null) {
744      return verdict
745    }
746  }
747
748  return null
749}
750
751/**
752 * The guard's decision for one Bash command: refuse it, snapshot the repo
753 * first, or let it run.
754 */
755export const classify = (command: string, ctx: Context): Verdict => {
756  const walk: Walk = { cwd: ctx.cwd, depth: 0, snapshotDirs: [], withStashes: false, summary: '' }
757  const verdict = walkScript(command, walk, ctx)
758
759  if (verdict !== null) {
760    return verdict
761  }
762
763  if (walk.snapshotDirs.length > 0) {
764    return { kind: 'snapshot', dirs: walk.snapshotDirs, withStashes: walk.withStashes, summary: walk.summary }
765  }
766
767  return ALLOW
768}
769
770/** Splits the protected-branches setting: comma or space separated. */
771export const parseBranches = (text: string): string[] =>
772  text
773    .split(/[,\s]+/)
774    .map(name => name.trim())
775    .filter(name => name !== '')
776
777export type ParsedPatterns = { patterns: RegExp[]; invalid: string[] }
778
779/** One regular expression per line; a line that does not compile is reported, not used. */
780export const parsePatterns = (text: string): ParsedPatterns => {
781  const patterns: RegExp[] = []
782  const invalid: string[] = []
783
784  for (const line of text.split('\n').map(item => item.trim()).filter(item => item !== '')) {
785    try {
786      patterns.push(new RegExp(line))
787    } catch {
788      invalid.push(line)
789    }
790  }
791
792  return { patterns, invalid }
793}
794
795export const SNAPSHOT_PREFIX = 'refs/shell-guard'
796/** Snapshots kept per repo; older ones are pruned so the refs do not pile up. */
797export const MAX_SNAPSHOTS = 100
798const FIELD = '\u001f'
799const RECORD = '\u001e'
800const STASH_LINE = 'stashes: '
801const TIME_LENGTH = 16
802
803/** `refs/shell-guard/20261006-120000-123`: sortable, unique per millisecond, legal in a ref name. */
804export const snapshotRef = (now: number): string => {
805  const iso = new Date(now).toISOString()
806  const stamp = `${iso.slice(0, 10).replace(/-/g, '')}-${iso.slice(11, 19).replace(/:/g, '')}-${iso.slice(20, 23)}`
807
808  return `${SNAPSHOT_PREFIX}/${stamp}`
809}
810
811/** The snapshot commit's message: the command as its subject, kept stashes listed in the body. */
812export const snapshotMessage = (command: string, stashes: readonly string[]): string => {
813  const lines = [`shell-guard: ${summarize(command)}`, '', command.trim()]
814
815  if (stashes.length > 0) {
816    lines.push('', `${STASH_LINE}${stashes.join(' ')}`)
817  }
818
819  return lines.join('\n')
820}
821
822/** The `git for-each-ref` format `parseSnapshots` reads. */
823export const SNAPSHOT_FORMAT = `%(refname)%1f%(creatordate:iso)%1f%(parent)%1f%(contents)%1e`
824
825export type Snapshot = { ref: string; time: string; command: string; hasHead: boolean; stashes: string[] }
826
827export const parseSnapshots = (stdout: string): Snapshot[] =>
828  stdout
829    .split(RECORD)
830    .map(record => record.replace(/^\n/, ''))
831    .filter(record => record.trim() !== '')
832    .map(record => {
833      const [ref = '', time = '', parents = '', contents = ''] = record.split(FIELD)
834      const [subject = ''] = contents.split('\n')
835      const stashLine = contents.split('\n').find(line => line.startsWith(STASH_LINE))
836      const stashes = stashLine === undefined ? [] : stashLine.slice(STASH_LINE.length).split(' ').filter(sha => sha !== '')
837      const parentCount = parents.split(' ').filter(sha => sha !== '').length
838
839      return {
840        ref,
841        time: time.slice(0, TIME_LENGTH),
842        command: subject.replace(/^shell-guard: /, ''),
843        hasHead: parentCount > stashes.length,
844        stashes,
845      }
846    })
847
848/** What `/shell-guard` prints: each snapshot with the exact commands that bring it back. */
849export const formatSnapshots = (snapshots: readonly Snapshot[], repo: string, isOn: boolean): string => {
850  const state = isOn ? 'on' : 'off for this session (/shell-guard on turns it back on)'
851
852  if (snapshots.length === 0) {
853    return `shell-guard is ${state}. No snapshots in ${repo} yet.`
854  }
855
856  const lines = [
857    `shell-guard is ${state}. ${snapshots.length} snapshot${snapshots.length === 1 ? '' : 's'} in ${repo}, newest first.`,
858    'Restoring overwrites the same files in the working tree; files created since stay.',
859  ]
860
861  snapshots.forEach((snapshot, i) => {
862    const see = snapshot.hasHead ? `git diff --stat ${snapshot.ref}^1 ${snapshot.ref}` : `git show --stat ${snapshot.ref}`
863
864    lines.push(
865      '',
866      `${i + 1}. ${snapshot.time}  ${snapshot.command}`,
867      `   see:      ${see}`,
868      `   restore:  git checkout ${snapshot.ref} -- .`,
869      `   one file: git checkout ${snapshot.ref} -- <path>`,
870      ...snapshot.stashes.map(sha => `   stash:    git stash store -m "restored by shell-guard" ${sha}`),
871    )
872  })
873
874  return lines.join('\n')
875}
876
hooks/paths.ts 46 lines
1/**
2 * Path arithmetic without Node: hooks modules have no `path` module.
3 * Every path here is absolute and POSIX.
4 */
5
6/** Resolves `.` and `..` and repeated slashes; `..` above `/` stays at `/`. */
7export const normalize = (path: string): string => {
8  const parts: string[] = []
9
10  for (const part of path.split('/')) {
11    if (part === '' || part === '.') {
12      continue
13    }
14
15    if (part === '..') {
16      parts.pop()
17      continue
18    }
19
20    parts.push(part)
21  }
22
23  return `/${parts.join('/')}`
24}
25
26export const join = (base: string, rest: string): string =>
27  rest.startsWith('/') ? normalize(rest) : normalize(`${base}/${rest}`)
28
29/** True when `inner` is `outer` or lies beneath it. */
30export const isWithin = (inner: string, outer: string): boolean =>
31  outer === '/' || inner === outer || inner.startsWith(`${outer}/`)
32
33/** True when `inner` lies strictly beneath `outer`. */
34export const isBelow = (inner: string, outer: string): boolean => inner !== outer && isWithin(inner, outer)
35
36export const basename = (path: string): string => {
37  const trimmed = path.replace(/\/+$/, '')
38  const slash = trimmed.lastIndexOf('/')
39
40  return slash === -1 ? trimmed : trimmed.slice(slash + 1)
41}
42
43/** Shows a path under the home folder as `~/...`, as people write it. */
44export const tildify = (path: string, home: string): string =>
45  home !== '/' && isWithin(path, home) ? `~${path.slice(home.length)}` : path
46
hooks/shell.ts 345 lines
1/**
2 * A small shell lexer: enough of POSIX sh to split a command line into the
3 * simple commands it runs, with quotes respected. It does not run anything
4 * and does not expand anything; expansions stay in the word text for the
5 * classifier to judge.
6 */
7
8export type Word = {
9  /** The word with quotes removed; `$VAR`, globs and `~` kept as written. */
10  text: string
11  /** True when the word begins with an unquoted `~`, so the shell expands it. */
12  isTildeLive: boolean
13}
14
15export type Segment = {
16  words: Word[]
17  /** Targets of `>`, `>>`, `&>` and the like. */
18  redirects: string[]
19  /** A heredoc's body or a here-string, what the command reads on stdin. */
20  stdin?: string
21}
22
23export type Parsed = {
24  segments: Segment[]
25  /** Inner text of `$(...)`, backticks and `<(...)`: commands of their own. */
26  subs: string[]
27}
28
29const BREAKS = new Set([';', '&', '|', '\n', '(', ')'])
30const WORD_END = new Set([' ', '\t', ';', '&', '|', '\n', '(', ')', '<', '>'])
31const DOUBLE_QUOTE_ESCAPES = new Set(['$', '`', '"', '\\', '\n'])
32const FD_PREFIX = /^\d+$/
33
34type Heredoc = { delimiter: string; stripsTabs: boolean; segment: Segment }
35
36/** Index just past the `)` closing the paren opened right before `start`. */
37const closeParen = (text: string, start: number): number => {
38  let depth = 1
39  let i = start
40  let quote: '"' | "'" | null = null
41
42  while (i < text.length) {
43    const ch = text[i]
44
45    if (quote === "'") {
46      if (ch === "'") {
47        quote = null
48      }
49    } else if (ch === '\\') {
50      i += 1
51    } else if (quote === '"') {
52      if (ch === '"') {
53        quote = null
54      }
55    } else if (ch === "'" || ch === '"') {
56      quote = ch
57    } else if (ch === '(') {
58      depth += 1
59    } else if (ch === ')') {
60      depth -= 1
61
62      if (depth === 0) {
63        return i + 1
64      }
65    }
66
67    i += 1
68  }
69
70  return text.length
71}
72
73/** Index just past the backtick closing the one right before `start`. */
74const closeBacktick = (text: string, start: number): number => {
75  let i = start
76
77  while (i < text.length) {
78    if (text[i] === '\\') {
79      i += 2
80      continue
81    }
82
83    if (text[i] === '`') {
84      return i + 1
85    }
86
87    i += 1
88  }
89
90  return text.length
91}
92
93/** Reads the heredoc bodies queued on a line, starting after its newline. */
94const readHeredocs = (text: string, start: number, pending: readonly Heredoc[]): number => {
95  let i = start
96
97  for (const doc of pending) {
98    const lines: string[] = []
99
100    while (i < text.length) {
101      const end = text.indexOf('\n', i)
102      const lineEnd = end === -1 ? text.length : end
103      const raw = text.slice(i, lineEnd)
104      const line = doc.stripsTabs ? raw.replace(/^\t+/, '') : raw
105      i = end === -1 ? text.length : end + 1
106
107      if (line === doc.delimiter) {
108        break
109      }
110
111      lines.push(line)
112    }
113
114    const body = lines.join('\n')
115    doc.segment.stdin = doc.segment.stdin === undefined ? body : `${doc.segment.stdin}\n${body}`
116  }
117
118  return i
119}
120
121/**
122 * Splits a command line into simple commands (on `&&`, `||`, `;`, `|`, `&`,
123 * newlines and parentheses) and collects the command substitutions inside it.
124 */
125export const parse = (text: string): Parsed => {
126  const segments: Segment[] = []
127  const subs: string[] = []
128  let segment: Segment = { words: [], redirects: [] }
129  let word = ''
130  let hasWord = false
131  let isTildeLive = false
132  let isRedirectTarget = false
133  let isHereString = false
134  let heredocNext: { stripsTabs: boolean } | null = null
135  let pending: Heredoc[] = []
136  let i = 0
137
138  const endWord = (): void => {
139    if (!hasWord) {
140      return
141    }
142
143    if (heredocNext !== null) {
144      pending.push({ delimiter: word, stripsTabs: heredocNext.stripsTabs, segment })
145      heredocNext = null
146    } else if (isHereString) {
147      segment.stdin = word
148      isHereString = false
149    } else if (isRedirectTarget) {
150      segment.redirects.push(word)
151      isRedirectTarget = false
152    } else {
153      segment.words.push({ text: word, isTildeLive })
154    }
155
156    word = ''
157    hasWord = false
158    isTildeLive = false
159  }
160
161  const endSegment = (): void => {
162    endWord()
163
164    if (segment.words.length > 0 || segment.redirects.length > 0 || segment.stdin !== undefined) {
165      segments.push(segment)
166    }
167
168    segment = { words: [], redirects: [] }
169  }
170
171  const substitution = (from: number, to: number, closeLength: number): void => {
172    const inner = text.slice(from, to - closeLength)
173    subs.push(inner)
174    word += inner.trim() === 'pwd' ? '$PWD' : '$(…)'
175    hasWord = true
176  }
177
178  while (i < text.length) {
179    const ch = text[i] ?? ''
180
181    if (ch === '\\') {
182      const nextCh = text[i + 1]
183
184      if (nextCh === '\n') {
185        i += 2
186        continue
187      }
188
189      word += nextCh ?? ''
190      hasWord = true
191      i += 2
192      continue
193    }
194
195    if (ch === "'") {
196      const end = text.indexOf("'", i + 1)
197      const close = end === -1 ? text.length : end
198      word += text.slice(i + 1, close)
199      hasWord = true
200      i = close + 1
201      continue
202    }
203
204    if (ch === '"') {
205      i += 1
206
207      while (i < text.length && text[i] !== '"') {
208        const inner = text[i] ?? ''
209
210        if (inner === '\\' && DOUBLE_QUOTE_ESCAPES.has(text[i + 1] ?? '')) {
211          word += text[i + 1] === '\n' ? '' : text[i + 1]
212          i += 2
213          continue
214        }
215
216        if (inner === '$' && text[i + 1] === '(') {
217          const end = closeParen(text, i + 2)
218          substitution(i + 2, end, 1)
219          i = end
220          continue
221        }
222
223        if (inner === '`') {
224          const end = closeBacktick(text, i + 1)
225          substitution(i + 1, end, 1)
226          i = end
227          continue
228        }
229
230        word += inner
231        i += 1
232      }
233
234      hasWord = true
235      i += 1
236      continue
237    }
238
239    if (ch === '$' && text[i + 1] === "'") {
240      const end = text.indexOf("'", i + 2)
241      const close = end === -1 ? text.length : end
242      word += text.slice(i + 2, close)
243      hasWord = true
244      i = close + 1
245      continue
246    }
247
248    if (ch === '$' && text[i + 1] === '(') {
249      const end = closeParen(text, i + 2)
250      substitution(i + 2, end, 1)
251      i = end
252      continue
253    }
254
255    if (ch === '`') {
256      const end = closeBacktick(text, i + 1)
257      substitution(i + 1, end, 1)
258      i = end
259      continue
260    }
261
262    if ((ch === '<' || ch === '>') && text[i + 1] === '(') {
263      endWord()
264      const end = closeParen(text, i + 2)
265      subs.push(text.slice(i + 2, end - 1))
266      i = end
267      continue
268    }
269
270    if (ch === '#' && !hasWord) {
271      const end = text.indexOf('\n', i)
272      i = end === -1 ? text.length : end
273      continue
274    }
275
276    if (ch === '<' && text[i + 1] === '<') {
277      if (FD_PREFIX.test(word)) {
278        word = ''
279        hasWord = false
280      }
281
282      endWord()
283
284      if (text[i + 2] === '<') {
285        isHereString = true
286        i += 3
287      } else {
288        const stripsTabs = text[i + 2] === '-'
289        heredocNext = { stripsTabs }
290        i += stripsTabs ? 3 : 2
291      }
292
293      continue
294    }
295
296    if (ch === '<' || ch === '>' || (ch === '&' && text[i + 1] === '>')) {
297      if (FD_PREFIX.test(word)) {
298        word = ''
299        hasWord = false
300      }
301
302      endWord()
303      i += 1
304
305      while (i < text.length && ['>', '&', '|', '<'].includes(text[i] ?? '')) {
306        i += 1
307      }
308
309      isRedirectTarget = true
310      continue
311    }
312
313    if (BREAKS.has(ch)) {
314      endSegment()
315
316      if (ch === '\n' && pending.length > 0) {
317        i = readHeredocs(text, i + 1, pending)
318        pending = []
319        continue
320      }
321
322      i += 1
323      continue
324    }
325
326    if (WORD_END.has(ch)) {
327      endWord()
328      i += 1
329      continue
330    }
331
332    if (ch === '~' && !hasWord) {
333      isTildeLive = true
334    }
335
336    word += ch
337    hasWord = true
338    i += 1
339  }
340
341  endSegment()
342
343  return { segments, subs }
344}
345
types/index.d.ts 12 lines
1/** What the guard does with a command it judges dangerous (the `mode` setting). */
2export type ShellGuardMode = 'deny' | 'ask'
3
4declare module 'claude-code' {
5  interface PluginState {
6    'shell-guard': {
7      /** True after `/shell-guard off`, until `/shell-guard on` or the session ends. */
8      isOff: boolean
9    }
10  }
11}
12