* feat(#4221): gsd-secret-read-guard PreToolUse hook + registration Add hooks/gsd-secret-read-guard.js, a blocking PreToolUse guard on Read|Grep|Bash that denies reads of .env, .env.<suffix> and .secrets (the .env.example/.sample/.template/.dist templates stay readable). Read checks file_path; Grep checks an explicit path and judges the glob per brace alternative; Bash runs a two-pass token scan (quotes, comments, redirects with fd digits, separators, $( )/backtick/<( ) recursion, heredoc bodies never scanned as commands, nested bash -c/eval rescans, git <ref>:<path> shapes) with a closed non-reading exemption set for existence checks. Fail-open crash policy; 1 MiB commands are denied as command-too-large; more than 64 glob alternatives as glob-too-complex. Why: Claude Code 2.1.259 makes every `cd DIR && grep …` compound prompt for approval whenever any Read() deny rule exists, even in auto mode. A hook denial is not a permission rule and never arms that check. The installer-written deny rules are retired in the follow-up commit. Registration: hooks.json (Read|Grep|Bash, timeout 5), build-hooks HOOKS_TO_COPY, managed-hooks-registry, runtime-hooks-surface (blocking guard with BLOCKING_GUARD_TIMEOUT_S; Kimi ReadFile|Grep|Shell), shell-command-projection managed sets, installer-migration-report, OpenCode/Kilo plugin (grep tool mapping, include -> glob, dispatch), docs tables in five locales, ADR-766 always-on list, regen:derived fixtures, and a new table-driven unit suite. * test(#4221): pin the secret-read guard in existing hook gates Register gsd-secret-read-guard.js in every existing hook gate: the hooks-crash-policy table (deny row; 6 -> 7 deny cases), plugin-manifest REQUIRED_HOOKS and its Read|Grep|Bash group, docs-hooks-table-parity EXPECTED_SURFACE_HOOKS, install.test MANAGED_JS_HOOKS, install-minimal- hooks JS_HOOKS/BLOCKING_GUARDS, portable-node-runner GUARD_HOOKS, kilo-upgrades PLUGIN_GUARD_HOOKS, the Kimi normalization-parity and typed-payload floors, the OpenCode adapter (grep mapping, include -> glob, three dispatch tests) and a Kimi TOML matcher assertion. * fix(#4221): retire installer Read() deny rules (legacy filter) Rename GSD_CLAUDE_DENY_PERMISSIONS to GSD_CLAUDE_LEGACY_DENY_PERMISSIONS and stop adding the three Read(.env) / Read(.env.*) / Read(.secrets) strings. mergeClaudePermissions now only filters them out of an existing permissions.deny: an absent deny key stays absent, a malformed one is still repaired to [], and an array emptied by the filter is deleted so no `"deny": []` residue is left. Uninstall filters the same legacy list and, symmetric with the Antigravity branch, drops an emptied allow or deny key and an emptied permissions object. Unlike the #2278 allow-side migration there is no surviving current deny list, so the constant is renamed rather than mirrored. Removal is byte-exact: a hand-written identical rule is indistinguishable from the installer's and is removed too (the manifest never recorded permission strings). USER-GUIDE and CONTEXT.md updated. * test(#4221): flip install-regressions deny-rule assertions to the retired shape The fresh-merge, non-destructive merge, idempotency, end-to-end install, reinstall and uninstall assertions now expect no Read(.env*) deny rules and no permissions.deny key on a fresh install; the deny:null repair case is kept. A new describe block covers the legacy filter: retired strings removed with a user entry kept, partial sets, near-miss strings untouched, idempotency, GSD-only deny array deleted, a pre-existing empty deny preserved, and uninstall symmetry for allow/deny/permissions. * chore(#4221): add changeset fragment for PR #4236 * fix(#4221): case-fold names; scan shell stdin and xargs pipes Review round 1 (trek-e): - Blocker: secret-name matching is now case-insensitive in the Read, Grep (path and glob) and Bash paths, so `.ENV` / `.Secrets` on a case-insensitive filesystem are recognized as the same secret file. - Major: a shell interpreter's script is now scanned wherever it comes from. The tokenizer keeps heredoc bodies as per-segment tokens and records separator operators; pass 2 groups by segment id and resolves bash/sh/zsh/dash/ksh/su invocation mode: `-c` (including combined `-lc`) scans the script operand, a file operand is checked as a file (a `<( )` operand's echo/printf output is reconstructed), otherwise stdin is the script and heredocs, here-strings and a piped echo/printf source are scanned. `eval` joins all its operands; `source`/`.` handle process substitution. Data heredocs (`cat <<EOF`, the commit-message shape) stay unscanned. - Major: `… | xargs <cmd>` checks the upstream segment's operands as file names when the sub-command reads (`echo .env | xargs cat`, `find . -name .env | xargs cat`); `-a`/`--arg-file` suppresses the inference; a shell sub-command's `-c` script is scanned. Header, USER-GUIDE bullet and changeset updated; documented gaps now include piped scripts from non-echo sources and `exec`/`timeout` wrappers. 60 new suite cases pin the block and allow shapes. --------- Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
11 KiB
Claude Code Plugin Manifest Module owns the projection of gsd-core surfaces onto the Claude Code plugin contract
- Status: Accepted
- Date: 2026-06-07
- Issue: #766
- Implementation: PR #797
Context
gsd-core has, until now, reached Claude Code through exactly one Adapter: the file-copy installer. The Runtime Artifact Layout Module (ADR-3660) projects gsd-core's artifact surfaces (commands, agents, skills) onto per-runtime filesystem placements, and the Runtime Install Policy Module (ADR-58) composes those placements with command text and config intentions into a typed install plan that adapters write to ~/.claude/ / .claude/.
Claude Code now exposes a second, first-class way to receive the same surfaces: the plugin contract — a .claude-plugin/plugin.json manifest plus a hooks/hooks.json, consumed either by a marketplace install or by the zero-friction @skills-dir path. This contract is an external interface owned by Claude Code, not by gsd-core: it has its own schema, its own namespacing rules (/<plugin-name>:<command>), its own validation tool (claude plugin validate), and its own constraints (notably: plugin-shipped agents may not carry hooks / permissionMode / mcpServers frontmatter — Claude Code silently ignores them).
Before this ADR, the only record of how gsd-core maps onto that external contract was the manifest files themselves. A hand-authored config file with no named Seam invites drift: the manifest's hook wiring silently diverges from what the Installer Module wires into settings.json; the identity fields drift from the Package Identity Module; and a future maintainer has no single place that says which gsd-core surface maps to which manifest field, and why. The plugin contract is exactly the kind of external interface that earns a defined, typed mapping rather than an ad-hoc file — the same reasoning that gave the file-copy path the Runtime Artifact Layout Module.
This is the structural signal the architecture review looks for: two Adapters at one Seam. The file-copy layout and the plugin manifest are two projections of the same gsd-core artifact surfaces onto two different distribution contracts. That makes the distribution Seam real, and the plugin-side projection deserves a name.
Decision
Introduce the Claude Code Plugin Manifest Module as the Seam that owns the projection of gsd-core's artifact surfaces onto the Claude Code plugin contract. It is the plugin-contract sibling of the Runtime Artifact Layout Module: where that Module projects surfaces onto filesystem placements, this Module projects the same surfaces onto .claude-plugin/plugin.json + hooks/hooks.json.
The mapping is defined, not incidental:
| gsd-core surface / source | Claude Code plugin field | Rule / invariant |
|---|---|---|
Package Identity Module binName |
name |
gsd-core — drives the /gsd-core: command namespace; must be kebab-case (no colon/space/uppercase). |
Package Identity Module repoUrl |
repository, homepage |
derived, never re-typed. |
package.json version / description / license |
version / description / license |
version is required for claude plugin validate --strict (a missing version is a strict failure), so it is synced to package.json and held by a drift-guard test. |
Command surface (commands/gsd/*.md) |
commands: "./commands/gsd/" |
exposed as /gsd-core:<command>; namespacing replaces the file-copy path's /gsd:<command> (an additive UX change, not a data-format break). |
Agent surface (agents/*.md) |
(omitted — default agents/ discovery) |
the explicit agents: <string> form is rejected by the plugin schema; relying on Claude Code's default agents/ discovery loads them and stays self-maintaining. Agents are already plugin-safe — their hooks/permissionMode frontmatter is inert. |
Always-on hook policy (subset of the Installer Module's settings.json wiring) |
hooks: "./hooks/hooks.json" |
see below. |
The hook projection is the load-bearing part of this Module, because of the external constraint: a plugin's agents cannot carry hook frontmatter, so all plugin-path hook wiring must live in hooks/hooks.json. The Module projects only the always-on subset of the Installer Module's Claude hook wiring — gsd-check-update (SessionStart), gsd-context-monitor (PostToolUse), and the security guards gsd-prompt-guard / gsd-read-guard / gsd-worktree-path-guard / gsd-read-injection-scanner / gsd-write-guard (PreToolUse, #2255) / gsd-secret-read-guard (PreToolUse Read|Grep|Bash, #4221) — preserving each event, matcher, and timeout. The installer's config-gated opt-in hooks (workflow-guard, validate-commit, graphify-update, session-state, phase-boundary, update-banner) are deliberately excluded: a static manifest cannot read a project's .planning/config.json to honor those gates, so projecting them would run them unconditionally — a behavior change the Module must not introduce. Hook commands reference bundled scripts through Claude Code's ${CLAUDE_PLUGIN_ROOT} variable.
The interface of this Module is therefore a conformance contract, validated two ways: claude plugin validate --strict (the external tool's view) and an in-repo drift-guard test (tests/plugin-manifest.test.cjs) that locks the identity mapping, the version sync, the always-on hook contract, and the absence of opt-in hooks. Manifest component paths are resolved relative to the plugin root (the directory containing .claude-plugin/), which is the repository root.
This is additive. The file-copy path — Runtime Artifact Layout Module, Runtime Install Policy Module, Installer Module — is unchanged. The plugin manifest is a parallel Adapter, the fallback for users on older Claude Code versions that predate the plugin contract.
What stays OUTSIDE this Module
To keep the Seam honest about where the plugin contract ends:
- Runtime execution. The Module projects the command/agent/hook surface and lifecycle metadata. It does not make gsd commands self-contained: their backing logic still resolves the gsd runtime CLI (
gsd-tools) andnodeonPATH. The plugin delivers discoverability and lifecycle (claude plugin enable|disable|update); it does not replace the runtime. - The file-copy install. Filesystem placement,
settings.jsonmerge semantics, and per-runtime config rendering remain owned by the Runtime Artifact Layout / Install Policy / Installer Modules. - Marketplace listing. Publishing gsd-core to a marketplace registry is an external, out-of-repo act.
- Manifest emission by the installer. Having
bin/install.jsdrop the manifest in-place for the npm@skills-dirpath is a follow-up; the repo-root manifest already serves the marketplace and git-clone@skills-dirpaths.
Consequences
- gsd-core gains a one-command install/update/disable lifecycle and automatic
/gsd-core:namespacing that prevents slash-command collisions, without disturbing the file-copy path. - The plugin contract gains a named place in the glossary (
CONTEXT.md) and a defined mapping, so future surface additions have an obvious projection target instead of an ad-hoc file edit. - Latent duplication is now named, not hidden. The always-on hook policy is currently encoded twice — imperatively in the Installer Module's
settings.jsonwiring, and declaratively inhooks/hooks.json— kept in agreement only by the drift-guard test. This ADR records that as the known cost of a static manifest. Elevating the Module from a hand-authored manifest to a generated projection (stampingplugin.jsonfrom the Package Identity Module +package.json, andhooks/hooks.jsonfrommanaged-hooks-registry.cjs+ a shared always-on-hook policy) would collapse the duplication to one source — the same generated-single-source move ADR-457 made for.cjsand the Runtime Install Policy Module made for install plans. Deferred; see Open questions. - The
namefield is a stability surface: it is the published/gsd-core:namespace. Changing it is a user-visible break under Hyrum's law, the same way command names are. - Rollout is incremental: this ADR + the hand-authored manifest land first (#766/PR#797); installer-emit, release-time version stamping, and the generated projection are tracked follow-ups under #766.
Open questions
- Should this Module be generated rather than hand-authored, deriving
version(and identity) at build/release time so apackage.jsonbump cannot leaveplugin.jsonstale? The release pipeline bumps vianpm version --no-git-tag-versionwith no regeneration hook, so today the drift-guard test enforces the sync manually (idiomatic with the repo's other drift guards, but a release speed-bump). - Should the always-on-hook policy be lifted into a single shared source consumed by both the Installer Module and this Module, retiring the dual hand-encoding?
References
- ADR-3660 — Runtime Artifact Layout Module (the file-copy sibling: projects the same surfaces onto filesystem placements).
- ADR-58 — Runtime Install Policy Module (typed install-plan projection for the file-copy path).
- ADR-457 — Generated single-source (the precedent a generated manifest projection would follow).
- ADR-0008 — Installer Migration Module (adjacent installer Seam).
- Package Identity Module (
gsd-core/bin/lib/package-identity.cjs) — source of the manifest's identity fields. - Installer Module (
bin/install.js) — owns thesettings.jsonalways-on hook wiring this Module mirrors for the plugin path. CONTEXT.md§ Glossary — Domain modules and seams (where this Module is registered).- Claude Code plugin contract: https://code.claude.com/docs/en/plugins-reference.
Amendment 2026-06-22 — Skills surface projection (#1596)
The original mapping table projected commands + hooks but omitted skills. Phase B-provide of epic #1258 adds the skills surface:
| gsd-core surface / source | Claude Code plugin field | Rule / invariant |
|---|---|---|
Skill surface (commands/gsd/*.md → build-converted) |
skills: "./skills/" |
A skills/ dir of build-generated gsd-<stem>/SKILL.md files, produced by scripts/gen-plugin-skills.cjs running convertClaudeCommandToClaudeSkill (the same converter the file-copy installer uses). Generated at build time (npm run build) and committed (consistent with ADR-457's generated-committed-output pattern). This closes the gap where plugin-only installs lacked the skill surface because bin/install.js never ran. Methodology defined by ADR-1593 §5. |
The skills/ dir is generated, not hand-authored — scripts/gen-plugin-skills.cjs --check verifies staleness. The conformance test (tests/plugin-manifest.test.cjs Section H) asserts the manifest field, dir presence, frontmatter validity, and count parity with commands/gsd/*.md (DEFECT.GENERATIVE-FIX).