Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/CATALOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ plugin manifests and kept in sync by CI — never hand-edit it; the category voc

## Security

- [`guardrails`](../plugins/guardrails) — Thirteen 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, Windows drive-root /tmp writes (POSIX /tmp, C:\tmp, \tmp), multi-line `git commit -m` messages (an actual-newline `-m` mangles across shells; single-line `-m` passes), 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) markdown citing a repo path the repo's own history shows was removed, (advisory, opt-in) un-throttled Workflow fan-out that risks burst 529s, and (advisory, opt-in) direct gh pr create calls bypassing this marketplace's own pull-request skill — each independently toggleable.
- [`guardrails`](../plugins/guardrails) — Fourteen 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, Windows drive-root /tmp writes (POSIX /tmp, C:\tmp, \tmp), an EXPORTED MSYS_NO_PATHCONV / MSYS2_ARG_CONV_EXCL that unconverts a later path argument on Windows, multi-line `git commit -m` messages (an actual-newline `-m` mangles across shells; single-line `-m` passes), 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) markdown citing a repo path the repo's own history shows was removed, (advisory, opt-in) un-throttled Workflow fan-out that risks burst 529s, and (advisory, opt-in) direct gh pr create calls bypassing this marketplace's own pull-request skill — each independently toggleable.

## Workflow

Expand Down
2 changes: 2 additions & 0 deletions docs/conventions/hook-telemetry/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,8 @@ producers without coordinating with them or each other.
| `guardrails` plugin | `block-noncanonical-commit` | `data/block-noncanonical-commit.schema.json` |
| `guardrails` plugin | `flag-commit-pr-skill-bypass` | `data/flag-commit-pr-skill-bypass.schema.json` |
| `guardrails` plugin | `workflow-resilience-check` | `data/workflow-resilience-check.schema.json` |
| `guardrails` plugin | `block-windows-drive-tmp` | `data/block-windows-drive-tmp.schema.json` |
| `guardrails` plugin | `block-exported-msys-pathconv` | `data/block-exported-msys-pathconv.schema.json` |
| `claude-ops` plugin | `api-error-audit` | `data/api-error-audit.schema.json` |
| `claude-ops` plugin | `config-change-audit` | `data/config-change-audit.schema.json` |
| `claude-ops` plugin | `instructions-loaded-audit` | `data/instructions-loaded-audit.schema.json` |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://raw.githubusercontent.com/melodic-software/claude-code-plugins/main/docs/conventions/hook-telemetry/data/block-exported-msys-pathconv.schema.json",
"title": "block-exported-msys-pathconv telemetry data",
"description": "Per-hook `data` payload for the block-exported-msys-pathconv guard. Discovered from the envelope `hook` value \"block-exported-msys-pathconv\". Evolves additive-only. Carries NO full command string.",
"type": "object",
"required": ["tool", "subject", "form"],
"additionalProperties": true,
"properties": {
"tool": {
"type": "string",
"description": "The invoking tool — \"Bash\" or \"PowerShell\"; this guard matches both."
},
"subject": {
"type": "string",
"description": "Privacy-safe command subject: `Bash:<first-token>` for a Bash call, with leading sudo / env-assignment prefixes stripped and the token basenamed; the bare tool name (`PowerShell`) for a PowerShell call, which is not tokenized. NEVER the full command or its arguments."
},
"form": {
"type": "string",
"description": "The matched form when blocked: \"export\" (an exported MSYS_NO_PATHCONV / MSYS2_ARG_CONV_EXCL, including the declare -x / typeset -x spellings) | \"child-shell\" (a per-command prefix whose command word is a shell, which leaks the suppressor into every command inside that child) | \"too-long\" (command named one of the variables but exceeded the parse cap, so it was blocked fail-closed). Empty string when the command was allowed (status ok)."
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"schema_version": "1.0",
"timestamp": "2026-08-16T20:46:30Z",
"hook": "block-exported-msys-pathconv",
"hook_event": "PreToolUse",
"status": "blocked",
"duration_ms": 11,
"data": {
"tool": "Bash",
"subject": "Bash:unset",
"form": "export"
}
}
81 changes: 79 additions & 2 deletions docs/conventions/windows-path-emit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,10 @@ here, which is why it is a repo convention with a detector rather than a note.

## The rules

Four rules, in the order they should be reached for. The first is the one that would have prevented
the motivating incident (#2834) outright; conversion is what to do when it does not apply.
Five rules, in the order they should be reached for. The first is the one that would have prevented
the motivating incident (#2834) outright; conversion is what to do when it does not apply. Rule 5 —
never `export` a conversion suppressor — has its own section below, because it is what actually
recurred (#2870).

1. **Prefer a path the native side computes itself.** When the consumer is already `cd`-ed into the
directory it should write to — or can be given a base it owns — pass a *relative* destination and
Expand All @@ -55,6 +57,81 @@ the motivating incident (#2834) outright; conversion is what to do when it does
unobserved. `emit-windows-path.sh` exits non-zero when `cygpath` is missing or fails, and prints
nothing on stdout for that argument.

## Never `export` a path-conversion suppressor

Rule 5, added after #2870, and the one that has actually fired since the rest of this document
shipped.

Git Bash normally rewrites POSIX-looking argv into Windows form before spawning a native binary, so
`git worktree add /d/worktrees/x` reaches `git.exe` as `D:/worktrees/x` and lands correctly. Two
environment variables switch that off: `MSYS_NO_PATHCONV` and `MSYS2_ARG_CONV_EXCL`. They exist for a
real reason — see the next section — but **exporting** either one disables conversion for *every
later command in the same shell*, including commands the author was not thinking about.

That is how `D:\d` was created a third time. A lane exported `MSYS_NO_PATHCONV=1` to stop MSYS
mangling a `<rev>:<path>` argument, then, seven segments later in the same command string, ran a
`git worktree add` whose `/d/worktrees/...` argument was **textually identical to one the same lane
had already run successfully**. The path was never the variable; the environment was.

The distinction is mechanical, and `git rev-parse --sq-quote` shows exactly what `git.exe` received:

```text
bash -c 'git rev-parse --sq-quote /d/probe' -> 'D:/probe' converted
bash -c 'MSYS_NO_PATHCONV=1; git rev-parse --sq-quote /d/probe' -> 'D:/probe' bare assignment: no effect
bash -c 'export MSYS_NO_PATHCONV=1; git rev-parse ... /d/probe' -> '/d/probe' THE DEFECT
bash -c 'MSYS_NO_PATHCONV=1 git ... /d/a; git ... /d/b' -> '/d/a' then 'D:/b' prefix scopes it
```

So, in preference order:

1. **Use Windows-native paths** (`<drive>:/<repo-root>/...`) for path arguments, and the question
never arises.
2. If a suppressor is genuinely needed, use it as a **per-command prefix** —
`MSYS_NO_PATHCONV=1 git show "origin/main:.github/workflows/ci.yml"` — which scopes it to that one
command and nothing after it.
3. Never `export` it, and never `declare -x` / `typeset -x` it. A bare assignment is not a middle
ground either: it has no effect at all, because the MSYS runtime reads the *environment*.
4. A prefix is only scoped when its command word is **not a shell**. `MSYS_NO_PATHCONV=1 bash -c
'…'` (and `env MSYS_NO_PATHCONV=1 sh -c '…'`) leaks into every command inside that child, because
a prefix scopes to one *process* and an interpreter's process is the whole script. Measured:

```text
bash -c 'MSYS_NO_PATHCONV=1 git ... /d/a; git ... /d/b' -> '/d/a' then 'D:/b' scoped
MSYS_NO_PATHCONV=1 bash -c 'git ... /d/a; git ... /d/b' -> '/d/a' then '/d/b' leaks
```

[`plugins/guardrails/hooks/block-exported-msys-pathconv.sh`](../../../plugins/guardrails/hooks/block-exported-msys-pathconv.sh)
enforces this as a `PreToolUse` block.

## The colon-argument mangling this is usually a workaround for

MSYS also rewrites an argument it reads as a colon-separated PATH list, converting `:` to `;` and `/`
to `\`. It is what sends people reaching for the suppressor above:

```text
git show origin/main:.github/workflows/ci.yml
fatal: ambiguous argument 'origin\main;.github\workflows\ci.yml': unknown revision or path not in the working tree.
```

The conversion is **conditional**, which is why it reads as a bad revision rather than a shell
problem. Measured on this machine, `A:B` is treated as a path list when `A` contains a `/` **and**
`B` begins with a dot-name (a `.` followed by something that is not `/` or `.`):

| argument | converted? |
| --- | --- |
| `origin/main:.github/workflows/ci.yml` | yes |
| `origin/main:.claude/settings.json` | yes |
| `origin/main:scripts/foo.sh` | no |
| `origin/main:./scripts/foo.sh` | no |
| `origin/main:../x` | no |
| `HEAD:.github/x` | no |
| `aaa:.bbb` | no |

`.github/`, `.claude/` and `.chezmoi*` are exactly the directories this repo's agents read most, so
the exposure is routine rather than exotic. Unlike the drive-root class, this one **fails loudly** and
creates nothing. The fix is a per-command `MSYS_NO_PATHCONV=1` prefix, or `git show` against a
`-C <windows-path>` checkout with the path spelled relative — never an export.

## Do not reuse the hook-utils path helpers for this

[`lib/hook-utils.sh`](../../../lib/hook-utils.sh) is the precedent for `OSTYPE`-gated path handling
Expand Down
10 changes: 8 additions & 2 deletions plugins/guardrails/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
"name": "guardrails",
"description": "Thirteen 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, Windows drive-root /tmp writes (POSIX /tmp, C:\\tmp, \\tmp), multi-line `git commit -m` messages (an actual-newline `-m` mangles across shells; single-line `-m` passes), 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) markdown citing a repo path the repo's own history shows was removed, (advisory, opt-in) un-throttled Workflow fan-out that risks burst 529s, and (advisory, opt-in) direct gh pr create calls bypassing this marketplace's own pull-request skill \u2014 each independently toggleable.",
"description": "Fourteen 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, Windows drive-root /tmp writes (POSIX /tmp, C:\\tmp, \\tmp), an EXPORTED MSYS_NO_PATHCONV / MSYS2_ARG_CONV_EXCL that unconverts a later path argument on Windows, multi-line `git commit -m` messages (an actual-newline `-m` mangles across shells; single-line `-m` passes), 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) markdown citing a repo path the repo's own history shows was removed, (advisory, opt-in) un-throttled Workflow fan-out that risks burst 529s, and (advisory, opt-in) direct gh pr create calls bypassing this marketplace's own pull-request skill \u2014 each independently toggleable.",
"author": {
"name": "Melodic Software",
"email": "info@melodicsoftware.com"
Expand Down Expand Up @@ -55,6 +55,12 @@
"description": "Block Bash/PowerShell writes whose target is a Windows drive-root temp path (/tmp, C:\\tmp, \\tmp, /c/tmp) that resolves to <drive>:\\tmp instead of %TEMP%",
"default": true
},
"block_exported_msys_pathconv_enabled": {
"type": "boolean",
"title": "block-exported-msys-pathconv guard",
"description": "Block a leaking MSYS path-conversion suppressor on Windows: an EXPORTED MSYS_NO_PATHCONV / MSYS2_ARG_CONV_EXCL, or a prefix on a child shell (MSYS_NO_PATHCONV=1 bash -c ...). Either switches off conversion for later commands, letting an unconverted /d/... reach git as <current-drive>:\\d\\...; a prefix on a non-shell command word and a bare assignment are not matched",
"default": true
},
"block_noncanonical_commit_enabled": {
"type": "boolean",
"title": "block-noncanonical-commit guard",
Expand Down Expand Up @@ -141,5 +147,5 @@
"min": 1
}
},
"version": "0.28.33"
"version": "0.29.0"
}
67 changes: 67 additions & 0 deletions plugins/guardrails/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,73 @@
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.29.0]

### Added

- **`block-exported-msys-pathconv`: block an EXPORTED MSYS path-conversion
suppressor on Windows
([#2870](https://github.com/melodic-software/claude-code-plugins/issues/2870)).**
New `PreToolUse` guard on `Bash|PowerShell`, default on, kill switch
`block_exported_msys_pathconv_enabled`. Blocks `export MSYS_NO_PATHCONV` /
`export MSYS2_ARG_CONV_EXCL` (including `export --`, and the `declare` /
`typeset` spellings with the export flag in any cluster — `-x`, `-rx`,
`-gx`, `-x -g`), which switch off MSYS argv rewriting for *every later
command in the same command string*. A later path argument then reaches a Windows-native program
unconverted, and git resolves the leading `/` against the current drive —
`git worktree add /d/worktrees/x` becomes `<current-drive>:\d\worktrees\x`.
That is how `D:\d` was recreated a third time, by a lane that exported the
variable seven segments earlier to work around an unrelated problem (MSYS
mangling a `<rev>:<path>` argument).

A second leaking form is matched too: a prefix whose command word is a
**shell** (`MSYS_NO_PATHCONV=1 bash -c '…'`, `env MSYS_NO_PATHCONV=1 sh -c
'…'`). The prefix scopes to one *process*, and when that process is an
interpreter, one process is every command in the script — verified
behaviorally, where the same prefix on `git` directly leaves only the first
argument unconverted. An adversarial review found this as an undeclared false
negative; closing it cost **zero** additional false positives on the same
14,234-command corpus (its single match was already blocked by the export
rule). A prefix on a non-shell command word — the safe idiom, 193 corpus
uses — stays allowed.

The guard deliberately does **not** match a path shape. The incident command's
path argument was textually identical to one the same lane had already run
successfully, so a `/[a-z]/` matcher has a false negative on the real defect —
and measured a 45.7% firing rate across 14,234 real Bash commands (81% on
`git worktree add` alone), because in an ordinary shell MSYS converts those
correctly. The export form fires on **0.32%** of the same corpus (46 commands,
spanning four lanes and two repositories) and leaves the safe per-command
prefix idiom (193 uses) and bare assignments untouched. The distinction is
mechanical rather than heuristic: `bash -c 'export MSYS_NO_PATHCONV=1; git
rev-parse --sq-quote /d/probe'` yields `'/d/probe'` while the bare-assignment
and per-command-prefix forms both yield `'D:/probe'` for the following command.

Review rounds refined the matcher in both directions. Without a shell word
in the command string, the export keyword must sit at command position, so
commit messages, `echo` arguments, and grep patterns that merely quote the
forbidden spelling stay allowed; with a shell word present, quoted text can
execute and any occurrence still blocks. Launchers and shells are judged by
quote-stripped basename, so `/usr/bin/env bash -c`, `command bash -c`, a
quoted `'bash'`, and a quoted `"C:\...\bash.exe"` behind PowerShell's call
operator all block, as does a suppressor prefix opening a quoted child
command string.

Declared coverage gaps: a suppressor exported by a script the command invokes,
`set -a` plus a bare assignment, an expansion-built value, an export guarded
by a keyword rather than a separator (`if ...; then export ...`), and any
spawner outside the two tool surfaces (CI runners, `subprocess`). Declared
residual false positives, accepted fail-closed: an export spelling inside a
heredoc body, and prose quoting a forbidden spelling alongside a shell word
in the same command string.

### Changed

- **`block-windows-drive-tmp`: reciprocal sibling cross-reference.** Its header
now records why the new guard is a separate hook rather than an extension of
this one — disjoint scopes (path-shape/write-target vs environment variable),
neither firing on the other's cases.

## [0.28.33]

### Fixed
Expand Down
Loading
Loading