Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 0 additions & 10 deletions docs/MCP_TOOLS.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,6 @@ Synthesize whole-repository symbol dependency and reference graph.
|---|---|---|---|---|
| `target_dir` | `string` | No | - | Target source directory to verify or analyze. |
| `max_files` | `integer` | No | `100` | Maximum source files to include. |
| `output_path` | `string` | No | - | Destination file path for output report or artifacts. |
| `fmt` | `string` | No | `json` | Output format for synthesized code graph: 'json' or 'dot'. |

### `ai_ast_parse`
Expand Down Expand Up @@ -370,7 +369,6 @@ Introspect an installed Python package and extract its public API contract.
|---|---|---|---|---|
| `package_name` | `string` | Yes | - | Introspect an installed Python package and extract its public API contract. |
| `max_depth` | `integer` | No | `1` | Maximum module recursion depth for package introspection. |
| `output_dir` | `string` | No | - | Directory path for generated output files. |

### `ai_ingest_query_library`

Expand Down Expand Up @@ -1233,8 +1231,6 @@ Create or update a kubernetes.io/tls secret from certificate and private key fil
|---|---|---|---|---|
| `secret_name` | `string` | Yes | - | Name of the Kubernetes TLS secret to create or update. |
| `namespace` | `string` | No | `default` | Kubernetes namespace. |
| `cert_path` | `string` | No | `` | Path to TLS certificate file (.crt or .pem). |
| `key_path` | `string` | No | `` | Path to TLS private key file (.key or .pem). |
| `context` | `string` | No | - | Kubernetes cluster context name. |
| `dry_run` | `boolean` | No | `False` | Preview execution plan without mutating external state. |

Expand Down Expand Up @@ -1305,7 +1301,6 @@ Generate Homelab certificates and apply TLS secrets across Kubernetes cluster na
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
| `context` | `string` | No | - | Kubernetes cluster context name. |
| `tls_dir` | `string` | No | `` | Directory with generated TLS certificates. |
| `secret_name` | `string` | No | `homelab-tls` | Name of the Kubernetes TLS secret to create or update. |
| `stack` | `string` | No | `all` | Stack to operate on: infra | llm | logging | devops | gitea | chat | all. |
| `overwrite` | `boolean` | No | `False` | Overwrite existing files or resources if they exist. |
Expand Down Expand Up @@ -1381,7 +1376,6 @@ Stream runtime security anomaly events from Kubernetes Falco eBPF probes.
| `simulate` | `boolean` | No | `False` | Generate simulated kernel eBPF security anomalies for testing. |
| `dry_run` | `boolean` | No | `False` | Preview execution plan without mutating external state. |
| `json_output` | `boolean` | No | `False` | Output findings or metrics as JSON. |
| `output` | `string` | No | - | Export discovered alerts to JSON file |

### `k8s_status`

Expand Down Expand Up @@ -2016,7 +2010,6 @@ Generate AI Bill of Materials (AIBOM) with model licenses and hardware estimates
|---|---|---|---|---|
| `target` | `string` | No | `.` | Target directory or model repository to analyze for AI models and AIBOM. |
| `format_type` | `string` | No | `cyclonedx` | AIBOM format output (cyclonedx, json). |
| `output_file` | `string` | No | - | Destination file path for generated AIBOM manifest. |

### `scan_complexity`

Expand Down Expand Up @@ -2098,7 +2091,6 @@ Generate Software Bill of Materials (SBOM) using Trivy (#1439).
|---|---|---|---|---|
| `target` | `string` | No | `.` | Target directory, file, or repository to scan. |
| `format_type` | `string` | No | `cyclonedx` | SBOM format output (cyclonedx, spdx, json). |
| `output_file` | `string` | No | - | Destination file path for generated SBOM document. |

### `scan_secrets`

Expand Down Expand Up @@ -2179,7 +2171,6 @@ List the managed SSH keys of the project prefix with their age and rotation stat

| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
| `key_dir` | `string` | No | - | Directory where SSH keys are stored. |
| `prefix` | `string` | No | - | Optional prefix for the SSH key name (defaults to config setting, devcontainer name, or basename pwd). |
| `all_projects` | `boolean` | No | `False` | List the managed keys of all projects, not only the keys of the prefix. |

Expand All @@ -2193,7 +2184,6 @@ Show the active SSH key and days until rotation.

| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
| `key_dir` | `string` | No | - | Directory where SSH keys are stored. |
| `prefix` | `string` | No | - | Optional prefix for the SSH key name (defaults to config setting, devcontainer name, or basename pwd). |

### `telemetry_logfire`
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-11-04
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Design

## Decisions

- **Put the path data in `src/devops_cli/ai/mcp/path_confinement.py`: `PATH_CONFINEMENTS` (tool name to kept parameters) and `NOT_A_FILE_PATH` (tool name to a parameter and its reason).**: The audit needs one place to read. A parameter that is a host, a Vault secret path or a container directory then has a recorded reason and no silent exemption.
- **Drop a path option when the command writes it, or when it has a safe configured default; confine the option that the command must keep.** The write options `output_path`, `output_dir`, `output_file` and `output` leave the schema, because a model that chooses where the server writes is the defect. The certificate and SSH key options leave the schema too, because their configured defaults are absolute paths outside the work tree and the command already reads its store. A read path with a useful relative form, such as `target`, `directory`, `repo_path` or `root_dir`, stays and is confined.
- **Resolve the value and replace what the model sent.** `_check_path_confinement` calls `safe_resolve_subpath` against the work tree of the server process, and the argument that reaches the command is the resolved absolute path. A relative value then names the same file whatever directory the command reads it from, and a symlink swap after the check cannot move it.
- **Take the root from `find_worktree_root(Path.cwd())` in the server process, never from a tool argument.**: A root that the model gives would move the boundary that confines the model. The work tree, not the launch directory, is the boundary that the issue names.
- **Call the guard in `_create_tool_function`, beside `_check_oidc_token_reference`, before `_build_command_argv`.** The existing guards of #2638 and #3222 use this pattern. A refusal reaches the caller as a validation error, and no command starts with a path outside the root.
- **Guard the hand-written `scan_uv_audit` tool in `server.py` too.** It is not in `CONST_MCP_EXPOSURE_TABLE`, so the generator never sees it, but the `ops` role publishes it and it takes a directory.
- **Audit every published tool, not only the exposure table.** The catalogue is a superset: it holds the hand-written tools. The test therefore covers the acceptance criterion for `CONST_MCP_EXPOSURE_TABLE` and closes the same risk for a tool that a later edit registers by hand.
- **A `NOT_A_FILE_PATH` entry needs a reason of at least 30 characters, and the audit fails on a stale entry.** A bare allowlist hides why the parameter is safe, and a stale entry hides that its tool changed.
- **Deliver the module and guard that #3236 plans.** #3300 cannot meet its own acceptance criteria without them, and the plan of #3236 cannot land first inside this pull request. This change therefore does the confinement work of #3236 in full, and the audit covers both the job roles and the `ops` role.

## Rejected alternatives

- **Confine an output path under the work tree instead of dropping it.**: A model could overwrite a source file in the tree. The commands print their report or use a configured data directory, so the model needs no output option.
- **Confine the certificate and SSH key options.**: Their defaults are absolute paths in the user configuration, outside the work tree, so a call with no value would fail. Dropping the option leaves the configured store in charge.
- **Exempt every path-like parameter with a comment instead of a table.** A comment cannot fail the test. The table and the reason length make the audit fail.
- **Audit only `CONST_MCP_EXPOSURE_TABLE`.** It leaves `scan_uv_audit` outside the rule, and that is the one hand-written tool with a path parameter.
- **Add an external path library.** It adds a dependency and still needs the MCP classification and the audit. `safe_resolve_subpath` already covers containment.
- **Audit the job roles only, as #3236 planned.** The issue covers the whole `ops` role and 61 tools, so that audit leaves the reported defect open.
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Proposal

## Why

The `ops` role holds every MCP tool, and 61 of them publish 70 text parameters with a path-like name that the server opens or writes (#3300). SECURITY.md rule 11 says a tool publishes no option for a path that the command reads or writes, unless a path helper confines it. The confinement tables and the guard that #3236 plans do not exist on `main` yet. This change adds them, decides every published path parameter, and audits the whole catalogue, so a new tool cannot escape the rule in silence.

## What Changes

- A new module `src/devops_cli/ai/mcp/path_confinement.py` holds `PATH_CONFINEMENTS`, which names each kept path parameter of a tool, and `NOT_A_FILE_PATH`, which names each text parameter whose name looks like a path and that the command does not open, with a reason of at least 30 characters.
- `_PATH_PARAM_RESTRICTIONS` in `src/devops_cli/ai/mcp/generator.py` drops each path option that the model must not choose. The write paths come first: `ai_ast_graph.output_path`, `ai_ingest_library.output_dir`, `scan_sbom.output_file`, `scan_aibom.output_file` and `k8s_security_stream.output`. The change also drops the certificate options of `k8s_create_tls_secret` and `k8s_enable_tls` and the SSH key directory, so each command reads its configured store.
- `_check_path_confinement` in `generator.py` runs in `_create_tool_function` beside the other guards, before the command starts. It resolves each kept path value with `safe_resolve_subpath` against the work tree of the server process, passes the resolved path to the command, and refuses an escape with a `ValidationError` that names the parameter. The same check guards the hand-written `scan_uv_audit` tool in `server.py`.
- A new test `tests/ai/mcp/test_path_confinement.py` reads the published schema of every tool that the server offers, including the hand-written tools, and fails on a text parameter with a path-like name that neither table names. It also fails on a missing or short reason and on a stale entry.
- `docs/MCP_TOOLS.md` is regenerated: the dropped options leave the published schemas.
- The change carries a spec delta for the `mcp-tool-path-confinement` capability, because the published schema and the refusal are observable to a consumer.
- The change fits one pull request.

## Feasibility

- `git show HEAD:src/devops_cli/ai/mcp/path_confinement.py` exits 128, so `main` holds no confinement module. `grep` finds `_PATH_PARAM_RESTRICTIONS` in `generator.py` and `_PATH_PARAM_KEYWORDS` in `core/paths.py`, but no call to `safe_resolve_subpath` in the MCP package.
- A script imports `devops_cli.ai.mcp.server.mcp`, lists each published tool schema and matches `_PATH_PARAM_KEYWORDS`: it finds 70 text parameters in 61 tools, for example `scan_sbom.output_file`, `tf_apply.plan_file`, `ai_read.target_path`, `vault_get.path` and `scan_uv_audit.directory`.
- The same script reads each Click command through `resolve_click_command`: every dropped option is optional, so the command keeps a usable default. The certificate defaults of `k8s_create_tls_secret` and `k8s_enable_tls` are absolute paths in the user configuration, so the change drops those options instead of confining a value outside the work tree.
- `src/devops_cli/core/paths.py` holds `safe_resolve_subpath`, which resolves a relative or absolute path under a base and raises on `..`, on an escape and on a symlink escape. The helper the change needs exists.
- `uv run devops ci test tests/ai/mcp` passes on the base, so the audit starts from a green suite.

## Existing Tool Considered

No open, maintained library confines the path parameters of an MCP server's tools: FastMCP publishes a schema and pydantic validates types, not containment. The change reuses `safe_resolve_subpath` in `src/devops_cli/core/paths.py` and the `_PATH_PARAM_RESTRICTIONS` table of `generator.py`, so it adds no dependency. The written cost of the module, the guard and the audit is about 20,000 tokens to build, about 5,000 tokens per year to keep and fix, and one day elapsed. The change deletes no tool function, registry entry or test. It removes published options only, and the commands keep a configured default for each one.

## Capabilities

### New Capabilities

- `mcp-tool-path-confinement`: A tool publishes no path option that the server can choose, A kept path option is confined to a server root, Every published path-like parameter has a confinement or a reasoned exemption.

### Modified Capabilities

None.

## Impact

- Code: `src/devops_cli/ai/mcp/path_confinement.py` (new), `src/devops_cli/ai/mcp/generator.py`, `src/devops_cli/ai/mcp/server.py`, `src/devops_cli/lang/en/errors.py`
- Documents: `docs/MCP_TOOLS.md`
- Tests: `tests/ai/mcp/test_path_confinement.py` (new)
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
## ADDED Requirements

### Requirement: Write path confinement
Every MCP tool that writes a file MUST remove each path parameter from its published schema or SHALL confine it with `PATH_CONFINEMENTS`.

#### Scenario: Unconfined write path
- **WHEN** A tool in the catalogue publishes a write-path parameter that matches `_PATH_PARAM_KEYWORDS`.
- **THEN** The audit fails unless the parameter has a `PATH_CONFINEMENTS` entry.

#### Scenario: Listed write tools publish no write path
- **WHEN** A caller reads the published schema of `ai_ingest_library`, `scan_sbom`, `scan_aibom`, `tf_apply` or `k8s_create_tls_secret`.
- **THEN** Each path-like write parameter is absent or has a `PATH_CONFINEMENTS` entry.

### Requirement: Read path confinement
Every MCP tool that reads a file MUST remove each path parameter from its published schema or SHALL confine it with `PATH_CONFINEMENTS`.

#### Scenario: Unconfined read path
- **WHEN** A tool in the catalogue publishes a read-path parameter that matches `_PATH_PARAM_KEYWORDS`.
- **THEN** The audit fails unless the parameter has a `PATH_CONFINEMENTS` entry.

#### Scenario: A traversal value is refused
- **WHEN** A caller gives `../../etc/passwd` to the `root_dir` parameter of `gh_pages_verify`.
- **THEN** The tool raises a validation error that names the parameter, and no command starts.

#### Scenario: A relative value stays in the work tree
- **WHEN** A caller gives `docs` to the `root_dir` parameter of `gh_pages_verify`.
- **THEN** The tool resolves the value under the work tree of the server and runs the command with the resolved path.

### Requirement: Full catalogue audit
The path-parameter audit SHALL inspect every tool that the server offers, including the hand-written tools and the `ops` role.

#### Scenario: Tool outside the exposure table
- **WHEN** A hand-written tool such as `scan_uv_audit` publishes a path-like parameter.
- **THEN** The audit checks that parameter and fails when no entry exists.

#### Scenario: Tool outside the job roles
- **WHEN** A tool in `CONST_MCP_EXPOSURE_TABLE` is not in a job role and publishes a path-like parameter.
- **THEN** The audit checks that parameter and fails when no entry exists.

### Requirement: Reasoned non-file exemption
Each `NOT_A_FILE_PATH` entry SHALL hold a reason of at least 30 characters, and the audit MUST fail on a missing or short reason.

#### Scenario: Bare or short exemption
- **WHEN** A path-like parameter has a `NOT_A_FILE_PATH` entry with no reason or a reason shorter than 30 characters.
- **THEN** The audit fails and names the tool and parameter.

### Requirement: Stale exemption
The audit MUST fail when a `NOT_A_FILE_PATH` entry names no published path-like parameter.

#### Scenario: Stale non-file entry
- **WHEN** A `NOT_A_FILE_PATH` entry matches no published parameter.
- **THEN** The audit fails and names the stale entry.

### Requirement: Vault secret paths
The `vault_get.path` and `vault_sync.path` parameters SHALL be recorded in `NOT_A_FILE_PATH` as Vault secret paths, not filesystem paths.

#### Scenario: Vault path exemption
- **WHEN** The audit reads `vault_get.path` or `vault_sync.path`.
- **THEN** The audit accepts the parameter only through its `NOT_A_FILE_PATH` reason and not through a filesystem confinement.
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Tasks

## 1. Add the confinement module and the guard

Reads: `src/devops_cli/core/paths.py`, `src/devops_cli/ai/mcp/generator.py`, `src/devops_cli/ai/mcp/server.py`.
Changes: `src/devops_cli/ai/mcp/path_confinement.py` (new), `src/devops_cli/ai/mcp/generator.py`, `src/devops_cli/ai/mcp/server.py`, `src/devops_cli/lang/en/errors.py`, `tests/ai/mcp/test_path_confinement.py` (new).

- [x] 1.1 Write `tests/ai/mcp/test_path_confinement.py` cases first: a traversal value of `root_dir` and an absolute value outside the work tree each raise a `ValidationError` that names the parameter and start no command, a relative value runs the command, and the confinement root is not a tool argument.
- Verification: `uv run devops ci test tests/ai/mcp/test_path_confinement.py`
- [x] 1.2 Write `src/devops_cli/ai/mcp/path_confinement.py` with `PATH_CONFINEMENTS`, `NOT_A_FILE_PATH` and `confine_tool_paths`, which resolves each kept value with `safe_resolve_subpath` against `find_worktree_root(Path.cwd())` and replaces the argument. Add `ERRORS.mcp.path_outside_root`. Call `_check_path_confinement` in `_create_tool_function` before `_build_command_argv`, and call the same check in the hand-written `scan_uv_audit` tool.
- Verification: `uv run devops ci test tests/ai/mcp/test_path_confinement.py`

## 2. Decide every published path parameter

Reads: `src/devops_cli/ai/mcp/generator.py`, `src/devops_cli/ai/mcp/path_confinement.py`.
Changes: `src/devops_cli/ai/mcp/generator.py`, `src/devops_cli/ai/mcp/path_confinement.py`, `tests/ai/mcp/test_path_confinement.py`.

- [x] 2.1 Add `_PATH_PARAM_RESTRICTIONS` entries that drop the write options and the certificate, SSH key and template options that the command replaces with its configured store, in `src/devops_cli/ai/mcp/generator.py`.
- Verification: `uv run devops ci test tests/ai/mcp/test_fastmcp_contracts.py`
- [x] 2.2 Record every kept path parameter in `PATH_CONFINEMENTS` and every path-like value that is not a file path in `NOT_A_FILE_PATH`, with a reason of at least 30 characters.
- Verification: `uv run devops ci test tests/ai/mcp/test_path_confinement.py`

## 3. Audit every published tool

Reads: `src/devops_cli/ai/mcp/path_confinement.py`, `src/devops_cli/core/paths.py`.
Changes: `tests/ai/mcp/test_path_confinement.py`.

- [x] 3.1 Read the published schema of every tool that the server offers, match each text parameter against `_PATH_PARAM_KEYWORDS`, and fail with the tool and parameter name when neither table names it. Fail also on a stale entry and on a reason shorter than 30 characters.
- Verification: `uv run devops ci test tests/ai/mcp/test_path_confinement.py`

## 4. Document the published options

Reads: `docs/MCP_TOOLS.md`.
Changes: `docs/MCP_TOOLS.md`.

- [x] 4.1 Run `uv run devops docs generate --sync-readme`, so the dropped options leave the published schemas in the document.
- Verification: `uv run devops ci docs`

## Criteria

- No tool publishes a path option that the command writes.: group 2
- Every kept path parameter is confined to the server work tree.: group 1, group 2
- The audit names a published path-like parameter that neither table decides.: group 3
- Each exemption holds a reason of at least 30 characters, and a stale entry fails the audit.: group 3
- `vault_get.path` and `vault_sync.path` are Vault secret paths and not file paths.: group 2
- The listed write tools publish no unconfined write-path option.: group 2

## Workflow follow-up

- Pending a person: review the diff in tuicr (tuicr -r <base>..HEAD).
Loading
Loading