diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 0d72d7d..e7c401b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -4,9 +4,9 @@ on:
push:
branches:
- main
+ # Every PR, whatever its base, so a PR stacked on another PR's branch is tested too.
pull_request:
- branches:
- - main
+ workflow_dispatch:
jobs:
lint:
diff --git a/README.md b/README.md
index f4751b0..947f7b5 100644
--- a/README.md
+++ b/README.md
@@ -14,8 +14,35 @@ Sees through `sudo`/`env`/`xargs`/`bash -c`/heredocs/`$(…)` and fails closed
[](https://bundlephobia.com/package/shell-command-guard)
[](./LICENSE)
+[Install](#install) · [Quick start](#quick-start) · [API](#api) · [Recipes](#recipes) · [FAQ](#faq) ·
+[Security model](#security-model-and-limits)
+
+
+
+
+Table of contents
+
+- [Why](#why)
+- [Features](#features)
+- [Install](#install)
+- [Quick start](#quick-start)
+- [API](#api)
+ - [`deniedBy`](#deniedbycmd-deny) · [`runsCommand`](#runscommandcmd-command) ·
+ [`commandSegments`](#commandsegmentscmd) · [`effectiveDeny`](#effectivedenypolicy-extra) ·
+ [`DEFAULT_DENY`](#default_deny) · [`DENY_REASONS`](#deny_reasons)
+- [Recipes](#recipes)
+- [How it works](#how-it-works)
+- [Comparison](#comparison)
+- [Security model and limits](#security-model-and-limits)
+- [FAQ](#faq)
+- [Roadmap](#roadmap)
+- [Contributing](#contributing)
+- [License](#license)
+
+
+
## Why
A coding agent, CI bot or chat-ops tool is about to run a shell command, and you want to stop `git push`. The naive check
@@ -30,6 +57,8 @@ mention the words.
| `{git,push}` | allowed | `'command word comes from an expansion'` |
| `echo origin \| xargs git push` | allowed | `'git push'` |
| `g'i't push` | allowed | `'git push'` |
+| `trap 'git push' EXIT` | allowed | `'git push'` |
+| `python3 -c "import os; os.system('git push')"` | allowed | `'git push'` |
| `grep "git push" notes.md` | allowed (`includes` would block it) | `null` — it only mentions it |
`shell-command-guard` parses the line instead. Quoted strings, comments and quoted here-document bodies are data; every
@@ -287,6 +316,68 @@ rule when a reason such as `'shell runs a script file'` blocks a command you tru
Run it in addition to OS-level isolation (containers, separate users, read-only mounts, scoped credentials), not in place
of it.
+## FAQ
+
+
+Why is rm -rf $DIR denied?
+
+`$DIR` is known only when the shell runs, and it could be `/`. `deniedBy` matches a pattern that an expansion could
+still complete, so `rm -rf $DIR` returns `'rm -rf /'` under `DEFAULT_DENY`. Spell the path out, or use `runsCommand`
+when you only need to know what certainly runs.
+
+
+
+
+How do I allow a command that a deny reason blocks?
+
+`deniedBy` returns the reason as a string, so you decide which ones to let through:
+
+```ts
+import { deniedBy, DENY_REASONS } from 'shell-command-guard'
+
+const hit = deniedBy(cmd, deny)
+const trusted = hit === DENY_REASONS.scriptFile && /^bash scripts\/[\w-]+\.sh$/.test(cmd)
+
+if (hit && !trusted) {
+ block(hit)
+}
+```
+
+
+
+
+Which shells does it understand?
+
+It parses bash syntax and also reads each line the way bash 3.2, zsh and sh would, so a command that any of them runs
+is caught. Scripts handed to `bash`, `sh`, `zsh`, `dash`, `ksh`, `fish`, `csh` and others with `-c` are read the same
+way.
+
+
+
+
+Does it run anything, or touch the filesystem?
+
+No. It is a pure function over the command string: no child processes, no file reads, no environment lookups.
+
+
+
+
+Should I use a deny-list or an allow-list?
+
+A deny-list blocks the commands you name and lets the rest through, which suits an agent that should mostly work
+unattended. When the stakes are high, allow-list instead. `commandSegments` lists every command a line runs, so you can
+check each one against the list.
+
+
+
+## Roadmap
+
+- [ ] More launchers: `setpriv`, `xcrun`, `direnv exec`, `mise exec`, `op run`
+- [ ] Resolve `rm` operands above `~` (`rm -rf ~/..`) against the real home directory
+- [ ] A small CLI for shell hooks and CI: `shell-command-guard --deny 'git push' -- "$CMD"`
+
+Found a bypass or a missing wrapper? [Open an issue](https://github.com/devmaxxx/shell-command-guard/issues).
+
## Contributing
Issues and pull requests are welcome. A bypass — a line that runs a denied command while `deniedBy` returns `null` — is
@@ -298,6 +389,7 @@ pnpm test # vitest
pnpm lint # eslint
pnpm typecheck # tsc
pnpm build # tsdown
+pnpm demo # regenerate assets/demo.svg from the real deniedBy output
```
## License
diff --git a/assets/demo.svg b/assets/demo.svg
new file mode 100644
index 0000000..2a934c1
--- /dev/null
+++ b/assets/demo.svg
@@ -0,0 +1,105 @@
+
diff --git a/package.json b/package.json
index 5cb99d3..8cda011 100644
--- a/package.json
+++ b/package.json
@@ -50,6 +50,7 @@
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"typecheck": "tsc",
+ "demo": "unrun scripts/demo.ts",
"test": "vitest run",
"prepublishOnly": "pnpm build"
},
diff --git a/scripts/demo.ts b/scripts/demo.ts
new file mode 100644
index 0000000..9b9320b
--- /dev/null
+++ b/scripts/demo.ts
@@ -0,0 +1,132 @@
+// Renders assets/demo.svg: an animated terminal that types each command and prints what `deniedBy` really returns for
+// it, so the README demo cannot drift from the library. Run with `pnpm demo`.
+import { writeFileSync } from 'node:fs'
+import process from 'node:process'
+import { DEFAULT_DENY, deniedBy } from '../src'
+
+const COMMANDS = [
+ 'grep "git push" notes.md',
+ 'env FOO=1 git push origin main',
+ 'bash -c "pnpm test && git push"',
+ 'echo origin | xargs git push',
+ 'g\'i\'t push --force',
+ 'echo \'git push\' | sh',
+ 'python3 -c "import os; os.system(\'git push\')"',
+ 'rm -r -f /',
+ 'pnpm test && git commit -m "push later"',
+]
+
+const FONT_SIZE = 14
+// Monospace fonts set at 14px advance about 8.4px per character; the cover over a typed line is sized from this, and
+// textLength pins the rendered text to the same width so a fallback font cannot peek out past the cover.
+const CHAR_WIDTH = 8.4
+const LINE_HEIGHT = 22
+const PAD_X = 20
+const TITLE_BAR = 36
+const WIDTH = 760
+const TYPE_SECONDS_PER_CHAR = 0.035
+const PAUSE_BEFORE_VERDICT = 0.35
+const PAUSE_AFTER_VERDICT = 0.9
+const HOLD_AT_END = 3
+
+const COLORS = {
+ window: '#0d1117',
+ bar: '#161b22',
+ border: '#30363d',
+ text: '#e6edf3',
+ muted: '#7d8590',
+ prompt: '#58a6ff',
+ denied: '#ff7b72',
+ allowed: '#3fb950',
+}
+
+function escapeXml(s: string): string {
+ return s.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"')
+}
+
+function pct(seconds: number, total: number): string {
+ return `${((seconds / total) * 100).toFixed(3)}%`
+}
+
+interface Step {
+ cmd: string
+ verdict: string | null
+ typeStart: number
+ typeEnd: number
+ verdictAt: number
+}
+
+function schedule(): { steps: Step[], total: number } {
+ let t = 0.6
+ const steps = COMMANDS.map((cmd) => {
+ const typeStart = t
+ const typeEnd = typeStart + cmd.length * TYPE_SECONDS_PER_CHAR
+ const verdictAt = typeEnd + PAUSE_BEFORE_VERDICT
+ t = verdictAt + PAUSE_AFTER_VERDICT
+ return { cmd, verdict: deniedBy(cmd, DEFAULT_DENY), typeStart, typeEnd, verdictAt }
+ })
+
+ return { steps, total: t + HOLD_AT_END }
+}
+
+function render(): string {
+ const { steps, total } = schedule()
+ const bodyTop = TITLE_BAR + 18
+ const height = bodyTop + (steps.length * 2 + 2) * LINE_HEIGHT
+ const css: string[] = []
+ const lines: string[] = []
+
+ lines.push(
+ `// deniedBy(cmd, DEFAULT_DENY) for each line`,
+ )
+
+ steps.forEach((step, i) => {
+ const y = bodyTop + (i * 2 + 2) * LINE_HEIGHT - LINE_HEIGHT / 2
+ const textX = PAD_X + 2 * CHAR_WIDTH
+ const coverWidth = step.cmd.length * CHAR_WIDTH + CHAR_WIDTH
+ const verdict = step.verdict === null
+ ? `✓ allowed`
+ : `✗ denied ${escapeXml(JSON.stringify(step.verdict))}`
+
+ css.push(
+ `.c${i}{animation:c${i} ${total.toFixed(2)}s infinite}`,
+ `@keyframes c${i}{0%,${pct(step.typeStart, total)}{transform:translateX(0);animation-timing-function:steps(${step.cmd.length},end)}`
+ + `${pct(step.typeEnd, total)},100%{transform:translateX(${coverWidth.toFixed(1)}px)}}`,
+ `.v${i},.p${i}{animation:v${i} ${total.toFixed(2)}s infinite;animation-timing-function:step-end}`,
+ `.p${i}{animation-name:p${i}}`,
+ `@keyframes v${i}{0%,${pct(step.verdictAt, total)}{opacity:0}${pct(step.verdictAt + 0.001, total)},100%{opacity:1}}`,
+ `@keyframes p${i}{0%,${pct(step.typeStart, total)}{opacity:0}${pct(step.typeStart + 0.001, total)},100%{opacity:1}}`,
+ )
+
+ lines.push(
+ `❯`,
+ `${escapeXml(step.cmd)}`,
+ ``,
+ `${verdict}`,
+ )
+ })
+
+ const dots = [['#ff5f57', 20], ['#febc2e', 40], ['#28c840', 60]] as const
+
+ return `
+`
+}
+
+const out = new URL('../assets/demo.svg', import.meta.url)
+writeFileSync(out, render())
+process.stdout.write(`wrote ${out.pathname}\n`)
diff --git a/tsconfig.json b/tsconfig.json
index a38b47c..fc1ae33 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -17,5 +17,5 @@
"skipDefaultLibCheck": true,
"skipLibCheck": true
},
- "include": ["src", "test"]
+ "include": ["src", "test", "scripts"]
}