SLOPSHOPPER

secret-guard

Keeps secrets out of the conversation: hides API keys and private keys before the model or the transcript sees them, and blocks reads of credential files and…

newguardstatus
★ 5v0.1.8MITupdated 2026-10-09arasovic/claude-code-mods/secret-guard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · secret-guard
› 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-guard: secret-guard: .env is a credential file. Do not try another way to read it. Use a ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ secret-guard: 🔴 secrets: none seen, 1 blocked · env scrub off
README

secret-guard

Keeps secrets out of the conversation. API keys and private keys are hidden before Claude reads them, credential files cannot be opened, and commands that print secrets do not run.

secret-guard's status light

What it does

  • Hides secrets in what tools return. Known key formats become [secret-guard: <rule>] before Claude reads the result, and the session transcript on disk keeps the hidden form too. Formats: AWS, GitHub, Slack, Stripe, Google, Anthropic and OpenAI keys, private key blocks, JWTs, and secret-named values such as DB_PASSWORD=… or "client_secret": "…".
  • Hides secrets you paste. A key in your own message reaches Claude and the transcript in the same hidden form.
  • Blocks credential files. Reading or editing .env files, .dev.vars, .envrc, SSH and GPG private keys, ~/.aws/credentials, gcloud, Azure, kube and Docker logins, ~/.config/gh/hosts.yml, .npmrc, .pypirc, .netrc, shell history, Terraform state, browser password stores and the macOS keychain. A link to one of these files is followed and blocked too. Templates (.env.example, .env.sample, .env.template, .env.defaults, .env.dist) and public keys (*.pub) stay readable.
  • Blocks commands that print secrets. printenv, bare env and export, security find-generic-password, gh auth token, aws configure get, gcloud auth print-access-token, kubectl get secret, git credential fill, echo $SOME_TOKEN, and any shell command that names a credential file, such as cat .dev.vars | curl ….
  • Blocks sending a secret out. A command, web request or MCP call whose arguments carry a key is refused.
  • Shows a status light. 🟢 nothing seen, 🟡 something was hidden, 🔴 something was blocked. env scrub off means Claude Code still passes your environment variables to the commands it runs (see below).

Turn on env scrubbing too

Set CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=1 in the env block of ~/.claude/settings.json. Claude Code then removes its own credentials from the environment of the commands it runs, so there is nothing for a command to print. The mod cannot set this for you; the status light shows when it is off.

Limits

  • A script Claude writes and runs (python -c …, node -e …) can open any file. The mod cannot see what it opens; it hides known key formats in the output, nothing more.
  • The mod does not read quoted text as commands. So grep -E "(export )?" file runs.
  • The mod treats a heredoc as data when cat or tee writes it out with a quoted delimiter, as in cat > notes.py <<'EOF'. It does not check that body. This holds only when the opener line has nothing but && steps before it, and no {, ( or $( is still open.
  • The mod checks every other heredoc body. python3 - <<'EOF' runs its body as code. A <<EOF body runs any $(…) in it. So a credential file name (.env) in such a body is blocked.
  • The mod does not read quotes in a heredoc body that runs. So a quoted env or export there is blocked, as in grep -E "(export )?" f in a bash <<'EOF' body, or print('env') in a python3 <<'EOF' body.
  • The mod splits commands with a simple reader, not a full shell parser. A command built to fool it gets through. Examples:
  • a heredoc end line with extra spaces, or with a \ on the line before it
  • a delimiter such as <<$END or <<A-B!
  • a << inside a quote or a comment
  • nested escaped backticks, or $(( '$(…)' ))
  • a function that redefines cat or tee
  • a quoted bracket before an open { or (, as in { echo ")" && cat <<'EOF' … } | bash, because the mod counts brackets without reading quotes

The mod still hides known key formats in the output.

  • Secrets in a format it does not know pass through.
  • A name followed by ( is read as a function call, not a file, so grep "mock.env(" tests runs. A zsh glob qualifier on a credential file (cat .env(N)) gets through the same way; known key formats in its output are still hidden. Code names such as process.env, import.meta.env and c.env are not files either, but a script that prints the whole environment (console.log(process.env), print(os.environ)) is blocked.
  • Commands that touch a credential file without printing it still run: ls, stat, test / [, touch, chmod, chown, rm. cp and mv run when the credential file is the target (cp .env.example .env), not when it is the source, so a file cannot be copied to a new name and read there.
  • The screen can show a row for a moment before it is rewritten. Claude and the transcript only read the hidden form.
  • A value hidden in source code, such as a test token, is hidden from Claude as well. Claude can still edit the other lines of that file. A write that would put a [secret-guard: <rule>] tag back into a file is refused, so the real value is never overwritten; Claude will ask you to change that line.

Install

claude plugin marketplace add arasovic/claude-code-mods
claude plugin install secret-guard@claude-code-mods

Restart Claude Code. The status light appears under the prompt.

Develop

claude plugin validate .
claude plugin test .
../typecheck.sh secret-guard
Source 2 files
hooks/register.ts 201 lines
1import type { EngineInterface, Register } from 'claude-code'
2import { commandSegments, withoutDataHeredocs } from './shell-read'
3
4// Derived from gitleaks' default rules, loosened where key formats change.
5const SECRET_RULES: readonly [string, RegExp][] = [
6  ['private-key', /-----BEGIN[ A-Z0-9_-]{0,100}PRIVATE KEY(?: BLOCK)?-----[\s\S]*?(?:-----END[ A-Z0-9_-]{0,100}PRIVATE KEY(?: BLOCK)?-----|$)/g],
7  ['aws-key', /\b(?:A3T[A-Z0-9]|AKIA|ASIA|ABIA|ACCA)[A-Z2-7]{16}\b/g],
8  ['github-token', /\b(?:gh[pousr]_[0-9A-Za-z]{36}|github_pat_\w{82})\b/g],
9  ['slack-token', /\bxox[abpers]-[0-9A-Za-z-]{10,}/g],
10  ['slack-webhook', /hooks\.slack\.com\/(?:services|workflows|triggers)\/[A-Za-z0-9+/]{43,56}/g],
11  ['stripe-key', /\b[sr]k_(?:test|live|prod)_[0-9A-Za-z]{10,99}\b/g],
12  ['google-api-key', /\bAIza[\w-]{35}(?![\w-])/g],
13  ['anthropic-key', /\bsk-ant-[\w-]{20,}/g],
14  ['openai-key', /\bsk-(?:proj|svcacct|admin)-[\w-]{20,}/g],
15  ['jwt', /\bey[A-Za-z0-9_-]{17,}\.ey[A-Za-z0-9_-]{17,}\.[A-Za-z0-9_-]{10,}/g],
16]
17
18// Only the value is hidden; the name stays so the model knows the key exists.
19const ASSIGNMENTS = [
20  // .env style: UPPER_CASE name, unquoted or quoted value, not a $reference or <placeholder>
21  /^(?<head>\s*(?:export\s+)?[A-Z0-9_]*(?:SECRET|TOKEN|PASSWORD|PASSWD|API_?KEY|ACCESS_?KEY|PRIVATE_?KEY|CREDENTIAL)[A-Z0-9_]*\s*=\s*["']?)(?<value>[^\s"'#$<]{12,})/gm,
22  // code, JSON, YAML: a quoted literal assigned to a secret-shaped name
23  /(?<head>["']?\b[\w.-]*(?:secret|token|password|passwd|api_?key|access_?key|private_?key)["']?\s*[:=]\s*(["']))(?<value>[^"'\s$<{]{12,})(?=\2)/gi,
24]
25
26const tag = (rule: string) => `[secret-guard: ${rule}]`
27const isExample = (match: string) => /example|dummy|placeholder/i.test(match)
28
29export const scrub = (text: string): { text: string; rules: string[] } => {
30  const rules: string[] = []
31  let out = text
32  for (const [rule, pattern] of SECRET_RULES) {
33    out = out.replace(pattern, match => {
34      // A cut-off private key runs to the end of the text, so the example check would read unrelated words.
35      if (rule !== 'private-key' && isExample(match)) return match
36      rules.push(rule)
37      return tag(rule)
38    })
39  }
40  for (const pattern of ASSIGNMENTS) {
41    out = out.replace(pattern, (match, ...args) => {
42      const { head, value } = args.at(-1) as { head: string; value: string }
43      if (isExample(value) || value.startsWith('[secret-guard')) return match
44      rules.push('secret-value')
45      return head + tag('secret-value')
46    })
47  }
48  return { text: out, rules }
49}
50
51// Rewrites every string inside a value, keeping its shape.
52export const scrubDeep = <T>(value: T, found: string[]): T => {
53  if (typeof value === 'string') {
54    const s = scrub(value)
55    found.push(...s.rules)
56    return s.text as T
57  }
58  if (Array.isArray(value)) return value.map(v => scrubDeep(v, found)) as T
59  if (value !== null && typeof value === 'object')
60    return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, scrubDeep(v, found)])) as T
61  return value
62}
63
64// Matched against the lowercased path with forward slashes and a leading slash.
65const SENSITIVE_PATHS: readonly RegExp[] = [
66  // `name.env` files, but not the code spellings a search names (`grep -rn process.env src`).
67  /\/\.env$/, /\/\.env\.(?!(example|sample|template|defaults|dist)$)[^/]+$/, /\/(?!(process|import\.meta|c)\.env$)[^/]+\.env$/,
68  /\/\.envrc$/, /\/\.dev\.vars(\.[^/]+)?$/, /\/\.flaskenv$/,
69  /\/\.(aws|gem|cargo|config\/git)\/credentials(\.toml)?$/, /\/credentials\.json$/, /\/service-account[^/]*\.json$/, /\/\.vault-token$/, /\/\.vault_pass$/,
70  // A bare `e.key` or `event.key` in a command is code reading a field (`store.get(e.key)`), not a key file; a path to one still counts.
71  /^(?!\/([a-z]|this|self|event|evt|entry|item|node|props|row|pair|kv|obj)\.key$).*\.(key|p12|pfx|jks|keystore|ppk)$/,
72  /\/id_(rsa|dsa|ecdsa|ed25519)(_sk)?$/,
73  /\/\.ssh\/(?!(known_hosts[^/]*|config|authorized_keys|[^/]+\.pub)$)[^/]+$/,
74  /\/\.(npmrc|pypirc|netrc|git-credentials|pgpass|my\.cnf|s3cfg)$/, /\/_netrc$/,
75  /\/\.(bash|zsh|sh|python|node_repl|psql|mysql|sqlite)_history$/, /\/\.zhistory$/, /\/fish_history$/,
76  /\/\.aws\/(sso|cli)\/cache\//, /\/\.config\/gcloud\/(credentials\.db|access_tokens\.db|application_default_credentials\.json|legacy_credentials\/)/,
77  /\/\.azure\/(accesstokens\.json|msal_token_cache[^/]*)$/, /\/\.kube\/config$/, /\/\.docker\/config\.json$/,
78  /\/\.config\/gh\/hosts\.yml$/, /\/\.config\/glab-cli\/config\.yml$/, /\/\.config\/rclone\/rclone\.conf$/,
79  /\/\.gnupg\/[^/]+/, /\/\.terraform\.d\/credentials\.tfrc\.json$/,
80  /\.tfstate(\.backup)?$/, /\/terraform\.tfvars(\.json)?$/, /\.auto\.tfvars(\.json)?$/,
81  /\/\.claude\/\.credentials\.json$/, /\/\.codex\/auth\.json$/, /\/\.gemini\/oauth_creds\.json$/,
82  /\/library\/keychains\//, /\/library\/(application support|cookies)\/.*\/(login data|cookies|cookies\.sqlite|key4\.db|logins\.json)$/,
83  /\/proc\/[^/]+\/environ$/, /\/run\/secrets\//, /\/etc\/shadow$/, /\/etc\/ssh\/ssh_host_[^/]+_key$/,
84]
85
86export const sensitivePath = (path: string, home: string): boolean => {
87  const expanded = path.replace(/^(~|\$HOME|\$\{HOME\})(?=\/|$)/, home)
88  const p = ('/' + expanded.replace(/\\/g, '/')).replace(/\/+/g, '/').toLowerCase()
89  return SENSITIVE_PATHS.some(r => r.test(p))
90}
91
92const SECRET_COMMANDS: readonly RegExp[] = [
93  /^(printenv|compgen -v)\b/, /^(env|set|export|export -p|declare -p|declare -x)$/,
94  /^security (find-generic-password|find-internet-password|dump-keychain)\b/,
95  /^gh auth (token|status .*(-t|--show-token))\b/,
96  /^aws (configure (get|export-credentials)|sts get-session-token|secretsmanager get-secret-value|ssm get-parameters?\b.*--with-decryption)/,
97  /^gcloud auth (application-default )?print-(access|identity)-token\b/,
98  /^az account get-access-token\b/,
99  /^kubectl (config view .*--raw|get secrets?\b)/,
100  /^git (credential fill|config .*--get.*credential)/,
101  /^(op read|bw get|vault (read|kv get)|doppler secrets|heroku auth:token)\b/,
102  /^(echo|printf)\b.*\$\{?[A-Za-z0-9_]*(TOKEN|SECRET|PASSWORD|PASSWD|API_?KEY|ACCESS_?KEY|PRIVATE_?KEY|CREDENTIAL)/i,
103]
104
105// These touch a credential file without printing what is in it.
106const NON_READING = new Set(['ls', 'stat', 'test', '[', 'touch', 'chmod', 'chown', 'rm'])
107
108export const secretCommand = (command: string, home: string): string | undefined => {
109  // A script that prints the whole environment is printenv by another name.
110  const runnable = withoutDataHeredocs(command)
111  if (/\b(console\.log|print|JSON\.stringify|json\.dumps)\(\s*(process\.env|os\.environ)\s*\)/.test(runnable)) return 'printing the whole environment prints secrets'
112  for (const seg of commandSegments(runnable)) {
113    if (SECRET_COMMANDS.some(r => r.test(seg))) return `\`${seg.split(/\s+/).slice(0, 3).join(' ')}\` prints secrets`
114    const words = seg.split(/\s+|[<>]=?|=/).map(w => w.replace(/^["']|["']$/g, '')).filter(Boolean)
115    const cmd = words[0] ?? ''
116    if (NON_READING.has(cmd)) continue
117    // cp and mv read only their sources: a credential file as the target is setup, as the source it could be copied out and read.
118    const checked = cmd === 'cp' || cmd === 'mv' ? words.slice(1, -1) : words
119    const token = checked.find(w => sensitivePath(w, home))
120    if (token) return `${token} is a credential file`
121  }
122  return undefined
123}
124
125const FILE_TOOLS: Record<string, string> = { Read: 'file_path', Edit: 'file_path', Write: 'file_path', NotebookEdit: 'notebook_path' }
126const LOCAL_WRITES = new Set(['Write', 'Edit', 'NotebookEdit'])
127
128const EMITTED_TAG = new RegExp(`\\[secret-guard${': '}(${[...SECRET_RULES.map(([rule]) => rule), 'secret-value'].join('|')})\\]`)
129
130// An old_string holding a tag cannot match the file anyway, so checking every field costs nothing.
131export const writesPlaceholder = (args: Record<string, unknown>) => EMITTED_TAG.test(JSON.stringify(args))
132
133const READ_HINT = 'Do not try another way to read it. Use a template such as .env.example, or ask the user to run the step and share only what is safe.'
134const SEND_HINT = 'Take the secret out of the call. If the step needs it, ask the user to run it themselves.'
135const WRITE_HINT = 'That would replace the real value in the file. Edit only the lines you need and leave hidden values untouched, or ask the user to make the change.'
136
137// Module state starts over on reload; the counts are this load's.
138const tally = { hidden: 0, blocked: 0, scrubOff: true }
139let home = ''
140
141export const statusText = (t: typeof tally) => {
142  const light = t.blocked > 0 ? '🔴' : t.hidden > 0 ? '🟡' : '🟢'
143  const parts = [t.hidden > 0 ? `${t.hidden} hidden` : 'none seen', t.blocked > 0 ? `${t.blocked} blocked` : '']
144  return `${light} secrets: ${parts.filter(Boolean).join(', ')}${t.scrubOff ? ' · env scrub off' : ''}`
145}
146
147function show($: EngineInterface) {
148  $.ui.status(statusText(tally))
149}
150
151export const register: Register = on => {
152  on('session.start', async ($, e, next) => {
153    home = (await $.env.get('HOME')) ?? ''
154    tally.scrubOff = (await $.env.get('CLAUDE_CODE_SUBPROCESS_ENV_SCRUB')) !== '1'
155    show($)
156    return next(e)
157  })
158
159  on('tool.call', async ($, e, next) => {
160    const args = e as Record<string, unknown>
161    const deny = (reason: string, hint = READ_HINT) => {
162      tally.blocked++
163      show($)
164      return { deny: `secret-guard: ${reason}. ${hint}` }
165    }
166
167    const pathKey = FILE_TOOLS[e.tool]
168    const path = pathKey ? args[pathKey] : undefined
169    if (typeof path === 'string') {
170      if (sensitivePath(path, home)) return deny(`${path} is a credential file`)
171      const real = (await $.fs.stat(path, { resolve: true }).catch(() => undefined))?.realPath
172      if (real && sensitivePath(real, home)) return deny(`${path} leads to a credential file`)
173    }
174    if (e.tool === 'Bash' && typeof args.command === 'string') {
175      const reason = secretCommand(args.command, home)
176      if (reason) return deny(reason)
177    }
178    if (!LOCAL_WRITES.has(e.tool) && scrub(JSON.stringify(args)).rules.length > 0) return deny('this call would send a secret', SEND_HINT)
179    if (LOCAL_WRITES.has(e.tool) && writesPlaceholder(args)) return deny('this write contains a hidden-value tag', WRITE_HINT)
180
181    const ran = await next(e)
182    if (ran.deny !== undefined || ran.isError) return ran
183    const found: string[] = []
184    const result = scrubDeep(ran.result, found)
185    if (found.length === 0) return ran
186    tally.hidden += found.length
187    show($)
188    return { result, context: ran.context }
189  })
190
191  // Every other row: prompts the person pastes, model output, attachments, compaction summaries, error results.
192  on('session.append', ($, e, next) => {
193    const found: string[] = []
194    const content = scrubDeep(e.message.content, found)
195    if (found.length === 0) return next(e)
196    tally.hidden += found.length
197    show($)
198    return next({ ...e, message: { ...e.message, content } })
199  })
200}
201
hooks/shell-read.ts 127 lines
1// ponytail: split on shell operators outside quotes; sh -c, eval, globs, scripts the model writes and a quote left open across lines
2// pass through. Output scrubbing is the net for those.
3// A name right before `(` is a function call (`mock.env(on)`), not a file. Quoted text stays one segment, so `grep "(export )?"` is not
4// read as `export`; `$(` and backticks still run a command inside double quotes, so they split there too.
5export const commandSegments = (command: string): string[] => {
6  const text = command.replace(/[\w.-]+\(/g, '(')
7  const parts: string[] = []
8  const resumeQuotes: ('"' | null)[] = []
9  let current = ''
10  let quote: '"' | "'" | "$'" | null = null
11  let isBacktickInQuote = false
12  const endSegment = () => {
13    parts.push(current)
14    current = ''
15  }
16  for (let index = 0; index < text.length; index++) {
17    const char = text[index] ?? ''
18    const pair = text.slice(index, index + 2)
19    if (quote === "'") {
20      if (char === "'") quote = null
21      current += char
22      continue
23    }
24    // In `$'…'` a backslash escapes the next character, so `$'it\'s'` closes on its last quote.
25    if (quote === "$'") {
26      if (char === "'") quote = null
27      current += char === '\\' ? pair : char
28      if (char === '\\') index++
29      continue
30    }
31    if (char === '\\') {
32      // A backslash-newline joins two lines into one, as bash does.
33      if (pair !== '\\\n') current += pair
34      index++
35      continue
36    }
37    if (quote === '"') {
38      if (pair === '$(') {
39        endSegment()
40        resumeQuotes.push('"')
41        quote = null
42        index++
43      } else if (char === '`') {
44        endSegment()
45        isBacktickInQuote = true
46        quote = null
47      } else {
48        if (char === '"') quote = null
49        current += char
50      }
51      continue
52    }
53    if (char === '#' && /^$|[\s;&|()`<>]/.test(text[index - 1] ?? '')) {
54      // A comment runs to the end of the line, so a quote in it (`# don't`) opens nothing.
55      const end = text.indexOf('\n', index)
56      const stop = end === -1 ? text.length : end
57      current += text.slice(index, stop)
58      index = stop - 1
59    } else if (pair === "$'") {
60      quote = "$'"
61      current += pair
62      index++
63    } else if (char === "'" || char === '"') {
64      quote = char
65      current += char
66    } else if (pair === '&&' || pair === '||') {
67      endSegment()
68      index++
69    } else if (pair === '$(' || char === '(') {
70      endSegment()
71      resumeQuotes.push(null)
72      if (pair === '$(') index++
73    } else if (char === ')') {
74      endSegment()
75      quote = resumeQuotes.pop() ?? null
76    } else if (char === '`') {
77      endSegment()
78      if (isBacktickInQuote) quote = '"'
79      isBacktickInQuote = false
80    } else if (';|\n'.includes(char)) {
81      endSegment()
82    } else {
83      current += char
84    }
85  }
86  endSegment()
87  return parts.map(part => part.trim().replace(/^(sudo|command|exec|time|nohup)\s+/, '')).filter(Boolean)
88}
89
90// `cat` or `tee` writing a heredoc out is data, but only with a quoted delimiter: in a `<<EOF` body `$(…)` and backticks still run.
91// The opener line holds that command alone, after plain `&&` steps at most, so in `echo tee; python3 - <<'EOF'` or
92// `cat <<'EOF' | sh` the body is still read as commands. A step or a file name may be quoted (`cd "/my dir"`, `"$DIR/a.md"`), but
93// not hold `$(` or a backtick, and a trailing `# comment` is allowed. `\x60` is a backtick, which String.raw cannot hold.
94const QUOTED = String.raw`'[^']*'|"(?:[^"\x60\\$]|\$(?!\())*"`
95const AND_STEP = String.raw`(?:[^<>|;&'"\x60\\()#]|${QUOTED})*`
96const FILE_NAME = String.raw`(?:[^\s<>|;&'"\x60\\()#]|${QUOTED})+`
97const DATA_HEREDOC = new RegExp(
98  String.raw`^(?:${AND_STEP}&&)*\s*(?:cat(?:\s*>>?\s*${FILE_NAME})?|tee\s+(?:-a\s+)?${FILE_NAME})\s*<<-?\s*(['"])([\w.-]+)\1(?:\s*>>?\s*${FILE_NAME})?(?:\s+#.*)?\s*$`,
99)
100const HEREDOC = /(?<!<)<<(?!<)-?\s*\\?(['"]?)([\w.-]+)\1/g
101// ponytail: counts brackets without reading quotes, so a stray `)` in an earlier quote can hide a group; a parser if that matters.
102const isGrouped = (text: string) => (text.match(/[({]/g)?.length ?? 0) > (text.match(/[)}]/g)?.length ?? 0)
103
104// Drops data heredoc bodies. Any other body is kept, and no line in it opens a data heredoc. Inside an open `{`, `(`, `<(` or `$(` the
105// output of `cat` can go to bash (`{ cat <<'EOF' … } | bash`), so a body there is not data.
106export const withoutDataHeredocs = (command: string): string => {
107  const kept: string[] = []
108  let endings: string[] = []
109  let isDataBody = false
110  for (const line of command.split('\n')) {
111    if (endings.length > 0) {
112      if (line.trim() === endings[0]) endings.shift()
113      else if (isDataBody) continue
114      // A body is not read with shell quotes: in `<<EOF` they are plain text around a running `$(…)`, and python's `'''it's'''`
115      // is an odd count. So quotes and `#` there must not hide what follows.
116      kept.push(line.replace(/['"#]/g, ' '))
117      continue
118    }
119    // After a trailing `\` the line continues the one before it, so its heredoc can belong to another command.
120    const opener = kept.at(-1)?.endsWith('\\') || isGrouped([...kept, line].join('\n')) ? null : DATA_HEREDOC.exec(line)
121    isDataBody = opener !== null
122    endings = opener ? [opener[2] ?? ''] : [...line.matchAll(HEREDOC)].map(match => match[2] ?? '')
123    kept.push(line)
124  }
125  return kept.join('\n')
126}
127