Skip to content

fix(security): land the credential perimeter ahead of the v5 rebuild - #163

Merged
rmzi merged 4 commits into
mainfrom
fix/credential-perimeter
Jul 28, 2026
Merged

fix(security): land the credential perimeter ahead of the v5 rebuild#163
rmzi merged 4 commits into
mainfrom
fix/credential-perimeter

Conversation

@rmzi

@rmzi rmzi commented Jul 21, 2026

Copy link
Copy Markdown
Owner

Splits the security work off PR #160 so a live credential exposure lands now, independent of the parked v5.0.0 rebuild (#159 / #161). These three commits sit contiguously on main with no swarm/agent retrofit — this branch is main + security only.

What's here (3 commits)

✅ VERIFIED — the live-exposure fix (912ec66)

  • 9 dead credential Write() deny rules → Edit(). Claude Code honours only Edit(path) for file permission checks; Write(path) rules are silently skipped. Every credential file-tampering rule had been a no-op since f4cd05a — reading a private key was blocked, overwriting one was not. Wrong half of the perimeter.
  • mcp__* removed from permissions.allow — wildcard tool names are rejected; it was a no-op emitting a startup warning.
  • Enforcement proven behaviourally — a write to a path matching Edit(**/id_rsa*) was blocked; a control write to a non-matching name in the same dir succeeded. Not proven by counting entries.

⚠ UNVERIFIED — the marketplace-reach vehicle (dd1c6f1, 5fb86d6)

  • config-presets/security-baseline.yaml — 58-rule credential perimeter as a shipped preset, Edit() forms throughout, per-entry reason. Default-on in examples/config.yaml; documented in docs/config.md.
  • Committed unverified, and labelled as such in-commit. pds sync resolves presets from the installed plugin cache, not this repo, so the preset cannot be exercised until published. It parses; parsing is not enforcement — that exact distinction is what let the 9 dead rules ship for months. Verify behaviourally before trusting it.
  • Also arms protected_branches: [main] in examples/config.yaml (shipped empty → /pds:finish guard never fired).

Scope boundary

Reviewer note

912ec66 is safe to merge on its own merits. The preset commits are honest-but-unverified — if you'd rather not ship a default-on preset that hasn't been exercised, drop dd1c6f1+5fb86d6 and land only 912ec66. Say the word.

Refs #159

Ramzi Abdoch and others added 3 commits July 17, 2026 01:10
Claude Code honours only Edit(path) for file permission checks. Write(path)
deny rules are silently skipped — CC prints a warning to stderr on every
invocation that nothing surfaces. All 9 credential Write() rules in
.claude/settings.json have therefore been no-ops since f4cd05a:

  Read(**/id_rsa*)  -> blocked reading the SSH key
  Write(**/id_rsa*) -> did nothing; an agent could overwrite it

Same for .env, *.pem, .git-credentials, secrets/**, *credential*,
*secret*key*, *token*.json, id_ed25519*. The perimeter blocked exfiltration
and permitted tampering — the wrong half.

Rewrites all 9 to Edit(...), which covers every file-editing tool, so the
Read(x) + Edit(x) pairs actually close. No dual-form shim for older CC —
clean break, per v5.0.0 G2.

Also drops "mcp__*" from permissions.allow: wildcard tool names are rejected
in allow rules, so it was a no-op emitting a startup warning. It was the
visible instance of the same class as the nine above.

Verified:
  - `claude config list` emits zero parse warnings against this file
  - enforcement proven behaviourally, not by counting: a Write to a path
    matching Edit(**/id_rsa*) was blocked, while a control write to a
    non-matching name in the same directory succeeded

Does not fix users. Marketplace installs receive no security block at all
(install.sh:377 — "can't be in plugin"), and no shipped preset carries
credential denies, so `pds sync` doesn't deliver them either. Those are
tracked in .claude/swarm/findings.md as P0s for #159.

Refs #159

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLCbseL5UX9RddrddHGYGD
…to a preset

The credential perimeter has lived only in .claude/settings.json, which is
applied exclusively by install.sh. No shipped preset carried any of it —
pds-default has 3 denies (all rm -rf-class), dev-tools has 0. So `pds sync`,
the documented "portable preferences" path, delivered velocity and no
perimeter. This preset closes that half of the split.

58 entries, ported at parity with .claude/settings.json as of 912ec66.
Deliberately not a redesign — composition changes belong to #159, not to
the port.

  - scope: both on every entry
  - Edit() forms throughout, never Write(). CC honours only Edit(path) for
    file permission checks; Write() deny rules are silently skipped. The
    forms here were pulled from the corrected file, not retyped, precisely
    so this artifact does not reintroduce the bug it exists to ship the fix
    for.
  - per-entry `reason`, per the preset format's own contract
  - two groups flagged OPINIONATED inline (production tripwires, outbound
    remote access). They false-positive for real users — a consultant on
    client infrastructure wants them gone. Included for parity with what
    install.sh users already receive. A reviewer should decide whether they
    belong in a baseline or a separate prod-guardrails preset.

UNVERIFIED — and deliberately shipped saying so. `pds sync` resolves presets
from the installed plugin cache, not the repo:

  Error: expanding preset 'security-baseline' (known presets live in
  ~/.claude/plugins/cache/pds-marketplace/pds/<v>/config-presets)

so it cannot be exercised until published, or until copied into the cache
(mutates the user's installation). It parses. Parsing is not enforcement —
that distinction is what let 9 dead rules ship since f4cd05a. Verify
behaviourally before marking the #159 criterion done.

Necessary, not sufficient. Correction to an earlier claim: the marketplace
DOES ship settings, presets, install.sh and the CLI source — the whole repo
tree lands in the plugin cache. Nothing applies any of it. The gap is
activation, not distribution. Even with this preset published, a user must
build the CLI from cached source, author a config.yaml, list the preset, and
run `pds sync` — four steps, zero prompts. Claude Code auto-activates only
skills, agents, hooks and MCP servers, so a SessionStart perimeter check is
the only vehicle that reaches someone who does not already know they are
exposed. Not written here: session-start.sh already ships and cannot be
fire-tested from inside a session.

Refs #159

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLCbseL5UX9RddrddHGYGD
/pds:verify caught this: dd1c6f1 shipped a preset that nothing told anyone
existed. docs/config.md:10 instructs users to copy examples/config.yaml, and
that file listed `presets: [pds-default, dev-tools]`. Follow every documented
step correctly and you got no perimeter — exactly the state the preset was
written to fix. Unverified AND undiscoverable is not shipped.

  - examples/config.yaml: security-baseline ON by default. Shipping the
    perimeter off-by-default is the mechanism by which it went missing
    everywhere. Nothing in it trades against velocity.
  - docs/config.md: shipped-presets table + a security-baseline section
    covering what it does, which parts are opinionated, and how to drop it
    correctly (copy the entries you want into your own deny list; read the
    per-entry `reason` fields first).
  - CHANGELOG: [Unreleased] entry for the preset, the nine dead deny rules,
    and the mcp__* no-op.

Also fixes a guard that was never armed: examples/config.yaml shipped
`protected_branches: []`. /pds:finish reads that list and prompts before
pushing to a match — an empty list means nothing is protected and the prompt
never fires, for every user who copied the example as documented. Now [main].

Three things recorded in docs/config.md that previously had no home:
  - `pds sync` cannot manage the sandbox. There is no `sandbox` key in the
    schema; the block reaches settings.json only via install.sh.
  - Presets resolve from the installed plugin cache, not a repo checkout.
  - `pds doctor` reports "config parses: ok" for YAML that Claude Code then
    rejects rule-by-rule. It validates syntax, not semantic acceptance by the
    consumer. Ask `claude config list` instead.
  - Bash() deny patterns substring-match the entire command string, so a
    command that merely mentions a guarded path is denied — including one
    documenting the perimeter itself.

security-baseline remains UNVERIFIED — presets resolve from the plugin cache,
so it cannot be exercised until published. Parsing is not enforcement.

Refs #159

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLCbseL5UX9RddrddHGYGD

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Code review skipped — your organization's overage spend limit has been reached.

Code review is billed via overage credits. To resume reviews, an organization admin can raise the monthly limit at claude.ai/admin-settings/claude-code.

Once credits are available, push a new commit or reopen this pull request to trigger a review.

skills/grill/SKILL.md was rewritten in 4.22.0 to require AskUserQuestion
(every step) and EnterPlanMode (its Mode section), but the orchestrator's
tools: frontmatter never granted either, and pds:grill wasn't in its
skills: list. Subagents only get tools explicitly listed in frontmatter,
so orchestrator-driven Phase 1 grill (mandatory before every swarm) had
no way to ask a structured question.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Comment on lines +1 to +20
# security-baseline preset — the credential perimeter.
#
# WHY THIS EXISTS
# PDS advertises a credential perimeter. Until now it lived ONLY in the repo's
# .claude/settings.json, which reaches users exclusively via install.sh. The
# marketplace plugin cannot ship settings (install.sh:377 — "can't be in
# plugin"), so every marketplace-install user has had `deny: []` — no perimeter
# at all. And no shipped preset carried these rules, so `pds sync` did not
# deliver them either: pds-default has 3 denies (all rm -rf-class), dev-tools
# has 0. Neither distribution path delivered the product. This preset closes
# that gap for the `pds sync` path.
#
# Ported at parity with .claude/settings.json as of 912ec66. Deliberately NOT a
# redesign — composition changes belong to #159, not to the port.
#
# EDIT(), NEVER WRITE(). Claude Code honours only Edit(path) for file permission
# checks; Write(path) deny rules are silently skipped, with a warning printed to
# stderr that nothing surfaces. All 9 file-tampering rules below were dead from
# f4cd05a until 912ec66 — the perimeter blocked reading a credential and
# permitted overwriting one. Enforcement is verified behaviourally (attempt a

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The new preset's header comment (lines 8-9) says "pds-default has 3 denies (all rm -rf-class), dev-tools has 0" but pds-default.yaml has exactly 1 deny entry — the other 3 are ask-gated git ops, not denies. Separately, CLAUDE.md:66's Project Structure listing still reads "config-presets/ — Shipped permission presets (pds-default, dev-tools)", which this PR's new security-baseline.yaml preset makes stale.

Extended reasoning...

This is two small, PR-introduced documentation inaccuracies bundled into one finding since they live in the same new file's surrounding context.

1. Miscounted deny rules (config-presets/security-baseline.yaml:8-9)

The header comment reads: "pds-default has 3 denies (all rm -rf-class), dev-tools has 0. Neither distribution path delivered the product." I checked config-presets/pds-default.yaml directly:

$ grep -c "verb: deny" config-presets/pds-default.yaml
1
$ grep -B3 "verb: deny" config-presets/pds-default.yaml
  # --- Hard stops that would lose work irrecoverably ---
  - pattern: "Bash(rm -rf /)"
    verb: deny
$ grep -c "verb: ask" config-presets/pds-default.yaml
3

So pds-default has exactly one deny entry (Bash(rm -rf /)), not three. The "3" almost certainly comes from conflating the deny count with the 3 ask-gated entries nearby (git push --force, git reset --hard, git clean -f) — which are ask-gated history-destroying git operations, not deny-class rules at all, and not "rm -rf-class" either. The dev-tools has 0 half of the claim is correct. Notably, this PR's own docs/config.md shipped-presets table gets it right: "Denies rm -rf /. Puts the three history-destroying git operations behind ask." — so the new preset's own header comment contradicts the PR's own correct documentation elsewhere.

Impact is limited to a comment: it doesn't affect any rule, pattern, or runtime behavior. But it does overstate the existing security coverage the file's rationale leans on ("neither distribution path delivered the product" — the "3 denies" framing inflates how much pds-default already covered before this PR).

2. Stale CLAUDE.md preset enumeration (CLAUDE.md:66)

CLAUDE.md's Project Structure section reads: "config-presets/ — Shipped permission presets (pds-default, dev-tools) — referenced from pds.config.yaml". This PR adds config-presets/security-baseline.yaml as a third shipped preset, wires it default-on into examples/config.yaml (presets: [pds-default, dev-tools, security-baseline]), and documents it correctly in docs/config.md's "Shipped presets" table — but CLAUDE.md itself is not in the PR's changed-files set, so its two-preset enumeration is now incomplete. CLAUDE.md's own Workflow rule requires updating docs that code changes invalidate, and this parenthetical is a concrete, easily-checked list the PR invalidates.

Fix: change "3 denies" to "1 deny" (and drop or reword "all rm -rf-class" since it no longer needs to cover the ask entries) in security-baseline.yaml:9; add security-baseline to the parenthetical at CLAUDE.md:66.

Both are comment/doc-only inaccuracies with zero effect on rule enforcement or preset behavior — worth a one-line fix each but not merge-blocking.

Comment thread .claude/settings.json
Comment on lines 34 to 37
"WebFetch",
"WebSearch",
"Task",
"mcp__*",
"Bash(git add:*)",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 This PR removes "mcp__" from permissions.allow in .claude/settings.json (a rejected no-op wildcard), but docs/teams.md:270,319 and docs/sandbox.md:97-110,199 still describe "mcp__ wildcard" as the live default auto-allow mechanism for MCP tools, including security advice to replace it with explicit allowlists. These docs now reference a permission entry and mitigation that no longer apply.

Extended reasoning...

The bug: 912ec66 deletes the "mcp__*" line from permissions.allow in .claude/settings.json (diff line 37) because wildcard tool names are rejected by Claude Code and the entry was a no-op that only emitted a startup warning. Two docs were not updated to match:

  • docs/teams.md:270 — lists "All MCP tools (via mcp__* wildcard — see note below)" under What's Auto-Allowed.
  • docs/teams.md:319 — "mcp__* wildcard risk: The default mcp__* permission auto-approves all MCP tools from any configured server. For security-sensitive environments, replace with explicit allowlists..."
  • docs/sandbox.md:97-110 — "For teams that want tighter control, replace the mcp__* wildcard in permissions with explicit MCP tool names" and "The default mcp__* permission auto-approves all MCP tools."
  • docs/sandbox.md:199 — security-model table row: MCP tools | mcp__* allow list.

Why this happened: the PR's own CHANGELOG entry states the removal plainly ("mcp__* removed from permissions.allow... it was a no-op that emitted a startup warning"), and the PR heavily updated docs/config.md and examples/config.yaml for the sibling changes in this same commit (the Write→Edit fix, protected_branches), but the sweep never touched docs/teams.md or docs/sandbox.md, which describe the exact permission line that was deleted.

Why nothing catches this: there's no test or lint that cross-checks doc prose against .claude/settings.json contents — this is a documentation-only drift that only a reader auditing MCP tool exposure via these docs would notice.

Impact: someone reading docs/sandbox.md or docs/teams.md to understand MCP tool exposure will believe there is a live mcp__* auto-allow they need to consider narrowing for security-sensitive environments — a mitigation for a mechanism that no longer exists in shipped settings. Concretely: a reader following docs/sandbox.md:97's "replace the mcp__* wildcard" instructions today would look for a line that is no longer there and could reasonably conclude the doc (or their install) is broken, when in fact the wildcard was already inert even before this PR removed it — worth noting since it slightly softens the practical risk (the wildcard never actually granted anything, per the PR's own description).

Step-by-step proof:

  1. Before this PR: .claude/settings.json permissions.allow contains "mcp__*" (a no-op line, per PR description).
  2. docs/teams.md:270 and docs/sandbox.md:97-110,199 describe this line as the live MCP auto-allow mechanism and give advice on narrowing it.
  3. This PR deletes the "mcp__*" line from .claude/settings.json (diff line 37).
  4. docs/teams.md and docs/sandbox.md are not touched in the diff — grep confirms the four references still exist verbatim post-PR.
  5. Reader consequence: docs describe a settings entry that doesn't exist in shipped config, and give security mitigation advice for something that's already gone.

Fix: in docs/teams.md, drop the "All MCP tools (via mcp__* wildcard)" bullet from What's Auto-Allowed and remove or rewrite the "mcp__* wildcard risk" note; in docs/sandbox.md, remove the "Maximum lockdown" replace-the-wildcard section, the "mcp__* wildcard risk" section, and update the table row to reflect that MCP tools now require explicit per-tool/per-server permission entries (no wildcard shorthand exists).

Severity: nit. This is documentation-only drift — nothing breaks at runtime, and per the PR's own description mcp__* was already inert before removal, so the real MCP security posture is unchanged either way. It's a legitimate cleanup the author should make (and the PR's own CHANGELOG/doc-sync habits elsewhere in this same commit show the precedent for doing so), but it doesn't block merge.

@rmzi
rmzi merged commit aa6a580 into main Jul 28, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant