Asks before, or blocks, destructive shell commands and writes to system paths.

L0 (no declared file writes, subprocess spawns or network egress). The manifest only declares environment reads of VANGUARD_BLAST_RADIUS_GUARD_MODE and VANGUARD_BLAST_RADIUS_GUARD_PATTERNS. The file_writes, spawns and egress_hosts arrays are empty. This is capability disclosure, not enforced isolation.
Checks every tool call before it runs. It asks for confirmation, or blocks, when the call looks destructive: a shell command from the built-in pattern list (recursive deletes of system or home paths, SQL drops, force pushes to protected branches, piping a download into a shell, and similar) or a write to a system path. Paths are normalized lexically first, so /tmp/../etc/passwd is caught.
VANGUARD_BLAST_RADIUS_GUARD_MODE: confirm (the default when unset) asks. Any other value, including a typo, blocks.VANGUARD_BLAST_RADIUS_GUARD_PATTERNS: optional path to a JSON file of extra patterns. They are appended to the built-in list and never replace it. If the file is set but cannot be read, is not valid JSON, is malformed, or reuses a built-in id, every call is denied.The extension file is an array of entries with the same keys as patterns.json: id (kebab-case, unique), name, description, regex, flags ("" or "i"), context (["bash"] for shell text or ["file-write"] for normalized paths) and severity.
| Harness | Surface | Behavior |
|---|---|---|
| Claude Code | native | tool.check guard. Never loosens the engine verdict; a hit asks (or denies in block mode). |
| Claude Code | classic | PreToolUse command hook; a hit asks (or denies in block mode). |
| Kiro | hook | Degraded. The whole payload is scanned, the system-path rule applies to the path keys whatever the tool, and confirm mode denies. |
| Codex | hook | Degraded. Codex rejects ask, so confirm mode denies with a reason. |
ask is settled by the permission mode, so a bypass mode may auto-approve.See mods/README.md.
hooks/register.ts 14 lines1import type { Register } from 'claude-code'
2import { checkToolUse, compileGuardPatterns, modeOf } from '../lib/core.mjs'
3
4export const register: Register = (on) => {
5 on('tool.check', async ($, e, next) => {
6 const v = await next(e)
7 if (v.decision === 'deny') return v
8 const mode = modeOf(await $.env.get('VANGUARD_BLAST_RADIUS_GUARD_MODE'))
9 const file = await $.env.get('VANGUARD_BLAST_RADIUS_GUARD_PATTERNS')
10 const patterns = compileGuardPatterns(file ? await $.fs.read(file) : undefined)
11 return checkToolUse({ tool: e.tool, input: e.input }, patterns, mode) ?? v
12 }).catch(() => ({ decision: 'deny', reason: 'blast-radius-guard: check failed; denied' }))
13}
14lib/core.mjs 67 lines1// Blast Radius Guard logic, shared by the native module and the Node hook so every
2// harness reaches the same verdict. Pure: no I/O, no environment.
3import { candidates, compilePatterns, matchPatterns, parseJson } from './common.mjs'
4import builtin from './patterns.mjs'
5
6const PATH_KEYS = new Set(['file_path', 'path', 'notebook_path', 'filePath'])
7const WRITE_TOOL = /write|edit|create|patch|delete|move|rename/i
8
9// Built-in patterns, plus the operator's extension appended (never replacing). Throws on
10// bad JSON or shape, or an id reused from the built-ins; callers fail closed.
11export function compileGuardPatterns(extraText, source = 'extension patterns') {
12 if (extraText === undefined) return compilePatterns(builtin, 'built-in patterns')
13 return compilePatterns([...builtin, ...parseJson(extraText, source)], source)
14}
15
16// Only an unset value or exactly `confirm` asks; any other value (typos included) blocks.
17export function modeOf(value) {
18 return value === undefined || value === 'confirm' ? 'confirm' : 'block'
19}
20
21// Lexical POSIX normalization: no cwd, `..` never climbs above an absolute root.
22export function normalizePosix(p) {
23 const abs = p.startsWith('/')
24 const out = []
25 for (const seg of p.split('/')) {
26 if (seg === '' || seg === '.') continue
27 if (seg !== '..') out.push(seg)
28 else if (out.length > 0 && out[out.length - 1] !== '..') out.pop()
29 else if (!abs) out.push('..')
30 }
31 const body = out.join('/')
32 return abs ? '/' + body : body || '.'
33}
34
35// String values of path-like keys at any depth.
36function pathValues(value, out = []) {
37 if (Array.isArray(value)) value.forEach((v) => pathValues(v, out))
38 else if (value !== null && typeof value === 'object') {
39 for (const [k, v] of Object.entries(value)) {
40 if (PATH_KEYS.has(k) && typeof v === 'string') out.push(v)
41 else pathValues(v, out)
42 }
43 }
44 return out
45}
46
47// undefined when nothing matched, else the ask/deny verdict naming every pattern hit.
48export function checkToolUse({ tool, input }, patterns, mode) {
49 const hits = new Set(matchPatterns(patterns, candidates(input), 'bash'))
50 if (typeof tool !== 'string' || WRITE_TOOL.test(tool)) {
51 for (const h of matchPatterns(patterns, pathValues(input).map(normalizePosix), 'file-write')) hits.add(h)
52 }
53 if (hits.size === 0) return undefined
54 const names = [...hits].map((h) => h.name)
55 return {
56 decision: mode === 'confirm' ? 'ask' : 'deny',
57 reason: `blast-radius-guard: ${[...new Set(names)].join('; ')}`,
58 }
59}
60
61// Node harnesses: without a tool_input object (Kiro) the whole payload is scanned.
62export function toolUseFromPayload(payload) {
63 const ti = payload.tool_input
64 const plain = ti !== null && typeof ti === 'object' && !Array.isArray(ti)
65 return { tool: typeof payload.tool_name === 'string' ? payload.tool_name : undefined, input: plain ? ti : payload }
66}
67lib/common.mjs 214 lines1// Shared helpers for the mods under mods/. scripts/generate-mods.mjs copies this file
2// byte-for-byte to mods/<id>/lib/common.mjs, so edit it here and run `npm run mods:write`.
3//
4// Pure on purpose: no imports, no environment, no I/O. Claude Code's native mod runtime
5// imports it and has no Node, and every harness must reach the same verdict on the same
6// input. Regexes compile without the `u` flag so Node and Python `re.ASCII`
7// (tests/validate-mods.py) read them the same way.
8
9export const LIMITS = Object.freeze({
10 // A redaction that still changes after this many passes is withheld as a whole.
11 maxPasses: 8,
12 // A guard candidate longer than this is not evaluated and counts as a hit.
13 maxCandidateChars: 65536,
14 // Guard candidates are not evaluated past this combined length; the call counts as a hit.
15 maxTotalChars: 262144,
16 // Only the tail of a leaf's context (key or previous sibling) is prefixed to it.
17 maxContextChars: 256,
18})
19
20const MARKER_PREFIX = '[REDACTED:'
21export const OVERFLOW_MARKER = '[REDACTED:overflow]'
22const OVERFLOW_HIT = Object.freeze({ id: 'overflow', name: 'redaction did not converge' })
23export const TOO_LARGE_HIT = Object.freeze({ id: 'too-large', name: 'input too large to evaluate' })
24
25const ID_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/
26const PATTERN_KEYS = new Set(['id', 'name', 'description', 'regex', 'flags', 'context', 'severity', 'agents'])
27const isStringArray = (v) => Array.isArray(v) && v.every((s) => typeof s === 'string' && s !== '')
28
29// Parser diagnostics can quote credential-bearing data; never expose it in hook logs.
30export function parseJson(text, source) {
31 try {
32 return JSON.parse(text)
33 } catch {
34 throw new Error(`${source}: invalid JSON`)
35 }
36}
37
38// Validates a patterns.json-shaped list and compiles it. Throws on anything malformed so a
39// bad extension file fails closed in the caller instead of silently matching nothing.
40export function compilePatterns(list, source = 'patterns') {
41 if (!Array.isArray(list)) throw new Error(`${source}: expected an array of patterns`)
42 const seen = new Set()
43 return list.map((p, i) => {
44 const where = `${source}[${i}]`
45 if (p === null || typeof p !== 'object' || Array.isArray(p)) throw new Error(`${where}: expected an object`)
46 for (const k of Object.keys(p)) if (!PATTERN_KEYS.has(k)) throw new Error(`${where}: unknown key ${k}`)
47 if (typeof p.id !== 'string' || !ID_RE.test(p.id)) throw new Error(`${where}: id must be kebab-case`)
48 if (seen.has(p.id)) throw new Error(`${where}: duplicate id ${p.id}`)
49 seen.add(p.id)
50 if (typeof p.name !== 'string' || p.name === '') throw new Error(`${where}: name must be a non-empty string`)
51 if (typeof p.regex !== 'string' || p.regex === '') throw new Error(`${where}: regex must be a non-empty string`)
52 if (p.flags !== '' && p.flags !== 'i') throw new Error(`${where}: flags must be "" or "i"`)
53 if (!isStringArray(p.context) || p.context.length === 0) throw new Error(`${where}: context must be a non-empty string array`)
54 for (const k of ['description', 'severity']) {
55 if (p[k] !== undefined && typeof p[k] !== 'string') throw new Error(`${where}: ${k} must be a string`)
56 }
57 if (p.agents !== undefined && !isStringArray(p.agents)) throw new Error(`${where}: agents must be a string array`)
58 let rx, rxg
59 try {
60 rx = new RegExp(p.regex, p.flags)
61 rxg = new RegExp(p.regex, 'g' + p.flags)
62 } catch {
63 throw new Error(`${where}: regex does not compile`)
64 }
65 return Object.freeze({
66 id: p.id,
67 name: p.name,
68 severity: p.severity,
69 context: Object.freeze([...p.context]),
70 agents: p.agents ? Object.freeze([...p.agents]) : undefined,
71 rx,
72 rxg,
73 marker: `${MARKER_PREFIX}${p.id}]`,
74 })
75 })
76}
77
78// Every string value (not key) in depth-first order. The order follows the input, so the
79// same payload always yields the same list.
80export function stringLeaves(value, out = []) {
81 if (typeof value === 'string') out.push(value)
82 else if (Array.isArray(value)) for (const v of value) stringLeaves(v, out)
83 else if (value !== null && typeof value === 'object') for (const v of Object.values(value)) stringLeaves(v, out)
84 return out
85}
86
87// Guard candidates: every string leaf, plus every all-string array joined with spaces so
88// an argv-style command (["rm", "-rf", "/"]) reads like the shell line it runs.
89export function candidates(value) {
90 const out = stringLeaves(value)
91 const joinArrays = (v) => {
92 if (Array.isArray(v)) {
93 if (v.length > 1 && v.every((s) => typeof s === 'string')) out.push(v.join(' '))
94 v.forEach(joinArrays)
95 } else if (v !== null && typeof v === 'object') {
96 Object.values(v).forEach(joinArrays)
97 }
98 }
99 joinArrays(value)
100 return out
101}
102
103// The patterns tagged with `context` that match any text, in catalog order. Oversized input
104// is not evaluated (regex cost is unbounded there) and reports TOO_LARGE_HIT instead.
105export function matchPatterns(patterns, texts, context) {
106 let total = 0
107 for (const t of texts) {
108 total += t.length
109 if (t.length > LIMITS.maxCandidateChars || total > LIMITS.maxTotalChars) return [TOO_LARGE_HIT]
110 }
111 return patterns.filter((p) => p.context.includes(context) && texts.some((t) => p.rx.test(t)))
112}
113
114// One pattern pass after another until the text stops changing. Each pass can expose a new
115// match (password-in-url can swallow an earlier marker), so a single pass is not enough.
116function fixedPoint(text, patterns, hits) {
117 let x = text
118 for (let pass = 0; pass <= LIMITS.maxPasses; pass++) {
119 let y = x
120 for (const p of patterns) {
121 y = y.replace(p.rxg, (m) => {
122 if (m === '') return m
123 hits.add(p)
124 return p.marker
125 })
126 }
127 if (y === x) return x
128 x = y
129 }
130 hits.add(OVERFLOW_HIT)
131 return OVERFLOW_MARKER
132}
133
134// Redacts `s` with `ctx` in front so a secret split across a key and its value, or across
135// argv elements ("Bearer", "<token>"), still matches; then strips the context back off.
136function redactLeaf(s, ctx, patterns, hits) {
137 if (ctx === '') return fixedPoint(s, patterns, hits)
138 const pre = ctx.slice(-LIMITS.maxContextChars) + ' '
139 const y = fixedPoint(pre + s, patterns, hits)
140 if (y === OVERFLOW_MARKER) return y
141 const r = fixedPoint(pre, patterns, new Set())
142 if (y.startsWith(r)) return fixedPoint(y.slice(r.length), patterns, hits)
143 // A match spans the boundary: keep from the marker that covers it. Slicing changes
144 // anchors and boundaries, so also check the standalone leaf after contextual matching.
145 let d = 0
146 while (d < r.length && y[d] === r[d]) d++
147 const i = y.lastIndexOf(MARKER_PREFIX, d)
148 return fixedPoint(i >= 0 ? y.slice(i) : y, patterns, hits)
149}
150
151function walk(v, ctx, patterns, hits) {
152 if (typeof v === 'string') {
153 const local = new Set()
154 const out = redactLeaf(v, ctx, patterns, local)
155 if (out !== v) for (const h of local) hits.add(h)
156 return out
157 }
158 if (Array.isArray(v)) {
159 let prev = null
160 return v.map((el) => {
161 const out = walk(el, typeof prev === 'string' ? prev : ctx, patterns, hits)
162 prev = el
163 return out
164 })
165 }
166 if (v !== null && typeof v === 'object') {
167 const named = typeof v.name === 'string' ? v.name : typeof v.key === 'string' ? v.key : null
168 // fromEntries, not assignment: `out[k] = v` would drop an own "__proto__" key.
169 return Object.fromEntries(
170 Object.entries(v).map(([k, x]) => [
171 walk(k, '', patterns, hits),
172 walk(x, k === 'value' && named !== null ? named : k, patterns, hits),
173 ]),
174 )
175 }
176 return v
177}
178
179// Returns a copy of `value` with every match replaced by `[REDACTED:<id>]`, plus the
180// patterns that fired (catalog order, unique). Keys are redacted too; two keys that redact
181// to the same marker collapse into one, which loses data but never leaks it.
182export function redactValue(value, patterns) {
183 const hits = new Set()
184 const out = walk(value, '', patterns, hits)
185 const order = [...patterns, OVERFLOW_HIT]
186 return { value: out, hits: order.filter((p) => hits.has(p)) }
187}
188
189// Same shape as `value`, every string replaced by `text`. The fallback when redaction
190// itself failed: withhold everything rather than pass anything through.
191export function maskValue(value, text) {
192 if (typeof value === 'string') return text
193 if (Array.isArray(value)) return value.map((v) => maskValue(v, text))
194 if (value !== null && typeof value === 'object') {
195 return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, maskValue(v, text)]))
196 }
197 return value
198}
199
200// JSON with object keys sorted at every level, so equal values hash equally.
201export function canonicalJson(value) {
202 if (Array.isArray(value)) return '[' + value.map((v) => (v === undefined ? 'null' : canonicalJson(v))).join(',') + ']'
203 if (value !== null && typeof value === 'object') {
204 const keys = Object.keys(value).filter((k) => value[k] !== undefined).sort()
205 return '{' + keys.map((k) => JSON.stringify(k) + ':' + canonicalJson(value[k])).join(',') + '}'
206 }
207 return JSON.stringify(value) ?? 'null'
208}
209
210export async function sha256Hex(text) {
211 const digest = await globalThis.crypto.subtle.digest('SHA-256', new TextEncoder().encode(text))
212 return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, '0')).join('')
213}
214lib/patterns.mjs 103 lines1// Generated by scripts/generate-mods.mjs from patterns.json. Do not edit.
2export default [
3 {
4 "id": "rm-rf-root-or-home",
5 "name": "rm -r on /, /*, ~, ~/, $HOME or a top-level system dir",
6 "description": "rm with a recursive flag (any of -r -R -rf -fr -Rf -rfv, split -r -f, --recursive) AND a whole-token target of / /* ~ ~/ ~/* $HOME ${HOME} (optionally quoted) or a top-level system dir (/etc /usr /var ...). Flag/target order-independent (lookahead AND). Force flag deliberately not required: rm -r / is as destructive. Known FP: the text inside echo/grep/commit messages.",
7 "regex": "(?:^|[^A-Za-z0-9_.-])rm(?=[ \\t])(?=[^;&|\\n]*[ \\t](?:-[A-Za-z]*[rR][A-Za-z]*|--recursive)(?![^ \\t\\r\\n;&|)]))(?=[^;&|\\n]*[ \\t][\"']?(?:/(?:(?:bin|boot|dev|etc|home|lib|lib32|lib64|opt|proc|root|sbin|srv|sys|usr|var)/?)?\\*?|~/?\\*?|\\$HOME/?\\*?|\\$\\{HOME\\}/?\\*?)[\"']?(?![^ \\t\\r\\n;&|)]))",
8 "flags": "",
9 "severity": "critical",
10 "context": [
11 "bash"
12 ]
13 },
14 {
15 "id": "sql-drop-object",
16 "name": "SQL DROP TABLE/DATABASE/SCHEMA",
17 "description": "DROP followed by whitespace/newline and TABLE|DATABASE|SCHEMA, case-insensitive, left/right word-bounded. Matches the SQL text inside psql -c / mysql -e / sqlcmd -Q strings. Does not see SQL inside files (psql -f x.sql) nor the dropdb CLI.",
18 "regex": "(?:^|[^A-Za-z0-9_])drop[ \\t\\r\\n]+(?:table|database|schema)(?![A-Za-z0-9_])",
19 "flags": "i",
20 "severity": "critical",
21 "context": [
22 "bash"
23 ]
24 },
25 {
26 "id": "git-force-push-protected",
27 "name": "git push --force / -f / --force-with-lease / +refspec to protected branch",
28 "description": "git [global opts] push with (a force flag AND a protected-branch token) OR a +refspec of a protected branch. Protected = main, master, release/*, prod, production, prod-*, prod_*, prod/*. Branch token may be bare, src:dst, or refs/heads/*. --force-with-lease IS included: the lease only guards against clobbering unseen commits, it still rewrites protected history. 'prod*' from the spec narrowed so product-page does not match. Bare 'git push -f' (current branch unknown) is NOT matched.",
29 "regex": "(?:^|[^A-Za-z0-9_.-])git(?:[ \\t]+-[^ \\t\\r\\n;&|]+(?:[ \\t]+[^ \\t\\r\\n;&|-][^ \\t\\r\\n;&|]*)?){0,6}[ \\t]+push(?![A-Za-z0-9_-])(?:(?=[^;&|\\n]*[ \\t](?:--force(?:-with-lease|-if-includes)?(?:=[^ \\t\\r\\n;&|]*)?|-[A-Za-z]*f[A-Za-z]*)(?![^ \\t\\r\\n;&|)]))(?=[^;&|\\n]*[ \\t][\"']?\\+?(?:[^ \\t\\r\\n;&|:]*:)?(?:refs/heads/)?(?:main|master|release/[^ \\t\\r\\n;&|\"']+|prod(?:uction)?|prod[-_/][^ \\t\\r\\n;&|\"']*)[\"']?(?![^ \\t\\r\\n;&|)]))|(?=[^;&|\\n]*[ \\t][\"']?\\+(?:[^ \\t\\r\\n;&|:]*:)?(?:refs/heads/)?(?:main|master|release/[^ \\t\\r\\n;&|\"']+|prod(?:uction)?|prod[-_/][^ \\t\\r\\n;&|\"']*)[\"']?(?![^ \\t\\r\\n;&|)])))",
30 "flags": "",
31 "severity": "high",
32 "context": [
33 "bash"
34 ]
35 },
36 {
37 "id": "pipe-remote-to-shell",
38 "name": "curl|wget piped to a shell",
39 "description": "curl or wget whose output is piped (possibly through intermediate filters) into sh/bash/zsh/dash/ksh/csh, optionally via sudo/env and an absolute path. '||' is not treated as a pipe. Interpreters other than shells (python, node, perl) are not covered.",
40 "regex": "(?:^|[^A-Za-z0-9_.-])(?:curl|wget)(?![A-Za-z0-9_-])[^;&\\n|]*(?:\\|(?!\\|)[^;&\\n|]*)*\\|(?!\\|)[ \\t]*(?:sudo(?:[ \\t]+-[^ \\t\\r\\n;&|]+(?:[ \\t]+[^ \\t\\r\\n;&|-][^ \\t\\r\\n;&|]*)?){0,4}[ \\t]+|env[ \\t]+)?(?:/[A-Za-z0-9_./-]*/)?(?:ba|z|da|k|c)?sh(?![A-Za-z0-9_.-])",
41 "flags": "",
42 "severity": "high",
43 "context": [
44 "bash"
45 ]
46 },
47 {
48 "id": "remote-script-substitution",
49 "name": "shell/source/eval of curl|wget command substitution",
50 "description": "bash <(curl ...), sh -c \"$(curl ...)\", eval \"$(wget ...)\", source <(curl ...), . <(curl ...). Same threat as pipe-to-shell via process/command substitution.",
51 "regex": "(?:^|[^A-Za-z0-9_.-])(?:(?:ba|z|da|k)?sh(?:[ \\t]+-[A-Za-z]+)*|source|eval|\\.)[ \\t]+[\"']?(?:<\\(|\\$\\()[ \\t]*(?:curl|wget)(?![A-Za-z0-9_-])",
52 "flags": "",
53 "severity": "high",
54 "context": [
55 "bash"
56 ]
57 },
58 {
59 "id": "terraform-destroy-auto-approve",
60 "name": "terraform/tofu/terragrunt destroy with -auto-approve",
61 "description": "terraform|tofu|terragrunt with a destroy token (destroy or -destroy, so 'apply -destroy' is included) AND -auto-approve/--auto-approve, in either order. Plain 'terraform destroy' (interactive confirm; no TTY in agent shells) and plain 'terraform apply -auto-approve' are deliberately NOT matched: apply is covered by live-guard-gate, and matching all auto-approved applies would double-prompt and flag routine CI-style usage.",
62 "regex": "(?:^|[^A-Za-z0-9_.-])(?:terraform|tofu|terragrunt)(?![A-Za-z0-9_-])(?=[^;&|\\n]*[ \\t]-?destroy(?![^ \\t\\r\\n;&|)]))(?=[^;&|\\n]*[ \\t]--?auto-approve(?:=[^ \\t\\r\\n;&|]*)?(?![^ \\t\\r\\n;&|)]))",
63 "flags": "",
64 "severity": "critical",
65 "context": [
66 "bash"
67 ]
68 },
69 {
70 "id": "kubectl-delete-namespace",
71 "name": "kubectl/oc delete namespace",
72 "description": "kubectl|oc ... delete [flags] namespace|namespaces|ns (also ns/<name>). Flags between delete and the resource kind are allowed (--force --grace-period=0).",
73 "regex": "(?:^|[^A-Za-z0-9_.-])(?:kubectl|oc)(?![A-Za-z0-9_-])(?:[ \\t]+-[^ \\t\\r\\n;&|]+(?:[ \\t]+[^ \\t\\r\\n;&|-][^ \\t\\r\\n;&|]*)?){0,8}[ \\t]+delete(?:[ \\t]+-[^ \\t\\r\\n;&|]+(?:[ \\t]+[^ \\t\\r\\n;&|-][^ \\t\\r\\n;&|]*)?){0,6}[ \\t]+(?:namespaces?|ns)(?:/|,|(?![^ \\t\\r\\n;&|)]))",
74 "flags": "",
75 "severity": "critical",
76 "context": [
77 "bash"
78 ]
79 },
80 {
81 "id": "kubectl-delete-all",
82 "name": "kubectl/oc delete ... --all",
83 "description": "kubectl|oc with a delete token AND --all (or --all=true). --all-namespaces is excluded by the terminator check.",
84 "regex": "(?:^|[^A-Za-z0-9_.-])(?:kubectl|oc)(?![A-Za-z0-9_-])(?:[ \\t]+-[^ \\t\\r\\n;&|]+(?:[ \\t]+[^ \\t\\r\\n;&|-][^ \\t\\r\\n;&|]*)?){0,8}[ \\t]+delete(?![^ \\t\\r\\n;&|)])(?=[^;&|\\n]*[ \\t]--all(?:=[^ \\t\\r\\n;&|]*)?(?![^ \\t\\r\\n;&|)]))",
85 "flags": "",
86 "severity": "high",
87 "context": [
88 "bash"
89 ]
90 },
91 {
92 "id": "file-write-system-path",
93 "name": "File write into a system directory",
94 "description": "Anchored at path start: /etc/ /usr/ /bin/ /sbin/ /boot/ /sys/ /proc/ (leading // tolerated). Anchoring avoids matching myproject/etc/x or /home/u/usr/x, which the spec's unanchored /(etc|usr|...)/ would. The caller MUST resolve the path (path.resolve / realpath -m) first; ../../etc/passwd or symlinks are not caught by the pattern.",
95 "regex": "^/+(?:etc|usr|bin|sbin|boot|sys|proc)/",
96 "flags": "",
97 "severity": "critical",
98 "context": [
99 "file-write"
100 ]
101 }
102];
103