Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
92 changes: 92 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,35 @@ Sees through `sudo`/`env`/`xargs`/`bash -c`/heredocs/`$(…)` and fails closed
[![bundle size](https://img.shields.io/bundlephobia/minzip/shell-command-guard)](https://bundlephobia.com/package/shell-command-guard)
[![license MIT](https://img.shields.io/badge/license-MIT-blue)](./LICENSE)

[Install](#install) · [Quick start](#quick-start) · [API](#api) · [Recipes](#recipes) · [FAQ](#faq) ·
[Security model](#security-model-and-limits)

<img src="./assets/demo.svg" width="760" alt="Terminal demo: deniedBy allows grep &quot;git push&quot; notes.md but denies git push hidden behind env, bash -c, xargs, quoting, a stdin shell and python -c, and denies rm -r -f /">

</div>

<details>
<summary><strong>Table of contents</strong></summary>

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

</details>

## 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
Expand All @@ -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
Expand Down Expand Up @@ -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

<details>
<summary><strong>Why is <code>rm -rf $DIR</code> denied?</strong></summary>

`$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.

</details>

<details>
<summary><strong>How do I allow a command that a deny reason blocks?</strong></summary>

`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)
}
```

</details>

<details>
<summary><strong>Which shells does it understand?</strong></summary>

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.

</details>

<details>
<summary><strong>Does it run anything, or touch the filesystem?</strong></summary>

No. It is a pure function over the command string: no child processes, no file reads, no environment lookups.

</details>

<details>
<summary><strong>Should I use a deny-list or an allow-list?</strong></summary>

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.

</details>

## 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
Expand All @@ -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
Expand Down
105 changes: 105 additions & 0 deletions assets/demo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"typecheck": "tsc",
"demo": "unrun scripts/demo.ts",
"test": "vitest run",
"prepublishOnly": "pnpm build"
},
Expand Down
132 changes: 132 additions & 0 deletions scripts/demo.ts
Original file line number Diff line number Diff line change
@@ -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('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;')
}

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(
`<text x="${PAD_X}" y="${bodyTop + 4}" fill="${COLORS.muted}">// deniedBy(cmd, DEFAULT_DENY) for each line</text>`,
)

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
? `<tspan fill="${COLORS.allowed}">✓ allowed</tspan>`
: `<tspan fill="${COLORS.denied}">✗ denied</tspan> <tspan fill="${COLORS.text}">${escapeXml(JSON.stringify(step.verdict))}</tspan>`

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(
`<text class="p${i}" x="${PAD_X}" y="${y}" fill="${COLORS.prompt}">❯</text>`,
`<text x="${textX}" y="${y}" fill="${COLORS.text}" textLength="${(step.cmd.length * CHAR_WIDTH).toFixed(1)}" lengthAdjust="spacing" xml:space="preserve">${escapeXml(step.cmd)}</text>`,
`<rect class="cover c${i}" x="${textX - 1}" y="${y - LINE_HEIGHT + 4}" width="${coverWidth.toFixed(1)}" height="${LINE_HEIGHT + 2}" fill="${COLORS.window}" shape-rendering="crispEdges"/>`,
`<text class="v${i}" x="${textX}" y="${y + LINE_HEIGHT}" xml:space="preserve">${verdict}</text>`,
)
})

const dots = [['#ff5f57', 20], ['#febc2e', 40], ['#28c840', 60]] as const

return `<svg xmlns="http://www.w3.org/2000/svg" width="${WIDTH}" height="${height}" viewBox="0 0 ${WIDTH} ${height}" role="img" aria-label="shell-command-guard demo: deniedBy allows commands that only mention git push and denies the ones that run it through env, bash -c, xargs, quoting, a stdin shell or python">
<style>
text{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,"Liberation Mono",monospace;font-size:${FONT_SIZE}px}
.fade{animation:fade ${total.toFixed(2)}s infinite}
@keyframes fade{0%,${pct(total - 0.4, total)}{opacity:1}100%{opacity:0}}
${css.join('\n')}
@media (prefers-reduced-motion:reduce){*{animation:none!important}.cover{display:none}}
</style>
<rect x="0.5" y="0.5" width="${WIDTH - 1}" height="${height - 1}" rx="10" fill="${COLORS.window}" stroke="${COLORS.border}"/>
<path d="M0.5 ${TITLE_BAR} V10.5 a10 10 0 0 1 10 -10 H${WIDTH - 10.5} a10 10 0 0 1 10 10 V${TITLE_BAR} Z" fill="${COLORS.bar}"/>
${dots.map(([fill, cx]) => `<circle cx="${cx}" cy="${TITLE_BAR / 2}" r="6" fill="${fill}"/>`).join('')}
<text x="${WIDTH / 2}" y="${TITLE_BAR / 2 + 5}" fill="${COLORS.muted}" text-anchor="middle">shell-command-guard</text>
<g class="fade">
${lines.join('\n')}
</g>
</svg>
`
}

const out = new URL('../assets/demo.svg', import.meta.url)
writeFileSync(out, render())
process.stdout.write(`wrote ${out.pathname}\n`)
2 changes: 1 addition & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,5 @@
"skipDefaultLibCheck": true,
"skipLibCheck": true
},
"include": ["src", "test"]
"include": ["src", "test", "scripts"]
}
Loading