SLOPSHOPPER

shell-sense

Stops Windows shell mistakes before they run: long heredocs, inline edit scripts, PowerShell 5.1 syntax, mangled paths.

newguard
v0.1.0no licenseupdated 2026-10-05cleverfakealias/agents/mods/shell-sense
A shopper browsing a rack in a slop shop
README

agents — a clean starting point for agentic development

A small, language-neutral scaffold you copy into a repo so coding agents start with sensible guardrails. It is Claude Code–native, with AGENTS.md as the cross-tool contract that Cursor, Codex, Copilot, and others read too.

It is deliberately small. It leans on Claude Code's built-in controls where they exist, and adds one hook for the part that has to be project-specific: running your formatter and your tests.

What's in the box

scaffold/                 ← copy this into your repo
├── AGENTS.md             project facts, commands, conventions, security (fill in)
├── CLAUDE.md             imports AGENTS.md, plus a few Claude-specific notes
├── .gitignore            lines to add to yours
└── .claude/
    ├── settings.json     permission rules and hook wiring
    ├── hooks/
    │   ├── checks.mjs    runs your formatter after edits, your tests before Claude finishes
    │   └── checks.json   the commands it runs (empty until you fill it in)
    └── skills/zenn/      /zenn: optional spec-first workflow for larger work
providers.md              notes for Cursor / Copilot / Codex / Gemini / Devin
mods/                     user-level Claude Code mods (see mods/README.md)
tests/                    node --test "tests/*.test.mjs": the hook, the scaffold rules, the mods

Setup

1. Copy the scaffold into your repo

cp -r /path/to/agents/scaffold/. /path/to/your-repo/

The trailing /. copies the contents, including the dot-directories. If the repo already has a .gitignore, AGENTS.md, or CLAUDE.md, merge those by hand instead of overwriting them. Node on PATH is the only requirement.

2. Fill in AGENTS.md

Replace each <!-- placeholder --> with the project's name, stack, and real commands, and delete the sections you don't need.

3. Tell the checks hook what to run

Edit .claude/hooks/checks.json. Both lists are empty by default, which turns the hook off.

{
  "format": {
    "py": "ruff format {file}",
    "ts,tsx,js": "npx --no-install prettier --write {file}"
  },
  "verify": ["pytest -q"]
}
  • format maps file extensions to a command that runs after Claude edits a file of that type. {file} is the edited file's path, already quoted. If the command fails, its output goes back to Claude to fix.
  • verify commands run when Claude finishes a turn in which it edited files. If one fails, Claude keeps working until it passes.
  • Commands run from the repo root. A command whose tool isn't installed is skipped. CLAUDE_SKIP_CHECKS=1 claude turns the hook off for a session.

4. Turn on the OS sandbox (optional)

On macOS, Linux, or WSL2, run /sandbox in Claude Code. It confines shell commands to the project directory and to network hosts you approve. The secret paths denied in settings.json apply inside the sandbox too.

How the guardrails work

| Layer | What it covers | | :- | :- | | deny rules | Secrets are never read or written: .env*, .dev.vars*, key files, ~/.ssh, ~/.gnupg, cloud credentials (AWS, GCP, Azure, Kubernetes), tool and registry credentials (GitHub CLI, Docker, npm, PyPI, RubyGems, ~/.git-credentials, ~/.netrc). .env.example stays usable. Lockfiles (package-lock.json, pnpm-lock.yaml, *.lock, *.lockb, go.sum) are not read either: they are large, and they only change through the package manager. | | File search | settings.json sets CLAUDE_CODE_GLOB_NO_IGNORE=false, so Claude's file search respects .gitignore. By default it also lists ignored files such as node_modules and build output. | | ask rules | A person approves git push, git reset --hard, git clean, gh pr merge, CI workflow edits, and any command retried outside the sandbox. These prompt in every permission mode, including auto. | | Built into Claude Code | Writes to .claude/, .git/, .mcp.json, and shell startup files are never auto-approved. rm -rf on the project, home, or root is always stopped. settings.json also disables bypass-permissions mode. | | Checks hook | Your formatter and tests run without anyone remembering to. | | AGENTS.md | Conventions and intent. It shapes what agents try; it enforces nothing. |

There are no allow rules: nothing is pre-approved. Claude Code already runs read-only commands without asking, and saves your own "don't ask again" choices to .claude/settings.local.json.

No permission mode is pinned either. Use Manual, auto, or plan as you prefer; the deny and ask rules hold in all of them.

Limits worth knowing

  • Shell rules match the command as written. Bash(git push *) catches git push origin main but not git -C . push. The rules stop the usual form, not a determined workaround. For anything that must never happen, protect the branch on the remote.
  • Read rules don't see inside scripts. They cover Claude's file tools and common shell readers such as cat. A script that opens a file itself is only stopped by the OS sandbox.
  • The sandbox doesn't run on native Windows. Use WSL2 or a dev container there if you need real isolation.
  • ***.pem and *.key are denied wholesale.** Delete those two lines from settings.json if your repo keeps non-secret files with those extensions.
  • Lockfiles can't be read. To check a resolved version, ask the package manager (npm ls <pkg>, pnpm why <pkg>), or delete the lockfile lines from settings.json.

User-level mods

mods/ holds Claude Code mods that load into every session on the machine, whatever repo it runs in, the Desktop Code tab included. Each one fixes friction that kept repeating across real sessions:

| Mod | In short | | :- | :- | | shell-sense | Denies shell commands that are certain to fail on Windows, and says what works instead. | | package-gate | Asks you before any package install, with a registry link per package. | | secret-shield | Keeps secret files and token values out of shell reads and the transcript. | | repo-lock | Denies dependency changes with the wrong package manager or from the wrong folder. | | context-meter | A band above the prompt: context fill against quality marks (30% fading, 40% "dumb zone"), rate limits, and model and effort switchers. | | session-context | Tells Claude the repo, branch and uncommitted work with each prompt. |

Setup, the full behaviour of each, and how to work on them: mods/README.md.

Working on this repo

node --test "tests/*.test.mjs"

The tests exercise checks.mjs, enforce the scaffold's own rules (file size limits, valid hook paths, rule syntax), and check that the mods' shared files match. Each mod also has its own tests: claude plugin test mods/<name>. Using another agent? See providers.md.

Earlier versions (per-language standards skills, command-guard hooks, the multi-provider scaffolds) live in git history.

Source 2 files
hooks/register.ts 24 lines
1import type { Register, ToolCallResult } from 'claude-code'
2
3import { bash, powershell, type Verdict } from './rules'
4
5// Deny before the call runs, or attach a note the model reads after the result.
6const withNote = (result: ToolCallResult, verdict: Verdict): ToolCallResult =>
7  verdict && 'note' in verdict && result.deny === undefined
8    ? { ...result, context: [...(result.context ?? []), verdict.note] }
9    : result
10
11export const register: Register = on => {
12  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
13    const verdict = bash(e.command)
14    if (verdict && 'deny' in verdict) return { deny: verdict.deny }
15    return withNote(await next(e), verdict)
16  })
17
18  on('tool.call', { tool: 'PowerShell' }, async ($, e, next) => {
19    const verdict = powershell(e.command)
20    if (verdict && 'deny' in verdict) return { deny: verdict.deny }
21    return withNote(await next(e), verdict)
22  })
23}
24
hooks/rules.ts 123 lines
1// Pure rules for shell-sense: no `$`, so `claude plugin test` can run them directly.
2// Each rule answers a deny reason (the call never runs) or a note the model reads
3// after the result. Deny only what is certain to fail; note what is only likely.
4
5export type Verdict = { deny: string } | { note: string } | undefined
6
7const HEREDOC_MAX_LINES = 40
8
9// Drop quoted spans so operators inside strings ("a && b", 'x ?? y') don't match.
10// A scan, not a regex: a regex reads an unclosed quote to the end once per quote
11// character, which is quadratic. Here the first unclosed quote of a kind marks
12// the rest of that kind as plain text.
13export function stripQuoted(command: string): string {
14  let out = ''
15  const unclosed = new Set<string>()
16  for (let i = 0; i < command.length; i++) {
17    const q = command[i]
18    if ((q !== '"' && q !== "'") || unclosed.has(q)) {
19      out += q
20      continue
21    }
22    let j = i + 1
23    while (j < command.length && command[j] !== q) j += command[j] === '\\' ? 2 : 1
24    if (j >= command.length) {
25      unclosed.add(q)
26      out += q
27    } else {
28      out += '""'
29      i = j
30    }
31  }
32  return out
33}
34
35// Every unbounded run below stops at a character the pattern needs next, or at a
36// bound, so one long command can't make a rule backtrack quadratically.
37const INLINE_SCRIPT =
38  /(^|[\s;&|(])(python3?|py|node)(\.exe)?\s+(-c\s|-e\s|--eval\s|-\s*<<|-\s*$|<<)/m
39const WRITES_FILES =
40  /writeFileSync|writeFile\(|appendFileSync|\.write_text\(|\.write_bytes\(|open\([^),\n]*,\s*['"][wa]|\bshutil\.(copy|move)/
41
42export function bash(command: string): Verdict {
43  const lines = command.split('\n').length
44  const hasHeredoc = /<<-?\s*['"]?\w+['"]?/.test(command)
45
46  if (INLINE_SCRIPT.test(command) && WRITES_FILES.test(command)) {
47    return {
48      deny:
49        'shell-sense: this inline script edits files. Use the Edit or Write tool instead, so the diff shows, ' +
50        'the formatter hook runs, and Claude keeps an accurate view of the file. For a large mechanical change, ' +
51        'Write the script to the scratchpad first and run it from there.',
52    }
53  }
54  if (hasHeredoc && lines > HEREDOC_MAX_LINES) {
55    return {
56      deny:
57        `shell-sense: this heredoc is ${lines} lines. Long heredocs fail in this shell with "unexpected EOF". ` +
58        'Write the content to a file with the Write tool (the scratchpad is fine), then run or cat that file.',
59    }
60  }
61  if (/(^|[\s;&|])pwsh(\.exe)?(\s|$)/.test(stripQuoted(command))) {
62    return {
63      deny:
64        'shell-sense: pwsh (PowerShell 7) is not installed here. Use the PowerShell tool, which runs Windows PowerShell 5.1.',
65    }
66  }
67  if (/(^|[\s;&|])tar\s[^|;&\n]{0,300}\s['"]?[A-Za-z]:[\\/]/.test(command) && !/--force-local/.test(command)) {
68    return {
69      deny:
70        'shell-sense: GNU tar reads "C:" as a remote host. Add --force-local, or use a /c/... path.',
71    }
72  }
73  if (/(^|\s)[A-Za-z]:\\[^\s'"]/.test(stripQuoted(command))) {
74    return {
75      note:
76        'shell-sense: Bash removes unquoted backslashes, so a path like Z:\\dir\\file becomes Z:dirfile. ' +
77        'In Bash, write /z/dir/file or Z:/dir/file, or quote the Windows path.',
78    }
79  }
80  return undefined
81}
82
83// The body of git commit -m @'...'@: the opener by regex, the close by indexOf,
84// so an unclosed here-string is read to the end once.
85function hereStringBody(command: string): string | undefined {
86  const open = /git\s+commit\b[^\n]{0,300}?-m\s+@(['"])\r?\n/.exec(command)
87  if (!open) return undefined
88  const start = open.index + open[0].length
89  const close = command.indexOf(`\n${open[1]}@`, start - 1)
90  return close < 0 ? undefined : command.slice(start, close)
91}
92
93export function powershell(command: string): Verdict {
94  const bare = stripQuoted(command)
95
96  if (/\s\?\?=?\s/.test(bare) || /\w\?\.\w/.test(bare)) {
97    return {
98      deny:
99        'shell-sense: Windows PowerShell 5.1 has no ?? or ?. operators. Use if ($null -eq $x) { ... } else { ... } instead.',
100    }
101  }
102  if (/\s(&&|\|\|)\s/.test(bare)) {
103    return {
104      deny:
105        'shell-sense: Windows PowerShell 5.1 has no && or || chain operators. Use "A; if ($?) { B }" to run B only when A succeeds, or "A; B" to run both.',
106    }
107  }
108  if (hereStringBody(command)?.includes('"')) {
109    return {
110      deny:
111        'shell-sense: PowerShell 5.1 splits a native argument at embedded double quotes, so git reads parts of this ' +
112        'message as pathspecs. Write the message to a file with the Write tool and run git commit -F <file>.',
113    }
114  }
115  if (/\b2>&1\b/.test(bare) && /\b(pnpm|npm|npx|git|node|wrangler|python)\b/.test(bare)) {
116    return {
117      note:
118        'shell-sense: in PowerShell 5.1, 2>&1 on a native program wraps each stderr line in an error record and sets $? to false even on exit code 0. Drop 2>&1; stderr is captured already.',
119    }
120  }
121  return undefined
122}
123