fix(security): patch six Claude Security scan findings (F1–F6) - #1097
Conversation
… build (F3, F4, F6) Source URLs parsed from briefing markdown (parse-briefing.js) flowed to every URL sink with no scheme allowlist and no private-host filter, enabling three issues found by the Claude Security scan: - F3 (XSS, CWE-79): a javascript:/data: bullet source URL reached the deck anchor href unfiltered (escape() encodes only & < > "). - F4 (SSRF, CWE-918): the linkinator reachability gate fetched attacker-controlled URLs (e.g. http://169.254.169.254, 127.0.0.1, RFC1918) from the build host with no private-host filter. - F6 (input validation, CWE-20): a file:// UNC URL was embedded as a clickable PPTX hyperlink (NTLM leak on click). Fix, via one shared seam in lib/url-policy.js reused at every sink: - isAllowedUrlScheme() allowlists http/https/mailto/tel; the schema (lib/schema.js) refines Url with it (root defense), and the HTML (build-sections.js) and PPTX (build-pptx.js buildNews/buildCondensed) sinks filter bullet URLs before emitting (defense in depth). - shouldSkipLinkCheck() now also skips literal private/loopback/ link-local/reserved hosts (incl. decimal/hex/octal IPv4 and IPv4-mapped IPv6 via WHATWG canonicalization), so linkinator never fetches them. Benign http/https/mailto/tel links are preserved; javascript:/data:/ file: and rarer schemes (ftp:, etc.) are rejected/dropped as deliberate, documented hardening. Residual: a public hostname that resolves to a private address at fetch time (DNS rebind) is not gated offline, since linkinator resolves DNS itself — documented in the CHANGELOG and code. Verified by a panel of agents (independent verifier + fresh adversarial reviewer of the diff); `node --test` in the build dir passes 27/27, including new schema and sink coverage. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…-utils redaction (F1, F5)
F1 (code injection, CWE-94): editing any markdown file auto-ran
markdownlint-cli2, which loads and executes a repo-supplied
.markdownlint-cli2.cjs/.mjs (or customRules/markdownItPlugins/
outputFormatters module identifiers) as Node code. The hook only emitted
a one-time non-blocking advisory and ran the linter anyway, so a
malicious repo's checked-in config achieved arbitrary code execution on a
routine edit.
Fix: a fail-closed trust gate ahead of the linter. When a code-loading
config is discovered, the lint run is skipped — with a visible
once-per-session notice naming the exact approval command — unless a
content-addressed approval marker exists under
${CLAUDE_PLUGIN_DATA}/trust-approvals. Any config change revokes the
approval; the gate fails closed when CLAUDE_PLUGIN_DATA is unavailable.
Declarative rule-only configs are unaffected; the edit is never blocked
(hook always exits 0).
Also carries the shared lib/hook-utils.sh fix (F5): the per-plugin
hook-utils.sh copy is re-synced so a bare/trailing unquoted NAME=value
Bash command no longer leaks its value into the telemetry/audit subject.
Verified by a panel of agents; the plugin's contract suite passes 69/69
(11 new trust-gate assertions) and shellcheck is clean.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…c hook-utils redaction (F2, F5)
F2 (code injection, CWE-94): editing a PowerShell file auto-ran
PSScriptAnalyzer with the repo's PSScriptAnalyzerSettings.psd1. A
settings file declaring CustomRulePath makes the analyzer load and
execute repo-supplied rule modules, so a malicious repo achieved
arbitrary PowerShell execution on a routine edit.
Fix: a fail-closed trust gate inside the pwsh invocation, before
Set-Location/Invoke-Formatter. CustomRulePath is detected with
Import-PowerShellDataFile (PowerShell's restricted, non-executing
data-file parser — a textual scan is evadable by backtick-escaped keys);
the run is skipped unless a content-addressed approval marker exists
under ${CLAUDE_PLUGIN_DATA}/trust-approvals. Any settings change revokes
the approval; a settings file the restricted parser cannot read also
fails closed. Settings without CustomRulePath run exactly as before; the
edit is never blocked (hook always exits 0).
Also carries the shared lib/hook-utils.sh fix (F5): the per-plugin
hook-utils.sh copy is re-synced so a bare/trailing unquoted NAME=value
Bash command no longer leaks its value into the telemetry/audit subject.
Verified by a panel of agents; the plugin's contract suite passes 54/54
(new gate + escaped-key evasion cases) against real pwsh + PSScriptAnalyzer,
and shellcheck is clean.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…(F5) F5 (info disclosure, CWE-532): hook::extract_bash_subject in the shared lib/hook-utils.sh builds a privacy-safe telemetry/audit subject for Bash commands. Its prefix-strip loop removed a leading NAME=value token only when a further command word followed it, so a command whose LAST token was an unquoted assignment (e.g. the whole command is TOKEN=ghp_secret) survived to the subject and was emitted verbatim as Bash:TOKEN=ghp_secret into .claude/observability/hook-events.jsonl and any wired HOOK_TELEMETRY_SINK — leaking the credential value into the same observability store these hooks otherwise redact paths for. Fix: a resolved first_token still shaped like a shell assignment (^[a-zA-Z_][a-zA-Z0-9_]*=) now bails to the bare `Bash` subject, matching the existing quoted-value bail. Placed before the basename strip so a path-valued assignment (TOKEN=/a/b/secret) cannot leak via the tail. VAR=x cmd still reduces to Bash:cmd; the subject feeds only telemetry/ audit emission, so no guard's block/allow decision changes. lib/hook-utils.sh is the source of truth; scripts/sync-hook-utils.sh propagates it to every per-plugin copy, and the repo's coupled sync/changelog gates require each carrying plugin to bump its version and add a CHANGELOG entry. This commit carries the lib fix and the remaining carrying plugins (markdown-format and powershell-format ride with their own security fixes in the preceding commits); guardrails is bumped to 0.12.3 over the version it already carries on main. Verified by a panel of agents; lib/hook-utils.test.sh passes 90/90 (7 new subject cases), sync-hook-utils.sh --check confirms all 12 copies match, gitleaks is clean, and shellcheck is clean. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
Claude encountered an error after 0s —— View job I'll analyze this and get back to you. |
|
Claude encountered an error after 0s —— View job I'll analyze this and get back to you. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5debbb7166
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 50e1ee750d
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
# Conflicts: # plugins/ai-briefing/CHANGELOG.md # plugins/claude-ops/.claude-plugin/plugin.json # plugins/claude-ops/CHANGELOG.md # plugins/guardrails/.claude-plugin/plugin.json # plugins/guardrails/CHANGELOG.md # plugins/markdown-format/.claude-plugin/plugin.json # plugins/markdown-format/CHANGELOG.md # plugins/rate-limit-guard/.claude-plugin/plugin.json # plugins/rate-limit-guard/CHANGELOG.md
…SRF gate to all non-global blocks Review-round hardening of the F1/F2/F4 patches: - markdown-format (F1): the approval signature is now content-addressed over the risky configuration AND every repository file its string literals resolve to (transitively, bounded 64), so a referenced rule module changing under an unchanged config revokes the approval. Module-key detection drops the key-colon anchor (YAML explicit-key syntax splits key and colon) and classifies escape/tag constructs able to hide a key spelling (JSONC \uXXXX; YAML \x/\u/\U, escaped line joins, !! tags) as unverifiable: gated with no approval route. - powershell-format (F2): the approval signature moves into the pwsh block where the restricted parser resolves CustomRulePath, and covers the settings file plus every file reachable under each declared entry (recursive for directories, bounded 512). An entry that does not resolve to hashable content leaves the state unverifiable and fail-closed. The pwsh block hands the verdict to the shell as structured PSSA_TRUST lines. - ai-briefing url-policy (F4): the literal-host SSRF gate now refuses every non-global IANA special-purpose block, not just RFC1918 - shared address space 100.64/10, benchmarking 198.18/15, TEST-NETs, 192.0.0/24, multicast, reserved, broadcast; IPv6 ff00::/8, 100::/64, 2001:db8::/32, 3fff::/20, and NAT64 64:ff9b::/96 judged by its embedded IPv4 address. - autonomy: hook-utils.sh sync of the F5 fix reached the copy added on main (0.11.1). Suites: markdown-format 78/78, powershell-format 56/56, ai-briefing node --test 28/28, hook-utils 92/92; sync + changelog-parity + validate + catalog + silent-skips + portability gates green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012SV88ZgGDKdUKEPJaJtDpG
# Conflicts: # plugins/rate-limit-guard/.claude-plugin/plugin.json # plugins/rate-limit-guard/CHANGELOG.md
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 26fefc258e
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
# Conflicts: # plugins/claude-ops/.claude-plugin/plugin.json # plugins/claude-ops/CHANGELOG.md
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5d17b0e386
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
# Conflicts: # plugins/actionlint/CHANGELOG.md # plugins/ai-briefing/CHANGELOG.md # plugins/bash-format/CHANGELOG.md # plugins/biome-format/CHANGELOG.md # plugins/claude-ops/CHANGELOG.md # plugins/desktop-notification/CHANGELOG.md # plugins/eol-normalizer/CHANGELOG.md # plugins/go-format/CHANGELOG.md # plugins/guardrails/CHANGELOG.md # plugins/markdown-format/.claude-plugin/plugin.json # plugins/markdown-format/CHANGELOG.md # plugins/powershell-format/.claude-plugin/plugin.json # plugins/powershell-format/CHANGELOG.md # plugins/ruff-format/CHANGELOG.md # plugins/typos-format/CHANGELOG.md
|
Claude encountered an error after 2s —— View job I'll analyze this and get back to you. |
|
Claude encountered an error after 2s —— View job I'll analyze this and get back to you. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 35a2e60b4d
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…oor, bash 3.2, DNS gate, assignment forms
Addresses the six round-two Codex findings on the F1/F2/F4/F5 patches:
- markdown-format: module tracking resolves Node CommonJS candidates
(.cjs/.mjs/.js/.json/.node and directory package.json/index.*) so an
extensionless require("./rules/local-rule") pins local-rule.cjs; the trust
gate is bash 3.2-clean (no associative arrays; every empty-array expansion
guarded for set -u).
- powershell-format: the signature adds transitive string-literal references
of each collected rule file (bounded; unreadable file or overflow is
UNVERIFIABLE), and hashing uses instance SHA256.ComputeHash + x2 formatting
(the static HashData/ToHexString shortcuts are .NET 5+, absent on the
PowerShell 7.0 floor) with any failure emitting UNVERIFIABLE - an empty
signature can never mint a shared marker.
- ai-briefing url-policy: DNS-name hosts are resolved at gate time (all
A/AAAA records) and refused when any resolved address is non-global;
unresolvable or unreadable answers fail closed. Resolver injectable for
tests. Residual (rebind between gate and fetch; redirect hops inside the
checker) documented.
- hook-utils: the trailing-assignment bail recognizes every valid Bash
assignment form - NAME=v, NAME+=v, NAME[i]=v, NAME[i]+=v - so append and
subscripted assignments no longer leak values into the telemetry subject.
Synced to all 14 copies.
Suites: markdown-format 79/79, powershell-format 58/58, ai-briefing node
--test 31/31, hook-utils 95/95; shellcheck clean; parity/sync/validate/
catalog/silent-skips gates green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012SV88ZgGDKdUKEPJaJtDpG
|
Claude finished @kyle-sexton's task in 5m 5s —— View job PR Review: fix(security): patch six Claude Security scan findings (F1–F6)
SummaryThis PR patches all six original findings (F1–F6). After reviewing the full diff at HEAD ( F5 — hook-utils.sh assignment redactionThe new regex
The multi-assignment case ( F3/F4/F6 — ai-briefing URL policyThe scheme allowlist is enforced at three independent layers:
The The DNS resolution layer in One observation (non-blocking): F1 — markdown-format trust gateThe two-tier textual over-approximation for JSONC/YAML key detection is the right call over building a second parser (which would open a differential-parsing gap). Specifically:
The Bash 3.2 compatibility is maintained throughout: dedup state uses newline-delimited strings rather than One edge note (non-blocking): The string-literal extractor F2 — powershell-format trust gateThe PS 7.0 compatibility fix — using The transitive scanning loop collects string literals from each rule file and rescans any file hits, bounded at 256 scans / 512 files. An Test coverageThe test suites are thorough:
VerdictAll six original findings are patched and all Codex P1s from the multi-round review are addressed in the current code. The implementation is careful and the documented residuals (DNS rebinding window, computed dot-source transitive deps) are appropriately scoped. No changes required from this review — the |
|
Claude finished @kyle-sexton's task in 9m 13s —— View job Security Review — PR #1097
|
| Area | Verdict |
|---|---|
| F1 — markdown-format trust gate | Well-implemented. Content-addressed signature covers configs + resolved module graph. Fail-closed on unverifiable state. Bash 3.2 compatibility guards are correct. |
| F2 — powershell-format trust gate | Well-implemented. Signature computed inside pwsh using Import-PowerShellDataFile (prevents backtick/quoting bypasses a textual grep would miss). SHA256 uses .NET 3.1-compatible instance API. PSSA_TRUST line parsing is safe (pwsh never writes user-controlled content to stdout in that block). |
F3 — XSS via javascript:/data: href |
isAllowedUrlScheme allowlist applied at schema validation and every HTML/PPTX sink. escape(u) encodes " so attribute injection is not possible. |
| F4 — SSRF in link reachability gate | DNS pre-resolution gate covers all A/AAAA records; fail-closed on non-resolving names. isPrivateIPv4 covers the full IANA special-purpose registry. Residual (DNS rebind, redirect hops) is documented. |
F6 — file:// UNC NTLM leak |
file: scheme not in allowlist; blocked at schema time and all sinks. |
| F5 assignment forms (common) | NAME=val, NAME+=val, NAME[0]=val, NAME[idx]+=val are all caught. The narrow residual for subscripts containing ] is noted above. |
| Trust-marker path traversal | Signature is a hex string; pssa-<64hex> and sha1<40hex> forms contain no path separators. |
| pwsh env-var injection | File/settings/state-base paths pass via env vars (not interpolated into the script string); -LiteralPath used throughout. |
| APPROVE_HINT shell quoting | $TRUST_DIR is display-only (user runs mkdir manually); no execution risk. Single-quote edge case only affects UX. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f517ff4fc8
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
# Conflicts: # plugins/claude-ops/CHANGELOG.md # plugins/desktop-notification/CHANGELOG.md # plugins/markdown-format/.claude-plugin/plugin.json # plugins/markdown-format/CHANGELOG.md # plugins/rate-limit-guard/CHANGELOG.md
|
Claude finished @kyle-sexton's task in 6m 39s —— View job PR Review: fix(security): patch six Claude Security scan findings (F1–F6)Tasks
Reviewed current HEAD ( 1 — Open P1: NAT64 local-use prefix not classified privateFile: The Trace for
Fix: add a branch that blocks the entire // 64:ff9b:1::/48 local-use NAT64 (RFC 8215) — non-global, treat as private
if (g[0] === 0x0064 && g[1] === 0xff9b && g[2] === 0x0001) return true;Also add 2 — Open IMPORTANT: subscript-with-
|
| Area | Status |
|---|---|
F5 common assignment forms (NAME=v, NAME+=v, NAME[0]=v, NAME[idx]+=v) |
✓ Addressed; subscript-with-] is the residual |
F3 — javascript:/data: XSS via href |
✓ Scheme allowlist enforced at schema and all sinks |
| F4 — SSRF literal hosts + DNS pre-resolution gate | ✓ Correct; all-records lookup, fail-closed on unresolvable |
F6 — file:// UNC NTLM leak |
✓ file: blocked at schema + sinks |
| F1 — markdown trust gate (extensionless CJS, Bash 3.2, JSONC/YAML escapes) | ✓ Addressed; computed paths are the residual |
| F2 — powershell trust gate (PS 7.0 .NET floor, transitive scan) | ✓ Addressed; interpolated vars are the residual |
Mixed-notation IPv4-mapped ::ffff:192.168.1.1 |
✓ WHATWG new URL() canonicalizes to full hex before expandIPv6 |
| 100.64.0.0/10, multicast, reserved IANA ranges | ✓ Covered in isPrivateIPv4 |
| Trust-marker path traversal | ✓ Hex-only signature format |
Verdict
Two targeted fixes are needed before merge — NAT64 64:ff9b:1::/48 (#1) and the subscript-with-] regex (#2) — both small and non-architectural. Findings #3 and #4 are the "computed path" class that the Codex has flagged across both plugins; the right mitigation (fail-closed on detection) is consistent with the existing UNVERIFIABLE strategy and I'd recommend addressing them rather than accepting as silent residuals, but that is a policy call for the maintainer.
|
Claude encountered an error after 0s —— View job I'll analyze this and get back to you. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6552b0ca01
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…ed loaders Two more mechanisms, both new classes rather than instances of one already closed. markdown-format: a reference that RESOLVES outside the repository was silently skipped. A symlink inside the tree aimed out of it, or a `../` escape, names code no signature over repository content can cover — re-aiming the symlink at a different existing external target leaves the approval state byte-identical while Node follows the new one and executes different code. Hashing the external file instead would extend the signature beyond the repository the approval is scoped to, so the state is refused. CONFIG_ROOT is already a physical path (`pwd -P`), so this compares like with like and a symlinked checkout does not read as an escape. Covered by a `../` fixture that runs everywhere plus a symlink fixture gated on host symlink support, matching the suite's existing idiom. powershell-format: a loader fed by a pipeline takes its source from the upstream element, not from its own arguments, so the AST walk saw only a constant command name and accepted. `Get-Content (Join-Path $PSScriptRoot "deps" "helper.ps1") -Raw | Invoke-Expression` executed a file the scan never reconstructed, and `Get-ChildItem *.psm1 | Import-Module` has the same shape. Any loader that is not the first element of its own pipeline is now refused. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
|
Claude encountered an error after 0s —— View job I'll analyze this and get back to you. |
|
Claude encountered an error after 1s —— View job I'll analyze this and get back to you. |
…ule symlink The out-of-repository guard added alongside this depends on hook::physical_path, which degrades to the unchanged lexical path when neither realpath nor readlink -f resolves. On such a host an escaping symlink reads as in-repository and gets pinned by its lexical path, leaving the external target free to change under a live approval — the guard failing open on exactly the hosts it matters most on. A symlink whose physical path came back unchanged is the observable signature of that degradation, since a symlink never canonicalizes to itself. That is the same test the file's membership scope already uses for the same reason, and it gets the same fail-closed answer. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…ng-scan-findings plugins/guardrails/CHANGELOG.md: both sides added a distinct bullet to the same 0.16.2 "Fixed" section and neither removed anything, so both are kept — main's skill-reference-verify anchoring fix (#1453) and this branch's hook-utils.sh telemetry-subject redaction sync. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
|
Claude encountered an error after 0s —— View job I'll analyze this and get back to you. |
|
Claude encountered an error after 0s —— View job I'll analyze this and get back to you. |
The plugin version is the update cache key, so `sync-hook-utils.sh --check-bump` requires every plugin carrying `lib/hook-utils.sh` to bump when the lib changes. This branch had already bumped guardrails to 0.16.2 for the telemetry-subject redaction, but main then released its own 0.16.2 for the skill-reference-verify anchoring fix, so the version no longer cleared the base and an unbumped plugin would never deliver the lib change. 0.16.3 carries the redaction sync; main's 0.16.2 entry keeps the release note it was published with.
|
Claude encountered an error after 0s —— View job I'll analyze this and get back to you. |
|
Claude encountered an error after 0s —— View job I'll analyze this and get back to you. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4b669c71a5
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
Claude finished @kyle-sexton's task in 9m 1s —— View job PR Review: fix(security): patch six Claude Security scan findings (F1–F6)Tasks
Reviewed HEAD commit Open P1 —
|
| Finding | Status | Key mechanism |
|---|---|---|
| F1 markdown-format CWE-94 | ✓ | Content-addressed trust gate over config + module graph |
| F2 powershell-format CWE-94 | ✓ | pwsh-computed SHA256 signature over settings + transitive deps |
| F3 ai-briefing XSS CWE-79 | ✓ | isAllowedUrlScheme at schema + both HTML/PPTX sinks |
| F4 ai-briefing SSRF CWE-918 | ✓ | Full IANA special-purpose registry + DNS pre-resolution gate |
| F5 hook-utils CWE-532 | ✓ | Greedy (\[.*\])? subscript; all assignment forms; TOKEN[1+INDEX[0]]=v fixture |
| F6 ai-briefing NTLM CWE-20 | ✓ | file: not in allowlist; blocked at schema + sinks |
Subscript [^]]* → .* |
✓ | Fixed in ec17b15; regression at test:895 |
Escaped JS specifiers \uXXXX |
✓ | Fixed in ec17b15; regression fixture |
RFC 8215 64:ff9b:1::/48 NAT64 |
✓ | Fixed in 9bfaec7 |
2001::/23, 2002::/16, 5f00::/16 IPv6 |
✓ | Fixed in 9bfaec7 and 117ba32 |
Computed path.join() module paths |
✓ | Non-literal require/import arg → fail closed |
$PSScriptRoot relative dot-sources |
✓ | Fixed in 9bfaec7 |
$PSScriptRootFoo variable-name prefix collision |
✓ | Fixed in 0aba2b4; bounded expansion pattern |
Alias of path module (const { join } = require("path")) |
✓ | Fixed in ff5fd80; import refused at import not call sites |
comment-separated loader require/*c*/(...) |
✓ | Fixed in 117ba32; residue test needs no window |
| JSONC/YAML if-elif ordering (escape tier suppressed) | ✓ | Fixed in 6552b0c; independent tests |
| YAML plain scalars in module paths | ✓ | Fixed in 6552b0c |
using module (not CommandAst) |
✓ | Fixed in 6552b0c; UsingStatementAst scan |
using assembly |
✓ | Documented: .dll via using assembly is a .NET assembly, not a repo PS module; Add-Type is in $loaders |
| Extensionless PS module resolution | ✓ | Fixed in 6552b0c; .psd1/.psm1 + versioned layout candidates |
Out-of-repository module targets (symlinks/../) |
✓ | Fixed in b2ca7f1; CONFIG_ROOT boundary check |
Pipeline-fed iex (Get-Content ... | iex) |
✓ | Fixed in b2ca7f1; pipeline-position check |
| Symlink canonicalization degradation | ✓ | Fixed in 0641962; unchanged lexical path → fail closed |
| PS 7.0 / .NET 3.1 SHA256 floor | ✓ | Instance API [SHA256]::Create() + ComputeHash |
| Bash 3.2 compatibility | ✓ | local -A replaced; empty-array expansion guards |
| Trust-marker path traversal | ✓ | 40/64-char hex signatures; no separators |
Documented residuals (appropriate scope)
- DNS rebinding: The pre-resolution gate runs at validation time; Linkinator re-resolves for the actual fetch. Rebind between those two points is outside an offline tool's threat model.
- Redirect hops: Linkinator follows HTTP redirects internally; a chain ending at a private host after the pre-check is not interceptable without replacing Linkinator's HTTP stack.
- Arbitrary
$var-relative PS dot-sources (not$PSScriptRoot/$PSCommandPath): e.g.. "$MyCustomRoot/helper.ps1"— static expansion not possible; the$check inexpandKnownVarsleaves the$in the value, which then fails path resolution and the dependency is silently omitted (weaker than fail-closed). This residual predates this PR's scope and is explicitly documented.
Verdict
Two targeted fixes are needed before merge. Both are small and self-contained:
evalin JS configs (markdown-format) — addeval[[:space:]]*\(to the Tier 1 grep inunpinnable_js_specifier. One pattern line + one regression fixture.- Constant
iexbody (powershell-format) — emit UNPINNABLE for anyiex/Invoke-Expressioncall after the pipeline-position check. Four lines + one regression fixture.
The rest of the implementation is thorough and correct. The testing across all six original findings plus the 20+ subsequent review-round findings is comprehensive. These two items are the only remaining gaps at HEAD.
|
Claude finished @kyle-sexton's task in 10m 11s —— View job Security Review — PR #1097Tasks
Reviewed HEAD commit IMPORTANT —
|
| Area | Status |
|---|---|
F5 assignment forms — plain, append, nested-subscript ((\[.*\])?) |
(\[.*\])?\+?= greedy match correctly handles A[${#arr[@]}]=secret. Test at line 895 ✓ |
F3 — XSS via javascript:/data: href |
Schema + both sinks enforce isAllowedUrlScheme; escape() encodes " in attributes ✓ |
F6 — file:// UNC NTLM leak |
file: not in allowedSchemes; blocked at schema and sinks ✓ |
| F4 SSRF — literal private IPv4 (RFC 1918, CGN, loopback, link-local, multicast) | Full IANA special-purpose registry in isPrivateIPv4 ✓ |
F4 SSRF — IPv6 allowlist inversion (2000::/3) |
(g[0] & 0xe000) !== 0x2000 classifies everything outside global unicast as private ✓ |
F4 SSRF — RFC 8215 NAT64 64:ff9b:1::/48 |
Explicit g[2] === 0x0001 check ✓ |
F4 SSRF — 2001::/23, 2002::/16, 3fff::/20 |
Each enumerated check correct; 2001:db8::/32 has separate explicit check outside /23 range ✓ |
F4 SSRF — [4000::1] and unassigned space |
Allowlist inversion: 0x4000 & 0xe000 = 0x4000 ≠ 0x2000 → private ✓ |
| F4 SSRF — DNS pre-resolution gate | All A/AAAA records resolved; any non-global or unresolvable → skip; fail-closed ✓ |
F1 — markdown trust gate, extensionless CJS (./rules/local-rule → local-rule.cjs) |
.cjs/.mjs/.js/.json/.node candidates tried in resolution loop ✓ |
F1 — computed path.join() / non-literal require arguments |
unpinnable_js_specifier detects path import and residue loader; refuses approval ✓ |
F1 — escaped JS specifiers ("./rules.js") |
Backslash-u/x/octal pattern in first grep → RISK_UNPINNABLE ✓ |
F1 — escaped JSONC keys ("customRules") |
Independent JSONC \\u tier sets RISK_UNVERIFIABLE regardless of literal tier ✓ |
| F2 — PowerShell 7.0 / .NET 3.1 SHA256 floor | Instance [SHA256]::Create() + ComputeHash + ToString("x2"); empty → UNVERIFIABLE ✓ |
F2 — $PSScriptRoot dot-source expansion |
expandKnownVars handles both $PSScriptRoot/ and ${PSScriptRoot}/ forms ✓ |
| F2 — transitive rule dependency pinning | Recursive scan bounded at 256/512; unreadable file → UNVERIFIABLE ✓ |
F2 — using module extensionless loads (Import-Module "$PSScriptRoot/MyModule") |
.psd1/.psm1/.ps1/.dll candidates + versioned-directory layout tried ✓ |
Pipeline-fed loaders (Get-Content ... | iex) |
PipelineElements.Count > 1 && not first element → UNPINNABLE ✓ |
| Out-of-repository symlinks in module graph | physical_path + $CONFIG_ROOT/* check → UNPINNABLE ✓ |
| Symlink canonicalization failure | [[ -L "$candidate" && "$resolved" == "$candidate" ]] → UNPINNABLE ✓ |
| Trust-marker path traversal | 40/64-char hex signatures; no separators possible ✓ |
Verdict
Three targeted fixes are needed before merge. The eval-wrapped JS loader and the constant-string iex body are both confirmed trust-gate bypasses; the unquoted YAML scalar path is a confirmed signature gap. All three are straightforward to close at the pattern level. The using assembly observation is low-severity and can be deferred if DLL-backed custom rules are considered out of scope.
…mbly loads markdown-format: the collector read only QUOTED strings, so a YAML plain scalar was invisible — `customRules: [./rules/local.cjs]` never put local.cjs in the signature, and a config-only marker stayed valid while the rule file changed underneath it. Path-shaped bare tokens are now harvested alongside quoted ones, restricted to tokens carrying a `/` or `.` so an ordinary key name or rule id is not tried as a path. The quoted harvest keeps its exact prior behavior; the two streams are tagged so neither filter leaks into the other. powershell-format: `using assembly <path>` loads a repository DLL exactly as `using module` loads a .psm1, and the previous branch skipped every non-Module using statement — a comment that claimed those "name no repository file", which was true for namespace and type and wrong for assembly. Assembly paths are now collected too. Both directions are closed: the parser reports an assembly it cannot load as a parse error, which the gate already treats as unverifiable, so a loadable assembly is pinned and an unloadable one refuses approval. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 6m 39s —— View job PR Review: fix(security): patch six Claude Security scan findings (F1–F6)Tasks
Reviewed HEAD commit Open IMPORTANT — Constant-body
|
| Finding | Status | Round |
|---|---|---|
| F1 markdown-format CWE-94 | ✓ | 285a037 |
| F2 powershell-format CWE-94 | ✓ | 285a037 |
| F3 ai-briefing XSS CWE-79 | ✓ | 285a037 |
| F4 ai-briefing SSRF CWE-918 | ✓ | 285a037 + 9bfaec7 |
| F5 hook-utils CWE-532 | ✓ | 90d9ccb + ec17b15 |
| F6 ai-briefing NTLM CWE-20 | ✓ | 285a037 |
Subscript [^]]* → greedy .* |
✓ | ec17b15 |
Escaped JS specifiers \uXXXX in loader arg |
✓ | ec17b15 |
RFC 8215 64:ff9b:1::/48 NAT64 |
✓ | 9bfaec7 |
2001::/23, 2002::/16, 3fff::/20, 5f00::/16 IPv6 |
✓ | 9bfaec7 + 117ba32 |
IPv6 allowlist inversion — (g[0] & 0xe000) !== 0x2000 |
✓ | 117ba32 |
Computed path.join() module paths (markdown) |
✓ | 9bfaec7 |
$PSScriptRoot relative dot-sources |
✓ | 9bfaec7 |
$PSScriptRootFoo variable-name prefix collision |
✓ | 0aba2b4 |
Path module alias (const { join } = require("path")) |
✓ | ff5fd80 |
Comment-separated loader require/*c*/(...) |
✓ | 117ba32 |
| JSONC/YAML if-elif ordering (escape tier suppressed) | ✓ | 6552b0c |
| YAML plain scalars in keys | ✓ | 6552b0c |
using module extensionless loads |
✓ | 6552b0c |
Out-of-repository module targets (symlinks / ../) |
✓ | b2ca7f1 |
Pipeline-fed iex (Get-Content ... | iex) |
✓ | b2ca7f1 |
| Symlink canonicalization degradation | ✓ | 0641962 |
| PS 7.0 / .NET 3.1 SHA256 floor | ✓ | 90d9ccb |
Bash 3.2 local -A compatibility |
✓ | 90d9ccb |
| Trust-marker path traversal | ✓ | All rounds |
| Unquoted YAML module paths | ✓ | 98f367e |
using assembly DLL loads |
✓ | 98f367e |
Constant-body iex bypass |
OPEN | — |
Verdict
One targeted fix is needed before merge: the constant-body iex/Invoke-Expression case in powershell-format.sh (above). The fix is four lines of PowerShell after the existing pipeline guard. The eval-in-JS observation is non-blocking given the incidental coverage by plain token extraction.
|
Claude finished @kyle-sexton's task in 8m 22s —— View job Security Review — PR #1097Tasks
Reviewed HEAD commit IMPORTANT —
|
| Finding | Round closed | Status |
|---|---|---|
F3 XSS via javascript:/data: href |
285a037 |
✓ Scheme allowlist at schema + sinks |
| F4 SSRF literal private hosts (full IANA) | 285a037+9bfaec7+117ba32 |
✓ IPv4 full registry, IPv6 allowlist inversion |
| F5 hook-utils assignment regex (nested subscripts) | ec17b15 |
✓ Greedy (\[.*\])?; fixture at test:895 |
F6 file:// NTLM leak |
285a037 |
✓ file: not in allowlist |
RFC 8215 64:ff9b:1::/48 NAT64 |
9bfaec7 |
✓ Explicit g[2]===0x0001 check |
2001::/23, 2002::/16, 5f00::/16 IPv6 |
9bfaec7+117ba32 |
✓ Allowlist inversion covers all |
| Escaped JS specifiers | ec17b15 |
✓ Backslash-u/x/octal → UNPINNABLE |
Computed path.join() module paths |
9bfaec7 |
✓ Non-literal require → UNPINNABLE |
$PSScriptRoot double-quoted dot-sources |
9bfaec7 |
✓ Expansion + resolution correct |
Pipeline-fed iex |
b2ca7f1 |
✓ Pipeline position check → UNPINNABLE |
| Out-of-repository module targets | b2ca7f1 |
✓ CONFIG_ROOT boundary check |
| Symlink canonicalization failure | 0641962 |
✓ Lexical-path degradation → UNPINNABLE |
| PS 7.0 / .NET 3.1 SHA256 floor | 90d9ccb |
✓ Instance API; empty → UNVERIFIABLE |
using module extensionless loads |
6552b0c |
✓ .psd1/.psm1/.ps1/.dll candidates |
| Bash 3.2 compatibility | 90d9ccb |
✓ local -A replaced |
| Trust-marker path traversal | ✓ | Hex-only signatures |
Verdict
Two targeted fixes are needed before merge, both unchanged since the prior round. The eval-wrapped loader (#1) and the constant iex body (#2) are confirmed trust-gate bypasses; both allow code that executes on the next lint invocation to change after the approval is granted, without revoking it. Each fix is a few lines — add eval( to Tier 1's grep patterns, and emit UNPINNABLE for iex/Invoke-Expression with a string-constant argument.
The 98f367e changes (unquoted YAML paths, using assembly) are correct and introduce no new security issues.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 98f367e1e4
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…sed to 0.16.4) #1097 merged first and took guardrails 0.16.3 — the version this branch was claiming — so the version collided again and had to be recomposed. Conflict, composed rather than side-picked: - plugins/guardrails/CHANGELOG.md — the same trap as the previous merge: both sides had created a `## [0.16.3]` / `### Fixed` heading pair, so git matched those two lines as COMMON CONTEXT and presented only the bullets as conflicting. Resolving the bullets in place would have silently merged this branch's entries into main's already-released 0.16.3 section with no marker to notice. This branch's entries move to a new `## [0.16.4]` above main's `## [0.16.3]`, whose bullets are kept verbatim. - plugins/guardrails/.claude-plugin/plugin.json — 0.16.3 -> 0.16.4. main's hook-utils.sh change (telemetry-subject assignment-value leak, F1-F6) is unrelated to this branch's guards and needed no composition. Verified after resolving: no conflict markers, no `## ` section from main dropped, only `## [0.16.4]` added, markdownlint clean, changelog-parity --check-bump and cross-plugin-source-drift green, shellcheck clean on the merged hook-utils.sh.
This reverts commit f3c221d. Following a `cd` correctly means implementing a shell interpreter inside a static guard. Within an hour of that commit, four findings landed against the machinery it added — control-flow reachability (`false && cd x` must not relocate), wrapped builtins (`command cd`), subshell scoping (`(cd x; true)`), and explicit-global replay — which are not four bugs but the first four items of shell semantics. Quoting, `eval`, `$(…)`, `trap` and `set -e` are next. Two of those were regressions against `main`, measured with real-git ground truth (want=2 means the guard must block; branch vs origin/main's guard): F1 unreachable cd (false &&) real=commits want=2 branch=0 main=2 F4 cd inside a subshell real=commits want=2 branch=0 main=2 F3 --git-dir/--work-tree canonical real=no-op want=0 branch=2 main=0 Moving the base on an unreachable or subshell-scoped `cd` sent the analysis into the child, where the alias was harmless, so the guard allowed — while `main`, which never moves the base, resolved the outer alias and blocked correctly. F3 refused a VALID canonical commit. A half-interpreted shell is worse than a documented gap: it produced a guard weaker than the one it replaced on two shapes and a false positive on a third. The relocation gap returns to being documented rather than half-closed, and is tracked in #1486 together with this commit and the four findings, which are a map of what a real fix must handle. Kept from 8984d93: the invocation-prefix slice and the git-resolved `repo_identity` primitive, neither of which is implicated. Also kept the 0.16.4 version composed when #1097 took 0.16.3. Note for whoever re-runs the suite mid-revert: `git revert --no-commit` leaves REVERT_HEAD, which `sequencer_in_progress` correctly treats as an in-progress sequencer, so every commit-path fixture is exempted and reports 96 passed / 30 failed. That is #942, not this change.
) *This was generated by AI during work-loop execution.* ## Summary - No CI gate covers shell portability: `shellcheck` lints syntax/style, and `portability-lint` (#531) matches skill-coupling tokens against changed *skill* files only. A GNU-only construct — like the `\brequire\b` word-boundary escape that nearly shipped a fail-open security predicate in `markdown-format.sh` (fixed on `main` via #1097) — passes both gates silently on BSD userland (macOS system `grep`/`sed`/`date`/`stat`/`mktemp`/`sort`), which no runner in this repo's CI covers (a Windows runner's Git Bash still ships GNU `grep`/`sed`, so it would not help either). - Adds `scripts/check-shell-portability.sh`, a changed-file-scoped gate over `**/*.sh` mirroring `check-skill-portability.sh`'s shape: an external ERE token list (`scripts/shell-portability-tokens.txt`), a same-line auto-guard for a co-located BSD counterpart, a per-site `portability-ok: <reason>` opt-out, and a whole-file `portability-scope: <reason>` declaration (used on the gate's own test file, which necessarily contains the constructs it detects as fixture data). - Wires a new `shell-portability-lint` job into `ci.yml` (self-test on every push, diff-gated on pull requests) and adds it to the `ci-status` required-check list. - **Active** classes today (zero real corpus impact, or auto-guarded): the regex-escape family (`\b \< \> \s \S \w \W`), `grep -P`/`--perl-regexp`, `echo -e`, `sort -V`, unsuffixed `sed -i`, and `readlink -f` (guarded when a `realpath` attempt sits on the same line — the shape `lib/hook-utils.sh` already uses). All four flag-based classes (`grep -P`, `sort -V`, `echo -e`, plus `sed -i`) match the target letter anywhere in a combined short-option cluster (`-Pn`, `-Vr`, `-ne`), not only as the cluster's last letter, and `sed -i`'s portable BSD-safe empty-suffix idiom (`-i ''` / `-i ""`) is auto-guarded rather than flagged. - **Staged** (commented, inactive) classes: `date -d`, `stat -c`, `mktemp -p`. A corpus survey during this change found real, already-legitimate uses (a cross-statement GNU-then-BSD dialect function in `morning-brief.sh`; ~20 shared test-scaffolding `mktemp -p` sites with no BSD counterpart) that the same-line auto-guard doesn't yet cover — enabling them is tracked in the follow-up below, the same staged-rollout posture `scripts/skill-portability-tokens.txt` already documents for its own classes. ## Triage note #1491's triage marked the token-list-vs-BSD-container design fork as decision-defaulted (token list, vetoable) and separately delegated "the starter token list's exact membership" to the implementer as reversible/low-stakes. The ACTIVE/STAGED split above is that delegated, reversible call, made from an actual corpus survey rather than guesswork — not a second judgment call requiring escalation. ## Review response An automated Codex review left 6 findings. Two risked flagging the CORRECT portable form and were fixed directly (the combined-short-option-cluster gap on `grep -P`/`sort -V`/`echo -e`, and the `sed -i ''`/`sed -i ""` empty-suffix idiom being wrongly flagged) plus a guard-scoping tightening (the `realpath` auto-guard now applies only to the readlink pattern match, not the whole line). The remaining three lower-severity findings (additional `sed -i` spellings, `portability-scope:` substring-match precision — shared with the sibling gate, not unique to this PR — and an `awk` operand edge case on a pathological filename) are deferred to #1513. See the threaded replies on each finding for the per-finding classification. Two further review rounds followed and the unresolved-thread count grew 6 to 11 without net decrease — including one finding that asks to REVERSE the `sed -i ''` auto-guard added in response to round one. Per this repo's convergence posture, the fix loop is cut off here: the five new findings are grouped and deferred to #1517 with per-item re-open triggers, and each thread carries the reasoning. None is a defect in the shipped behavior — four are false-negative detection gaps (before this gate they all passed silently), and the one false positive is the token file's own documented over-flag posture, which ships a per-site `portability-ok:` opt-out. Absorbing them would re-widen the change and invalidate the corpus survey the ACTIVE/STAGED split rests on. ## Test plan - [x] `bash scripts/check-shell-portability.test.sh` — 35/35 passing, including: the literal `\b` token actually fires (verified against the real awk resolved in this environment, gawk 5.4.0 — not assumed; this is a distinct, POSIX-fundamental escape from the sibling token list's documented `\b`-as-boundary-anchor pitfall, which this gate does not use), each of `\< \> \s \S \w \W`, `grep -P`/`-riP`/`-Pn` (and that a comment merely naming `grep -P` does not fire), `echo -e`/`-ne`, `sort -V`/`-Vr`, unsuffixed `sed -i` vs. `sed -i.bak` vs. the guarded `sed -i ''`/`-i ""`, `readlink -f` bare vs. `realpath`-guarded (and that the guard does not leak to an unrelated token on the same line), same-line/comment-block-above/leak-boundary `portability-ok:` annotation behavior, the whole-file `portability-scope:` declaration, fail-closed behavior (malformed token, missing token file, invalid base ref), `--all` scope exclusion, a Git-quoted non-ASCII changed path, and — against the real corpus — that the shipped list does not flag `markdown-format.sh`'s known-good reference implementation and that the staged classes stay inactive. - [x] `scripts/check-shell-portability.sh origin/main` run directly against this PR's own diff — the new gate's own source files (2 shell files in scope) pass clean. - [x] `shellcheck --rcfile=.shellcheckrc` on both new scripts — clean. - [x] `actionlint .github/workflows/ci.yml` — clean. - [x] `bash scripts/check-skill-portability.test.sh` (sibling gate) still passes — no cross-gate regression. - [x] Full CI run green, including the new `shell-portability-lint` job and the required `ci-status` aggregate. ## Related Closes #1491. Follow-ups: #1510 (enabling the staged classes), #1513 (detection-precision findings from review round 1), #1517 (detection-precision findings from review rounds 2-3).

Summary
Patches all six findings from the first Claude Security scan of this
marketplace (4 MEDIUM, 2 LOW), on one branch. Each fix was generated in an
isolated scratch workspace and verified by a panel of agents — an
independent verifier that reviewed the diff and ran the project's tests,
plus a fresh adversarial reviewer asked what the change newly enables —
before it was accepted. Grouped into four commits by plugin.
.cjs/.mjsconfig as Node code on any markdown edit (CWE-94)CustomRulePathmodule as PowerShell on any.ps1edit (CWE-94)javascript:/data:source URL reached a deck anchorhrefunfiltered — stored XSS on click (CWE-79)lib/hook-utils.shNAME=valueBash command leaked its value into the telemetry/audit subject (CWE-532)file://UNC URL embedded as a PPTX hyperlink — NTLM leak on click (CWE-20)Fix families:
config/settings file skips the auto-run (with a once-per-session notice
naming the approval command) until the user records a content-addressed
approval marker under
${CLAUDE_PLUGIN_DATA}/trust-approvals. Any configchange revokes it; fails closed when the plugin-data dir is unavailable. The
edit itself is never blocked.
lib/url-policy.js: anhttp/https/mailto/telscheme allowlistenforced at schema time and at every href/hyperlink sink, plus private/
loopback/link-local/reserved literal-host filtering in the reachability gate.
hook::extract_bash_subjectnow bails a resolved assignment-shapedtoken to the bare
Bashsubject; synced to all per-plugin copies viascripts/sync-hook-utils.sh, which is why this PR bumps every carryingplugin (the coupled
sync-hook-utils/changelog-paritygates).Deliberate, documented behaviour changes: ai-briefing now rejects
non-allowlisted URL schemes (
javascript:/data:/file:and rarer ones likeftp:); a repo whose linter config can execute code is no longer auto-rununtil approved. Residual: ai-briefing's SSRF gate filters literal private
hosts only — a public hostname resolving to a private address at fetch time
(DNS rebind) is not gated offline, since linkinator resolves DNS itself
(documented in code and CHANGELOG).
Test plan
Run locally against the merge base, all green:
bash lib/hook-utils.test.sh→ 90/90 (7 new subject cases)bash scripts/sync-hook-utils.sh --check→ all 12 copies matchbash scripts/sync-hook-utils.sh --check-bump origin/main→ passbash scripts/check-changelog-parity.sh --checkand--check-bump origin/main→ passbash scripts/validate-plugins.shandnode scripts/generate-catalog.mjs --check→ passbash scripts/check-silent-skips.sh,check-cross-plugin-source-drift.sh,check-changed-skills.sh origin/main,check-skill-portability.sh origin/main→ passshellcheck -x lib/hook-utils.sh plugins/markdown-format/hooks/markdown-format.sh plugins/powershell-format/hooks/powershell-format.sh→ cleannode --test27/27 (new schema + sink coverage)Related
No linked issue — these are findings from a local Claude Security scan of this
repository (finding ids F1–F6); there is no tracked GitHub issue to close.