SLOPSHOPPER

secret-shield

Keeps secrets out of transcripts: blocks shell reads of secret files, hides token values in tool output, and checks git add and push.

newguardtoastprocess
v0.1.0no licenseupdated 2026-10-08cleverfakealias/agents/mods/secret-shield
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · secret-shield
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(cat .env) ⎿ Denied by secret-shield: secret-shield: .env holds secrets, so its contents stay out of the transcript. If ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
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 3 files
hooks/register.ts 140 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import { MARK, addsMark, envDump, isSecretFile, redact, shellRead } from './rules'
4import { segments } from './shell'
5
6const READ_DENY = (file: string) =>
7  `secret-shield: ${file} holds secrets, so its contents stay out of the transcript. ` +
8  'If you need a value, ask the user. If you need the variable names, read the .example or .template file.'
9const MARK_DENY = (file: string) =>
10  `secret-shield: this edit would write "${MARK}" into ${file}. The marker stands in for a value you never saw, ` +
11  'so writing it would destroy that value. Edit only the lines you need and leave redacted lines as they are. ' +
12  'If the file really needs the marker text, ask the user.'
13
14// /z/github_projects/x → Z:/github_projects/x, so git gets a path Windows knows.
15const winPath = (p: string) => p.replace(/^\/([a-zA-Z])(\/|$)/, (_, d: string) => `${d.toUpperCase()}:/`)
16
17// The folder a command runs in: its leading `cd X`, else the session's.
18async function commandDir($: EngineInterface, command: string): Promise<string> {
19  const cwd = await $.session.cwd()
20  const first = segments(command)[0] ?? []
21  if (/^(cd|set-location|pushd)$/i.test(first[0] ?? '') && first[1]) {
22    const target = winPath(first[1])
23    return /^[A-Za-z]:[\\/]/.test(target) || target.startsWith('/') ? target : `${cwd}/${target}`
24  }
25  return cwd
26}
27
28async function git($: EngineInterface, dir: string, args: string[]): Promise<string | undefined> {
29  try {
30    const r = await $.process.run(['git', ...args], { cwd: dir, timeoutMs: 15000 })
31    return r.exitCode === 0 ? r.stdout : undefined
32  } catch {
33    return undefined
34  }
35}
36
37// Before git add -A / git add . : untracked secret files that .gitignore misses.
38// Before git push: secret files inside the commits about to leave.
39async function gitGuard($: EngineInterface, command: string): Promise<string | undefined> {
40  const words = segments(command).find(w => w[0] === 'git' && (w[1] === 'add' || w[1] === 'push'))
41  if (!words) return undefined
42  const dir = await commandDir($, command)
43
44  if (words[1] === 'add' && words.slice(2).some(w => w === '-A' || w === '--all' || w === '.' || w === '-u')) {
45    const status = await git($, dir, ['status', '--porcelain', '--untracked-files=all'])
46    const risky = (status ?? '')
47      .split('\n')
48      .filter(l => l.startsWith('??'))
49      .map(l => l.slice(3).trim())
50      .filter(isSecretFile)
51    if (risky.length) {
52      return `secret-shield: ${risky.join(', ')} would be staged, and ${risky.length === 1 ? 'it looks' : 'they look'} like secrets. ` +
53        'Add them to .gitignore first, or stage files by name.'
54    }
55  }
56  if (words[1] === 'push') {
57    const files =
58      (await git($, dir, ['log', '@{u}..HEAD', '--name-only', '--format='])) ??
59      (await git($, dir, ['log', 'origin/HEAD..HEAD', '--name-only', '--format=']))
60    const risky = [...new Set((files ?? '').split('\n').map(l => l.trim()).filter(isSecretFile))]
61    if (risky.length) {
62      return `secret-shield: the commits to push touch ${risky.join(', ')}, which look like secrets. ` +
63        'Stop and show the user. Do not push until they decide.'
64    }
65  }
66  return undefined
67}
68
69async function shellGuard($: EngineInterface, command: string): Promise<string | undefined> {
70  const file = shellRead(command)
71  if (file) return READ_DENY(file)
72  if (envDump(command)) {
73    return 'secret-shield: dumping every environment variable would print secrets. Read the one variable you need by name.'
74  }
75  return gitGuard($, command)
76}
77
78// If a guard throws or times out, the engine would skip it and run the call.
79// Fail closed when the command names anything secret-shaped; let the rest through.
80const MENTIONS_SECRET = /\.env\b|\.dev\.vars|tokens?\.json|credentials|\.pem\b|\.key\b|id_(rsa|ed25519)|printenv|env:|git\s+(add|push)/i
81const FAILED = 'secret-shield could not check this command, so it did not run. Run a narrower command, or ask the user.'
82const UNSCANNED = 'secret-shield could not scan this tool output for secrets, so it hid all of it. Rerun with less output (head, a filter, a smaller file).'
83
84export const register: Register = on => {
85  on('tool.call', { tool: 'Read' }, ($, e, next) =>
86    isSecretFile(e.file_path) ? { deny: READ_DENY(e.file_path) } : next(e),
87  )
88
89  // What Claude read may hold MARK in place of a value: don't let it write that back.
90  on('tool.call', { tool: 'Write' }, async ($, e, next) => {
91    if (!e.content.includes(MARK)) return next(e)
92    const before = await $.fs.read(e.file_path).catch(() => undefined)
93    return addsMark(typeof before === 'string' ? before : '', e.content) ? { deny: MARK_DENY(e.file_path) } : next(e)
94  })
95
96  on('tool.call', { tool: 'Edit' }, ($, e, next) =>
97    addsMark(e.old_string, e.new_string) ? { deny: MARK_DENY(e.file_path) } : next(e),
98  )
99
100  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
101    const deny = await shellGuard($, e.command)
102    return deny ? { deny } : next(e)
103  }).catch(($, e, next) => (MENTIONS_SECRET.test(e.command) ? { deny: FAILED } : next(e)))
104
105  on('tool.call', { tool: 'PowerShell' }, async ($, e, next) => {
106    const deny = await shellGuard($, e.command)
107    return deny ? { deny } : next(e)
108  }).catch(($, e, next) => (MENTIONS_SECRET.test(e.command) ? { deny: FAILED } : next(e)))
109
110  // Scrub token shapes from tool output before the transcript keeps it. The
111  // stored row is also what the model reads, so the value never reaches it.
112  on('session.append', { door: 'tool-result' }, async ($, e, next) => {
113    let total = 0
114    const scrub = (text: string) => {
115      const r = redact(text)
116      total += r.count
117      return r.text
118    }
119    const content = e.message.content.map(block => {
120      if (block.type === 'text' && typeof block.text === 'string') return { ...block, text: scrub(block.text) }
121      if (block.type !== 'tool_result') return block
122      const inner = block.content
123      if (typeof inner === 'string') return { ...block, content: scrub(inner) }
124      if (!Array.isArray(inner)) return block
125      return { ...block, content: inner.map(c => (c.type === 'text' ? { ...c, text: scrub(c.text) } : c)) }
126    })
127    if (!total) return next(e)
128    // The engine already heads a toast with the plugin's name.
129    $.ui.toast(`Hid ${total} secret value${total === 1 ? '' : 's'} in a tool result`)
130    return next({ ...e, message: { ...e.message, content } })
131  }).catch(($, e, next) => {
132    // Skipped, this hook would store the output unscanned: hide all of it instead.
133    const content = e.message.content.map(block => {
134      if (block.type === 'text') return { ...block, text: UNSCANNED }
135      return block.type === 'tool_result' ? { ...block, content: UNSCANNED } : block
136    })
137    return next({ ...e, message: { ...e.message, content } })
138  })
139}
140
hooks/rules.ts 177 lines
1// Pure rules for secret-shield. Permission deny rules cover Claude's Read tool,
2// not the shell, so a `head tokens.json` once printed live tokens into a
3// transcript. These rules close that gap and scrub token shapes from tool output.
4
5import { segments } from './shell'
6
7// ── secret files ──────────────────────────────────────────────────────────────
8const SECRET_FILE =
9  /^(\.env(\..+)?|\.envrc|\.dev\.vars(\..+)?|tokens?\.json|\.?credentials(\.json)?|\.git-credentials|secrets?\.(json|ya?ml|toml)|service[-_]account.*\.json|.+\.(pem|key|p12|pfx)|id_(rsa|ed25519|ecdsa|dsa)|\.netrc|\.pypirc)$/i
10const TEMPLATE = /\.(example|sample|template|dist|defaults)$/i
11
12const base = (p: string) => p.replace(/^.*[\\/]/, '').replace(/^['"]|['"]$/g, '')
13
14// A glob counts when it can match a secret file and no everyday file: `.en*`,
15// `*.pem` and `id_*` do, `*`, `.*` and `*.json` don't.
16const SECRET_NAMES = ['.env', '.env.local', '.envrc', '.dev.vars', 'tokens.json', 'credentials.json', '.git-credentials',
17  'secrets.yaml', 'service-account.json', 'server.key', 'cert.pem', 'id_rsa', 'id_ed25519', '.netrc', '.pypirc']
18const EVERYDAY_NAMES = ['package.json', 'tsconfig.json', 'README.md', 'index.ts', 'main.py', 'config.yaml', '.gitignore', '.editorconfig']
19function globHitsSecret(glob: string): boolean {
20  try {
21    const re = new RegExp(`^${glob.replace(/[.+^${}()|\\]/g, '\\$&').replace(/\*+/g, '.*').replace(/\?/g, '.')}$`, 'i')
22    return SECRET_NAMES.some(n => re.test(n)) && !EVERYDAY_NAMES.some(n => re.test(n))
23  } catch {
24    return false // an unclosed [ is not a glob we can read
25  }
26}
27
28export const isSecretFile = (path: string) => {
29  const name = base(path)
30  if (TEMPLATE.test(name)) return false
31  return /[*?[]/.test(name) ? globHitsSecret(name) : SECRET_FILE.test(name)
32}
33
34// ── shell reads ───────────────────────────────────────────────────────────────
35const READERS = new Set([
36  'cat', 'head', 'tail', 'less', 'more', 'bat', 'type', 'nl', 'od', 'xxd', 'hexdump', 'strings', 'base64',
37  'sed', 'awk', 'sort', 'uniq', 'cut', 'tac', 'source', '.', 'cp', 'scp', 'rsync', 'jq', 'yq',
38  'diff', 'cmp', 'comm', 'paste', 'rev', 'fold', 'zcat',
39  'get-content', 'gc', 'copy-item', 'import-csv', 'format-hex', 'out-string',
40])
41const SEARCHERS = new Set(['grep', 'egrep', 'fgrep', 'rg', 'select-string', 'sls', 'findstr'])
42const GIT_SHOWS = new Set(['show', 'diff', 'blame', 'log', 'cat-file'])
43
44const fileWord = (w: string) => w.replace(/^[A-Za-z]+:(?=[^\\/])/, '') // git show HEAD:.env → .env
45
46// PowerShell's .NET calls: [IO.File]::ReadAllText('.env')
47const NET_READ = /::(?:ReadAll(?:Text|Lines|Bytes)|OpenText|OpenRead)\(\s*['"]([^'"]+)['"]/gi
48
49export function shellRead(command: string): string | undefined {
50  for (const m of command.matchAll(NET_READ)) if (isSecretFile(m[1])) return m[1]
51  for (const words of segments(command)) {
52    const tool = words[0].toLowerCase().replace(/\.exe$/, '')
53    const rest = words.slice(1)
54    const positional = rest.filter(w => !w.startsWith('-'))
55
56    // input redirect from a secret file: anything < .env
57    const redirected = words.findIndex(w => w === '<')
58    if (redirected >= 0 && words[redirected + 1] && isSecretFile(words[redirected + 1])) return words[redirected + 1]
59
60    if (READERS.has(tool)) {
61      const hit = positional.find(isSecretFile)
62      if (hit) return hit
63    }
64    if (SEARCHERS.has(tool)) {
65      // the first positional is the pattern (".env" in a grep of .gitignore is fine)
66      const hasExplicitPattern = rest.some(w => w === '-e' || w === '-f' || /^-pattern$/i.test(w))
67      const files = hasExplicitPattern ? positional : positional.slice(1)
68      const pathFlag = rest.findIndex(w => /^-(path|literalpath)$/i.test(w))
69      const hit = files.find(isSecretFile) ?? (pathFlag >= 0 && isSecretFile(rest[pathFlag + 1] ?? '') ? rest[pathFlag + 1] : undefined)
70      if (hit) return hit
71    }
72    if (tool === 'git' && GIT_SHOWS.has((positional[0] ?? '').toLowerCase())) {
73      const hit = positional.slice(1).map(fileWord).find(isSecretFile)
74      if (hit) return hit
75    }
76    if (/^(node|python3?|py|deno|bun|ruby|perl)$/.test(tool)) {
77      const hit = rest.flatMap(w => w.match(/[\w./\\-]*(\.env[\w.]*|\.dev\.vars[\w.]*|tokens?\.json|credentials\.json)/g) ?? []).find(isSecretFile)
78      if (hit && /readFile|open\(|read_text|load_dotenv|dotenv/.test(command)) return hit
79    }
80  }
81  return undefined
82}
83
84export function envDump(command: string): boolean {
85  return segments(command).some(words => {
86    const tool = words[0].toLowerCase()
87    if ((tool === 'printenv' || tool === 'env' || tool === 'set') && words.length === 1) return true
88    if (/^(get-childitem|gci|dir|ls|get-item)$/.test(tool)) {
89      return words.slice(1).some(w => /^env:[\\/]?$/i.test(w))
90    }
91    return false
92  })
93}
94
95// ── redaction ─────────────────────────────────────────────────────────────────
96export const MARK = '[redacted by secret-shield]'
97// Code, not a value: `process.env.API_KEY`, `getToken()`, `os.environ[...]`. Dotted
98// parts with digits (`MTk4.Cl2F.ZnCj`) still look like a token and stay redacted.
99const CODE = /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*[([]|^[A-Za-z_$]+(?:\.[A-Za-z_$]+)+[!?]?$/
100// Placeholder words count only as whole words: `test-token` is one, `Contest2024` is not.
101const PLACEHOLDER =
102  /^(.{0,3}|.*(your[_-]|xxx|\*\*\*|<|\$\{|\$\().*|(.*[^a-z])?(example|placeholder|changeme|redacted|dummy|fake|test)([^a-z].*)?)$/i
103// Every quantifier around a keyword is bounded: an unbounded `\w*KEYWORD\w*`
104// backtracks quadratically on one long word ("tokentoken..."), and a hook that
105// outruns its budget is skipped, which would store the result unredacted.
106const KEYWORDS = 'SECRET|TOKEN|PASSWORD|PASSWD|API_?KEY|PRIVATE_?KEY|AUTH_?KEY|CLIENT_?SECRET|ACCESS_?KEY'
107const SECRET_KEY = `[A-Za-z0-9_]{0,64}(?:${KEYWORDS})[A-Za-z0-9_]{0,64}`
108// Env-style names only (upper case) for the rules whose values may hold spaces, so
109// code like `passwordLabel: "Enter your password"` stays readable.
110const ENV_KEY = `(?!PUBLIC_)[A-Z0-9_]{0,64}(?:${KEYWORDS})[A-Z0-9_]{0,64}`
111
112// Source code after an unquoted `name:` or `name =`: a type annotation
113// (`secret: string)`), a generic (`Promise<string>`), a constant
114// (`MAX_RESPONSE_TOKENS`), a camelCase name, or a template or regex literal.
115// Letters only for names: a digit makes it look like a token again.
116const TYPE_WORD = 'string|number|boolean|bigint|symbol|object|unknown|any|never|void|undefined|null|readonly|keyof|typeof'
117const SOURCE = new RegExp(
118  `^(?:(?:${TYPE_WORD})(?=$|[)\\][|>&=])|[A-Za-z_$][\\w$]*<|[A-Z]+(?:_[A-Z]+)+[)\\]}]*$|[a-z]+(?:[A-Z][a-z]+)+[)\\]}]*$|\`|/[\\\\^([])`,
119)
120
121// keep: how many leading groups to keep. code: also pass values that are code.
122// source: also pass unquoted values that are source code (see SOURCE).
123type Rule = { re: RegExp; keep?: number; code?: boolean; source?: boolean }
124
125const RULES: Rule[] = [
126  // The body stops at the next ----- line, so a BEGIN with no END scans one block, not the rest.
127  { re: /-----BEGIN [A-Z ]{0,40}PRIVATE KEY-----[^-]*(?:-(?!----)[^-]*)*-----END [A-Z ]{0,40}PRIVATE KEY-----/g },
128  // KEY="a value with spaces" and KEY='...'
129  ...['"', "'"].map(q => ({
130    re: new RegExp(`(\\b${ENV_KEY}\\s*[=:]\\s*${q})([^${q}\\r\\n]{8,})(?=${q})`, 'g'), keep: 1, code: true,
131  })),
132  // KEY=rest of the line, unquoted, as dotenv reads it (spaces, ; and # included)
133  { re: new RegExp(`(^[ \\t]*(?:export[ \\t]+)?${ENV_KEY}[ \\t]*=[ \\t]*)([^\\s"'][^\\r\\n]{7,})`, 'gm'), keep: 1, code: true },
134  // KEY=value and KEY: value anywhere (YAML, wrangler output, source code), up to a space
135  { re: new RegExp(`(\\b(?!PUBLIC_)${SECRET_KEY}\\s*[=:]\\s*["']?)([^\\s"',;]{8,})`, 'gi'), keep: 1, code: true, source: true },
136  // "accessToken": "..." in JSON; the key may be namespaced ("oauth:tokenCache",
137  // "auth.token"), as the Desktop app's config.json names its OAuth caches.
138  { re: new RegExp(`("(?!public)[A-Za-z0-9_:.-]{0,64}(?:${KEYWORDS})[A-Za-z0-9_]{0,64}"\\s*:\\s*")([^"]{8,})`, 'gi'), keep: 1 },
139  // <writeToken>...</writeToken> in XML
140  { re: /(<\w{0,64}(?:token|secret|password|apikey)\w{0,64}>)([^<]{12,})(?=<\/)/gi, keep: 1 },
141  { re: /(Authorization:\s*(?:Bearer|token)\s+)([A-Za-z0-9._~+/=-]{20,})/gi, keep: 1 },
142  { re: /(?<![A-Za-z0-9+/])sk[A-Za-z0-9]{70,}(?![A-Za-z0-9+/=])/g }, // Sanity
143  { re: /(?<![A-Za-z0-9])sk-(?:ant-|proj-)?[A-Za-z0-9_-]{30,}/g }, // Anthropic, OpenAI
144  { re: /\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{40,})/g },
145  { re: /\bxox[abprs]-[A-Za-z0-9-]{10,}/g },
146  { re: /\bAKIA[0-9A-Z]{16}\b/g },
147  { re: /\beyJ[\w-]{10,}\.eyJ[\w-]{10,}\.[\w-]{10,}/g }, // JWT
148  { re: /\boauth:[a-z0-9]{20,}/g }, // Twitch chat token
149]
150
151export function redact(text: string): { text: string; count: number } {
152  let count = 0
153  let out = text
154  for (const { re, keep, code, source } of RULES) {
155    out = out.replace(re, (...m: string[]) => {
156      const whole = m[0]
157      if (keep === undefined) {
158        count++
159        return MARK
160      }
161      const value = m[keep + 1] ?? ''
162      const prefix = m.slice(1, keep + 1).join('')
163      if (PLACEHOLDER.test(value) || (code && CODE.test(value))) return whole
164      // In quotes it is data, whatever it looks like.
165      if (source && !/["']$/.test(prefix) && SOURCE.test(value)) return whole
166      count++
167      return prefix + MARK
168    })
169  }
170  return { text: out, count }
171}
172
173// Claude saw MARK where a value was. Writing what it saw back would replace
174// the real value with MARK, so an edit may not add more MARKs than it removes.
175const marks = (text: string) => text.split(MARK).length - 1
176export const addsMark = (before: string, after: string) => marks(after) > marks(before)
177
hooks/shell.ts 115 lines
1// The shell tokenizer package-gate, repo-lock and secret-shield share. Each mod is
2// its own plugin folder, so each holds a copy of this file: change one, then copy
3// it to the other two. tests/mods.test.mjs fails while the copies differ.
4//
5// Splits a shell command into segments (at && || ; | & ( ) $( ` and newlines
6// outside quotes) of whitespace-separated words with their quotes removed, and
7// `<` as a word of its own. A wrapper (time, env, sudo, corepack, ...) is
8// dropped, and the script of a `bash -c` or `powershell -Command` is split in
9// turn. Heredoc bodies are skipped unless a shell runs them. Good enough for the
10// commands an agent writes; it is a gate on intent, not a shell parser.
11const WRAPPERS = new Set(['time', 'nohup', 'exec', 'sudo', 'nice', 'env', 'xargs', 'corepack', 'timeout', 'stdbuf'])
12const SHELLS = /^(bash|sh|zsh|dash|cmd|powershell|pwsh)(\.exe)?$/i
13const RUN_FLAG = /^(-[a-z]*c|\/c|-command)$/i
14const HEREDOC = /<<-?[ \t]*(['"]?)([\w.-]+)\1/y
15
16function expand(words: string[]): string[][] {
17  let w = words.filter(x => x !== '{' && x !== '}')
18  while (w.length > 1) {
19    if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(w[0])) w = w.slice(1) // FOO=1 cmd
20    else if (w[0].startsWith('$') && w[1] === '=') w = w.slice(2) // $x = cmd
21    else if (WRAPPERS.has(w[0].toLowerCase())) {
22      w = w.slice(1)
23      while (w.length > 1 && /^-|^\d+[smhd]?$/.test(w[0])) w = w.slice(1) // sudo -E, timeout 30
24    } else break
25  }
26  const run = w.findIndex(x => RUN_FLAG.test(x))
27  if (w.length && SHELLS.test(w[0]) && run > 0 && run < w.length - 1) return segments(w.slice(run + 1).join(' '))
28  return w.length ? [w] : []
29}
30
31export function segments(command: string): string[][] {
32  const out: string[][] = []
33  let words: string[] = []
34  let word = ''
35  let quote: string | null = null
36  let has = false
37  const subs: (string | null)[] = [] // the quote each open ( or $( returns to at its )
38  let bodies: string[] = [] // heredoc end tags whose bodies start at the next newline
39  // A heredoc body is data (cat > notes.md <<EOF): skip to the line after its end tag.
40  const skipBodies = (at: number) => {
41    for (const tag of bodies) {
42      while (at < command.length) {
43        const end = command.indexOf('\n', at + 1)
44        const line = command.slice(at + 1, end < 0 ? command.length : end)
45        at = end < 0 ? command.length : end
46        if (line.trim() === tag) break
47      }
48    }
49    bodies = []
50    return at
51  }
52  const endWord = () => {
53    if (has) words.push(word)
54    word = ''
55    has = false
56  }
57  const endSegment = () => {
58    endWord()
59    if (words.length) out.push(...expand(words))
60    words = []
61  }
62  for (let i = 0; i < command.length; i++) {
63    const c = command[i]
64    const next = command[i + 1]
65    if (quote === '"' && c === '$' && next === '(') {
66      endSegment() // "$(cat x)" still runs cat x
67      subs.push(quote)
68      quote = null
69      i++
70    } else if (quote) {
71      if (c === quote) quote = null
72      else word += c
73    } else if (c === '"' || c === "'") {
74      quote = c
75      has = true
76    } else if (c === '(' || (c === '$' && next === '(')) {
77      if (c === '$') i++
78      endSegment()
79      subs.push(null)
80    } else if (c === ')') {
81      endSegment()
82      quote = subs.pop() ?? null
83    } else if (c === '<' && next === '<') {
84      HEREDOC.lastIndex = i
85      const m = command[i + 2] === '<' ? null : HEREDOC.exec(command)
86      if (m) {
87        endWord()
88        if (!SHELLS.test(words[0] ?? '')) bodies.push(m[2]) // bash <<EOF runs its body: read it
89        i = HEREDOC.lastIndex - 1
90      } else {
91        while (command[i + 1] === '<') word += command[i++] // <<< here-string
92        word += c
93        has = true
94      }
95    } else if (c === '&' && (next === '>' || command[i - 1] === '>' || command[i - 1] === '<')) {
96      word += c // 2>&1 and &> are redirects, not separators
97      has = true
98    } else if (c === '\n' || c === ';' || c === '|' || c === '&' || c === '`') {
99      if ((c === '&' || c === '|') && next === c) i++
100      endSegment()
101      if (c === '\n' && bodies.length) i = skipBodies(i)
102    } else if (c === '<' && next !== '<') {
103      endWord()
104      words.push('<')
105    } else if (/\s/.test(c)) {
106      endWord()
107    } else {
108      word += c
109      has = true
110    }
111  }
112  endSegment()
113  return out
114}
115