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

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>
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:
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.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.| Command | What happens |
|---|---|
rm -r/-f of /, ~, $HOME, /*, the project root, * or . at the root, anything outside the project | Refused |
git push --force / -f / --force-with-lease / +ref to a protected branch, git push --mirror, deleting a protected branch | Refused |
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 project | Refused |
terraform destroy, kubectl delete of namespaces or --all, docker system prune --volumes, docker volume rm/prune, docker compose down -v | Refused |
DROP DATABASE, DROP SCHEMA, DROP TABLE, TRUNCATE through psql -c, mysql -e, sqlite3, or a heredoc; redis-cli FLUSHALL | Refused |
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 project | Snapshot, 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/x | Run |
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/….
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.
Change them in /config under the plugin, or in settings.json under pluginConfigs:
| Field | Default | Meaning |
|---|---|---|
mode | deny | deny refuses a dangerous command. ask sends it to the permission dialog with the reason instead |
protectedBranches | main,master | Branches that may not be force-pushed, deleted on the remote or deleted with git branch -D |
snapshots | true | Snapshot uncommitted work before commands that destroy it |
extraDeny | empty | Regular 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.
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.$.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.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..git folders and sends nothing anywhere.What it cannot do:
./cleanup.sh, make clean, python -c "shutil.rmtree(...)" and ssh host 'rm -rf /' run unchecked.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..gitignore: git clean -fdx deletes ignored files (an .env, local databases) that no snapshot holds.echo "DROP TABLE x" | psql), only -c/-e, sqlite3 arguments and heredocs.DROP TABLE against a throwaway local database, rm -f of a single file outside the project, any shred.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 печатает готовые команды восстановления.
MIT
hooks/register.ts 293 lines1import { 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}
293hooks/logic.ts 876 lines1import { 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}
876hooks/paths.ts 46 lines1/**
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
46hooks/shell.ts 345 lines1/**
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}
345types/index.d.ts 12 lines1/** 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