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()
