This is an automated email from the ASF dual-hosted git repository.

luigidemasi pushed a commit to branch fix/security-model-verification-153-158
in repository https://gitbox.apache.org/repos/asf/camel-kit.git

commit f4e4f7ac1f9a6396ec906609aec4f65f3a48a924
Author: Luigi De Masi <[email protected]>
AuthorDate: Mon Nov 9 19:15:20 2026 +0100

    Fix #155: grant only manifest-listed Claude MCP tools
    
    Fixes #155
    
    Co-authored-by: OpenAI Codex <[email protected]>
---
 CHANGELOG.md                                       |   2 +
 docs/agent-architectures.md                        |   4 +
 docs/modules/ROOT/pages/agents.adoc                |   4 +
 docs/security-model.md                             |  51 ++--
 docs/security-model.yaml                           |  17 +-
 docs/security-verification.md                      |  22 ++
 .../main/resources/templates/claude/settings.json  |  49 +++-
 .../camel/kit/generator/ClaudeGeneratorTest.java   |  22 ++
 .../kit/generator/ShippedAssetStructureTest.java   |  15 ++
 tools/verify_claude_mcp_permissions.py             | 272 +++++++++++++++++++++
 10 files changed, 417 insertions(+), 41 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2862997a..b8951af1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -22,6 +22,8 @@ and this project adheres to [Semantic 
Versioning](https://semver.org/spec/v2.0.0
 
 ### Added
 
+- **Claude MCP permissions (#155)** — replace server-wide wildcard approvals 
with explicit manifest-listed tools, with generation and actual-host permission 
checks.
+
 - **Graph input boundaries (#154)** — reject outside-root and symlink inputs, 
bound source bytes and discovery, and apply the parser timeout to POM analysis.
 
 - **Parser source-reading regression coverage (#153)** — exercise all nine 
graph parsers with execution, class-loading, placeholder and external-entity 
canaries.
diff --git a/docs/agent-architectures.md b/docs/agent-architectures.md
index 3fca4a1c..7c8a8a37 100644
--- a/docs/agent-architectures.md
+++ b/docs/agent-architectures.md
@@ -549,3 +549,7 @@ container or VM concern.
 | Subagent recursion | Yes (no limit) | No (subagents must not spawn 
subagents) | Generated children do not delegate | No (parent-owned) | Unknown | 
N/A | Generated leaves and forks cannot dispatch | Primary executor to 
task-denying leaves |
 | Execute phase | Subagent with parallel dispatch | Parent task orchestrates 
subagents | Parent owns orchestration and report writes | Parent dispatches 
custom roles with inline fallback | Project skill delegates when available | 
Main Pi session | Primary session dispatches bounded leaves | Executor 
dispatches allowlisted implementation/research/review leaves |
 | Instruction composition | `CLAUDE.md` importing `AGENTS.md` | Shared skills 
+ Bob 2 traits + modes | `AGENTS.md` + `.agents/skills/` | `AGENTS.md` + 
`.agents/skills` | `.github/copilot-instructions.md` + project skills | 
`AGENTS.md` + `.pi/skills` | Single `QWEN.md` | Ultra-minimal `AGENTS.md` |
+
+### Claude MCP approvals
+
+Fresh and regenerated Claude workspaces pre-approve only the explicit MCP 
tools listed in the workflow manifest. Other server tools remain visible and 
require Claude Code permission. Regenerate older workspaces to replace 
server-wide wildcard grants; review user-level settings separately. The 
generated Read and Maven command grants retain their existing behavior.
diff --git a/docs/modules/ROOT/pages/agents.adoc 
b/docs/modules/ROOT/pages/agents.adoc
index 017fb0ae..895b3dd0 100644
--- a/docs/modules/ROOT/pages/agents.adoc
+++ b/docs/modules/ROOT/pages/agents.adoc
@@ -190,3 +190,7 @@ files. Move custom MCP servers from `.gemini/settings.json` 
to `.agents/mcp_conf
 and custom skills from `.gemini/skills/<name>/` to `.agents/skills/<name>/`. 
Bob 2 reinitialization replaces the
 generated gate skills and modes with the shared pipeline and native subagents, 
removing only the known obsolete Bob
 v1 mode-rule files.
+
+== Claude MCP approvals
+
+Fresh and regenerated Claude workspaces pre-approve only the explicit MCP 
tools listed in the workflow manifest. Other server tools remain visible and 
require Claude Code permission. Regenerate older workspaces to replace 
server-wide wildcard grants; review user-level settings separately. The 
generated Read and Maven command grants retain their existing behavior.
diff --git a/docs/security-model.md b/docs/security-model.md
index 28bfe1dc..0d3d7791 100644
--- a/docs/security-model.md
+++ b/docs/security-model.md
@@ -372,8 +372,8 @@ Camel-Kit is a family of postures. The knobs that move the 
security envelope:
 
 | Knob | Default | Effect on the model | Maintainer stance |
 | --- | --- | --- | --- |
-| `camel-kit init --ai <target>` | `bob2` | Selects which **generated harness 
controls** exist: Claude `settings.json` allow-list (`Read`, `Bash(mvn *)`, 
`Bash(./mvnw *)`, all `camel`, `camel-knowledge` and `citrus` MCP tools) and a 
read-only Ship worker (`tools: Read, Grep, Glob`); Codex read-only roles 
(`sandbox_mode = "read-only"`) and prompt-approved MCP tools; Copilot 
`preToolUse` deny hook; Pi guard extension and policy; OpenCode three-state 
permissions; Bob 2 modes; Qwen allow/blo [...]
-| Generated MCP tool lists (`enabled_tools`, `tools`, `directTools`, 
`includeTools`, `alwaysAllow`/`autoApprove`, OpenCode `permission`) and the 
Claude `settings.json` MCP rules | the workflow manifest's `allowed_tools` — 
"the read-only and generation helpers used by the skills", with "runtime 
mutation/control tools" left out *(documented — `docs/architecture.md` §6)* | 
**Availability filter** (only the listed tools exist for the agent): Codex 
`enabled_tools`, Copilot `tools`, Pi `direct [...]
+| `camel-kit init --ai <target>` | `bob2` | Selects which **generated harness 
controls** exist: Claude `settings.json` allow-list (`Read`, `Bash(mvn *)`, 
`Bash(./mvnw *)`, the manifest-listed `camel`, `camel-knowledge` and `citrus` 
MCP tools) and a read-only Ship worker (`tools: Read, Grep, Glob`); Codex 
read-only roles (`sandbox_mode = "read-only"`) and prompt-approved MCP tools; 
Copilot `preToolUse` deny hook; Pi guard extension and policy; OpenCode 
three-state permissions; Bob 2 modes [...]
+| Generated MCP tool lists (`enabled_tools`, `tools`, `directTools`, 
`includeTools`, `alwaysAllow`/`autoApprove`, OpenCode `permission`) and the 
Claude `settings.json` MCP rules | the workflow manifest's `allowed_tools` — 
"the read-only and generation helpers used by the skills", with "runtime 
mutation/control tools" left out *(documented — `docs/architecture.md` §6)* | 
**Availability filter** (only the listed tools exist for the agent): Codex 
`enabled_tools`, Copilot `tools`, Pi `direct [...]
 | `camel.mcp.repos`, `knowledge.mcp.repos`, `citrus.mcp.repos`, 
`camel.catalog.repos`; JBang launcher `//REPOS` | Maven Central, ASF public, 
**ASF snapshots, Central snapshots** *(documented — `distribution.properties`, 
`CamelKit.java`)* | Snapshot repositories serve mutable artifacts: "hosted 
snapshots are mutable and may lag `main`" *(documented — README)*. `-p` can 
repoint every list. | **Ruled 2026-10-27 (Q4):** snapshots are retained for 
development with their mutability documented; [...]
 | `camel-kit init --no-fetch` | off | Skips every `init`-time download; the 
workspace relies on pre-populated `.camel-kit/.cache`. | Documented; no 
security effect beyond removing network access. |
 | `camel-kit init --force` | off | Overwrites generated assets, including user 
customisations of generated files *(documented — `docs/commands.md`)*. | 
Data-loss, not security. |
@@ -614,21 +614,18 @@ violation warrants coordinated disclosure and a CVE; 
**hardening** means it is f
     (read-only and generation helpers, no runtime mutation or control tools 
*(documented — `docs/architecture.md`
     §6)*) is an availability filter for Codex, Copilot, Pi and Qwen and an 
auto-approval list for Bob 2; Codex,
     Copilot, Qwen and OpenCode prompt before MCP calls; Antigravity gets the 
full tool set with no generated
-    approval; Claude Code gets the full tool set and its generated 
`settings.json` pre-approves every tool of the
-    three servers through `mcp__<server>__*` rules *(documented — 
`templates/mcp-configs/*`,
-    `templates/claude/settings.json`, Claude Code permissions reference)* 
*(maintainer, 2026-10-22 — review of #6)* —
-    the **accepted** Claude posture is explicit per-tool grants aligned with 
the manifest, so the current generated
-    `settings.json` is a known deviation tracked as V3 *(maintainer, 
2026-10-27 — Q3)*;
+    approval; Claude Code gets the full tool set and its generated 
`settings.json` pre-approves only
+    explicit manifest-listed tools. `ClaudeGeneratorTest` and 
`ShippedAssetStructureTest` pin the exact
+    set, and actual Claude Code probes confirm an unlisted call asks before 
dispatch
+    ([V3 evidence](security-verification.md#v3-claude-permissions)). This 
implements the accepted
+    posture *(maintainer, 2026-10-27 — Q3)*;
     the generated Camel and Citrus MCP launch commands disable the Quarkus 
HTTP port (`-Dquarkus.http.port=-1`)
     *(documented — `mcp-setup.md`)*. The claim is about the **generated 
artifact**; enforcement is the harness's
     (§4.9). *Violation:* a generated asset grants more than the accepted 
posture — a worker with edit or shell tools, a
     filter or auto-approval list containing a tool outside the manifest's 
`allowed_tools`, a prompt-mode target
-    generated with auto-approval, a hook or guard that fails open on a pattern 
it documents denying. The known Claude
-    wildcard pre-approval is a documented deviation, not a breach of this 
property: a report that only restates it is
-    tracked under V3 (#155) as `VALID-HARDENING` until the template changes. A 
report that demonstrates what the grant
-    lets through — a credential read or a destructive action reaching the 
developer's account without a prompt, or
-    any other in-scope boundary breach — is evaluated on that breach and its 
impact under the tiers below; it is
-    neither downgraded to V3 nor closed as its duplicate *(maintainer, 
2026-10-27 — review of #6)*.
+    generated with auto-approval, a hook or guard that fails open on a pattern 
it documents denying. A widened grant is evaluated on the breach and its impact;
+    the former V3 wildcard exception no longer applies to newly generated 
workspaces. Historical
+    reports against the earlier template retain the 2026-10-27 review of #6 
disposition.
     *Severity:* hardening; security-critical when the widened grant reaches a 
credential or destructive action
     without a prompt.
 11. **P11 — Stated resource bounds.** Route YAML and Citrus YAML ≤ 2 MiB each 
*(documented —
@@ -796,8 +793,7 @@ For the **developer** (who is also the route author and the 
operator of the mach
 2. **Keep the harness's permission model on.** Do not run Camel-Kit skills 
under a harness mode that skips
    permission prompts for shell or file tools; review the generated 
allow-list, hook or guard before trusting the
    workspace in the harness, and treat the generated Claude `Bash(mvn *)` / 
`Bash(./mvnw *)` pre-approval as a
-   deliberate grant. Until V3 lands, narrow the generated Claude 
`mcp__<server>__*` rules to the tools you use; the
-   wildcard is not the accepted posture *(maintainer, 2026-10-27 — Q3)*.
+   deliberate grant. Regenerate older Claude workspaces to replace the former 
server-wide MCP wildcard grants with the explicit manifest list. Review local 
settings and user-level grants too.
 3. **Review generated routes, `pom.xml`, tests and configuration before 
running them**, and run them under the
    Apache Camel deployment hardening guidance (production profile, security 
policy enforcement, vaults, TLS). Camel-Kit's
    validation is static (§4.9).
@@ -895,11 +891,9 @@ Suitable as a suppression or negative-prompt list for 
scanners and AI-assisted t
   target's transport before the hop (apache/camel-kit#143), so a hit on its 
redirect handling is a non-finding only
   while that check stands.
 - **The generated Claude `settings.json` pre-approving `mvn` and `./mvnw`**: 
documented posture (§4.5a).
-- **The generated Claude `settings.json` pre-approving every tool of the three 
MCP servers (`mcp__<server>__*`)**: a
-  known deviation from the accepted posture, tracked as V3 (#155) and triaged 
`VALID-HARDENING` when the report only
-  restates the configuration; duplicates of that report are closed against V3, 
not opened as new findings. A report
-  that demonstrates a breach *through* the grant is not a non-finding: it is 
triaged on the breach and its impact
-  (P10).
+- **An older Claude workspace retaining the former server-wide MCP wildcard 
grants**: the pre-V3
+  configuration was tracked by #155. Regenerate it to receive the explicit 
manifest list. A new
+  generated wildcard is a P10 regression; any demonstrated breach is triaged 
on its impact.
 - **A Pi-backend Ship run publishing an ordinary additional material file** 
(documentation, test data): the
   documented acceptance behaviour of P5, not a violation; the launch surface 
and the declared artifacts are what
   validation bounds.
@@ -955,7 +949,7 @@ match the Apache Camel Security Model so a triager can cite 
either document.
 | Disposition | Meaning | Licensed by |
 | --- | --- | --- |
 | `VALID` | Violates a §4.8 property through an in-scope adversary (§4.7) and 
an attacker-controllable input (§4.6). Fixed in a coordinated release; 
published as a CVE. | §4.8, §4.6, §4.7 |
-| `VALID-HARDENING` | No §4.8 property is violated, but a §4.11 misuse is easy 
enough, or a §4.11 limit fails its documented value, that the project elects to 
harden; also a report that only restates a documented deviation tracked by an 
open §4.14.2 item (today V3). Reported privately; usually no CVE. | §4.11, §4.8 
P9, P11, §4.14.2 |
+| `VALID-HARDENING` | No §4.8 property is violated, but a §4.11 misuse is easy 
enough, or a §4.11 limit fails its documented value, that the project elects to 
harden; also a report that only restates a documented deviation tracked by an 
open §4.14.2 item (historically V3). Reported privately; usually no CVE. | 
§4.11, §4.8 P9, P11, §4.14.2 |
 | `OUT-OF-MODEL: trusted-input` | Requires control of a trusted input: CLI 
flags, config files, the developer's approval of a design, or the developer's 
own OS account. Covers "the user asked for an insecure route". | §4.6, §4.2 |
 | `OUT-OF-MODEL: adversary-not-in-scope` | Requires an actor §4.7 excludes: 
the developer or a same-user process, a compromised harness or provider, a 
hostile pinned origin, a transitive-dependency author. Covers operator 
misconfiguration of the host or harness. | §4.7 |
 | `OUT-OF-MODEL: unsupported-component` | Lands in family F (external MCP 
servers — route to Apache Camel or Citrus; Knowledge is family K, in model), G 
(the generated integration's runtime — route to the Apache Camel Security 
Model) or H (shipped-but-unsupported code). | §4.3, §4.2 |
@@ -968,10 +962,10 @@ A report that demonstrates an outside-root read, 
non-termination or unbounded me
 `VALID` under P1 whatever the status of V1 and V2; it is neither `BY-DESIGN` 
nor a `MODEL-GAP`. A Pi-backend run
 that publishes an ordinary additional material file is the documented 
behaviour of P5, not a violation. A report that
 matches an open verification item (§4.14.2) is triaged on the model's claim: a 
breach of a claimed property is `VALID`
-while the item is open. The Claude wildcard MCP grant (V3) is the one 
documented deviation: a report that only restates
-the configuration is `VALID-HARDENING` until the generated default changes, 
while a report that demonstrates a breach
-through the grant is evaluated on that breach and its impact under P10's tiers 
and is never downgraded to V3 or closed
-as its duplicate *(maintainer, 2026-10-27 — review of #6)*. Knowledge findings 
use the same dispositions (§4.4a).
+while the item is open. The former Claude wildcard MCP grant (V3) was a 
documented deviation until #155.
+A regenerated workspace uses explicit grants; a future widened grant is 
evaluated under P10.
+The historical configuration-only disposition does not excuse a demonstrated 
breach
+*(maintainer, 2026-10-27 — review of #6)*. Knowledge findings use the same 
dispositions (§4.4a).
 
 Mapping the categories named in apache/camel-kit#5: a **framework 
vulnerability** is `VALID`; an **LLM
 hallucination** or insecure suggestion is `BY-DESIGN: property-disclaimed` 
unless a documented tool, permission, parser or controller boundary failed
@@ -1044,11 +1038,8 @@ own issue, linked below.
 
 - **V2 — Graph confinement and resource bounds (Q2; #154): implemented.** 
`GraphInputBoundaryTest` covers symlink/outside inputs, selected-root aliases, 
oversized files and aggregate input, large unrelated artifacts and the POM 
timeout. [V2 evidence](security-verification.md#v2-graph-inputs) states the 
bounds and cancellation limits. A demonstrated P1 breach remains `VALID`.
 
-- **V3 — Narrower Claude MCP grants (Q3; #155) → §4.5a, §4.8 P10, §4.11a.** 
Replace `mcp__camel__*`,
-  `mcp__camel-knowledge__*` and `mcp__citrus__*` in the generated 
`settings.json` with explicit per-tool grants aligned
-  with the workflow manifest's `allowed_tools`, and verify the generated 
behaviour in Claude Code. *Interim triage:* a
-  report that only restates the current wildcard grant is `VALID-HARDENING` 
and is closed against this item; a report
-  that demonstrates a breach through the grant is triaged on the breach and 
its impact under P10, never as a duplicate.
+- **V3 — Narrower Claude MCP grants (Q3; #155): implemented.** Fresh and 
repeated generation emit exactly the manifest-listed MCP grants. Real Claude 
Code 2.1.316 probes cover allowed and unlisted tools for all three servers, 
plus wildcard/removal controls. [V3 
evidence](security-verification.md#v3-claude-permissions).
+
 - **V4 — Verified per-process side-effect inventory (Q6; #156) → §4.5, 
§4.4a.** Maintainer verification of every row of the
   §4.5 inventory — Kit CLI, graph analysis, Ship controller, Pi worker, 
evidence JVMs, Knowledge server, Knowledge
   indexer — for listeners, child processes, network origins, filesystem 
writes, environment reads, telemetry and
diff --git a/docs/security-model.yaml b/docs/security-model.yaml
index 622dc261..8f960713 100644
--- a/docs/security-model.yaml
+++ b/docs/security-model.yaml
@@ -200,9 +200,9 @@ configuration_variants:
     stance: documented per target in 4.5a; the Claude MCP grant is V3
   - knob: Generated MCP tool lists and Claude settings.json MCP rules
     default: workflow manifest allowed_tools (read-only and generation 
helpers, no runtime mutation or control tools)
-    effect: Availability filter - Codex enabled_tools, Copilot tools, Pi 
directTools, Qwen includeTools. Approval only - Bob 2 alwaysAllow/autoApprove = 
manifest list; Claude Code settings.json permissions.allow mcp__camel__*, 
mcp__camel-knowledge__*, mcp__citrus__* = every tool of each server (.mcp.json 
autoApprove/alwaysAllow keys are not a Claude Code filter). Prompt before every 
call - Codex default_tools_approval_mode prompt, OpenCode permission ask, Qwen 
and Copilot prompts. Antigr [...]
+    effect: Availability filter - Codex enabled_tools, Copilot tools, Pi 
directTools, Qwen includeTools. Approval only - Bob 2 alwaysAllow/autoApprove = 
manifest list; Claude Code settings.json permissions.allow explicit 
mcp__<server>__<tool> entries = manifest-listed tools only (.mcp.json 
autoApprove/alwaysAllow keys are not a Claude Code filter). Prompt before every 
call - Codex default_tools_approval_mode prompt, OpenCode permission ask, Qwen 
and Copilot prompts. Antigravity - full to [...]
     security_relevant: true
-    stance: ruled 2026-10-27 - the Claude wildcard pre-approval is NOT the 
accepted posture; accepted posture is explicit grants aligned with the manifest 
allowed_tools, verified in the generated behaviour (V3). Until then the 
generated default is a documented deviation triaged VALID-HARDENING.
+    stance: ruled 2026-10-27 - the Claude wildcard pre-approval is NOT the 
accepted posture; accepted posture is explicit grants aligned with the manifest 
allowed_tools, verified in the generated behaviour (V3). Implemented by V3; old 
workspaces require regeneration; see security-verification.md.
   - knob: camel.mcp.repos, knowledge.mcp.repos, citrus.mcp.repos, 
camel.catalog.repos, JBang //REPOS
     default: Maven Central, ASF public, ASF snapshots, Central snapshots
     effect: Snapshot repositories serve mutable artifacts.
@@ -326,8 +326,8 @@ claimed_properties:
     severity: hardening
     provenance: documented (ShipExpressionPolicy)
   - id: P10
-    property: Generated harness assets carry the accepted least-privilege 
posture - read-only Ship workers, read-only Codex roles, Copilot deny hook, Pi 
guard policy, no HTTP port for the Camel and Citrus MCP servers, and MCP grants 
aligned with the manifest allowed_tools (no runtime mutation or control tools) 
- as availability filters for Codex (enabled_tools), Copilot (tools), Pi 
(directTools) and Qwen (includeTools), as Bob 2's auto-approval list, with 
Codex, Copilot, Qwen and OpenCod [...]
-    violation_symptom: "Generated asset grants more than the accepted posture 
- a worker with edit or shell tools, a filter or auto-approval list containing 
a tool outside the manifest allowed_tools, a prompt-mode target generated with 
auto-approval, a hook or guard that fails open on a documented pattern. The 
known Claude wildcard pre-approval is a documented deviation, not a breach - a 
report that only restates it is tracked under V3 (#155) as VALID-HARDENING 
until the template changes [...]
+    property: Generated harness assets carry the accepted least-privilege 
posture - read-only Ship workers, read-only Codex roles, Copilot deny hook, Pi 
guard policy, no HTTP port for the Camel and Citrus MCP servers, and MCP grants 
aligned with the manifest allowed_tools (no runtime mutation or control tools) 
- as availability filters for Codex (enabled_tools), Copilot (tools), Pi 
(directTools) and Qwen (includeTools), as Bob 2's auto-approval list, with 
Codex, Copilot, Qwen and OpenCod [...]
+    violation_symptom: "Generated asset grants more than the accepted posture: 
a worker with edit/shell tools, a filter or auto-approval list wider than 
manifest allowed_tools, a prompt-mode target auto-approved, or a hook/guard 
failing open on a documented pattern. Former wildcard exception applied only to 
the pre-V3 template (review of #6, 2026-10-27); newly generated wildcards are 
regressions. Any demonstrated breach is evaluated on its impact."
     severity: hardening; security-critical when the widened grant reaches a 
credential or destructive action without a prompt
     provenance: "documented (docs/agent-architectures.md, docs/ship-claude.md, 
docs/ship-native.md, templates/mcp-configs/*, templates/claude/settings.json, 
Claude Code permissions reference); current Claude grant - maintainer, 
2026-10-22 (review of #6); accepted posture - maintainer, 2026-10-27 (Q3)"
   - id: P11
@@ -453,7 +453,7 @@ known_non_findings:
   - pattern: Generated Claude settings.json pre-approving mvn and ./mvnw
     why: Documented posture (4.5a).
     disposition: KNOWN-NON-FINDING
-  - pattern: Generated Claude settings.json pre-approving every tool of the 
three MCP servers (mcp__<server>__*)
+  - pattern: Older pre-V3 Claude workspace retaining server-wide MCP grants
     why: Known deviation from the accepted posture, tracked as V3 (#155); 
duplicates of a report that only restates the configuration are closed against 
V3. A demonstrated breach through the grant is not a non-finding - it is 
triaged on the breach and its impact (P10).
     disposition: VALID-HARDENING (tracked) for configuration-only reports; 
VALID when a breach through the grant is demonstrated
   - pattern: A Pi-backend Ship run publishing an ordinary additional material 
file (documentation, test data)
@@ -514,7 +514,7 @@ issue_5_category_mapping:
 triage_notes:
   - Outside-root read, non-termination or unbounded memory during graph 
analysis is VALID under P1 regardless of the verification status in V1/V2 - 
neither BY-DESIGN nor MODEL-GAP.
   - A Pi-backend Ship run publishing an ordinary additional material file is 
documented P5 behaviour, not a violation.
-  - A report matching an open verification item is triaged on the model's 
claim - a breach of a claimed property is VALID while the item is open. The 
Claude wildcard MCP grant (V3) is the one documented deviation - a report that 
only restates the configuration is VALID-HARDENING until the generated default 
changes, while a report that demonstrates a breach through the grant is 
evaluated on that breach and its impact under P10 and is never downgraded to V3 
or closed as its duplicate (main [...]
+  - A report matching an open verification item is triaged on the model's 
claim - a breach of a claimed property is VALID while the item is open. The 
historical pre-V3 Claude wildcard MCP grant was a documented deviation - a 
report that only restates the configuration is VALID-HARDENING until the 
generated default changes, while a report that demonstrates a breach through 
the grant is evaluated on that breach and its impact under P10 and is never 
downgraded to V3 or closed as its duplica [...]
   - Knowledge findings use the same dispositions; the indexer's inputs are 
trusted upstream sources, so a finding there needs hostile upstream content 
(adversary not in scope) unless the indexer breaks its own path or process 
boundary.
 
 maintainer_decisions:
@@ -597,8 +597,9 @@ verification_items:
     from: [Q3]
     sections: ["4.5a", "4.8 P10", "4.11a"]
     work: Replace mcp__camel__*, mcp__camel-knowledge__* and mcp__citrus__* 
with explicit per-tool grants aligned with the workflow manifest allowed_tools, 
and verify the generated behaviour in Claude Code.
-    interim_triage: A report that only restates the current wildcard grant is 
VALID-HARDENING and is closed against this item; a report that demonstrates a 
breach through the grant is triaged on the breach and its impact under P10, 
never as a duplicate.
-    status: open
+    interim_triage: "New generated wildcard grants violate P10. Historical 
configuration-only reports retain the pre-V3 disposition; demonstrated breaches 
remain evaluated on impact."
+    status: "implemented"
+    evidence: "ClaudeGeneratorTest, ShippedAssetStructureTest; actual Claude 
Code 2.1.316 generated-output probe with12 permission/dispatch cases; 
security-verification.md V3"
   - id: V4
     tracking: apache/camel-kit#156
     title: Verified per-process side-effect inventory
diff --git a/docs/security-verification.md b/docs/security-verification.md
index ad2f4bfc..636734fd 100644
--- a/docs/security-verification.md
+++ b/docs/security-verification.md
@@ -35,3 +35,25 @@ A timed-out parser's fragment is discarded; daemon threads 
do not hold the CLI o
 Cancellation remains cooperative, and parser/library expansion can exceed the 
input byte size:
 there is no hard heap quota. Filesystem checks cover a pre-existing hostile 
tree, not concurrent
 same-account directory substitution. Website impact: required; graph reference 
updated.
+
+
+## V3: Claude permissions
+
+`ClaudeGeneratorTest` tests fresh and repeated generation; 
`ShippedAssetStructureTest` compares
+the shipped settings to the manifest. Both require exactly the 46 current MCP 
grants, with no
+server-wide wildcard or duplicate. Existing Read and Maven command grants are 
unchanged.
+
+Actual Claude Code 2.1.316 was run with generated settings, isolated 
configuration, a benign
+stdio MCP fixture and a loopback model API fixture. For each of camel, 
camel-knowledge and citrus:
+
+| Rule/tool | Observed behavior |
+| --- | --- |
+| Listed tool, exact grant | Dispatched without a permission request |
+| Unlisted tool, exact grants | Host permission request before dispatch; 
denied; zero MCP calls |
+| Same unlisted tool, former wildcard restored | Dispatched without a 
permission request |
+| Listed tool, its exact grant removed | Host permission request; denied; zero 
MCP calls |
+
+All 12 cases passed. The fixture drives the actual CLI permission protocol 
without a remote
+model account; it does not certify future Claude releases or unrelated 
user-level grants.
+The reproducible harness is `tools/verify_claude_mcp_permissions.py`; use 
`--help` for paths.
+Website impact: required; agent reference updated. Existing workspaces need 
regeneration.
diff --git 
a/kit/camel-kit-core/src/main/resources/templates/claude/settings.json 
b/kit/camel-kit-core/src/main/resources/templates/claude/settings.json
index 495f4fda..eb559e00 100644
--- a/kit/camel-kit-core/src/main/resources/templates/claude/settings.json
+++ b/kit/camel-kit-core/src/main/resources/templates/claude/settings.json
@@ -4,9 +4,52 @@
       "Read",
       "Bash(mvn *)",
       "Bash(./mvnw *)",
-      "mcp__camel__*",
-      "mcp__camel-knowledge__*",
-      "mcp__citrus__*"
+      "mcp__camel__camel_catalog_components",
+      "mcp__camel__camel_catalog_component_doc",
+      "mcp__camel__camel_catalog_component_maven",
+      "mcp__camel__camel_catalog_dataformats",
+      "mcp__camel__camel_catalog_dataformat_doc",
+      "mcp__camel__camel_catalog_languages",
+      "mcp__camel__camel_catalog_language_doc",
+      "mcp__camel__camel_catalog_eips",
+      "mcp__camel__camel_catalog_eip_doc",
+      "mcp__camel__camel_catalog_kamelets",
+      "mcp__camel__camel_catalog_kamelet_doc",
+      "mcp__camel__camel_catalog_examples",
+      "mcp__camel__camel_catalog_example_file",
+      "mcp__camel__camel_validate_route",
+      "mcp__camel__camel_validate_yaml_dsl",
+      "mcp__camel__camel_transform_route",
+      "mcp__camel__camel_route_context",
+      "mcp__camel__camel_route_harden_context",
+      "mcp__camel__camel_route_test_scaffold",
+      "mcp__camel__camel_component_properties",
+      "mcp__camel__camel_configuration_validate",
+      "mcp__camel__camel_dependency_check",
+      "mcp__camel__camel_error_diagnose",
+      "mcp__camel__camel_properties_translate",
+      "mcp__camel__camel_version_list",
+      "mcp__camel__camel_migration_analyze",
+      "mcp__camel__camel_migration_compatibility",
+      "mcp__camel__camel_migration_recipes",
+      "mcp__camel__camel_migration_guide_search",
+      "mcp__camel__camel_migration_wildfly_karaf",
+      "mcp__camel__camel_openapi_validate",
+      "mcp__camel__camel_openapi_scaffold",
+      "mcp__camel__camel_openapi_mock_guidance",
+      "mcp__camel-knowledge__camel_docs_component_info",
+      "mcp__camel-knowledge__camel_docs_search",
+      "mcp__camel-knowledge__camel_docs_cve_search",
+      "mcp__camel-knowledge__camel_docs_release_info",
+      "mcp__camel-knowledge__camel_docs_jira_lookup",
+      "mcp__citrus__citrus_catalog_actions",
+      "mcp__citrus__citrus_catalog_action",
+      "mcp__citrus__citrus_catalog_action_schema",
+      "mcp__citrus__citrus_catalog_endpoints",
+      "mcp__citrus__citrus_catalog_endpoint",
+      "mcp__citrus__citrus_catalog_endpoint_schema",
+      "mcp__citrus__citrus_docs_index",
+      "mcp__citrus__citrus_docs_page"
     ]
   }
 }
diff --git 
a/kit/camel-kit-core/src/test/java/org/apache/camel/kit/generator/ClaudeGeneratorTest.java
 
b/kit/camel-kit-core/src/test/java/org/apache/camel/kit/generator/ClaudeGeneratorTest.java
index 98e75edc..2b00e03d 100644
--- 
a/kit/camel-kit-core/src/test/java/org/apache/camel/kit/generator/ClaudeGeneratorTest.java
+++ 
b/kit/camel-kit-core/src/test/java/org/apache/camel/kit/generator/ClaudeGeneratorTest.java
@@ -2,12 +2,15 @@ package org.apache.camel.kit.generator;
 
 import java.nio.file.Files;
 import java.nio.file.Path;
+import java.util.HashSet;
 import java.util.Set;
 
 import org.apache.camel.kit.config.AgentConfig;
 import org.apache.camel.kit.config.AgentRegistry;
 import org.apache.camel.kit.output.Printer;
+import org.apache.camel.kit.workflow.WorkflowManifestLoader;
 
+import com.fasterxml.jackson.databind.ObjectMapper;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.io.TempDir;
 
@@ -34,6 +37,25 @@ class ClaudeGeneratorTest {
                 "camel-kit", Printer.noop());
     }
 
+    @Test
+    void grantsOnlyManifestMcpToolsOnFreshAndRepeatedGeneration() throws 
Exception {
+        Set<String> expected = new HashSet<>();
+        WorkflowManifestLoader.loadDefault().mcpServers().forEach(server -> 
server.allowedTools()
+                .forEach(tool -> expected.add("mcp__" + server.id() + "__" + 
tool)));
+        for (int initialization = 0; initialization < 2; initialization++) {
+            new ClaudeGenerator().generate(createContext());
+            var allow = new 
ObjectMapper().readTree(tempDir.resolve(".claude/settings.json").toFile())
+                    .path("permissions").path("allow");
+            Set<String> actual = new HashSet<>();
+            allow.forEach(rule -> {
+                if (rule.asText().startsWith("mcp__")) {
+                    assertTrue(actual.add(rule.asText()), "Duplicate MCP 
permission: " + rule);
+                }
+            });
+            assertEquals(expected, actual);
+        }
+    }
+
     @Test
     void generatesClaudeMd() throws Exception {
         InitContext ctx = createContext();
diff --git 
a/kit/camel-kit-core/src/test/java/org/apache/camel/kit/generator/ShippedAssetStructureTest.java
 
b/kit/camel-kit-core/src/test/java/org/apache/camel/kit/generator/ShippedAssetStructureTest.java
index fbba41a6..91134cb1 100644
--- 
a/kit/camel-kit-core/src/test/java/org/apache/camel/kit/generator/ShippedAssetStructureTest.java
+++ 
b/kit/camel-kit-core/src/test/java/org/apache/camel/kit/generator/ShippedAssetStructureTest.java
@@ -56,6 +56,21 @@ class ShippedAssetStructureTest {
     @TempDir
     Path tempDir;
 
+    @Test
+    void claudeSettingsPreapproveExactlyTheManifestTools() throws Exception {
+        Set<String> expected = 
WorkflowManifestLoader.loadDefault().mcpServers().stream()
+                .flatMap(server -> server.allowedTools().stream().map(tool -> 
"mcp__" + server.id() + "__" + tool))
+                .collect(Collectors.toSet());
+        Set<String> actual = new java.util.HashSet<>();
+        
MAPPER.readTree(resourcePath("templates/claude/settings.json").toFile())
+                .path("permissions").path("allow").forEach(rule -> {
+                    if (rule.asText().startsWith("mcp__")) {
+                        assertTrue(actual.add(rule.asText()), "Duplicate MCP 
permission: " + rule);
+                    }
+                });
+        assertEquals(expected, actual);
+    }
+
     @Test
     void workflowManifestSkillsHaveShippedSkillAssets() throws Exception {
         WorkflowManifest manifest = WorkflowManifestLoader.loadDefault();
diff --git a/tools/verify_claude_mcp_permissions.py 
b/tools/verify_claude_mcp_permissions.py
new file mode 100644
index 00000000..49a74739
--- /dev/null
+++ b/tools/verify_claude_mcp_permissions.py
@@ -0,0 +1,272 @@
+#!/usr/bin/env python3
+"""Exercise actual Claude MCP permissions using only local deterministic 
servers."""
+import argparse
+import hashlib
+import http.server
+import json
+import os
+from pathlib import Path
+import queue
+import subprocess
+import sys
+import threading
+import time
+import uuid
+
+
+def mcp_main(log, tools):
+    for line in sys.stdin:
+        message = json.loads(line)
+        method = message.get("method")
+        if "id" not in message:
+            continue
+        if method == "initialize":
+            result = {"protocolVersion": message["params"]["protocolVersion"],
+                      "capabilities": {"tools": {}}, "serverInfo": {"name": 
"permission-canary", "version": "1"}}
+        elif method == "tools/list":
+            result = {"tools": [{"name": tool, "description": "Local 
permission canary; records its own name only.",
+                                  "inputSchema": {"type": "object", 
"properties": {}, "additionalProperties": False}}
+                                 for tool in tools]}
+        elif method == "tools/call":
+            name = message["params"]["name"]
+            with open(log, "a") as output:
+                output.write(json.dumps({"tool": name, "time_ns": 
time.time_ns()}) + "\n")
+            result = {"content": [{"type": "text", "text": "benign local 
canary called"}]}
+        else:
+            result = {}
+        print(json.dumps({"jsonrpc": "2.0", "id": message["id"], "result": 
result}), flush=True)
+
+
+class Api(http.server.BaseHTTPRequestHandler):
+    def log_message(self, *args):
+        pass
+
+    def do_POST(self):
+        body = 
json.loads(self.rfile.read(int(self.headers.get("Content-Length", "0"))) or 
b"{}")
+        self.server.requests.append({"path": self.path, "tools": 
[t.get("name") for t in body.get("tools", [])]})
+        if "count_tokens" in self.path:
+            return self.send_json({"input_tokens": 50})
+        if not self.path.startswith("/v1/messages"):
+            return self.send_json({})
+        tool_result = any(isinstance(message.get("content"), list) and any(
+            block.get("type") == "tool_result" for block in message["content"])
+            for message in body.get("messages", []))
+        if tool_result:
+            content = [{"type": "text", "text": "Permission canary finished."}]
+            stop_reason = "end_turn"
+        else:
+            content = [{"type": "tool_use", "id": "toolu_canary", "name": 
self.server.tool, "input": {}}]
+            stop_reason = "tool_use"
+        response = {"id": "msg_canary", "type": "message", "role": "assistant",
+                    "model": body.get("model", "claude-sonnet-4-5"), 
"content": content,
+                    "stop_reason": stop_reason, "stop_sequence": None,
+                    "usage": {"input_tokens": 50, "output_tokens": 10}}
+        if not body.get("stream"):
+            return self.send_json(response)
+        events = [("message_start", {"type": "message_start", "message": 
dict(response, content=[], stop_reason=None)})]
+        for index, block in enumerate(content):
+            events.append(("content_block_start", {"type": 
"content_block_start", "index": index,
+                                                   "content_block": block}))
+            events.append(("content_block_stop", {"type": 
"content_block_stop", "index": index}))
+        events.extend([
+            ("message_delta", {"type": "message_delta", "delta": 
{"stop_reason": stop_reason, "stop_sequence": None},
+                               "usage": {"output_tokens": 10}}),
+            ("message_stop", {"type": "message_stop"})])
+        data = "".join(f"event: {event}\ndata: {json.dumps(value)}\n\n" for 
event, value in events).encode()
+        self.send_response(200)
+        self.send_header("Content-Type", "text/event-stream")
+        self.send_header("Content-Length", str(len(data)))
+        self.end_headers()
+        self.wfile.write(data)
+
+    def send_json(self, value):
+        data = json.dumps(value).encode()
+        self.send_response(200)
+        self.send_header("Content-Type", "application/json")
+        self.send_header("Content-Length", str(len(data)))
+        self.end_headers()
+        self.wfile.write(data)
+
+    def do_CONNECT(self):
+        self.server.requests.append({"blocked_proxy_connect": self.path})
+        self.send_error(502, "Only loopback canary traffic is permitted")
+
+
+def run_case(cli, output, name, settings, server_name, target, all_tools, 
expect_prompt):
+    case = output / name
+    case.mkdir(parents=True, exist_ok=False)
+    config = case / "config"
+    config.mkdir()
+    workspace = case / "workspace"
+    workspace.mkdir()
+    settings_file = case / "settings.json"
+    settings_file.write_text(json.dumps(settings, indent=2) + "\n")
+    mcp_log = case / "mcp-calls.jsonl"
+    mcp_config = case / "mcp.json"
+    mcp_config.write_text(json.dumps({"mcpServers": {server_name: {
+        "command": sys.executable, "args": [str(Path(__file__).resolve()), 
"mcp", str(mcp_log), *all_tools]}}}))
+    api = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Api)
+    api.requests = []
+    api.tool = f"mcp__{server_name}__{target}"
+    thread = threading.Thread(target=api.serve_forever, daemon=True)
+    thread.start()
+    url = f"http://127.0.0.1:{api.server_port}";
+    env = {key: value for key, value in os.environ.items() if key in ["PATH", 
"LANG", "LC_ALL", "TZ", "TERM"]}
+    env.update({"CLAUDE_CONFIG_DIR": str(config), "ANTHROPIC_API_KEY": 
"local-canary-only",
+                "ANTHROPIC_BASE_URL": url, 
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1",
+                "DISABLE_TELEMETRY": "1", "DISABLE_ERROR_REPORTING": "1", 
"DISABLE_AUTOUPDATER": "1",
+                "ENABLE_TOOL_SEARCH": "false", "HTTP_PROXY": url, 
"HTTPS_PROXY": url,
+                "NO_PROXY": "localhost,127.0.0.1,::1"})
+    cmd = [cli, "--bare", "--print", "--verbose", "--setting-sources", "", 
"--strict-mcp-config",
+           "--mcp-config", str(mcp_config), "--settings", str(settings_file), 
"--permission-mode", "manual",
+           "--permission-prompts", "host", "--permission-prompt-tool", 
"stdio", "--tools", "",
+           "--input-format", "stream-json", "--output-format", "stream-json", 
"--no-session-persistence",
+           "--model", "claude-sonnet-4-5", "--max-turns", "3"]
+    events, prompts = [], []
+    proc = None
+    result = None
+    try:
+        with (case / "stderr.log").open("w") as stderr:
+            proc = subprocess.Popen(cmd, cwd=workspace, env=env, 
stdin=subprocess.PIPE,
+                                    stdout=subprocess.PIPE, stderr=stderr, 
text=True)
+            messages = queue.Queue()
+            def reader():
+                for line in proc.stdout:
+                    messages.put(line)
+                messages.put(None)
+            threading.Thread(target=reader, daemon=True).start()
+            def send(message):
+                proc.stdin.write(json.dumps(message) + "\n")
+                proc.stdin.flush()
+            send({"type": "control_request", "request_id": "initialize-canary",
+                  "request": {"subtype": "initialize", "hooks": None}})
+            deadline = time.monotonic() + 45
+            user_sent = False
+            while time.monotonic() < deadline:
+                try:
+                    line = messages.get(timeout=min(1, deadline - 
time.monotonic()))
+                except queue.Empty:
+                    continue
+                if line is None:
+                    break
+                message = json.loads(line)
+                events.append(message)
+                if message.get("type") == "control_response" and not user_sent:
+                    send({"type": "user", "message": {"role": "user", 
"content": "Run the local permission canary."},
+                          "parent_tool_use_id": None, "session_id": "", 
"uuid": str(uuid.uuid4())})
+                    user_sent = True
+                if message.get("type") == "control_request":
+                    request = message["request"]
+                    assert request["subtype"] == "can_use_tool", request
+                    assert request["tool_name"] == api.tool, request
+                    before = mcp_log.read_text() if mcp_log.exists() else ""
+                    prompts.append({"request": request, "calls_before_prompt": 
before})
+                    send({"type": "control_response", "response": {"subtype": 
"success",
+                          "request_id": message["request_id"], "response": 
{"behavior": "deny",
+                          "message": "Denied by the deterministic permission 
canary."}}})
+                if message.get("type") == "result":
+                    result = message
+                    break
+            proc.stdin.close()
+            try:
+                returncode = proc.wait(timeout=5)
+            except subprocess.TimeoutExpired:
+                proc.terminate()
+                returncode = proc.wait(timeout=5)
+        calls = [json.loads(line) for line in 
mcp_log.read_text().splitlines()] if mcp_log.exists() else []
+        summary = {"case": name, "server": server_name, "tool": target,
+                   "expected_prompt": expect_prompt, "prompts": prompts, 
"calls": calls,
+                   "result": result, "returncode": returncode,
+                   "settings_sha256": 
hashlib.sha256(settings_file.read_bytes()).hexdigest()}
+        (case / "events.json").write_text(json.dumps(events, indent=2) + "\n")
+        (case / "api-requests.json").write_text(json.dumps(api.requests, 
indent=2) + "\n")
+        (case / "summary.json").write_text(json.dumps(summary, indent=2) + 
"\n")
+        assert result is not None, f"{name}: no result; inspect {case}"
+        assert returncode == 0, f"{name}: exit {returncode}; inspect {case}"
+        assert not result.get("is_error"), summary
+        assert any(api.tool in request.get("tools", []) for request in 
api.requests), "Target was not exposed to actual CLI"
+        if expect_prompt:
+            assert len(prompts) == 1 and not calls, summary
+            assert prompts[0]["calls_before_prompt"] == "", summary
+        else:
+            assert not prompts and [call["tool"] for call in calls] == 
[target], summary
+        return summary
+    finally:
+        if proc is not None and proc.poll() is None:
+            proc.kill()
+            proc.wait()
+        api.shutdown()
+        api.server_close()
+
+
+def manifest_grants(path):
+    """Read this manifest's simple server/tool lists without installing 
dependencies."""
+    servers = {}
+    server = None
+    in_servers = False
+    in_tools = False
+    for line in path.read_text().splitlines():
+        if line == "mcp_servers:":
+            in_servers = True
+        elif in_servers and line and not line.startswith(" "):
+            break
+        elif in_servers and line.startswith("  - id: "):
+            server = line.partition(": ")[2]
+            servers[server] = []
+            in_tools = False
+        elif in_servers and line == "    allowed_tools:":
+            in_tools = True
+        elif in_tools and line.startswith("      - "):
+            servers[server].append(line[8:])
+    assert servers and all(servers.values())
+    return servers
+
+
+def main():
+    parser = argparse.ArgumentParser()
+    parser.add_argument("--repo", type=Path, required=True)
+    parser.add_argument("--output", type=Path, required=True)
+    parser.add_argument("--cli", default="claude")
+    parser.add_argument("--server", default="all")
+    parser.add_argument("--settings", type=Path, help="Actual generated 
settings to verify, instead of the candidate grants")
+    args = parser.parse_args()
+    args.output.mkdir(parents=True, exist_ok=True)
+    resources = args.repo / "kit/camel-kit-core/src/main/resources"
+    original = json.loads((resources / 
"templates/claude/settings.json").read_text())
+    servers = manifest_grants(resources / "workflow/camel-kit-workflow.yaml")
+    exact = json.loads(json.dumps(original))
+    exact["permissions"]["allow"] = [item for item in 
original["permissions"]["allow"] if not item.startswith("mcp__")]
+    exact["permissions"]["allow"] += [f"mcp__{server}__{tool}" for server, 
tools in servers.items() for tool in tools]
+    if args.settings:
+        exact = json.loads(args.settings.read_text())
+        expected = {f"mcp__{server}__{tool}" for server, tools in 
servers.items() for tool in tools}
+        actual = {rule for rule in exact["permissions"]["allow"] if 
rule.startswith("mcp__")}
+        assert actual == expected, {"missing": sorted(expected - actual), 
"extra": sorted(actual - expected)}
+    wildcard = json.loads(json.dumps(exact))
+    wildcard["permissions"]["allow"] = [item for item in 
exact["permissions"]["allow"] if not item.startswith("mcp__")]
+    wildcard["permissions"]["allow"] += [f"mcp__{server}__*" for server in 
servers]
+    unlisted = "canary_unlisted_tool"
+    summaries = []
+    for server in servers if args.server == "all" else [args.server]:
+        allowed = servers[server][0]
+        removed = json.loads(json.dumps(exact))
+        removed["permissions"]["allow"].remove(f"mcp__{server}__{allowed}")
+        for label, settings, tool, prompt in [
+            ("exact-allowed", exact, allowed, False),
+            ("exact-unlisted", exact, unlisted, True),
+            ("wildcard-control", wildcard, unlisted, False),
+            ("removed-grant-control", removed, allowed, True),
+        ]:
+            summary = run_case(args.cli, args.output, server + "-" + label, 
settings,
+                               server, tool, [allowed, unlisted], prompt)
+            summaries.append(summary)
+            print(server, label, "PASS", flush=True)
+    result = {"cli_version": subprocess.check_output([args.cli, "--version"], 
text=True).strip(), "cases": summaries}
+    (args.output / "result.json").write_text(json.dumps(result, indent=2) + 
"\n")
+
+
+if __name__ == "__main__":
+    if len(sys.argv) > 1 and sys.argv[1] == "mcp":
+        mcp_main(sys.argv[2], sys.argv[3:])
+    else:
+        main()

Reply via email to