diff --git a/docs/MCP_TOOLS.md b/docs/MCP_TOOLS.md
index 1736a2697..5ddb7adfe 100644
--- a/docs/MCP_TOOLS.md
+++ b/docs/MCP_TOOLS.md
@@ -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`
@@ -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`
@@ -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. |
@@ -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. |
@@ -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`
@@ -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`
@@ -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`
@@ -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. |
@@ -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`
diff --git a/openspec/changes/archive/2026-11-04-3300-the-ops-role-publishes-60-tools/.openspec.yaml b/openspec/changes/archive/2026-11-04-3300-the-ops-role-publishes-60-tools/.openspec.yaml
new file mode 100644
index 000000000..4ccb2b41a
--- /dev/null
+++ b/openspec/changes/archive/2026-11-04-3300-the-ops-role-publishes-60-tools/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-11-04
diff --git a/openspec/changes/archive/2026-11-04-3300-the-ops-role-publishes-60-tools/design.md b/openspec/changes/archive/2026-11-04-3300-the-ops-role-publishes-60-tools/design.md
new file mode 100644
index 000000000..69d4507e0
--- /dev/null
+++ b/openspec/changes/archive/2026-11-04-3300-the-ops-role-publishes-60-tools/design.md
@@ -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.
diff --git a/openspec/changes/archive/2026-11-04-3300-the-ops-role-publishes-60-tools/proposal.md b/openspec/changes/archive/2026-11-04-3300-the-ops-role-publishes-60-tools/proposal.md
new file mode 100644
index 000000000..5f5039a0a
--- /dev/null
+++ b/openspec/changes/archive/2026-11-04-3300-the-ops-role-publishes-60-tools/proposal.md
@@ -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)
diff --git a/openspec/changes/archive/2026-11-04-3300-the-ops-role-publishes-60-tools/specs/mcp-tool-path-confinement/spec.md b/openspec/changes/archive/2026-11-04-3300-the-ops-role-publishes-60-tools/specs/mcp-tool-path-confinement/spec.md
new file mode 100644
index 000000000..505a5936d
--- /dev/null
+++ b/openspec/changes/archive/2026-11-04-3300-the-ops-role-publishes-60-tools/specs/mcp-tool-path-confinement/spec.md
@@ -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.
diff --git a/openspec/changes/archive/2026-11-04-3300-the-ops-role-publishes-60-tools/tasks.md b/openspec/changes/archive/2026-11-04-3300-the-ops-role-publishes-60-tools/tasks.md
new file mode 100644
index 000000000..7a1afef99
--- /dev/null
+++ b/openspec/changes/archive/2026-11-04-3300-the-ops-role-publishes-60-tools/tasks.md
@@ -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 ..HEAD).
diff --git a/openspec/specs/mcp-tool-path-confinement/spec.md b/openspec/specs/mcp-tool-path-confinement/spec.md
new file mode 100644
index 000000000..bbe485613
--- /dev/null
+++ b/openspec/specs/mcp-tool-path-confinement/spec.md
@@ -0,0 +1,64 @@
+# mcp-tool-path-confinement Specification
+
+## Purpose
+Which path options an MCP tool publishes, and how each kept path value is confined to the work tree of the server, so a model that calls a tool reaches no path outside the server tree. The audit covers every published tool, including the `ops` role and the hand-written tools.
+
+## 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.
diff --git a/src/devops_cli/ai/mcp/generator.py b/src/devops_cli/ai/mcp/generator.py
index 0d00ba971..47cd4cedb 100644
--- a/src/devops_cli/ai/mcp/generator.py
+++ b/src/devops_cli/ai/mcp/generator.py
@@ -67,6 +67,59 @@
),
"required": frozenset(),
},
+ # No `output_path`: the command prints its graph, so the model needs no file.
+ ("ai", "ast", "graph"): {
+ "allowed": frozenset({"target_dir", "max_files", "fmt"}),
+ "required": frozenset(),
+ },
+ # No `output_dir`: the command writes its contract files under the configured data directory.
+ ("ai", "ingest", "library"): {
+ "allowed": frozenset({"package_name", "max_depth", "output_format"}),
+ "required": frozenset({"package_name"}),
+ },
+ # No `output_file`: the command prints its report, so the model needs no file.
+ ("scan", "sbom"): {
+ "allowed": frozenset({"target", "format_type"}),
+ "required": frozenset(),
+ },
+ ("scan", "aibom"): {
+ "allowed": frozenset({"target", "format_type"}),
+ "required": frozenset(),
+ },
+ # No `output`: the command prints the stream, so the model needs no file.
+ ("k8s", "security-stream"): {
+ "allowed": frozenset(
+ {
+ "namespace",
+ "label",
+ "severity",
+ "duration",
+ "tail",
+ "follow",
+ "simulate",
+ "dry_run",
+ "output_format",
+ "json_output",
+ }
+ ),
+ "required": frozenset(),
+ },
+ # No `cert_path` or `key_path`: the command reads the certificate from its configured store.
+ ("k8s", "create-tls-secret"): {
+ "allowed": frozenset({"secret_name", "namespace", "context", "dry_run"}),
+ "required": frozenset({"secret_name"}),
+ },
+ # No `tls_dir`: the command reads the certificate from its configured store.
+ ("k8s", "enable-tls"): {
+ "allowed": frozenset({"context", "secret_name", "stack", "overwrite", "dry_run"}),
+ "required": frozenset(),
+ },
+ # No `key_dir`: the command reads the keys from the configured SSH directory.
+ ("ssh", "audit"): {
+ "allowed": frozenset({"prefix", "all_projects"}),
+ "required": frozenset(),
+ },
+ ("ssh", "status"): {"allowed": frozenset({"prefix"}), "required": frozenset()},
("ai", "gateway", "status"): {"allowed": frozenset({"provider"}), "required": frozenset()},
("ai", "gateway", "routes"): {"allowed": frozenset({"provider"}), "required": frozenset()},
("ai", "gateway", "probe-backend"): {
@@ -322,6 +375,13 @@ def _check_oidc_token_reference(path: tuple[str, ...], kwargs: dict[str, Any]) -
raise ValidationError(ERRORS.mcp.oidc_token_not_reference, field="oidc_token")
+def _check_path_confinement(tool_name: str, arguments: dict[str, Any]) -> None:
+ """Resolve each kept path argument under the work tree, and refuse an escape (#3300)."""
+ from devops_cli.ai.mcp.path_confinement import confine_tool_paths
+
+ confine_tool_paths(tool_name, arguments)
+
+
def _append_options(
argv: list[str],
opts: list[Any],
@@ -405,6 +465,7 @@ def tool_fn(*args: Any, **kwargs: Any) -> str:
_check_sandbox_preconditions(path, bound.arguments)
_check_model_endpoints(path, bound.arguments)
_check_oidc_token_reference(path, bound.arguments)
+ _check_path_confinement(tool_name, bound.arguments)
argv = _build_command_argv(path, click_cmd, format_json, bound.arguments)
return _run_mcp_cmd(["uv", "run", "devops", *argv], timeout=timeout)
diff --git a/src/devops_cli/ai/mcp/path_confinement.py b/src/devops_cli/ai/mcp/path_confinement.py
new file mode 100644
index 000000000..8f7e7585a
--- /dev/null
+++ b/src/devops_cli/ai/mcp/path_confinement.py
@@ -0,0 +1,132 @@
+"""The path parameters that an MCP tool confines, and the parameters that are no file path.
+
+A tool publishes no option for a path that the command reads or writes, unless the path resolves
+under a root that the server sets (SECURITY.md rule 11). The confinement table names each kept
+path parameter. The exemption table names each text parameter whose name looks like a path and
+that the command does not open, with the reason. `confine_tool_paths` resolves each kept value
+against the work tree of the server process and replaces the value that the model sent. The audit
+in `tests/ai/mcp/test_path_confinement.py` fails on a published path-like parameter that neither
+table names, so a new tool cannot escape the rule in silence.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+from pathlib import Path
+from typing import Any, Final
+
+from devops_cli.core.paths import safe_resolve_subpath
+from devops_cli.exceptions import ValidationError
+from devops_cli.lang import ERRORS
+
+PATH_CONFINEMENTS: Final[Mapping[str, frozenset[str]]] = {
+ "ai_ast_graph": frozenset({"target_dir"}),
+ "ai_ast_parse": frozenset({"file_path"}),
+ "ai_diagram": frozenset({"target_dir"}),
+ "ai_ingest_query_library": frozenset({"contracts_dir"}),
+ "ai_pack_context": frozenset({"target_path"}),
+ "ai_rag_drift": frozenset({"path"}),
+ "ai_rag_index": frozenset({"path"}),
+ "ai_read": frozenset({"target_path"}),
+ "ai_repomap": frozenset({"target_dir"}),
+ "ai_test_gen": frozenset({"target_file"}),
+ "branches_list": frozenset({"base_dir"}),
+ "docker_sandbox": frozenset({"seccomp_profile"}),
+ "gh_labels_sync": frozenset({"file"}),
+ "gh_issues_close": frozenset({"comment_file"}),
+ "gh_pages_verify": frozenset({"root_dir"}),
+ "gh_project_audit": frozenset({"template_file"}),
+ "gh_project_status": frozenset({"template_file"}),
+ "gh_project_sync": frozenset({"template_file"}),
+ "gh_views_audit": frozenset({"template_file"}),
+ "gh_views_spec": frozenset({"template_file"}),
+ "gh_views_sync": frozenset({"template_file"}),
+ "k8s_bootstrap": frozenset({"k8s_dir"}),
+ "k8s_deploy_stack": frozenset({"k8s_dir"}),
+ "k8s_diff_helm": frozenset({"chart_path"}),
+ "k8s_lint": frozenset({"target"}),
+ "k8s_teardown_stack": frozenset({"k8s_dir"}),
+ "k8s_validate": frozenset({"manifest_path"}),
+ "repos_list": frozenset({"base_dir"}),
+ "repos_sync": frozenset({"base_dir"}),
+ "review_branch": frozenset({"repo_path"}),
+ "review_export_feedback": frozenset({"reviews_dir"}),
+ "review_path": frozenset({"targets"}),
+ "review_stats": frozenset({"reviews_dir"}),
+ "roadmap_intake": frozenset({"body_file"}),
+ "sandbox_deploy": frozenset({"seccomp_profile"}),
+ "scan_aibom": frozenset({"target"}),
+ "scan_complexity": frozenset({"target"}),
+ "scan_fix": frozenset({"target"}),
+ "scan_injection": frozenset({"target"}),
+ "scan_sast": frozenset({"target"}),
+ "scan_sbom": frozenset({"target"}),
+ "scan_secrets": frozenset({"target"}),
+ "scan_trivy": frozenset({"target"}),
+ "scan_uv_audit": frozenset({"directory"}),
+ "tf_apply": frozenset({"directory", "plan_file", "var_file"}),
+ "tf_cost_breakdown": frozenset({"directory"}),
+ "tf_notify_plan": frozenset({"plan_file"}),
+ "tf_output": frozenset({"directory"}),
+ "tf_plan": frozenset({"directory", "var_file"}),
+ "tls_inspect": frozenset({"cert_path"}),
+}
+
+NOT_A_FILE_PATH: Final[Mapping[str, Mapping[str, str]]] = {
+ "ai_failover": {
+ "target_model": "The target model of a fallback route is a model name, not a file.",
+ "target_provider": "The target provider of a fallback route is a provider name, not a file.",
+ },
+ "ai_rag_query": {
+ "file_filter": "A file filter is a glob pattern for the search, not a path the server opens.",
+ },
+ "roadmap_intake": {
+ "filed_by": "The filed_by value names the actor that files the candidate, not a file.",
+ },
+ "sandbox_exec": {
+ "workdir": "The workdir is a directory inside the sandbox container, not a host file path.",
+ },
+ "security_intel_network": {
+ "target": "The target is a host name or an IP address for threat intelligence, not a file.",
+ },
+ "vault_get": {
+ "path": "A Vault secret path selects a secret from Vault, and the server opens no file.",
+ },
+ "vault_sync": {
+ "path": "A Vault secret path selects a secret from Vault, and the server opens no file.",
+ },
+}
+
+
+def _confine(parameter: str, value: Any, root: Path) -> Any:
+ if not isinstance(value, (str, Path)):
+ return value
+ try:
+ return str(safe_resolve_subpath(root, value, error_cls=ValidationError))
+ except ValidationError as exc:
+ raise ValidationError(
+ ERRORS.mcp.path_outside_root.format(name=parameter, root=root),
+ field=parameter,
+ ) from exc
+
+
+def confine_tool_paths(tool_name: str, arguments: dict[str, Any]) -> None:
+ """Resolve each kept path argument under the work tree, and replace the model's value.
+
+ The command receives the resolved path, so a relative value names the same file whatever
+ directory the command reads it from (#3300).
+ """
+ parameters = PATH_CONFINEMENTS.get(tool_name)
+ if not parameters:
+ return
+ from devops_cli.core.repo import find_worktree_root
+
+ root = find_worktree_root(Path.cwd())
+ for parameter in parameters:
+ value = arguments.get(parameter)
+ if value is None or value == "":
+ continue
+ if isinstance(value, (list, tuple)):
+ arguments[parameter] = [_confine(parameter, item, root) for item in value]
+ else:
+ arguments[parameter] = _confine(parameter, value, root)
diff --git a/src/devops_cli/ai/mcp/server.py b/src/devops_cli/ai/mcp/server.py
index fcf268b4d..e3191fe01 100644
--- a/src/devops_cli/ai/mcp/server.py
+++ b/src/devops_cli/ai/mcp/server.py
@@ -142,8 +142,10 @@ def security_intel_network(target: str) -> str:
def scan_uv_audit(directory: str = ".") -> str:
"""Audit the packages that the uv.lock of a project pins, as it is, for known vulnerabilities and adverse project statuses, with uv audit."""
_validate_mcp_arg("directory", directory)
+ from devops_cli.ai.mcp.path_confinement import confine_tool_paths
from devops_cli.ai.tools.builtin_tools import scan_uv_audit as _native_uv_audit
+ confine_tool_paths("scan_uv_audit", {"directory": directory})
return _native_uv_audit(directory=directory)
diff --git a/src/devops_cli/lang/en/errors.py b/src/devops_cli/lang/en/errors.py
index 1c4fd62cc..4402b11fd 100644
--- a/src/devops_cli/lang/en/errors.py
+++ b/src/devops_cli/lang/en/errors.py
@@ -258,6 +258,9 @@ class MCPErrorMessages:
"Invalid value for 'oidc_token': use a reference of the form keyring:. "
"A token in an argument passes through the model, so the tool never takes the token itself."
)
+ path_outside_root: str = (
+ "Invalid value for '{name}': the path must resolve inside the server work tree '{root}'."
+ )
conflicting_symbol_and_pattern: str = (
"Cannot specify both 'symbol' and 'pattern'; provide one or the other."
)
diff --git a/tests/ai/mcp/test_fastmcp_contracts.py b/tests/ai/mcp/test_fastmcp_contracts.py
index d07fc9c88..239cdef88 100644
--- a/tests/ai/mcp/test_fastmcp_contracts.py
+++ b/tests/ai/mcp/test_fastmcp_contracts.py
@@ -2,6 +2,8 @@
from __future__ import annotations
+from pathlib import Path
+
import pytest
from devops_cli.ai.mcp.server import list_mcp_tools
@@ -450,7 +452,7 @@ def test_fastmcp_ast_tools() -> None:
"ai",
"ast",
"parse",
- "src/main.py",
+ str(Path("src/main.py").resolve()),
"--query",
"(function_definition) @fn",
"--format",
@@ -470,7 +472,7 @@ def test_fastmcp_ast_tools() -> None:
"ast",
"graph",
"--dir",
- "src",
+ str(Path("src").resolve()),
"--max-files",
"20",
"--format",
@@ -505,7 +507,7 @@ def test_fastmcp_context_packing_tool() -> None:
"devops",
"ai",
"pack-context",
- "src/main.py",
+ str(Path("src/main.py").resolve()),
"--referenced",
"main,app",
"--max-tokens",
@@ -545,7 +547,7 @@ def test_fastmcp_ai_read_tool() -> None:
"devops",
"ai",
"read",
- "src/main.py",
+ str(Path("src/main.py").resolve()),
"--inspect",
"--level",
"0",
@@ -580,7 +582,7 @@ def test_fastmcp_ai_rag_drift_tool() -> None:
"ai",
"rag",
"drift",
- "src",
+ str(Path("src").resolve()),
"--auto-sync",
"--format",
"json",
diff --git a/tests/ai/mcp/test_path_confinement.py b/tests/ai/mcp/test_path_confinement.py
new file mode 100644
index 000000000..11ce80998
--- /dev/null
+++ b/tests/ai/mcp/test_path_confinement.py
@@ -0,0 +1,174 @@
+"""The path parameters of every published MCP tool are confined or are not file paths (#3300)."""
+
+from __future__ import annotations
+
+import asyncio
+from pathlib import Path
+from typing import Any
+
+import pytest
+
+from devops_cli.ai.mcp.generator import _create_tool_function, resolve_click_command
+from devops_cli.ai.mcp.path_confinement import NOT_A_FILE_PATH, PATH_CONFINEMENTS
+from devops_cli.ai.mcp.server import mcp
+from devops_cli.core.paths import _PATH_PARAM_KEYWORDS
+from devops_cli.exceptions import ValidationError
+
+_MIN_REASON = 30
+
+
+def _catalogue() -> dict[str, Any]:
+ return {tool.name: tool for tool in asyncio.run(mcp._list_tools())}
+
+
+def _properties(tool: Any) -> dict[str, Any]:
+ return (tool.parameters or {}).get("properties", {})
+
+
+def _is_text(spec: dict[str, Any]) -> bool:
+ if spec.get("type") in {"string", "array"}:
+ return True
+ return any(_is_text(part) for part in spec.get("anyOf", []))
+
+
+def _path_parameters() -> dict[str, set[str]]:
+ found: dict[str, set[str]] = {}
+ for name, tool in _catalogue().items():
+ names = {
+ parameter
+ for parameter, spec in _properties(tool).items()
+ if _is_text(spec)
+ and any(keyword in parameter.lower() for keyword in _PATH_PARAM_KEYWORDS)
+ }
+ if names:
+ found[name] = names
+ return found
+
+
+def _tool(path: tuple[str, ...]) -> Any:
+ name = "_".join(path).replace("-", "_")
+ return _create_tool_function(path, 5.0, False, name, resolve_click_command(path))
+
+
+def _record(monkeypatch: pytest.MonkeyPatch) -> list[list[str]]:
+ calls: list[list[str]] = []
+
+ def fake_run(argv: list[str], timeout: float) -> str:
+ calls.append(argv)
+ return "ok"
+
+ monkeypatch.setattr("devops_cli.ai.mcp.server._run_mcp_cmd", fake_run)
+ return calls
+
+
+def test_every_published_path_parameter_is_confined_or_is_not_a_file() -> None:
+ for tool, parameters in _path_parameters().items():
+ for parameter in parameters:
+ confined = parameter in PATH_CONFINEMENTS.get(tool, frozenset())
+ exempt = parameter in NOT_A_FILE_PATH.get(tool, {})
+ assert confined or exempt, f"{tool}.{parameter} has no path decision"
+
+
+def test_every_not_a_file_entry_names_a_published_path_parameter() -> None:
+ published = _path_parameters()
+ for tool, entries in NOT_A_FILE_PATH.items():
+ for parameter in entries:
+ assert parameter in published.get(tool, set()), f"stale {tool}.{parameter}"
+
+
+def test_every_confinement_entry_names_a_published_path_parameter() -> None:
+ published = _path_parameters()
+ for tool, parameters in PATH_CONFINEMENTS.items():
+ for parameter in parameters:
+ assert parameter in published.get(tool, set()), f"stale {tool}.{parameter}"
+
+
+def test_every_not_a_file_reason_is_at_least_thirty_characters() -> None:
+ for tool, entries in NOT_A_FILE_PATH.items():
+ for parameter, reason in entries.items():
+ assert len(reason) >= _MIN_REASON, f"{tool}.{parameter} has a short reason"
+
+
+def test_a_traversal_value_is_refused_before_a_command_runs(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ calls = _record(monkeypatch)
+ tool = _tool(("gh", "pages", "verify"))
+
+ with pytest.raises(ValidationError) as caught:
+ tool(root_dir="../../etc/passwd")
+
+ assert caught.value.details["field"] == "root_dir"
+ assert calls == []
+
+
+def test_an_absolute_path_outside_the_work_tree_is_refused(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ calls = _record(monkeypatch)
+ tool = _tool(("ai", "ast", "graph"))
+
+ with pytest.raises(ValidationError) as caught:
+ tool(target_dir="/etc")
+
+ assert caught.value.details["field"] == "target_dir"
+ assert calls == []
+
+
+def test_a_relative_path_stays_in_the_work_tree(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ (tmp_path / "docs").mkdir()
+ monkeypatch.chdir(tmp_path)
+ calls = _record(monkeypatch)
+ tool = _tool(("gh", "pages", "verify"))
+
+ assert tool(root_dir="docs") == "ok"
+
+ assert calls == [
+ ["uv", "run", "devops", "gh", "pages", "verify", "--dir", str(tmp_path / "docs")]
+ ]
+
+
+def test_a_relative_path_resolves_from_a_nested_directory_against_the_work_tree(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ (tmp_path / ".git").mkdir()
+ (tmp_path / "docs").mkdir()
+ nested = tmp_path / "nested"
+ nested.mkdir()
+ monkeypatch.chdir(nested)
+ calls = _record(monkeypatch)
+ tool = _tool(("gh", "pages", "verify"))
+
+ assert tool(root_dir="docs") == "ok"
+
+ assert calls == [
+ ["uv", "run", "devops", "gh", "pages", "verify", "--dir", str(tmp_path / "docs")]
+ ]
+
+
+def test_the_confinement_root_is_not_a_tool_argument() -> None:
+ tool = _tool(("gh", "pages", "verify"))
+ assert {"root", "base_dir", "work_tree"}.isdisjoint(tool.__signature__.parameters)
+
+
+@pytest.mark.parametrize(
+ ("tool", "dropped"),
+ [
+ ("ai_ingest_library", {"output_dir"}),
+ ("scan_sbom", {"output_file"}),
+ ("scan_aibom", {"output_file"}),
+ ("ai_ast_graph", {"output_path"}),
+ ("k8s_security_stream", {"output"}),
+ ("k8s_create_tls_secret", {"cert_path", "key_path"}),
+ ("k8s_enable_tls", {"tls_dir"}),
+ ("ssh_audit", {"key_dir"}),
+ ("ssh_status", {"key_dir"}),
+ ],
+)
+def test_a_tool_publishes_no_unconfined_write_or_free_path_option(
+ tool: str, dropped: set[str]
+) -> None:
+ published = set(_properties(_catalogue()[tool]))
+ assert dropped.isdisjoint(published)
diff --git a/tests/roadmap/test_roadmap_intake.py b/tests/roadmap/test_roadmap_intake.py
index 8c56f0e16..31bb025a7 100644
--- a/tests/roadmap/test_roadmap_intake.py
+++ b/tests/roadmap/test_roadmap_intake.py
@@ -1730,9 +1730,10 @@ def run(cmd: list[str], **_: object) -> str:
def test_the_mcp_mirror_plans_by_default_and_builds_a_command_line_the_cli_accepts(
- opened: Roadmap, fake_model: FakeModel, tmp_path: Path
+ opened: Roadmap, fake_model: FakeModel, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
opened.issue("feat: from mcp")
+ monkeypatch.chdir(tmp_path)
tool = asyncio.run(mcp_server.mcp.get_tool("roadmap_intake"))
names = {listed.name for listed in asyncio.run(mcp_server.mcp._list_tools())}
exits: list[int] = []
diff --git a/tests/tf/test_tf_cost.py b/tests/tf/test_tf_cost.py
index 2289e7af6..2beb33397 100644
--- a/tests/tf/test_tf_cost.py
+++ b/tests/tf/test_tf_cost.py
@@ -215,11 +215,12 @@ def test_tf_cost_command_dry_run(tmp_path: Path) -> None:
assert "infracost breakdown" in result.output
-def test_tf_cost_breakdown_mcp_tool(tmp_path: Path) -> None:
+def test_tf_cost_breakdown_mcp_tool(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Verify FastMCP tool tf_cost_breakdown produces valid JSON output."""
from devops_cli.ai.mcp.server import tf_cost_breakdown
- out = tf_cost_breakdown(directory=str(tmp_path), mock=True)
+ monkeypatch.chdir(tmp_path)
+ out = tf_cost_breakdown(directory=".", mock=True)
assert "total_monthly_cost" in out