# PR [#23550](https://github.com/pingdotgg/t3code/pull/23550): native proof for the skills-dir guard branches

Branch `fix/issue-23507`, fix commit [d4fe68f3aa](https://github.com/pingdotgg/t3code/commit/d4fe68f3aa) (parent [73100b5de5](https://github.com/pingdotgg/t3code/commit/73100b5de5)). Native CLI: **Claude Code 2.1.303** on Linux x86_64 (`claude --version`).

This adds evidence for the new guard branches only. The existing happy-path captures still stand as historical evidence.

## Method

Everything ran in throwaway directories under `/tmp/cc23550`. No real settings were read or changed, and host `/etc` was never written.

- **Managed settings.** `run-managed.sh <file> <cmd>` runs the command in a private user+mount namespace, `unshare -Urm`. Inside it, an overlay mount on `/etc` holds the probe's `claude-code/managed-settings.json`. Host `/etc` stays unchanged.
- **Config dir.** `CLAUDE_CONFIG_DIR=/tmp/cc23550/cfg3` contains only `skills/mine/.claude-plugin/plugin.json` = `{"name":"mine","version":"1.0.0"}` and `skills/mine/skills/hello/SKILL.md`. Its `settings.json` is `{}` unless noted.
- **Project.** cwd is `/tmp/cc23550/proj3`, and `.claude/settings.json` is `{}` unless noted.
- **List probe.** `claude plugin list --json`, reduced to `(id, enabled, errors)`.
- **Runtime probe.** `claude -p "reply OK" --output-format stream-json --verbose --max-turns 1`. Only the `init` message's `slash_commands` and `plugins[].source` were read. Auth and model output were not recorded.

## 1. Managed marketplace and customization policy (`plugin list --json`)

| managed-settings.json | mine@skills-dir |
|---|---|
| `{}` (baseline) | loaded, enabled |
| `{"strictKnownMarketplaces":[]}` | **absent** |
| `{"strictKnownMarketplaces":[{"source":"github","repo":"a/b"}]}` | **absent** |
| `{"strictKnownMarketplaces":[{"source":"skills-dir"}]}` | loaded |
| `{"strictKnownMarketplaces":[{"source":"skills-dir","x":1}]}` | loaded |
| `{"strictKnownMarketplaces":[1,{"source":"skills-dir"}]}` | loaded |
| `{"strictKnownMarketplaces":["skills-dir"]}` / `[1]` / `{}` / `"bogus"` / `[{"source":"bogus"}]` | **absent** |
| `{"strictKnownMarketplaces":[{"source":"hostPattern","hostPattern":".*"}]}` / `pathPattern ".*"` | **absent** |
| `{"strictKnownMarketplaces":null}` | loaded |
| `{"blockedMarketplaces":[{"source":"skills-dir"}]}` | **absent** |
| `{"blockedMarketplaces":[{"source":"skills-dir","x":1}]}` / `[1,{"source":"skills-dir"}]` | **absent** |
| `{"blockedMarketplaces":[]}` / `[{"source":"github","repo":"a/b"}]` / `["skills-dir"]` / `"bogus"` / `[{"source":"SKILLS-DIR"}]` | loaded |
| `{"strictPluginOnlyCustomization":true}` | **absent** |
| `{"strictPluginOnlyCustomization":["skills"]}` / `["hooks"]` / `["mcp"]` / `["agents"]` / `["skills","bogus"]` / `["hooks",1]` | **absent** |
| `{"strictPluginOnlyCustomization":"bogus"}` / `1` / `"true"` / `{}` | **absent** |
| `{"strictPluginOnlyCustomization":false}` / `[]` / `null` / `["bogus"]` / `["SKILLS"]` / `[1]` | loaded |
| `{"strictKnownMarketplaces":[{"source":"skills-dir"}],"strictPluginOnlyCustomization":true}` | **absent** |
| `{"permissions":"bogus"}` (invalid unrelated key) | loaded |
| `{/*c*/"blockedMarketplaces":[{"source":"skills-dir"}],}` / `not json` | CLI error: "Managed settings document could not be parsed as a JSON object; none of its settings are in effect." |

### User and project scopes cannot set these keys

With managed `{}`, each of these was set in `cfg3/settings.json`, and separately in `proj3/.claude/settings.json`: `{"blockedMarketplaces":[{"source":"skills-dir"}]}`, `{"strictKnownMarketplaces":[]}`, `{"strictPluginOnlyCustomization":true}`. `mine@skills-dir` **stayed loaded** in all 6 cases.

### User and project settings cannot override managed policy

- Managed `{"blockedMarketplaces":[{"source":"skills-dir"}]}`, user `{"enabledPlugins":{"mine@skills-dir":true}}`, project `{"enabledPlugins":{"mine@skills-dir":true},"blockedMarketplaces":[]}`: **absent**.
- Managed `{"strictKnownMarketplaces":[]}`, user `{"enabledPlugins":{"mine@skills-dir":true},"strictKnownMarketplaces":[{"source":"skills-dir"}]}`: **absent**.
- Managed `{"enabledPlugins":{"mine@skills-dir":false}}`, user `true`: disabled, with error `Not loaded — the name "mine" is already taken by managed settings, which takes precedence.` The existing enablement merge already handles this case.

### Runtime confirmation (`init.slash_commands`)

| managed | mine:* commands | plugin sources |
|---|---|---|
| `{}` | `['mine:hello']` | `mine@skills-dir` + builtins |
| `{"blockedMarketplaces":[{"source":"skills-dir"}]}` | `[]` | builtins only |
| `{"strictPluginOnlyCustomization":["hooks"]}` | `[]` | builtins only |
| `{"strictKnownMarketplaces":[]}` | `[]` | builtins only |

Official docs ([Plugin loading reference, "Skills-directory plugins blocked by managed settings"](https://code.claude.com/docs/en/plugins/loading#skills-directory-plugins-blocked-by-managed-settings)) agree:

The documented policy skips the whole plugin when any supported customization kind is locked. Its managed allowlist and blocklist also apply to the skills-dir source.

**Bot claim rejected:** CodeRabbit said `strictPluginOnlyCustomization` is not a restriction for this source. Native runs and the docs both show it is, so the guard includes it.

## 2. Same manifest name: installed plugin vs skills-dir plugin

Fixture `CLAUDE_CONFIG_DIR=/tmp/cc23550/cfg`:
- Marketplace `localmkt` (directory source `/tmp/cc23550/mkt`), with `mine@localmkt` installed at user scope. Its manifest is `{"name":"mine","version":"2.0.0"}` and it has skill `inst-only`.
- `settings.json` has `enabledPlugins: {"mine@localmkt": true}` and the `localmkt` directory marketplace.
- `skills/mine` is the skills-dir plugin `{"name":"mine"}`, with skill `hello`.

`claude plugin list --json`:

```
mine@localmkt   enabled: true
mine@skills-dir enabled: false
  errors: ["Not loaded — the name \"mine\" is already taken by an installed plugin (mine@localmkt), which takes precedence. Give the plugin at /tmp/cc23550/cfg/skills/mine a different \"name\" (in plugin.json or SKILL.md frontmatter) to load this copy."]
```

The runtime `init` shows `slash_commands` containing only `['mine:inst-only']` (not `mine:hello`). Plugins: `mine@localmkt` (path `/tmp/cc23550/mkt/plugins/mine`) plus builtins.

Before the fix, T3 discovery returned `['mine:hello','mine:inst-only']`. `mine:hello` would appear in the picker but cannot run natively. This matches docs "Name conflicts" item 3. The duplicate-personal-winner case (two skills-dir copies) was not expanded.

## 3. Red/green tests (`apps/server/src/provider/Drivers/ClaudeSkills.test.ts`)

Red, at `73100b5de5` plus the new tests:

```
× skips skills-dir plugins that managed policy blocks, as Claude Code 2.1.303 does
  AssertionError: {"strictKnownMarketplaces":[]}: expected [ 'mine:hello' ] to deeply equal []
× lets an enabled installed plugin hold its manifest name over a skills-dir plugin
  AssertionError: expected [ 'mine:hello', 'mine:inst-only' ] to deeply equal [ 'mine:inst-only' ]
Tests  2 failed | 23 passed (25)
```

Green, at `d4fe68f3aa`, running `vp test run …/ClaudeSkills.test.ts …/ClaudeSkillDispatch.test.ts`:

```
Test Files  2 passed (2)
     Tests  32 passed (32)
```

The policy test uses `HostProcess.Platform = "win32"` with a temp `PROGRAMDATA`, which points the managed path at a fixture. It checks the allowed and blocked variants from section 1, and that the same keys in user settings have no effect.

## 4. Observer output path (`testFixtures/ClaudeStdin.fixture.mjs`)

The fix applies `NodePath.resolve` before the `/tmp/` prefix check. Smoke run with `T3_CLAUDE_OBSERVER_EXECUTABLE=cat`:

```
traversal /tmp/../home/ubuntu/observer-escape.log -> exit 1, rejected; escaped file exists: False
valid /tmp/issue-23507/./observer.jsonl, 🎉 split across 2 chunks -> exit 0, forwarded bytes identical, record ["héllo 日本 🎉"]
write to /tmp/issue-23507/missing-dir/x.jsonl -> exit 1, ENOENT surfaced
```

Before the fix, the raw `"/tmp/../home/…".startsWith("/tmp/")` check returned true.

## 5. Timing (3000 malformed tokens)

- **Policy check.** 3000 mixed malformed entries (numbers, strings, nulls, foreign-source objects, arrays) in each of the three keys, JSON parse plus check: 1.5 ms per run. One linear pass per key, O(n).
- **Observer.** All runs include about 56 ms of node startup:

  | Input | Total |
  |---|---|
  | 3000 malformed lines, one chunk | 135 ms |
  | 3000 malformed lines, 3000 chunks | 81 ms |
  | 3000 newline-free tokens, 3000 chunks | 63 ms |

  Per chunk the cost is linear in the pending buffer.

## Known gaps (not fixed)

- T3 parses managed settings leniently (comments, trailing commas). Native rejects such a managed file entirely. In that case T3 would still apply the policy and hide plugins that native loads. This is pre-existing, shared with `skillOverrides`, and fails conservatively.
- `managed-settings.d/` drop-ins are not read. This is pre-existing.
- Docs precedence rule 1: a managed `enabledPlugins` id whose *name part* matches another origin also locks out the skills-dir copy. Only the exact `name@skills-dir` id is honored. Not probed and not implemented.
- Project `.claude/skills/` plugins are still not adopted. This is unchanged scope.
