diff --git a/lib/hook-utils.sh b/lib/hook-utils.sh
index b854e2598c..411c4a6731 100644
--- a/lib/hook-utils.sh
+++ b/lib/hook-utils.sh
@@ -279,6 +279,11 @@ hook::jq_field() {
# A quoted assignment value (e.g. `TOKEN="a b" curl …`) would otherwise leak a
# fragment of the value into the token, so any token carrying a quote aborts to a
# bare "Bash" subject rather than risk exposing part of the value.
+#
+# A bare or trailing unquoted assignment that no following command consumed
+# (e.g. the whole command is `TOKEN=ghp_…`) is likewise a value the subject must
+# not carry, so a resolved token still shaped like a NAME=value assignment aborts
+# to the bare "Bash" subject too.
# SUBJECT=$(hook::extract_bash_subject "$TOOL" "$CMD")
hook::extract_bash_subject() {
local tool="$1" cmd="${2:-}"
@@ -307,6 +312,19 @@ hook::extract_bash_subject() {
printf '%s' "$tool"
return 0
fi
+ # A resolved token still shaped like a bare/trailing assignment (no following
+ # command word consumed it in the strip loop) would emit the assignment's
+ # value — a possible credential — as the subject; bail to the bare "Bash"
+ # subject as with a quoted value. All valid Bash assignment forms count:
+ # NAME=value, append NAME+=value, and subscripted NAME[idx]=value /
+ # NAME[idx]+=value — the subscript matched greedily (`.*`) because Bash
+ # accepts nested subscripts like NAME[1+IDX[0]]=value, which a
+ # no-close-bracket class would miss. This runs BEFORE the basename strip so
+ # a path-valued assignment (TOKEN=/a/b/secret) cannot lose its "=" first.
+ if [[ "$first_token" =~ ^[a-zA-Z_][a-zA-Z0-9_]*(\[.*\])?\+?= ]]; then
+ printf '%s' "$tool"
+ return 0
+ fi
first_token="${first_token##*/}"
if [[ -n "$first_token" ]]; then
printf 'Bash:%s' "$first_token"
diff --git a/lib/hook-utils.test.sh b/lib/hook-utils.test.sh
index d632a5ccc0..d2dfacc378 100755
--- a/lib/hook-utils.test.sh
+++ b/lib/hook-utils.test.sh
@@ -860,6 +860,55 @@ else
fi
rm -f "$tel19" "$sink19"
+# --- hook::extract_bash_subject: privacy-safe subject reduction --------------
+# The subject is emitted verbatim into hook-events.jsonl and any wired
+# HOOK_TELEMETRY_SINK, so it must never carry an assignment VALUE (a credential).
+subject_is() {
+ local desc="$1" tool="$2" cmd="$3" want="$4" got
+ got=$(hook::extract_bash_subject "$tool" "$cmd")
+ if [[ "$got" == "$want" ]]; then
+ ok "extract_bash_subject: $desc"
+ else
+ fail "extract_bash_subject ($desc): want [$want] got [$got]"
+ fi
+}
+
+# Leak case (the fix): a command whose LAST token is an unquoted assignment must
+# NOT emit the value — it bails to the bare "Bash" subject.
+subject_is "bare trailing assignment bails to Bash" \
+ "Bash" "TOKEN=ghp_realtokenvalue" "Bash"
+# A path-valued trailing assignment must bail BEFORE the basename strip, or the
+# value's basename would leak (TOKEN=/a/b/secret -> "secret").
+subject_is "path-valued trailing assignment bails (no basename leak)" \
+ "Bash" "TOKEN=/a/b/secret" "Bash"
+# Multiple assignments with no following command are all value — bail.
+subject_is "trailing multi-assignment bails to Bash" \
+ "Bash" "VAR=x TOKEN=secret" "Bash"
+# Every valid Bash assignment form counts: append and subscripted assignments
+# carry the value just the same.
+subject_is "trailing append assignment bails to Bash" \
+ "Bash" "TOKEN+=ghp_secret" "Bash"
+subject_is "trailing subscripted assignment bails to Bash" \
+ "Bash" "TOKEN[0]=ghp_secret" "Bash"
+subject_is "trailing subscripted append assignment bails to Bash" \
+ "Bash" "TOKEN[0]+=ghp_secret" "Bash"
+subject_is "trailing nested-subscript assignment bails to Bash" \
+ "Bash" "TOKEN[1+INDEX[0]]=ghp_secret" "Bash"
+
+# Preserved: a following real command wins the token, so the assignment prefix is
+# stripped and the command name is the subject.
+subject_is "leading assignment prefix then a command yields the command" \
+ "Bash" "VAR=x realcmd --flag arg" "Bash:realcmd"
+# Preserved: a quoted assignment value still hits the quote-bail.
+subject_is "quoted assignment value bails to Bash" \
+ "Bash" 'TOKEN="a b" curl https://x' "Bash"
+# Preserved: an ordinary command yields its basename subject, no tail.
+subject_is "ordinary command yields basename subject" \
+ "Bash" "/usr/bin/git status --short" "Bash:git"
+# Preserved: a non-Bash tool returns its name unchanged.
+subject_is "non-Bash tool returns the tool name" \
+ "Write" "irrelevant" "Write"
+
echo
echo "PASS=$PASS FAIL=$FAIL"
[[ $FAIL -eq 0 ]]
diff --git a/plugins/actionlint/.claude-plugin/plugin.json b/plugins/actionlint/.claude-plugin/plugin.json
index a52503bad3..3d4b7754a0 100644
--- a/plugins/actionlint/.claude-plugin/plugin.json
+++ b/plugins/actionlint/.claude-plugin/plugin.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
"name": "actionlint",
- "version": "0.7.1",
+ "version": "0.7.2",
"description": "Lint GitHub Actions workflow files on edit via actionlint, surfacing findings as advisory context.",
"author": {
"name": "Melodic Software",
diff --git a/plugins/actionlint/CHANGELOG.md b/plugins/actionlint/CHANGELOG.md
index eb6a7a9664..4c5873b3d4 100644
--- a/plugins/actionlint/CHANGELOG.md
+++ b/plugins/actionlint/CHANGELOG.md
@@ -3,6 +3,22 @@
All notable changes to the `actionlint` plugin are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning.
+## [0.7.2]
+
+### Fixed
+
+- **Shared `hook-utils.sh`: a bare or trailing unquoted `NAME=value` Bash
+ command no longer leaks the assignment value into the privacy-safe
+ telemetry/audit subject.** `hook::extract_bash_subject` stripped a leading
+ `VAR=value` prefix only when a following command word consumed it, so a
+ command whose LAST token was an unquoted assignment (e.g. `TOKEN=ghp_…`)
+ survived to the subject and emitted `Bash:TOKEN=ghp_…` into
+ `hook-events.jsonl` and any wired `HOOK_TELEMETRY_SINK`. A resolved token
+ still shaped like a shell assignment now bails to the bare `Bash` subject,
+ matching the existing quoted-value bail (`VAR=x cmd` still reduces to
+ `Bash:cmd`). Synced from `lib/hook-utils.sh`; the subject is
+ telemetry/audit-only, so no guard or formatter block/allow behavior changes.
+
## [0.7.1]
### Fixed
diff --git a/plugins/actionlint/hooks/hook-utils.sh b/plugins/actionlint/hooks/hook-utils.sh
index b854e2598c..411c4a6731 100644
--- a/plugins/actionlint/hooks/hook-utils.sh
+++ b/plugins/actionlint/hooks/hook-utils.sh
@@ -279,6 +279,11 @@ hook::jq_field() {
# A quoted assignment value (e.g. `TOKEN="a b" curl …`) would otherwise leak a
# fragment of the value into the token, so any token carrying a quote aborts to a
# bare "Bash" subject rather than risk exposing part of the value.
+#
+# A bare or trailing unquoted assignment that no following command consumed
+# (e.g. the whole command is `TOKEN=ghp_…`) is likewise a value the subject must
+# not carry, so a resolved token still shaped like a NAME=value assignment aborts
+# to the bare "Bash" subject too.
# SUBJECT=$(hook::extract_bash_subject "$TOOL" "$CMD")
hook::extract_bash_subject() {
local tool="$1" cmd="${2:-}"
@@ -307,6 +312,19 @@ hook::extract_bash_subject() {
printf '%s' "$tool"
return 0
fi
+ # A resolved token still shaped like a bare/trailing assignment (no following
+ # command word consumed it in the strip loop) would emit the assignment's
+ # value — a possible credential — as the subject; bail to the bare "Bash"
+ # subject as with a quoted value. All valid Bash assignment forms count:
+ # NAME=value, append NAME+=value, and subscripted NAME[idx]=value /
+ # NAME[idx]+=value — the subscript matched greedily (`.*`) because Bash
+ # accepts nested subscripts like NAME[1+IDX[0]]=value, which a
+ # no-close-bracket class would miss. This runs BEFORE the basename strip so
+ # a path-valued assignment (TOKEN=/a/b/secret) cannot lose its "=" first.
+ if [[ "$first_token" =~ ^[a-zA-Z_][a-zA-Z0-9_]*(\[.*\])?\+?= ]]; then
+ printf '%s' "$tool"
+ return 0
+ fi
first_token="${first_token##*/}"
if [[ -n "$first_token" ]]; then
printf 'Bash:%s' "$first_token"
diff --git a/plugins/ai-briefing/.claude-plugin/plugin.json b/plugins/ai-briefing/.claude-plugin/plugin.json
index 9f1f3e1868..5aafadb1d1 100644
--- a/plugins/ai-briefing/.claude-plugin/plugin.json
+++ b/plugins/ai-briefing/.claude-plugin/plugin.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
"name": "ai-briefing",
- "version": "0.6.2",
+ "version": "0.6.3",
"description": "Build source-backed AI-industry briefings from official vendor publications, configured RSS/Atom feeds, GitHub releases, reputable secondary reporting, and user-supplied URLs. Deduplicate, rank, and present results as markdown or optional HTML/PPTX decks, with repository-owned profile, audience, and brand configuration. Automated X/Twitter collection is disabled; Playwright is used only for deterministic local rendering.",
"author": {
"name": "Melodic Software",
diff --git a/plugins/ai-briefing/CHANGELOG.md b/plugins/ai-briefing/CHANGELOG.md
index e64c19e1e4..e096b6ec35 100644
--- a/plugins/ai-briefing/CHANGELOG.md
+++ b/plugins/ai-briefing/CHANGELOG.md
@@ -3,6 +3,61 @@
All notable changes to the `ai-briefing` plugin are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning.
+## [0.6.3]
+
+### Security
+
+- **Source-URL schemes are allowlisted at every deck sink.** A shared
+ `lib/url-policy.js` seam now exposes `isAllowedUrlScheme`, reused at schema
+ validation and at each href/hyperlink sink. `http:`, `https:`, `mailto:`, and
+ `tel:` — the schemes a legitimate briefing may contain, inert at every sink —
+ are preserved and continue to render as working links. **Every other scheme is
+ now rejected**: the `javascript:`, `data:`, and `file:` attack vectors that
+ could inject script into the HTML deck or embed a local-file hyperlink in the
+ PPTX, and — as deliberate fail-closed hardening — rarer schemes such as `ftp:`
+ that the previous permissive `z.string().url()` accepted. Two layers: the Zod
+ schema hard-fails a deck containing a disallowed scheme (loud fail-closed on an
+ attack indicator), and the HTML and PPTX builders drop the individual unsafe
+ link (defense-in-depth on the `--skip-emit` rebuild path).
+- **Link-reachability checks refuse non-global hosts (SSRF guard).**
+ `shouldSkipLinkCheck` now skips URLs whose literal host falls in any
+ non-global block of the IANA special-purpose registries, not just RFC1918:
+ loopback, private, link-local, shared address space (CGN), benchmarking,
+ documentation TEST-NETs, IETF protocol assignments, multicast, and reserved
+ (127/8, 10/8, 100.64/10, 172.16/12, 192.168/16, 169.254/16, 0/8, 192.0.0/24,
+ 192.0.2/24, 198.18/15, 198.51.100/24, 203.0.113/24, 192.88.99/24 deprecated
+ 6to4 relay anycast, 224/4, 240/4,
+ `localhost`/`*.localhost`) — the IPv4 list is complete against the registry;
+ the only rows omitted are those it marks globally reachable (the AS112, AMT,
+ PCP and TURN anycast assignments). A deny list is the correct shape for IPv4,
+ unlike IPv6 below: global unicast is not one prefix but 1.0.0.0 through
+ 223.255.255.255 minus the carve-outs, so the two ends are handled by range and
+ the middle needs the registry's blocks enumerated either way. Matching relies
+ on WHATWG URL canonicalization of
+ decimal/hex/octal/integer IPv4. IPv6 is judged by ALLOWLIST rather than by an
+ enumerated deny list: only globally reachable unicast space (`2000::/3`)
+ survives, and the IANA IPv6 Special-Purpose Address Registry's non-global
+ blocks inside it are carved back out (`2001::/23` IETF protocol assignments —
+ Teredo, benchmarking `2001:2::/48`, ORCHIDv2, AMT and the anycast singletons —
+ plus `2001:db8::/32` and `3fff::/20` documentation and `2002::/16` 6to4, which
+ wraps an arbitrary IPv4 tunnel endpoint). So `::`/`::1`, `fc00::/7`,
+ `fe80::/10`, `ff00::/8`, `100::/64`, `100:0:0:1::/64`, `5f00::/16` and every
+ unassigned or newly registered block are refused by default rather than read
+ as public — closing the class of bypass a deny list reopens each time a
+ prefix nobody enumerated turns out to be routable. RFC 8215's local-use
+ translation prefix `64:ff9b:1::/48` is refused outright, while IPv4-mapped and
+ NAT64 `64:ff9b::/96` forms are judged by their embedded IPv4 address. Literal
+ parsing also accepts RFC 4291 form 3 (a trailing dotted quad), which a
+ resolver can answer with and which reading as hex silently misread
+ (`192.168.1.1` as `0x192`). A DNS-name
+ host is additionally resolved at gate time (every A/AAAA record) and refused
+ when ANY resolved address is non-global, so a hostname whose record points
+ at, e.g., the cloud metadata address is never handed to the checker; an
+ unresolvable or unreadable answer fails closed. Residual: the checker
+ performs its own resolution at fetch time, so a rebind between this gate and
+ the fetch, or a redirect hop to a private target inside the checker, remains
+ outside this gate.
+
## [0.6.2]
### Fixed
diff --git a/plugins/ai-briefing/skills/generate/output/build/build-pptx.js b/plugins/ai-briefing/skills/generate/output/build/build-pptx.js
index 1dbf59d44a..c714dc507d 100644
--- a/plugins/ai-briefing/skills/generate/output/build/build-pptx.js
+++ b/plugins/ai-briefing/skills/generate/output/build/build-pptx.js
@@ -5,6 +5,7 @@ import path from "node:path";
import fs from "node:fs";
import { fileURLToPath } from "node:url";
import { loadSlidesData, meetingsDir } from "./lib/paths.js";
+import { isAllowedUrlScheme } from "./lib/url-policy.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const { meta, theme, slides } = await loadSlidesData();
@@ -317,7 +318,7 @@ function buildNews(s, slide) {
});
// ALL URLs — each as separate hyperlinked line
- const urls = b.urls ?? [];
+ const urls = (b.urls ?? []).filter(isAllowedUrlScheme);
const urlBlock = urls.flatMap((u, j) => [
...(j > 0 ? [{ text: "\n", options: { fontSize: 4 } }] : []),
{ text: u, options: { fontFace: FONT_BODY, fontSize: 8.5, color: theme.accent, hyperlink: { url: u } } },
@@ -383,7 +384,7 @@ function buildCondensed(s, slide) {
valign: "top",
});
- const urls = b.urls ?? [];
+ const urls = (b.urls ?? []).filter(isAllowedUrlScheme);
if (urls.length > 0) {
const urlText = urls.flatMap((u, j) => [
...(j > 0 ? [{ text: "\n", options: { fontSize: 3 } }] : []),
diff --git a/plugins/ai-briefing/skills/generate/output/build/build-sections.js b/plugins/ai-briefing/skills/generate/output/build/build-sections.js
index 03310c8b09..84dd253b3f 100644
--- a/plugins/ai-briefing/skills/generate/output/build/build-sections.js
+++ b/plugins/ai-briefing/skills/generate/output/build/build-sections.js
@@ -1,6 +1,7 @@
// Section grouping and HTML fragment generation for single-file HTML deck.
import { formatUrlDisplay } from "./lib/url-display.js";
+import { isAllowedUrlScheme } from "./lib/url-policy.js";
export const escape = (s) =>
String(s ?? "")
@@ -197,7 +198,7 @@ function renderNewsSection(sectionKey, sectionSlides, providerLogoSvg) {
${heading}
${bullets.map((b) => {
- const urls = b.urls || [];
+ const urls = (b.urls || []).filter(isAllowedUrlScheme);
const dateStr = b.date ? new Date(b.date).toISOString().slice(0, 10) : "";
return `
-
diff --git a/plugins/ai-briefing/skills/generate/output/build/lib/schema.js b/plugins/ai-briefing/skills/generate/output/build/lib/schema.js
index 7d6080878f..ad6a553a45 100644
--- a/plugins/ai-briefing/skills/generate/output/build/lib/schema.js
+++ b/plugins/ai-briefing/skills/generate/output/build/lib/schema.js
@@ -1,8 +1,14 @@
// Zod schema for slides-data.js exports. Validates shape after emit, and as
// part of validate.js gates. Catches accidental drift in slide-type fields.
import { z } from "zod";
+import { isAllowedUrlScheme } from "./url-policy.js";
-const Url = z.string().url();
+// A source URL must parse AND carry an allowlisted scheme. `.url()` alone accepts
+// javascript:/data:/file: (WHATWG-parseable), so the refine is what rejects them —
+// a dangerous or unlisted scheme anywhere in the deck fails validation loudly.
+const Url = z.string().url().refine(isAllowedUrlScheme, {
+ message: "URL scheme not allowed — only http, https, mailto, and tel are accepted",
+});
const Hex6 = z.string().regex(/^[0-9A-Fa-f]{6}$/);
const Bullet = z.object({
diff --git a/plugins/ai-briefing/skills/generate/output/build/lib/url-policy.js b/plugins/ai-briefing/skills/generate/output/build/lib/url-policy.js
index e042d2f84a..3733587430 100644
--- a/plugins/ai-briefing/skills/generate/output/build/lib/url-policy.js
+++ b/plugins/ai-briefing/skills/generate/output/build/lib/url-policy.js
@@ -1,5 +1,23 @@
const citationOnlyHosts = ["x.com", "twitter.com"];
+// Schemes safe to render as an href, embed as a PPTX hyperlink, or hand to the
+// reachability checker. http/https/mailto/tel are the schemes a legitimate
+// briefing may contain and are inert at every sink; every other scheme
+// (javascript:, data:, file:, blob:, vbscript:, and rarer ones such as ftp:) is
+// rejected as deliberate fail-closed hardening.
+const allowedSchemes = new Set(["http:", "https:", "mailto:", "tel:"]);
+
+/** True when `value` parses as a URL whose scheme is on the allowlist. */
+export function isAllowedUrlScheme(value) {
+ let protocol;
+ try {
+ protocol = new URL(value).protocol;
+ } catch {
+ return false;
+ }
+ return allowedSchemes.has(protocol.toLowerCase());
+}
+
function isCitationOnlyHost(hostname) {
const normalized = hostname.toLowerCase().replace(/\.$/, "");
return citationOnlyHosts.some(
@@ -7,16 +25,204 @@ function isCitationOnlyHost(hostname) {
);
}
-/** Return true for links that must not be included in reachability checks. */
-export async function shouldSkipLinkCheck(link) {
+// Every block in the IANA IPv4 Special-Purpose Address Registry whose "Globally
+// Reachable" column is not True, not just RFC1918: shared address space (CGN),
+// the documentation TEST-NETs, benchmarking, the deprecated 6to4 relay anycast
+// range, plus the multicast and reserved space above 224/8.
+//
+// A deny list is the right shape here, unlike the IPv6 predicate below. IPv4
+// global unicast is not one prefix — it is 1.0.0.0 through 223.255.255.255 minus
+// the carve-outs — so the two ends are handled by range (`a === 0`, `a >= 224`)
+// and the middle needs the registry's blocks enumerated either way. The list is
+// complete against the registry; the only rows omitted are those the registry
+// marks globally reachable (the AS112, AMT, PCP and TURN anycast assignments).
+function isPrivateIPv4(host) {
+ const octets = host.split(".").map(Number);
+ if (
+ octets.length !== 4 ||
+ octets.some((n) => !Number.isInteger(n) || n < 0 || n > 255)
+ ) {
+ return false;
+ }
+ const [a, b, c] = octets;
+ return (
+ a === 0 || // 0.0.0.0/8 "this host"
+ a === 10 || // 10.0.0.0/8 private
+ (a === 100 && b >= 64 && b <= 127) || // 100.64.0.0/10 shared address space (CGN)
+ a === 127 || // 127.0.0.0/8 loopback
+ (a === 169 && b === 254) || // 169.254.0.0/16 link-local (cloud metadata)
+ (a === 172 && b >= 16 && b <= 31) || // 172.16.0.0/12 private
+ (a === 192 && b === 0 && c === 0) || // 192.0.0.0/24 IETF protocol assignments
+ (a === 192 && b === 0 && c === 2) || // 192.0.2.0/24 TEST-NET-1
+ (a === 192 && b === 88 && c === 99) || // 192.88.99.0/24 deprecated 6to4 relay anycast (RFC 7526)
+ (a === 192 && b === 168) || // 192.168.0.0/16 private
+ (a === 198 && (b === 18 || b === 19)) || // 198.18.0.0/15 benchmarking
+ (a === 198 && b === 51 && c === 100) || // 198.51.100.0/24 TEST-NET-2
+ (a === 203 && b === 0 && c === 113) || // 203.0.113.0/24 TEST-NET-3
+ a >= 224 // 224.0.0.0/4 multicast + 240.0.0.0/4 reserved + broadcast
+ );
+}
+
+const hextetRe = /^[0-9a-f]{1,4}$/i;
+
+// Expand an IPv6 literal (brackets already stripped) to its eight 16-bit groups,
+// or null when it is not a well-formed address. Accepts RFC 4291 form 3 — a
+// trailing dotted quad standing for the last two groups — because the DNS
+// resolver feeding this gate can answer in that form, and reading the quad as
+// hex would silently misread the address (`192.168.1.1` as 0x192).
+function expandIPv6(address) {
+ const halves = address.split("::");
+ if (halves.length > 2) return null;
+ const parse = (part) => {
+ if (part === "") return [];
+ const tokens = part.split(":");
+ let trailing = [];
+ if (tokens[tokens.length - 1].includes(".")) {
+ const octets = tokens.pop().split(".").map(Number);
+ if (
+ octets.length !== 4 ||
+ octets.some((n) => !Number.isInteger(n) || n < 0 || n > 255)
+ ) {
+ return null;
+ }
+ trailing = [(octets[0] << 8) | octets[1], (octets[2] << 8) | octets[3]];
+ }
+ if (!tokens.every((h) => hextetRe.test(h))) return null;
+ return [...tokens.map((h) => parseInt(h, 16)), ...trailing];
+ };
+ const head = parse(halves[0]);
+ const tail = halves.length === 2 ? parse(halves[1]) : [];
+ if (head === null || tail === null) return null;
+ const groups =
+ halves.length === 2
+ ? [...head, ...Array(8 - head.length - tail.length).fill(0), ...tail]
+ : head;
+ if (
+ groups.length !== 8 ||
+ groups.some((g) => !Number.isInteger(g) || g < 0 || g > 0xffff)
+ ) {
+ return null;
+ }
+ return groups;
+}
+
+// Non-global IPv6, derived from the IANA IPv6 Special-Purpose Address Registry.
+// The test is an ALLOWLIST inversion: only globally reachable unicast space
+// (2000::/3) survives, and the registry's non-global blocks inside 2000::/3 are
+// carved back out. An enumerated deny list keeps reopening one class of bypass —
+// every prefix nobody listed reads as public, so each newly noticed block is
+// another fix — whereas this shape defaults an unlisted, unassigned, or newly
+// registered block to refused.
+function isPrivateIPv6(address) {
+ const g = expandIPv6(address);
+ if (!g) return false;
+
+ // The prefixes that carry an IPv4 address are judged by that address: the v6
+ // wrapper is only as global as the v4 target it names, so a NAT64 literal
+ // wrapping the cloud metadata address is refused like the bare v4 form.
+ const embedsIPv4 =
+ (g.slice(0, 5).every((h) => h === 0) && g[5] === 0xffff) || // ::ffff:a.b.c.d IPv4-mapped
+ (g[0] === 0x0064 &&
+ g[1] === 0xff9b &&
+ g.slice(2, 6).every((h) => h === 0)); // 64:ff9b::/96 NAT64 well-known prefix
+ if (embedsIPv4) {
+ const v4 = `${g[6] >> 8}.${g[6] & 0xff}.${g[7] >> 8}.${g[7] & 0xff}`;
+ return isPrivateIPv4(v4);
+ }
+
+ // RFC 8215 reserves 64:ff9b:1::/48 for translation local to a single domain
+ // and permits inter-domain use only under RFC 6052 section 3.2, so the whole
+ // prefix is refused rather than unwrapped: its suffix is not a fixed-width v4
+ // address, and an internal network routing it is precisely the SSRF target
+ // this gate exists to keep out.
+ if (g[0] === 0x0064 && g[1] === 0xff9b && g[2] === 0x0001) return true;
+
+ // Outside 2000::/3: `::` unspecified, `::1` loopback, 100::/64 discard-only,
+ // 100:0:0:1::/64 dummy prefix (RFC 9780), 5f00::/16 SRv6 SIDs, fc00::/7
+ // unique-local, fe80::/10 link-local, ff00::/8 multicast, and every block
+ // IANA has not allocated for global unicast.
+ if ((g[0] & 0xe000) !== 0x2000) return true;
+
+ // The non-global blocks that sit inside 2000::/3.
+ if (g[0] === 0x2001 && (g[1] & 0xfe00) === 0) return true; // 2001::/23 IETF special-purpose (Teredo, benchmarking 2001:2::/48, ORCHID, ...)
+ if (g[0] === 0x2001 && g[1] === 0x0db8) return true; // 2001:db8::/32 documentation
+ if (g[0] === 0x2002) return true; // 2002::/16 6to4 — wraps an arbitrary IPv4 tunnel endpoint
+ if (g[0] === 0x3fff && (g[1] & 0xf000) === 0) return true; // 3fff::/20 documentation (RFC 9637)
+
+ return false;
+}
+
+// True for hosts that must never be dereferenced during a reachability check.
+// Relies on WHATWG URL canonicalization to fold decimal/hex/octal/integer IPv4
+// and compressed IPv6 into the literal forms matched here. DNS names that resolve
+// to a private address at fetch time are not covered — the checker resolves DNS
+// itself, so a rebind remains out of reach of this offline literal gate.
+function isPrivateHost(hostname) {
+ const host = hostname.toLowerCase().replace(/\.$/, "");
+ if (host === "localhost" || host.endsWith(".localhost")) return true;
+ if (host.startsWith("[") && host.endsWith("]")) {
+ return isPrivateIPv6(host.slice(1, -1));
+ }
+ if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(host)) return isPrivateIPv4(host);
+ return false;
+}
+
+// Default DNS resolver for the non-literal-host gate below: every address the
+// name resolves to, both families.
+async function lookupAllAddresses(hostname) {
+ const { lookup } = await import("node:dns/promises");
+ return lookup(hostname, { all: true });
+}
+
+/**
+ * Return true for links that must not be included in reachability checks.
+ *
+ * SSRF guard, two layers: a literal IP host is judged directly against the
+ * non-global blocks above, and a DNS name is resolved (every A/AAAA record)
+ * and refused when ANY resolved address is non-global — so a hostname whose
+ * record points at, e.g., 169.254.169.254 is never dereferenced. A name that
+ * does not resolve is skipped too: nothing reachable to check. Residual,
+ * documented in the CHANGELOG: the checker performs its own resolution at
+ * fetch time, so a rebind between this gate and the fetch, or a redirect hop
+ * to a private target inside the checker, remains outside this gate.
+ *
+ * `resolveHost` is injectable for tests; production uses the real resolver.
+ */
+export async function shouldSkipLinkCheck(link, resolveHost = lookupAllAddresses) {
if (/^(?:data:|file:|#)/i.test(link)) return true;
try {
const url = new URL(link);
- return (
- (url.protocol === "http:" || url.protocol === "https:") &&
- isCitationOnlyHost(url.hostname)
- );
+ if (isPrivateHost(url.hostname)) return true;
+ if (url.protocol !== "http:" && url.protocol !== "https:") return false;
+ if (isCitationOnlyHost(url.hostname)) return true;
+
+ const host = url.hostname.toLowerCase().replace(/\.$/, "");
+ const isLiteral =
+ (host.startsWith("[") && host.endsWith("]")) ||
+ /^\d{1,3}(?:\.\d{1,3}){3}$/.test(host);
+ if (!isLiteral) {
+ let records;
+ try {
+ records = await resolveHost(host);
+ } catch {
+ return true; // unresolvable — nothing reachable to check
+ }
+ const addresses = Array.isArray(records) ? records : [records];
+ if (addresses.length === 0) return true;
+ for (const record of addresses) {
+ const address =
+ typeof record === "string" ? record : record?.address;
+ if (typeof address !== "string" || address.length === 0) {
+ return true; // unreadable answer — fail closed
+ }
+ const nonGlobal = address.includes(":")
+ ? isPrivateIPv6(address)
+ : isPrivateIPv4(address);
+ if (nonGlobal) return true;
+ }
+ }
+ return false;
} catch {
return false;
}
diff --git a/plugins/ai-briefing/skills/generate/output/build/test/build-sinks.test.js b/plugins/ai-briefing/skills/generate/output/build/test/build-sinks.test.js
new file mode 100644
index 0000000000..145320db95
--- /dev/null
+++ b/plugins/ai-briefing/skills/generate/output/build/test/build-sinks.test.js
@@ -0,0 +1,118 @@
+import assert from "node:assert/strict";
+import { spawnSync } from "node:child_process";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import test from "node:test";
+import { fileURLToPath } from "node:url";
+
+import JSZip from "jszip";
+
+import { buildSections } from "../build-sections.js";
+
+const BUILD_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
+
+const theme = {
+ bg: "0B1020", bgAccent: "141A2E", bgCard: "1C2440",
+ brandIndigo: "3F3E87", brandRed: "E23B3B",
+ accent: "80BEFF", accent2: "FFB901", accent3: "80FFC0",
+ text: "FFFFFF", textMuted: "9AA3B2", divider: "2A3350",
+ pptFontHead: "Arial", pptFontBody: "Arial",
+ htmlFontHead: "Arial", htmlFontBody: "Arial",
+};
+
+test("HTML news sink drops a javascript: href and keeps a safe https href", () => {
+ const newsSlide = {
+ type: "news",
+ provider: "openai",
+ tier: "high",
+ title: "OpenAI",
+ bullets: [
+ {
+ title: "Launch",
+ body: "detail",
+ urls: ["https://safe.example/keep", "javascript:alert(1)"],
+ },
+ ],
+ section: "openai",
+ };
+ const { sectionsHtml } = buildSections({
+ slides: [newsSlide],
+ meta: { meetingNumber: 1, date: "2026-01-01" },
+ logoWhiteData: "",
+ providerLogoSvg: {},
+ });
+
+ assert.ok(
+ sectionsHtml.includes('href="https://safe.example/keep"'),
+ "safe https href must be rendered",
+ );
+ assert.ok(
+ !sectionsHtml.includes("javascript:alert"),
+ "javascript: href must be dropped",
+ );
+});
+
+// Drives the real build-pptx.js entrypoint against a fixture slides-data.js so the
+// buildNews/buildCondensed hyperlink sinks are exercised end-to-end, then reads the
+// emitted deck's external hyperlink relationships to confirm the filtering.
+test("PPTX news/condensed sinks drop file:// hyperlinks and keep safe ones", async () => {
+ const safeNews = "https://safe.example/keep-news";
+ const safeCondensed = "https://safe.example/keep-condensed";
+ const evilNews = "file:///etc/passwd";
+ const evilCondensed = "file:///etc/shadow";
+
+ const stateRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pptx-sink-"));
+ try {
+ const dataDir = path.join(stateRoot, "default", "output", "build");
+ fs.mkdirSync(dataDir, { recursive: true });
+
+ const slides = [
+ {
+ type: "news", provider: "openai", tier: "high", title: "News",
+ subtitle: "s",
+ bullets: [{ title: "N", body: "x", urls: [safeNews, evilNews] }],
+ section: "openai",
+ },
+ {
+ type: "condensed", provider: "openai", tier: "low", title: "Condensed",
+ subtitle: "s",
+ bullets: [{ title: "C", body: "y", urls: [safeCondensed, evilCondensed] }],
+ section: "openai",
+ },
+ ];
+ const fixture =
+ `export const meta = ${JSON.stringify({ meetingNumber: 99, org: "T", tagline: "t", date: "2026-01-01", window: "w", logoColor: "", logoWhite: "" })};\n` +
+ `export const theme = ${JSON.stringify(theme)};\n` +
+ `export const providerLogos = {};\n` +
+ `export const slides = ${JSON.stringify(slides)};\n`;
+ fs.writeFileSync(path.join(dataDir, "slides-data.js"), fixture);
+
+ const r = spawnSync(process.execPath, [path.join(BUILD_DIR, "build-pptx.js")], {
+ cwd: BUILD_DIR,
+ env: { ...process.env, CLAUDE_PLUGIN_DATA: stateRoot },
+ encoding: "utf8",
+ });
+ assert.equal(r.status, 0, `build-pptx.js failed: ${r.stderr || r.stdout}`);
+
+ const pptxPath = path.join(stateRoot, "default", "output", "meetings", "ai-meeting-99.pptx");
+ const zip = await JSZip.loadAsync(fs.readFileSync(pptxPath));
+ const relNames = Object.keys(zip.files).filter((n) =>
+ /ppt\/slides\/_rels\/slide\d+\.xml\.rels$/.test(n),
+ );
+ const targets = [];
+ for (const n of relNames) {
+ const xml = await zip.file(n).async("string");
+ for (const m of xml.matchAll(/Target="([^"]+)"\s+TargetMode="External"/g)) {
+ targets.push(m[1]);
+ }
+ }
+
+ assert.ok(targets.includes(safeNews), "safe news hyperlink must survive");
+ assert.ok(targets.includes(safeCondensed), "safe condensed hyperlink must survive");
+ assert.ok(!targets.includes(evilNews), "file:// news hyperlink must be dropped");
+ assert.ok(!targets.includes(evilCondensed), "file:// condensed hyperlink must be dropped");
+ } finally {
+ fs.rmSync(stateRoot, { recursive: true, force: true });
+ }
+});
diff --git a/plugins/ai-briefing/skills/generate/output/build/test/schema.test.js b/plugins/ai-briefing/skills/generate/output/build/test/schema.test.js
new file mode 100644
index 0000000000..739618fcfe
--- /dev/null
+++ b/plugins/ai-briefing/skills/generate/output/build/test/schema.test.js
@@ -0,0 +1,61 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { validateDeck } from "../lib/schema.js";
+
+const HEX = "112233";
+const theme = {
+ bg: HEX, bgAccent: HEX, bgCard: HEX,
+ brandIndigo: HEX, brandRed: HEX,
+ accent: HEX, accent2: HEX, accent3: HEX,
+ text: HEX, textMuted: HEX, divider: HEX,
+ pptFontHead: "Arial", pptFontBody: "Arial",
+ htmlFontHead: "Arial", htmlFontBody: "Arial",
+};
+
+const meta = {
+ meetingNumber: 1, org: "Org", tagline: "t",
+ date: "2026-01-01", window: "w", logoColor: "", logoWhite: "",
+};
+
+const titleSlide = {
+ type: "title", eyebrow: "e", title: "t", subtitle: "s", footer: "f",
+};
+
+// A valid 10-slide deck whose single news bullet carries `url`. The Url refine is
+// the only thing under test, so everything else is held constant and valid.
+function makeDeck(url) {
+ const newsSlide = {
+ type: "news",
+ provider: null,
+ title: "News",
+ bullets: [{ title: "Item", body: "body", urls: [url] }],
+ };
+ return {
+ meta,
+ theme,
+ providerLogos: {},
+ slides: [newsSlide, ...Array.from({ length: 9 }, () => ({ ...titleSlide }))],
+ };
+}
+
+test("validateDeck accepts benign source-URL schemes", () => {
+ for (const url of [
+ "https://example.com/a",
+ "http://example.com/a",
+ "mailto:person@example.com",
+ "tel:+15551234567",
+ ]) {
+ assert.doesNotThrow(() => validateDeck(makeDeck(url)), url);
+ }
+});
+
+test("validateDeck rejects the whole deck on a dangerous or unlisted scheme", () => {
+ for (const url of [
+ "javascript:alert(1)",
+ "data:text/html,",
+ "file:///etc/passwd",
+ "ftp://example.com/x",
+ ]) {
+ assert.throws(() => validateDeck(makeDeck(url)), /schema violation/, url);
+ }
+});
diff --git a/plugins/ai-briefing/skills/generate/output/build/test/url-policy.test.js b/plugins/ai-briefing/skills/generate/output/build/test/url-policy.test.js
index 878848947c..a32eb9e5ab 100644
--- a/plugins/ai-briefing/skills/generate/output/build/test/url-policy.test.js
+++ b/plugins/ai-briefing/skills/generate/output/build/test/url-policy.test.js
@@ -1,6 +1,206 @@
import assert from "node:assert/strict";
import test from "node:test";
-import { shouldSkipLinkCheck } from "../lib/url-policy.js";
+import { isAllowedUrlScheme, shouldSkipLinkCheck } from "../lib/url-policy.js";
+
+test("isAllowedUrlScheme accepts the benign briefing schemes", () => {
+ for (const url of [
+ "https://example.com/a",
+ "http://example.com/a",
+ "mailto:person@example.com",
+ "tel:+15551234567",
+ ]) {
+ assert.equal(isAllowedUrlScheme(url), true, url);
+ }
+});
+
+test("isAllowedUrlScheme rejects dangerous and unlisted schemes", () => {
+ for (const url of [
+ "javascript:alert(1)",
+ "JavaScript:alert(1)",
+ "data:text/html,",
+ "file:///etc/passwd",
+ "file://host/share",
+ "blob:https://example.com/uuid",
+ "vbscript:msgbox(1)",
+ "ftp://example.com/x",
+ "not-a-url",
+ ]) {
+ assert.equal(isAllowedUrlScheme(url), false, url);
+ }
+});
+
+test("shouldSkipLinkCheck skips private, loopback, link-local and reserved hosts", async () => {
+ for (const link of [
+ "http://127.0.0.1/",
+ "http://127.0.0.1:8080/x",
+ "http://0x7f000001/", // hex form of 127.0.0.1
+ "http://2130706433/", // integer form of 127.0.0.1
+ "http://10.0.0.5/",
+ "http://172.16.0.1/",
+ "http://172.31.255.1/",
+ "http://192.168.1.1/",
+ "https://169.254.169.254/latest/meta-data/", // cloud metadata
+ "http://0.0.0.0/",
+ "http://localhost/",
+ "http://foo.localhost/",
+ "http://[::1]/",
+ "http://[::]/",
+ "http://[fe80::1]/",
+ "http://[fc00::1]/",
+ "http://[fd12:3456::1]/",
+ "http://[::ffff:127.0.0.1]/", // IPv4-mapped loopback
+ ]) {
+ assert.equal(await shouldSkipLinkCheck(link), true, link);
+ }
+});
+
+test("shouldSkipLinkCheck skips shared-address and other non-global blocks", async () => {
+ for (const link of [
+ "http://100.64.0.1/", // 100.64.0.0/10 shared address space (CGN)
+ "http://100.127.255.254/",
+ "http://192.0.0.8/", // 192.0.0.0/24 IETF protocol assignments
+ "http://192.0.2.1/", // TEST-NET-1
+ "http://198.18.0.1/", // benchmarking
+ "http://198.19.255.1/",
+ "http://198.51.100.7/", // TEST-NET-2
+ "http://203.0.113.9/", // TEST-NET-3
+ "http://192.88.99.1/", // 192.88.99.0/24 deprecated 6to4 relay anycast
+ "http://192.88.99.2/", // 6a44-relay anycast
+ "http://224.0.0.251/", // multicast
+ "http://240.0.0.1/", // reserved
+ "http://255.255.255.255/", // broadcast
+ "http://[::ffff:100.64.0.1]/", // IPv4-mapped shared address space
+ "http://[64:ff9b::a00:1]/", // NAT64 prefix embedding 10.0.0.1
+ "http://[100::1]/", // discard-only
+ "http://[64:ff9b:1::1]/", // local-use NAT64 (RFC 8215)
+ "http://[2001:2::1]/", // benchmarking, inside 2001::/23 special-purpose
+ "http://[2001::1]/", // Teredo, inside 2001::/23 special-purpose
+ "http://[2001:db8::1]/", // documentation
+ "http://[2002:5db8:d822::1]/", // 6to4
+ "http://[3fff::1]/", // documentation (RFC 9637)
+ "http://[5f00::1]/", // SRv6 SIDs (RFC 9602)
+ "http://[ff02::1]/", // multicast
+ ]) {
+ assert.equal(await shouldSkipLinkCheck(link), true, link);
+ }
+});
+
+// The IPv6 predicate allowlists global unicast (2000::/3) rather than
+// enumerating a deny list, so a block nobody listed is refused by default
+// instead of read as public.
+test("shouldSkipLinkCheck skips IPv6 outside global unicast space", async () => {
+ for (const link of [
+ "http://[4000::1]/", // unassigned
+ "http://[1000::1]/", // unassigned
+ "http://[8000::1]/", // unassigned
+ "http://[c000::1]/", // unassigned
+ "http://[100:0:0:1::1]/", // dummy IPv6 prefix (RFC 9780)
+ "http://[64:ff9b:2::1]/", // unassigned, just above the local-use NAT64 block
+ "http://[1fff:ffff:ffff:ffff:ffff:ffff:ffff:ffff]/", // just below 2000::/3
+ "http://[4000::]/", // just above it
+ ]) {
+ assert.equal(await shouldSkipLinkCheck(link), true, link);
+ }
+});
+
+test("shouldSkipLinkCheck covers the whole 2001::/23 special-purpose block", async () => {
+ for (const link of [
+ "http://[2001:1::1]/", // Port Control Protocol anycast
+ "http://[2001:3::1]/", // AMT
+ "http://[2001:4:112::1]/", // AS112-v6
+ "http://[2001:10::1]/", // deprecated ORCHID
+ "http://[2001:20::1]/", // ORCHIDv2
+ "http://[2001:30::1]/", // drone remote ID
+ "http://[2001:1ff:ffff:ffff:ffff:ffff:ffff:ffff]/", // top of the block
+ ]) {
+ assert.equal(await shouldSkipLinkCheck(link), true, link);
+ }
+ // 2001:200:: is the first global-unicast address above the block.
+ assert.equal(await shouldSkipLinkCheck("http://[2001:200::1]/"), false);
+});
+
+// Stub resolvers so tests never touch real DNS.
+const resolvesPublic = async () => [{ address: "93.184.216.34", family: 4 }];
+
+test("shouldSkipLinkCheck reads resolver answers in RFC 4291 dotted-quad form", async () => {
+ // node:dns can answer with the IPv4-mapped dotted form; parsing the quad as
+ // hex reads 192.168.1.1 as 0x192, so the record would fall through as public.
+ for (const address of ["::ffff:192.168.1.1", "::ffff:169.254.169.254"]) {
+ assert.equal(
+ await shouldSkipLinkCheck("http://name.example/", async () => [
+ { address, family: 6 },
+ ]),
+ true,
+ address,
+ );
+ }
+ assert.equal(
+ await shouldSkipLinkCheck("http://name.example/", async () => [
+ { address: "::ffff:93.184.216.34", family: 6 },
+ ]),
+ false,
+ );
+});
+
+test("shouldSkipLinkCheck skips hostnames resolving to non-global addresses", async () => {
+ const cases = [
+ ["http://metadata.attacker.example/", [{ address: "169.254.169.254", family: 4 }]],
+ ["http://internal.attacker.example/", [{ address: "10.0.0.7", family: 4 }]],
+ ["http://cgn.attacker.example/", [{ address: "100.64.0.9", family: 4 }]],
+ ["http://v6.attacker.example/", [{ address: "::1", family: 6 }]],
+ ["http://mixed.attacker.example/", [
+ { address: "93.184.216.34", family: 4 },
+ { address: "192.168.1.5", family: 4 },
+ ]], // ANY non-global record refuses the whole name
+ ];
+ for (const [link, records] of cases) {
+ assert.equal(await shouldSkipLinkCheck(link, async () => records), true, link);
+ }
+});
+
+test("shouldSkipLinkCheck skips unresolvable and unreadable hostnames (fail closed)", async () => {
+ const failing = async () => {
+ throw new Error("ENOTFOUND");
+ };
+ assert.equal(await shouldSkipLinkCheck("https://nxdomain.example/", failing), true);
+ assert.equal(
+ await shouldSkipLinkCheck("https://empty.example/", async () => []),
+ true,
+ );
+ assert.equal(
+ await shouldSkipLinkCheck("https://weird.example/", async () => [{}]),
+ true,
+ );
+});
+
+test("shouldSkipLinkCheck still checks hostnames resolving to global addresses", async () => {
+ assert.equal(
+ await shouldSkipLinkCheck("https://example.com/a", resolvesPublic),
+ false,
+ );
+});
+
+test("shouldSkipLinkCheck still checks ordinary public hosts", async () => {
+ for (const link of [
+ "https://example.com/a",
+ "http://8.8.8.8/",
+ "https://172.15.0.1/", // just below the 172.16/12 private block
+ "https://172.32.0.1/", // just above it
+ "https://100.63.255.254/", // just below the 100.64/10 shared block
+ "https://100.128.0.1/", // just above it
+ "https://192.0.1.1/", // between 192.0.0/24 and 192.0.2/24
+ "https://192.88.98.1/", // just below the 192.88.99/24 relay block
+ "https://192.88.100.1/", // just above it
+ "https://198.17.255.1/", // just below the 198.18/15 benchmarking block
+ "https://198.20.0.1/", // just above it
+ "https://223.255.255.254/", // top of unicast space, below multicast
+ "https://[2001:db7::1]/", // just below the 2001:db8::/32 documentation block
+ "https://[2001:200::1]/", // just above the 2001::/23 special-purpose block
+ "https://[2003::1]/", // just above the 2002::/16 6to4 block
+ ]) {
+ assert.equal(await shouldSkipLinkCheck(link, resolvesPublic), false, link);
+ }
+});
test("skips X and Twitter citation hosts and their aliases", async () => {
const links = [
@@ -26,7 +226,7 @@ test("does not skip lookalike or unrelated hosts", async () => {
];
for (const link of links) {
- assert.equal(await shouldSkipLinkCheck(link), false, link);
+ assert.equal(await shouldSkipLinkCheck(link, resolvesPublic), false, link);
}
});
diff --git a/plugins/autonomy/.claude-plugin/plugin.json b/plugins/autonomy/.claude-plugin/plugin.json
index bc58560b3f..3e5fc9adaa 100644
--- a/plugins/autonomy/.claude-plugin/plugin.json
+++ b/plugins/autonomy/.claude-plugin/plugin.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
"name": "autonomy",
- "version": "0.11.0",
+ "version": "0.11.1",
"description": "Governed autonomous agent operation: role-topology, binding-seam, wiring-vs-advisor, telemetry, return-accounting, trigger-dispatch, per-work-class guardrail-matrix, standing-routine-catalog, and design-only runner-charter contracts for climbing the AI-adoption ladder, plus a guided-setup skill that discovers an adopting org's state, writes its schema-versioned binding, wires standards-pinned OTLP emission with a zero-cost file-artifact default, wires human-attested return capture at the task boundary, wires signal adapters with one governed dispatch entrypoint, binds the five-class guardrail matrix to an org's isolation substrates with an in-boundary live-validation probe before recording each fail-closed binding, and stands up standing-routine-catalog classes as scheduled temporal signal adapters behind the one governed queue with free scheduling defaults wired as reviewable changes and each routine's work-class mapping homed on the security surface.",
"author": {
"name": "Melodic Software",
diff --git a/plugins/autonomy/CHANGELOG.md b/plugins/autonomy/CHANGELOG.md
index 6e78743374..977fe908d7 100644
--- a/plugins/autonomy/CHANGELOG.md
+++ b/plugins/autonomy/CHANGELOG.md
@@ -6,6 +6,22 @@ All notable changes to the `autonomy` plugin are documented here. Format follows
Versions 0.1.0–0.7.0 predate this file (introduced with 0.7.1); their history lives in the
merged work-package PRs (#333, #343, #356, #372, #377, #600, #676).
+## [0.11.1]
+
+### Fixed
+
+- **Shared `hook-utils.sh`: a bare or trailing unquoted `NAME=value` Bash
+ command no longer leaks the assignment value into the privacy-safe
+ telemetry/audit subject.** `hook::extract_bash_subject` stripped a leading
+ `VAR=value` prefix only when a following command word consumed it, so a
+ command whose LAST token was an unquoted assignment (e.g. `TOKEN=ghp_…`)
+ survived to the subject and emitted `Bash:TOKEN=ghp_…` into
+ `hook-events.jsonl` and any wired `HOOK_TELEMETRY_SINK`. A resolved token
+ still shaped like a shell assignment now bails to the bare `Bash` subject,
+ matching the existing quoted-value bail (`VAR=x cmd` still reduces to
+ `Bash:cmd`). Synced from `lib/hook-utils.sh`; the subject is
+ telemetry/audit-only, so no hook block/allow behavior changes.
+
## [0.11.0]
### Added
diff --git a/plugins/autonomy/hooks/hook-utils.sh b/plugins/autonomy/hooks/hook-utils.sh
index b854e2598c..411c4a6731 100644
--- a/plugins/autonomy/hooks/hook-utils.sh
+++ b/plugins/autonomy/hooks/hook-utils.sh
@@ -279,6 +279,11 @@ hook::jq_field() {
# A quoted assignment value (e.g. `TOKEN="a b" curl …`) would otherwise leak a
# fragment of the value into the token, so any token carrying a quote aborts to a
# bare "Bash" subject rather than risk exposing part of the value.
+#
+# A bare or trailing unquoted assignment that no following command consumed
+# (e.g. the whole command is `TOKEN=ghp_…`) is likewise a value the subject must
+# not carry, so a resolved token still shaped like a NAME=value assignment aborts
+# to the bare "Bash" subject too.
# SUBJECT=$(hook::extract_bash_subject "$TOOL" "$CMD")
hook::extract_bash_subject() {
local tool="$1" cmd="${2:-}"
@@ -307,6 +312,19 @@ hook::extract_bash_subject() {
printf '%s' "$tool"
return 0
fi
+ # A resolved token still shaped like a bare/trailing assignment (no following
+ # command word consumed it in the strip loop) would emit the assignment's
+ # value — a possible credential — as the subject; bail to the bare "Bash"
+ # subject as with a quoted value. All valid Bash assignment forms count:
+ # NAME=value, append NAME+=value, and subscripted NAME[idx]=value /
+ # NAME[idx]+=value — the subscript matched greedily (`.*`) because Bash
+ # accepts nested subscripts like NAME[1+IDX[0]]=value, which a
+ # no-close-bracket class would miss. This runs BEFORE the basename strip so
+ # a path-valued assignment (TOKEN=/a/b/secret) cannot lose its "=" first.
+ if [[ "$first_token" =~ ^[a-zA-Z_][a-zA-Z0-9_]*(\[.*\])?\+?= ]]; then
+ printf '%s' "$tool"
+ return 0
+ fi
first_token="${first_token##*/}"
if [[ -n "$first_token" ]]; then
printf 'Bash:%s' "$first_token"
diff --git a/plugins/bash-format/.claude-plugin/plugin.json b/plugins/bash-format/.claude-plugin/plugin.json
index d55a73d026..7166135090 100644
--- a/plugins/bash-format/.claude-plugin/plugin.json
+++ b/plugins/bash-format/.claude-plugin/plugin.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
"name": "bash-format",
- "version": "0.6.3",
+ "version": "0.6.4",
"description": "Auto-format and lint shell scripts on edit via shfmt + ShellCheck, using the consuming repo's own .editorconfig and .shellcheckrc.",
"author": {
"name": "Melodic Software",
diff --git a/plugins/bash-format/CHANGELOG.md b/plugins/bash-format/CHANGELOG.md
index 39f76f5c34..fddc8e8806 100644
--- a/plugins/bash-format/CHANGELOG.md
+++ b/plugins/bash-format/CHANGELOG.md
@@ -3,6 +3,22 @@
All notable changes to the `bash-format` plugin are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning.
+## [0.6.4]
+
+### Fixed
+
+- **Shared `hook-utils.sh`: a bare or trailing unquoted `NAME=value` Bash
+ command no longer leaks the assignment value into the privacy-safe
+ telemetry/audit subject.** `hook::extract_bash_subject` stripped a leading
+ `VAR=value` prefix only when a following command word consumed it, so a
+ command whose LAST token was an unquoted assignment (e.g. `TOKEN=ghp_…`)
+ survived to the subject and emitted `Bash:TOKEN=ghp_…` into
+ `hook-events.jsonl` and any wired `HOOK_TELEMETRY_SINK`. A resolved token
+ still shaped like a shell assignment now bails to the bare `Bash` subject,
+ matching the existing quoted-value bail (`VAR=x cmd` still reduces to
+ `Bash:cmd`). Synced from `lib/hook-utils.sh`; the subject is
+ telemetry/audit-only, so no guard or formatter block/allow behavior changes.
+
## [0.6.3]
### Fixed
diff --git a/plugins/bash-format/hooks/hook-utils.sh b/plugins/bash-format/hooks/hook-utils.sh
index b854e2598c..411c4a6731 100644
--- a/plugins/bash-format/hooks/hook-utils.sh
+++ b/plugins/bash-format/hooks/hook-utils.sh
@@ -279,6 +279,11 @@ hook::jq_field() {
# A quoted assignment value (e.g. `TOKEN="a b" curl …`) would otherwise leak a
# fragment of the value into the token, so any token carrying a quote aborts to a
# bare "Bash" subject rather than risk exposing part of the value.
+#
+# A bare or trailing unquoted assignment that no following command consumed
+# (e.g. the whole command is `TOKEN=ghp_…`) is likewise a value the subject must
+# not carry, so a resolved token still shaped like a NAME=value assignment aborts
+# to the bare "Bash" subject too.
# SUBJECT=$(hook::extract_bash_subject "$TOOL" "$CMD")
hook::extract_bash_subject() {
local tool="$1" cmd="${2:-}"
@@ -307,6 +312,19 @@ hook::extract_bash_subject() {
printf '%s' "$tool"
return 0
fi
+ # A resolved token still shaped like a bare/trailing assignment (no following
+ # command word consumed it in the strip loop) would emit the assignment's
+ # value — a possible credential — as the subject; bail to the bare "Bash"
+ # subject as with a quoted value. All valid Bash assignment forms count:
+ # NAME=value, append NAME+=value, and subscripted NAME[idx]=value /
+ # NAME[idx]+=value — the subscript matched greedily (`.*`) because Bash
+ # accepts nested subscripts like NAME[1+IDX[0]]=value, which a
+ # no-close-bracket class would miss. This runs BEFORE the basename strip so
+ # a path-valued assignment (TOKEN=/a/b/secret) cannot lose its "=" first.
+ if [[ "$first_token" =~ ^[a-zA-Z_][a-zA-Z0-9_]*(\[.*\])?\+?= ]]; then
+ printf '%s' "$tool"
+ return 0
+ fi
first_token="${first_token##*/}"
if [[ -n "$first_token" ]]; then
printf 'Bash:%s' "$first_token"
diff --git a/plugins/biome-format/.claude-plugin/plugin.json b/plugins/biome-format/.claude-plugin/plugin.json
index a7d24f54c2..56069da7b8 100644
--- a/plugins/biome-format/.claude-plugin/plugin.json
+++ b/plugins/biome-format/.claude-plugin/plugin.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
"name": "biome-format",
- "version": "0.5.2",
+ "version": "0.5.3",
"description": "Auto-format and lint JS/TS/JSX/JSON on edit via Biome, only when a biome.json governs the repo — using the consuming repo's own Biome config.",
"author": {
"name": "Melodic Software",
diff --git a/plugins/biome-format/CHANGELOG.md b/plugins/biome-format/CHANGELOG.md
index d3c88f9aa4..50cab842ec 100644
--- a/plugins/biome-format/CHANGELOG.md
+++ b/plugins/biome-format/CHANGELOG.md
@@ -3,6 +3,22 @@
All notable changes to the `biome-format` plugin are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning.
+## [0.5.3]
+
+### Fixed
+
+- **Shared `hook-utils.sh`: a bare or trailing unquoted `NAME=value` Bash
+ command no longer leaks the assignment value into the privacy-safe
+ telemetry/audit subject.** `hook::extract_bash_subject` stripped a leading
+ `VAR=value` prefix only when a following command word consumed it, so a
+ command whose LAST token was an unquoted assignment (e.g. `TOKEN=ghp_…`)
+ survived to the subject and emitted `Bash:TOKEN=ghp_…` into
+ `hook-events.jsonl` and any wired `HOOK_TELEMETRY_SINK`. A resolved token
+ still shaped like a shell assignment now bails to the bare `Bash` subject,
+ matching the existing quoted-value bail (`VAR=x cmd` still reduces to
+ `Bash:cmd`). Synced from `lib/hook-utils.sh`; the subject is
+ telemetry/audit-only, so no guard or formatter block/allow behavior changes.
+
## [0.5.2]
### Fixed
diff --git a/plugins/biome-format/hooks/hook-utils.sh b/plugins/biome-format/hooks/hook-utils.sh
index b854e2598c..411c4a6731 100644
--- a/plugins/biome-format/hooks/hook-utils.sh
+++ b/plugins/biome-format/hooks/hook-utils.sh
@@ -279,6 +279,11 @@ hook::jq_field() {
# A quoted assignment value (e.g. `TOKEN="a b" curl …`) would otherwise leak a
# fragment of the value into the token, so any token carrying a quote aborts to a
# bare "Bash" subject rather than risk exposing part of the value.
+#
+# A bare or trailing unquoted assignment that no following command consumed
+# (e.g. the whole command is `TOKEN=ghp_…`) is likewise a value the subject must
+# not carry, so a resolved token still shaped like a NAME=value assignment aborts
+# to the bare "Bash" subject too.
# SUBJECT=$(hook::extract_bash_subject "$TOOL" "$CMD")
hook::extract_bash_subject() {
local tool="$1" cmd="${2:-}"
@@ -307,6 +312,19 @@ hook::extract_bash_subject() {
printf '%s' "$tool"
return 0
fi
+ # A resolved token still shaped like a bare/trailing assignment (no following
+ # command word consumed it in the strip loop) would emit the assignment's
+ # value — a possible credential — as the subject; bail to the bare "Bash"
+ # subject as with a quoted value. All valid Bash assignment forms count:
+ # NAME=value, append NAME+=value, and subscripted NAME[idx]=value /
+ # NAME[idx]+=value — the subscript matched greedily (`.*`) because Bash
+ # accepts nested subscripts like NAME[1+IDX[0]]=value, which a
+ # no-close-bracket class would miss. This runs BEFORE the basename strip so
+ # a path-valued assignment (TOKEN=/a/b/secret) cannot lose its "=" first.
+ if [[ "$first_token" =~ ^[a-zA-Z_][a-zA-Z0-9_]*(\[.*\])?\+?= ]]; then
+ printf '%s' "$tool"
+ return 0
+ fi
first_token="${first_token##*/}"
if [[ -n "$first_token" ]]; then
printf 'Bash:%s' "$first_token"
diff --git a/plugins/claude-ops/.claude-plugin/plugin.json b/plugins/claude-ops/.claude-plugin/plugin.json
index 9822574ebd..c94333b2a1 100644
--- a/plugins/claude-ops/.claude-plugin/plugin.json
+++ b/plugins/claude-ops/.claude-plugin/plugin.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
"name": "claude-ops",
- "version": "0.21.2",
+ "version": "0.21.3",
"description": "Claude Code operations toolkit. Seven skills: observability (read locally captured telemetry — OTEL store, collector, hook-event JSONL, ccusage — with trend reports and store pruning), known-issues (search known Claude product GitHub bugs, check service health, maintain a persistent tracked-issue registry), changelog (ingest Claude Code changelog entries and integrate them into the current repo), plugins (bring a machine's plugin fleet current on demand — marketplace refresh, effective-scope updates including in-repo project/local installs, new-plugin install per policy, scope-divergence detection and explicit convergence), morning-brief (read-only gh-based operator morning view — queue-label counts, merge-ready PRs, parked decisions with their RECOMMENDED lines, and loop-lane telemetry freshness), lanes (start/restart/stop/status loop lanes as named background Claude Code sessions seeded from canonical prompt files, with per-lane model/effort and a repo-pull + marketplace-refresh launch step), and a re-runnable setup action that settles where the known-issues registry lives. Plus a family of seven advisory *-audit telemetry-emitter hooks (API errors, config changes, instruction loads, permission denials, pre-compaction, skill usage, tool failures) that emit the shared hook-telemetry envelope, and a reference sink that maps envelopes into the hook-events.jsonl the observability skill reads.",
"author": {
"name": "Melodic Software",
diff --git a/plugins/claude-ops/CHANGELOG.md b/plugins/claude-ops/CHANGELOG.md
index a5c5ce5112..69513d0fea 100644
--- a/plugins/claude-ops/CHANGELOG.md
+++ b/plugins/claude-ops/CHANGELOG.md
@@ -3,6 +3,24 @@
All notable changes to the `claude-ops` plugin are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning.
+## [0.21.3]
+
+### Fixed
+
+- **Shared `hook-utils.sh`: a bare or trailing unquoted `NAME=value` Bash
+ command no longer leaks the assignment value into the privacy-safe
+ telemetry/audit subject.** `hook::extract_bash_subject` stripped a leading
+ `VAR=value` prefix only when a following command word consumed it, so a
+ command whose LAST token was an unquoted assignment (e.g. `TOKEN=ghp_…`)
+ survived to the subject and emitted `Bash:TOKEN=ghp_…` into
+ `hook-events.jsonl` and any wired `HOOK_TELEMETRY_SINK`. This plugin's
+ `permission-denied-audit` and `tool-failure-audit` hooks compute their
+ subject through this helper, so the leak was reachable here. A resolved token
+ still shaped like a shell assignment now bails to the bare `Bash` subject,
+ matching the existing quoted-value bail (`VAR=x cmd` still reduces to
+ `Bash:cmd`). Synced from `lib/hook-utils.sh`; the subject is
+ telemetry/audit-only, so no audit hook's outcome changes.
+
## [0.21.2]
### Fixed
diff --git a/plugins/claude-ops/hooks/hook-utils.sh b/plugins/claude-ops/hooks/hook-utils.sh
index b854e2598c..411c4a6731 100644
--- a/plugins/claude-ops/hooks/hook-utils.sh
+++ b/plugins/claude-ops/hooks/hook-utils.sh
@@ -279,6 +279,11 @@ hook::jq_field() {
# A quoted assignment value (e.g. `TOKEN="a b" curl …`) would otherwise leak a
# fragment of the value into the token, so any token carrying a quote aborts to a
# bare "Bash" subject rather than risk exposing part of the value.
+#
+# A bare or trailing unquoted assignment that no following command consumed
+# (e.g. the whole command is `TOKEN=ghp_…`) is likewise a value the subject must
+# not carry, so a resolved token still shaped like a NAME=value assignment aborts
+# to the bare "Bash" subject too.
# SUBJECT=$(hook::extract_bash_subject "$TOOL" "$CMD")
hook::extract_bash_subject() {
local tool="$1" cmd="${2:-}"
@@ -307,6 +312,19 @@ hook::extract_bash_subject() {
printf '%s' "$tool"
return 0
fi
+ # A resolved token still shaped like a bare/trailing assignment (no following
+ # command word consumed it in the strip loop) would emit the assignment's
+ # value — a possible credential — as the subject; bail to the bare "Bash"
+ # subject as with a quoted value. All valid Bash assignment forms count:
+ # NAME=value, append NAME+=value, and subscripted NAME[idx]=value /
+ # NAME[idx]+=value — the subscript matched greedily (`.*`) because Bash
+ # accepts nested subscripts like NAME[1+IDX[0]]=value, which a
+ # no-close-bracket class would miss. This runs BEFORE the basename strip so
+ # a path-valued assignment (TOKEN=/a/b/secret) cannot lose its "=" first.
+ if [[ "$first_token" =~ ^[a-zA-Z_][a-zA-Z0-9_]*(\[.*\])?\+?= ]]; then
+ printf '%s' "$tool"
+ return 0
+ fi
first_token="${first_token##*/}"
if [[ -n "$first_token" ]]; then
printf 'Bash:%s' "$first_token"
diff --git a/plugins/desktop-notification/.claude-plugin/plugin.json b/plugins/desktop-notification/.claude-plugin/plugin.json
index 675821ca32..d5f8ed1954 100644
--- a/plugins/desktop-notification/.claude-plugin/plugin.json
+++ b/plugins/desktop-notification/.claude-plugin/plugin.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
"name": "desktop-notification",
- "version": "0.5.3",
+ "version": "0.5.4",
"description": "Alert you when Claude Code needs input — an audible terminal bell, an OSC 9 terminal notification, and an OS-native toast (macOS/Linux) on permission and idle prompts.",
"author": {
"name": "Melodic Software",
diff --git a/plugins/desktop-notification/CHANGELOG.md b/plugins/desktop-notification/CHANGELOG.md
index bb50e6e929..8098b0db04 100644
--- a/plugins/desktop-notification/CHANGELOG.md
+++ b/plugins/desktop-notification/CHANGELOG.md
@@ -3,6 +3,22 @@
All notable changes to the `desktop-notification` plugin are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning.
+## [0.5.4]
+
+### Fixed
+
+- **Shared `hook-utils.sh`: a bare or trailing unquoted `NAME=value` Bash
+ command no longer leaks the assignment value into the privacy-safe
+ telemetry/audit subject.** `hook::extract_bash_subject` stripped a leading
+ `VAR=value` prefix only when a following command word consumed it, so a
+ command whose LAST token was an unquoted assignment (e.g. `TOKEN=ghp_…`)
+ survived to the subject and emitted `Bash:TOKEN=ghp_…` into
+ `hook-events.jsonl` and any wired `HOOK_TELEMETRY_SINK`. A resolved token
+ still shaped like a shell assignment now bails to the bare `Bash` subject,
+ matching the existing quoted-value bail (`VAR=x cmd` still reduces to
+ `Bash:cmd`). Synced from `lib/hook-utils.sh`; the subject is
+ telemetry/audit-only, so no guard or formatter block/allow behavior changes.
+
## [0.5.3]
### Fixed
diff --git a/plugins/desktop-notification/hooks/hook-utils.sh b/plugins/desktop-notification/hooks/hook-utils.sh
index b854e2598c..411c4a6731 100644
--- a/plugins/desktop-notification/hooks/hook-utils.sh
+++ b/plugins/desktop-notification/hooks/hook-utils.sh
@@ -279,6 +279,11 @@ hook::jq_field() {
# A quoted assignment value (e.g. `TOKEN="a b" curl …`) would otherwise leak a
# fragment of the value into the token, so any token carrying a quote aborts to a
# bare "Bash" subject rather than risk exposing part of the value.
+#
+# A bare or trailing unquoted assignment that no following command consumed
+# (e.g. the whole command is `TOKEN=ghp_…`) is likewise a value the subject must
+# not carry, so a resolved token still shaped like a NAME=value assignment aborts
+# to the bare "Bash" subject too.
# SUBJECT=$(hook::extract_bash_subject "$TOOL" "$CMD")
hook::extract_bash_subject() {
local tool="$1" cmd="${2:-}"
@@ -307,6 +312,19 @@ hook::extract_bash_subject() {
printf '%s' "$tool"
return 0
fi
+ # A resolved token still shaped like a bare/trailing assignment (no following
+ # command word consumed it in the strip loop) would emit the assignment's
+ # value — a possible credential — as the subject; bail to the bare "Bash"
+ # subject as with a quoted value. All valid Bash assignment forms count:
+ # NAME=value, append NAME+=value, and subscripted NAME[idx]=value /
+ # NAME[idx]+=value — the subscript matched greedily (`.*`) because Bash
+ # accepts nested subscripts like NAME[1+IDX[0]]=value, which a
+ # no-close-bracket class would miss. This runs BEFORE the basename strip so
+ # a path-valued assignment (TOKEN=/a/b/secret) cannot lose its "=" first.
+ if [[ "$first_token" =~ ^[a-zA-Z_][a-zA-Z0-9_]*(\[.*\])?\+?= ]]; then
+ printf '%s' "$tool"
+ return 0
+ fi
first_token="${first_token##*/}"
if [[ -n "$first_token" ]]; then
printf 'Bash:%s' "$first_token"
diff --git a/plugins/eol-normalizer/.claude-plugin/plugin.json b/plugins/eol-normalizer/.claude-plugin/plugin.json
index 919af58489..86430d8461 100644
--- a/plugins/eol-normalizer/.claude-plugin/plugin.json
+++ b/plugins/eol-normalizer/.claude-plugin/plugin.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
"name": "eol-normalizer",
- "version": "0.5.2",
+ "version": "0.5.3",
"description": "Normalize a written file's working-tree line endings to its .gitattributes eol value on edit — symmetric CRLF/LF driven by git check-attr, advisory and never blocking.",
"author": {
"name": "Melodic Software",
diff --git a/plugins/eol-normalizer/CHANGELOG.md b/plugins/eol-normalizer/CHANGELOG.md
index a82284a910..dbbce3d9b5 100644
--- a/plugins/eol-normalizer/CHANGELOG.md
+++ b/plugins/eol-normalizer/CHANGELOG.md
@@ -3,6 +3,22 @@
All notable changes to the `eol-normalizer` plugin are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning.
+## [0.5.3]
+
+### Fixed
+
+- **Shared `hook-utils.sh`: a bare or trailing unquoted `NAME=value` Bash
+ command no longer leaks the assignment value into the privacy-safe
+ telemetry/audit subject.** `hook::extract_bash_subject` stripped a leading
+ `VAR=value` prefix only when a following command word consumed it, so a
+ command whose LAST token was an unquoted assignment (e.g. `TOKEN=ghp_…`)
+ survived to the subject and emitted `Bash:TOKEN=ghp_…` into
+ `hook-events.jsonl` and any wired `HOOK_TELEMETRY_SINK`. A resolved token
+ still shaped like a shell assignment now bails to the bare `Bash` subject,
+ matching the existing quoted-value bail (`VAR=x cmd` still reduces to
+ `Bash:cmd`). Synced from `lib/hook-utils.sh`; the subject is
+ telemetry/audit-only, so no guard or formatter block/allow behavior changes.
+
## [0.5.2]
### Fixed
diff --git a/plugins/eol-normalizer/hooks/hook-utils.sh b/plugins/eol-normalizer/hooks/hook-utils.sh
index b854e2598c..411c4a6731 100644
--- a/plugins/eol-normalizer/hooks/hook-utils.sh
+++ b/plugins/eol-normalizer/hooks/hook-utils.sh
@@ -279,6 +279,11 @@ hook::jq_field() {
# A quoted assignment value (e.g. `TOKEN="a b" curl …`) would otherwise leak a
# fragment of the value into the token, so any token carrying a quote aborts to a
# bare "Bash" subject rather than risk exposing part of the value.
+#
+# A bare or trailing unquoted assignment that no following command consumed
+# (e.g. the whole command is `TOKEN=ghp_…`) is likewise a value the subject must
+# not carry, so a resolved token still shaped like a NAME=value assignment aborts
+# to the bare "Bash" subject too.
# SUBJECT=$(hook::extract_bash_subject "$TOOL" "$CMD")
hook::extract_bash_subject() {
local tool="$1" cmd="${2:-}"
@@ -307,6 +312,19 @@ hook::extract_bash_subject() {
printf '%s' "$tool"
return 0
fi
+ # A resolved token still shaped like a bare/trailing assignment (no following
+ # command word consumed it in the strip loop) would emit the assignment's
+ # value — a possible credential — as the subject; bail to the bare "Bash"
+ # subject as with a quoted value. All valid Bash assignment forms count:
+ # NAME=value, append NAME+=value, and subscripted NAME[idx]=value /
+ # NAME[idx]+=value — the subscript matched greedily (`.*`) because Bash
+ # accepts nested subscripts like NAME[1+IDX[0]]=value, which a
+ # no-close-bracket class would miss. This runs BEFORE the basename strip so
+ # a path-valued assignment (TOKEN=/a/b/secret) cannot lose its "=" first.
+ if [[ "$first_token" =~ ^[a-zA-Z_][a-zA-Z0-9_]*(\[.*\])?\+?= ]]; then
+ printf '%s' "$tool"
+ return 0
+ fi
first_token="${first_token##*/}"
if [[ -n "$first_token" ]]; then
printf 'Bash:%s' "$first_token"
diff --git a/plugins/go-format/.claude-plugin/plugin.json b/plugins/go-format/.claude-plugin/plugin.json
index 48d32886e3..da450df600 100644
--- a/plugins/go-format/.claude-plugin/plugin.json
+++ b/plugins/go-format/.claude-plugin/plugin.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
"name": "go-format",
- "version": "0.2.2",
+ "version": "0.2.3",
"description": "Auto-fix Go formatting and import management on edit via goimports — runs unconditionally (no consumer-config gate), skipping generated files.",
"author": {
"name": "Melodic Software",
diff --git a/plugins/go-format/CHANGELOG.md b/plugins/go-format/CHANGELOG.md
index a2e41dae89..017b30a05f 100644
--- a/plugins/go-format/CHANGELOG.md
+++ b/plugins/go-format/CHANGELOG.md
@@ -3,6 +3,22 @@
All notable changes to the `go-format` plugin are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning.
+## [0.2.3]
+
+### Fixed
+
+- **Shared `hook-utils.sh`: a bare or trailing unquoted `NAME=value` Bash
+ command no longer leaks the assignment value into the privacy-safe
+ telemetry/audit subject.** `hook::extract_bash_subject` stripped a leading
+ `VAR=value` prefix only when a following command word consumed it, so a
+ command whose LAST token was an unquoted assignment (e.g. `TOKEN=ghp_…`)
+ survived to the subject and emitted `Bash:TOKEN=ghp_…` into
+ `hook-events.jsonl` and any wired `HOOK_TELEMETRY_SINK`. A resolved token
+ still shaped like a shell assignment now bails to the bare `Bash` subject,
+ matching the existing quoted-value bail (`VAR=x cmd` still reduces to
+ `Bash:cmd`). Synced from `lib/hook-utils.sh`; the subject is
+ telemetry/audit-only, so no guard or formatter block/allow behavior changes.
+
## [0.2.2]
### Fixed
diff --git a/plugins/go-format/hooks/hook-utils.sh b/plugins/go-format/hooks/hook-utils.sh
index b854e2598c..411c4a6731 100644
--- a/plugins/go-format/hooks/hook-utils.sh
+++ b/plugins/go-format/hooks/hook-utils.sh
@@ -279,6 +279,11 @@ hook::jq_field() {
# A quoted assignment value (e.g. `TOKEN="a b" curl …`) would otherwise leak a
# fragment of the value into the token, so any token carrying a quote aborts to a
# bare "Bash" subject rather than risk exposing part of the value.
+#
+# A bare or trailing unquoted assignment that no following command consumed
+# (e.g. the whole command is `TOKEN=ghp_…`) is likewise a value the subject must
+# not carry, so a resolved token still shaped like a NAME=value assignment aborts
+# to the bare "Bash" subject too.
# SUBJECT=$(hook::extract_bash_subject "$TOOL" "$CMD")
hook::extract_bash_subject() {
local tool="$1" cmd="${2:-}"
@@ -307,6 +312,19 @@ hook::extract_bash_subject() {
printf '%s' "$tool"
return 0
fi
+ # A resolved token still shaped like a bare/trailing assignment (no following
+ # command word consumed it in the strip loop) would emit the assignment's
+ # value — a possible credential — as the subject; bail to the bare "Bash"
+ # subject as with a quoted value. All valid Bash assignment forms count:
+ # NAME=value, append NAME+=value, and subscripted NAME[idx]=value /
+ # NAME[idx]+=value — the subscript matched greedily (`.*`) because Bash
+ # accepts nested subscripts like NAME[1+IDX[0]]=value, which a
+ # no-close-bracket class would miss. This runs BEFORE the basename strip so
+ # a path-valued assignment (TOKEN=/a/b/secret) cannot lose its "=" first.
+ if [[ "$first_token" =~ ^[a-zA-Z_][a-zA-Z0-9_]*(\[.*\])?\+?= ]]; then
+ printf '%s' "$tool"
+ return 0
+ fi
first_token="${first_token##*/}"
if [[ -n "$first_token" ]]; then
printf 'Bash:%s' "$first_token"
diff --git a/plugins/guardrails/.claude-plugin/plugin.json b/plugins/guardrails/.claude-plugin/plugin.json
index 9d5072c80a..0d33ba6fba 100644
--- a/plugins/guardrails/.claude-plugin/plugin.json
+++ b/plugins/guardrails/.claude-plugin/plugin.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
"name": "guardrails",
- "version": "0.16.2",
+ "version": "0.16.3",
"description": "Eleven safety guards that block secret/credential writes, hardcoded machine-specific paths, git hook-bypass attempts, irreversible git operations (force-push, reset --hard, worktree-wide checkout/restore discards), Bash file-write workarounds that circumvent Write/Edit hooks, commit subjects and gh pr create titles that violate the repo's tracked team convention (when one is declared in .claude/source-control.md), (advisory) hallucinated CLI flags, (advisory) /plugin:skill references that do not resolve, (advisory) un-throttled Workflow fan-out that risks burst 529s, and (advisory) direct git commit/gh pr create calls bypassing this marketplace's own commit/pull-request skills — each independently toggleable.",
"author": {
"name": "Melodic Software",
diff --git a/plugins/guardrails/CHANGELOG.md b/plugins/guardrails/CHANGELOG.md
index d446d5ff39..7ea1e00526 100644
--- a/plugins/guardrails/CHANGELOG.md
+++ b/plugins/guardrails/CHANGELOG.md
@@ -3,6 +3,22 @@
All notable changes to the `guardrails` plugin are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning.
+## [0.16.3]
+
+### Fixed
+
+- **Shared `hook-utils.sh`: a bare or trailing unquoted `NAME=value` Bash
+ command no longer leaks the assignment value into the privacy-safe
+ telemetry/audit subject.** `hook::extract_bash_subject` stripped a leading
+ `VAR=value` prefix only when a following command word consumed it, so a
+ command whose LAST token was an unquoted assignment (e.g. `TOKEN=ghp_…`)
+ survived to the subject and emitted `Bash:TOKEN=ghp_…` into
+ `hook-events.jsonl` and any wired `HOOK_TELEMETRY_SINK`. A resolved token
+ still shaped like a shell assignment now bails to the bare `Bash` subject,
+ matching the existing quoted-value bail (`VAR=x cmd` still reduces to
+ `Bash:cmd`). Synced from `lib/hook-utils.sh`; the subject is
+ telemetry/audit-only, so no guard or formatter block/allow behavior changes.
+
## [0.16.2]
### Fixed
diff --git a/plugins/guardrails/hooks/hook-utils.sh b/plugins/guardrails/hooks/hook-utils.sh
index b854e2598c..411c4a6731 100644
--- a/plugins/guardrails/hooks/hook-utils.sh
+++ b/plugins/guardrails/hooks/hook-utils.sh
@@ -279,6 +279,11 @@ hook::jq_field() {
# A quoted assignment value (e.g. `TOKEN="a b" curl …`) would otherwise leak a
# fragment of the value into the token, so any token carrying a quote aborts to a
# bare "Bash" subject rather than risk exposing part of the value.
+#
+# A bare or trailing unquoted assignment that no following command consumed
+# (e.g. the whole command is `TOKEN=ghp_…`) is likewise a value the subject must
+# not carry, so a resolved token still shaped like a NAME=value assignment aborts
+# to the bare "Bash" subject too.
# SUBJECT=$(hook::extract_bash_subject "$TOOL" "$CMD")
hook::extract_bash_subject() {
local tool="$1" cmd="${2:-}"
@@ -307,6 +312,19 @@ hook::extract_bash_subject() {
printf '%s' "$tool"
return 0
fi
+ # A resolved token still shaped like a bare/trailing assignment (no following
+ # command word consumed it in the strip loop) would emit the assignment's
+ # value — a possible credential — as the subject; bail to the bare "Bash"
+ # subject as with a quoted value. All valid Bash assignment forms count:
+ # NAME=value, append NAME+=value, and subscripted NAME[idx]=value /
+ # NAME[idx]+=value — the subscript matched greedily (`.*`) because Bash
+ # accepts nested subscripts like NAME[1+IDX[0]]=value, which a
+ # no-close-bracket class would miss. This runs BEFORE the basename strip so
+ # a path-valued assignment (TOKEN=/a/b/secret) cannot lose its "=" first.
+ if [[ "$first_token" =~ ^[a-zA-Z_][a-zA-Z0-9_]*(\[.*\])?\+?= ]]; then
+ printf '%s' "$tool"
+ return 0
+ fi
first_token="${first_token##*/}"
if [[ -n "$first_token" ]]; then
printf 'Bash:%s' "$first_token"
diff --git a/plugins/markdown-format/.claude-plugin/plugin.json b/plugins/markdown-format/.claude-plugin/plugin.json
index 50f9a20670..30e2291ad0 100644
--- a/plugins/markdown-format/.claude-plugin/plugin.json
+++ b/plugins/markdown-format/.claude-plugin/plugin.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
"name": "markdown-format",
- "version": "0.6.5",
+ "version": "0.7.0",
"description": "Auto-format and lint Markdown on edit via markdownlint-cli2, using the consuming repo's own markdownlint config.",
"author": {
"name": "Melodic Software",
diff --git a/plugins/markdown-format/CHANGELOG.md b/plugins/markdown-format/CHANGELOG.md
index c0d36b2ee6..9d907b003f 100644
--- a/plugins/markdown-format/CHANGELOG.md
+++ b/plugins/markdown-format/CHANGELOG.md
@@ -3,6 +3,93 @@
All notable changes to the `markdown-format` plugin are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning.
+## [0.7.0]
+
+### Security
+
+- **Code-loading markdownlint configuration is now gated on explicit approval.**
+ When the discovered configuration can execute repository-supplied code
+ (`.cjs`/`.mjs` config files, or `customRules`/`markdownItPlugins`/
+ `outputFormatters` module identifiers), the hook no longer runs
+ `markdownlint-cli2` after a one-time non-blocking advisory — it skips the
+ lint run, with a visible once-per-session notice on both channels, until the
+ user approves that exact configuration-content state by creating the marker
+ directory named in the notice (under `${CLAUDE_PLUGIN_DATA}/trust-approvals`).
+ The approval signature is content-addressed over the configuration AND every
+ repository file its string literals — plus, since a YAML plain scalar carries
+ no quotes, its path-shaped bare tokens — resolve to, through Node's CommonJS
+ resolution candidates (`.cjs`/`.mjs`/`.js`/`.json`/`.node` extensions and
+ directory `package.json`/`index.*` entry points), transitively, bounded — so a
+ change to the configuration or to a referenced repository module — e.g. a
+ branch switch swapping rule-module bytes under an unchanged config — revokes
+ the approval; the gate fails closed when `CLAUDE_PLUGIN_DATA` is unavailable
+ or the module scan overflows its bound. A reference that RESOLVES outside the
+ repository — a symlink aimed out of the tree, or a `../` escape — refuses
+ approval rather than being skipped: no signature over repository content can
+ cover it, so re-aiming the symlink at a different existing external target
+ would otherwise leave the approval valid while Node follows the new one. On a
+ host with no canonicalizer the resolution degrades to the lexical path, which
+ would read an escaping symlink as in-repository; a symlink whose physical path
+ came back unchanged is the signature of that degradation and refuses approval
+ too — the same fail-closed answer the membership scope already gives. Module-key detection in declarative
+ configs is a fail-closed textual over-approximation rather than a second
+ parser (which would only open a differential-parsing gap against
+ markdownlint-cli2's own parser): the literal key words anywhere in the file
+ gate as code-loading, and constructs able to synthesize a hidden spelling
+ (JSONC `\uXXXX` escapes; YAML `\x`/`\u`/`\U` escapes, escaped line joins,
+ `!!` tags) mark the configuration unverifiable — gated with no approval
+ route, since text whose meaning cannot be read cannot be reviewed. Those two
+ tiers are independent tests rather than a chain, so a config carrying a literal
+ key AND an escaped module value still reaches the escape verdict instead of
+ having it suppressed by the key match.
+
+ **An executable (`.cjs`/`.mjs`) config that declares one of the module-loading
+ keys now gets no approval route at all** — a deliberate narrowing.
+ markdownlint-cli2 resolves those entries itself, so an entry may be any
+ expression producing a string (`path.join(...)`,
+ `["./rules","x.cjs"].join("/")`, a concatenation, a helper call, a value
+ imported from elsewhere), and no text scan can enumerate that space. A repo
+ that names custom rules from a JS config must move those entries to a
+ declarative config, where they are data this scan reads exactly rather than an
+ expression it would have to predict; an executable config that declares none of
+ those keys stays approvable as before. A module specifier the scan cannot pin to
+ a file likewise refuses approval, because a
+ signature that omits the module would keep honoring an approval across
+ arbitrary edits to it: any path-building machinery in a JS source
+ (an import of the `path` module — refused at the import, because a call site
+ can be spelled through any alias while the import cannot; `require.resolve`,
+ `import.meta`, `__dirname`/`__filename`, `process.*`, template
+ interpolation, string concatenation), a
+ loader whose argument is not a plain quoted specifier, and a string literal
+ carrying a letter-capable escape sequence (which Node decodes to a different
+ path than the raw text). Detection is file-wide rather than anchored on a
+ loader call: markdownlint-cli2 resolves `customRules` entries itself, so
+ `customRules: [path.join(__dirname, "rules", "x.cjs")]` — or
+ `[process.env.RULE]` — carries no loader token at all, and JavaScript permits
+ a comment or newline at any token boundary, so `require/*c*/(…)` sits outside
+ any fixed window. The loader test deletes every plainly-written call first and
+ then looks for a loader token in the residue, which needs no window. Every
+ pattern is POSIX ERE — no `\b`, whose GNU-only meaning would turn the whole
+ predicate into a silent pass under the macOS system grep this hook supports.
+ Previously the hook warned once and executed anyway, so a malicious
+ repository's checked-in config could run arbitrary code on a routine
+ markdown edit. Declarative rule-only configuration is unaffected. The edit
+ itself is still never blocked — the hook always exits 0.
+
+### Fixed
+
+- **Shared `hook-utils.sh`: a bare or trailing unquoted `NAME=value` Bash
+ command no longer leaks the assignment value into the privacy-safe
+ telemetry/audit subject.** `hook::extract_bash_subject` stripped a leading
+ `VAR=value` prefix only when a following command word consumed it, so a
+ command whose LAST token was an unquoted assignment (e.g. `TOKEN=ghp_…`)
+ survived to the subject and emitted `Bash:TOKEN=ghp_…` into
+ `hook-events.jsonl` and any wired `HOOK_TELEMETRY_SINK`. A resolved token
+ still shaped like a shell assignment now bails to the bare `Bash` subject,
+ matching the existing quoted-value bail (`VAR=x cmd` still reduces to
+ `Bash:cmd`). Synced from `lib/hook-utils.sh`; the subject is
+ telemetry/audit-only, so no guard or formatter block/allow behavior changes.
+
## [0.6.5]
### Fixed
diff --git a/plugins/markdown-format/README.md b/plugins/markdown-format/README.md
index f13fe01242..719cbdb286 100644
--- a/plugins/markdown-format/README.md
+++ b/plugins/markdown-format/README.md
@@ -21,6 +21,8 @@ imposes no rules of its own.
the file's directory up through its parents — so a nested config governs its
subtree. The hook `cd`s to the repository root before linting so that
discovery caps at the root regardless of the session's working directory.
+ A configuration that can execute code is gated on explicit approval — see
+ [Configuration trust boundary](#configuration-trust-boundary).
## Requirements
@@ -49,13 +51,25 @@ envelope is skipped while formatting still runs.
### Configuration trust boundary
`markdownlint-cli2` supports executable `.cjs`/`.mjs` configuration and can
-load custom rules, Markdown-it plugins, and output formatters. Because the hook
-runs the consuming repository's configuration, enable it only for repositories
-whose configuration and installed dependencies you trust. Prefer declarative
-JSONC or YAML when executable configuration is unnecessary. Before running a
-risky configuration, the hook emits a non-blocking trust advisory once for each
-repository and configuration-content state; changing that configuration causes
-the advisory to appear again.
+load custom rules, Markdown-it plugins, and output formatters — running it
+under such configuration executes code the repository supplies. The hook
+therefore never runs the linter under a code-loading configuration without an
+explicit approval: it skips the lint run and reports a visible trust-gate
+notice (once per session, on both the agent and user channels) naming the
+risky files and the approval marker to create. To approve, review those files
+and their installed dependencies, then create the marker directory using the
+exact `mkdir -p` command the notice carries. The marker lives under
+`${CLAUDE_PLUGIN_DATA}/trust-approvals` and is content-addressed over the
+repository, its risky configuration files, and every repository file those
+files' string literals resolve to (transitively, bounded), so a change to the
+configuration or to a referenced repository module — including a branch switch
+that swaps module bytes under an unchanged config — revokes the approval and
+re-gates the run. When `CLAUDE_PLUGIN_DATA` is unavailable, the module scan
+overflows its bound, or the configuration contains constructs that defeat
+textual verification (string escapes or tags able to hide a module-loading
+key), the gate fails closed and the lint run stays skipped. Declarative
+rule-only JSONC/YAML configuration is unaffected and lints immediately —
+prefer it when executable configuration is unnecessary.
## Install
diff --git a/plugins/markdown-format/hooks/hook-utils.sh b/plugins/markdown-format/hooks/hook-utils.sh
index b854e2598c..411c4a6731 100644
--- a/plugins/markdown-format/hooks/hook-utils.sh
+++ b/plugins/markdown-format/hooks/hook-utils.sh
@@ -279,6 +279,11 @@ hook::jq_field() {
# A quoted assignment value (e.g. `TOKEN="a b" curl …`) would otherwise leak a
# fragment of the value into the token, so any token carrying a quote aborts to a
# bare "Bash" subject rather than risk exposing part of the value.
+#
+# A bare or trailing unquoted assignment that no following command consumed
+# (e.g. the whole command is `TOKEN=ghp_…`) is likewise a value the subject must
+# not carry, so a resolved token still shaped like a NAME=value assignment aborts
+# to the bare "Bash" subject too.
# SUBJECT=$(hook::extract_bash_subject "$TOOL" "$CMD")
hook::extract_bash_subject() {
local tool="$1" cmd="${2:-}"
@@ -307,6 +312,19 @@ hook::extract_bash_subject() {
printf '%s' "$tool"
return 0
fi
+ # A resolved token still shaped like a bare/trailing assignment (no following
+ # command word consumed it in the strip loop) would emit the assignment's
+ # value — a possible credential — as the subject; bail to the bare "Bash"
+ # subject as with a quoted value. All valid Bash assignment forms count:
+ # NAME=value, append NAME+=value, and subscripted NAME[idx]=value /
+ # NAME[idx]+=value — the subscript matched greedily (`.*`) because Bash
+ # accepts nested subscripts like NAME[1+IDX[0]]=value, which a
+ # no-close-bracket class would miss. This runs BEFORE the basename strip so
+ # a path-valued assignment (TOKEN=/a/b/secret) cannot lose its "=" first.
+ if [[ "$first_token" =~ ^[a-zA-Z_][a-zA-Z0-9_]*(\[.*\])?\+?= ]]; then
+ printf '%s' "$tool"
+ return 0
+ fi
first_token="${first_token##*/}"
if [[ -n "$first_token" ]]; then
printf 'Bash:%s' "$first_token"
diff --git a/plugins/markdown-format/hooks/markdown-format.sh b/plugins/markdown-format/hooks/markdown-format.sh
index e86d6588dd..a565d8f1c0 100755
--- a/plugins/markdown-format/hooks/markdown-format.sh
+++ b/plugins/markdown-format/hooks/markdown-format.sh
@@ -2,9 +2,11 @@
# PostToolUse hook: auto-format and lint Markdown via markdownlint-cli2.
# Triggered on Write|Edit of *.md and *.mdc (Cursor MDC = markdown + frontmatter).
#
-# ADVISORY: always exits 0. markdownlint-cli2 --fix auto-format always applies;
-# unfixable markdownlint violations surface via additionalContext but never
-# block the edit. Uses the consuming repo's own markdownlint config — ships none.
+# ADVISORY: always exits 0 — unfixable markdownlint violations surface via
+# additionalContext but never block the edit. Uses the consuming repo's own
+# markdownlint config — ships none. When that configuration can execute code
+# (.cjs/.mjs config, or module-loading keys), the lint run itself is gated on
+# an explicit per-repo trust approval; see the trust gate below.
set -uo pipefail
@@ -216,16 +218,19 @@ fi
# documented same-directory precedence so a shadowed executable config does not
# create a false warning.
RISK_CONFIGS=()
+RISK_UNVERIFIABLE=0
+RISK_UNPINNABLE=0
CONFIG_ROOT="$(cd "$REPO_ROOT" 2>/dev/null && pwd -P)" || CONFIG_ROOT="$REPO_ROOT"
CONFIG_TARGET_DIR="$(cd "$(dirname "$FILE")" 2>/dev/null && pwd -P)" ||
CONFIG_TARGET_DIR="$(dirname "$FILE")"
collect_risky_configs() {
- local cursor dir candidate config
+ local cursor dir candidate config risky
local dirs=()
cursor="$CONFIG_TARGET_DIR"
while :; do
- dirs=("$cursor" "${dirs[@]}")
+ # Guarded for bash 3.2 + `set -u`: expanding an empty array errs there.
+ if ((${#dirs[@]} > 0)); then dirs=("$cursor" "${dirs[@]}"); else dirs=("$cursor"); fi
[[ "$cursor" == "$CONFIG_ROOT" ]] && break
dir="$(dirname "$cursor")"
[[ "$dir" != "$cursor" ]] || return 0
@@ -246,9 +251,65 @@ collect_risky_configs() {
done
if [[ -n "$config" ]]; then
case "$config" in
- *.cjs | *.mjs) RISK_CONFIGS+=("$config") ;;
+ *.cjs | *.mjs)
+ RISK_CONFIGS+=("$config")
+ # A module-loading key in an EXECUTABLE config names entries
+ # markdownlint-cli2 resolves ITSELF, so an entry can be any expression
+ # that produces a string — `path.join(...)`, `["./rules","x.cjs"]
+ # .join("/")`, a concatenation, a helper call, a value imported from
+ # elsewhere. That space cannot be enumerated by a text scan, and each
+ # attempt only moves the edge, so a JS config carrying one of these keys
+ # gets no approval route at all.
+ #
+ # A JS config WITHOUT these keys stays approvable: its only code loading
+ # is its own require/import calls, whose arguments unpinnable_js_specifier
+ # reads exactly. A repo that does name custom rules from a JS config must
+ # move those entries to a declarative config, where they are data this
+ # scan can read rather than an expression it must predict.
+ if grep -Eq 'customRules|markdownItPlugins|outputFormatters' "$config" 2>/dev/null; then
+ RISK_UNPINNABLE=1
+ fi
+ ;;
*)
- if grep -Eq "[\"']?(customRules|markdownItPlugins|outputFormatters)[\"']?[[:space:]]*:" "$config" 2>/dev/null; then
+ # A textual scan cannot see a module-loading key spelled through
+ # string escapes (JSONC "customRules") or YAML escape/tag
+ # machinery, and building a second parser here would only open a
+ # differential-parsing gap against the parser markdownlint-cli2
+ # actually uses. The predicate is instead a fail-closed
+ # over-approximation in two tiers. Tier one: the literal key words
+ # ANYWHERE in the file — no key-colon anchor, because YAML
+ # explicit-key syntax (`? customRules` with `:` on the next line)
+ # separates the key from its colon — mark the config code-loading.
+ # Tier two: any construct capable of synthesizing a spelling the
+ # scan cannot see (JSONC \uXXXX escapes; YAML \x/\u/\U escapes,
+ # escaped line joins, !! tags — a !!binary key decodes to arbitrary
+ # text) marks it UNVERIFIABLE: it gates AND refuses approval below,
+ # because text whose meaning cannot be read cannot be meaningfully
+ # reviewed. YAML anchors/aliases stay verifiable — an alias only
+ # reuses a node whose text is spelled literally elsewhere in the
+ # same file, where tier one sees it.
+ #
+ # The two tiers are INDEPENDENT tests, not a chain: a config can carry a
+ # literal key AND an escaped module VALUE
+ # (`"customRules": ["./rules.cjs"]`), and chaining them would let
+ # the tier-one match suppress the tier-two verdict — the collector would
+ # then hash the raw escaped spelling rather than the file markdownlint
+ # decodes it to and loads, leaving an approval valid across edits to it.
+ risky=0
+ if grep -Eq 'customRules|markdownItPlugins|outputFormatters' "$config" 2>/dev/null; then
+ risky=1
+ fi
+ if [[ "$config" == *.jsonc ]] &&
+ grep -Eq '\\u[0-9a-fA-F]{4}' "$config" 2>/dev/null; then
+ risky=1
+ RISK_UNVERIFIABLE=1
+ fi
+ if [[ "$config" == *.yaml ]] &&
+ grep -Eq '\\[xuU][0-9a-fA-F]|\\$|!![A-Za-z]' "$config" 2>/dev/null; then
+ risky=1
+ RISK_UNVERIFIABLE=1
+ fi
+ if ((risky == 1)); then
RISK_CONFIGS+=("$config")
fi
;;
@@ -275,18 +336,231 @@ collect_risky_configs() {
done
}
-# Return success only for the first observation of this repo + risky-config
-# content state. CLAUDE_PLUGIN_DATA is the official persistent plugin-state
-# location and survives plugin updates. If it is unavailable or unwritable
-# (for example, a direct development invocation), fail open by warning again;
-# never suppress a trust warning merely because its marker could not be saved.
-claim_trust_advisory() {
- local state_base="${CLAUDE_PLUGIN_DATA:-}" state_dir signature config digest
- [[ -n "$state_base" ]] || return 0
+# The approval signature must cover the code that would RUN, not only the
+# config that names it: a customRules/markdownItPlugins/outputFormatters
+# module — or a file require()d by an approved .cjs/.mjs config — can change
+# (e.g. on a branch switch) while the config text stays identical, and a
+# config-only signature would keep honoring the stale approval. Enumerating
+# the true module graph would mean executing Node resolution, which is the
+# very thing being gated, so approximate it conservatively from text: collect
+# every string literal in each risky config, resolve it against the config's
+# directory and the repo root, and for each hit inside this repository take
+# the file (or, for a directory, its package.json/index entry points, the
+# files Node's directory-require loads) into MODULE_FILES — then rescan each
+# collected file the same way, so a repo rule module's own relative requires
+# are covered transitively. Bounded at 64 scanned files; exceeding the bound
+# returns 1 and the caller fails closed. Bare package identifiers resolve to
+# node_modules, which the user installs explicitly — that separate trust
+# decision is not folded into this repository-content signature.
+# Bash 3.2-compatible throughout (macOS system bash): dedup state lives in
+# newline-delimited strings rather than associative arrays, and every array
+# expansion is guarded non-empty — `"${arr[@]}"` on an empty array is an
+# unbound-variable error under `set -u` before bash 4.4.
+
+# True when a JS source names code this text scan cannot pin to a file.
+#
+# Two independent tests, because neither alone covers the shape space.
+#
+# 1. PATH-BUILDING MACHINERY anywhere in the file. markdownlint-cli2 resolves
+# `customRules`/`markdownItPlugins`/`outputFormatters` entries itself, so
+# `customRules: [path.join(__dirname, "rules", "x.cjs")]` carries no loader
+# call to anchor a pattern on — and the entries are conventionally written
+# across several lines, so no line-scoped anchor could see the key and the
+# expression together either. String concatenation and template
+# interpolation assemble a specifier the same way and are refused with it.
+# The `path` module is refused at its IMPORT rather than at its call sites,
+# because a call site can be spelled through any alias — `p.join(...)`,
+# `const { join } = require("path")` — while the import cannot: a file that
+# pulls in `path` is building paths, and this scan cannot pin what it builds.
+# Matching bare `.join(`/`.resolve(` instead would refuse every
+# `array.join(",")` and `Promise.resolve`, which is collateral, not caution.
+#
+# 2. A LOADER RESIDUE. Delete every plainly-written loader call — `require("x")`
+# / `import("x")` — from the text, then look for a loader token in what
+# remains. A proximity pattern cannot decide this, because JavaScript
+# permits a comment or newline at any token boundary, so
+# `require/*c*/(path.join(...))` sits outside any fixed window; and counting
+# occurrences cannot either, because `grep -o` consumes the boundary
+# character a word-start guard needs and so undercounts a nested
+# `require(require("x"))`. Deleting first leaves nothing to align: a loader
+# the plain-form pattern did not consume is a loader whose argument this
+# scan cannot read.
+#
+# Plus a string literal carrying a letter-capable escape (backslash-u/x/octal),
+# which Node decodes to a different path than the raw text resolved here — a
+# specifier can hide its real spelling that way.
+#
+# Every pattern is POSIX ERE: no `\b`, which is a GNU extension that BSD grep
+# (macOS system grep, the platform this file targets) may read as a literal `b`,
+# turning the whole predicate into a silent pass — the one failure direction
+# this function must not have. Word starts and ends are spelled as bracket
+# expressions instead, and the residue test uses `grep -q` rather than `-o` so
+# no boundary character is consumed.
+#
+# Deliberately over-approximating: a `path.join` used to read a data file,
+# `process.cwd()` in an unrelated call, or `__dirname` inside a string refuses
+# approval too. That is the fail-closed direction, and its cost is a skipped
+# lint run.
+unpinnable_js_specifier() {
+ local file="$1"
+ if grep -Eq \
+ -e "(require[[:space:]]*\([[:space:]]*|from[[:space:]]*)[\"'](node:)?path[\"']" \
+ -e 'path[[:space:]]*\.[[:space:]]*(join|resolve|normalize)[[:space:]]*\(' \
+ -e 'require[[:space:]]*\.[[:space:]]*resolve[[:space:]]*\(' \
+ -e 'import[[:space:]]*\.[[:space:]]*meta' \
+ -e 'process[[:space:]]*\.' \
+ -e '__dirname|__filename' \
+ -e '\$\{' \
+ -e "[\"'][[:space:]]*\+|\+[[:space:]]*[\"']" \
+ -e "[\"'][^\"']*\\\\[uxUX0-7]" \
+ "$file" 2>/dev/null; then
+ return 0
+ fi
+ sed -E "s/(require|import)[[:space:]]*\([[:space:]]*(\"[^\"]*\"|'[^']*')/ /g" "$file" 2>/dev/null |
+ grep -Eq "(^|[^A-Za-z0-9_\$])(require($|[^A-Za-z0-9_\$])|import[[:space:]]*\()"
+}
+
+MODULE_FILES=()
+collect_module_files() {
+ local item dir str tag base candidate resolved entry scanned=0
+ local queue=("$@")
+ local seen_scan=$'\n' seen_module=$'\n'
+ local quoted_string_re="\"[^\"]+\"|'[^']+'"
+ # YAML plain scalars carry no quotes, so `customRules: [./rules/local.cjs]`
+ # is invisible to the quoted-string scan and its module would never enter the
+ # signature. Path-shaped bare tokens are therefore harvested too. Restricted
+ # to tokens containing a `/` or `.` below, so an ordinary word (a key name, a
+ # rule id) is not tried as a path; over-collecting a token that resolves to
+ # nothing costs nothing, which is the same over-approximation the quoted scan
+ # already relies on.
+ local plain_token_re="[A-Za-z0-9_.@~/+-]+"
+
+ while ((${#queue[@]} > 0)); do
+ item="${queue[0]}"
+ if ((${#queue[@]} > 1)); then queue=("${queue[@]:1}"); else queue=(); fi
+ case "$seen_scan" in *$'\n'"$item"$'\n'*) continue ;; *) ;; esac
+ seen_scan+="$item"$'\n'
+ scanned=$((scanned + 1))
+ ((scanned <= 64)) || return 1
+ # A module specifier this text scan cannot read as written names code it
+ # cannot pin, so the state gets no approval at all: an approval whose
+ # signature omits the module would survive arbitrary edits to it.
+ # unpinnable_js_specifier answers that question for a JS source; a
+ # declarative config is judged by the separate key/escape tiers above.
+ case "$item" in
+ *.cjs | *.mjs | *.js)
+ if unpinnable_js_specifier "$item"; then
+ RISK_UNPINNABLE=1
+ return 1
+ fi
+ ;;
+ *) ;;
+ esac
+ dir="$(dirname "$item")"
+ while IFS= read -r str; do
+ # The stream carries both harvests, each tagged with its kind: Q keeps the
+ # quoted scan's exact prior behavior (strip the delimiters, try every
+ # token), P adds bare tokens and admits only path-shaped ones.
+ tag="${str:0:1}"
+ str="${str:1}"
+ if [[ "$tag" == Q ]]; then
+ str="${str#?}"
+ str="${str%?}"
+ else
+ case "$str" in */* | *.*) ;; *) continue ;; esac
+ fi
+ [[ -n "$str" && "$str" != *$'\n'* ]] || continue
+ for base in "$dir/$str" "$CONFIG_ROOT/$str"; do
+ # Node's CommonJS resolution tries the literal path, then the
+ # .cjs/.mjs/.js/.json/.node extension candidates, then a directory's
+ # package.json/index entry points — an extensionless
+ # require("./rules/local-rule") must still pin local-rule.cjs.
+ for candidate in "$base" "$base.cjs" "$base.mjs" "$base.js" "$base.json" "$base.node"; do
+ resolved=""
+ if [[ -f "$candidate" ]]; then
+ resolved="$(hook::physical_path "$candidate")"
+ # hook::physical_path degrades to the unchanged lexical path when no
+ # canonicalizer resolves it. A symlink whose physical path came back
+ # unchanged is the observable signature of that degradation — a
+ # symlink never canonicalizes to itself — and the boundary check
+ # below would then read an escaping symlink as in-repository and pin
+ # it by its lexical path, leaving the external target free to change
+ # under a live approval. Same test the membership scope above uses,
+ # and the same fail-closed answer.
+ if [[ -L "$candidate" && "$resolved" == "$candidate" ]]; then
+ RISK_UNPINNABLE=1
+ return 1
+ fi
+ elif [[ "$candidate" == "$base" && -d "$candidate" ]]; then
+ for entry in package.json index.js index.cjs index.mjs; do
+ if [[ -f "$candidate/$entry" ]]; then
+ queue+=("$(hook::physical_path "$candidate/$entry")")
+ fi
+ done
+ continue
+ else
+ continue
+ fi
+ case "$resolved" in
+ "$CONFIG_ROOT"/*) ;;
+ *)
+ # A repository path that RESOLVES outside the repository — a symlink
+ # aimed out of the tree, or a `../` escape — names code no signature
+ # over repository content can cover: the approval state is unchanged
+ # when the external target changes, or when the symlink is re-aimed
+ # at a different existing target, yet Node follows it and executes
+ # the new code. Hashing the external file instead would extend the
+ # signature beyond the repository the approval is scoped to, so the
+ # state is refused rather than signed. CONFIG_ROOT is itself a
+ # physical path (`pwd -P`), so this compares like with like and a
+ # symlinked checkout does not read as an escape.
+ RISK_UNPINNABLE=1
+ return 1
+ ;;
+ esac
+ case "$seen_module" in
+ *$'\n'"$resolved"$'\n'*) ;;
+ *)
+ seen_module+="$resolved"$'\n'
+ MODULE_FILES+=("$resolved")
+ queue+=("$resolved")
+ ;;
+ esac
+ done
+ done
+ done < <(
+ grep -oE "$quoted_string_re" "$item" 2>/dev/null | sed 's/^/Q/'
+ grep -oE "$plain_token_re" "$item" 2>/dev/null | sed 's/^/P/'
+ )
+ done
+ return 0
+}
+
+# Resolve the trust-approval marker directory for the current repo +
+# risky-config content state into TRUST_DIR. CLAUDE_PLUGIN_DATA is the
+# official persistent plugin-state location and survives plugin updates; the
+# signature is content-addressed over every risky config PLUS every resolved
+# code-loading input it references (see collect_module_files), so a change to
+# the configuration or to a referenced repository module yields a new
+# directory and revokes a prior approval. Returns 1 when the state base is
+# unavailable, a config or module cannot be digested, the module scan
+# overflows its bound, or the config was classified unverifiable — the caller
+# must fail CLOSED and skip the lint run: configuration that can execute code
+# and whose approval cannot be verified is never run. Named trust-approvals,
+# NOT trust-advisories: that directory's markers recorded only that a warning
+# had been shown, and reading them as approvals would silently grant trust.
+TRUST_DIR=""
+resolve_trust_dir() {
+ local state_base="${CLAUDE_PLUGIN_DATA:-}" signature config module digest
+ TRUST_DIR=""
+ [[ -n "$state_base" ]] || return 1
+ ((RISK_UNVERIFIABLE == 0)) || return 1
+ ((RISK_UNPINNABLE == 0)) || return 1
if command -v cygpath >/dev/null 2>&1 && [[ "$state_base" == [A-Za-z]:\\* ]]; then
- state_base="$(cygpath -u "$state_base" 2>/dev/null)" || return 0
+ state_base="$(cygpath -u "$state_base" 2>/dev/null)" || return 1
fi
- state_dir="${state_base%/}/trust-advisories"
+ MODULE_FILES=()
+ collect_module_files "${RISK_CONFIGS[@]}" || return 1
signature=$(
{
printf '%s\n' "$REPO_ROOT"
@@ -294,23 +568,71 @@ claim_trust_advisory() {
digest=$(git hash-object "$config" 2>/dev/null) || return 1
printf '%s\t%s\n' "$config" "$digest"
done
+ # Guarded for bash 3.2 + `set -u`: expanding an empty array errs there.
+ if ((${#MODULE_FILES[@]} > 0)); then
+ for module in "${MODULE_FILES[@]}"; do
+ digest=$(git hash-object "$module" 2>/dev/null) || return 1
+ printf 'module\t%s\t%s\n' "$module" "$digest"
+ done
+ fi
} | git hash-object --stdin 2>/dev/null
- ) || return 0
- [[ -n "$signature" ]] || return 0
- mkdir -p "$state_dir" 2>/dev/null || return 0
- mkdir "$state_dir/$signature" 2>/dev/null
+ ) || return 1
+ [[ -n "$signature" ]] || return 1
+ TRUST_DIR="${state_base%/}/trust-approvals/$signature"
}
hook::ctx_reset
collect_risky_configs
-if ((${#RISK_CONFIGS[@]} > 0)) && claim_trust_advisory; then
- RISK_LIST=""
- for config in "${RISK_CONFIGS[@]}"; do
- config="${config#"$CONFIG_ROOT"/}"
- RISK_LIST+="${RISK_LIST:+, }$config"
- done
- hook::ctx_append \
- "markdown-format trust advisory: markdownlint-cli2 will load executable or module-loading repository configuration ($RISK_LIST). Formatting continues, but review these files and their installed dependencies before trusting this repository. This warning appears once per configuration state."
+# Trust gate: markdownlint-cli2's configuration contract loads .cjs/.mjs
+# config modules and customRules/markdownItPlugins/outputFormatters module
+# identifiers through Node's require/import machinery, so running the linter
+# under such configuration executes repository-supplied code. That must never
+# happen on the strength of a markdown edit alone: the lint run is skipped
+# until the user, having reviewed the configuration, records an explicit
+# approval of this exact configuration state. The skip is reported on both
+# channels once per session; the notice key carries the state signature so a
+# configuration change re-notices within the same session.
+if ((${#RISK_CONFIGS[@]} > 0)); then
+ resolve_trust_dir || TRUST_DIR=""
+ if [[ -z "$TRUST_DIR" || ! -d "$TRUST_DIR" ]]; then
+ RISK_LIST=""
+ for config in "${RISK_CONFIGS[@]}"; do
+ config="${config#"$CONFIG_ROOT"/}"
+ RISK_LIST+="${RISK_LIST:+, }$config"
+ done
+ if [[ -n "$TRUST_DIR" ]]; then
+ APPROVE_HINT="Review these files and their installed dependencies; to approve this exact configuration state and enable linting, run: mkdir -p '$TRUST_DIR' (any change to the configuration or a referenced repository module revokes the approval)."
+ elif ((RISK_UNVERIFIABLE == 1)); then
+ APPROVE_HINT="The configuration contains constructs (string escapes or tags) that defeat textual verification, so it cannot be reviewed as written and linting stays disabled for this repository."
+ elif ((RISK_UNPINNABLE == 1)); then
+ APPROVE_HINT="The configuration or a module it references names code through an expression this hook cannot pin to a file (a built path, a template, a concatenation, or a loader argument it cannot read), so the code that would execute cannot be bound to an approval and linting stays disabled for this repository."
+ else
+ APPROVE_HINT="Approval state is unavailable (CLAUDE_PLUGIN_DATA unset or unusable, or the configuration's referenced modules could not be tracked), so linting stays disabled for this repository."
+ fi
+ # Approvable states key the notice by signature; states with no approval
+ # route (unverifiable / untrackable / no store) have no signature, so key
+ # by the risky configs' content instead — otherwise every such state
+ # would share one key and only the first would ever notice in a session,
+ # while an unchanged state still dedupes.
+ if [[ -n "$TRUST_DIR" ]]; then
+ TRUST_NOTICE_KEY="markdown-format-trust-${TRUST_DIR##*/}"
+ else
+ TRUST_NOTICE_KEY="markdown-format-trust-noroute-$(
+ {
+ printf '%s\n' "$REPO_ROOT"
+ for config in "${RISK_CONFIGS[@]}"; do
+ git hash-object "$config" 2>/dev/null || printf 'undigested\n'
+ done
+ } | git hash-object --stdin 2>/dev/null || printf 'unkeyed'
+ )"
+ fi
+ if hook::notice_once "$TRUST_NOTICE_KEY" "$INPUT"; then
+ hook::emit_skip_notice PostToolUse \
+ "markdown-format trust gate: Markdown lint/format skipped — this repository's markdownlint configuration can execute repository-supplied code ($RISK_LIST). $APPROVE_HINT"
+ fi
+ emit_tel "skipped" '[]'
+ exit 0
+ fi
fi
if FIX_OUTPUT=$(cd "$REPO_ROOT" && "${MDLINT[@]}" --fix "$FILE" 2>&1); then
@@ -320,8 +642,8 @@ if FIX_OUTPUT=$(cd "$REPO_ROOT" && "${MDLINT[@]}" --fix "$FILE" 2>&1); then
exit 0
fi
-# Residual findings — append to any trust advisory, surface one JSON object via
-# additionalContext, then emit ok with findings.
+# Residual findings — surface one JSON object via additionalContext, then
+# emit ok with findings.
# ctx_append receives all output lines (human-readable context for Claude Code).
# FINDINGS_JSON is filtered to violation lines only — schema requires
# "Unfixable markdownlint violations remaining after --fix, one per line";
diff --git a/plugins/markdown-format/hooks/markdown-format.test.sh b/plugins/markdown-format/hooks/markdown-format.test.sh
index 4e42c83af7..c4211c6875 100755
--- a/plugins/markdown-format/hooks/markdown-format.test.sh
+++ b/plugins/markdown-format/hooks/markdown-format.test.sh
@@ -534,15 +534,22 @@ else
fail "missing jq warning absent: $OUT_NO_JQ"
fi
-# --- Repository-config trust boundary: visible once per risky state ---------
-# Use the official persistent plugin-data surface so separate hook processes
-# share the acknowledgement marker. A config-content change must produce a new
-# state signature and therefore a fresh warning.
+# --- Repository-config trust gate: risky config blocks lint until approved ---
+# Uses the official persistent plugin-data surface so separate hook processes
+# share the approval marker. A code-loading configuration must SKIP the lint
+# run — with a visible notice on both channels — until the user records an
+# explicit approval for that exact configuration-content state; any config
+# change must revoke the approval. Blocking is observed through MD047: the
+# fixture lacks a final newline, so a lint run is exactly "newline appended".
TRUST_DATA="$WORK/plugin-data"
ORIGINAL_CONFIG="$REPO/.markdownlint-cli2.jsonc"
SAVED_CONFIG="$WORK/original-markdownlint-cli2.jsonc"
mv "$ORIGINAL_CONFIG" "$SAVED_CONFIG"
+has_final_newline() {
+ [[ "$(tail -c 1 "$1" | od -An -tx1 | tr -d ' \n')" == "0a" ]]
+}
+
cat >"$REPO/.markdownlint-cli2.cjs" <<'CJS'
module.exports = {
config: { "MD013": false },
@@ -556,33 +563,61 @@ printf '# Executable config\n\nClean text.' >"$TRUST_FILE"
OUT_TRUST_1="$(cd "$UNRELATED" && printf '{"tool_input":{"file_path":"%s"}}' "$TRUST_FILE" |
env -u CLAUDE_PROJECT_DIR CLAUDE_PLUGIN_DATA="$TRUST_DATA" CLAUDE_PLUGIN_OPTION_MARKDOWN_FORMAT_ENABLED=true bash "$HOOK")"
RC_TRUST_1=$?
-if [[ $RC_TRUST_1 -eq 0 ]]; then ok "executable config advisory exits 0"; else fail "executable config advisory exit $RC_TRUST_1"; fi
-if printf '%s' "$OUT_TRUST_1" | jq -e '.hookSpecificOutput.additionalContext | contains("trust advisory") and contains(".markdownlint-cli2.cjs")' >/dev/null 2>&1; then
- ok "executable .cjs config emits visible trust advisory"
+if [[ $RC_TRUST_1 -eq 0 ]]; then ok "unapproved executable config exits 0 (advisory)"; else fail "unapproved executable config exit $RC_TRUST_1"; fi
+if printf '%s' "$OUT_TRUST_1" | jq -e '(.hookSpecificOutput.additionalContext | contains("trust gate") and contains(".markdownlint-cli2.cjs")) and (.systemMessage | contains("trust gate"))' >/dev/null 2>&1; then
+ ok "executable .cjs config emits visible trust-gate notice on both channels"
else
- fail "executable .cjs trust advisory absent: $OUT_TRUST_1"
+ fail "executable .cjs trust-gate notice absent: $OUT_TRUST_1"
fi
-if [[ "$(tail -c 1 "$TRUST_FILE" | od -An -tx1 | tr -d ' \n')" == "0a" ]]; then
- ok "trust advisory does not block markdownlint --fix"
+if ! has_final_newline "$TRUST_FILE"; then
+ ok "unapproved executable config blocks markdownlint --fix"
else
- fail "trust advisory blocked markdownlint --fix"
+ fail "unapproved executable config still ran markdownlint --fix"
fi
+# Same state, same session: the notice dedupes, but the lint run stays blocked.
OUT_TRUST_2="$(cd "$UNRELATED" && printf '{"tool_input":{"file_path":"%s"}}' "$TRUST_FILE" |
env -u CLAUDE_PROJECT_DIR CLAUDE_PLUGIN_DATA="$TRUST_DATA" CLAUDE_PLUGIN_OPTION_MARKDOWN_FORMAT_ENABLED=true bash "$HOOK")"
if [[ -z "$OUT_TRUST_2" ]]; then
- ok "unchanged executable config warning appears only once"
+ ok "unchanged unapproved state notices only once per session"
+else
+ fail "unchanged unapproved state noticed again: $OUT_TRUST_2"
+fi
+if ! has_final_newline "$TRUST_FILE"; then
+ ok "repeat edit stays blocked while unapproved"
else
- fail "unchanged executable config warned again: $OUT_TRUST_2"
+ fail "repeat edit ran markdownlint --fix while unapproved"
fi
-printf '\n// reviewed configuration revision\n' >>"$REPO/.markdownlint-cli2.cjs"
+# The notice's approval instruction must name a marker under this plugin-data
+# store; creating that marker is the explicit opt-in that enables the lint run.
+TRUST_MARKER="$(printf '%s' "$OUT_TRUST_1" | jq -r '.systemMessage' | sed -n "s/.*mkdir -p '\([^']*\)'.*/\1/p")"
+if [[ -n "$TRUST_MARKER" && "$TRUST_MARKER" == "$TRUST_DATA"/* ]]; then
+ ok "trust-gate notice carries an approval marker under CLAUDE_PLUGIN_DATA"
+else
+ fail "trust-gate approval marker missing or misplaced: $OUT_TRUST_1"
+fi
+mkdir -p "$TRUST_MARKER"
OUT_TRUST_3="$(cd "$UNRELATED" && printf '{"tool_input":{"file_path":"%s"}}' "$TRUST_FILE" |
env -u CLAUDE_PROJECT_DIR CLAUDE_PLUGIN_DATA="$TRUST_DATA" CLAUDE_PLUGIN_OPTION_MARKDOWN_FORMAT_ENABLED=true bash "$HOOK")"
-if printf '%s' "$OUT_TRUST_3" | jq -e '.hookSpecificOutput.additionalContext | contains("trust advisory")' >/dev/null 2>&1; then
- ok "changed executable config state warns again"
+RC_TRUST_3=$?
+if [[ $RC_TRUST_3 -eq 0 && -z "$OUT_TRUST_3" ]] && has_final_newline "$TRUST_FILE"; then
+ ok "approved configuration state lints again (fix applied, no notice)"
else
- fail "changed executable config state did not warn: $OUT_TRUST_3"
+ fail "approved configuration state did not lint (rc=$RC_TRUST_3 out=$OUT_TRUST_3)"
+fi
+
+# A config-content change produces a new state signature: the approval is
+# revoked, and the gate blocks — and notices, despite the same session — again.
+printf '\n// unreviewed configuration revision\n' >>"$REPO/.markdownlint-cli2.cjs"
+printf '# Executable config\n\nClean text.' >"$TRUST_FILE"
+OUT_TRUST_4="$(cd "$UNRELATED" && printf '{"tool_input":{"file_path":"%s"}}' "$TRUST_FILE" |
+ env -u CLAUDE_PROJECT_DIR CLAUDE_PLUGIN_DATA="$TRUST_DATA" CLAUDE_PLUGIN_OPTION_MARKDOWN_FORMAT_ENABLED=true bash "$HOOK")"
+if printf '%s' "$OUT_TRUST_4" | jq -e '.hookSpecificOutput.additionalContext | contains("trust gate")' >/dev/null 2>&1 &&
+ ! has_final_newline "$TRUST_FILE"; then
+ ok "changed executable config state revokes approval and blocks again"
+else
+ fail "changed executable config state was not re-gated: $OUT_TRUST_4"
fi
# Declarative CLI2 configuration still loads modules when these official keys
@@ -601,21 +636,448 @@ cat >"$ORIGINAL_CONFIG" <<'JSONC'
JSONC
OUT_TRUST_MODULES="$(cd "$UNRELATED" && printf '{"tool_input":{"file_path":"%s"}}' "$TRUST_FILE" |
env -u CLAUDE_PROJECT_DIR CLAUDE_PLUGIN_DATA="$TRUST_DATA" CLAUDE_PLUGIN_OPTION_MARKDOWN_FORMAT_ENABLED=true bash "$HOOK")"
-if printf '%s' "$OUT_TRUST_MODULES" | jq -e '.hookSpecificOutput.additionalContext | contains("trust advisory") and contains(".markdownlint-cli2.jsonc")' >/dev/null 2>&1; then
- ok "module-loading config keys emit visible trust advisory"
+if printf '%s' "$OUT_TRUST_MODULES" | jq -e '.hookSpecificOutput.additionalContext | contains("trust gate") and contains(".markdownlint-cli2.jsonc")' >/dev/null 2>&1 &&
+ ! has_final_newline "$TRUST_FILE"; then
+ ok "module-loading config keys are gated with a visible notice"
+else
+ fail "module-loading config keys were not gated: $OUT_TRUST_MODULES"
+fi
+
+# Fail closed: with no CLAUDE_PLUGIN_DATA an approval can be neither recorded
+# nor verified, so a risky config must still skip the lint run (and notice
+# every time — the once-per-session gate fails open toward visibility when it
+# has no marker store).
+OUT_TRUST_NOSTATE="$(cd "$UNRELATED" && printf '{"tool_input":{"file_path":"%s"}}' "$TRUST_FILE" |
+ env -u CLAUDE_PROJECT_DIR -u CLAUDE_PLUGIN_DATA CLAUDE_PLUGIN_OPTION_MARKDOWN_FORMAT_ENABLED=true bash "$HOOK")"
+if printf '%s' "$OUT_TRUST_NOSTATE" | jq -e '.systemMessage | contains("trust gate")' >/dev/null 2>&1 &&
+ ! has_final_newline "$TRUST_FILE"; then
+ ok "risky config without a plugin-data store fails closed"
+else
+ fail "risky config without a plugin-data store did not fail closed: $OUT_TRUST_NOSTATE"
+fi
+
+# A module-loading key spelled through a JSONC string escape must not slip past
+# the textual key scan: the config is unverifiable as written, so it gates AND
+# refuses approval (no marker instruction is offered).
+cat >"$ORIGINAL_CONFIG" <<'JSONC'
+{
+ "config": { "MD013": false },
+ "custom\u0052ules": [],
+ "noBanner": true,
+ "noProgress": true
+}
+JSONC
+printf '# Executable config\n\nClean text.' >"$TRUST_FILE"
+OUT_TRUST_ESCAPE="$(cd "$UNRELATED" && printf '{"tool_input":{"file_path":"%s"}}' "$TRUST_FILE" |
+ env -u CLAUDE_PROJECT_DIR CLAUDE_PLUGIN_DATA="$TRUST_DATA" CLAUDE_PLUGIN_OPTION_MARKDOWN_FORMAT_ENABLED=true bash "$HOOK")"
+if printf '%s' "$OUT_TRUST_ESCAPE" | jq -e '(.systemMessage | contains("trust gate") and contains("defeat textual verification")) and (.systemMessage | contains("mkdir -p") | not)' >/dev/null 2>&1 &&
+ ! has_final_newline "$TRUST_FILE"; then
+ ok "escape-obfuscated module key gates and refuses approval"
+else
+ fail "escape-obfuscated module key was not gated unverifiable: $OUT_TRUST_ESCAPE"
+fi
+
+# A computed module expression (require whose argument is not a single string
+# literal) cannot be pinned by the text scan, so the state must refuse approval
+# outright rather than sign a module graph it cannot see.
+rm -f "$ORIGINAL_CONFIG"
+cat >"$REPO/.markdownlint-cli2.cjs" <<'CJS'
+const path = require("path");
+module.exports = {
+ config: { "MD013": false },
+ customRules: [require(path.join(__dirname, "rules", "local.cjs"))],
+ noBanner: true,
+ noProgress: true
+};
+CJS
+printf '# Executable config\n\nClean text.' >"$TRUST_FILE"
+OUT_TRUST_COMPUTED="$(cd "$UNRELATED" && printf '{"tool_input":{"file_path":"%s"}}' "$TRUST_FILE" |
+ env -u CLAUDE_PROJECT_DIR CLAUDE_PLUGIN_DATA="$TRUST_DATA" CLAUDE_PLUGIN_OPTION_MARKDOWN_FORMAT_ENABLED=true bash "$HOOK")"
+if printf '%s' "$OUT_TRUST_COMPUTED" | jq -e '(.systemMessage | contains("trust gate")) and (.systemMessage | contains("mkdir -p") | not)' >/dev/null 2>&1 &&
+ ! has_final_newline "$TRUST_FILE"; then
+ ok "computed require expression gates and refuses approval"
+else
+ fail "computed require expression was not refused: $OUT_TRUST_COMPUTED"
+fi
+rm "$REPO/.markdownlint-cli2.cjs"
+
+# An escaped module specifier decodes to a different path than its raw text
+# (Node reads this one as ./rules.js), so it cannot be pinned: refuse approval.
+cat >"$REPO/.markdownlint-cli2.cjs" <<'CJS'
+module.exports = {
+ config: { "MD013": false },
+ customRules: [require("./r\u0075les.js")],
+ noBanner: true,
+ noProgress: true
+};
+CJS
+printf '# Executable config\n\nClean text.' >"$TRUST_FILE"
+OUT_TRUST_ESCSPEC="$(cd "$UNRELATED" && printf '{"tool_input":{"file_path":"%s"}}' "$TRUST_FILE" |
+ env -u CLAUDE_PROJECT_DIR CLAUDE_PLUGIN_DATA="$TRUST_DATA" CLAUDE_PLUGIN_OPTION_MARKDOWN_FORMAT_ENABLED=true bash "$HOOK")"
+if printf '%s' "$OUT_TRUST_ESCSPEC" | jq -e '(.systemMessage | contains("trust gate")) and (.systemMessage | contains("mkdir -p") | not)' >/dev/null 2>&1 &&
+ ! has_final_newline "$TRUST_FILE"; then
+ ok "escaped module specifier gates and refuses approval"
+else
+ fail "escaped module specifier was not refused: $OUT_TRUST_ESCSPEC"
+fi
+rm "$REPO/.markdownlint-cli2.cjs"
+
+# An EXECUTABLE config that carries a module-loading key gets no approval route,
+# whatever the entry looks like: markdownlint-cli2 resolves those entries itself,
+# so the entry may be any string-producing expression and no text scan can
+# enumerate that space. Both a plain literal and an array-built path are refused
+# here — the literal case is the capability this deliberately gives up, and the
+# array case is what no per-shape pattern could have caught.
+mkdir -p "$REPO/rules"
+cat >"$REPO/rules/local.cjs" <<'CJS'
+module.exports = { names: ["local"], description: "noop", tags: [], function: () => {} };
+CJS
+for shape in literal arrayjoin; do
+ case "$shape" in
+ literal)
+ cat >"$REPO/.markdownlint-cli2.cjs" <<'CJS'
+module.exports = {
+ config: { "MD013": false },
+ customRules: ["./rules/local.cjs"],
+ noBanner: true,
+ noProgress: true
+};
+CJS
+ ;;
+ arrayjoin)
+ cat >"$REPO/.markdownlint-cli2.cjs" <<'CJS'
+module.exports = {
+ config: { "MD013": false },
+ customRules: [["./rules", "local.cjs"].join("/")],
+ noBanner: true,
+ noProgress: true
+};
+CJS
+ ;;
+ *) fail "unknown JS-config shape: $shape" ;;
+ esac
+ printf '# Executable config
+
+Clean text.' >"$TRUST_FILE"
+ OUT_JSKEY="$(cd "$UNRELATED" && printf '{"session_id":"jskey-%s","tool_input":{"file_path":"%s"}}' "$shape" "$TRUST_FILE" |
+ env -u CLAUDE_PROJECT_DIR CLAUDE_PLUGIN_DATA="$TRUST_DATA" CLAUDE_PLUGIN_OPTION_MARKDOWN_FORMAT_ENABLED=true bash "$HOOK")"
+ if printf '%s' "$OUT_JSKEY" | jq -e '(.systemMessage | contains("trust gate") and contains("cannot pin")) and (.systemMessage | contains("mkdir -p") | not)' >/dev/null 2>&1 &&
+ ! has_final_newline "$TRUST_FILE"; then
+ ok "module-loading key in an executable config ($shape) refuses approval"
+ else
+ fail "module-loading key in an executable config ($shape) was not refused: $OUT_JSKEY"
+ fi
+ rm "$REPO/.markdownlint-cli2.cjs"
+done
+
+# A declarative config can carry BOTH a literal module key AND an escaped module
+# VALUE. The escape verdict must still fire: chained as an else-branch, the key
+# match suppressed it and the collector hashed the raw escaped spelling instead
+# of the file markdownlint decodes it to and loads.
+# The escape is assembled from a backslash variable (unquoted heredoc) so the
+# fixture's intent survives authoring: the file must hold the six characters
+# backslash-u-0-0-6-1, which JSONC decodes to the letter "a".
+BS=$'\\'
+cat >"$ORIGINAL_CONFIG" <"$TRUST_FILE"
+OUT_BOTH="$(cd "$UNRELATED" && printf '{"session_id":"key-and-escape","tool_input":{"file_path":"%s"}}' "$TRUST_FILE" |
+ env -u CLAUDE_PROJECT_DIR CLAUDE_PLUGIN_DATA="$TRUST_DATA" CLAUDE_PLUGIN_OPTION_MARKDOWN_FORMAT_ENABLED=true bash "$HOOK")"
+if printf '%s' "$OUT_BOTH" | jq -e '(.systemMessage | contains("trust gate") and contains("defeat textual verification")) and (.systemMessage | contains("mkdir -p") | not)' >/dev/null 2>&1 &&
+ ! has_final_newline "$TRUST_FILE"; then
+ ok "literal key plus escaped module value still reaches the escape verdict"
+else
+ fail "literal key suppressed the escape verdict: $OUT_BOTH"
+fi
+rm -f "$ORIGINAL_CONFIG"
+rm -rf "$REPO/rules"
+
+# The evasions a loader-proximity pattern cannot see. Each must still refuse
+# approval, and each defeats a `require`-adjacency window on its own:
+# comment a JS comment between the loader name and its open paren
+# nocall markdownlint-cli2 resolves customRules itself, so an assembled
+# specifier needs no loader token in the file at all
+# concat string concatenation assembles the specifier
+# bare a computed specifier reached through a variable, with a comment
+# separating it from the loader
+# envkey the specifier comes straight from the environment: no loader call
+# and no path helper, so only the process.* token sees it
+# alias path reached through an alias and a destructured import, with no
+# __dirname, process, template or concatenation to fall back on, so
+# only refusing the path IMPORT sees it
+for evasion in comment nocall concat bare envkey alias; do
+ case "$evasion" in
+ comment)
+ cat >"$REPO/.markdownlint-cli2.cjs" <<'CJS'
+const path = require("node:path");
+module.exports = {
+ config: { "MD013": false },
+ customRules: [require/*sneak*/(path.join(__dirname, "rules", "local.cjs"))],
+ noBanner: true,
+ noProgress: true
+};
+CJS
+ ;;
+ nocall)
+ cat >"$REPO/.markdownlint-cli2.cjs" <<'CJS'
+const path = require("node:path");
+module.exports = {
+ config: { "MD013": false },
+ customRules: [
+ path.join(__dirname, "rules", "local.cjs")
+ ],
+ noBanner: true,
+ noProgress: true
+};
+CJS
+ ;;
+ concat)
+ cat >"$REPO/.markdownlint-cli2.cjs" <<'CJS'
+const dir = "./rules";
+module.exports = {
+ config: { "MD013": false },
+ customRules: [dir + "/local.cjs"],
+ noBanner: true,
+ noProgress: true
+};
+CJS
+ ;;
+ bare)
+ cat >"$REPO/.markdownlint-cli2.cjs" <<'CJS'
+const which = process.env.MDLINT_RULE_MODULE;
+module.exports = {
+ config: { "MD013": false },
+ customRules: [require /* pick */ (which)],
+ noBanner: true,
+ noProgress: true
+};
+CJS
+ ;;
+ envkey)
+ cat >"$REPO/.markdownlint-cli2.cjs" <<'CJS'
+module.exports = {
+ config: { "MD013": false },
+ customRules: [process.env.MDLINT_RULE_MODULE],
+ noBanner: true,
+ noProgress: true
+};
+CJS
+ ;;
+ alias)
+ cat >"$REPO/.markdownlint-cli2.cjs" <<'CJS'
+const { join, resolve } = require("path");
+const base = resolve("rules");
+module.exports = {
+ config: { "MD013": false },
+ customRules: [join(base, "local.cjs")],
+ noBanner: true,
+ noProgress: true
+};
+CJS
+ ;;
+ *) fail "unknown evasion fixture: $evasion" ;;
+ esac
+ printf '# Executable config\n\nClean text.' >"$TRUST_FILE"
+ OUT_EVADE="$(cd "$UNRELATED" && printf '{"session_id":"evade-%s","tool_input":{"file_path":"%s"}}' "$evasion" "$TRUST_FILE" |
+ env -u CLAUDE_PROJECT_DIR CLAUDE_PLUGIN_DATA="$TRUST_DATA" CLAUDE_PLUGIN_OPTION_MARKDOWN_FORMAT_ENABLED=true bash "$HOOK")"
+ if printf '%s' "$OUT_EVADE" | jq -e '(.systemMessage | contains("trust gate") and contains("cannot pin")) and (.systemMessage | contains("mkdir -p") | not)' >/dev/null 2>&1 &&
+ ! has_final_newline "$TRUST_FILE"; then
+ ok "unpinnable specifier ($evasion) gates and refuses approval"
+ else
+ fail "unpinnable specifier ($evasion) was not refused: $OUT_EVADE"
+ fi
+ rm "$REPO/.markdownlint-cli2.cjs"
+done
+
+# The approval signature must cover the referenced module content, not only the
+# config text: after approving a config whose customRules names a repository
+# module, changing THAT MODULE (config untouched) must revoke the approval.
+mkdir -p "$REPO/rules"
+cat >"$REPO/rules/local-rule.cjs" <<'CJS'
+module.exports = { names: ["local-rule"], description: "noop", tags: [], function: () => {} };
+CJS
+cat >"$ORIGINAL_CONFIG" <<'JSONC'
+{
+ "config": { "MD013": false },
+ "customRules": ["./rules/local-rule.cjs"],
+ "noBanner": true,
+ "noProgress": true
+}
+JSONC
+printf '# Executable config\n\nClean text.' >"$TRUST_FILE"
+OUT_TRUST_MOD1="$(cd "$UNRELATED" && printf '{"tool_input":{"file_path":"%s"}}' "$TRUST_FILE" |
+ env -u CLAUDE_PROJECT_DIR CLAUDE_PLUGIN_DATA="$TRUST_DATA" CLAUDE_PLUGIN_OPTION_MARKDOWN_FORMAT_ENABLED=true bash "$HOOK")"
+MOD_MARKER="$(printf '%s' "$OUT_TRUST_MOD1" | jq -r '.systemMessage' | sed -n "s/.*mkdir -p '\([^']*\)'.*/\1/p")"
+if [[ -n "$MOD_MARKER" && "$MOD_MARKER" == "$TRUST_DATA"/* ]] && ! has_final_newline "$TRUST_FILE"; then
+ ok "module-referencing config gates with an approval marker"
+else
+ fail "module-referencing config not gated with marker: $OUT_TRUST_MOD1"
+fi
+mkdir -p "$MOD_MARKER"
+OUT_TRUST_MOD2="$(cd "$UNRELATED" && printf '{"tool_input":{"file_path":"%s"}}' "$TRUST_FILE" |
+ env -u CLAUDE_PROJECT_DIR CLAUDE_PLUGIN_DATA="$TRUST_DATA" CLAUDE_PLUGIN_OPTION_MARKDOWN_FORMAT_ENABLED=true bash "$HOOK")"
+if [[ -z "$OUT_TRUST_MOD2" ]] && has_final_newline "$TRUST_FILE"; then
+ ok "approved config+module state lints"
+else
+ fail "approved config+module state did not lint: $OUT_TRUST_MOD2"
+fi
+printf '\n// unreviewed rule revision\n' >>"$REPO/rules/local-rule.cjs"
+printf '# Executable config\n\nClean text.' >"$TRUST_FILE"
+OUT_TRUST_MOD3="$(cd "$UNRELATED" && printf '{"tool_input":{"file_path":"%s"}}' "$TRUST_FILE" |
+ env -u CLAUDE_PROJECT_DIR CLAUDE_PLUGIN_DATA="$TRUST_DATA" CLAUDE_PLUGIN_OPTION_MARKDOWN_FORMAT_ENABLED=true bash "$HOOK")"
+if printf '%s' "$OUT_TRUST_MOD3" | jq -e '.hookSpecificOutput.additionalContext | contains("trust gate")' >/dev/null 2>&1 &&
+ ! has_final_newline "$TRUST_FILE"; then
+ ok "changed referenced module revokes approval and blocks again"
+else
+ fail "changed referenced module did not revoke approval: $OUT_TRUST_MOD3"
+fi
+# An EXTENSIONLESS module reference resolves through Node's CommonJS extension
+# candidates, so it must pin the same module file: approving a config that says
+# "./rules/local-rule" and then changing local-rule.cjs must revoke.
+cat >"$ORIGINAL_CONFIG" <<'JSONC'
+{
+ "config": { "MD013": false },
+ "customRules": ["./rules/local-rule"],
+ "noBanner": true,
+ "noProgress": true
+}
+JSONC
+printf '# Executable config\n\nClean text.' >"$TRUST_FILE"
+OUT_TRUST_EXT1="$(cd "$UNRELATED" && printf '{"tool_input":{"file_path":"%s"}}' "$TRUST_FILE" |
+ env -u CLAUDE_PROJECT_DIR CLAUDE_PLUGIN_DATA="$TRUST_DATA" CLAUDE_PLUGIN_OPTION_MARKDOWN_FORMAT_ENABLED=true bash "$HOOK")"
+EXT_MARKER="$(printf '%s' "$OUT_TRUST_EXT1" | jq -r '.systemMessage' | sed -n "s/.*mkdir -p '\([^']*\)'.*/\1/p")"
+mkdir -p "$EXT_MARKER"
+printf '\n// another unreviewed rule revision\n' >>"$REPO/rules/local-rule.cjs"
+printf '# Executable config\n\nClean text.' >"$TRUST_FILE"
+OUT_TRUST_EXT2="$(cd "$UNRELATED" && printf '{"tool_input":{"file_path":"%s"}}' "$TRUST_FILE" |
+ env -u CLAUDE_PROJECT_DIR CLAUDE_PLUGIN_DATA="$TRUST_DATA" CLAUDE_PLUGIN_OPTION_MARKDOWN_FORMAT_ENABLED=true bash "$HOOK")"
+if [[ -n "$EXT_MARKER" ]] && printf '%s' "$OUT_TRUST_EXT2" | jq -e '.hookSpecificOutput.additionalContext | contains("trust gate")' >/dev/null 2>&1 &&
+ ! has_final_newline "$TRUST_FILE"; then
+ ok "extensionless module reference is pinned; module change revokes approval"
+else
+ fail "extensionless module reference not pinned: m=$EXT_MARKER out=$OUT_TRUST_EXT2"
+fi
+rm -rf "$REPO/rules"
+
+# A YAML plain scalar carries no quotes, so the quoted-string scan never saw
+# `customRules: [./rules/local.cjs]` and the module stayed out of the signature:
+# the config-only marker approved, then changing the rule file kept it valid.
+# Approve, then change ONLY the rule module: the approval must be revoked.
+mkdir -p "$REPO/rules"
+cat >"$REPO/rules/plain.cjs" <<'CJS'
+module.exports = { names: ["plain"], description: "noop", tags: [], function: () => {} };
+CJS
+rm -f "$ORIGINAL_CONFIG"
+cat >"$REPO/.markdownlint-cli2.yaml" <<'YAML'
+config:
+ MD013: false
+customRules: [./rules/plain.cjs]
+noBanner: true
+noProgress: true
+YAML
+printf '# Executable config
+
+Clean text.' >"$TRUST_FILE"
+OUT_YAML1="$(cd "$UNRELATED" && printf '{"session_id":"yaml-plain-a","tool_input":{"file_path":"%s"}}' "$TRUST_FILE" |
+ env -u CLAUDE_PROJECT_DIR CLAUDE_PLUGIN_DATA="$TRUST_DATA" CLAUDE_PLUGIN_OPTION_MARKDOWN_FORMAT_ENABLED=true bash "$HOOK")"
+Y_MARKER="$(printf '%s' "$OUT_YAML1" | jq -r '.systemMessage' | sed -n "s/.*mkdir -p '\([^']*\)'.*/\1/p")"
+mkdir -p "$Y_MARKER"
+printf '
+// unreviewed rule revision
+' >>"$REPO/rules/plain.cjs"
+printf '# Executable config
+
+Clean text.' >"$TRUST_FILE"
+OUT_YAML2="$(cd "$UNRELATED" && printf '{"session_id":"yaml-plain-b","tool_input":{"file_path":"%s"}}' "$TRUST_FILE" |
+ env -u CLAUDE_PROJECT_DIR CLAUDE_PLUGIN_DATA="$TRUST_DATA" CLAUDE_PLUGIN_OPTION_MARKDOWN_FORMAT_ENABLED=true bash "$HOOK")"
+if [[ -n "$Y_MARKER" ]] && printf '%s' "$OUT_YAML2" | jq -e '.hookSpecificOutput.additionalContext | contains("trust gate")' >/dev/null 2>&1 &&
+ ! has_final_newline "$TRUST_FILE"; then
+ ok "unquoted YAML module path is pinned; module change revokes approval"
+else
+ fail "unquoted YAML module path not pinned: m=$Y_MARKER out=$OUT_YAML2"
+fi
+rm -f "$REPO/.markdownlint-cli2.yaml"
+rm -rf "$REPO/rules"
+
+# The same escape without needing symlink support: a `../` reference reaching a
+# file outside the repository. Runs on every host, so the branch stays covered
+# where the symlink fixture below has to skip.
+mkdir -p "$WORK/outside-repo"
+printf '%s
+' 'module.exports = { names: ["a"], description: "x", tags: [], function: () => {} };' >"$WORK/outside-repo/ext.cjs"
+cat >"$ORIGINAL_CONFIG" <<'JSONC'
+{
+ "config": { "MD013": false },
+ "customRules": ["../outside-repo/ext.cjs"],
+ "noBanner": true,
+ "noProgress": true
+}
+JSONC
+printf '# Executable config
+
+Clean text.' >"$TRUST_FILE"
+OUT_ESCAPE_REL="$(cd "$UNRELATED" && printf '{"session_id":"dotdot-escape","tool_input":{"file_path":"%s"}}' "$TRUST_FILE" |
+ env -u CLAUDE_PROJECT_DIR CLAUDE_PLUGIN_DATA="$TRUST_DATA" CLAUDE_PLUGIN_OPTION_MARKDOWN_FORMAT_ENABLED=true bash "$HOOK")"
+if printf '%s' "$OUT_ESCAPE_REL" | jq -e '(.systemMessage | contains("trust gate") and contains("cannot pin")) and (.systemMessage | contains("mkdir -p") | not)' >/dev/null 2>&1 &&
+ ! has_final_newline "$TRUST_FILE"; then
+ ok "module reference escaping the repository refuses approval"
+else
+ fail "out-of-repository module reference was not refused: $OUT_ESCAPE_REL"
+fi
+rm -f "$ORIGINAL_CONFIG"
+rm -rf "$WORK/outside-repo"
+
+# A repository module symlink whose target resolves OUTSIDE the repository names
+# code no repository-content signature can cover: re-aiming the symlink at a
+# different existing external target leaves the signature identical while Node
+# follows the new one. Such a state must refuse approval rather than sign it.
+if OUTSIDE_DIR="$(mktemp -d)" &&
+ printf '%s
+' 'module.exports = { names: ["a"], description: "x", tags: [], function: () => {} };' >"$OUTSIDE_DIR/ext.cjs" &&
+ mkdir -p "$REPO/rules" &&
+ ln -s "$OUTSIDE_DIR/ext.cjs" "$REPO/rules/escaping.cjs" 2>/dev/null &&
+ [[ -L "$REPO/rules/escaping.cjs" ]]; then
+ cat >"$ORIGINAL_CONFIG" <<'JSONC'
+{
+ "config": { "MD013": false },
+ "customRules": ["./rules/escaping.cjs"],
+ "noBanner": true,
+ "noProgress": true
+}
+JSONC
+ printf '# Executable config
+
+Clean text.' >"$TRUST_FILE"
+ OUT_ESCAPE_LINK="$(cd "$UNRELATED" && printf '{"session_id":"symlink-escape","tool_input":{"file_path":"%s"}}' "$TRUST_FILE" |
+ env -u CLAUDE_PROJECT_DIR CLAUDE_PLUGIN_DATA="$TRUST_DATA" CLAUDE_PLUGIN_OPTION_MARKDOWN_FORMAT_ENABLED=true bash "$HOOK")"
+ if printf '%s' "$OUT_ESCAPE_LINK" | jq -e '(.systemMessage | contains("trust gate") and contains("cannot pin")) and (.systemMessage | contains("mkdir -p") | not)' >/dev/null 2>&1 &&
+ ! has_final_newline "$TRUST_FILE"; then
+ ok "module symlink resolving outside the repository refuses approval"
+ else
+ fail "out-of-repository module symlink was not refused: $OUT_ESCAPE_LINK"
+ fi
+ rm -f "$ORIGINAL_CONFIG"
+ rm -rf "$REPO/rules" "$OUTSIDE_DIR"
else
- fail "module-loading config trust advisory absent: $OUT_TRUST_MODULES"
+ echo "SKIP: host cannot create real symlinks for the escape fixture"
+ rm -rf "$REPO/rules" "${OUTSIDE_DIR:-}"
fi
# Negative control: a declarative rule-only config is not executable and loads
-# no modules, so it must not produce trust-warning noise.
+# no modules, so linting proceeds immediately with no gate noise.
mv "$SAVED_CONFIG" "$ORIGINAL_CONFIG"
OUT_TRUST_SAFE="$(cd "$UNRELATED" && printf '{"tool_input":{"file_path":"%s"}}' "$TRUST_FILE" |
env -u CLAUDE_PROJECT_DIR CLAUDE_PLUGIN_DATA="$TRUST_DATA" CLAUDE_PLUGIN_OPTION_MARKDOWN_FORMAT_ENABLED=true bash "$HOOK")"
-if [[ -z "$OUT_TRUST_SAFE" ]]; then
- ok "rule-only declarative config emits no trust advisory"
+if [[ -z "$OUT_TRUST_SAFE" ]] && has_final_newline "$TRUST_FILE"; then
+ ok "rule-only declarative config lints with no trust gate"
else
- fail "rule-only declarative config emitted advisory: $OUT_TRUST_SAFE"
+ fail "rule-only declarative config gated or noisy: $OUT_TRUST_SAFE"
fi
# --- Kill switch: disabled hook is a no-op ----------------------------------
diff --git a/plugins/powershell-format/.claude-plugin/plugin.json b/plugins/powershell-format/.claude-plugin/plugin.json
index b06eeba1ca..537a3013e7 100644
--- a/plugins/powershell-format/.claude-plugin/plugin.json
+++ b/plugins/powershell-format/.claude-plugin/plugin.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
"name": "powershell-format",
- "version": "0.5.2",
+ "version": "0.6.0",
"description": "Auto-format and lint PowerShell on edit via PSScriptAnalyzer, only when a PSScriptAnalyzerSettings.psd1 governs the repo — using the consuming repo's own analyzer settings.",
"author": {
"name": "Melodic Software",
diff --git a/plugins/powershell-format/CHANGELOG.md b/plugins/powershell-format/CHANGELOG.md
index 24b5f99e7f..003b97a4f7 100644
--- a/plugins/powershell-format/CHANGELOG.md
+++ b/plugins/powershell-format/CHANGELOG.md
@@ -3,6 +3,85 @@
All notable changes to the `powershell-format` plugin are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning.
+## [0.6.0]
+
+### Security
+
+- **Code-loading analyzer settings are now gated on explicit approval.** A
+ `PSScriptAnalyzerSettings.psd1` that declares `CustomRulePath` makes
+ PSScriptAnalyzer load and execute repository-supplied rule modules during
+ analysis, so the hook no longer runs the formatter/analyzer under such a
+ settings file automatically: it skips the run — with a visible
+ once-per-session notice on both channels — until the user approves that exact
+ settings-and-rule-module content state by creating the marker directory named
+ in the notice (under `${CLAUDE_PLUGIN_DATA}/trust-approvals`). The approval
+ signature is content-addressed over the settings file AND every file
+ reachable under each declared `CustomRulePath` entry (recursively for
+ directories), plus every repository file those files reference by string
+ literal (transitively, bounded — a leaf module's dot-sourced or imported
+ dependencies execute with it; `$PSScriptRoot` and `$PSCommandPath` are
+ expanded wherever they appear in the reference, not only as a leading prefix,
+ so the standard interpolated dependency form pins instead of dropping out of
+ the signature), so a change to
+ the settings or to any referenced rule module —
+ e.g. a branch switch swapping module bytes under an unchanged settings file —
+ revokes the approval. The gate fails closed when `CLAUDE_PLUGIN_DATA` is
+ unavailable, and also when a `CustomRulePath` entry does not resolve to
+ hashable content: an unpinnable state offers no approval route at all.
+ A load whose TARGET cannot be pinned to a file is refused the same way — a
+ variable, an env lookup, a composed expression such as
+ `. (Join-Path $PSScriptRoot "deps" "helper.ps1")`, or an interpolated string
+ holding any other variable. That verdict comes from PowerShell's own parser
+ (`Parser::ParseInput`, examining every `.`/`&` invocation and
+ `Import-Module`/`Add-Type`/`Invoke-Expression`-class command) rather than from
+ a text pattern, so it cannot be evaded by quoting or comment placement and
+ needs no file-extension guessing — the extensionless
+ `Import-Module "$root/MyModule"` form is caught without one. A loader fed by a
+ PIPELINE is refused too: it takes its source from the upstream element rather
+ than from its own arguments, so `Get-Content (Join-Path $PSScriptRoot deps
+ helper.ps1) -Raw | Invoke-Expression` would otherwise present nothing but a
+ constant command name to inspect. A target the
+ parser accepts is pinned from the parser too, not left to the quoted-literal
+ scan: PowerShell does not require quotes around a command argument, so
+ `. $PSScriptRoot\helper.ps1` would otherwise be judged pinnable and then never
+ pinned. `using module ` and `using assembly ` are collected as well
+ — both are `UsingStatementAst` nodes rather than commands, so neither the
+ command walk nor a text scan would see them, and an assembly directive loads a
+ repository DLL exactly as a module directive loads a `.psm1`. An assembly the
+ parser cannot load is reported as a parse error, which already refuses
+ approval, so both directions are closed: loadable is pinned, unloadable is
+ unverifiable. `using namespace` and `using type` name no repository file and are
+ left alone. An extensionless reference resolves through
+ PowerShell module resolution, so the `.psd1`/`.psm1`/`.ps1`/`.dll` candidates
+ and both directory layouts — `MyModule/MyModule.psd1` and the versioned
+ `MyModule//MyModule.psd1` — are all pinned rather than only an exact
+ leaf. An inline script block is exempt because it is part of the
+ already-hashed file, and a composed load nested inside it is still judged on
+ its own.
+ Detection uses PowerShell's restricted data-file parser
+ (`Import-PowerShellDataFile`), not a textual scan, so quoting/escape
+ obfuscation of the key cannot evade it — and a settings file the restricted
+ parser rejects stays gated rather than run, since it cannot be proven
+ code-free. Previously the hook ran the analyzer unconditionally, so a
+ malicious repository's checked-in settings could execute arbitrary PowerShell
+ on a routine `.ps1`/`.psm1`/`.psd1` edit. Settings without `CustomRulePath`
+ are unaffected. The edit itself is still never blocked — the hook always
+ exits 0.
+
+### Fixed
+
+- **Shared `hook-utils.sh`: a bare or trailing unquoted `NAME=value` Bash
+ command no longer leaks the assignment value into the privacy-safe
+ telemetry/audit subject.** `hook::extract_bash_subject` stripped a leading
+ `VAR=value` prefix only when a following command word consumed it, so a
+ command whose LAST token was an unquoted assignment (e.g. `TOKEN=ghp_…`)
+ survived to the subject and emitted `Bash:TOKEN=ghp_…` into
+ `hook-events.jsonl` and any wired `HOOK_TELEMETRY_SINK`. A resolved token
+ still shaped like a shell assignment now bails to the bare `Bash` subject,
+ matching the existing quoted-value bail (`VAR=x cmd` still reduces to
+ `Bash:cmd`). Synced from `lib/hook-utils.sh`; the subject is
+ telemetry/audit-only, so no guard or formatter block/allow behavior changes.
+
## [0.5.2]
### Fixed
diff --git a/plugins/powershell-format/README.md b/plugins/powershell-format/README.md
index 3113387e0e..c61e777e94 100644
--- a/plugins/powershell-format/README.md
+++ b/plugins/powershell-format/README.md
@@ -19,7 +19,8 @@ and runs only when your repo has opted into a `PSScriptAnalyzerSettings.psd1`.
so the hook both gates on that file and passes it through. A repo without a
settings file is left untouched rather than formatted and linted with
PSScriptAnalyzer's built-in defaults, so the plugin never imposes a style you
- did not choose.
+ did not choose. A settings file that declares `CustomRulePath` is additionally
+ gated on explicit approval — see [Trust model](#trust-model).
- **Format on edit.** `Invoke-Formatter` applies your settings' formatting rules
(indentation, alias expansion, brace placement, and so on) in place.
- **Findings are advisory.** Semantic diagnostics your settings enable (for
@@ -37,12 +38,30 @@ The opt-in `PSScriptAnalyzerSettings.psd1` is **executed-adjacent configuration*
not inert data. A settings file may declare a
[`CustomRulePath`](https://learn.microsoft.com/powershell/utility-modules/psscriptanalyzer/using-scriptanalyzer#custom-rules)
pointing at PowerShell rule modules, and PSScriptAnalyzer **loads and runs** those
-modules' exported functions during analysis. Treat the settings file — and any
-module it references — with the same trust you give your build and CI
-configuration: it runs on your machine on every edit. The hook only reads a
-settings file at or below your project root (bounded by `CLAUDE_PROJECT_DIR` when
-set), so it never picks up one from an ancestor directory outside the project.
-Do not enable this plugin against an untrusted working tree.
+modules' exported functions during analysis. The hook therefore never runs the
+analyzer under such a settings file without an explicit approval: it skips the
+format/lint run and reports a visible trust-gate notice (once per session, on
+both the agent and user channels) naming the settings file and the approval
+marker to create. To approve, review the settings file and every rule module it
+references — treat them with the same trust you give your build and CI
+configuration — then create the marker directory using the exact `mkdir -p`
+command the notice carries. The marker lives under
+`${CLAUDE_PLUGIN_DATA}/trust-approvals` and is content-addressed over the
+repository, the settings file, every file reachable under each declared
+`CustomRulePath` entry (recursively for directories), and every repository
+file those files reference by string literal (transitively, bounded), so a
+change to the settings, to any referenced rule module, or to a file a rule
+module loads — including a branch switch that swaps module bytes under an
+unchanged settings file — revokes the approval and re-gates the run. Detection uses PowerShell's restricted data-file parser,
+not a textual scan; a settings file that parser cannot read is treated as
+code-loading and stays gated, a `CustomRulePath` entry that does not resolve
+to hashable content leaves the state unverifiable with no approval route, and
+when `CLAUDE_PLUGIN_DATA` is unavailable the gate fails closed and the run
+stays skipped. A settings file without
+`CustomRulePath` is declarative rule configuration and runs immediately. The
+hook only reads a settings file at or below your project root (bounded by
+`CLAUDE_PROJECT_DIR` when set), so it never picks up one from an ancestor
+directory outside the project.
## Requirements
diff --git a/plugins/powershell-format/hooks/hook-utils.sh b/plugins/powershell-format/hooks/hook-utils.sh
index b854e2598c..411c4a6731 100644
--- a/plugins/powershell-format/hooks/hook-utils.sh
+++ b/plugins/powershell-format/hooks/hook-utils.sh
@@ -279,6 +279,11 @@ hook::jq_field() {
# A quoted assignment value (e.g. `TOKEN="a b" curl …`) would otherwise leak a
# fragment of the value into the token, so any token carrying a quote aborts to a
# bare "Bash" subject rather than risk exposing part of the value.
+#
+# A bare or trailing unquoted assignment that no following command consumed
+# (e.g. the whole command is `TOKEN=ghp_…`) is likewise a value the subject must
+# not carry, so a resolved token still shaped like a NAME=value assignment aborts
+# to the bare "Bash" subject too.
# SUBJECT=$(hook::extract_bash_subject "$TOOL" "$CMD")
hook::extract_bash_subject() {
local tool="$1" cmd="${2:-}"
@@ -307,6 +312,19 @@ hook::extract_bash_subject() {
printf '%s' "$tool"
return 0
fi
+ # A resolved token still shaped like a bare/trailing assignment (no following
+ # command word consumed it in the strip loop) would emit the assignment's
+ # value — a possible credential — as the subject; bail to the bare "Bash"
+ # subject as with a quoted value. All valid Bash assignment forms count:
+ # NAME=value, append NAME+=value, and subscripted NAME[idx]=value /
+ # NAME[idx]+=value — the subscript matched greedily (`.*`) because Bash
+ # accepts nested subscripts like NAME[1+IDX[0]]=value, which a
+ # no-close-bracket class would miss. This runs BEFORE the basename strip so
+ # a path-valued assignment (TOKEN=/a/b/secret) cannot lose its "=" first.
+ if [[ "$first_token" =~ ^[a-zA-Z_][a-zA-Z0-9_]*(\[.*\])?\+?= ]]; then
+ printf '%s' "$tool"
+ return 0
+ fi
first_token="${first_token##*/}"
if [[ -n "$first_token" ]]; then
printf 'Bash:%s' "$first_token"
diff --git a/plugins/powershell-format/hooks/powershell-format.sh b/plugins/powershell-format/hooks/powershell-format.sh
index db0c38a63d..1c2d454e9f 100755
--- a/plugins/powershell-format/hooks/powershell-format.sh
+++ b/plugins/powershell-format/hooks/powershell-format.sh
@@ -13,7 +13,9 @@
# the hook both gates on that file and passes it through. A repo that has not
# adopted a settings file is left untouched rather than formatted and linted with
# PSScriptAnalyzer's built-in defaults, so the plugin never imposes a style it did
-# not choose.
+# not choose. A settings file that declares CustomRulePath would make the analyzer
+# execute repository-supplied rule modules, so that state is further gated on an
+# explicit per-content trust approval (see the trust gate below).
#
# Graceful degrade: pwsh absent (pwsh-less contributor box, Linux cloud session
# without PowerShell) OR the PSScriptAnalyzer module not installed -> clean silent
@@ -122,8 +124,9 @@ root="$(hook::normalize_path "$(hook::physical_path "$REPO_ROOT")")" || root=""
# there, so the settings ceiling matches the file-membership ceiling that
# hook::read_file_path already enforced — a settings file above the project dir
# (which the agent was never allowed to write under) can never govern the edit,
-# and a settings file discovered under CustomRulePath is executed during analysis
-# (see README "Trust model"). The git-root ceiling is the fallback when unset.
+# and a settings file that declares CustomRulePath is gated on explicit trust
+# approval before analysis (see README "Trust model"). The git-root ceiling is
+# the fallback when unset.
CEILING="$root"
if [[ -n "${CLAUDE_PROJECT_DIR:-}" ]]; then
CEILING="$(hook::normalize_path "$(hook::physical_path "$CLAUDE_PROJECT_DIR")")"
@@ -170,21 +173,374 @@ to_pwsh_path() {
PSSA_FILE_ARG="$(to_pwsh_path "$FILE")"
PSSA_SETTINGS_ARG="$(to_pwsh_path "$SETTINGS_FOUND")"
-# Single pwsh invocation — probe the module, format in place, then lint. File
-# and settings pass via env vars to avoid pwsh argument parsing issues.
+# Trust gate state base for the code-loading settings check inside the pwsh
+# block below. A settings file may declare CustomRulePath, and PSScriptAnalyzer
+# loads and RUNS those rule modules during analysis — repository-supplied code
+# must never execute on the strength of a PowerShell edit alone. Approval is an
+# explicit marker directory the user creates after reviewing the settings file
+# and every rule module it references. CLAUDE_PLUGIN_DATA is the official
+# persistent plugin-state location and survives plugin updates. The approval
+# signature itself is computed inside pwsh, because only the restricted data
+# parser can resolve WHAT the settings load: it is content-addressed over the
+# repo root, the settings file, and every file reachable under each declared
+# CustomRulePath entry, so a change to the settings OR to any referenced rule
+# module yields a new marker directory and revokes a prior approval — a
+# settings-only signature would keep honoring an approval after a branch
+# switch replaced the rule module bytes. The exit-6 arm fails CLOSED whenever
+# the signature cannot be computed. (Same shape as markdown-format's trust
+# gate; a future refactor could hoist the shared primitive into the hook-utils
+# library.)
+PSSA_STATE_BASE_ARG=""
+if [[ -n "${CLAUDE_PLUGIN_DATA:-}" ]]; then
+ PSSA_STATE_BASE_ARG="$(to_pwsh_path "$CLAUDE_PLUGIN_DATA")"
+fi
+
+# Single pwsh invocation — probe the module, gate code-loading settings, format
+# in place, then lint. File and settings pass via env vars to avoid pwsh
+# argument parsing issues.
# exit 3 PSScriptAnalyzer module not installed -> clean skip (no findings)
# exit 0 clean
# exit 1 findings (written to stderr, one line each)
# exit 4 Invoke-Formatter / Invoke-ScriptAnalyzer threw -> tool break
# exit 5 file is neither BOM'd nor valid UTF-8 (legacy ANSI) -> clean skip
# (cannot round-trip the bytes safely, so never rewrite)
+# exit 6 settings declare CustomRulePath (or could not be verified code-free)
+# and this settings-content state is unapproved -> trust-gate skip
# SC2016: PowerShell uses $env:VAR syntax inside single quotes — not bash expansion.
# shellcheck disable=SC2016
PSSA_OUTPUT=$(PSSA_FILE="$PSSA_FILE_ARG" PSSA_SETTINGS="$PSSA_SETTINGS_ARG" \
+ PSSA_STATE_BASE="$PSSA_STATE_BASE_ARG" PSSA_REPO_ROOT="$REPO_ROOT" \
pwsh -NoProfile -NonInteractive -Command '
if (-not (Get-Module -ListAvailable -Name PSScriptAnalyzer)) {
exit 3
}
+
+ # Trust gate: a settings file may declare CustomRulePath, which makes
+ # Invoke-ScriptAnalyzer load and execute repository-supplied rule modules.
+ # Detect the key via the restricted data-file parser PowerShell itself
+ # provides — a textual grep is evadable through quoting and escape
+ # sequences (e.g. a backtick escape inside a double-quoted key still
+ # evaluates to CustomRulePath). A settings file the restricted parser
+ # rejects cannot be proven code-free (PSScriptAnalyzer settings parsing is
+ # more lenient, for example taking the first hashtable of a
+ # multi-statement file), so it gates too: fail closed.
+ #
+ # The approval signature covers the settings file AND every file reachable
+ # under each declared CustomRulePath entry (recursively for directories,
+ # matching -RecurseCustomRulePath reach), so approval binds to the rule
+ # module content that would execute, not just to the text that names it.
+ # An entry that does not resolve, or a state base that is unusable, makes
+ # the state unverifiable: exit 6 with no approval route (fail closed).
+ # Structured PSSA_TRUST lines on stdout hand the verdict to the shell.
+ $sd = $null
+ try {
+ $sd = Import-PowerShellDataFile -LiteralPath $env:PSSA_SETTINGS -ErrorAction Stop
+ } catch {
+ $sd = $null
+ }
+ if (-not ($sd -is [hashtable])) {
+ Write-Output "PSSA_TRUST UNVERIFIABLE"
+ exit 6
+ }
+ if ($sd.ContainsKey("CustomRulePath")) {
+ $settingsDir = Split-Path -Parent $env:PSSA_SETTINGS
+ $ruleFiles = [System.Collections.Generic.List[string]]::new()
+ $unverifiable = $false
+ foreach ($entry in @($sd["CustomRulePath"])) {
+ if (-not ($entry -is [string]) -or [string]::IsNullOrWhiteSpace($entry)) {
+ $unverifiable = $true; break
+ }
+ $resolvedPath = $entry
+ if (-not [System.IO.Path]::IsPathRooted($resolvedPath)) {
+ $resolvedPath = Join-Path $settingsDir $resolvedPath
+ }
+ if (Test-Path -LiteralPath $resolvedPath -PathType Leaf) {
+ $ruleFiles.Add((Convert-Path -LiteralPath $resolvedPath))
+ } elseif (Test-Path -LiteralPath $resolvedPath -PathType Container) {
+ foreach ($f in Get-ChildItem -LiteralPath $resolvedPath -Recurse -File -Force) {
+ $ruleFiles.Add($f.FullName)
+ }
+ } else {
+ $unverifiable = $true; break
+ }
+ }
+ if ($unverifiable -or $ruleFiles.Count -gt 512) {
+ Write-Output "PSSA_TRUST UNVERIFIABLE"
+ exit 6
+ }
+ # Transitive code-loading inputs: a declared leaf module may dot-source
+ # or Import-Module further repository files, and those execute with it,
+ # so they must be pinned too. Enumerating PowerShell resolution exactly
+ # would mean evaluating the module - the very thing being gated - so
+ # approximate from text: collect every string literal in each collected
+ # file, resolve it against the containing directory and the settings
+ # directory, take existing files, and rescan those the same way,
+ # bounded. Overflow or an unreadable file makes the state unverifiable.
+ $sq = [char]39
+ $dq = [char]34
+ $litPattern = "$sq([^$sq]+)$sq|$dq([^$dq]+)$dq"
+ # Substitute the automatic variables whose values are known here -
+ # anywhere in the reference, not just as a leading prefix - so the
+ # standard interpolated dependency . "$PSScriptRoot/x.ps1" resolves.
+ # Without it Join-Path reads PSScriptRoot as a directory name and the
+ # helper escapes the signature. Shared by the parser and text passes so
+ # both judge and resolve the same string.
+ # The name is BOUNDED, so a longer variable that merely starts with the
+ # same text is not mistaken for it: PowerShell continues a variable name
+ # through [A-Za-z0-9_] and reads a colon as a scope qualifier, so an
+ # unbounded pattern would rewrite $PSScriptRootFoo to Foo and the
+ # caller would then judge that reference pinnable and never pin it.
+ function expandKnownVars([string]$value, [string]$scriptRoot, [string]$commandPath) {
+ $out = [regex]::Replace(
+ $value,
+ "(?i)\`$(?:\{PSScriptRoot\}|PSScriptRoot(?![A-Za-z0-9_:]))",
+ $scriptRoot.Replace("`$", "`$`$"))
+ [regex]::Replace(
+ $out,
+ "(?i)\`$(?:\{PSCommandPath\}|PSCommandPath(?![A-Za-z0-9_:]))",
+ $commandPath.Replace("`$", "`$`$"))
+ }
+ $seen = [System.Collections.Generic.HashSet[string]]::new(
+ [System.StringComparer]::OrdinalIgnoreCase)
+ $allFiles = [System.Collections.Generic.List[string]]::new()
+ $scanQueue = [System.Collections.Generic.Queue[string]]::new()
+ foreach ($rf in $ruleFiles) {
+ if ($seen.Add($rf)) { $allFiles.Add($rf); $scanQueue.Enqueue($rf) }
+ }
+ $scanned = 0
+ while ($scanQueue.Count -gt 0) {
+ $cur = $scanQueue.Dequeue()
+ $scanned++
+ if ($scanned -gt 256 -or $allFiles.Count -gt 512) {
+ Write-Output "PSSA_TRUST UNVERIFIABLE"
+ exit 6
+ }
+ $curDir = Split-Path -Parent $cur
+ try { $text = [System.IO.File]::ReadAllText($cur) } catch {
+ Write-Output "PSSA_TRUST UNVERIFIABLE"
+ exit 6
+ }
+ # Candidate paths to resolve for this file: every load target the
+ # parser reports plus every quoted literal the text scan finds.
+ $pending = [System.Collections.Generic.List[string]]::new()
+
+ # Refuse a load whose target cannot be pinned to a file. A COMPOSED
+ # target - a variable, an env lookup, or an expression such as
+ # . (Join-Path $PSScriptRoot "deps" "helper.ps1") - names a
+ # dependency that could never enter the signature, and an approval
+ # would then survive arbitrary edits to it. The PowerShell parser
+ # decides pinnability exactly: the target must be a constant string,
+ # or an interpolated string whose every variable this scan can
+ # expand. Unlike a text pattern it cannot be evaded by quoting or
+ # comment placement, and it needs no file-extension guessing, so the
+ # extensionless Import-Module "$root/MyModule" form is caught too.
+ #
+ # A target the parser accepts is queued HERE rather than left to the
+ # quoted-literal scan below, which sees only quoted text: PowerShell
+ # does not require quotes around a command argument, so
+ # . $PSScriptRoot\helper.ps1 and Import-Module ./rules/helper.ps1
+ # would otherwise be judged pinnable and then never pinned - the one
+ # direction this gate must not fail in.
+ #
+ # Scoped to PowerShell source because the collector also pins
+ # non-code data files, which the parser would reject wholesale.
+ if ($cur -match "\.ps(m|d)?1$") {
+ $perrs = $null
+ $fileAst = [System.Management.Automation.Language.Parser]::ParseInput(
+ $text, [ref]$null, [ref]$perrs)
+ if ($null -eq $fileAst -or ($null -ne $perrs -and $perrs.Count -gt 0)) {
+ Write-Output "PSSA_TRUST UNVERIFIABLE"
+ exit 6
+ }
+ # Named code-loading commands. The dot-source and call-operator
+ # forms need no name match: InvocationOperator identifies them,
+ # and their element 0 IS the target, so it is checked too.
+ $loaders = @("Import-Module", "ipmo", "Add-Type", "New-Module",
+ "Invoke-Expression", "iex", "Import-PowerShellDataFile")
+ $cmdAsts = $fileAst.FindAll({
+ param($n) $n -is [System.Management.Automation.Language.CommandAst]
+ }, $true)
+ foreach ($cmdAst in $cmdAsts) {
+ $cmdName = $cmdAst.GetCommandName()
+ $isLoad = $cmdAst.InvocationOperator -ne
+ [System.Management.Automation.Language.TokenKind]::Unknown
+ if (-not $isLoad) {
+ $isLoad = [string]::IsNullOrEmpty($cmdName) -or
+ $loaders -contains $cmdName
+ }
+ if (-not $isLoad) { continue }
+ # A loader fed by a PIPELINE takes its source from the
+ # upstream element, not from its own arguments, so the loop
+ # below would see only a constant command name and accept:
+ # Get-Content (Join-Path $PSScriptRoot deps helper.ps1) | iex
+ # executes a file this scan never reconstructs, and
+ # Get-ChildItem *.psm1 | Import-Module has the same shape.
+ # Anything but the first element of its own pipeline is
+ # refused.
+ if ($null -ne $cmdAst.Parent -and
+ $cmdAst.Parent -is [System.Management.Automation.Language.PipelineAst] -and
+ $cmdAst.Parent.PipelineElements.Count -gt 1 -and
+ -not [object]::ReferenceEquals($cmdAst.Parent.PipelineElements[0], $cmdAst)) {
+ Write-Output "PSSA_TRUST UNPINNABLE"
+ exit 6
+ }
+ foreach ($el in $cmdAst.CommandElements) {
+ if ($el -is [System.Management.Automation.Language.CommandParameterAst]) { continue }
+ if ($el -is [System.Management.Automation.Language.StringConstantExpressionAst]) {
+ [void]$pending.Add($el.Value)
+ continue
+ }
+ # An inline script block is part of the text already
+ # hashed here; FindAll recursed into it, so a composed
+ # load nested inside is still judged on its own.
+ if ($el -is [System.Management.Automation.Language.ScriptBlockExpressionAst]) { continue }
+ if ($el -is [System.Management.Automation.Language.ExpandableStringExpressionAst]) {
+ $target = expandKnownVars $el.Value $curDir $cur
+ if (-not $target.Contains("`$")) {
+ [void]$pending.Add($target)
+ continue
+ }
+ }
+ Write-Output "PSSA_TRUST UNPINNABLE"
+ exit 6
+ }
+ }
+ # `using module ./deps/helper.psm1` and `using assembly
+ # ./deps/helper.dll` both load repository code, but are
+ # UsingStatementAst nodes rather than commands, so the loop above
+ # never sees them - and neither form needs quotes, so the text
+ # scan does not either. `using namespace` and `using type` name
+ # no repository file and are left alone. The path must be a
+ # constant (PowerShell requires one), so a hashtable module
+ # specification cannot be pinned and refuses approval.
+ $loadKinds = @(
+ [System.Management.Automation.Language.UsingStatementKind]::Module,
+ [System.Management.Automation.Language.UsingStatementKind]::Assembly)
+ $usingAsts = $fileAst.FindAll({
+ param($n) $n -is [System.Management.Automation.Language.UsingStatementAst]
+ }, $true)
+ foreach ($useAst in $usingAsts) {
+ if ($loadKinds -notcontains $useAst.UsingStatementKind) {
+ continue
+ }
+ if ($useAst.Name -is
+ [System.Management.Automation.Language.StringConstantExpressionAst]) {
+ [void]$pending.Add($useAst.Name.Value)
+ continue
+ }
+ Write-Output "PSSA_TRUST UNPINNABLE"
+ exit 6
+ }
+ }
+ foreach ($hit in [regex]::Matches($text, $litPattern)) {
+ $lit = if ($hit.Groups[1].Success) { $hit.Groups[1].Value } else { $hit.Groups[2].Value }
+ if ([string]::IsNullOrWhiteSpace($lit) -or $lit.Contains("`n")) { continue }
+ # The raw literal AND its expansion are both candidates: a file
+ # whose name genuinely contains the text still resolves, and the
+ # standard interpolated dependency resolves too.
+ [void]$pending.Add($lit)
+ if ($lit.Contains("`$")) {
+ $expanded = expandKnownVars $lit $curDir $cur
+ if ($expanded -ne $lit) { [void]$pending.Add($expanded) }
+ }
+ }
+ # An extensionless reference resolves through PowerShell module
+ # resolution, so pinning only the exact leaf would miss the file that
+ # actually executes: Import-Module "$PSScriptRoot/MyModule" loads
+ # MyModule.psd1/.psm1, or MyModule/MyModule.psd1 when the reference
+ # names a module directory. Every candidate is tried; whatever exists
+ # is pinned, which over-collects at worst.
+ foreach ($cand0 in $pending) {
+ if ([string]::IsNullOrWhiteSpace($cand0)) { continue }
+ foreach ($baseDir in @($curDir, $settingsDir)) {
+ $base = $cand0
+ try {
+ if (-not [System.IO.Path]::IsPathRooted($base)) {
+ $base = Join-Path $baseDir $base
+ }
+ } catch { continue }
+ $cands = @($base)
+ foreach ($ext in @(".psd1", ".psm1", ".ps1", ".dll")) {
+ $cands += "$base$ext"
+ }
+ try {
+ if (Test-Path -LiteralPath $base -PathType Container) {
+ $leaf = Split-Path -Leaf $base
+ foreach ($ext in @(".psd1", ".psm1")) {
+ $cands += (Join-Path $base "$leaf$ext")
+ }
+ # The versioned layout PowerShell also loads:
+ # MyModule//MyModule.psd1. Every immediate
+ # subdirectory is tried rather than version strings
+ # being parsed - a non-version directory simply has
+ # no manifest to find.
+ foreach ($sub in Get-ChildItem -LiteralPath $base -Directory -Force -ErrorAction SilentlyContinue) {
+ foreach ($ext in @(".psd1", ".psm1")) {
+ $cands += (Join-Path $sub.FullName "$leaf$ext")
+ }
+ }
+ }
+ } catch { }
+ foreach ($cand in $cands) {
+ try {
+ if (Test-Path -LiteralPath $cand -PathType Leaf) {
+ $full = Convert-Path -LiteralPath $cand
+ if ($seen.Add($full)) { $allFiles.Add($full); $scanQueue.Enqueue($full) }
+ }
+ } catch { continue }
+ }
+ }
+ }
+ }
+ $manifest = [System.Text.StringBuilder]::new()
+ [void]$manifest.AppendLine($env:PSSA_REPO_ROOT)
+ $digestTargets = @($env:PSSA_SETTINGS) + (@($allFiles) | Sort-Object)
+ foreach ($target in $digestTargets) {
+ try {
+ $h = (Get-FileHash -LiteralPath $target -Algorithm SHA256 -ErrorAction Stop).Hash
+ } catch {
+ Write-Output "PSSA_TRUST UNVERIFIABLE"
+ exit 6
+ }
+ [void]$manifest.AppendLine("$target`t$h")
+ }
+ if ([string]::IsNullOrEmpty($env:PSSA_STATE_BASE)) {
+ Write-Output "PSSA_TRUST NOSTORE"
+ exit 6
+ }
+ # Instance SHA256 + x2 formatting: the static SHA256.HashData /
+ # Convert.ToHexString shortcuts are .NET 5+, absent on the PowerShell
+ # 7.0 (.NET Core 3.1) floor this hook supports. Any failure is
+ # UNVERIFIABLE - an empty signature must never mint a shared marker.
+ $signature = ""
+ try {
+ $sha = [System.Security.Cryptography.SHA256]::Create()
+ try {
+ $sigBytes = $sha.ComputeHash(
+ [System.Text.Encoding]::UTF8.GetBytes($manifest.ToString()))
+ } finally {
+ $sha.Dispose()
+ }
+ $hex = [System.Text.StringBuilder]::new()
+ foreach ($b in $sigBytes) { [void]$hex.Append($b.ToString("x2")) }
+ $signature = $hex.ToString()
+ } catch {
+ $signature = ""
+ }
+ if ([string]::IsNullOrEmpty($signature)) {
+ Write-Output "PSSA_TRUST UNVERIFIABLE"
+ exit 6
+ }
+ # Emit the marker NAME, not a path: the shell rebuilds the directory in
+ # its own path form so the approval hint matches the POSIX shell the
+ # user runs mkdir in, while Test-Path here uses the native form — both
+ # views name the same physical directory.
+ $trustDir = Join-Path $env:PSSA_STATE_BASE "trust-approvals" "pssa-$signature"
+ if (-not (Test-Path -LiteralPath $trustDir -PathType Container)) {
+ Write-Output "PSSA_TRUST GATE pssa-$signature"
+ exit 6
+ }
+ }
try {
$file = $env:PSSA_FILE
$settings = $env:PSSA_SETTINGS
@@ -289,6 +645,66 @@ case $PWSH_EXIT in
# commit hook / CI remains the gate for such files.
emit_skipped
;;
+6)
+ # Trust gate — the settings file can make the analyzer execute
+ # repository-supplied code (CustomRulePath), or could not be verified
+ # code-free, and this exact settings-plus-rule-module content state carries
+ # no approval marker. Skip the run with a visible once-per-session notice on
+ # both channels; the notice key carries the state signature so a settings or
+ # rule-module change re-notices within the same session. When the approval
+ # store is unavailable or the state is unverifiable the gate fails closed:
+ # analysis stays disabled rather than trusted. The pwsh block reports the
+ # verdict as a structured PSSA_TRUST line: "GATE " (approvable —
+ # the marker directory is rebuilt here from CLAUDE_PLUGIN_DATA in shell path
+ # form so the mkdir hint runs as printed), "NOSTORE" (no state base),
+ # "UNVERIFIABLE" (settings unparsable, a CustomRulePath entry unresolvable,
+ # or rule content undigestable), or "UNPINNABLE" (a rule module loads code
+ # through a target that cannot be resolved to a file, so no approval can
+ # bind to what would execute).
+ SETTINGS_REL="$SETTINGS_FOUND"
+ [[ -n "$root" ]] && SETTINGS_REL="${SETTINGS_FOUND#"$root"/}"
+ TRUST_VERDICT=""
+ TRUST_MARKER_NAME=""
+ TRUST_DIR=""
+ while IFS= read -r line; do
+ case "$line" in
+ "PSSA_TRUST GATE "*)
+ TRUST_VERDICT="GATE"
+ TRUST_MARKER_NAME="${line#PSSA_TRUST GATE }"
+ ;;
+ "PSSA_TRUST NOSTORE") TRUST_VERDICT="NOSTORE" ;;
+ "PSSA_TRUST UNVERIFIABLE") TRUST_VERDICT="UNVERIFIABLE" ;;
+ "PSSA_TRUST UNPINNABLE") TRUST_VERDICT="UNPINNABLE" ;;
+ *) ;;
+ esac
+ done <<<"$PSSA_OUTPUT"
+ if [[ "$TRUST_VERDICT" == "GATE" && -n "$TRUST_MARKER_NAME" ]]; then
+ trust_state_base="${CLAUDE_PLUGIN_DATA:-}"
+ if command -v cygpath >/dev/null 2>&1 && [[ "$trust_state_base" == [A-Za-z]:\\* ]]; then
+ trust_state_base="$(cygpath -u "$trust_state_base" 2>/dev/null)" || trust_state_base=""
+ fi
+ [[ -n "$trust_state_base" ]] &&
+ TRUST_DIR="${trust_state_base%/}/trust-approvals/$TRUST_MARKER_NAME"
+ fi
+ if [[ "$TRUST_VERDICT" == "GATE" && -n "$TRUST_DIR" ]]; then
+ APPROVE_HINT="Review that file and every rule module it references; to approve this exact settings-and-rule-module content state and enable analysis, run: mkdir -p '$TRUST_DIR' (any change to the settings or a referenced rule module revokes the approval)."
+ NOTICE_KEY="powershell-format-trust-${TRUST_DIR##*[/\\]}"
+ elif [[ "$TRUST_VERDICT" == "UNVERIFIABLE" ]]; then
+ APPROVE_HINT="The settings state cannot be verified (settings unparsable by the restricted data parser, or a declared CustomRulePath entry does not resolve to hashable content), so analysis stays disabled for this repository."
+ NOTICE_KEY="powershell-format-trust-unverifiable"
+ elif [[ "$TRUST_VERDICT" == "UNPINNABLE" ]]; then
+ APPROVE_HINT="A rule module loads code through a target this hook cannot pin to a file (a variable, an env lookup, a composed expression, or an interpolated string it cannot expand), so the code that would execute cannot be bound to an approval and analysis stays disabled for this repository."
+ NOTICE_KEY="powershell-format-trust-unpinnable"
+ else
+ APPROVE_HINT="Approval state is unavailable (CLAUDE_PLUGIN_DATA unset or unusable), so analysis stays disabled for this repository."
+ NOTICE_KEY="powershell-format-trust-nostore"
+ fi
+ if hook::notice_once "$NOTICE_KEY" "$INPUT"; then
+ hook::emit_skip_notice PostToolUse \
+ "powershell-format trust gate: PSScriptAnalyzer run skipped — $SETTINGS_REL declares CustomRulePath (analysis would load and execute repository-supplied rule modules) or cannot be verified code-free. $APPROVE_HINT"
+ fi
+ emit_skipped
+ ;;
*)
# pwsh threw for non-lint reasons (bad settings file, internal error) — no
# judgment was made. Surface via additionalContext (NOT stderr — an advisory
diff --git a/plugins/powershell-format/hooks/powershell-format.test.sh b/plugins/powershell-format/hooks/powershell-format.test.sh
index a9395621f3..195d3894e2 100755
--- a/plugins/powershell-format/hooks/powershell-format.test.sh
+++ b/plugins/powershell-format/hooks/powershell-format.test.sh
@@ -5,7 +5,8 @@
# PSScriptAnalyzer's formatting (alias expansion), surfaces residual findings via
# additionalContext (advisory, exit 0), honors the kill switch, gates on a
# consumer PSScriptAnalyzerSettings.psd1 (present -> run, absent -> leave bytes
-# untouched), degrades cleanly when the PSScriptAnalyzer module is unavailable,
+# untouched), trust-gates a CustomRulePath-declaring settings state on explicit
+# approval, degrades cleanly when the PSScriptAnalyzer module is unavailable,
# and emits a schema-valid telemetry envelope.
#
# Self-contained: builds throwaway git repos with runtime-generated fixtures. The
@@ -350,11 +351,14 @@ else
fail "ANSI file -> bytes changed"
fi
-# --- Case 10: relative CustomRulePath resolves from the settings dir ----------
-# PSScriptAnalyzer resolves a relative CustomRulePath from the current
-# PowerShell location; the hook runs from an unrelated cwd, so it must anchor
-# at the settings directory first. Without that anchor this repo's analysis
-# throws (rule path not found) -> tool break; with it, the format applies.
+# --- Case 10: CustomRulePath trust gate + relative rule path resolution ------
+# A settings file that declares CustomRulePath makes PSScriptAnalyzer load and
+# execute repository-supplied rule modules, so the hook must SKIP the run —
+# with a visible notice on both channels — until the user approves that exact
+# settings-content state; any settings change must revoke the approval. Once
+# approved, the run must succeed from an unrelated cwd, proving the hook
+# anchors relative CustomRulePath resolution at the settings directory
+# (without that anchor this repo's analysis throws -> tool break).
REPO_CRP="$WORK/customrule"
new_repo "$REPO_CRP" NO_SETTINGS
mkdir -p "$REPO_CRP/rules"
@@ -380,13 +384,357 @@ cat >"$REPO_CRP/PSScriptAnalyzerSettings.psd1" <<'EOF'
}
EOF
printf "%s\n" "get-childitem -Path '.'" >"$REPO_CRP/crp.ps1"
-OUT=$(run_hook "$REPO_CRP/crp.ps1")
-RC=$?
-if [[ $RC -eq 0 && -z "$OUT" ]]; then ok "CustomRulePath -> exit 0, no tool break"; else fail "CustomRulePath (rc=$RC out=$OUT)"; fi
+CRP_DATA="$WORK/crp-plugin-data"
+
+OUT_GATE_1=$(run_hook_env "$REPO_CRP/crp.ps1" CLAUDE_PLUGIN_DATA="$CRP_DATA" CLAUDE_PLUGIN_OPTION_POWERSHELL_FORMAT_ENABLED=true)
+RC_GATE_1=$?
+if [[ $RC_GATE_1 -eq 0 ]]; then ok "unapproved CustomRulePath -> exit 0 (advisory)"; else fail "unapproved CustomRulePath exit $RC_GATE_1"; fi
+if printf '%s' "$OUT_GATE_1" | jq -e '(.hookSpecificOutput.additionalContext | contains("trust gate") and contains("CustomRulePath")) and (.systemMessage | contains("trust gate"))' >/dev/null 2>&1; then
+ ok "unapproved CustomRulePath -> trust-gate notice on both channels"
+else
+ fail "trust-gate notice absent: $OUT_GATE_1"
+fi
+if grep -q 'get-childitem' "$REPO_CRP/crp.ps1"; then
+ ok "unapproved CustomRulePath -> analyzer blocked (file untouched)"
+else
+ fail "unapproved CustomRulePath still ran the analyzer: $(cat "$REPO_CRP/crp.ps1")"
+fi
+
+# Same state, same session: the notice dedupes, but the run stays blocked.
+OUT_GATE_2=$(run_hook_env "$REPO_CRP/crp.ps1" CLAUDE_PLUGIN_DATA="$CRP_DATA" CLAUDE_PLUGIN_OPTION_POWERSHELL_FORMAT_ENABLED=true)
+if [[ -z "$OUT_GATE_2" ]] && grep -q 'get-childitem' "$REPO_CRP/crp.ps1"; then
+ ok "unchanged unapproved state notices once, stays blocked"
+else
+ fail "unchanged unapproved state (out=$OUT_GATE_2 file=$(cat "$REPO_CRP/crp.ps1"))"
+fi
+
+# The notice's approval instruction must name a marker under this plugin-data
+# store; creating that marker is the explicit opt-in that enables the run.
+CRP_MARKER="$(printf '%s' "$OUT_GATE_1" | jq -r '.systemMessage' | sed -n "s/.*mkdir -p '\([^']*\)'.*/\1/p")"
+if [[ -n "$CRP_MARKER" && "$CRP_MARKER" == "$CRP_DATA"/* ]]; then
+ ok "trust-gate notice carries an approval marker under CLAUDE_PLUGIN_DATA"
+else
+ fail "trust-gate approval marker missing or misplaced: $OUT_GATE_1"
+fi
+mkdir -p "$CRP_MARKER"
+OUT_GATE_3=$(run_hook_env "$REPO_CRP/crp.ps1" CLAUDE_PLUGIN_DATA="$CRP_DATA" CLAUDE_PLUGIN_OPTION_POWERSHELL_FORMAT_ENABLED=true)
+RC_GATE_3=$?
+if [[ $RC_GATE_3 -eq 0 && -z "$OUT_GATE_3" ]]; then ok "approved CustomRulePath -> exit 0, no tool break, no notice"; else fail "approved CustomRulePath (rc=$RC_GATE_3 out=$OUT_GATE_3)"; fi
if grep -q 'Get-ChildItem' "$REPO_CRP/crp.ps1"; then
- ok "CustomRulePath -> relative rule path resolved, formatter ran"
+ ok "approved CustomRulePath -> relative rule path resolved, formatter ran"
+else
+ fail "approved CustomRulePath -> formatter did not run: $(cat "$REPO_CRP/crp.ps1")"
+fi
+
+# A settings-content change produces a new state signature: the approval is
+# revoked, and the gate blocks — and notices, despite the same session — again.
+printf '%s\n' '# unreviewed settings revision' >>"$REPO_CRP/PSScriptAnalyzerSettings.psd1"
+printf "%s\n" "get-childitem -Path '.'" >"$REPO_CRP/crp2.ps1"
+OUT_GATE_4=$(run_hook_env "$REPO_CRP/crp2.ps1" CLAUDE_PLUGIN_DATA="$CRP_DATA" CLAUDE_PLUGIN_OPTION_POWERSHELL_FORMAT_ENABLED=true)
+if printf '%s' "$OUT_GATE_4" | jq -e '.hookSpecificOutput.additionalContext | contains("trust gate")' >/dev/null 2>&1 &&
+ grep -q 'get-childitem' "$REPO_CRP/crp2.ps1"; then
+ ok "changed settings state revokes approval and blocks again"
+else
+ fail "changed settings state was not re-gated: $OUT_GATE_4"
+fi
+
+# The approval signature must cover the rule-module content, not only the
+# settings text: after approving a settings state, changing a referenced rule
+# module (settings untouched) must revoke the approval — e.g. a branch switch
+# swapping the module bytes under an unchanged settings file.
+CRP_MARKER_2="$(printf '%s' "$OUT_GATE_4" | jq -r '.systemMessage' | sed -n "s/.*mkdir -p '\([^']*\)'.*/\1/p")"
+mkdir -p "$CRP_MARKER_2"
+printf "%s\n" "get-childitem -Path '.'" >"$REPO_CRP/crp2b.ps1"
+OUT_GATE_M1=$(run_hook_env "$REPO_CRP/crp2b.ps1" CLAUDE_PLUGIN_DATA="$CRP_DATA" CLAUDE_PLUGIN_OPTION_POWERSHELL_FORMAT_ENABLED=true)
+if [[ -z "$OUT_GATE_M1" ]] && grep -q 'Get-ChildItem' "$REPO_CRP/crp2b.ps1"; then
+ ok "re-approved settings+module state analyzes again"
+else
+ fail "re-approved settings+module state did not analyze: $OUT_GATE_M1 $(cat "$REPO_CRP/crp2b.ps1")"
+fi
+printf '%s\n' '# unreviewed rule-module revision' >>"$REPO_CRP/rules/CleanRules.psm1"
+printf "%s\n" "get-childitem -Path '.'" >"$REPO_CRP/crp3.ps1"
+OUT_GATE_M2=$(run_hook_env "$REPO_CRP/crp3.ps1" CLAUDE_PLUGIN_DATA="$CRP_DATA" CLAUDE_PLUGIN_OPTION_POWERSHELL_FORMAT_ENABLED=true)
+if printf '%s' "$OUT_GATE_M2" | jq -e '.hookSpecificOutput.additionalContext | contains("trust gate")' >/dev/null 2>&1 &&
+ grep -q 'get-childitem' "$REPO_CRP/crp3.ps1"; then
+ ok "changed rule module revokes approval and blocks again"
+else
+ fail "changed rule module was not re-gated: $OUT_GATE_M2 $(cat "$REPO_CRP/crp3.ps1")"
+fi
+
+# Transitive dependency: a file the rule module references by string literal is
+# part of the code that runs, so it is pinned too — changing ONLY that file
+# (settings and declared module untouched) must revoke the approval.
+cat >>"$REPO_CRP/rules/CleanRules.psm1" <<'EOF'
+$rulesHelper = './helper.ps1'
+EOF
+printf '%s\n' '# helper v1' >"$REPO_CRP/rules/helper.ps1"
+printf "%s\n" "get-childitem -Path '.'" >"$REPO_CRP/crp4.ps1"
+OUT_GATE_T1=$(run_hook_env "$REPO_CRP/crp4.ps1" CLAUDE_PLUGIN_DATA="$CRP_DATA" CLAUDE_PLUGIN_OPTION_POWERSHELL_FORMAT_ENABLED=true)
+T_MARKER="$(printf '%s' "$OUT_GATE_T1" | jq -r '.systemMessage' | sed -n "s/.*mkdir -p '\([^']*\)'.*/\1/p")"
+mkdir -p "$T_MARKER"
+OUT_GATE_T2=$(run_hook_env "$REPO_CRP/crp4.ps1" CLAUDE_PLUGIN_DATA="$CRP_DATA" CLAUDE_PLUGIN_OPTION_POWERSHELL_FORMAT_ENABLED=true)
+if [[ -n "$T_MARKER" && -z "$OUT_GATE_T2" ]] && grep -q 'Get-ChildItem' "$REPO_CRP/crp4.ps1"; then
+ ok "approved state including transitive helper analyzes"
+else
+ fail "approved transitive state did not analyze: m=$T_MARKER out=$OUT_GATE_T2 $(cat "$REPO_CRP/crp4.ps1")"
+fi
+printf '%s\n' '# unreviewed helper revision' >>"$REPO_CRP/rules/helper.ps1"
+printf "%s\n" "get-childitem -Path '.'" >"$REPO_CRP/crp5.ps1"
+OUT_GATE_T3=$(run_hook_env "$REPO_CRP/crp5.ps1" CLAUDE_PLUGIN_DATA="$CRP_DATA" CLAUDE_PLUGIN_OPTION_POWERSHELL_FORMAT_ENABLED=true)
+if printf '%s' "$OUT_GATE_T3" | jq -e '.hookSpecificOutput.additionalContext | contains("trust gate")' >/dev/null 2>&1 &&
+ grep -q 'get-childitem' "$REPO_CRP/crp5.ps1"; then
+ ok "changed transitive helper revokes approval and blocks again"
+else
+ fail "changed transitive helper was not re-gated: $OUT_GATE_T3 $(cat "$REPO_CRP/crp5.ps1")"
+fi
+
+# The standard self-relative dependency form `. "$PSScriptRoot/x.ps1"` resolves
+# against the module's own directory, so the scan must pin it there: changing
+# only that helper must revoke the approval.
+cat >>"$REPO_CRP/rules/CleanRules.psm1" <<'EOF'
+. "$PSScriptRoot/helper2.ps1"
+EOF
+printf '%s\n' 'function Get-CrpHelperTwo { return 2 }' >"$REPO_CRP/rules/helper2.ps1"
+printf "%s\n" "get-childitem -Path '.'" >"$REPO_CRP/crp6.ps1"
+OUT_GATE_P1=$(run_hook_env "$REPO_CRP/crp6.ps1" CLAUDE_PLUGIN_DATA="$CRP_DATA" CLAUDE_PLUGIN_OPTION_POWERSHELL_FORMAT_ENABLED=true)
+P_MARKER="$(printf '%s' "$OUT_GATE_P1" | jq -r '.systemMessage' | sed -n "s/.*mkdir -p '\([^']*\)'.*/\1/p")"
+mkdir -p "$P_MARKER"
+OUT_GATE_P2=$(run_hook_env "$REPO_CRP/crp6.ps1" CLAUDE_PLUGIN_DATA="$CRP_DATA" CLAUDE_PLUGIN_OPTION_POWERSHELL_FORMAT_ENABLED=true)
+if [[ -n "$P_MARKER" && -z "$OUT_GATE_P2" ]] && grep -q 'Get-ChildItem' "$REPO_CRP/crp6.ps1"; then
+ ok "approved state including PSScriptRoot-relative helper analyzes"
+else
+ fail "approved PSScriptRoot-relative state did not analyze: m=$P_MARKER out=$OUT_GATE_P2 $(cat "$REPO_CRP/crp6.ps1")"
+fi
+printf '%s\n' '# unreviewed helper2 revision' >>"$REPO_CRP/rules/helper2.ps1"
+printf "%s\n" "get-childitem -Path '.'" >"$REPO_CRP/crp7.ps1"
+OUT_GATE_P3=$(run_hook_env "$REPO_CRP/crp7.ps1" CLAUDE_PLUGIN_DATA="$CRP_DATA" CLAUDE_PLUGIN_OPTION_POWERSHELL_FORMAT_ENABLED=true)
+if printf '%s' "$OUT_GATE_P3" | jq -e '.hookSpecificOutput.additionalContext | contains("trust gate")' >/dev/null 2>&1 &&
+ grep -q 'get-childitem' "$REPO_CRP/crp7.ps1"; then
+ ok "changed PSScriptRoot-relative helper revokes approval and blocks again"
+else
+ fail "changed PSScriptRoot-relative helper was not re-gated: $OUT_GATE_P3 $(cat "$REPO_CRP/crp7.ps1")"
+fi
+
+# $PSScriptRoot mid-string, not just as a prefix: still pinned, so changing only
+# that helper revokes the approval.
+cat >>"$REPO_CRP/rules/CleanRules.psm1" <<'EOF'
+. "$PSScriptRoot/../rules/helper3.ps1"
+EOF
+printf '%s\n' 'function Get-CrpHelperThree { return 3 }' >"$REPO_CRP/rules/helper3.ps1"
+printf "%s\n" "get-childitem -Path '.'" >"$REPO_CRP/crp8.ps1"
+OUT_GATE_R1=$(run_hook_env "$REPO_CRP/crp8.ps1" CLAUDE_PLUGIN_DATA="$CRP_DATA" CLAUDE_PLUGIN_OPTION_POWERSHELL_FORMAT_ENABLED=true)
+R_MARKER="$(printf '%s' "$OUT_GATE_R1" | jq -r '.systemMessage' | sed -n "s/.*mkdir -p '\([^']*\)'.*/\1/p")"
+mkdir -p "$R_MARKER"
+printf '%s\n' '# unreviewed helper3 revision' >>"$REPO_CRP/rules/helper3.ps1"
+printf "%s\n" "get-childitem -Path '.'" >"$REPO_CRP/crp9.ps1"
+OUT_GATE_R2=$(run_hook_env "$REPO_CRP/crp9.ps1" CLAUDE_PLUGIN_DATA="$CRP_DATA" CLAUDE_PLUGIN_OPTION_POWERSHELL_FORMAT_ENABLED=true)
+if [[ -n "$R_MARKER" ]] && printf '%s' "$OUT_GATE_R2" | jq -e '.hookSpecificOutput.additionalContext | contains("trust gate")' >/dev/null 2>&1 &&
+ grep -q 'get-childitem' "$REPO_CRP/crp9.ps1"; then
+ ok "non-prefix \$PSScriptRoot reference is pinned; helper change revokes"
+else
+ fail "non-prefix \$PSScriptRoot reference not pinned: m=$R_MARKER out=$OUT_GATE_R2 $(cat "$REPO_CRP/crp9.ps1")"
+fi
+
+# PowerShell does not require quotes around a command argument, so an UNQUOTED
+# load target is invisible to a quoted-literal scan. The parser reports it, so it
+# must be pinned from there: changing only that helper revokes the approval.
+# Without this the target would be judged pinnable and then never pinned.
+cat >>"$REPO_CRP/rules/CleanRules.psm1" <<'EOF'
+. $PSScriptRoot\helper4.ps1
+EOF
+printf '%s\n' 'function Get-CrpHelperFour { return 4 }' >"$REPO_CRP/rules/helper4.ps1"
+printf "%s\n" "get-childitem -Path '.'" >"$REPO_CRP/crp10.ps1"
+OUT_GATE_Q1=$(run_hook_env "$REPO_CRP/crp10.ps1" CLAUDE_PLUGIN_DATA="$CRP_DATA" CLAUDE_PLUGIN_OPTION_POWERSHELL_FORMAT_ENABLED=true)
+Q_MARKER="$(printf '%s' "$OUT_GATE_Q1" | jq -r '.systemMessage' | sed -n "s/.*mkdir -p '\([^']*\)'.*/\1/p")"
+mkdir -p "$Q_MARKER"
+printf '%s\n' '# unreviewed helper4 revision' >>"$REPO_CRP/rules/helper4.ps1"
+printf "%s\n" "get-childitem -Path '.'" >"$REPO_CRP/crp11.ps1"
+OUT_GATE_Q2=$(run_hook_env "$REPO_CRP/crp11.ps1" CLAUDE_PLUGIN_DATA="$CRP_DATA" CLAUDE_PLUGIN_OPTION_POWERSHELL_FORMAT_ENABLED=true)
+if [[ -n "$Q_MARKER" ]] && printf '%s' "$OUT_GATE_Q2" | jq -e '.hookSpecificOutput.additionalContext | contains("trust gate")' >/dev/null 2>&1 &&
+ grep -q 'get-childitem' "$REPO_CRP/crp11.ps1"; then
+ ok "unquoted load target is pinned; helper change revokes approval"
+else
+ fail "unquoted load target not pinned: m=$Q_MARKER out=$OUT_GATE_Q2 $(cat "$REPO_CRP/crp11.ps1")"
+fi
+
+# Two load shapes the collector must reach, both hosted in a module the rule
+# module names by literal rather than appended to the rule module itself:
+# `using` statements must LEAD their file, and hosting the fixtures separately
+# also keeps PSScriptAnalyzer's own load of the rule module free of side effects
+# while still exercising the scan (the collector pins the host by literal, then
+# scans it).
+# using module a UsingStatementAst rather than a command, and needing no
+# quotes, so neither the command walk nor the text scan sees
+# it.
+# `using namespace` names no repository file and must stay
+# approvable, so it rides along as the negative control.
+# extensionless Import-Module resolves through PowerShell module resolution,
+# so pinning only an exact leaf would miss the file that runs:
+# a sibling LeafMod.psm1, and a DirMod directory whose
+# DirMod.psd1 is the manifest PowerShell loads.
+mkdir -p "$REPO_CRP/rules/deps" "$REPO_CRP/rules/DirMod"
+printf '%s\n' 'function Get-CrpUsingDep { return 5 }' >"$REPO_CRP/rules/deps/UsingDep.psm1"
+printf '%s\n' '@{ ModuleVersion = "1.0" }' >"$REPO_CRP/rules/DirMod/DirMod.psd1"
+printf '%s\n' 'function Get-CrpLeafMod { return 6 }' >"$REPO_CRP/rules/LeafMod.psm1"
+cat >"$REPO_CRP/rules/LoadHost.psm1" <<'EOF'
+using namespace System.Collections
+using module ./deps/UsingDep.psm1
+Import-Module "$PSScriptRoot/LeafMod"
+Import-Module "$PSScriptRoot/DirMod"
+function Get-CrpLoadHost { return 7 }
+EOF
+cat >>"$REPO_CRP/rules/CleanRules.psm1" <<'EOF'
+$loadHost = './LoadHost.psm1'
+EOF
+n=0
+for target in \
+ "$REPO_CRP/rules/deps/UsingDep.psm1" \
+ "$REPO_CRP/rules/LeafMod.psm1" \
+ "$REPO_CRP/rules/DirMod/DirMod.psd1"; do
+ n=$((n + 1))
+ # A distinct session per invocation: consecutive iterations begin in the state
+ # the previous one ended in, so a shared session would dedupe the notice and
+ # the marker extraction would read an empty payload as a missing gate.
+ printf "%s\n" "get-childitem -Path '.'" >"$REPO_CRP/crp-ext$n-a.ps1"
+ OUT_EXT1="$(cd "$UNRELATED" && printf '{"session_id":"dep-%s-a","tool_input":{"file_path":"%s"},"tool_name":"Write"}' "$n" "$REPO_CRP/crp-ext$n-a.ps1" |
+ env -u CLAUDE_PROJECT_DIR CLAUDE_PLUGIN_DATA="$CRP_DATA" CLAUDE_PLUGIN_OPTION_POWERSHELL_FORMAT_ENABLED=true bash "$HOOK")"
+ E_MARKER="$(printf '%s' "$OUT_EXT1" | jq -r '.systemMessage' | sed -n "s/.*mkdir -p '\([^']*\)'.*/\1/p")"
+ mkdir -p "$E_MARKER"
+ printf '%s\n' '# unreviewed dependency revision' >>"$target"
+ printf "%s\n" "get-childitem -Path '.'" >"$REPO_CRP/crp-ext$n-b.ps1"
+ OUT_EXT2="$(cd "$UNRELATED" && printf '{"session_id":"dep-%s-b","tool_input":{"file_path":"%s"},"tool_name":"Write"}' "$n" "$REPO_CRP/crp-ext$n-b.ps1" |
+ env -u CLAUDE_PROJECT_DIR CLAUDE_PLUGIN_DATA="$CRP_DATA" CLAUDE_PLUGIN_OPTION_POWERSHELL_FORMAT_ENABLED=true bash "$HOOK")"
+ if [[ -n "$E_MARKER" ]] && printf '%s' "$OUT_EXT2" | jq -e '.hookSpecificOutput.additionalContext | contains("trust gate")' >/dev/null 2>&1 &&
+ grep -q 'get-childitem' "$REPO_CRP/crp-ext$n-b.ps1"; then
+ ok "host-module dependency $(basename "$target") is pinned; its change revokes"
+ else
+ fail "host-module dependency $target not pinned: m=$E_MARKER out=$OUT_EXT2"
+ fi
+done
+
+# `using assembly ` loads a repository DLL, so its path is collected like a
+# `using module` path. Where the assembly cannot actually be loaded the parser
+# reports it as a parse error, which the gate already treats as unverifiable, so
+# BOTH directions are closed: a loadable assembly is pinned, an unloadable one
+# refuses approval. This asserts the unloadable direction, the one a fixture can
+# create portably (a real assembly would need a compiler at test time).
+cp "$REPO_CRP/rules/LoadHost.psm1" "$WORK/LoadHost.psm1.bak"
+printf '%s\n' 'not-a-real-assembly' >"$REPO_CRP/rules/deps/UsingAsm.dll"
+{
+ printf '%s\n' 'using assembly ./deps/UsingAsm.dll'
+ cat "$WORK/LoadHost.psm1.bak"
+} >"$REPO_CRP/rules/LoadHost.psm1"
+printf "%s\n" "get-childitem -Path '.'" >"$REPO_CRP/crp-asm.ps1"
+OUT_ASM="$(cd "$UNRELATED" && printf '{"session_id":"using-assembly","tool_input":{"file_path":"%s"},"tool_name":"Write"}' "$REPO_CRP/crp-asm.ps1" |
+ env -u CLAUDE_PROJECT_DIR CLAUDE_PLUGIN_DATA="$CRP_DATA" CLAUDE_PLUGIN_OPTION_POWERSHELL_FORMAT_ENABLED=true bash "$HOOK")"
+if printf '%s' "$OUT_ASM" | jq -e '(.systemMessage | contains("trust gate")) and (.systemMessage | contains("mkdir -p") | not)' >/dev/null 2>&1 &&
+ grep -q 'get-childitem' "$REPO_CRP/crp-asm.ps1"; then
+ ok "using assembly naming an unloadable DLL refuses approval"
+else
+ fail "using assembly was not refused: $OUT_ASM"
+fi
+cp "$WORK/LoadHost.psm1.bak" "$REPO_CRP/rules/LoadHost.psm1"
+rm -f "$REPO_CRP/rules/deps/UsingAsm.dll"
+
+# A load target the scan cannot resolve to a file cannot be bound to any
+# approval, so each of these must gate AND refuse approval (no mkdir hint)
+# rather than sign a signature that omits the code that would execute. Each
+# form defeats a text-pattern or prefix-expansion approach on its own:
+# composed . (Join-Path $PSScriptRoot "deps" "helper.ps1") — the literals
+# are scanned separately and neither resolves beside the module
+# envvar the target comes from the environment
+# othervar an interpolated string holding a variable that is not expandable
+# bareimport Import-Module with no extension, so extension matching cannot see it
+# lookalike a DIFFERENT variable whose name merely starts with PSScriptRoot,
+# which an unbounded expansion would rewrite and then call pinnable
+# pipeline the loader takes its source from the UPSTREAM pipeline element,
+# so its own arguments are just a constant command name
+for form in composed envvar othervar bareimport lookalike pipeline; do
+ cp "$REPO_CRP/rules/CleanRules.psm1" "$WORK/CleanRules.psm1.bak"
+ case "$form" in
+ composed)
+ mkdir -p "$REPO_CRP/rules/deps"
+ printf '%s\n' 'function Get-CrpDep { return 4 }' >"$REPO_CRP/rules/deps/helper.ps1"
+ cat >>"$REPO_CRP/rules/CleanRules.psm1" <<'EOF'
+. (Join-Path $PSScriptRoot "deps" "helper.ps1")
+EOF
+ ;;
+ envvar)
+ cat >>"$REPO_CRP/rules/CleanRules.psm1" <<'EOF'
+Import-Module $env:PSSA_EXTRA_RULES
+EOF
+ ;;
+ othervar)
+ cat >>"$REPO_CRP/rules/CleanRules.psm1" <<'EOF'
+$rulesHome = $env:PSSA_RULES_HOME
+. "$rulesHome/helper.ps1"
+EOF
+ ;;
+ bareimport)
+ cat >>"$REPO_CRP/rules/CleanRules.psm1" <<'EOF'
+$modRoot = $env:PSSA_MOD_ROOT
+Import-Module "$modRoot/MyModule"
+EOF
+ ;;
+ lookalike)
+ cat >>"$REPO_CRP/rules/CleanRules.psm1" <<'EOF'
+$PSScriptRootFoo = $env:PSSA_ALT_ROOT
+. "$PSScriptRootFoo/helper.ps1"
+EOF
+ ;;
+ pipeline)
+ cat >>"$REPO_CRP/rules/CleanRules.psm1" <<'EOF'
+Get-Content (Join-Path $PSScriptRoot "deps" "PipeSrc.ps1") -Raw | Invoke-Expression
+EOF
+ ;;
+ *) fail "unknown unpinnable fixture: $form" ;;
+ esac
+ printf "%s\n" "get-childitem -Path '.'" >"$REPO_CRP/crp-$form.ps1"
+ # A distinct session per form: every unpinnable state shares one notice key
+ # (it carries no signature), so a single session would dedupe all but the
+ # first and the assertion would read an empty payload as a missing gate.
+ OUT_UNPIN="$(cd "$UNRELATED" && printf '{"session_id":"unpin-%s","tool_input":{"file_path":"%s"},"tool_name":"Write"}' "$form" "$REPO_CRP/crp-$form.ps1" |
+ env -u CLAUDE_PROJECT_DIR CLAUDE_PLUGIN_DATA="$CRP_DATA" CLAUDE_PLUGIN_OPTION_POWERSHELL_FORMAT_ENABLED=true bash "$HOOK")"
+ if printf '%s' "$OUT_UNPIN" | jq -e '(.systemMessage | contains("trust gate") and contains("cannot pin")) and (.systemMessage | contains("mkdir -p") | not)' >/dev/null 2>&1 &&
+ grep -q 'get-childitem' "$REPO_CRP/crp-$form.ps1"; then
+ ok "unpinnable load target ($form) gates and refuses approval"
+ else
+ fail "unpinnable load target ($form) was not refused: $OUT_UNPIN $(cat "$REPO_CRP/crp-$form.ps1")"
+ fi
+ cp "$WORK/CleanRules.psm1.bak" "$REPO_CRP/rules/CleanRules.psm1"
+done
+rm -rf "$REPO_CRP/rules/deps"
+
+# Fail closed: with no CLAUDE_PLUGIN_DATA an approval can be neither recorded
+# nor verified, so a CustomRulePath settings state must still skip the run (and
+# notice every time — the once-per-session gate fails open toward visibility
+# when it has no marker store).
+OUT_GATE_5=$(run_hook_env "$REPO_CRP/crp2.ps1" -u CLAUDE_PLUGIN_DATA CLAUDE_PLUGIN_OPTION_POWERSHELL_FORMAT_ENABLED=true)
+if printf '%s' "$OUT_GATE_5" | jq -e '.systemMessage | contains("trust gate")' >/dev/null 2>&1 &&
+ grep -q 'get-childitem' "$REPO_CRP/crp2.ps1"; then
+ ok "CustomRulePath without a plugin-data store fails closed"
+else
+ fail "CustomRulePath without a plugin-data store did not fail closed: $OUT_GATE_5"
+fi
+
+# Textual-scan evasion: key detection uses PowerShell's restricted data-file
+# parser, so a backtick-escaped double-quoted key ("CustomRule`Path" evaluates
+# to CustomRulePath) must still gate — a grep for the literal key would miss it.
+REPO_ESC="$WORK/customrule-escaped"
+new_repo "$REPO_ESC" NO_SETTINGS
+cat >"$REPO_ESC/PSScriptAnalyzerSettings.psd1" <<'EOF'
+@{
+ "CustomRule`Path" = './rules/CleanRules.psm1'
+ IncludeRules = @('PSUseCorrectCasing')
+}
+EOF
+printf "%s\n" "get-childitem -Path '.'" >"$REPO_ESC/esc.ps1"
+OUT_GATE_6=$(run_hook_env "$REPO_ESC/esc.ps1" CLAUDE_PLUGIN_DATA="$CRP_DATA" CLAUDE_PLUGIN_OPTION_POWERSHELL_FORMAT_ENABLED=true)
+if printf '%s' "$OUT_GATE_6" | jq -e '.systemMessage | contains("trust gate")' >/dev/null 2>&1 &&
+ grep -q 'get-childitem' "$REPO_ESC/esc.ps1"; then
+ ok "escaped CustomRulePath key still gates (restricted-parser detection)"
else
- fail "CustomRulePath -> formatter did not run: $(cat "$REPO_CRP/crp.ps1")"
+ fail "escaped CustomRulePath key evaded the gate: $OUT_GATE_6"
fi
# ============================================================================
diff --git a/plugins/powershell-format/skills/setup/SKILL.md b/plugins/powershell-format/skills/setup/SKILL.md
index e1003ede80..6d3adfed12 100644
--- a/plugins/powershell-format/skills/setup/SKILL.md
+++ b/plugins/powershell-format/skills/setup/SKILL.md
@@ -58,9 +58,10 @@ restores the FAIL semantics.
is the opt-out and is **by design, not a defect** — the plugin is inert until a repo adopts
a settings file. Report whether one exists and its location. When one exists, surface the
README **Trust model**: the settings file is executed-adjacent configuration — a
- `CustomRulePath` it declares is loaded and run during analysis on every edit — so it
- carries the same trust as build/CI configuration. Do not enable this plugin against an
- untrusted working tree.
+ `CustomRulePath` it declares would load and run repository-supplied rule modules during
+ analysis, so the hook gates such a settings state on an explicit per-content trust
+ approval (marker under `${CLAUDE_PLUGIN_DATA}/trust-approvals`; any settings change
+ revokes it). It carries the same trust as build/CI configuration.
6. **Hook toggle** — report the effective `powershell_format_enabled` value:
`${user_config.powershell_format_enabled}` (unexpanded or empty means default `true`; any
value other than `true` disables the hook).
diff --git a/plugins/rate-limit-guard/.claude-plugin/plugin.json b/plugins/rate-limit-guard/.claude-plugin/plugin.json
index 66df0ae1f3..be11c31174 100644
--- a/plugins/rate-limit-guard/.claude-plugin/plugin.json
+++ b/plugins/rate-limit-guard/.claude-plugin/plugin.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
"name": "rate-limit-guard",
- "version": "0.3.1",
+ "version": "0.3.2",
"description": "Shared rate-limit guard for loop lanes: a statusline wrapper tees the subscription rate-limit windows to a fixed machine-scope file, a StopFailure hook records rate-limit stops reactively, and a reader contract fixes how consuming sessions pause and resume.",
"author": {
"name": "Melodic Software",
diff --git a/plugins/rate-limit-guard/CHANGELOG.md b/plugins/rate-limit-guard/CHANGELOG.md
index 73902e5e28..b0bd507554 100644
--- a/plugins/rate-limit-guard/CHANGELOG.md
+++ b/plugins/rate-limit-guard/CHANGELOG.md
@@ -3,6 +3,22 @@
All notable changes to the `rate-limit-guard` plugin are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning.
+## [0.3.2]
+
+### Fixed
+
+- **Shared `hook-utils.sh`: a bare or trailing unquoted `NAME=value` Bash
+ command no longer leaks the assignment value into the privacy-safe
+ telemetry/audit subject.** `hook::extract_bash_subject` stripped a leading
+ `VAR=value` prefix only when a following command word consumed it, so a
+ command whose LAST token was an unquoted assignment (e.g. `TOKEN=ghp_…`)
+ survived to the subject and emitted `Bash:TOKEN=ghp_…` into
+ `hook-events.jsonl` and any wired `HOOK_TELEMETRY_SINK`. A resolved token
+ still shaped like a shell assignment now bails to the bare `Bash` subject,
+ matching the existing quoted-value bail (`VAR=x cmd` still reduces to
+ `Bash:cmd`). Synced from `lib/hook-utils.sh`; the subject is
+ telemetry/audit-only, so no hook block/allow behavior changes.
+
## [0.3.1]
### Fixed
diff --git a/plugins/rate-limit-guard/hooks/hook-utils.sh b/plugins/rate-limit-guard/hooks/hook-utils.sh
index b854e2598c..411c4a6731 100755
--- a/plugins/rate-limit-guard/hooks/hook-utils.sh
+++ b/plugins/rate-limit-guard/hooks/hook-utils.sh
@@ -279,6 +279,11 @@ hook::jq_field() {
# A quoted assignment value (e.g. `TOKEN="a b" curl …`) would otherwise leak a
# fragment of the value into the token, so any token carrying a quote aborts to a
# bare "Bash" subject rather than risk exposing part of the value.
+#
+# A bare or trailing unquoted assignment that no following command consumed
+# (e.g. the whole command is `TOKEN=ghp_…`) is likewise a value the subject must
+# not carry, so a resolved token still shaped like a NAME=value assignment aborts
+# to the bare "Bash" subject too.
# SUBJECT=$(hook::extract_bash_subject "$TOOL" "$CMD")
hook::extract_bash_subject() {
local tool="$1" cmd="${2:-}"
@@ -307,6 +312,19 @@ hook::extract_bash_subject() {
printf '%s' "$tool"
return 0
fi
+ # A resolved token still shaped like a bare/trailing assignment (no following
+ # command word consumed it in the strip loop) would emit the assignment's
+ # value — a possible credential — as the subject; bail to the bare "Bash"
+ # subject as with a quoted value. All valid Bash assignment forms count:
+ # NAME=value, append NAME+=value, and subscripted NAME[idx]=value /
+ # NAME[idx]+=value — the subscript matched greedily (`.*`) because Bash
+ # accepts nested subscripts like NAME[1+IDX[0]]=value, which a
+ # no-close-bracket class would miss. This runs BEFORE the basename strip so
+ # a path-valued assignment (TOKEN=/a/b/secret) cannot lose its "=" first.
+ if [[ "$first_token" =~ ^[a-zA-Z_][a-zA-Z0-9_]*(\[.*\])?\+?= ]]; then
+ printf '%s' "$tool"
+ return 0
+ fi
first_token="${first_token##*/}"
if [[ -n "$first_token" ]]; then
printf 'Bash:%s' "$first_token"
diff --git a/plugins/ruff-format/.claude-plugin/plugin.json b/plugins/ruff-format/.claude-plugin/plugin.json
index 79e060d1a2..9faec446a3 100644
--- a/plugins/ruff-format/.claude-plugin/plugin.json
+++ b/plugins/ruff-format/.claude-plugin/plugin.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
"name": "ruff-format",
- "version": "0.5.2",
+ "version": "0.5.3",
"description": "Auto-format and lint Python on edit via Ruff, only when a Ruff config governs the repo — using the consuming repo's own Ruff config.",
"author": {
"name": "Melodic Software",
diff --git a/plugins/ruff-format/CHANGELOG.md b/plugins/ruff-format/CHANGELOG.md
index 395efd310f..5b4788acab 100644
--- a/plugins/ruff-format/CHANGELOG.md
+++ b/plugins/ruff-format/CHANGELOG.md
@@ -3,6 +3,22 @@
All notable changes to the `ruff-format` plugin are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning.
+## [0.5.3]
+
+### Fixed
+
+- **Shared `hook-utils.sh`: a bare or trailing unquoted `NAME=value` Bash
+ command no longer leaks the assignment value into the privacy-safe
+ telemetry/audit subject.** `hook::extract_bash_subject` stripped a leading
+ `VAR=value` prefix only when a following command word consumed it, so a
+ command whose LAST token was an unquoted assignment (e.g. `TOKEN=ghp_…`)
+ survived to the subject and emitted `Bash:TOKEN=ghp_…` into
+ `hook-events.jsonl` and any wired `HOOK_TELEMETRY_SINK`. A resolved token
+ still shaped like a shell assignment now bails to the bare `Bash` subject,
+ matching the existing quoted-value bail (`VAR=x cmd` still reduces to
+ `Bash:cmd`). Synced from `lib/hook-utils.sh`; the subject is
+ telemetry/audit-only, so no guard or formatter block/allow behavior changes.
+
## [0.5.2]
### Fixed
diff --git a/plugins/ruff-format/hooks/hook-utils.sh b/plugins/ruff-format/hooks/hook-utils.sh
index b854e2598c..411c4a6731 100644
--- a/plugins/ruff-format/hooks/hook-utils.sh
+++ b/plugins/ruff-format/hooks/hook-utils.sh
@@ -279,6 +279,11 @@ hook::jq_field() {
# A quoted assignment value (e.g. `TOKEN="a b" curl …`) would otherwise leak a
# fragment of the value into the token, so any token carrying a quote aborts to a
# bare "Bash" subject rather than risk exposing part of the value.
+#
+# A bare or trailing unquoted assignment that no following command consumed
+# (e.g. the whole command is `TOKEN=ghp_…`) is likewise a value the subject must
+# not carry, so a resolved token still shaped like a NAME=value assignment aborts
+# to the bare "Bash" subject too.
# SUBJECT=$(hook::extract_bash_subject "$TOOL" "$CMD")
hook::extract_bash_subject() {
local tool="$1" cmd="${2:-}"
@@ -307,6 +312,19 @@ hook::extract_bash_subject() {
printf '%s' "$tool"
return 0
fi
+ # A resolved token still shaped like a bare/trailing assignment (no following
+ # command word consumed it in the strip loop) would emit the assignment's
+ # value — a possible credential — as the subject; bail to the bare "Bash"
+ # subject as with a quoted value. All valid Bash assignment forms count:
+ # NAME=value, append NAME+=value, and subscripted NAME[idx]=value /
+ # NAME[idx]+=value — the subscript matched greedily (`.*`) because Bash
+ # accepts nested subscripts like NAME[1+IDX[0]]=value, which a
+ # no-close-bracket class would miss. This runs BEFORE the basename strip so
+ # a path-valued assignment (TOKEN=/a/b/secret) cannot lose its "=" first.
+ if [[ "$first_token" =~ ^[a-zA-Z_][a-zA-Z0-9_]*(\[.*\])?\+?= ]]; then
+ printf '%s' "$tool"
+ return 0
+ fi
first_token="${first_token##*/}"
if [[ -n "$first_token" ]]; then
printf 'Bash:%s' "$first_token"
diff --git a/plugins/typos-format/.claude-plugin/plugin.json b/plugins/typos-format/.claude-plugin/plugin.json
index f1723f2bde..e82f39520c 100644
--- a/plugins/typos-format/.claude-plugin/plugin.json
+++ b/plugins/typos-format/.claude-plugin/plugin.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
"name": "typos-format",
- "version": "0.3.2",
+ "version": "0.3.3",
"description": "Auto-fix spelling typos on edit via typos-cli, unconditionally — honoring the consuming repo's own typos configuration when one is present.",
"author": {
"name": "Melodic Software",
diff --git a/plugins/typos-format/CHANGELOG.md b/plugins/typos-format/CHANGELOG.md
index 826a43b02e..e83ee1e223 100644
--- a/plugins/typos-format/CHANGELOG.md
+++ b/plugins/typos-format/CHANGELOG.md
@@ -3,6 +3,22 @@
All notable changes to the `typos-format` plugin are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning.
+## [0.3.3]
+
+### Fixed
+
+- **Shared `hook-utils.sh`: a bare or trailing unquoted `NAME=value` Bash
+ command no longer leaks the assignment value into the privacy-safe
+ telemetry/audit subject.** `hook::extract_bash_subject` stripped a leading
+ `VAR=value` prefix only when a following command word consumed it, so a
+ command whose LAST token was an unquoted assignment (e.g. `TOKEN=ghp_…`)
+ survived to the subject and emitted `Bash:TOKEN=ghp_…` into
+ `hook-events.jsonl` and any wired `HOOK_TELEMETRY_SINK`. A resolved token
+ still shaped like a shell assignment now bails to the bare `Bash` subject,
+ matching the existing quoted-value bail (`VAR=x cmd` still reduces to
+ `Bash:cmd`). Synced from `lib/hook-utils.sh`; the subject is
+ telemetry/audit-only, so no guard or formatter block/allow behavior changes.
+
## [0.3.2]
### Fixed
diff --git a/plugins/typos-format/hooks/hook-utils.sh b/plugins/typos-format/hooks/hook-utils.sh
index b854e2598c..411c4a6731 100644
--- a/plugins/typos-format/hooks/hook-utils.sh
+++ b/plugins/typos-format/hooks/hook-utils.sh
@@ -279,6 +279,11 @@ hook::jq_field() {
# A quoted assignment value (e.g. `TOKEN="a b" curl …`) would otherwise leak a
# fragment of the value into the token, so any token carrying a quote aborts to a
# bare "Bash" subject rather than risk exposing part of the value.
+#
+# A bare or trailing unquoted assignment that no following command consumed
+# (e.g. the whole command is `TOKEN=ghp_…`) is likewise a value the subject must
+# not carry, so a resolved token still shaped like a NAME=value assignment aborts
+# to the bare "Bash" subject too.
# SUBJECT=$(hook::extract_bash_subject "$TOOL" "$CMD")
hook::extract_bash_subject() {
local tool="$1" cmd="${2:-}"
@@ -307,6 +312,19 @@ hook::extract_bash_subject() {
printf '%s' "$tool"
return 0
fi
+ # A resolved token still shaped like a bare/trailing assignment (no following
+ # command word consumed it in the strip loop) would emit the assignment's
+ # value — a possible credential — as the subject; bail to the bare "Bash"
+ # subject as with a quoted value. All valid Bash assignment forms count:
+ # NAME=value, append NAME+=value, and subscripted NAME[idx]=value /
+ # NAME[idx]+=value — the subscript matched greedily (`.*`) because Bash
+ # accepts nested subscripts like NAME[1+IDX[0]]=value, which a
+ # no-close-bracket class would miss. This runs BEFORE the basename strip so
+ # a path-valued assignment (TOKEN=/a/b/secret) cannot lose its "=" first.
+ if [[ "$first_token" =~ ^[a-zA-Z_][a-zA-Z0-9_]*(\[.*\])?\+?= ]]; then
+ printf '%s' "$tool"
+ return 0
+ fi
first_token="${first_token##*/}"
if [[ -n "$first_token" ]]; then
printf 'Bash:%s' "$first_token"