Skip to content
Open
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
9 changes: 8 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

### Added

- `invocations(cmd, command)` — lists each command a line certainly runs with its words as the shell passes them and
what it reads from a here-document or here-string; a word that is one whole `$(cat <<'EOF' … EOF)` resolves to the
here-document.
- Authorship checks: `authorshipViolations(cmd, options?)` reports AI signatures, non-Conventional commit subjects and
non-Conventional branch names in what a line writes; built on `authoredMessages(cmd)` (commit and `gh pr` text),
`branchNames(cmd)`, `aiSignatures(text)`, `isConventionalCommit(message, types?)`,
`isConventionalBranch(name, types?)`, `COMMIT_TYPES` and `BRANCH_TYPES`.
- `deniedBy(cmd, deny)` — checks a command line against word-prefix patterns and fails closed: returns the matched
pattern, a `DENY_REASONS` value when what the line runs cannot be known, or `null`.
- `runsCommand(cmd, command)` — tells whether a line certainly runs a program or subcommand, reading it only as bash
Expand Down Expand Up @@ -42,4 +49,4 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
operands, versioned interpreters (`python3.12`), and `gtimeout`, `fakeroot`, `unshare` and `sandbox-exec`.
- Zero runtime dependencies, ESM only, TypeScript declarations, Node 20+.

[0.1.0]: https://github.com/devmaxxx/shell-command-guard/releases/tag/v0.1.0
[0.1.0]: https://github.com/devmaxxx/shell-guards/releases/tag/v0.1.0
141 changes: 120 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
<div align="center">

# shell-command-guard
# shell-guards

**Tell whether a shell command line really runs a program — the way bash would read it.**

Sees through `sudo`/`env`/`xargs`/`bash -c`/heredocs/`$(…)` and fails closed — zero dependencies.

[![npm version](https://img.shields.io/npm/v/shell-command-guard)](https://www.npmjs.com/package/shell-command-guard)
[![npm downloads](https://img.shields.io/npm/dm/shell-command-guard)](https://www.npmjs.com/package/shell-command-guard)
[![CI](https://img.shields.io/github/actions/workflow/status/devmaxxx/shell-command-guard/ci.yml?branch=main&label=CI)](https://github.com/devmaxxx/shell-command-guard/actions/workflows/ci.yml)
[![types included](https://img.shields.io/npm/types/shell-command-guard)](https://www.npmjs.com/package/shell-command-guard)
[![npm version](https://img.shields.io/npm/v/shell-guards)](https://www.npmjs.com/package/shell-guards)
[![npm downloads](https://img.shields.io/npm/dm/shell-guards)](https://www.npmjs.com/package/shell-guards)
[![CI](https://img.shields.io/github/actions/workflow/status/devmaxxx/shell-guards/ci.yml?branch=main&label=CI)](https://github.com/devmaxxx/shell-guards/actions/workflows/ci.yml)
[![types included](https://img.shields.io/npm/types/shell-guards)](https://www.npmjs.com/package/shell-guards)
[![zero dependencies](https://img.shields.io/badge/dependencies-0-brightgreen)](./package.json)
[![bundle size](https://img.shields.io/bundlephobia/minzip/shell-command-guard)](https://bundlephobia.com/package/shell-command-guard)
[![bundle size](https://img.shields.io/bundlephobia/minzip/shell-guards)](https://bundlephobia.com/package/shell-guards)
[![license MIT](https://img.shields.io/badge/license-MIT-blue)](./LICENSE)

[Install](#install) · [Quick start](#quick-start) · [API](#api) · [Recipes](#recipes) · [FAQ](#faq) ·
Expand All @@ -31,7 +31,13 @@ Sees through `sudo`/`env`/`xargs`/`bash -c`/heredocs/`$(…)` and fails closed
- [API](#api)
- [`deniedBy`](#deniedbycmd-deny) · [`runsCommand`](#runscommandcmd-command) ·
[`commandSegments`](#commandsegmentscmd) · [`effectiveDeny`](#effectivedenypolicy-extra) ·
[`DEFAULT_DENY`](#default_deny) · [`DENY_REASONS`](#deny_reasons)
[`DEFAULT_DENY`](#default_deny) · [`DENY_REASONS`](#deny_reasons) ·
[`invocations`](#invocationscmd-command)
- [Authorship](#authorship)
- [`authorshipViolations`](#authorshipviolationscmd-options) ·
[`authoredMessages`](#authoredmessagescmd) · [`branchNames`](#branchnamescmd) ·
[`aiSignatures`](#aisignaturestext) · [`isConventionalCommit`](#isconventionalcommitmessage-types) ·
[`isConventionalBranch`](#isconventionalbranchname-types)
- [Recipes](#recipes)
- [How it works](#how-it-works)
- [Comparison](#comparison)
Expand Down Expand Up @@ -61,7 +67,7 @@ mention the words.
| `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
`shell-guards` parses the line instead. Quoted strings, comments and quoted here-document bodies are data; every
command the line could run is checked; and anything it cannot read is denied rather than guessed.

## Features
Expand All @@ -75,6 +81,8 @@ command the line could run is checked; and anything it cannot read is denied rat
`pnpm exec`, `npm exec`, `yarn exec`, `bun x`, `git -C dir`, and zsh's `=cmd`, `noglob` and `nocorrect`.
- ⏱️ **Catches commands set up to run later** — `trap '…' EXIT`, `alias`, `git rebase -x`, `git bisect run`,
`git submodule foreach`, `git filter-branch`, and a pager, editor or ssh command set with `git -c`.
- ✍️ **Checks what a line writes** — commit messages, `gh pr` text and new branch names, read from `-m`, `--trailer`,
`-F -` here-documents and `$(cat <<'EOF' …)`, checked for AI signatures, Conventional Commits and Conventional Branch.
- 🔒 **Fails closed** — an expansion in command position, unbalanced quoting, a stdin-fed shell or `source`, a script
file, a `git -c alias.*` or `git config alias.*`, and a line nested deeper than the stack return a reason instead
of `null` — never a throw.
Expand All @@ -89,16 +97,16 @@ command the line could run is checked; and anything it cannot read is denied rat
## Install

```sh
npm install shell-command-guard
pnpm add shell-command-guard
yarn add shell-command-guard
bun add shell-command-guard
npm install shell-guards
pnpm add shell-guards
yarn add shell-guards
bun add shell-guards
```

## Quick start

```ts
import { DEFAULT_DENY, deniedBy, runsCommand } from 'shell-command-guard'
import { DEFAULT_DENY, deniedBy, runsCommand } from 'shell-guards'

deniedBy('pnpm test && sudo -u ci git push origin', ['git push']) // 'git push'
deniedBy('grep "git push" notes.md', ['git push']) // null
Expand All @@ -122,6 +130,14 @@ runsCommand('echo "then git commit"', 'git commit') // false
| [`DEFAULT_DENY`](#default_deny) | `readonly string[]` |
| [`DENY_REASONS`](#deny_reasons) | `{ expansion, unbalanced, gitAlias, stdinScript, scriptFile }` |
| `DenyPolicy` (type) | `{ deny: readonly string[] }` |
| [`invocations`](#invocationscmd-command) | `(cmd: string, command: string \| readonly string[]) => Invocation[]` |
| [`authorshipViolations`](#authorshipviolationscmd-options) | `(cmd: string, options?: AuthorshipOptions) => string[]` |
| [`authoredMessages`](#authoredmessagescmd) | `(cmd: string) => AuthoredMessage[]` |
| [`branchNames`](#branchnamescmd) | `(cmd: string) => (string \| null)[]` |
| [`aiSignatures`](#aisignaturestext) | `(text: string) => { line: number, text: string }[]` |
| [`isConventionalCommit`](#isconventionalcommitmessage-types) | `(message: string, types?: readonly string[]) => boolean` |
| [`isConventionalBranch`](#isconventionalbranchname-types) | `(name: string, types?: readonly string[]) => boolean` |
| `COMMIT_TYPES`, `BRANCH_TYPES` | `readonly string[]` |

### `deniedBy(cmd, deny)`

Expand Down Expand Up @@ -199,6 +215,62 @@ folded away.
| `stdinScript` | `'shell reads script from stdin'` | a shell runs without `-c` and without a script file (`echo … \| sh`, `bash -s`), `source` reads stdin or a process substitution, or `parallel` reads its commands from stdin |
| `scriptFile` | `'shell runs a script file'` | a shell runs a script file (`bash deploy.sh`) this reading cannot see |

### `invocations(cmd, command)`

Lists every command a line certainly runs that starts with one of the patterns, read the way `runsCommand` reads it.
Each entry has the command's words as the shell passes them (wrappers off, quotes removed, `null` for a word that
depends on what runs) and what it reads on stdin from a here-document or here-string. A word that is one whole
`$(cat <<'EOF' … EOF)` resolves to the here-document.

```ts
invocations('sudo git -C repo commit -m "fix: x" <<< y', 'git commit')
// [{ args: ['git', 'commit', '-m', 'fix: x'], stdin: 'y\n' }]
```

## Authorship

Checks for what a command line *writes*: commit messages, pull-request text and branch names. Commands are found with
`invocations`, so a message that only appears in an `echo` or a heredoc body is never read, and one behind `env`,
`sudo` or `bash -c` always is.

### `authorshipViolations(cmd, options?)`

Returns one line per violation: an AI signature in any `git commit` or `gh pr create|edit|comment|review|merge`
(message, `--trailer`, title, body or file), a commit subject that is not Conventional Commits, or a new branch that
is not Conventional Branch. What the line does not show (an editor, `-C HEAD`, `$MSG`) is not a violation. Message
files are read only through `options.readFile`; `commitTypes` and `branchTypes` replace the defaults.

```ts
authorshipViolations('git switch -c Fix_Login && git commit -m "fixed it"')
// ['branch "Fix_Login" is not a Conventional Branch', 'commit subject "fixed it" is not a Conventional Commit']
```

### `authoredMessages(cmd)`

One `{ kind: 'commit' | 'pull-request', text, file }` per `git commit` or `gh pr …` the line runs. `text` joins
`-m` paragraphs and `--trailer` lines, or a title and body; it reads `-F -` / `--body-file -` from a here-document.
`file` names a message file, which is not opened.

### `branchNames(cmd)`

The branches a line creates or renames: `git checkout -b`, `git switch -c`, `git branch <name>`, `git branch -m`,
`git worktree add -b`, and `git worktree add <path>`, which names its branch after the path.

### `aiSignatures(text)`

The 1-based lines that sign a text as AI-written: a `*-By:` / `*-With:` trailer naming an AI tool, its no-reply
address, a Claude Code session link, a "Generated with …" footer or a leading 🤖. A tool named in prose does not count.

### `isConventionalCommit(message, types?)`

Whether the first non-comment line is `<type>(<scope>)!: <description>` with a lowercase scope. Subjects git writes
(`Merge …`, `fixup! …`, `Revert "…"`) pass. Types default to `COMMIT_TYPES`.

### `isConventionalBranch(name, types?)`

Whether a name is `<type>/<description>` in lowercase letters, digits and single hyphens, with dots only in a
`release/` version. `main`, `master` and `develop` pass. Prefixes default to `BRANCH_TYPES`.

## Recipes

### Claude Code (or any AI agent) `PreToolUse` hook
Expand All @@ -210,13 +282,13 @@ blocks the call and shows stderr to the model.
// ~/.claude/hooks/guard.mjs
import { readFileSync } from 'node:fs'
import process from 'node:process'
import { deniedBy, effectiveDeny } from 'shell-command-guard'
import { deniedBy, effectiveDeny } from 'shell-guards'

const { tool_input: input } = JSON.parse(readFileSync(0, 'utf8'))
const hit = deniedBy(input.command ?? '', effectiveDeny({ deny: ['gh pr merge'] }))

if (hit) {
console.error(`Blocked by shell-command-guard: ${hit}`)
console.error(`Blocked by shell-guards: ${hit}`)
process.exit(2)
}
```
Expand All @@ -231,14 +303,41 @@ if (hit) {
}
```

### Authorship hook for an AI agent

Block a commit or pull request that an agent signs, or that breaks Conventional Commits or Conventional Branch.

```js
// ~/.claude/hooks/authorship.mjs
import { existsSync, readFileSync } from 'node:fs'
import process from 'node:process'
import { authorshipViolations } from 'shell-guards'

const { tool_input: input } = JSON.parse(readFileSync(0, 'utf8'))
function readFile(path) {
try {
return existsSync(path) ? readFileSync(path, 'utf8') : null
}
catch {
return null
}
}
const violations = authorshipViolations(input.command ?? '', { readFile })

if (violations.length > 0) {
console.error(`Fix and retry:\n- ${violations.join('\n- ')}`)
process.exit(2)
}
```

### Pre-exec / CI guard

Check a command before a bot or pipeline hands it to a shell.

```js
// guard.mjs — usage: node guard.mjs "$CMD" && sh -c "$CMD"
import process from 'node:process'
import { DEFAULT_DENY, deniedBy } from 'shell-command-guard'
import { DEFAULT_DENY, deniedBy } from 'shell-guards'

const hit = deniedBy(process.argv[2] ?? '', DEFAULT_DENY)

Expand All @@ -251,7 +350,7 @@ if (hit) {
### Custom deny-list, and checks that apply only to one command

```ts
import { deniedBy, effectiveDeny, runsCommand } from 'shell-command-guard'
import { deniedBy, effectiveDeny, runsCommand } from 'shell-guards'

const deny = effectiveDeny({ deny: ['docker', 'kubectl delete', 'terraform apply'] })

Expand All @@ -278,7 +377,7 @@ Patterns are word prefixes, so deny the program or subcommand (`'git clean'`) wh

## Comparison

| | shell-command-guard | [unbash](https://www.npmjs.com/package/unbash) | [shell-quote](https://www.npmjs.com/package/shell-quote) | Naive regex / `startsWith` |
| | shell-guards | [unbash](https://www.npmjs.com/package/unbash) | [shell-quote](https://www.npmjs.com/package/shell-quote) | Naive regex / `startsWith` |
| --- | --- | --- | --- | --- |
| Purpose | decide if a line runs a command | full bash AST parser (no policy) | tokenize and quote argv | — |
| Quotes and comments are data | ✅ | ✅ | ✅ | ❌ |
Expand Down Expand Up @@ -333,7 +432,7 @@ when you only need to know what certainly runs.
`deniedBy` returns the reason as a string, so you decide which ones to let through:

```ts
import { deniedBy, DENY_REASONS } from 'shell-command-guard'
import { deniedBy, DENY_REASONS } from 'shell-guards'

const hit = deniedBy(cmd, deny)
const trusted = hit === DENY_REASONS.scriptFile && /^bash scripts\/[\w-]+\.sh$/.test(cmd)
Expand Down Expand Up @@ -374,9 +473,9 @@ check each one against the list.

- [ ] 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"`
- [ ] A small CLI for shell hooks and CI: `shell-guards --deny 'git push' -- "$CMD"`

Found a bypass or a missing wrapper? [Open an issue](https://github.com/devmaxxx/shell-command-guard/issues).
Found a bypass or a missing wrapper? [Open an issue](https://github.com/devmaxxx/shell-guards/issues).

## Contributing

Expand Down
4 changes: 2 additions & 2 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.
8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
{
"name": "shell-command-guard",
"name": "shell-guards",
"type": "module",
"version": "0.1.0",
"packageManager": "pnpm@10.23.0",
"description": "Tell whether a shell command really runs a program — a fail-closed deny-list that sees through quotes, here-documents, $(…), bash -c, env, sudo and xargs",
"author": "arenuzzz",
"license": "MIT",
"homepage": "https://github.com/devmaxxx/shell-command-guard#readme",
"homepage": "https://github.com/devmaxxx/shell-guards#readme",
"repository": {
"type": "git",
"url": "git+https://github.com/devmaxxx/shell-command-guard.git"
"url": "git+https://github.com/devmaxxx/shell-guards.git"
},
"bugs": {
"url": "https://github.com/devmaxxx/shell-command-guard/issues"
"url": "https://github.com/devmaxxx/shell-guards/issues"
},
"keywords": [
"shell",
Expand Down
4 changes: 2 additions & 2 deletions scripts/demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ function render(): string {

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">
return `<svg xmlns="http://www.w3.org/2000/svg" width="${WIDTH}" height="${height}" viewBox="0 0 ${WIDTH} ${height}" role="img" aria-label="shell-guards 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}
Expand All @@ -119,7 +119,7 @@ ${css.join('\n')}
<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>
<text x="${WIDTH / 2}" y="${TITLE_BAR / 2 + 5}" fill="${COLORS.muted}" text-anchor="middle">shell-guards</text>
<g class="fade">
${lines.join('\n')}
</g>
Expand Down
Loading
Loading