From c7e7c280b6a65be70e2575d6fc6c41ad723ed7d4 Mon Sep 17 00:00:00 2001 From: zereight Date: Sat, 10 Oct 2026 05:51:07 +0900 Subject: [PATCH 1/9] feat: add explicit opt-in gitlab_api_request tool (api toolset) Adds gitlab_api_request in a new explicit-only 'api' toolset for one authenticated GitLab REST call under /api/v4. Enabled only when 'api' is listed in GITLAB_TOOLSETS; GITLAB_TOOLSETS=all, discover_tools, GITLAB_TOOLS, USE_* and GITLAB_TOOL_PROFILE do not enable it. Readonly allows GET only, modify rejects DELETE, path validation rejects absolute URLs, other hosts, dot segments and backslashes, and responses go through existing redaction/masking. Docs carry strong bypass warnings. Closes #804 --- .well-known/mcp/server-card.json | 42 ++- CHANGELOG.md | 1 + README.ko.md | 10 +- README.md | 13 +- README.pt-BR.md | 10 +- README.zh-CN.md | 10 +- cli/exposure.ts | 7 + docker/DOCKERHUB.md | 2 +- docs/comparison/community-gitlab-mcp-a.md | 2 +- docs/comparison/index.md | 2 +- docs/configuration/environment-variables.md | 13 +- docs/getting-started/cli-arguments.md | 2 +- docs/reference/cli-usage-design.md | 4 +- docs/reference/tool-coverage.md | 15 +- docs/tools/api.md | 32 ++ docs/tools/index.md | 21 +- docs/tools/meta.md | 2 +- index.ts | 75 +++- mkdocs.yml | 1 + scripts/generate-tool-docs.ts | 43 ++- scripts/tool-coverage/coverage.ts | 2 + skills/gitlab-mcp/SKILL.md | 16 +- test/test-gitlab-api-request.ts | 385 ++++++++++++++++++++ test/test-toolset-filtering.ts | 10 +- test/utils/gitlab-api-request.test.ts | 232 ++++++++++++ tools/discover-activation.ts | 4 + tools/exposed-tools.ts | 16 +- tools/gitlab-api-request.ts | 235 ++++++++++++ tools/registry.ts | 62 +++- tools/tool-descriptions.ts | 2 + utils/url.ts | 91 +++++ 31 files changed, 1303 insertions(+), 59 deletions(-) create mode 100644 docs/tools/api.md create mode 100644 test/test-gitlab-api-request.ts create mode 100644 test/utils/gitlab-api-request.test.ts create mode 100644 tools/gitlab-api-request.ts diff --git a/.well-known/mcp/server-card.json b/.well-known/mcp/server-card.json index 34706c7b7..17f276729 100644 --- a/.well-known/mcp/server-card.json +++ b/.well-known/mcp/server-card.json @@ -1,7 +1,7 @@ { "serverInfo": { "name": "io.github.zereight/gitlab-mcp", - "version": "2.2.3" + "version": "2.2.4" }, "authentication": { "required": true, @@ -10827,9 +10827,47 @@ ] } }, + { + "name": "gitlab_api_request", + "description": "DANGER: bypasses group and project restrictions, tool allowlists (GITLAB_TOOLS and GITLAB_DENIED_TOOLS_REGEX on other tools), and individual tool safety checks. The full token scope is exposed. Send one GitLab REST call under /api/v4. Readonly mode allows GET only, and modify mode rejects DELETE. Enable it only by listing api in GITLAB_TOOLSETS; all, discover_tools, and GITLAB_TOOLS do not. Prompt injection can drive this tool, so operators who rely on group, project, or tool restrictions must not enable it. GITLAB_ALLOWED_PROJECT_IDS is enforced only for /projects/:id paths; other endpoints are not constrained.", + "inputSchema": { + "type": "object", + "properties": { + "method": { + "description": "HTTP method. Defaults to GET. Non-GET requires a permission mode that allows writes. DELETE is rejected in modify mode.", + "type": "string", + "enum": [ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE" + ] + }, + "path": { + "type": "string", + "minLength": 1, + "description": "Path relative to /api/v4, such as projects/1 or /projects/1/issues. Absolute URLs are rejected." + }, + "query": { + "description": "Query string parameters. Arrays are repeated as separate keys.", + "type": "object", + "propertyNames": { + "type": "string" + } + }, + "body": { + "description": "JSON request body for POST, PUT, PATCH, and DELETE. Rejected on GET." + } + }, + "required": [ + "path" + ] + } + }, { "name": "discover_tools", - "description": "Discover and activate additional tool categories for this session. Available categories: core, merge_requests, issues, repositories, branches, projects, labels, ci, groups, pipelines, milestones, wiki, releases, tags, snippets, users, workitems, webhooks, search, variables, dependency_proxy, vulnerabilities, orbit. Already-active categories are listed in the response. Use this when a needed opt-in category is not currently exposed; omit `category` to inspect available categories, then call it with a category to activate that group for the current session. It changes only the session's tool registry, returns the active-tool summary, and does not change GitLab data.", + "description": "Discover and activate additional tool categories for this session. Available categories: core, merge_requests, issues, repositories, branches, projects, labels, ci, groups, pipelines, milestones, wiki, releases, tags, snippets, users, workitems, webhooks, search, variables, dependency_proxy, vulnerabilities, orbit. Already-active categories are listed in the response. Explicit-only toolsets are omitted; list them in GITLAB_TOOLSETS. Use this when a needed opt-in category is not currently exposed; omit `category` to inspect available categories, then call it with a category to activate that group for the current session. It changes only the session's tool registry, returns the active-tool summary, and does not change GitLab data.", "inputSchema": { "type": "object", "properties": { diff --git a/CHANGELOG.md b/CHANGELOG.md index d555086ec..93440ef50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). ##### Added - Protected tag tools in the `tags` toolset: `list_protected_tags`, `get_protected_tag`, `protect_tag`, and `unprotect_tag` ([#802](https://github.com/zereight/gitlab-mcp/issues/802)). `protect_tag` and `unprotect_tag` are writes (`readonly` hides them). `unprotect_tag` is also blocked in `modify`, matching `unprotect_branch`. +- Explicit opt-in `api` toolset with `gitlab_api_request` for one GitLab REST call under `/api/v4`. Not enabled by `GITLAB_TOOLSETS=all`, `discover_tools`, or `GITLAB_TOOLS`. Readonly allows GET only; modify rejects DELETE ([#804](https://github.com/zereight/gitlab-mcp/issues/804)) - Opt-in `snippets` toolset: `list_snippets`, `get_snippet`, `create_snippet`, `update_snippet`, and `delete_snippet` for personal and project snippets ([#730](https://github.com/zereight/gitlab-mcp/pull/730)) - Standalone `zereight-mcp-gitlab auth` device-flow command for SSO / headless OAuth ([#591](https://github.com/zereight/gitlab-mcp/issues/591)) - `create_webhook`, `update_webhook`, and `delete_webhook` for project and group hooks ([#713](https://github.com/zereight/gitlab-mcp/issues/713)) diff --git a/README.ko.md b/README.ko.md index d3745db8f..a329a1cb3 100644 --- a/README.ko.md +++ b/README.ko.md @@ -36,7 +36,7 @@ PAT, OAuth, 읽기 전용 모드, 동적 API URL, 원격 인증을 지원하며 ### 왜 이 GitLab MCP를 사용하나요? -- **274개 도구: toolset 272개 + `execute_graphql` + `discover_tools`** — 작은 toolset으로 시작하고, 런타임에 카테고리 활성화 +- **275개 도구: toolset 273개 + `execute_graphql` + `discover_tools`** — 작은 toolset으로 시작하고, 런타임에 카테고리 활성화 - **MR 2단계 리뷰** — `list_merge_request_changed_files` → 배치 `get_merge_request_file_diff` - **Agent Skill 내장** — `skills/gitlab-mcp/` 워크플로우 가이드 - **유연한 인증** — Personal Access Token, 로컬 OAuth2 브라우저 플로우, MCP OAuth 프록시, 요청별 원격 인증 @@ -50,7 +50,7 @@ PAT, OAuth, 읽기 전용 모드, 동적 API URL, 원격 인증을 지원하며 | | @zereight/mcp-gitlab | GitLab MCP A (커뮤니티 CQRS형) | GitLab 공식 MCP 서버 | | --------------- | ---------------------------------------------------------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- | | **적합한 경우** | AI 에이전트 워크플로우 | 엔터프라이즈 멀티 인스턴스 / 그룹형 도구 | GitLab에 내장, 별도 설치 없음 | -| **도구 모델** | 274개 도구: toolset 272개 + `execute_graphql` + `discover_tools` | ~50–60개 `browse_*` / `manage_*` 그룹 도구 | HTTP 헤더로 고르는 toolset | +| **도구 모델** | 275개 도구: toolset 273개 + `execute_graphql` + `discover_tools` | ~50–60개 `browse_*` / `manage_*` 그룹 도구 | HTTP 헤더로 고르는 toolset | | **MR 리뷰** | 2단계 배치 diff | 서버마다 다름 | `get_merge_request`에 diffs | | **Node.js** | >=18.17 | 보통 >=24 | stdio는 `mcp-remote`, Node.js 20+ | | **라이선스** | MIT | 서버마다 다름 | GitLab의 일부 | @@ -162,7 +162,7 @@ command = lib.getExe inputs.gitlab-mcp.packages.${system}.default; - `--api-url` - GitLab API URL (`GITLAB_API_URL` 대체) - `--read-only=true` - Deprecated (removed in next major (v3.0.0)); `--permission-mode=readonly`를 사용 (`GITLAB_READ_ONLY_MODE` 대체) - `--permission-mode` - 권한 수준: `readonly`, `modify`(삭제/중단 도구 비활성), `full` (`GITLAB_PERMISSION_MODE` 대체, 기본값 `full`) -- `--toolsets=all` - 지정한 툴셋 활성화 (`GITLAB_TOOLSETS` 대체, 미설정 시 lean `core` 기본값 사용) +- `--toolsets=all` - 지정한 툴셋 활성화. 명시적 `api` 툴셋은 빠짐 (`GITLAB_TOOLSETS` 대체, 미설정 시 lean `core` 기본값 사용). `gitlab_api_request`는 `--toolsets=api` - `--tools=list_issues` - 개별 도구 추가 (`GITLAB_TOOLS` 대체) - `--use-wiki=true` - Deprecated (removed in next major (v3.0.0)); `--toolsets=core,wiki`를 사용 (`USE_GITLAB_WIKI` 대체) - `--use-milestone=true` - Deprecated (removed in next major (v3.0.0)); `--toolsets=core,milestones`를 사용 (`USE_MILESTONE` 대체) @@ -202,6 +202,10 @@ CLI 인자는 환경 변수보다 우선합니다. > [Tools Reference](./docs/tools/index.md#feature-toggles)와 > [Environment Variables](./docs/configuration/environment-variables.md)를 참고하세요. +> [!CAUTION] +> `gitlab_api_request`(`api` 툴셋)는 `GITLAB_ALLOWED_GROUPS`와 그 밖의 그룹 제한, 프로젝트 허용 목록, 도구 단위 제한(`GITLAB_TOOLS`, 다른 도구에 걸린 `GITLAB_DENIED_TOOLS_REGEX`), 개별 도구 안전 검사를 우회합니다. 토큰 권한 범위 전체가 노출됩니다. 프롬프트 인젝션이 이 도구를 호출할 수 있습니다. 이런 제한에 의존하는 운영자는 켜면 안 됩니다. +> `GITLAB_TOOLSETS`에 `api`를 직접 적을 때만 켜집니다 (`GITLAB_TOOLSETS=all`, `discover_tools`, `GITLAB_TOOLS`, 레거시 `USE_*`, `GITLAB_TOOL_PROFILE`로는 안 됩니다). `GITLAB_ALLOWED_PROJECT_IDS`는 `/projects/:id` 경로만 검사합니다. `/search`, `/users`, `/groups/:id`, `/runners` 같은 엔드포인트는 제한되지 않습니다. `GITLAB_PROJECT_ID`와 OAuth 그룹 허용 목록은 이 도구가 검사하지 않습니다. + - sse 변수 이름만 넘기세요. Docker 클라이언트는 export한 `GITLAB_PERSONAL_ACCESS_TOKEN` 값을 컨테이너로 복사합니다. diff --git a/README.md b/README.md index ccb9abd2f..5c0ab2f52 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ Supports PAT, OAuth, read-only mode, dynamic API URLs, and remote authorization ### Why use this GitLab MCP? -- **274 tools: 272 in toolsets + `execute_graphql` + `discover_tools`** — start with a small toolset; activate more at runtime without CQRS-style grouping +- **275 tools: 273 in toolsets + `execute_graphql` + `discover_tools`** — start with a small toolset; activate more at runtime without CQRS-style grouping - **MR 2-step review** — `list_merge_request_changed_files` → batched `get_merge_request_file_diff` - **Agent Skill built in** — workflow guidance in `skills/gitlab-mcp/` - **Flexible auth** — Personal Access Token, local OAuth2 browser flow, MCP OAuth proxy, and per-request remote authorization @@ -55,7 +55,7 @@ Supports PAT, OAuth, read-only mode, dynamic API URLs, and remote authorization | | @zereight/mcp-gitlab | GitLab MCP A (community CQRS-style) | GitLab official MCP server | | -------------- | ----------------------------------------------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | | **Best for** | AI agent workflows | Enterprise multi-instance / grouped tools | Built into GitLab, no separate install | -| **Tool model** | 274 tools: 272 in toolsets + `execute_graphql` + `discover_tools` | ~50–60 grouped `browse_*` / `manage_*` tools | Toolsets chosen with an HTTP header | +| **Tool model** | 275 tools: 273 in toolsets + `execute_graphql` + `discover_tools` | ~50–60 grouped `browse_*` / `manage_*` tools | Toolsets chosen with an HTTP header | | **MR review** | 2-step batched diff | Varies | `get_merge_request` with diffs | | **Node.js** | >=18.17 | Often >=24 | Node.js 20+ for stdio via `mcp-remote` | | **License** | MIT | Varies | Part of GitLab | @@ -167,7 +167,7 @@ Some MCP clients (like GitHub Copilot CLI) have issues with environment variable - `--api-url` - GitLab API URL (replaces `GITLAB_API_URL`) - `--read-only=true` - Deprecated (removed in next major (v3.0.0)); use `--permission-mode=readonly` instead (replaces `GITLAB_READ_ONLY_MODE`) - `--permission-mode` - Permission level: `readonly`, `modify` (no delete or teardown tools), or `full` (replaces `GITLAB_PERMISSION_MODE`, default `full`) -- `--toolsets=all` - Enable named toolsets (replaces `GITLAB_TOOLSETS`; unset uses the lean `core` default) +- `--toolsets=all` - Enable named toolsets except the explicit-only `api` toolset (replaces `GITLAB_TOOLSETS`; unset uses the lean `core` default). `gitlab_api_request` needs `--toolsets=api` - `--tools=list_issues` - Add individual tools (replaces `GITLAB_TOOLS`) - `--use-wiki=true` - Deprecated (removed in next major (v3.0.0)); use `--toolsets=core,wiki` instead (replaces `USE_GITLAB_WIKI`) - `--use-milestone=true` - Deprecated (removed in next major (v3.0.0)); use `--toolsets=core,milestones` instead (replaces `USE_MILESTONE`) @@ -208,6 +208,10 @@ The same binary is also a `gh`-style GitLab CLI (`tool ` or curated forms > See [Tools Reference](./docs/tools/index.md#feature-toggles) and > [Environment Variables](./docs/configuration/environment-variables.md). +> [!CAUTION] +> `gitlab_api_request` (toolset `api`) bypasses `GITLAB_ALLOWED_GROUPS` and other group restrictions, project allowlists, tool-level restrictions (`GITLAB_TOOLS`, `GITLAB_DENIED_TOOLS_REGEX` on other tools), and individual tool safety checks. The full token permission scope is exposed. Prompt injection can drive this tool. Operators who rely on these restrictions must NOT enable it. +> Enable it only by listing `api` in `GITLAB_TOOLSETS` (`GITLAB_TOOLSETS=all`, `discover_tools`, `GITLAB_TOOLS`, legacy `USE_*` flags, and `GITLAB_TOOL_PROFILE` do not). `GITLAB_ALLOWED_PROJECT_IDS` is checked only for `/projects/:id` paths. Endpoints such as `/search`, `/users`, `/groups/:id`, and `/runners` are not constrained. `GITLAB_PROJECT_ID` and OAuth group allowlists are not enforced. + - sse Pass the variable name only. The Docker client copies the exported `GITLAB_PERSONAL_ACCESS_TOKEN` into the container. @@ -918,7 +922,8 @@ Register the skill directory in your AI client to get optimal tool usage guidanc 271. `delete_snippet` - Delete a snippet 272. `list_project_runners` - List runners available to a project (type, status, paused, tag_list). Omits registration and authentication tokens. 273. `get_runner` - Get a runner's tag_list, run_untagged, locked, and access_level. When a project allowlist is active, the runner must be available to an allowed project. Omits registration and authentication tokens. -274. `discover_tools` - Discover and activate additional tool categories for this session. Available categories: core, merge_requests, issues, repositories, branches, projects, labels, ci, groups, pipelines, milestones, wiki, releases, tags, snippets, users, workitems, webhooks, search, variables, dependency_proxy, vulnerabilities, orbit. Already-active categories are listed in the response. +274. `gitlab_api_request` - Send one GitLab REST call under /api/v4. Enabled only when GITLAB_TOOLSETS lists api. Readonly allows GET; modify rejects DELETE. +275. `discover_tools` - Discover and activate additional tool categories for this session. Available categories: core, merge_requests, issues, repositories, branches, projects, labels, ci, groups, pipelines, milestones, wiki, releases, tags, snippets, users, workitems, webhooks, search, variables, dependency_proxy, vulnerabilities, orbit. Already-active categories are listed in the response. diff --git a/README.pt-BR.md b/README.pt-BR.md index ae051d9d0..06bd0c195 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -36,7 +36,7 @@ Suporta PAT, OAuth, modo somente leitura, URLs de API dinâmicas e autorização ### Por que usar este GitLab MCP? -- **274 ferramentas: 272 em toolsets + `execute_graphql` + `discover_tools`** — comece com um toolset pequeno e ative mais categorias em tempo de execução +- **275 ferramentas: 273 em toolsets + `execute_graphql` + `discover_tools`** — comece com um toolset pequeno e ative mais categorias em tempo de execução - **Revisão de MR em 2 etapas** — `list_merge_request_changed_files` → `get_merge_request_file_diff` em lote - **Agent Skill integrado** — orientação de workflows em `skills/gitlab-mcp/` - **Autenticação flexível** — Personal Access Token, fluxo OAuth2 local no navegador, proxy MCP OAuth e autorização remota por requisição @@ -50,7 +50,7 @@ Suporta PAT, OAuth, modo somente leitura, URLs de API dinâmicas e autorização | | @zereight/mcp-gitlab | GitLab MCP A (comunidade, estilo CQRS) | Servidor MCP oficial do GitLab | | ------------------------- | ----------------------------------------------------------------------- | --------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Mais indicado para** | Workflows de agentes de IA | Múltiplas instâncias corporativas / ferramentas agrupadas | Integrado ao GitLab, sem instalação separada | -| **Modelo de ferramentas** | 274 ferramentas: 272 em toolsets + `execute_graphql` + `discover_tools` | ~50–60 ferramentas agrupadas `browse_*` / `manage_*` | Toolsets escolhidos por cabeçalho HTTP | +| **Modelo de ferramentas** | 275 ferramentas: 273 em toolsets + `execute_graphql` + `discover_tools` | ~50–60 ferramentas agrupadas `browse_*` / `manage_*` | Toolsets escolhidos por cabeçalho HTTP | | **Revisão de MR** | Diff em lote em 2 etapas | Varia | `get_merge_request` com diffs | | **Node.js** | >=18.17 | Frequentemente >=24 | Node.js 20+ para stdio via `mcp-remote` | | **Licença** | MIT | Varia | Parte do GitLab | @@ -162,7 +162,7 @@ Alguns clientes MCP, como o GitHub Copilot CLI, têm problemas com variáveis de - `--api-url` - URL da API do GitLab (substitui `GITLAB_API_URL`) - `--read-only=true` - Deprecated (removed in next major (v3.0.0)); use `--permission-mode=readonly` (substitui `GITLAB_READ_ONLY_MODE`) - `--permission-mode` - nível de permissão: `readonly`, `modify` (sem ferramentas de exclusão ou teardown) ou `full` (substitui `GITLAB_PERMISSION_MODE`, padrão `full`) -- `--toolsets=all` - ativa toolsets nomeados (substitui `GITLAB_TOOLSETS`; sem valor usa o padrão enxuto `core`) +- `--toolsets=all` - ativa toolsets nomeados, exceto o toolset explícito `api` (substitui `GITLAB_TOOLSETS`; sem valor usa o padrão enxuto `core`). `gitlab_api_request` exige `--toolsets=api` - `--tools=list_issues` - adiciona ferramentas individuais (substitui `GITLAB_TOOLS`) - `--use-wiki=true` - Deprecated (removed in next major (v3.0.0)); use `--toolsets=core,wiki` (substitui `USE_GITLAB_WIKI`) - `--use-milestone=true` - Deprecated (removed in next major (v3.0.0)); use `--toolsets=core,milestones` (substitui `USE_MILESTONE`) @@ -202,6 +202,10 @@ O mesmo binário também é uma CLI do GitLab no estilo `gh` (`tool ` ou f > Consulte a [Referência de ferramentas](./docs/tools/index.md#feature-toggles) e > [Variáveis de ambiente](./docs/configuration/environment-variables.md). +> [!CAUTION] +> `gitlab_api_request` (toolset `api`) ignora `GITLAB_ALLOWED_GROUPS` e outras restrições de grupo, allowlists de projeto, restrições por ferramenta (`GITLAB_TOOLS`, `GITLAB_DENIED_TOOLS_REGEX` nas outras ferramentas) e as checagens de segurança de cada ferramenta. O escopo completo do token fica exposto. Prompt injection pode acionar esta ferramenta. Operadores que dependem dessas restrições NÃO devem habilitá-la. +> Ela só liga quando `api` está listado em `GITLAB_TOOLSETS` (`GITLAB_TOOLSETS=all`, `discover_tools`, `GITLAB_TOOLS`, flags legadas `USE_*` e `GITLAB_TOOL_PROFILE` não ligam). `GITLAB_ALLOWED_PROJECT_IDS` vale só para caminhos `/projects/:id`. Endpoints como `/search`, `/users`, `/groups/:id` e `/runners` não são restringidos. `GITLAB_PROJECT_ID` e allowlists de grupo OAuth não são aplicados por esta ferramenta. + - sse Passe só o nome da variável. O cliente Docker copia o `GITLAB_PERSONAL_ACCESS_TOKEN` exportado para o contêiner. diff --git a/README.zh-CN.md b/README.zh-CN.md index dc1bc3117..c42653fd6 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -36,7 +36,7 @@ read -rs GITLAB_PERSONAL_ACCESS_TOKEN && export GITLAB_PERSONAL_ACCESS_TOKEN ### 为什么使用这个 GitLab MCP? -- **274 个工具:toolset 中 272 个 + `execute_graphql` + `discover_tools`** — 从小型 toolset 开始,运行时按需激活类别 +- **275 个工具:toolset 中 273 个 + `execute_graphql` + `discover_tools`** — 从小型 toolset 开始,运行时按需激活类别 - **MR 两步审查** — `list_merge_request_changed_files` → 批量 `get_merge_request_file_diff` - **内置 Agent Skill** — `skills/gitlab-mcp/` 工作流指南 - **认证灵活** — Personal Access Token、本地 OAuth2 浏览器流程、MCP OAuth 代理、按请求远程授权 @@ -50,7 +50,7 @@ read -rs GITLAB_PERSONAL_ACCESS_TOKEN && export GITLAB_PERSONAL_ACCESS_TOKEN | | @zereight/mcp-gitlab | GitLab MCP A(社区 CQRS 型) | GitLab 官方 MCP 服务器 | | ------------ | -------------------------------------------------------------------- | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- | | **更适合** | AI 代理工作流 | 企业多实例 / 分组工具 | 内置于 GitLab,无需单独安装 | -| **工具模型** | 274 个工具:toolset 中 272 个 + `execute_graphql` + `discover_tools` | ~50–60 个 `browse_*` / `manage_*` 分组工具 | 用 HTTP 头选择 toolset | +| **工具模型** | 275 个工具:toolset 中 273 个 + `execute_graphql` + `discover_tools` | ~50–60 个 `browse_*` / `manage_*` 分组工具 | 用 HTTP 头选择 toolset | | **MR 审查** | 两步批量 diff | 因服务器而异 | `get_merge_request` 带 diffs | | **Node.js** | >=18.17 | 通常 >=24 | stdio 经 `mcp-remote`,需要 Node.js 20+ | | **许可证** | MIT | 因服务器而异 | GitLab 的一部分 | @@ -162,7 +162,7 @@ command = lib.getExe inputs.gitlab-mcp.packages.${system}.default; - `--api-url` - GitLab API URL(替代 `GITLAB_API_URL`) - `--read-only=true` - Deprecated (removed in next major (v3.0.0));请改用 `--permission-mode=readonly`(替代 `GITLAB_READ_ONLY_MODE`) - `--permission-mode` - 权限级别:`readonly`、`modify`(禁用删除/拆除工具)或 `full`(替代 `GITLAB_PERMISSION_MODE`,默认 `full`) -- `--toolsets=all` - 启用指定工具集(替代 `GITLAB_TOOLSETS`;未设置时使用精简 `core` 默认值) +- `--toolsets=all` - 启用指定工具集,但不包括必须显式列出的 `api`(替代 `GITLAB_TOOLSETS`;未设置时使用精简 `core` 默认值)。`gitlab_api_request` 需要 `--toolsets=api` - `--tools=list_issues` - 添加单个工具(替代 `GITLAB_TOOLS`) - `--use-wiki=true` - Deprecated (removed in next major (v3.0.0));请改用 `--toolsets=core,wiki`(替代 `USE_GITLAB_WIKI`) - `--use-milestone=true` - Deprecated (removed in next major (v3.0.0));请改用 `--toolsets=core,milestones`(替代 `USE_MILESTONE`) @@ -201,6 +201,10 @@ CLI 参数优先于环境变量。 > 参见 [Tools Reference](./docs/tools/index.md#feature-toggles) 和 > [Environment Variables](./docs/configuration/environment-variables.md)。 +> [!CAUTION] +> `gitlab_api_request`(`api` 工具集)会绕过 `GITLAB_ALLOWED_GROUPS` 及其他群组限制、项目允许列表、工具级限制(`GITLAB_TOOLS`、作用在其他工具上的 `GITLAB_DENIED_TOOLS_REGEX`)以及各个工具自己的安全检查。令牌的完整权限范围都会暴露。提示注入可以驱动这个工具。依赖这些限制的运维人员不得启用它。 +> 只有在 `GITLAB_TOOLSETS` 中显式写入 `api` 时才会启用(`GITLAB_TOOLSETS=all`、`discover_tools`、`GITLAB_TOOLS`、旧版 `USE_*`、`GITLAB_TOOL_PROFILE` 都不会)。`GITLAB_ALLOWED_PROJECT_IDS` 只检查 `/projects/:id` 路径。`/search`、`/users`、`/groups/:id`、`/runners` 等端点不受约束。`GITLAB_PROJECT_ID` 和 OAuth 群组允许列表不会被此工具强制执行。 + - sse 只传变量名。Docker 客户端把已 export 的 `GITLAB_PERSONAL_ACCESS_TOKEN` 复制进容器。 diff --git a/cli/exposure.ts b/cli/exposure.ts index f26154a1f..3cc9cc105 100644 --- a/cli/exposure.ts +++ b/cli/exposure.ts @@ -8,6 +8,7 @@ import { import { compileDeniedToolsRegex } from "../tools/denied-regex.js"; import { buildFeatureFlagOverrides, + isExplicitToolsetOnlyTool, isToolInEnabledToolset, parseEnabledToolsets, parseIndividualTools, @@ -51,6 +52,9 @@ export function isToolExposed(input: ToolExposureInput): boolean { if (isToolInEnabledToolset(input.toolName, input.enabledToolsets)) { return true; } + if (isExplicitToolsetOnlyTool(input.toolName)) { + return false; + } if (input.individuallyEnabledTools.has(input.toolName)) { return true; } @@ -67,6 +71,9 @@ export function exposureRefusalMessage(input: ToolExposureInput): string | undef if (isToolExposed(input)) { return undefined; } + if (isExplicitToolsetOnlyTool(input.toolName)) { + return `${input.toolName} is enabled only by listing its toolset in GITLAB_TOOLSETS. GITLAB_TOOLS, GITLAB_TOOLSETS=all, and discover_tools do not enable it.`; + } return `${input.toolName} is not enabled by GITLAB_TOOLSETS, GITLAB_TOOLS, or legacy wiki/milestone/pipeline flags`; } diff --git a/docker/DOCKERHUB.md b/docker/DOCKERHUB.md index c4bd8f364..c7eeb426a 100644 --- a/docker/DOCKERHUB.md +++ b/docker/DOCKERHUB.md @@ -4,7 +4,7 @@ Agent-workflow-optimized [GitLab MCP](https://github.com/zereight/gitlab-mcp) se projects, merge requests, issues, pipelines, wiki, releases, tags, milestones, and more from AI clients such as Claude Code, VS Code, Cursor, and Copilot. -- 274 tools: 272 in toolsets + `execute_graphql` + `discover_tools` for on-demand activation +- 275 tools: 273 in toolsets + `execute_graphql` + `discover_tools` for on-demand activation - Personal Access Token, OAuth2, read-only mode, and remote authorization - stdio, SSE, and Streamable HTTP transports - Works with gitlab.com and self-hosted instances diff --git a/docs/comparison/community-gitlab-mcp-a.md b/docs/comparison/community-gitlab-mcp-a.md index 30be12f13..a36d5e148 100644 --- a/docs/comparison/community-gitlab-mcp-a.md +++ b/docs/comparison/community-gitlab-mcp-a.md @@ -11,7 +11,7 @@ vendor or fork. | Dimension | @zereight/mcp-gitlab | GitLab MCP A (CQRS-style) | |-----------|----------------------|---------------------------| -| Tool count (listed) | 274 tools: 272 in toolsets + `execute_graphql` + `discover_tools` | ~50–60 grouped tools | +| Tool count (listed) | 275 tools: 273 in toolsets + `execute_graphql` + `discover_tools` | ~50–60 grouped tools | | Operations | 1 tool ≈ 1 API call | 1 tool × many `action` values | | Token control | `discover_tools`, `GITLAB_TOOLSETS`, `GITLAB_TOOLS` | Smaller default tool list | | Tool discovery | Explicit names (`get_merge_request`, `list_pipelines`, …) | Category + action (`browse_merge_requests`, …) | diff --git a/docs/comparison/index.md b/docs/comparison/index.md index 5e662df46..a77161d37 100644 --- a/docs/comparison/index.md +++ b/docs/comparison/index.md @@ -8,7 +8,7 @@ servers. | | @zereight/mcp-gitlab | GitLab MCP A | |---|----------------------|--------------| | **Best for** | AI agent workflows (MR review, pipelines, issues) | Enterprise platform teams (multi-instance, CQRS tooling) | -| **Tool model** | 274 tools: 272 in toolsets + `execute_graphql` + `discover_tools` | CQRS-style grouped tools with action parameters | +| **Tool model** | 275 tools: 273 in toolsets + `execute_graphql` + `discover_tools` | CQRS-style grouped tools with action parameters | | **Token budget** | Start minimal via `discover_tools` / toolsets | Fewer listed tools by default | | **Node.js** | >=18 | Typically >=24 | | **License** | MIT | Varies (often Apache-2.0) | diff --git a/docs/configuration/environment-variables.md b/docs/configuration/environment-variables.md index 344b0a693..a93af0952 100644 --- a/docs/configuration/environment-variables.md +++ b/docs/configuration/environment-variables.md @@ -637,6 +637,8 @@ Behavior: mutation field whose name contains a deletion verb (`delete`, `destroy`, `remove`, `prune`, `purge`, `erase`) or a teardown verb (`revoke`, `cancel`, `stop`, `terminate`, `unprotect`, `disable`, `deactivate`, `drop`, `unschedule`) +- `gitlab_api_request`, when the `api` toolset is explicitly enabled, stays listed in `readonly` + and `modify`. The handler allows only `GET` in `readonly` and rejects `DELETE` in `modify` - Invalid values fail startup with an error - `GITLAB_DENIED_TOOLS_REGEX` and the tool policy variables still apply on top @@ -674,8 +676,17 @@ For agent-specific tool surfaces, see Special values: -- `all` — enable every toolset. +- `all` — enable every toolset except the explicit-only `api` toolset. - `GITLAB_TOOLSETS=merge_requests,issues,repositories,branches,projects,labels,ci,groups,users` — restores the pre-lean default set. +- `api` — enable `gitlab_api_request`. This id is **not** included in `all`. `discover_tools`, `GITLAB_TOOLS=gitlab_api_request`, legacy `USE_*` flags, and `GITLAB_TOOL_PROFILE` do not enable it. CLI: `--toolsets=api` (same `--toolsets` flag as the other ids). + +!!! danger "gitlab_api_request bypasses other restrictions" + + `gitlab_api_request` bypasses `GITLAB_ALLOWED_GROUPS` and other group restrictions, project allowlists, tool-level restrictions (`GITLAB_TOOLS`, `GITLAB_DENIED_TOOLS_REGEX` on other tools), and individual tool safety checks. The full token permission scope is exposed. Prompt injection in GitLab content can drive this tool. Operators who rely on these restrictions must NOT enable the `api` toolset. + + `GITLAB_ALLOWED_PROJECT_IDS` and a session project scope (`ENABLE_DYNAMIC_PROJECT_SCOPE`) are checked only for `/projects/:id` and `/projects/:id/...`. Endpoints such as `/search`, `/users`, `/groups/:id`, `/runners`, and the `/projects` collection are not constrained. `GITLAB_PROJECT_ID` and OAuth group allowlists (`GITLAB_OAUTH_ALLOWED_GROUPS` / `GITLAB_ALLOWED_GROUPS`, which are checked at login) are not enforced by this tool. + + When the toolset is enabled the server logs a startup warning. `GITLAB_PERMISSION_MODE=readonly` (and legacy `GITLAB_READ_ONLY_MODE=true`) still lists the tool but the handler allows only `GET`. `modify` rejects `DELETE`. `GITLAB_DENIED_TOOLS_REGEX` and `GITLAB_TOOL_POLICY_APPROVE` still apply to `gitlab_api_request` itself. ### `GITLAB_TOOLS` diff --git a/docs/getting-started/cli-arguments.md b/docs/getting-started/cli-arguments.md index 4045eae7c..de1688f24 100644 --- a/docs/getting-started/cli-arguments.md +++ b/docs/getting-started/cli-arguments.md @@ -43,7 +43,7 @@ No global install? Pin `npx` to the previous stable release and keep the server | `--api-url` | `GITLAB_API_URL` | GitLab API URL (e.g., `https://gitlab.com/api/v4`). | | `--read-only=true` | `GITLAB_READ_ONLY_MODE` | Deprecated (removed in next major (v3.0.0)); use `--permission-mode=readonly` instead. | | `--permission-mode` | `GITLAB_PERMISSION_MODE` | `readonly`, `modify` (no delete or teardown tools), or `full`. | -| `--toolsets=all` | `GITLAB_TOOLSETS` | Enable named toolsets. Unset uses lean `core`. | +| `--toolsets=all` | `GITLAB_TOOLSETS` | Enable named toolsets. Unset uses lean `core`. `all` does not enable the explicit-only `api` toolset; use `--toolsets=api`. | | `--tools=list_issues` | `GITLAB_TOOLS` | Add individual tools on top of enabled toolsets. | | `--use-wiki=true` | `USE_GITLAB_WIKI` | Deprecated (removed in next major (v3.0.0)); use `--toolsets=core,wiki` or `GITLAB_TOOLSETS=core,wiki` instead. | | `--use-milestone=true` | `USE_MILESTONE` | Deprecated (removed in next major (v3.0.0)); use `--toolsets=core,milestones` or `GITLAB_TOOLSETS=core,milestones` instead. | diff --git a/docs/reference/cli-usage-design.md b/docs/reference/cli-usage-design.md index 6ff38f9c9..fc2891f91 100644 --- a/docs/reference/cli-usage-design.md +++ b/docs/reference/cli-usage-design.md @@ -33,7 +33,7 @@ Non-goals for this design: - `cli-command.ts` (`getPositionalCliCommand`) reads the first positional arg. `index.ts` `main()` dispatches `auth`, `tool `, and curated ` ` via `resolveCli`, then falls through to `runServer()` when there is no CLI command. - All other flags (`--token`, `--api-url`, `--permission-mode`, …) are server settings parsed in `config.ts`. Boolean MCP flags accept `--flag`, `--flag=true`, or `--flag false`; they do not swallow the next positional command. - Human CLI refuses tools that MCP would not expose: toolsets / `GITLAB_TOOLS` / legacy flags, `GITLAB_DENIED_TOOLS_REGEX`, `GITLAB_TOOL_POLICY_HIDDEN`. `GITLAB_TOOL_POLICY_APPROVE` requires `--yes`. -- Tool inventory: 274 tools in `allTools` (272 toolset tools plus `execute_graphql` and `discover_tools`), grouped into 23 toolsets in `TOOLSET_DEFINITIONS`. Shared tools (e.g. `get_branch`, `list_branches`) belong to several toolsets, tracked by `TOOLSETS_BY_TOOL_NAME`. +- Tool inventory: 275 tools in `allTools` (273 toolset tools plus `execute_graphql` and `discover_tools`), grouped into 24 toolsets in `TOOLSET_DEFINITIONS`. The `api` toolset is explicit-only (`GITLAB_TOOLSETS=all` does not enable it). Shared tools (e.g. `get_branch`, `list_branches`) belong to several toolsets, tracked by `TOOLSETS_BY_TOOL_NAME`. --- @@ -43,7 +43,7 @@ Two layers. The generic layer guarantees full coverage with zero per-tool code. ```text zereight-mcp-gitlab auth ... # unchanged -zereight-mcp-gitlab tool [options] # generic, all 274 tools +zereight-mcp-gitlab tool [options] # generic, all 275 tools zereight-mcp-gitlab [options] # curated, phased rollout zereight-mcp-gitlab --help | --help # generated help ``` diff --git a/docs/reference/tool-coverage.md b/docs/reference/tool-coverage.md index 8eca9bc82..483b423fe 100644 --- a/docs/reference/tool-coverage.md +++ b/docs/reference/tool-coverage.md @@ -8,9 +8,9 @@ A tool counts as invoked when a test sends that name through MCP (`callTool` / ` | | | | --- | ---: | -| Tools | 274 | -| Invoked | 169 | -| Coverage | 61.7% | +| Tools | 275 | +| Invoked | 170 | +| Coverage | 61.8% | ## By toolset @@ -38,6 +38,7 @@ A tool counts as invoked when a test sends that name through MCP (`callTool` / ` | [Vulnerabilities](#vulnerabilities) | 4 | 4 | 100.0% | | [GitLab Orbit](#orbit) | 4 | 4 | 100.0% | | [Snippets](#snippets) | 5 | 5 | 100.0% | +| [Raw API](#api) | 1 | 1 | 100.0% | | [Meta & GraphQL](#meta) | 2 | 2 | 100.0% | ## Projects & Namespaces @@ -424,9 +425,15 @@ A tool counts as invoked when a test sends that name through MCP (`callTool` / ` - [x] [`update_snippet`](../tools/snippets.md#update_snippet) — `test/test-dynamic-project-scope.ts`, `test/test-snippets.ts` - [x] [`delete_snippet`](../tools/snippets.md#delete_snippet) — `test/test-dynamic-project-scope.ts`, `test/test-snippets.ts` +## Raw API + + + +- [x] [`gitlab_api_request`](../tools/api.md#gitlab_api_request) — `test/test-gitlab-api-request.ts` + ## Meta & GraphQL - [x] [`execute_graphql`](../tools/meta.md#execute_graphql) — `test/test-dynamic-project-scope.ts`, `test/test-permission-mode.ts` -- [x] [`discover_tools`](../tools/meta.md#discover_tools) — `test/streamable-http-sse-stream.test.ts`, `test/test-permission-mode.ts`, `test/test-token-optimizations.ts`, `test/utils/tool-profile-server.test.ts` +- [x] [`discover_tools`](../tools/meta.md#discover_tools) — `test/streamable-http-sse-stream.test.ts`, `test/test-gitlab-api-request.ts`, `test/test-permission-mode.ts`, `test/test-token-optimizations.ts`, `test/utils/tool-profile-server.test.ts` diff --git a/docs/tools/api.md b/docs/tools/api.md new file mode 100644 index 000000000..99b878bd8 --- /dev/null +++ b/docs/tools/api.md @@ -0,0 +1,32 @@ +# Raw API + +Explicit opt-in escape hatch for one GitLab REST call under /api/v4. Not part of core, not enabled by GITLAB_TOOLSETS=all, and not activatable with discover_tools. + +!!! danger "Raw API escape hatch" + `gitlab_api_request` bypasses `GITLAB_ALLOWED_GROUPS` and other group restrictions, project allowlists, tool-level restrictions (`GITLAB_TOOLS`, `GITLAB_DENIED_TOOLS_REGEX` on other tools), and individual tool safety checks. The full token permission scope is exposed. Prompt injection in GitLab content can drive this tool. Operators who rely on these restrictions must NOT enable it. + + `GITLAB_ALLOWED_PROJECT_IDS` and a session project scope are checked only for `/projects/:id` and `/projects/:id/...`. Endpoints such as `/search`, `/users`, `/groups/:id`, `/runners`, and the `/projects` collection are not constrained. `GITLAB_PROJECT_ID` and OAuth group allowlists are not enforced. + +!!! note "Feature toggle" + Explicit opt-in only. Enable by listing `api` in `GITLAB_TOOLSETS` (for example `GITLAB_TOOLSETS=api` or `GITLAB_TOOLSETS=core,api`). `GITLAB_TOOLSETS=all`, `discover_tools`, `GITLAB_TOOLS`, legacy `USE_*` flags, and `GITLAB_TOOL_PROFILE` do not enable it. + +## Tools in this group + +- [`gitlab_api_request`](#gitlab_api_request) — 📖✏️ Reads and writes + +--- + +### `gitlab_api_request` + +*📖✏️ Reads and writes* + +DANGER: bypasses group and project restrictions, tool allowlists (GITLAB_TOOLS and GITLAB_DENIED_TOOLS_REGEX on other tools), and individual tool safety checks. The full token scope is exposed. Send one GitLab REST call under /api/v4. Readonly mode allows GET only, and modify mode rejects DELETE. Enable it only by listing api in GITLAB_TOOLSETS; all, discover_tools, and GITLAB_TOOLS do not. Prompt injection can drive this tool, so operators who rely on group, project, or tool restrictions must not enable it. GITLAB_ALLOWED_PROJECT_IDS is enforced only for /projects/:id paths; other endpoints are not constrained. + +**Parameters** + +| Parameter | Type | Required | Description | +|---|---|:-:|---| +| `method` | enum (`GET` \| `POST` \| `PUT` \| `PATCH` \| `DELETE`) | | HTTP method. Defaults to GET. Non-GET requires a permission mode that allows writes. DELETE is rejected in modify mode. | +| `path` | string | ✓ | Path relative to /api/v4, such as projects/1 or /projects/1/issues. Absolute URLs are rejected. | +| `query` | object | | Query string parameters. Arrays are repeated as separate keys. | +| `body` | any | | JSON request body for POST, PUT, PATCH, and DELETE. Rejected on GET. | diff --git a/docs/tools/index.md b/docs/tools/index.md index 63538d69a..9f834fcea 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -21,20 +21,21 @@ directly from `TOOLSET_DEFINITIONS` in |---|---| | **Default** — always exposed | [Core](core.md) | | **Opt-in** — must be enabled | [Projects & Namespaces](projects.md), [Projects & Files](repositories.md), [Branches & Commits](branches.md), [Groups](groups.md), [Merge Requests](merge-requests.md), [Issues](issues.md), [Labels](labels.md), [Work Items](workitems.md), [CI Lint](ci.md), [Pipelines, Jobs & Deployments](pipelines.md) (legacy `USE_PIPELINE=true` is deprecated; removed in next major (v3.0.0)), [Milestones](milestones.md) (legacy `USE_MILESTONE=true` is deprecated; removed in next major (v3.0.0)), [Wiki](wiki.md) (legacy `USE_GITLAB_WIKI=true` is deprecated; removed in next major (v3.0.0)), [Releases](releases.md), [Tags](tags.md), [Users & Events](users.md), [Variables](variables.md), [Webhooks](webhooks.md), [Search](search.md), [Dependency Proxy](dependency-proxy.md), [Vulnerabilities](vulnerabilities.md), [GitLab Orbit](orbit.md), [Snippets](snippets.md) | +| **Explicit opt-in** — not included in `all` or `discover_tools` | [Raw API](api.md) | | **Not a toolset** — `discover_tools` is always exposed; `execute_graphql` needs `GITLAB_TOOLS` | [Meta & GraphQL](meta.md) | **How to enable opt-in groups** (any one is sufficient): - `GITLAB_TOOLSETS=` — comma-separated toolset IDs. -- `GITLAB_TOOLSETS=all` — enables every group. +- `GITLAB_TOOLSETS=all` — enables every group except explicit-only toolsets (`api`). - `GITLAB_TOOLSETS=merge_requests,issues,repositories,branches,projects,labels,ci,groups,users` — restores the pre-lean default set. -- `GITLAB_TOOLS=` — enables individual tools regardless of group. +- `GITLAB_TOOLS=` — enables individual tools regardless of group, except explicit-only tools such as `gitlab_api_request`. - `USE_PIPELINE`, `USE_MILESTONE`, and `USE_GITLAB_WIKI` are deprecated (removed in next major (v3.0.0)). Use `GITLAB_TOOLSETS=core,pipelines` and `GITLAB_TOOLS=validate_ci_lint,validate_project_ci_lint`, `GITLAB_TOOLSETS=core,milestones`, or `GITLAB_TOOLSETS=core,wiki`. If you already set `GITLAB_TOOLSETS`, append the toolset to your existing list instead of replacing it. If `GITLAB_TOOLS` is already set, append `validate_ci_lint,validate_project_ci_lint` instead of replacing that list. - Call the `discover_tools` MCP tool at runtime to activate categories for the current session. Permission modes control which tools are exposed: -- `GITLAB_PERMISSION_MODE=readonly` — hides every write tool regardless of toggles. +- `GITLAB_PERMISSION_MODE=readonly` — hides every write tool regardless of toggles. Mixed tools (`execute_graphql`, `gitlab_api_request`) stay listed; their handlers reject writes. - `GITLAB_PERMISSION_MODE=modify` — allows create/update but blocks delete and teardown tools: every `delete_*` tool, `erase_pipeline_job`, `purge_dependency_proxy_cache`, the destructive teardown verbs `cancel_pipeline`, `cancel_pipeline_job`, `stop_environment`, `stop_stale_environments`, `unprotect_branch`, `unprotect_tag`, plus `push_files` `delete`/`move` actions. - `GITLAB_READ_ONLY_MODE=true` is deprecated (removed in next major (v3.0.0)) — same as `readonly`; use `GITLAB_PERMISSION_MODE=readonly` instead. @@ -48,7 +49,7 @@ and [CLI Arguments](../getting-started/cli-arguments.md) for the full list. | Marker | Meaning | |---|---| | 📖 | **Read-only** — fetches data, does not modify GitLab state. Safe to invoke freely. | -| 📖✏️ | **Reads and writes** — can query or mutate. `execute_graphql` stays available in readonly mode, which rejects its mutations and subscriptions. | +| 📖✏️ | **Reads and writes** — can query or mutate. `execute_graphql` and `gitlab_api_request` stay available in readonly mode, which rejects their writes. | | ✏️ | **Writes** — creates, updates, or deletes data on GitLab. Confirm intent before running. | ## Browse by group @@ -572,6 +573,16 @@ Snippet CRUD — list, get (with optional file content), create, update, and del | [`update_snippet`](snippets.md#update_snippet) | Update an existing snippet (provide at least one field to change). For multi-file edits — renames, deletions, additions — pass files[] with action (create/update/delete/move) and previous_path. The file_name + content shortcut still works for single-file content replacement. | ✏️ | | [`delete_snippet`](snippets.md#delete_snippet) | Delete a snippet. Use this only after verifying the target; choose a get or list tool first when you need to inspect state without changing it. It changes or removes remote GitLab data and may be irreversible; it requires the necessary project or group permission and returns validation, conflict, permission, or rate-limit errors. When `project_id` or `group_id` is accepted, provide the numeric ID or complete URL-encoded path described by the schema; use required identifiers and pagination fields exactly as documented. | ✏️ | +### [Raw API](api.md) + +Explicit opt-in escape hatch for one GitLab REST call under /api/v4. Not part of core, not enabled by GITLAB_TOOLSETS=all, and not activatable with discover_tools. *(1 tools)* + +> Explicit opt-in only. Enable by listing `api` in `GITLAB_TOOLSETS` (for example `GITLAB_TOOLSETS=api` or `GITLAB_TOOLSETS=core,api`). `GITLAB_TOOLSETS=all`, `discover_tools`, `GITLAB_TOOLS`, legacy `USE_*` flags, and `GITLAB_TOOL_PROFILE` do not enable it. + +| Tool | What it does | R/W | +|---|---|:-:| +| [`gitlab_api_request`](api.md#gitlab_api_request) | DANGER: bypasses group and project restrictions, tool allowlists (GITLAB_TOOLS and GITLAB_DENIED_TOOLS_REGEX on other tools), and individual tool safety checks. The full token scope is exposed. Send one GitLab REST call under /api/v4. Readonly mode allows GET only, and modify mode rejects DELETE. Enable it only by listing api in GITLAB_TOOLSETS; all, discover_tools, and GITLAB_TOOLS do not. Prompt injection can drive this tool, so operators who rely on group, project, or tool restrictions must not enable it. GITLAB_ALLOWED_PROJECT_IDS is enforced only for /projects/:id paths; other endpoints are not constrained. | 📖✏️ | + ### [Meta & GraphQL](meta.md) Server diagnostics, tool discovery, and the GraphQL escape hatch. *(2 tools)* @@ -581,7 +592,7 @@ Server diagnostics, tool discovery, and the GraphQL escape hatch. *(2 tools)* | Tool | What it does | R/W | |---|---|:-:| | [`execute_graphql`](meta.md#execute_graphql) | Execute a GitLab GraphQL query. Use this only when a supported GitLab REST tool does not cover the requested operation; prefer a typed tool when one exists. The query is sent directly to GitLab and can include mutations when permission allows, so callers must treat it as potentially state-changing and handle GraphQL errors in the returned response. | 📖✏️ | -| [`discover_tools`](meta.md#discover_tools) | Discover and activate additional tool categories for this session. Available categories: core, merge_requests, issues, repositories, branches, projects, labels, ci, groups, pipelines, milestones, wiki, releases, tags, snippets, users, workitems, webhooks, search, variables, dependency_proxy, vulnerabilities, orbit. Already-active categories are listed in the response. Use this when a needed opt-in category is not currently exposed; omit `category` to inspect available categories, then call it with a category to activate that group for the current session. It changes only the session's tool registry, returns the active-tool summary, and does not change GitLab data. | 📖 | +| [`discover_tools`](meta.md#discover_tools) | Discover and activate additional tool categories for this session. Available categories: core, merge_requests, issues, repositories, branches, projects, labels, ci, groups, pipelines, milestones, wiki, releases, tags, snippets, users, workitems, webhooks, search, variables, dependency_proxy, vulnerabilities, orbit. Already-active categories are listed in the response. Explicit-only toolsets are omitted; list them in GITLAB_TOOLSETS. Use this when a needed opt-in category is not currently exposed; omit `category` to inspect available categories, then call it with a category to activate that group for the current session. It changes only the session's tool registry, returns the active-tool summary, and does not change GitLab data. | 📖 | --- diff --git a/docs/tools/meta.md b/docs/tools/meta.md index 4cfde2dc5..cff37d470 100644 --- a/docs/tools/meta.md +++ b/docs/tools/meta.md @@ -29,7 +29,7 @@ Execute a GitLab GraphQL query. Use this only when a supported GitLab REST tool *📖 Read-only* -Discover and activate additional tool categories for this session. Available categories: core, merge_requests, issues, repositories, branches, projects, labels, ci, groups, pipelines, milestones, wiki, releases, tags, snippets, users, workitems, webhooks, search, variables, dependency_proxy, vulnerabilities, orbit. Already-active categories are listed in the response. Use this when a needed opt-in category is not currently exposed; omit `category` to inspect available categories, then call it with a category to activate that group for the current session. It changes only the session's tool registry, returns the active-tool summary, and does not change GitLab data. +Discover and activate additional tool categories for this session. Available categories: core, merge_requests, issues, repositories, branches, projects, labels, ci, groups, pipelines, milestones, wiki, releases, tags, snippets, users, workitems, webhooks, search, variables, dependency_proxy, vulnerabilities, orbit. Already-active categories are listed in the response. Explicit-only toolsets are omitted; list them in GITLAB_TOOLSETS. Use this when a needed opt-in category is not currently exposed; omit `category` to inspect available categories, then call it with a category to activate that group for the current session. It changes only the session's tool registry, returns the active-tool summary, and does not change GitLab data. **Parameters** diff --git a/index.ts b/index.ts index 6eab8c7ee..3eaa9fea1 100644 --- a/index.ts +++ b/index.ts @@ -267,12 +267,18 @@ import { buildFeatureFlagOverrides, isToolInEnabledToolset, findSmallestToolsetForTool, + isExplicitOnlyToolsetId, + isExplicitToolsetOnlyTool, isToolsetFullyActive, listDiscoverableCategories, TOOLSET_DEFINITIONS, - ALL_TOOLSET_IDS, - type ToolsetId, + DISCOVERABLE_TOOLSET_IDS, } from "./tools/registry.js"; +import { + executeGitLabApiRequestAsync, + GitLabApiRequestHttpError, + GitLabApiRequestInputError, +} from "./tools/gitlab-api-request.js"; import { buildUnavailableToolMessage, isSkippedByDiscoverActivation, @@ -791,12 +797,14 @@ const MCP_PROMPTS = [ * Regex-denied tools stay blocked, and slim-excluded tools are not activated. */ function buildToolUnavailableMessage(toolName: string): string { + const toolset = findSmallestToolsetForTool(toolName); return buildUnavailableToolMessage({ toolName, - toolset: findSmallestToolsetForTool(toolName), + toolset, deniedByRegex: GITLAB_DENIED_TOOLS_REGEX?.test(toolName) === true, excludedBySlimProfile: isExcludedFromToolsetListing(toolName), enabledByCurrentToolsets: isToolInEnabledToolset(toolName, enabledToolsets), + explicitToolsetOnly: toolset !== undefined && isExplicitOnlyToolsetId(toolset), }); } @@ -816,6 +824,7 @@ function createServer(): McpServer { deniedToolsRegex: GITLAB_DENIED_TOOLS_REGEX, hiddenToolNames: hiddenToolSet, applySlimProfile, + isAdditiveEnablementAllowed: toolName => !isExplicitToolsetOnlyTool(toolName), }); const listedToolDecoration: ListedToolDecorationContext = { readOnlyToolNames: new Set([...readOnlyTools].filter(toolName => advertisesReadOnly(toolName))), @@ -1045,12 +1054,30 @@ function createServer(): McpServer { }); } - if (!ALL_TOOLSET_IDS.has(category as ToolsetId)) { + if (isExplicitOnlyToolsetId(category)) { return logCompletion({ content: [ { type: "text", - text: `Unknown category "${category}". Available: ${[...ALL_TOOLSET_IDS].join(", ")}`, + text: `Category "${category}" cannot be activated with discover_tools. Add "${category}" to GITLAB_TOOLSETS. GITLAB_TOOLSETS=all does not enable it.`, + }, + ], + isError: true, + }); + } + + let discoverableCategory = false; + for (const id of DISCOVERABLE_TOOLSET_IDS) { + if (id === category) { + discoverableCategory = true; + } + } + if (!discoverableCategory) { + return logCompletion({ + content: [ + { + type: "text", + text: `Unknown category "${category}". Available: ${[...DISCOVERABLE_TOOLSET_IDS].join(", ")}`, }, ], isError: true, @@ -2075,6 +2102,23 @@ if (GITLAB_TOOLSETS_RAW && (USE_PIPELINE || USE_MILESTONE || USE_GITLAB_WIKI)) { ); } +if (enabledToolsets.has("api")) { + logger.warn( + "gitlab_api_request is enabled because GITLAB_TOOLSETS lists api. " + + "This tool can bypass tool allowlists (GITLAB_TOOLS, GITLAB_TOOLSETS selection, GITLAB_DENIED_TOOLS_REGEX for other tools), " + + "group and project restrictions, and per-tool safety checks. It exposes the full token scope." + ); +} + +const ignoredExplicitTools = [...individuallyEnabledTools].filter( + name => isExplicitToolsetOnlyTool(name) && !isToolInEnabledToolset(name, enabledToolsets) +); +if (ignoredExplicitTools.length > 0) { + logger.warn( + `GITLAB_TOOLS names ${ignoredExplicitTools.join(", ")}, but that tool is enabled only when its toolset is listed in GITLAB_TOOLSETS. The GITLAB_TOOLS entry is ignored.` + ); +} + const MERGE_REQUEST_DEPLOYMENT_SUMMARY_LIMIT = 10; type GitLabMergeRequestDeploymentSummaryRecord = { @@ -11576,6 +11620,27 @@ async function handleToolCall(params: any) { clearTimeout(timeout); } } + case "gitlab_api_request": { + try { + return await executeGitLabApiRequestAsync({ + args: params.arguments, + apiBaseUrl: getEffectiveApiUrl(), + permissionMode: GITLAB_PERMISSION_MODE, + allowedProjectIds: getEffectiveAllowedProjectIds(), + fetchImpl: fetch, + fetchConfig: getFetchConfig, + }); + } catch (error) { + if (error instanceof GitLabApiRequestInputError) { + throw new ToolInputError(error.message); + } + if (error instanceof GitLabApiRequestHttpError) { + throw new GitLabApiError(error.message); + } + throw error; + } + } + case "fork_repository": { rejectIfProjectScopedDeployment("fork_repository"); const forkArgs = ForkRepositorySchema.parse(params.arguments); diff --git a/mkdocs.yml b/mkdocs.yml index 31e901f25..1a91fbad5 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -112,6 +112,7 @@ nav: - Vulnerabilities: tools/vulnerabilities.md - GitLab Orbit: tools/orbit.md - Snippets: tools/snippets.md + - Raw API: tools/api.md - Meta & GraphQL: tools/meta.md - Reference: - Tool Invocation Coverage: reference/tool-coverage.md diff --git a/scripts/generate-tool-docs.ts b/scripts/generate-tool-docs.ts index 75795987f..0b44f2e52 100644 --- a/scripts/generate-tool-docs.ts +++ b/scripts/generate-tool-docs.ts @@ -61,6 +61,10 @@ function isDefaultToolset(id: ToolsetId): boolean { return TOOLSET_DEFINITIONS.find(d => d.id === id)?.isDefault ?? false; } +function isExplicitOnlyToolset(id: ToolsetId): boolean { + return TOOLSET_DEFINITIONS.find(definition => definition.id === id)?.explicitOnly === true; +} + /** Tools of this group that are already exposed by a default toolset (e.g. `core`). */ function findDefaultOverlap(toolNames: readonly string[]): string[] { const defaults = TOOLSET_DEFINITIONS.filter(d => d.isDefault); @@ -77,6 +81,9 @@ function formatDefaultOverlapNote(toolNames: readonly string[]): string { /** Returns the availability note for a toolset group, or undefined when it is on by default. */ function computeToggleNote(id: ToolsetId, toolNames: readonly string[] = []): string | undefined { if (isDefaultToolset(id)) return undefined; + if (isExplicitOnlyToolset(id)) { + return `Explicit opt-in only. Enable by listing \`${id}\` in \`GITLAB_TOOLSETS\` (for example \`GITLAB_TOOLSETS=${id}\` or \`GITLAB_TOOLSETS=core,${id}\`). \`GITLAB_TOOLSETS=all\`, \`discover_tools\`, \`GITLAB_TOOLS\`, legacy \`USE_*\` flags, and \`GITLAB_TOOL_PROFILE\` do not enable it.`; + } // Synthetic group for tools not in any TOOLSET_DEFINITIONS entry. // discover_tools is always exposed; execute_graphql is opt-in via GITLAB_TOOLS. if ((id as string) === "meta") { @@ -202,6 +209,11 @@ const GROUP_META: Record = { blurb: "Snippet CRUD — list, get (with optional file content), create, update, and delete personal or project snippets.", }, + api: { + title: "Raw API", + blurb: + "Explicit opt-in escape hatch for one GitLab REST call under /api/v4. Not part of core, not enabled by GITLAB_TOOLSETS=all, and not activatable with discover_tools.", + }, }; const GROUP_ORDER: ToolsetId[] = [ @@ -228,6 +240,7 @@ const GROUP_ORDER: ToolsetId[] = [ "vulnerabilities", "orbit", "snippets", + "api", ]; // --- Helpers -------------------------------------------------------------- @@ -341,6 +354,17 @@ function toolSection(name: string, description: string, schema: JsonSchema | und function buildGroupPage(id: ToolsetId, toolNames: string[]): string { const meta = GROUP_META[id]; const lines: string[] = [`# ${meta.title}`, "", meta.blurb, ""]; + if (isExplicitOnlyToolset(id)) { + lines.push(`!!! danger "Raw API escape hatch"`); + lines.push( + ` \`gitlab_api_request\` bypasses \`GITLAB_ALLOWED_GROUPS\` and other group restrictions, project allowlists, tool-level restrictions (\`GITLAB_TOOLS\`, \`GITLAB_DENIED_TOOLS_REGEX\` on other tools), and individual tool safety checks. The full token permission scope is exposed. Prompt injection in GitLab content can drive this tool. Operators who rely on these restrictions must NOT enable it.` + ); + lines.push(" "); + lines.push( + ` \`GITLAB_ALLOWED_PROJECT_IDS\` and a session project scope are checked only for \`/projects/:id\` and \`/projects/:id/...\`. Endpoints such as \`/search\`, \`/users\`, \`/groups/:id\`, \`/runners\`, and the \`/projects\` collection are not constrained. \`GITLAB_PROJECT_ID\` and OAuth group allowlists are not enforced.` + ); + lines.push(""); + } const toggle = computeToggleNote(id, toolNames); if (toggle) { lines.push(`!!! note "Feature toggle"`); @@ -374,8 +398,12 @@ function buildGroupPage(id: ToolsetId, toolNames: string[]): string { function buildToggleSection(groupedToolsList: Array<[ToolsetId, string[]]>): string[] { const grouped = groupedToolsList.filter(([id]) => GROUP_META[id]); const defaults = grouped.filter(([id]) => isDefaultToolset(id)); + const explicit = grouped.filter( + ([id]) => isExplicitOnlyToolset(id) && (id as string) !== META_GROUP_ID + ); const optins = grouped.filter( - ([id]) => !isDefaultToolset(id) && (id as string) !== META_GROUP_ID + ([id]) => + !isDefaultToolset(id) && !isExplicitOnlyToolset(id) && (id as string) !== META_GROUP_ID ); const meta = grouped.filter(([id]) => (id as string) === META_GROUP_ID); @@ -396,6 +424,11 @@ function buildToggleSection(groupedToolsList: Array<[ToolsetId, string[]]>): str "|---|---|", `| **Default** — always exposed | ${formatList(defaults)} |`, `| **Opt-in** — must be enabled | ${formatList(optins)} |`, + ...(explicit.length > 0 + ? [ + `| **Explicit opt-in** — not included in \`all\` or \`discover_tools\` | ${formatList(explicit)} |`, + ] + : []), ...(meta.length > 0 ? [ `| **Not a toolset** — \`discover_tools\` is always exposed; \`execute_graphql\` needs \`GITLAB_TOOLS\` | ${formatList(meta)} |`, @@ -405,9 +438,9 @@ function buildToggleSection(groupedToolsList: Array<[ToolsetId, string[]]>): str "**How to enable opt-in groups** (any one is sufficient):", "", "- `GITLAB_TOOLSETS=` — comma-separated toolset IDs.", - "- `GITLAB_TOOLSETS=all` — enables every group.", + "- `GITLAB_TOOLSETS=all` — enables every group except explicit-only toolsets (`api`).", "- `GITLAB_TOOLSETS=merge_requests,issues,repositories,branches,projects,labels,ci,groups,users` — restores the pre-lean default set.", - "- `GITLAB_TOOLS=` — enables individual tools regardless of group.", + "- `GITLAB_TOOLS=` — enables individual tools regardless of group, except explicit-only tools such as `gitlab_api_request`.", "- `USE_PIPELINE`, `USE_MILESTONE`, and `USE_GITLAB_WIKI` are deprecated (removed in next major (v3.0.0))." + " Use `GITLAB_TOOLSETS=core,pipelines` and `GITLAB_TOOLS=validate_ci_lint,validate_project_ci_lint`," + " `GITLAB_TOOLSETS=core,milestones`, or `GITLAB_TOOLSETS=core,wiki`." + @@ -443,7 +476,7 @@ function buildIndexPage(groupedToolsList: Array<[ToolsetId, string[]]>): string "", "Permission modes control which tools are exposed:", "", - "- `GITLAB_PERMISSION_MODE=readonly` — hides every write tool regardless of toggles.", + "- `GITLAB_PERMISSION_MODE=readonly` — hides every write tool regardless of toggles. Mixed tools (`execute_graphql`, `gitlab_api_request`) stay listed; their handlers reject writes.", "- `GITLAB_PERMISSION_MODE=modify` — allows create/update but blocks delete and teardown tools: every `delete_*` tool, `erase_pipeline_job`, `purge_dependency_proxy_cache`, the destructive teardown verbs `cancel_pipeline`, `cancel_pipeline_job`, `stop_environment`, `stop_stale_environments`, `unprotect_branch`, `unprotect_tag`, plus `push_files` `delete`/`move` actions.", "- `GITLAB_READ_ONLY_MODE=true` is deprecated (removed in next major (v3.0.0)) — same as `readonly`; use `GITLAB_PERMISSION_MODE=readonly` instead.", "", @@ -457,7 +490,7 @@ function buildIndexPage(groupedToolsList: Array<[ToolsetId, string[]]>): string "| Marker | Meaning |", "|---|---|", "| 📖 | **Read-only** — fetches data, does not modify GitLab state. Safe to invoke freely. |", - "| 📖✏️ | **Reads and writes** — can query or mutate. `execute_graphql` stays available in readonly mode, which rejects its mutations and subscriptions. |", + "| 📖✏️ | **Reads and writes** — can query or mutate. `execute_graphql` and `gitlab_api_request` stay available in readonly mode, which rejects their writes. |", "| ✏️ | **Writes** — creates, updates, or deletes data on GitLab. Confirm intent before running. |", "", "## Browse by group", diff --git a/scripts/tool-coverage/coverage.ts b/scripts/tool-coverage/coverage.ts index 8dd398c0f..c67e483c8 100644 --- a/scripts/tool-coverage/coverage.ts +++ b/scripts/tool-coverage/coverage.ts @@ -54,6 +54,7 @@ export const GROUP_ORDER = [ "vulnerabilities", "orbit", "snippets", + "api", ]; export const GROUP_TITLE: Record = { @@ -79,6 +80,7 @@ export const GROUP_TITLE: Record = { vulnerabilities: "Vulnerabilities", orbit: "GitLab Orbit", snippets: "Snippets", + api: "Raw API", meta: "Meta & GraphQL", }; diff --git a/skills/gitlab-mcp/SKILL.md b/skills/gitlab-mcp/SKILL.md index e2cdf22d9..0d3ef5a2e 100644 --- a/skills/gitlab-mcp/SKILL.md +++ b/skills/gitlab-mcp/SKILL.md @@ -5,7 +5,7 @@ description: Use this skill when working with the GitLab MCP server tools for me # gitlab-mcp -GitLab MCP server providing 274 tools: 272 tools across 23 toolsets, plus `execute_graphql` and the always-available `discover_tools` meta-tool. +GitLab MCP server providing 275 tools: 273 tools across 24 toolsets, plus `execute_graphql` and the always-available `discover_tools` meta-tool. For exact generated parameter tables, see `docs/tools/`. Use this file for workflow shape and high-signal parameter hints. @@ -36,12 +36,15 @@ For exact generated parameter tables, see `docs/tools/`. Use this file for workf | dependency_proxy (4 tools) | no | `GITLAB_TOOLSETS=dependency_proxy` | | vulnerabilities (4 tools) | no | `GITLAB_TOOLSETS=vulnerabilities` | | orbit (4 tools) | no | `GITLAB_TOOLSETS=orbit` | +| api (1 tool) | no | `GITLAB_TOOLSETS=api` only (not `all`, not `discover_tools`, not `GITLAB_TOOLS`) | -Enable all: `GITLAB_TOOLSETS=all`. Restore the pre-lean default with `GITLAB_TOOLSETS=merge_requests,issues,repositories,branches,projects,labels,ci,groups,users`. Use `GITLAB_TOOLS` to enable individual tools outside their toolset. `discover_tools` can list and activate opt-in categories for the current session. `execute_graphql` is not in a toolset; enable it explicitly with `GITLAB_TOOLS=execute_graphql`. `USE_GITLAB_WIKI`, `USE_MILESTONE`, and `USE_PIPELINE` are deprecated (removed in next major (v3.0.0)). If you already set `GITLAB_TOOLSETS`, append the toolset to your existing list instead of replacing it. If `GITLAB_TOOLS` is unset, set `GITLAB_TOOLS=validate_ci_lint,validate_project_ci_lint`. If it is already set, append those two tools instead of replacing that list. +Enable all discoverable toolsets: `GITLAB_TOOLSETS=all` (does **not** enable `api`). Restore the pre-lean default with `GITLAB_TOOLSETS=merge_requests,issues,repositories,branches,projects,labels,ci,groups,users`. Use `GITLAB_TOOLS` to enable individual tools outside their toolset, except explicit-only tools such as `gitlab_api_request`. `discover_tools` can list and activate opt-in categories for the current session, except `api`. `execute_graphql` is not in a toolset; enable it explicitly with `GITLAB_TOOLS=execute_graphql`. `USE_GITLAB_WIKI`, `USE_MILESTONE`, and `USE_PIPELINE` are deprecated (removed in next major (v3.0.0)). If you already set `GITLAB_TOOLSETS`, append the toolset to your existing list instead of replacing it. If `GITLAB_TOOLS` is unset, set `GITLAB_TOOLS=validate_ci_lint,validate_project_ci_lint`. If it is already set, append those two tools instead of replacing that list. -The per-toolset counts above sum to 312 because the 38 `core` tools are each also listed +`gitlab_api_request` bypasses `GITLAB_ALLOWED_GROUPS` and other group restrictions, project allowlists, tool-level restrictions (`GITLAB_TOOLS`, `GITLAB_DENIED_TOOLS_REGEX` on other tools), and individual tool safety checks. The full token permission scope is exposed. Prompt injection can drive this tool. Operators who rely on these restrictions must NOT enable it. `GITLAB_ALLOWED_PROJECT_IDS` is checked only for `/projects/:id` paths; `/search`, `/users`, `/groups/:id`, and `/runners` are not constrained. `GITLAB_PROJECT_ID` and OAuth group allowlists are not enforced. + +The per-toolset counts above sum to 313 because the 38 `core` tools are each also listed in their full category (`get_branch` and `list_branches` additionally appear in both -`merge_requests` and `branches`); the unique tool count across all toolsets is 272. +`merge_requests` and `branches`); the unique tool count across all toolsets is 273. ## Key Workflows @@ -129,11 +132,12 @@ Enable with `GITLAB_TOOLSETS=vulnerabilities` (requires GitLab Ultimate). ## Destructive Tools (require caution) -`cancel_pipeline`, `cancel_pipeline_job`, `delete_branch`, `delete_deployment`, `approve_deployment`, `delete_draft_note`, `delete_environment`, `erase_pipeline_job`, `delete_group_milestone`, `delete_group_variable`, `delete_group_wiki_page`, `delete_issue`, `delete_issue_emoji_reaction`, `delete_issue_link`, `delete_issue_note_emoji_reaction`, `delete_label`, `delete_merge_request_discussion_note`, `delete_merge_request_emoji_reaction`, `delete_merge_request_note`, `delete_merge_request_note_emoji_reaction`, `delete_milestone`, `delete_pipeline`, `delete_pipeline_schedule`, `delete_pipeline_schedule_variable`, `delete_pipeline_trigger`, `delete_project_variable`, `delete_release`, `delete_review_app_environments`, `delete_snippet`, `delete_tag`, `delete_webhook`, `delete_wiki_page`, `delete_work_item_emoji_reaction`, `delete_work_item_note_emoji_reaction`, `merge_merge_request`, `protect_branch`, `protect_tag`, `purge_dependency_proxy_cache`, `push_files`, `stop_environment`, `stop_stale_environments`, `unprotect_branch`, `unprotect_tag`, `update_default_branch` +`cancel_pipeline`, `cancel_pipeline_job`, `delete_branch`, `delete_deployment`, `approve_deployment`, `delete_draft_note`, `delete_environment`, `erase_pipeline_job`, `delete_group_milestone`, `delete_group_variable`, `delete_group_wiki_page`, `delete_issue`, `delete_issue_emoji_reaction`, `delete_issue_link`, `delete_issue_note_emoji_reaction`, `delete_label`, `delete_merge_request_discussion_note`, `delete_merge_request_emoji_reaction`, `delete_merge_request_note`, `delete_merge_request_note_emoji_reaction`, `delete_milestone`, `delete_pipeline`, `delete_pipeline_schedule`, `delete_pipeline_schedule_variable`, `delete_pipeline_trigger`, `delete_project_variable`, `delete_release`, `delete_review_app_environments`, `delete_snippet`, `delete_tag`, `delete_webhook`, `delete_wiki_page`, `delete_work_item_emoji_reaction`, `delete_work_item_note_emoji_reaction`, `merge_merge_request`, `protect_branch`, `protect_tag`, `purge_dependency_proxy_cache`, `push_files`, `stop_environment`, `stop_stale_environments`, `unprotect_branch`, `unprotect_tag`, `update_default_branch`, `gitlab_api_request` ## Advanced -- **Dynamic discovery**: `discover_tools` lists and activates opt-in toolsets at runtime +- **Dynamic discovery**: `discover_tools` lists and activates opt-in toolsets at runtime. It cannot activate `api` +- **Raw API**: `gitlab_api_request` only when `GITLAB_TOOLSETS` lists `api`. Readonly is GET-only; modify rejects DELETE - **GraphQL**: `execute_graphql` for queries not covered by REST tools - **Tool docs**: `docs/tools/` is generated from `tools/registry.ts`; prefer it for exact schemas - **Remote MCP OAuth**: when `GITLAB_MCP_OAUTH=true`, `POST /register` (DCR) is rate-limited per client IP (default 20/hour via MCP SDK; tune with `OAUTH_REGISTER_RATE_LIMIT_PER_HOUR`). Separate from `MAX_REQUESTS_PER_MINUTE` and GitLab API quotas — see [environment-variables.md](../../docs/configuration/environment-variables.md#oauth_register_rate_limit_per_hour) diff --git a/test/test-gitlab-api-request.ts b/test/test-gitlab-api-request.ts new file mode 100644 index 000000000..2620549fd --- /dev/null +++ b/test/test-gitlab-api-request.ts @@ -0,0 +1,385 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, it } from "node:test"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { + getDefaultEnvironment, + StdioClientTransport, +} from "@modelcontextprotocol/sdk/client/stdio.js"; +import { MockGitLabServer, findMockServerPort } from "./utils/mock-gitlab-server.js"; + +const TOKEN = "api-request-test-token"; +const SECRET = "glpat-abcdefghijklmnopqrst"; + +interface ToolText { + readonly isError: boolean; + readonly text: string; +} + +function readCategoryIds(value: unknown): string[] { + if (!value || typeof value !== "object" || !("categories" in value)) { + return []; + } + const categories = value.categories; + if (!Array.isArray(categories)) { + return []; + } + const ids: string[] = []; + for (const category of categories) { + if ( + category && + typeof category === "object" && + "id" in category && + typeof category.id === "string" + ) { + ids.push(category.id); + } + } + return ids; +} + +function readToolText(result: unknown): ToolText { + if (!result || typeof result !== "object") { + throw new Error("missing tool result"); + } + const isError = "isError" in result && result.isError === true; + if (!("content" in result) || !Array.isArray(result.content) || result.content.length === 0) { + throw new Error("missing tool content"); + } + const block = result.content[0]; + if (!block || typeof block !== "object" || !("text" in block) || typeof block.text !== "string") { + throw new Error("missing tool text"); + } + return { isError, text: block.text }; +} + +async function withClient( + extraEnv: Record, + run: (client: Client, stderr: () => string, mock: MockGitLabServer) => Promise +): Promise { + const mock = new MockGitLabServer({ + port: await findMockServerPort(), + validTokens: [TOKEN], + }); + await mock.start(); + const stderrChunks: string[] = []; + const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "gitlab-api-request-")); + const transport = new StdioClientTransport({ + command: process.execPath, + args: [path.resolve("build/index.js")], + stderr: "pipe", + env: { + ...getDefaultEnvironment(), + GITLAB_PERSONAL_ACCESS_TOKEN: TOKEN, + GITLAB_API_URL: `${mock.getUrl()}/api/v4`, + GITLAB_DISABLE_VERSION_CHECK: "true", + LOG_FORMAT: "json", + LOG_LEVEL: "info", + GITLAB_READ_ONLY_MODE: "false", + GITLAB_PERMISSION_MODE: "full", + GITLAB_TOOLSETS: "", + GITLAB_TOOLS: "", + GITLAB_DENIED_TOOLS_REGEX: "", + GITLAB_TOOL_PROFILE: "full", + USE_PIPELINE: "false", + USE_MILESTONE: "false", + USE_GITLAB_WIKI: "false", + GITLAB_MASKING_ENABLED: "false", + GITLAB_MASKING_POLICY_FILE: "", + GITLAB_MASKING_CONFIG: "", + GITLAB_MASKING_WORKSPACE_DIR: workspace, + ...extraEnv, + }, + }); + transport.stderr?.on("data", (chunk: Buffer) => { + stderrChunks.push(chunk.toString("utf8")); + }); + const client = new Client({ name: "gitlab-api-request-test", version: "1.0.0" }); + try { + await client.connect(transport); + await run(client, () => stderrChunks.join(""), mock); + } finally { + await client.close(); + await mock.stop(); + fs.rmSync(workspace, { recursive: true, force: true }); + } +} + +async function toolNames(client: Client): Promise { + const listed = await client.listTools(); + return listed.tools.map(tool => tool.name); +} + +describe("When gitlab_api_request is not opted in", { timeout: 120_000 }, () => { + describe("with the default toolsets", () => { + it("should hide the tool", async () => { + await withClient({}, async client => { + const names = await toolNames(client); + assert.equal(names.includes("gitlab_api_request"), false); + }); + }); + }); + + describe("with GITLAB_TOOLSETS=all", () => { + it("should hide the tool and refuse discover_tools activation", async () => { + await withClient({ GITLAB_TOOLSETS: "all" }, async client => { + const names = await toolNames(client); + assert.equal(names.includes("gitlab_api_request"), false); + + const listed = readToolText( + await client.callTool({ name: "discover_tools", arguments: {} }) + ); + const parsed: unknown = JSON.parse(listed.text); + const categoryIds = readCategoryIds(parsed); + assert.equal(categoryIds.includes("api"), false); + + const activated = readToolText( + await client.callTool({ name: "discover_tools", arguments: { category: "api" } }) + ); + assert.equal(activated.isError, true); + assert.match(activated.text, /GITLAB_TOOLSETS/); + }); + }); + }); + + describe("with only GITLAB_TOOLS=gitlab_api_request", () => { + it("should hide the tool and warn", async () => { + await withClient({ GITLAB_TOOLS: "gitlab_api_request" }, async (client, stderr) => { + const names = await toolNames(client); + assert.equal(names.includes("gitlab_api_request"), false); + assert.match(stderr(), /GITLAB_TOOLS entry is ignored/); + }); + }); + }); +}); + +describe("When the api toolset is listed", { timeout: 180_000 }, () => { + describe("with GITLAB_TOOLSETS=api", () => { + it("should expose the tool and warn at startup", async () => { + await withClient({ GITLAB_TOOLSETS: "api" }, async (client, stderr) => { + const listed = await client.listTools(); + const tool = listed.tools.find(item => item.name === "gitlab_api_request"); + assert.ok(tool); + assert.equal(tool.annotations?.destructiveHint, true); + assert.equal(tool.annotations?.readOnlyHint, undefined); + assert.match(stderr(), /full token scope/); + }); + }); + }); + + describe("with GITLAB_TOOLSETS=core,api", () => { + it("should expose the tool", async () => { + await withClient({ GITLAB_TOOLSETS: "core,api" }, async client => { + const names = await toolNames(client); + assert.equal(names.includes("gitlab_api_request"), true); + }); + }); + }); + + describe("with GITLAB_DENIED_TOOLS_REGEX matching the tool", () => { + it("should hide gitlab_api_request", async () => { + await withClient( + { GITLAB_TOOLSETS: "api", GITLAB_DENIED_TOOLS_REGEX: "gitlab_api_request" }, + async client => { + const names = await toolNames(client); + assert.equal(names.includes("gitlab_api_request"), false); + } + ); + }); + }); + + describe("with a normal GET", () => { + it("should return pretty JSON", async () => { + await withClient({ GITLAB_TOOLSETS: "api" }, async (client, _stderr, mock) => { + mock.addMockHandler("get", "/projects/5", (_req, res) => { + res.json({ id: 5, title: "demo" }); + }); + const result = readToolText( + await client.callTool({ + name: "gitlab_api_request", + arguments: { path: "/projects/5" }, + }) + ); + assert.equal(result.isError, false); + assert.match(result.text, /\n/); + assert.equal(JSON.parse(result.text).title, "demo"); + }); + }); + }); + + describe("with query parameters", () => { + it("should encode them", async () => { + await withClient({ GITLAB_TOOLSETS: "api" }, async (client, _stderr, mock) => { + const seen: { search?: unknown; page?: unknown; labels?: unknown } = {}; + mock.addMockHandler("get", "/projects/5", (req, res) => { + seen.search = req.query.search; + seen.page = req.query.page; + seen.labels = req.query.labels; + res.json({ ok: true }); + }); + await client.callTool({ + name: "gitlab_api_request", + arguments: { + path: "projects/5", + query: { search: "a b", page: 2, labels: ["x", "y"] }, + }, + }); + assert.equal(seen.search, "a b"); + assert.equal(seen.page, "2"); + assert.deepEqual(seen.labels, ["x", "y"]); + }); + }); + }); + + describe("with a jmespath filter", () => { + it("should return the filtered JSON", async () => { + await withClient({ GITLAB_TOOLSETS: "api" }, async (client, _stderr, mock) => { + mock.addMockHandler("get", "/projects/5", (_req, res) => { + res.json({ items: [{ name: "a" }, { name: "b" }] }); + }); + const result = readToolText( + await client.callTool({ + name: "gitlab_api_request", + arguments: { path: "projects/5", jmespath: "items[].name" }, + }) + ); + assert.deepEqual(JSON.parse(result.text), ["a", "b"]); + }); + }); + }); + + describe("with a token in the JSON body and masking enabled", () => { + it("should redact the token", async () => { + await withClient( + { GITLAB_TOOLSETS: "api", GITLAB_MASKING_ENABLED: "true" }, + async (client, _stderr, mock) => { + mock.addMockHandler("get", "/projects/5", (_req, res) => { + res.json({ note: SECRET, title: "visible" }); + }); + const result = readToolText( + await client.callTool({ + name: "gitlab_api_request", + arguments: { path: "projects/5" }, + }) + ); + assert.equal(result.text.includes(SECRET), false); + assert.match(result.text, /\[Token masked\]/); + } + ); + }); + }); + + describe("with an absolute URL path", () => { + it("should reject the call", async () => { + await withClient({ GITLAB_TOOLSETS: "api" }, async client => { + const result = readToolText( + await client.callTool({ + name: "gitlab_api_request", + arguments: { path: "https://evil.example/api/v4/user" }, + }) + ); + assert.equal(result.isError, true); + assert.match(result.text, /relative path/); + }); + }); + }); + + describe("with readonly mode", () => { + it("should allow GET and reject POST", async () => { + await withClient( + { GITLAB_TOOLSETS: "api", GITLAB_PERMISSION_MODE: "readonly" }, + async (client, _stderr, mock) => { + mock.addMockHandler("get", "/projects/5", (_req, res) => { + res.json({ id: 5 }); + }); + const got = readToolText( + await client.callTool({ + name: "gitlab_api_request", + arguments: { path: "projects/5" }, + }) + ); + assert.equal(got.isError, false); + + const posted = readToolText( + await client.callTool({ + name: "gitlab_api_request", + arguments: { method: "POST", path: "projects/5", body: { title: "x" } }, + }) + ); + assert.equal(posted.isError, true); + assert.match(posted.text, /only GET/); + } + ); + }); + }); + + describe("with GITLAB_READ_ONLY_MODE=true", () => { + it("should reject POST even if permission mode is full", async () => { + await withClient( + { + GITLAB_TOOLSETS: "api", + GITLAB_READ_ONLY_MODE: "true", + GITLAB_PERMISSION_MODE: "full", + }, + async client => { + const result = readToolText( + await client.callTool({ + name: "gitlab_api_request", + arguments: { method: "POST", path: "projects/5", body: { title: "x" } }, + }) + ); + assert.equal(result.isError, true); + assert.match(result.text, /only GET/); + } + ); + }); + }); + + describe("with GITLAB_TOOL_POLICY_APPROVE", () => { + it("should require confirmation before the request", async () => { + await withClient( + { GITLAB_TOOLSETS: "api", GITLAB_TOOL_POLICY_APPROVE: "gitlab_api_request" }, + async (client, _stderr, mock) => { + mock.addMockHandler("get", "/projects/5", (_req, res) => { + res.json({ id: 5 }); + }); + const blocked = readToolText( + await client.callTool({ + name: "gitlab_api_request", + arguments: { path: "projects/5" }, + }) + ); + assert.match(blocked.text, /requires confirmation/); + + const allowed = readToolText( + await client.callTool({ + name: "gitlab_api_request", + arguments: { path: "projects/5", _confirmed: true }, + }) + ); + assert.equal(allowed.isError, false); + } + ); + }); + }); + + describe("with modify mode", () => { + it("should reject DELETE", async () => { + await withClient( + { GITLAB_TOOLSETS: "api", GITLAB_PERMISSION_MODE: "modify" }, + async client => { + const result = readToolText( + await client.callTool({ + name: "gitlab_api_request", + arguments: { method: "DELETE", path: "projects/5" }, + }) + ); + assert.equal(result.isError, true); + assert.match(result.text, /DELETE/); + } + ); + }); + }); +}); diff --git a/test/test-toolset-filtering.ts b/test/test-toolset-filtering.ts index c8589d1bc..d7520ca42 100644 --- a/test/test-toolset-filtering.ts +++ b/test/test-toolset-filtering.ts @@ -55,8 +55,10 @@ function uniqueToolCount(toolsetIds: readonly string[]): number { } const DEFAULT_TOOL_COUNT = uniqueToolCount(DEFAULT_TOOLSETS) + DISCOVER_TOOLS_COUNT; -const ALL_TOOLSET_TOOL_COUNT = - uniqueToolCount(TOOLSET_DEFINITIONS.map(def => def.id)) + DISCOVER_TOOLS_COUNT; +const BULK_TOOLSET_IDS = TOOLSET_DEFINITIONS.filter(def => def.explicitOnly !== true).map( + def => def.id +); +const ALL_TOOLSET_TOOL_COUNT = uniqueToolCount(BULK_TOOLSET_IDS) + DISCOVER_TOOLS_COUNT; // Representative tools per toolset for spot-checking const TOOLSET_SAMPLE_TOOLS: Record = { @@ -291,8 +293,8 @@ describe("Toolset Filtering", { concurrency: 1 }, () => { } }); - test("still excludes execute_graphql", () => { - assertContainsNone(tools, ["execute_graphql"], "unassigned"); + test("still excludes execute_graphql and the explicit-only api tool", () => { + assertContainsNone(tools, ["execute_graphql", "gitlab_api_request"], "not in all"); }); }); diff --git a/test/utils/gitlab-api-request.test.ts b/test/utils/gitlab-api-request.test.ts new file mode 100644 index 000000000..c99470753 --- /dev/null +++ b/test/utils/gitlab-api-request.test.ts @@ -0,0 +1,232 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { isToolExposed } from "../../cli/exposure.js"; +import { selectExposedTools } from "../../tools/exposed-tools.js"; +import { + advertisesReadOnly, + allTools, + deleteTools, + destructiveTools, + DISCOVERABLE_TOOLSET_IDS, + isExplicitToolsetOnlyTool, + listDiscoverableCategories, + parseEnabledToolsets, + readOnlyTools, +} from "../../tools/registry.js"; +import { + assertGitLabApiRequestMethodAllowed, + executeGitLabApiRequestAsync, + GitLabApiRequestHttpError, + GitLabApiRequestInputError, +} from "../../tools/gitlab-api-request.js"; +import { resolveGitLabApiRequestUrl } from "../../utils/url.js"; + +const API_BASE = "http://127.0.0.1:9/api/v4"; + +function resolveOrThrow(path: string): URL { + return resolveGitLabApiRequestUrl(API_BASE, path); +} + +describe("When resolving a gitlab_api_request path", () => { + describe("with a normal relative path", () => { + it("should stay under the configured /api/v4 origin", () => { + const resolved = resolveOrThrow("/projects/1/issues"); + + assert.equal(resolved.href, "http://127.0.0.1:9/api/v4/projects/1/issues"); + }); + }); + + describe("with an absolute URL", () => { + it("should reject the path", () => { + assert.throws(() => resolveOrThrow("https://evil.example/api/v4/user"), /relative path/); + }); + }); + + describe("with a protocol-relative host", () => { + it("should reject the path", () => { + assert.throws(() => resolveOrThrow("//evil.example/projects/1"), /relative path/); + }); + }); + + describe("with a parent segment", () => { + it("should reject the path", () => { + assert.throws(() => resolveOrThrow("projects/../secrets"), /path segment/); + }); + }); + + describe("with an encoded parent segment", () => { + it("should reject the path", () => { + assert.throws(() => resolveOrThrow("projects/%2e%2e/secrets"), /path segment/); + }); + }); + + describe("with a backslash", () => { + it("should reject the path", () => { + assert.throws(() => resolveOrThrow("projects\\1"), /relative path/); + }); + }); +}); + +describe("When classifying gitlab_api_request", () => { + describe("with the registry sets", () => { + it("should stay listed for readonly GET while advertising a destructive write", () => { + assert.equal(readOnlyTools.has("gitlab_api_request"), true); + assert.equal(advertisesReadOnly("gitlab_api_request"), false); + assert.equal(destructiveTools.has("gitlab_api_request"), true); + assert.equal(deleteTools.has("gitlab_api_request"), false); + }); + }); +}); + +describe("When enabling toolsets", () => { + describe("with GITLAB_TOOLSETS=all", () => { + it("should omit the api toolset", () => { + assert.equal(parseEnabledToolsets("all").has("api"), false); + assert.equal(DISCOVERABLE_TOOLSET_IDS.has("api"), false); + }); + }); + + describe("with GITLAB_TOOLSETS=api", () => { + it("should include the api toolset", () => { + assert.equal(parseEnabledToolsets("api").has("api"), true); + }); + }); + + describe("with discover_tools categories", () => { + it("should hide api", () => { + const ids = listDiscoverableCategories(new Set()).map(category => category.id); + + assert.equal(ids.includes("api"), false); + }); + }); + + describe("with GITLAB_TOOLS naming gitlab_api_request", () => { + it("should not expose the tool", () => { + const exposed = selectExposedTools({ + tools: allTools, + isInEnabledToolset: () => false, + individuallyEnabledTools: new Set(["gitlab_api_request"]), + featureFlagOverrides: new Set(), + isAllowedByPermissionMode: () => true, + deniedToolsRegex: undefined, + hiddenToolNames: new Set(), + applySlimProfile: false, + isAdditiveEnablementAllowed: name => !isExplicitToolsetOnlyTool(name), + }); + + assert.equal( + exposed.some(tool => tool.name === "gitlab_api_request"), + false + ); + }); + }); +}); + +describe("When checking CLI exposure", () => { + describe("with GITLAB_TOOLS and no api toolset", () => { + it("should refuse gitlab_api_request", () => { + const exposed = isToolExposed({ + toolName: "gitlab_api_request", + enabledToolsets: parseEnabledToolsets("core"), + individuallyEnabledTools: new Set(["gitlab_api_request"]), + featureFlagOverrides: new Set(), + deniedRegex: undefined, + hiddenTools: new Set(), + }); + + assert.equal(exposed, false); + }); + }); +}); + +describe("When enforcing gitlab_api_request methods", () => { + describe("with readonly mode", () => { + it("should reject POST", () => { + assert.throws( + () => assertGitLabApiRequestMethodAllowed("POST", "readonly"), + GitLabApiRequestInputError + ); + }); + }); + + describe("with modify mode", () => { + it("should reject DELETE", () => { + assert.throws(() => assertGitLabApiRequestMethodAllowed("DELETE", "modify"), /DELETE/); + }); + }); +}); + +describe("When executing gitlab_api_request", () => { + describe("with query parameters", () => { + it("should encode them on the request URL", async () => { + let requested = ""; + const result = await executeGitLabApiRequestAsync({ + args: { + path: "projects/5", + query: { search: "a b", page: 2, state: true, labels: ["x", "y"] }, + }, + apiBaseUrl: API_BASE, + permissionMode: "full", + allowedProjectIds: [], + fetchConfig: () => ({ headers: {} }), + fetchImpl: async url => { + requested = url; + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + }, + }); + + const parsed = new URL(requested); + assert.equal(parsed.searchParams.get("search"), "a b"); + assert.equal(parsed.searchParams.get("page"), "2"); + assert.equal(parsed.searchParams.get("state"), "true"); + assert.deepEqual(parsed.searchParams.getAll("labels"), ["x", "y"]); + assert.match(result.content[0].text, /"ok": true/); + }); + }); + + describe("with a project outside the allowlist", () => { + it("should reject before fetch", async () => { + await assert.rejects( + () => + executeGitLabApiRequestAsync({ + args: { path: "projects/9" }, + apiBaseUrl: API_BASE, + permissionMode: "full", + allowedProjectIds: ["5"], + fetchConfig: () => ({ headers: {} }), + fetchImpl: async () => { + throw new Error("fetch should not run"); + }, + }), + /not in the allowed project list/ + ); + }); + }); + + describe("with a non-2xx JSON body", () => { + it("should raise a status error without the token field", async () => { + await assert.rejects( + () => + executeGitLabApiRequestAsync({ + args: { path: "projects/5" }, + apiBaseUrl: API_BASE, + permissionMode: "full", + allowedProjectIds: [], + fetchConfig: () => ({ headers: {} }), + fetchImpl: async () => + new Response(JSON.stringify({ message: "nope", token: "secret-token" }), { + status: 403, + statusText: "Forbidden", + }), + }), + (error: unknown) => { + assert.ok(error instanceof GitLabApiRequestHttpError); + assert.match(error.message, /403/); + assert.equal(error.message.includes("secret-token"), false); + return true; + } + ); + }); + }); +}); diff --git a/tools/discover-activation.ts b/tools/discover-activation.ts index c22fad4d8..d7287a882 100644 --- a/tools/discover-activation.ts +++ b/tools/discover-activation.ts @@ -28,6 +28,7 @@ export interface UnavailableToolMessageInput { readonly deniedByRegex: boolean; readonly excludedBySlimProfile: boolean; readonly enabledByCurrentToolsets: boolean; + readonly explicitToolsetOnly?: boolean; } /** @@ -43,6 +44,9 @@ export function buildUnavailableToolMessage(input: UnavailableToolMessageInput): if (input.excludedBySlimProfile) { return `${base}. ${slimExclusionGuidance(input.toolName, toolset, input.enabledByCurrentToolsets)}`; } + if (input.explicitToolsetOnly) { + return `${base}. The "${toolset}" toolset is not enabled. discover_tools cannot activate it. Add "${toolset}" to GITLAB_TOOLSETS. GITLAB_TOOLSETS=all, GITLAB_TOOLS, and GITLAB_TOOL_PROFILE do not enable it.`; + } return `${base}. It belongs to the "${toolset}" toolset, which is not enabled: call discover_tools with category "${toolset}" or add "${toolset}" to GITLAB_TOOLSETS.`; } diff --git a/tools/exposed-tools.ts b/tools/exposed-tools.ts index 2034ff249..06bbf99f4 100644 --- a/tools/exposed-tools.ts +++ b/tools/exposed-tools.ts @@ -9,24 +9,36 @@ export interface ExposedToolSelection { readonly deniedToolsRegex: RegExp | undefined; readonly hiddenToolNames: ReadonlySet; readonly applySlimProfile: boolean; + /** + * When false, GITLAB_TOOLS and legacy USE_* flags cannot add the tool. + * Explicit-only tools stay off unless their toolset is already selected. + */ + readonly isAdditiveEnablementAllowed?: (toolName: string) => boolean; } export function selectExposedTools( input: ExposedToolSelection ): T[] { + const additiveAllowed = input.isAdditiveEnablementAllowed ?? (() => true); const toolsAfterToolsets = input.tools.filter(tool => input.isInEnabledToolset(tool.name)); const toolsetToolNames = new Set(toolsAfterToolsets.map(tool => tool.name)); const toolsAfterIndividual = [ ...toolsAfterToolsets, ...input.tools.filter( - tool => input.individuallyEnabledTools.has(tool.name) && !toolsetToolNames.has(tool.name) + tool => + input.individuallyEnabledTools.has(tool.name) && + !toolsetToolNames.has(tool.name) && + additiveAllowed(tool.name) ), ]; const afterIndividualNames = new Set(toolsAfterIndividual.map(tool => tool.name)); const toolsAfterLegacy = [ ...toolsAfterIndividual, ...input.tools.filter( - tool => input.featureFlagOverrides.has(tool.name) && !afterIndividualNames.has(tool.name) + tool => + input.featureFlagOverrides.has(tool.name) && + !afterIndividualNames.has(tool.name) && + additiveAllowed(tool.name) ), ]; const toolsAfterPermission = toolsAfterLegacy.filter(tool => diff --git a/tools/gitlab-api-request.ts b/tools/gitlab-api-request.ts new file mode 100644 index 000000000..538cd298b --- /dev/null +++ b/tools/gitlab-api-request.ts @@ -0,0 +1,235 @@ +import type { RequestInit as UndiciRequestInit } from "undici"; +import { z } from "zod"; + +import { redactSensitiveGitLabFields } from "../utils/redact-sensitive.js"; +import { + normalizeGitLabApiRelativePath, + readProjectIdFromApiRelativePath, + resolveGitLabApiRequestUrl, +} from "../utils/url.js"; + +/** + * Methods this tool will send. HEAD is omitted: callers do not need it, and + * a smaller allowlist is easier to gate. DELETE stays in the schema so + * `modify` can reject it at call time instead of hiding the whole tool. + */ +export const GITLAB_API_REQUEST_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"] as const; + +export type GitLabApiRequestMethod = (typeof GITLAB_API_REQUEST_METHODS)[number]; + +const QUERY_SCALAR = z.union([z.string(), z.number(), z.boolean()]); + +export const GitLabApiRequestSchema = z.object({ + method: z + .enum(GITLAB_API_REQUEST_METHODS) + .optional() + .describe( + "HTTP method. Defaults to GET. Non-GET requires a permission mode that allows writes. DELETE is rejected in modify mode." + ), + path: z + .string() + .min(1) + .describe( + "Path relative to /api/v4, such as projects/1 or /projects/1/issues. Absolute URLs are rejected." + ), + query: z + .record(z.string(), z.union([QUERY_SCALAR, z.array(QUERY_SCALAR)])) + .optional() + .describe("Query string parameters. Arrays are repeated as separate keys."), + body: z + .unknown() + .optional() + .describe("JSON request body for POST, PUT, PATCH, and DELETE. Rejected on GET."), +}); + +export type GitLabApiRequestArgs = z.infer; + +export class GitLabApiRequestInputError extends Error { + constructor(message: string) { + super(message); + this.name = "GitLabApiRequestInputError"; + } +} + +export class GitLabApiRequestHttpError extends Error { + constructor(message: string) { + super(message); + this.name = "GitLabApiRequestHttpError"; + } +} + +export interface GitLabApiRequestResponse { + readonly ok: boolean; + readonly status: number; + readonly statusText: string; + text(): Promise; +} + +export interface ExecuteGitLabApiRequestInput { + readonly args: unknown; + readonly apiBaseUrl: string; + readonly permissionMode: string; + readonly allowedProjectIds: readonly string[]; + readonly fetchImpl: (url: string, init: UndiciRequestInit) => Promise; + readonly fetchConfig: () => UndiciRequestInit; +} + +export interface GitLabApiRequestToolResult { + readonly content: readonly [{ readonly type: "text"; readonly text: string }]; +} + +const REQUEST_TIMEOUT_MS = 45_000; + +export function assertGitLabApiRequestMethodAllowed( + method: GitLabApiRequestMethod, + permissionMode: string +): void { + if (method === "GET") { + return; + } + if (permissionMode === "readonly") { + throw new GitLabApiRequestInputError( + "gitlab_api_request allows only GET when GITLAB_READ_ONLY_MODE=true or GITLAB_PERMISSION_MODE=readonly" + ); + } + if (permissionMode === "modify" && method === "DELETE") { + throw new GitLabApiRequestInputError( + "gitlab_api_request rejects DELETE when GITLAB_PERMISSION_MODE=modify (delete operations are disabled)" + ); + } +} + +function assertBodyAllowed(method: GitLabApiRequestMethod, body: unknown): void { + if (method === "GET" && body !== undefined) { + throw new GitLabApiRequestInputError("gitlab_api_request rejects a body on GET"); + } +} + +function projectAllowed( + projectId: string, + rawSegment: string, + allowlist: readonly string[] +): boolean { + const candidates = new Set([projectId, rawSegment]); + for (const entry of allowlist) { + if (candidates.has(entry)) { + return true; + } + try { + if (candidates.has(decodeURIComponent(entry))) { + return true; + } + } catch { + // An undecodable allowlist entry cannot match this path. + } + } + return false; +} + +function assertProjectAllowlist(relativePath: string, allowlist: readonly string[]): void { + if (allowlist.length === 0) { + return; + } + const match = /^projects\/([^/]+)(?:\/|$)/.exec(relativePath); + if (!match) { + return; + } + const rawSegment = match[1]; + let projectId: string; + try { + projectId = readProjectIdFromApiRelativePath(relativePath) ?? rawSegment; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new GitLabApiRequestInputError(message); + } + if (!projectAllowed(projectId, rawSegment, allowlist)) { + throw new GitLabApiRequestInputError( + `Access denied: Project ${projectId} is not in the allowed project list: ${allowlist.join(", ")}` + ); + } +} + +function appendQuery(url: URL, query: GitLabApiRequestArgs["query"]): void { + if (!query) { + return; + } + for (const [key, value] of Object.entries(query)) { + if (Array.isArray(value)) { + for (const item of value) { + url.searchParams.append(key, String(item)); + } + continue; + } + url.searchParams.append(key, String(value)); + } +} + +function formatHttpError(status: number, statusText: string, rawBody: string): string { + const trimmed = rawBody.trim(); + if (trimmed.length === 0) { + return `GitLab API error: ${status} ${statusText}`; + } + try { + const parsed: unknown = JSON.parse(rawBody); + redactSensitiveGitLabFields(parsed); + return `GitLab API error: ${status} ${statusText}\n${JSON.stringify(parsed)}`; + } catch { + return `GitLab API error: ${status} ${statusText}\n${rawBody}`; + } +} + +function formatSuccessBody(rawBody: string): string { + const trimmed = rawBody.trim(); + if (trimmed.length === 0) { + return ""; + } + try { + const parsed: unknown = JSON.parse(rawBody); + redactSensitiveGitLabFields(parsed); + return JSON.stringify(parsed, null, 2); + } catch { + return rawBody; + } +} + +export async function executeGitLabApiRequestAsync( + input: ExecuteGitLabApiRequestInput +): Promise { + const args = GitLabApiRequestSchema.parse(input.args); + const method = args.method ?? "GET"; + assertGitLabApiRequestMethodAllowed(method, input.permissionMode); + assertBodyAllowed(method, args.body); + + let url: URL; + let relative: string; + try { + relative = normalizeGitLabApiRelativePath(args.path); + url = resolveGitLabApiRequestUrl(input.apiBaseUrl, args.path); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new GitLabApiRequestInputError(message); + } + + assertProjectAllowlist(relative, input.allowedProjectIds); + appendQuery(url, args.query); + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + try { + const response = await input.fetchImpl(url.toString(), { + ...input.fetchConfig(), + method, + ...(method === "GET" || args.body === undefined ? {} : { body: JSON.stringify(args.body) }), + signal: controller.signal, + }); + const rawBody = await response.text(); + if (!response.ok) { + throw new GitLabApiRequestHttpError( + formatHttpError(response.status, response.statusText, rawBody) + ); + } + return { content: [{ type: "text", text: formatSuccessBody(rawBody) }] }; + } finally { + clearTimeout(timeout); + } +} diff --git a/tools/registry.ts b/tools/registry.ts index 21492c3a6..49e3298a2 100644 --- a/tools/registry.ts +++ b/tools/registry.ts @@ -275,6 +275,7 @@ import { DismissVulnerabilitySchema, ConfirmVulnerabilitySchema, } from "../schemas.js"; +import { GitLabApiRequestSchema } from "./gitlab-api-request.js"; const IS_REMOTE = SSE || STREAMABLE_HTTP; @@ -1708,6 +1709,13 @@ export const allTools = [ description: "Get one runner by ID", inputSchema: toJSONSchema(GetRunnerSchema), }, + // --- Explicit opt-in raw API. Not in `all` and not activatable via discover_tools. --- + { + name: "gitlab_api_request", + description: + "DANGER: bypasses group and project restrictions, tool allowlists (GITLAB_TOOLS and GITLAB_DENIED_TOOLS_REGEX on other tools), and individual tool safety checks. The full token scope is exposed.", + inputSchema: toJSONSchema(GitLabApiRequestSchema), + }, // --- Meta tool: Dynamic tool discovery --- { name: "discover_tools", @@ -1735,6 +1743,8 @@ export const readOnlyTools = new Set([ "search_project_code", "search_group_code", "execute_graphql", + // Mixed-access: listed in readonly so GET still works. Writes are rejected in the handler. + "gitlab_api_request", "get_file_contents", "get_merge_request", "get_merge_request_diffs", @@ -1876,7 +1886,7 @@ export const readOnlyTools = new Set([ // Stays in readOnlyTools so readonly mode can still run queries, but mutations // are allowed in other permission modes and rejected only at call time. -const MIXED_ACCESS_TOOLS = new Set(["execute_graphql"]); +const MIXED_ACCESS_TOOLS = new Set(["execute_graphql", "gitlab_api_request"]); export function advertisesReadOnly(toolName: string): boolean { return readOnlyTools.has(toolName) && !MIXED_ACCESS_TOOLS.has(toolName); @@ -1929,6 +1939,9 @@ export const destructiveTools = new Set([ "delete_pipeline_schedule", "delete_pipeline_schedule_variable", "purge_dependency_proxy_cache", + // Can DELETE (and other writes). Hint is true; modify mode still lists the tool and + // the handler rejects DELETE. Putting it in deleteTools would hide GET/POST too. + "gitlab_api_request", ]); // Tools blocked in "modify" permission mode: permanent deletions plus destructive @@ -2103,11 +2116,17 @@ export type ToolsetId = | "variables" | "dependency_proxy" | "vulnerabilities" - | "orbit"; + | "orbit" + | "api"; export interface ToolsetDefinition { readonly id: ToolsetId; readonly isDefault: boolean; + /** + * Enabled only when this id is written in GITLAB_TOOLSETS. + * Excluded from `GITLAB_TOOLSETS=all` and from `discover_tools`. + */ + readonly explicitOnly?: boolean; readonly tools: ReadonlySet; } @@ -2545,6 +2564,12 @@ export const TOOLSET_DEFINITIONS: readonly ToolsetDefinition[] = [ isDefault: false, tools: new Set(["orbit_query", "orbit_get_schema", "orbit_get_status", "orbit_list_tools"]), }, + { + id: "api", + isDefault: false, + explicitOnly: true, + tools: new Set(["gitlab_api_request"]), + }, ] as const; // Derived lookup: tool name → toolset IDs. Most tools belong to one category. @@ -2566,6 +2591,33 @@ export const DEFAULT_TOOLSET_IDS: ReadonlySet = new Set( export const ALL_TOOLSET_IDS: ReadonlySet = new Set(TOOLSET_DEFINITIONS.map(d => d.id)); +/** Toolsets `GITLAB_TOOLSETS=all` and `discover_tools` may enable. Excludes explicitOnly. */ +export const DISCOVERABLE_TOOLSET_IDS: ReadonlySet = new Set( + TOOLSET_DEFINITIONS.filter(definition => definition.explicitOnly !== true).map( + definition => definition.id + ) +); + +export function isExplicitOnlyToolsetId(id: string): boolean { + return TOOLSET_DEFINITIONS.some( + definition => definition.id === id && definition.explicitOnly === true + ); +} + +/** True when every toolset that contains the tool is explicit-only. */ +export function isExplicitToolsetOnlyTool(toolName: string): boolean { + const toolsetIds = TOOLSETS_BY_TOOL_NAME.get(toolName); + if (toolsetIds === undefined || toolsetIds.size === 0) { + return false; + } + for (const toolsetId of toolsetIds) { + if (!isExplicitOnlyToolsetId(toolsetId)) { + return false; + } + } + return true; +} + /** * Smallest toolset containing the tool, i.e. the cheapest category to activate for it. * Returns undefined for tools outside every toolset (e.g. `execute_graphql`) and unknown names. @@ -2582,7 +2634,7 @@ export function findSmallestToolsetForTool(toolName: string): ToolsetId | undefi // Update discover_tools description with all known categories (must be after TOOLSET_DEFINITIONS) const discoverTool = allTools.find(t => t.name === "discover_tools"); if (discoverTool) { - discoverTool.description = `Discover and activate additional tool categories for this session. Available categories: ${[...ALL_TOOLSET_IDS].join(", ")}. Already-active categories are listed in the response.`; + discoverTool.description = `Discover and activate additional tool categories for this session. Available categories: ${[...DISCOVERABLE_TOOLSET_IDS].join(", ")}. Already-active categories are listed in the response. Explicit-only toolsets are omitted; list them in GITLAB_TOOLSETS.`; } for (const tool of allTools) { @@ -2600,7 +2652,7 @@ export function parseEnabledToolsets(raw: string | undefined): ReadonlySet, isExcluded?: (toolName: string) => boolean ): DiscoverableCategory[] { - return TOOLSET_DEFINITIONS.map(def => ({ + return TOOLSET_DEFINITIONS.filter(def => def.explicitOnly !== true).map(def => ({ id: def.id, toolCount: def.tools.size, active: isToolsetFullyActive(def.tools, currentToolNames, isExcluded), diff --git a/tools/tool-descriptions.ts b/tools/tool-descriptions.ts index 186bf2d25..2106cf35f 100644 --- a/tools/tool-descriptions.ts +++ b/tools/tool-descriptions.ts @@ -73,6 +73,8 @@ const TOOL_GUIDANCE: Readonly> = { "Use this to retrieve a pipeline job's artifact archive; remote HTTP mode returns a download URL while local mode saves the archive to a local path. It is read-only but may create a local file in stdio mode, requires job/project access, and returns the download result or an artifact/permission error.", download_attachment: "Use this to retrieve a previously uploaded project attachment; remote mode returns inline base64 for images or a download URL, while local mode can save to a path. It is read-only with respect to GitLab, requires project access, and returns the file content or an attachment/permission error.", + gitlab_api_request: + "Send one GitLab REST call under /api/v4. Readonly mode allows GET only, and modify mode rejects DELETE. Enable it only by listing api in GITLAB_TOOLSETS; all, discover_tools, and GITLAB_TOOLS do not. Prompt injection can drive this tool, so operators who rely on group, project, or tool restrictions must not enable it. GITLAB_ALLOWED_PROJECT_IDS is enforced only for /projects/:id paths; other endpoints are not constrained.", execute_graphql: "Use this only when a supported GitLab REST tool does not cover the requested operation; prefer a typed tool when one exists. The query is sent directly to GitLab and can include mutations when permission allows, so callers must treat it as potentially state-changing and handle GraphQL errors in the returned response.", discover_tools: diff --git a/utils/url.ts b/utils/url.ts index 2abe4fe36..b6fa26119 100644 --- a/utils/url.ts +++ b/utils/url.ts @@ -154,3 +154,94 @@ export function encodeGitLabPath(value: string): string { return segments.map(encodeGitLabPathSegment).join("/"); } + +const ABSOLUTE_URL_PATTERN = /^[a-zA-Z][a-zA-Z0-9+.-]*:/; + +function looksLikeAbsoluteApiReference(value: string): boolean { + return value.includes("://") || value.startsWith("//") || ABSOLUTE_URL_PATTERN.test(value); +} + +/** + * Normalize a caller path so it can only name a resource under `/api/v4`. + * Rejects absolute URLs, protocol-relative hosts, backslashes, dot segments + * (including percent-encoded ones), and control characters. A single leading + * or trailing slash is removed so `/projects/1` and `projects/1` are the same + * relative path. + */ +export function normalizeGitLabApiRelativePath(rawPath: string): string { + const trimmed = rawPath.trim(); + if (trimmed.length === 0) { + throw new Error("GitLab API path is empty"); + } + if ( + CONTROL_CHARACTER_PATTERN.test(trimmed) || + trimmed.includes("\\") || + looksLikeAbsoluteApiReference(trimmed) + ) { + throw new Error( + "GitLab API path must be a relative path under /api/v4 (absolute URLs, protocol-relative hosts, backslashes, and control characters are rejected)" + ); + } + + const withoutLeadingSlash = trimmed.startsWith("/") ? trimmed.slice(1) : trimmed; + const relative = withoutLeadingSlash.endsWith("/") + ? withoutLeadingSlash.slice(0, -1) + : withoutLeadingSlash; + if (relative.length === 0 || relative.startsWith("/")) { + throw new Error("GitLab API path must be a relative path under /api/v4"); + } + + const decoded = decodeFully(relative); + if ( + decoded === null || + CONTROL_CHARACTER_PATTERN.test(decoded) || + decoded.includes("\\") || + looksLikeAbsoluteApiReference(decoded) + ) { + throw new Error( + "GitLab API path must be a relative path under /api/v4 (absolute URLs, protocol-relative hosts, backslashes, and control characters are rejected)" + ); + } + + // encodeGitLabPath rejects `.` / `..` segments, including percent-encoded forms. + encodeGitLabPath(relative); + return relative; +} + +/** + * Resolve `rawPath` against the configured GitLab API base (`.../api/v4`). + * The result is the same origin and stays under that base path. + */ +export function resolveGitLabApiRequestUrl(apiBase: string, rawPath: string): URL { + const relative = normalizeGitLabApiRelativePath(rawPath); + const encoded = encodeGitLabPath(relative); + const base = new URL(apiBase.endsWith("/") ? apiBase : `${apiBase}/`); + const resolved = new URL(encoded, base); + + if (resolved.origin !== base.origin || resolved.username !== "" || resolved.password !== "") { + throw new Error("GitLab API path escapes the configured API origin"); + } + + const basePath = base.pathname.endsWith("/") ? base.pathname : `${base.pathname}/`; + const staysUnderBase = + resolved.pathname === base.pathname.replace(/\/$/, "") || + resolved.pathname.startsWith(basePath); + if (!staysUnderBase || resolved.search !== "" || resolved.hash !== "") { + throw new Error("GitLab API path escapes the configured /api/v4 base"); + } + + return resolved; +} + +/** Project id from `projects/:id` or `projects/:id/...`. Other paths return undefined. */ +export function readProjectIdFromApiRelativePath(relativePath: string): string | undefined { + const match = /^projects\/([^/]+)(?:\/|$)/.exec(relativePath); + if (!match) { + return undefined; + } + try { + return decodeURIComponent(match[1]); + } catch { + throw new Error("GitLab API path has an invalid project id encoding"); + } +} From 320ea7d99ab14d662f913f6dec4ba2072daa8dc4 Mon Sep 17 00:00:00 2001 From: zereight Date: Sat, 10 Oct 2026 08:19:15 +0900 Subject: [PATCH 2/9] fix(api): redact secrets in non-JSON gitlab_api_request bodies JSON responses already drop token, import_url, and runners_token. Plain-text bodies and error messages skipped that step. --- test/utils/gitlab-api-request.test.ts | 42 +++++++++++++++++++++++++++ test/utils/redact-sensitive.test.ts | 40 ++++++++++++++++++++++++- tools/gitlab-api-request.ts | 9 ++++-- utils/redact-sensitive.ts | 15 ++++++++++ 4 files changed, 102 insertions(+), 4 deletions(-) diff --git a/test/utils/gitlab-api-request.test.ts b/test/utils/gitlab-api-request.test.ts index c99470753..db681ef28 100644 --- a/test/utils/gitlab-api-request.test.ts +++ b/test/utils/gitlab-api-request.test.ts @@ -204,6 +204,48 @@ describe("When executing gitlab_api_request", () => { }); }); + describe("with a non-JSON success body", () => { + it("should redact a plain-text token before returning it", async () => { + const result = await executeGitLabApiRequestAsync({ + args: { path: "projects/5/repository/files/note/raw" }, + apiBaseUrl: API_BASE, + permissionMode: "full", + allowedProjectIds: [], + fetchConfig: () => ({ headers: {} }), + fetchImpl: async () => new Response("token: plain-secret\nkeep", { status: 200 }), + }); + + assert.equal(result.content[0].text.includes("plain-secret"), false); + assert.match(result.content[0].text, /keep/); + }); + }); + + describe("with a non-JSON error body", () => { + it("should redact import_url text in the error", async () => { + await assert.rejects( + () => + executeGitLabApiRequestAsync({ + args: { path: "projects/5" }, + apiBaseUrl: API_BASE, + permissionMode: "full", + allowedProjectIds: [], + fetchConfig: () => ({ headers: {} }), + fetchImpl: async () => + new Response("import_url=https://user:pass@example.com/repo.git", { + status: 500, + statusText: "Error", + }), + }), + (error: unknown) => { + assert.ok(error instanceof GitLabApiRequestHttpError); + assert.equal(error.message.includes("user:pass"), false); + assert.match(error.message, /500/); + return true; + } + ); + }); + }); + describe("with a non-2xx JSON body", () => { it("should raise a status error without the token field", async () => { await assert.rejects( diff --git a/test/utils/redact-sensitive.test.ts b/test/utils/redact-sensitive.test.ts index f9d76887b..ee434e262 100644 --- a/test/utils/redact-sensitive.test.ts +++ b/test/utils/redact-sensitive.test.ts @@ -1,6 +1,9 @@ import assert from "node:assert/strict"; import { describe, test } from "node:test"; -import { redactSensitiveGitLabFields } from "../../utils/redact-sensitive.js"; +import { + redactSensitiveGitLabFields, + redactSensitiveGitLabText, +} from "../../utils/redact-sensitive.js"; describe("When redactSensitiveGitLabFields runs", () => { describe("with a project response containing credentials", () => { @@ -58,3 +61,38 @@ describe("When redactSensitiveGitLabFields runs", () => { }); }); }); + +describe("When redactSensitiveGitLabText runs", () => { + describe("with a token assignment", () => { + test("should redact the assigned value", () => { + assert.equal(redactSensitiveGitLabText("token: plain-secret"), "token: [REDACTED]"); + }); + }); + + describe("with an import_url assignment", () => { + test("should redact the URL value", () => { + assert.equal( + redactSensitiveGitLabText("import_url=https://user:pass@example.com/repo.git"), + "import_url=[REDACTED]" + ); + }); + }); + + describe("with a GitLab token prefix and no field name", () => { + test("should redact the token", () => { + assert.equal( + redactSensitiveGitLabText("see glpat-abcdefghijklmnopqrstuvwxyz"), + "see [REDACTED]" + ); + }); + }); + + describe("with ordinary text", () => { + test("should leave the text unchanged", () => { + assert.equal( + redactSensitiveGitLabText("token is not a secret here"), + "token is not a secret here" + ); + }); + }); +}); diff --git a/tools/gitlab-api-request.ts b/tools/gitlab-api-request.ts index 538cd298b..bb040152e 100644 --- a/tools/gitlab-api-request.ts +++ b/tools/gitlab-api-request.ts @@ -1,7 +1,10 @@ import type { RequestInit as UndiciRequestInit } from "undici"; import { z } from "zod"; -import { redactSensitiveGitLabFields } from "../utils/redact-sensitive.js"; +import { + redactSensitiveGitLabFields, + redactSensitiveGitLabText, +} from "../utils/redact-sensitive.js"; import { normalizeGitLabApiRelativePath, readProjectIdFromApiRelativePath, @@ -174,7 +177,7 @@ function formatHttpError(status: number, statusText: string, rawBody: string): s redactSensitiveGitLabFields(parsed); return `GitLab API error: ${status} ${statusText}\n${JSON.stringify(parsed)}`; } catch { - return `GitLab API error: ${status} ${statusText}\n${rawBody}`; + return `GitLab API error: ${status} ${statusText}\n${redactSensitiveGitLabText(rawBody)}`; } } @@ -188,7 +191,7 @@ function formatSuccessBody(rawBody: string): string { redactSensitiveGitLabFields(parsed); return JSON.stringify(parsed, null, 2); } catch { - return rawBody; + return redactSensitiveGitLabText(rawBody); } } diff --git a/utils/redact-sensitive.ts b/utils/redact-sensitive.ts index 066b80e6e..c602194a5 100644 --- a/utils/redact-sensitive.ts +++ b/utils/redact-sensitive.ts @@ -42,3 +42,18 @@ export function redactSensitiveGitLabFields(data: T): T { return data; } + +const SENSITIVE_ASSIGNMENT_PATTERN = + /(["']?\b(?:runners_token|import_url|token)\b["']?\s*[:=]\s*)(?:"[^"\r\n]*"|'[^'\r\n]*'|\S+)/gi; + +const GITLAB_TOKEN_PATTERN = /\bgl(?:pat|dt|rt|pt|ft|oas|soat)-[A-Za-z0-9._-]+/gi; + +/** + * Redacts the same secret fields when GitLab returns a non-JSON body. + * JSON responses keep using {@link redactSensitiveGitLabFields}. + */ +export function redactSensitiveGitLabText(text: string): string { + return text + .replace(SENSITIVE_ASSIGNMENT_PATTERN, "$1[REDACTED]") + .replace(GITLAB_TOKEN_PATTERN, "[REDACTED]"); +} From 24c1a3e04d02ba7876ca3bc78ed47040fdf223d9 Mon Sep 17 00:00:00 2001 From: zereight Date: Sat, 10 Oct 2026 10:36:42 +0900 Subject: [PATCH 3/9] fix(api): redact quoted secrets that contain escaped quotes The text fallback stopped at an escaped quote, so the rest of a quoted token could remain in a non-JSON gitlab_api_request body. --- test/utils/redact-sensitive.test.ts | 6 ++++++ utils/redact-sensitive.ts | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/test/utils/redact-sensitive.test.ts b/test/utils/redact-sensitive.test.ts index ee434e262..c11f659b1 100644 --- a/test/utils/redact-sensitive.test.ts +++ b/test/utils/redact-sensitive.test.ts @@ -87,6 +87,12 @@ describe("When redactSensitiveGitLabText runs", () => { }); }); + describe("with an escaped quote inside a quoted token", () => { + test("should redact the whole quoted value", () => { + assert.equal(redactSensitiveGitLabText('token: "ab\\"cd"'), "token: [REDACTED]"); + }); + }); + describe("with ordinary text", () => { test("should leave the text unchanged", () => { assert.equal( diff --git a/utils/redact-sensitive.ts b/utils/redact-sensitive.ts index c602194a5..7b1d1eacb 100644 --- a/utils/redact-sensitive.ts +++ b/utils/redact-sensitive.ts @@ -44,7 +44,7 @@ export function redactSensitiveGitLabFields(data: T): T { } const SENSITIVE_ASSIGNMENT_PATTERN = - /(["']?\b(?:runners_token|import_url|token)\b["']?\s*[:=]\s*)(?:"[^"\r\n]*"|'[^'\r\n]*'|\S+)/gi; + /(["']?\b(?:runners_token|import_url|token)\b["']?\s*[:=]\s*)(?:"(?:[^"\\\r\n]|\\.)*"|'(?:[^'\\\r\n]|\\.)*'|\S+)/gi; const GITLAB_TOKEN_PATTERN = /\bgl(?:pat|dt|rt|pt|ft|oas|soat)-[A-Za-z0-9._-]+/gi; From bf81a6bf8f14a143fd9db79b8c270b07528041f2 Mon Sep 17 00:00:00 2001 From: zereight Date: Tue, 13 Oct 2026 10:23:48 +0900 Subject: [PATCH 4/9] fix(api): validate redirects in gitlab_api_request Raw fetch followed redirects and forwarded Private-Token and JOB-TOKEN to the next host. Check each hop and withhold those headers off-origin. Co-authored-by: Cursor --- index.ts | 2 + test/utils/gitlab-api-request.test.ts | 221 +++++++++++++++++++++++++- tools/gitlab-api-request.ts | 76 +++++++-- utils/safe-redirect-fetch.ts | 44 ++++- 4 files changed, 323 insertions(+), 20 deletions(-) diff --git a/index.ts b/index.ts index 3eaa9fea1..c740669a2 100644 --- a/index.ts +++ b/index.ts @@ -11628,6 +11628,8 @@ async function handleToolCall(params: any) { permissionMode: GITLAB_PERMISSION_MODE, allowedProjectIds: getEffectiveAllowedProjectIds(), fetchImpl: fetch, + unauthenticatedFetchImpl: undiciFetch, + isTrustedRedirectHost: isTrustedGitLabRedirectHost, fetchConfig: getFetchConfig, }); } catch (error) { diff --git a/test/utils/gitlab-api-request.test.ts b/test/utils/gitlab-api-request.test.ts index db681ef28..2220a8dc6 100644 --- a/test/utils/gitlab-api-request.test.ts +++ b/test/utils/gitlab-api-request.test.ts @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; +import { Response as UndiciResponse } from "undici"; import { isToolExposed } from "../../cli/exposure.js"; import { selectExposedTools } from "../../tools/exposed-tools.js"; @@ -20,10 +21,35 @@ import { GitLabApiRequestHttpError, GitLabApiRequestInputError, } from "../../tools/gitlab-api-request.js"; +import type { ValidatedRedirectFetch } from "../../utils/safe-redirect-fetch.js"; import { resolveGitLabApiRequestUrl } from "../../utils/url.js"; const API_BASE = "http://127.0.0.1:9/api/v4"; +const PUBLIC_REDIRECT_HOST = "93.184.216.34"; + +interface RecordedHop { + readonly url: string; + readonly headers: Record; +} + +function redirectFetch(location: string, calls: RecordedHop[]): ValidatedRedirectFetch { + return async (input, init) => { + calls.push({ url: String(input), headers: { ...(init?.headers ?? {}) } }); + if (calls.length === 1) { + return new UndiciResponse(null, { status: 302, headers: { location } }); + } + return new UndiciResponse(JSON.stringify({ followed: true }), { status: 200 }); + }; +} + +const CREDENTIAL_HEADERS = { + Accept: "application/json", + Authorization: "Bearer secret", + "Private-Token": "pat-secret", + "JOB-TOKEN": "job-secret", +}; + function resolveOrThrow(path: string): URL { return resolveGitLabApiRequestUrl(API_BASE, path); } @@ -172,7 +198,7 @@ describe("When executing gitlab_api_request", () => { fetchConfig: () => ({ headers: {} }), fetchImpl: async url => { requested = url; - return new Response(JSON.stringify({ ok: true }), { status: 200 }); + return new UndiciResponse(JSON.stringify({ ok: true }), { status: 200 }); }, }); @@ -212,7 +238,7 @@ describe("When executing gitlab_api_request", () => { permissionMode: "full", allowedProjectIds: [], fetchConfig: () => ({ headers: {} }), - fetchImpl: async () => new Response("token: plain-secret\nkeep", { status: 200 }), + fetchImpl: async () => new UndiciResponse("token: plain-secret\nkeep", { status: 200 }), }); assert.equal(result.content[0].text.includes("plain-secret"), false); @@ -231,7 +257,7 @@ describe("When executing gitlab_api_request", () => { allowedProjectIds: [], fetchConfig: () => ({ headers: {} }), fetchImpl: async () => - new Response("import_url=https://user:pass@example.com/repo.git", { + new UndiciResponse("import_url=https://user:pass@example.com/repo.git", { status: 500, statusText: "Error", }), @@ -246,6 +272,193 @@ describe("When executing gitlab_api_request", () => { }); }); + describe("with a JSON body", () => { + it("should send POST and the serialized body", async () => { + const seen = { method: "", body: "" }; + const fetchImpl: ValidatedRedirectFetch = async (_input, init) => { + seen.method = init?.method ?? ""; + seen.body = init?.body ?? ""; + return new UndiciResponse(JSON.stringify({ id: 1 }), { + status: 201, + statusText: "Created", + }); + }; + + await executeGitLabApiRequestAsync({ + args: { method: "POST", path: "projects/5/issues", body: { title: "t" } }, + apiBaseUrl: API_BASE, + permissionMode: "full", + allowedProjectIds: [], + fetchConfig: () => ({ headers: {} }), + fetchImpl, + }); + + assert.deepEqual([seen.method, seen.body], ["POST", JSON.stringify({ title: "t" })]); + }); + }); + + describe("with a cross-origin redirect", () => { + it("should withhold credential headers from the next hop", async () => { + const calls: RecordedHop[] = []; + const fetchImpl = redirectFetch(`http://${PUBLIC_REDIRECT_HOST}/object`, calls); + const result = await executeGitLabApiRequestAsync({ + args: { path: "projects/5/jobs/7/artifacts" }, + apiBaseUrl: API_BASE, + permissionMode: "full", + allowedProjectIds: [], + fetchConfig: () => ({ headers: CREDENTIAL_HEADERS }), + fetchImpl, + unauthenticatedFetchImpl: fetchImpl, + }); + + assert.equal(calls[1]?.url, `http://${PUBLIC_REDIRECT_HOST}/object`); + assert.equal(calls[1]?.headers.Authorization, undefined); + assert.equal(calls[1]?.headers["Private-Token"], undefined); + assert.equal(calls[1]?.headers["JOB-TOKEN"], undefined); + assert.match(result.content[0].text, /followed/); + }); + }); + + describe("with a POST redirected to another origin", () => { + it("should follow as GET without the body or credential headers", async () => { + const calls: Array = []; + const fetchImpl: ValidatedRedirectFetch = async (input, init) => { + calls.push({ + url: String(input), + method: init?.method ?? "", + body: init?.body, + headers: { ...(init?.headers ?? {}) }, + }); + if (calls.length === 1) { + return new UndiciResponse(null, { + status: 302, + headers: { location: `http://${PUBLIC_REDIRECT_HOST}/object` }, + }); + } + return new UndiciResponse(JSON.stringify({ followed: true }), { status: 200 }); + }; + + await executeGitLabApiRequestAsync({ + args: { method: "POST", path: "projects/5/issues", body: { title: "t" } }, + apiBaseUrl: API_BASE, + permissionMode: "full", + allowedProjectIds: [], + fetchConfig: () => ({ headers: CREDENTIAL_HEADERS }), + fetchImpl, + unauthenticatedFetchImpl: fetchImpl, + }); + + assert.equal(calls[0]?.method, "POST"); + assert.equal(calls[1]?.method, "GET"); + assert.equal(calls[1]?.body, undefined); + assert.equal(calls[1]?.headers["Private-Token"], undefined); + assert.equal(calls[1]?.headers["JOB-TOKEN"], undefined); + }); + }); + + describe("with a same-origin redirect", () => { + it("should keep credential headers on the next hop", async () => { + const calls: RecordedHop[] = []; + const fetchImpl = redirectFetch("/api/v4/projects/5", calls); + await executeGitLabApiRequestAsync({ + args: { path: "projects/5" }, + apiBaseUrl: API_BASE, + permissionMode: "full", + allowedProjectIds: [], + fetchConfig: () => ({ headers: CREDENTIAL_HEADERS }), + fetchImpl, + unauthenticatedFetchImpl: fetchImpl, + }); + + assert.equal(calls[1]?.headers["Private-Token"], "pat-secret"); + assert.equal(calls[1]?.headers["JOB-TOKEN"], "job-secret"); + }); + }); + + describe("with a redirect to a private address", () => { + it("should refuse the hop before reading a body", async () => { + const calls: RecordedHop[] = []; + const fetchImpl = redirectFetch("http://169.254.169.254/latest/meta-data/", calls); + + await assert.rejects( + () => + executeGitLabApiRequestAsync({ + args: { path: "projects/5/jobs/7/artifacts" }, + apiBaseUrl: API_BASE, + permissionMode: "full", + allowedProjectIds: [], + fetchConfig: () => ({ headers: CREDENTIAL_HEADERS }), + fetchImpl, + unauthenticatedFetchImpl: fetchImpl, + }), + (error: unknown) => { + assert.ok(error instanceof GitLabApiRequestHttpError); + assert.match(error.message, /non-public address/); + return true; + } + ); + assert.equal(calls.length, 1); + }); + }); + + describe("with a 303 redirect", () => { + it("should follow PUT as GET and drop the body", async () => { + const calls: Array<{ readonly method: string; readonly body?: string }> = []; + const fetchImpl: ValidatedRedirectFetch = async (_input, init) => { + calls.push({ method: init?.method ?? "", body: init?.body }); + if (calls.length === 1) { + return new UndiciResponse(null, { + status: 303, + headers: { location: `http://${PUBLIC_REDIRECT_HOST}/object` }, + }); + } + return new UndiciResponse(JSON.stringify({ followed: true }), { status: 200 }); + }; + + await executeGitLabApiRequestAsync({ + args: { method: "PUT", path: "projects/5/issues/1", body: { title: "t" } }, + apiBaseUrl: API_BASE, + permissionMode: "full", + allowedProjectIds: [], + fetchConfig: () => ({ headers: {} }), + fetchImpl, + unauthenticatedFetchImpl: fetchImpl, + }); + + assert.equal(calls[1]?.method, "GET"); + assert.equal(calls[1]?.body, undefined); + }); + }); + + describe("with a 307 redirect", () => { + it("should keep the POST method and body", async () => { + const calls: Array<{ readonly method: string; readonly body?: string }> = []; + const fetchImpl: ValidatedRedirectFetch = async (_input, init) => { + calls.push({ method: init?.method ?? "", body: init?.body }); + if (calls.length === 1) { + return new UndiciResponse(null, { + status: 307, + headers: { location: "/api/v4/projects/5/issues" }, + }); + } + return new UndiciResponse(JSON.stringify({ kept: true }), { status: 200 }); + }; + + await executeGitLabApiRequestAsync({ + args: { method: "POST", path: "projects/5/issues", body: { title: "t" } }, + apiBaseUrl: API_BASE, + permissionMode: "full", + allowedProjectIds: [], + fetchConfig: () => ({ headers: {} }), + fetchImpl, + unauthenticatedFetchImpl: fetchImpl, + }); + + assert.equal(calls[1]?.method, "POST"); + assert.equal(calls[1]?.body, JSON.stringify({ title: "t" })); + }); + }); + describe("with a non-2xx JSON body", () => { it("should raise a status error without the token field", async () => { await assert.rejects( @@ -257,7 +470,7 @@ describe("When executing gitlab_api_request", () => { allowedProjectIds: [], fetchConfig: () => ({ headers: {} }), fetchImpl: async () => - new Response(JSON.stringify({ message: "nope", token: "secret-token" }), { + new UndiciResponse(JSON.stringify({ message: "nope", token: "secret-token" }), { status: 403, statusText: "Forbidden", }), diff --git a/tools/gitlab-api-request.ts b/tools/gitlab-api-request.ts index bb040152e..a96063e2f 100644 --- a/tools/gitlab-api-request.ts +++ b/tools/gitlab-api-request.ts @@ -5,6 +5,11 @@ import { redactSensitiveGitLabFields, redactSensitiveGitLabText, } from "../utils/redact-sensitive.js"; +import { + fetchWithValidatedRedirects, + UnsafeRedirectError, + type ValidatedRedirectFetch, +} from "../utils/safe-redirect-fetch.js"; import { normalizeGitLabApiRelativePath, readProjectIdFromApiRelativePath, @@ -61,19 +66,18 @@ export class GitLabApiRequestHttpError extends Error { } } -export interface GitLabApiRequestResponse { - readonly ok: boolean; - readonly status: number; - readonly statusText: string; - text(): Promise; -} - export interface ExecuteGitLabApiRequestInput { readonly args: unknown; readonly apiBaseUrl: string; readonly permissionMode: string; readonly allowedProjectIds: readonly string[]; - readonly fetchImpl: (url: string, init: UndiciRequestInit) => Promise; + readonly fetchImpl: ValidatedRedirectFetch; + readonly unauthenticatedFetchImpl?: ValidatedRedirectFetch; + /** + * Operator-declared GitLab hosts. Those hops may resolve to a private address and + * keep the credential headers. Every other redirect target is checked and stripped. + */ + readonly isTrustedRedirectHost?: (host: string) => boolean; readonly fetchConfig: () => UndiciRequestInit; } @@ -152,6 +156,41 @@ function assertProjectAllowlist(relativePath: string, allowlist: readonly string } } +function isHeaderList( + headers: object +): headers is { forEach: (callback: (value: string, key: string) => void) => void } { + return !Array.isArray(headers) && "forEach" in headers && typeof headers.forEach === "function"; +} + +function headerRecord(headers: UndiciRequestInit["headers"]): Record { + if (!headers) { + return {}; + } + if (Array.isArray(headers)) { + const record: Record = {}; + for (const [name, value] of headers) { + if (typeof value === "string") { + record[name] = value; + } + } + return record; + } + if (isHeaderList(headers)) { + const record: Record = {}; + headers.forEach((value, key) => { + record[key] = value; + }); + return record; + } + const record: Record = {}; + for (const [key, value] of Object.entries(headers)) { + if (typeof value === "string") { + record[key] = value; + } + } + return record; +} + function appendQuery(url: URL, query: GitLabApiRequestArgs["query"]): void { if (!query) { return; @@ -216,14 +255,22 @@ export async function executeGitLabApiRequestAsync( assertProjectAllowlist(relative, input.allowedProjectIds); appendQuery(url, args.query); + const config = input.fetchConfig(); + const headers = headerRecord(config.headers); + const body = method === "GET" || args.body === undefined ? undefined : JSON.stringify(args.body); + const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); try { - const response = await input.fetchImpl(url.toString(), { - ...input.fetchConfig(), - method, - ...(method === "GET" || args.body === undefined ? {} : { body: JSON.stringify(args.body) }), + const response = await fetchWithValidatedRedirects(url.toString(), { + headers, + dispatcher: config.dispatcher, signal: controller.signal, + method, + ...(body === undefined ? {} : { body }), + fetchImpl: input.fetchImpl, + unauthenticatedFetchImpl: input.unauthenticatedFetchImpl, + isTrustedRedirectHost: input.isTrustedRedirectHost, }); const rawBody = await response.text(); if (!response.ok) { @@ -232,6 +279,11 @@ export async function executeGitLabApiRequestAsync( ); } return { content: [{ type: "text", text: formatSuccessBody(rawBody) }] }; + } catch (error) { + if (error instanceof UnsafeRedirectError) { + throw new GitLabApiRequestHttpError(error.message); + } + throw error; } finally { clearTimeout(timeout); } diff --git a/utils/safe-redirect-fetch.ts b/utils/safe-redirect-fetch.ts index 1072c72eb..47df100a5 100644 --- a/utils/safe-redirect-fetch.ts +++ b/utils/safe-redirect-fetch.ts @@ -180,12 +180,26 @@ export function isNonPublicAddress(address: string): boolean { return true; } +export type ValidatedRedirectFetch = ( + input: string, + init?: { + method?: string; + headers?: Record; + body?: string; + dispatcher?: Dispatcher; + signal?: AbortSignal; + redirect?: "manual"; + } +) => Promise; + export interface FetchWithValidatedRedirectsOptions { headers: Record; dispatcher?: Dispatcher; signal?: AbortSignal; + method?: string; + body?: string; /** Client used while the request still carries the credentials. Defaults to undici's. */ - fetchImpl?: typeof undiciFetch; + fetchImpl?: ValidatedRedirectFetch; /** * Client used for the hops whose credentials have been withheld. Defaults to plain * `undici`, *not* to {@link fetchImpl}. @@ -198,7 +212,7 @@ export interface FetchWithValidatedRedirectsOptions { * reopening that hole, so pass the wrapped client here only if the hops without * credentials are expected to reach it. */ - unauthenticatedFetchImpl?: typeof undiciFetch; + unauthenticatedFetchImpl?: ValidatedRedirectFetch; maxRedirects?: number; /** * Header names withheld from redirect targets outside the initial origin unless @@ -292,8 +306,21 @@ async function assertRedirectTargetAllowed( } } +function methodAfterRedirect( + status: number, + method: string +): { readonly method: string; readonly dropBody: boolean } { + if ((status === 301 || status === 302) && method === "POST") { + return { method: "GET", dropBody: true }; + } + if (status === 303 && method !== "GET" && method !== "HEAD") { + return { method: "GET", dropBody: true }; + } + return { method, dropBody: false }; +} + /** - * Perform a GET request that validates every redirect hop before following it. + * Perform a request that validates every redirect hop before following it. * * The HTTP client default follows redirects without re-validating the destination, * which lets an upstream response redirect the server to an arbitrary host @@ -337,6 +364,8 @@ export async function fetchWithValidatedRedirects( let currentUrl = url; let currentProtocol = new URL(url).protocol; let currentHeaders = options.headers; + let currentMethod = (options.method ?? "GET").toUpperCase(); + let currentBody = options.body; // Set once a hop outside the origin has been followed without the credentials, so a // later hop back to the origin cannot re-attach them. let credentialsWithheld = false; @@ -345,8 +374,9 @@ export async function fetchWithValidatedRedirects( // A stripped hop must not go through a client that can add credentials again. const hopFetch = credentialsWithheld ? unauthenticatedFetch : authenticatedFetch; const response = await hopFetch(currentUrl, { - method: "GET", + method: currentMethod, headers: currentHeaders, + ...(currentBody === undefined ? {} : { body: currentBody }), dispatcher: options.dispatcher, signal: options.signal, redirect: "manual", @@ -400,6 +430,12 @@ export async function fetchWithValidatedRedirects( currentHeaders = credentialsWithheld ? withoutCredentialHeaders(options) : options.headers; } + const rewritten = methodAfterRedirect(response.status, currentMethod); + currentMethod = rewritten.method; + if (rewritten.dropBody) { + currentBody = undefined; + } + currentUrl = target.toString(); currentProtocol = target.protocol; } From 90103ee7947cc77b37104a723d1ed87185a95801 Mon Sep 17 00:00:00 2001 From: zereight Date: Tue, 13 Oct 2026 10:40:20 +0900 Subject: [PATCH 5/9] fix(api): enforce project allowlist on redirected gitlab_api_request hops A same-origin redirect could name a project outside GITLAB_ALLOWED_PROJECT_IDS. Check the next API path before that hop is fetched. Co-authored-by: Cursor --- test/utils/gitlab-api-request.test.ts | 45 +++++++++++++++++++++++++++ tools/gitlab-api-request.ts | 37 ++++++++++++++++++++++ utils/safe-redirect-fetch.ts | 3 ++ 3 files changed, 85 insertions(+) diff --git a/test/utils/gitlab-api-request.test.ts b/test/utils/gitlab-api-request.test.ts index 2220a8dc6..398582056 100644 --- a/test/utils/gitlab-api-request.test.ts +++ b/test/utils/gitlab-api-request.test.ts @@ -375,6 +375,51 @@ describe("When executing gitlab_api_request", () => { }); }); + describe("with an allowlist and a redirect to another project", () => { + it("should refuse the hop before requesting it", async () => { + const calls: RecordedHop[] = []; + const fetchImpl = redirectFetch("/api/v4/projects/9/issues", calls); + + await assert.rejects( + () => + executeGitLabApiRequestAsync({ + args: { path: "projects/5" }, + apiBaseUrl: API_BASE, + permissionMode: "full", + allowedProjectIds: ["5"], + fetchConfig: () => ({ headers: {} }), + fetchImpl, + unauthenticatedFetchImpl: fetchImpl, + }), + (error: unknown) => { + assert.ok(error instanceof GitLabApiRequestInputError); + assert.match(error.message, /Project 9 is not in the allowed project list/); + return true; + } + ); + assert.equal(calls.length, 1); + }); + }); + + describe("with an allowlist and a redirect to the same project", () => { + it("should follow the hop", async () => { + const calls: RecordedHop[] = []; + const fetchImpl = redirectFetch("/api/v4/projects/5/issues", calls); + const result = await executeGitLabApiRequestAsync({ + args: { path: "projects/5" }, + apiBaseUrl: API_BASE, + permissionMode: "full", + allowedProjectIds: ["5"], + fetchConfig: () => ({ headers: {} }), + fetchImpl, + unauthenticatedFetchImpl: fetchImpl, + }); + + assert.equal(calls[1]?.url, `${API_BASE}/projects/5/issues`); + assert.match(result.content[0].text, /followed/); + }); + }); + describe("with a redirect to a private address", () => { it("should refuse the hop before reading a body", async () => { const calls: RecordedHop[] = []; diff --git a/tools/gitlab-api-request.ts b/tools/gitlab-api-request.ts index a96063e2f..b2b243b84 100644 --- a/tools/gitlab-api-request.ts +++ b/tools/gitlab-api-request.ts @@ -133,6 +133,40 @@ function projectAllowed( return false; } +function apiRelativePathFromRedirect(apiBaseUrl: string, target: URL): string | undefined { + const base = new URL(apiBaseUrl.endsWith("/") ? apiBaseUrl : `${apiBaseUrl}/`); + if (target.origin !== base.origin) { + return undefined; + } + const basePrefix = base.pathname.endsWith("/") ? base.pathname : `${base.pathname}/`; + if (!target.pathname.startsWith(basePrefix)) { + return undefined; + } + return target.pathname.slice(basePrefix.length); +} + +function assertRedirectedApiPathAllowed( + apiBaseUrl: string, + allowlist: readonly string[], + target: URL +): void { + if (allowlist.length === 0) { + return; + } + const relative = apiRelativePathFromRedirect(apiBaseUrl, target); + if (relative === undefined || relative.length === 0) { + return; + } + let normalized: string; + try { + normalized = normalizeGitLabApiRelativePath(relative); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new GitLabApiRequestInputError(message); + } + assertProjectAllowlist(normalized, allowlist); +} + function assertProjectAllowlist(relativePath: string, allowlist: readonly string[]): void { if (allowlist.length === 0) { return; @@ -271,6 +305,9 @@ export async function executeGitLabApiRequestAsync( fetchImpl: input.fetchImpl, unauthenticatedFetchImpl: input.unauthenticatedFetchImpl, isTrustedRedirectHost: input.isTrustedRedirectHost, + validateRedirectTarget: target => { + assertRedirectedApiPathAllowed(input.apiBaseUrl, input.allowedProjectIds, target); + }, }); const rawBody = await response.text(); if (!response.ok) { diff --git a/utils/safe-redirect-fetch.ts b/utils/safe-redirect-fetch.ts index 47df100a5..95f5afb40 100644 --- a/utils/safe-redirect-fetch.ts +++ b/utils/safe-redirect-fetch.ts @@ -230,6 +230,7 @@ export interface FetchWithValidatedRedirectsOptions { * credentials, unless an earlier untrusted hop has already withheld them. */ isTrustedRedirectHost?: (host: string) => boolean; + validateRedirectTarget?: (target: URL) => void | Promise; } /** @@ -430,6 +431,8 @@ export async function fetchWithValidatedRedirects( currentHeaders = credentialsWithheld ? withoutCredentialHeaders(options) : options.headers; } + await options.validateRedirectTarget?.(target); + const rewritten = methodAfterRedirect(response.status, currentMethod); currentMethod = rewritten.method; if (rewritten.dropBody) { From 6e636d0f2f8d5ad970047b1fa8b5682395f941da Mon Sep 17 00:00:00 2001 From: zereight Date: Tue, 13 Oct 2026 11:20:40 +0900 Subject: [PATCH 6/9] fix(api): enforce project allowlist on trusted-host gitlab_api_request redirects A trusted alternate host keeps the request credentials, so a redirect onto another project's API path has to fail the allowlist before that hop is fetched. Co-authored-by: Cursor --- test/utils/gitlab-api-request.test.ts | 75 +++++++++++++++++++++++++++ tools/gitlab-api-request.ts | 20 +++++-- 2 files changed, 90 insertions(+), 5 deletions(-) diff --git a/test/utils/gitlab-api-request.test.ts b/test/utils/gitlab-api-request.test.ts index 398582056..0b01c0395 100644 --- a/test/utils/gitlab-api-request.test.ts +++ b/test/utils/gitlab-api-request.test.ts @@ -420,6 +420,81 @@ describe("When executing gitlab_api_request", () => { }); }); + describe("with an allowlist and a redirect to another project on a trusted host", () => { + it("should refuse the hop before requesting it", async () => { + const calls: RecordedHop[] = []; + const fetchImpl = redirectFetch( + "https://gitlab-alias.example/api/v4/projects/9/issues", + calls + ); + + await assert.rejects( + () => + executeGitLabApiRequestAsync({ + args: { path: "projects/5" }, + apiBaseUrl: API_BASE, + permissionMode: "full", + allowedProjectIds: ["5"], + fetchConfig: () => ({ headers: CREDENTIAL_HEADERS }), + fetchImpl, + unauthenticatedFetchImpl: fetchImpl, + isTrustedRedirectHost: host => host === "gitlab-alias.example", + }), + (error: unknown) => { + assert.ok(error instanceof GitLabApiRequestInputError); + assert.match(error.message, /Project 9 is not in the allowed project list/); + return true; + } + ); + assert.equal(calls.length, 1); + }); + }); + + describe("with an allowlist and a redirect to the same project on a trusted host", () => { + it("should follow the hop and keep the credential headers", async () => { + const calls: RecordedHop[] = []; + const fetchImpl = redirectFetch( + "https://gitlab-alias.example/api/v4/projects/5/issues", + calls + ); + const result = await executeGitLabApiRequestAsync({ + args: { path: "projects/5" }, + apiBaseUrl: API_BASE, + permissionMode: "full", + allowedProjectIds: ["5"], + fetchConfig: () => ({ headers: CREDENTIAL_HEADERS }), + fetchImpl, + unauthenticatedFetchImpl: fetchImpl, + isTrustedRedirectHost: host => host === "gitlab-alias.example", + }); + + assert.equal(calls[1]?.url, "https://gitlab-alias.example/api/v4/projects/5/issues"); + assert.equal(calls[1]?.headers["Private-Token"], "pat-secret"); + assert.match(result.content[0].text, /followed/); + }); + }); + + describe("with an allowlist and an untrusted redirect to another project API path", () => { + it("should follow the hop without credential headers", async () => { + const calls: RecordedHop[] = []; + const location = `http://${PUBLIC_REDIRECT_HOST}/api/v4/projects/9/issues`; + const fetchImpl = redirectFetch(location, calls); + const result = await executeGitLabApiRequestAsync({ + args: { path: "projects/5" }, + apiBaseUrl: API_BASE, + permissionMode: "full", + allowedProjectIds: ["5"], + fetchConfig: () => ({ headers: CREDENTIAL_HEADERS }), + fetchImpl, + unauthenticatedFetchImpl: fetchImpl, + }); + + assert.equal(calls[1]?.url, location); + assert.equal(calls[1]?.headers["Private-Token"], undefined); + assert.match(result.content[0].text, /followed/); + }); + }); + describe("with a redirect to a private address", () => { it("should refuse the hop before reading a body", async () => { const calls: RecordedHop[] = []; diff --git a/tools/gitlab-api-request.ts b/tools/gitlab-api-request.ts index b2b243b84..d70e556ea 100644 --- a/tools/gitlab-api-request.ts +++ b/tools/gitlab-api-request.ts @@ -133,9 +133,13 @@ function projectAllowed( return false; } -function apiRelativePathFromRedirect(apiBaseUrl: string, target: URL): string | undefined { +function apiRelativePathFromRedirect( + apiBaseUrl: string, + target: URL, + trustedAlternateHost: boolean +): string | undefined { const base = new URL(apiBaseUrl.endsWith("/") ? apiBaseUrl : `${apiBaseUrl}/`); - if (target.origin !== base.origin) { + if (target.origin !== base.origin && !trustedAlternateHost) { return undefined; } const basePrefix = base.pathname.endsWith("/") ? base.pathname : `${base.pathname}/`; @@ -148,12 +152,13 @@ function apiRelativePathFromRedirect(apiBaseUrl: string, target: URL): string | function assertRedirectedApiPathAllowed( apiBaseUrl: string, allowlist: readonly string[], - target: URL + target: URL, + trustedAlternateHost: boolean ): void { if (allowlist.length === 0) { return; } - const relative = apiRelativePathFromRedirect(apiBaseUrl, target); + const relative = apiRelativePathFromRedirect(apiBaseUrl, target, trustedAlternateHost); if (relative === undefined || relative.length === 0) { return; } @@ -306,7 +311,12 @@ export async function executeGitLabApiRequestAsync( unauthenticatedFetchImpl: input.unauthenticatedFetchImpl, isTrustedRedirectHost: input.isTrustedRedirectHost, validateRedirectTarget: target => { - assertRedirectedApiPathAllowed(input.apiBaseUrl, input.allowedProjectIds, target); + assertRedirectedApiPathAllowed( + input.apiBaseUrl, + input.allowedProjectIds, + target, + input.isTrustedRedirectHost?.(target.host) === true + ); }, }); const rawBody = await response.text(); From e0eac70421609c3e59dcb01f0982c7e9b5583734 Mon Sep 17 00:00:00 2001 From: zereight Date: Tue, 13 Oct 2026 11:40:48 +0900 Subject: [PATCH 7/9] fix(api): reject same-origin redirects outside API base path Same-origin redirect hops keep credential headers, so a gitlab_api_request redirect to a non-API path on the GitLab origin (e.g. /users/sign_in) would carry the operator token. Refuse it with GitLabApiRequestInputError regardless of the project allowlist; cross-origin hops are still handled by safe-redirect-fetch. --- test/utils/gitlab-api-request.test.ts | 105 ++++++++++++++++++++++++++ tools/gitlab-api-request.ts | 10 ++- 2 files changed, 114 insertions(+), 1 deletion(-) diff --git a/test/utils/gitlab-api-request.test.ts b/test/utils/gitlab-api-request.test.ts index 0b01c0395..389a7d9cb 100644 --- a/test/utils/gitlab-api-request.test.ts +++ b/test/utils/gitlab-api-request.test.ts @@ -375,6 +375,111 @@ describe("When executing gitlab_api_request", () => { }); }); + describe("with a same-origin redirect outside the API base path", () => { + it("should reject the hop before sending credentials", async () => { + const calls: RecordedHop[] = []; + const fetchImpl = redirectFetch("/users/sign_in", calls); + + await assert.rejects( + () => + executeGitLabApiRequestAsync({ + args: { path: "projects/5" }, + apiBaseUrl: API_BASE, + permissionMode: "full", + allowedProjectIds: [], + fetchConfig: () => ({ headers: CREDENTIAL_HEADERS }), + fetchImpl, + unauthenticatedFetchImpl: fetchImpl, + }), + (error: unknown) => { + assert.ok(error instanceof GitLabApiRequestInputError); + assert.match(error.message, /Redirect target must remain under the GitLab API base path/); + return true; + } + ); + assert.equal(calls.length, 1); + assert.equal(calls[0]?.url, `${API_BASE}/projects/5`); + assert.equal( + calls.some(call => call.url.includes("/users/sign_in")), + false + ); + }); + }); + + describe("with an allowlist and a same-origin redirect outside the API base path", () => { + it("should reject the hop before sending credentials", async () => { + const calls: RecordedHop[] = []; + const fetchImpl = redirectFetch("/users/sign_in", calls); + + await assert.rejects( + () => + executeGitLabApiRequestAsync({ + args: { path: "projects/5" }, + apiBaseUrl: API_BASE, + permissionMode: "full", + allowedProjectIds: ["5"], + fetchConfig: () => ({ headers: CREDENTIAL_HEADERS }), + fetchImpl, + unauthenticatedFetchImpl: fetchImpl, + }), + (error: unknown) => { + assert.ok(error instanceof GitLabApiRequestInputError); + assert.match(error.message, /Redirect target must remain under the GitLab API base path/); + return true; + } + ); + assert.equal(calls.length, 1); + assert.equal(calls[0]?.url, `${API_BASE}/projects/5`); + assert.equal( + calls.some(call => call.url.includes("/users/sign_in")), + false + ); + }); + }); + + describe("with an allowlist and a cross-origin redirect outside the API base path", () => { + it("should follow the hop without credential headers", async () => { + const calls: RecordedHop[] = []; + const location = `http://${PUBLIC_REDIRECT_HOST}/users/sign_in`; + const fetchImpl = redirectFetch(location, calls); + const result = await executeGitLabApiRequestAsync({ + args: { path: "projects/5" }, + apiBaseUrl: API_BASE, + permissionMode: "full", + allowedProjectIds: ["5"], + fetchConfig: () => ({ headers: CREDENTIAL_HEADERS }), + fetchImpl, + unauthenticatedFetchImpl: fetchImpl, + }); + + assert.equal(calls[1]?.url, location); + assert.equal(calls[1]?.headers.Authorization, undefined); + assert.equal(calls[1]?.headers["Private-Token"], undefined); + assert.equal(calls[1]?.headers["JOB-TOKEN"], undefined); + assert.match(result.content[0].text, /followed/); + }); + }); + + describe("with a redirect to the API base path itself", () => { + it("should follow the empty relative path", async () => { + const calls: RecordedHop[] = []; + const fetchImpl = redirectFetch("/api/v4/", calls); + const result = await executeGitLabApiRequestAsync({ + args: { path: "projects/5" }, + apiBaseUrl: API_BASE, + permissionMode: "full", + allowedProjectIds: ["5"], + fetchConfig: () => ({ headers: CREDENTIAL_HEADERS }), + fetchImpl, + unauthenticatedFetchImpl: fetchImpl, + }); + + assert.equal(calls[1]?.url, `${API_BASE}/`); + assert.equal(calls[1]?.headers["Private-Token"], "pat-secret"); + assert.match(result.content[0].text, /followed/); + }); + }); + describe("with an allowlist and a redirect to another project", () => { it("should refuse the hop before requesting it", async () => { const calls: RecordedHop[] = []; diff --git a/tools/gitlab-api-request.ts b/tools/gitlab-api-request.ts index d70e556ea..720fd2bf6 100644 --- a/tools/gitlab-api-request.ts +++ b/tools/gitlab-api-request.ts @@ -155,10 +155,18 @@ function assertRedirectedApiPathAllowed( target: URL, trustedAlternateHost: boolean ): void { + const relative = apiRelativePathFromRedirect(apiBaseUrl, target, trustedAlternateHost); + const apiBase = new URL(apiBaseUrl.endsWith("/") ? apiBaseUrl : `${apiBaseUrl}/`); + // Same-origin hops keep credential headers. A path outside /api/v4 must be refused + // before the allowlist short-circuit, including when the allowlist is empty. + if (relative === undefined && target.origin === apiBase.origin) { + throw new GitLabApiRequestInputError( + "Redirect target must remain under the GitLab API base path" + ); + } if (allowlist.length === 0) { return; } - const relative = apiRelativePathFromRedirect(apiBaseUrl, target, trustedAlternateHost); if (relative === undefined || relative.length === 0) { return; } From 3c60668d4694df2321261e52d5b24926e2617635 Mon Sep 17 00:00:00 2001 From: zereight Date: Tue, 13 Oct 2026 12:23:34 +0900 Subject: [PATCH 8/9] fix(api): reject trusted-host redirects outside API base path Co-authored-by: Cursor --- test/utils/gitlab-api-request.test.ts | 53 +++++++++++++++++++++++++++ tools/gitlab-api-request.ts | 7 ++-- 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/test/utils/gitlab-api-request.test.ts b/test/utils/gitlab-api-request.test.ts index 389a7d9cb..4670c5b84 100644 --- a/test/utils/gitlab-api-request.test.ts +++ b/test/utils/gitlab-api-request.test.ts @@ -579,6 +579,59 @@ describe("When executing gitlab_api_request", () => { }); }); + describe("with a redirect to a sign-in path on a trusted host", () => { + it("should reject the hop before sending credentials", async () => { + const calls: RecordedHop[] = []; + const fetchImpl = redirectFetch("https://gitlab-alias.example/users/sign_in", calls); + + await assert.rejects( + () => + executeGitLabApiRequestAsync({ + args: { path: "projects/5" }, + apiBaseUrl: API_BASE, + permissionMode: "full", + allowedProjectIds: [], + fetchConfig: () => ({ headers: CREDENTIAL_HEADERS }), + fetchImpl, + unauthenticatedFetchImpl: fetchImpl, + isTrustedRedirectHost: host => host === "gitlab-alias.example", + }), + (error: unknown) => { + assert.ok(error instanceof GitLabApiRequestInputError); + assert.match(error.message, /Redirect target must remain under the GitLab API base path/); + return true; + } + ); + assert.equal(calls.length, 1); + assert.equal(calls[0]?.url, `${API_BASE}/projects/5`); + assert.equal( + calls.some(call => call.url.includes("/users/sign_in")), + false + ); + }); + }); + + describe("with an allowlist and a redirect to a project path on a trusted host", () => { + it("should follow the hop and keep the credential headers", async () => { + const calls: RecordedHop[] = []; + const fetchImpl = redirectFetch("https://gitlab-alias.example/api/v4/projects/5", calls); + const result = await executeGitLabApiRequestAsync({ + args: { path: "projects/5" }, + apiBaseUrl: API_BASE, + permissionMode: "full", + allowedProjectIds: ["5"], + fetchConfig: () => ({ headers: CREDENTIAL_HEADERS }), + fetchImpl, + unauthenticatedFetchImpl: fetchImpl, + isTrustedRedirectHost: host => host === "gitlab-alias.example", + }); + + assert.equal(calls[1]?.url, "https://gitlab-alias.example/api/v4/projects/5"); + assert.equal(calls[1]?.headers["Private-Token"], "pat-secret"); + assert.match(result.content[0].text, /followed/); + }); + }); + describe("with an allowlist and an untrusted redirect to another project API path", () => { it("should follow the hop without credential headers", async () => { const calls: RecordedHop[] = []; diff --git a/tools/gitlab-api-request.ts b/tools/gitlab-api-request.ts index 720fd2bf6..c6fa592bb 100644 --- a/tools/gitlab-api-request.ts +++ b/tools/gitlab-api-request.ts @@ -157,9 +157,10 @@ function assertRedirectedApiPathAllowed( ): void { const relative = apiRelativePathFromRedirect(apiBaseUrl, target, trustedAlternateHost); const apiBase = new URL(apiBaseUrl.endsWith("/") ? apiBaseUrl : `${apiBaseUrl}/`); - // Same-origin hops keep credential headers. A path outside /api/v4 must be refused - // before the allowlist short-circuit, including when the allowlist is empty. - if (relative === undefined && target.origin === apiBase.origin) { + // Same-origin hops and trusted alternate hosts keep credential headers. A path + // outside the API base must be refused before the allowlist short-circuit, + // including when the allowlist is empty. + if (relative === undefined && (target.origin === apiBase.origin || trustedAlternateHost)) { throw new GitLabApiRequestInputError( "Redirect target must remain under the GitLab API base path" ); From dce50f789b15037af18ab2de0b1cb51646bdc84c Mon Sep 17 00:00:00 2001 From: zereight Date: Tue, 13 Oct 2026 13:10:12 +0900 Subject: [PATCH 9/9] fix(api): check project allowlist on decoded request path Co-authored-by: Cursor --- test/utils/gitlab-api-request.test.ts | 118 ++++++++++++++++++++++++++ tools/gitlab-api-request.ts | 13 ++- utils/url.ts | 32 +++++-- 3 files changed, 149 insertions(+), 14 deletions(-) diff --git a/test/utils/gitlab-api-request.test.ts b/test/utils/gitlab-api-request.test.ts index 4670c5b84..b43465d90 100644 --- a/test/utils/gitlab-api-request.test.ts +++ b/test/utils/gitlab-api-request.test.ts @@ -230,6 +230,124 @@ describe("When executing gitlab_api_request", () => { }); }); + describe("with a single-encoded projects prefix outside the allowlist", () => { + it("should reject before fetch", async () => { + await assert.rejects( + () => + executeGitLabApiRequestAsync({ + args: { path: "%70rojects/9" }, + apiBaseUrl: API_BASE, + permissionMode: "full", + allowedProjectIds: ["5"], + fetchConfig: () => ({ headers: {} }), + fetchImpl: async () => { + throw new Error("fetch should not run"); + }, + }), + (error: unknown) => { + assert.ok(error instanceof GitLabApiRequestInputError); + assert.match(error.message, /Project 9 is not in the allowed project list/); + return true; + } + ); + }); + }); + + describe("with a double-encoded projects prefix outside the allowlist", () => { + it("should reject before fetch", async () => { + await assert.rejects( + () => + executeGitLabApiRequestAsync({ + args: { path: "%2570rojects/9" }, + apiBaseUrl: API_BASE, + permissionMode: "full", + allowedProjectIds: ["5"], + fetchConfig: () => ({ headers: {} }), + fetchImpl: async () => { + throw new Error("fetch should not run"); + }, + }), + (error: unknown) => { + assert.ok(error instanceof GitLabApiRequestInputError); + assert.match(error.message, /Project 9 is not in the allowed project list/); + return true; + } + ); + }); + }); + + describe("with an allowlist and a redirect to a single-encoded projects prefix", () => { + it("should refuse the hop before requesting it", async () => { + const calls: RecordedHop[] = []; + const fetchImpl = redirectFetch("/api/v4/%70rojects/9", calls); + + await assert.rejects( + () => + executeGitLabApiRequestAsync({ + args: { path: "projects/5" }, + apiBaseUrl: API_BASE, + permissionMode: "full", + allowedProjectIds: ["5"], + fetchConfig: () => ({ headers: {} }), + fetchImpl, + unauthenticatedFetchImpl: fetchImpl, + }), + (error: unknown) => { + assert.ok(error instanceof GitLabApiRequestInputError); + assert.match(error.message, /Project 9 is not in the allowed project list/); + return true; + } + ); + assert.equal(calls.length, 1); + }); + }); + + describe("with an allowlist and a redirect to a double-encoded projects prefix", () => { + it("should refuse the hop before requesting it", async () => { + const calls: RecordedHop[] = []; + const fetchImpl = redirectFetch("/api/v4/%2570rojects/9", calls); + + await assert.rejects( + () => + executeGitLabApiRequestAsync({ + args: { path: "projects/5" }, + apiBaseUrl: API_BASE, + permissionMode: "full", + allowedProjectIds: ["5"], + fetchConfig: () => ({ headers: {} }), + fetchImpl, + unauthenticatedFetchImpl: fetchImpl, + }), + (error: unknown) => { + assert.ok(error instanceof GitLabApiRequestInputError); + assert.match(error.message, /Project 9 is not in the allowed project list/); + return true; + } + ); + assert.equal(calls.length, 1); + }); + }); + + describe("with an encoded project path inside the allowlist", () => { + it("should request that project", async () => { + let requested = ""; + const result = await executeGitLabApiRequestAsync({ + args: { path: "projects/group%2Fproj" }, + apiBaseUrl: API_BASE, + permissionMode: "full", + allowedProjectIds: ["group/proj"], + fetchConfig: () => ({ headers: {} }), + fetchImpl: async url => { + requested = url; + return new UndiciResponse(JSON.stringify({ ok: true }), { status: 200 }); + }, + }); + + assert.equal(requested, `${API_BASE}/projects/group%2Fproj`); + assert.match(result.content[0].text, /"ok": true/); + }); + }); + describe("with a non-JSON success body", () => { it("should redact a plain-text token before returning it", async () => { const result = await executeGitLabApiRequestAsync({ diff --git a/tools/gitlab-api-request.ts b/tools/gitlab-api-request.ts index c6fa592bb..1e202c4ea 100644 --- a/tools/gitlab-api-request.ts +++ b/tools/gitlab-api-request.ts @@ -185,18 +185,17 @@ function assertProjectAllowlist(relativePath: string, allowlist: readonly string if (allowlist.length === 0) { return; } - const match = /^projects\/([^/]+)(?:\/|$)/.exec(relativePath); - if (!match) { - return; - } - const rawSegment = match[1]; - let projectId: string; + let projectId: string | undefined; try { - projectId = readProjectIdFromApiRelativePath(relativePath) ?? rawSegment; + projectId = readProjectIdFromApiRelativePath(relativePath); } catch (error) { const message = error instanceof Error ? error.message : String(error); throw new GitLabApiRequestInputError(message); } + if (projectId === undefined) { + return; + } + const rawSegment = relativePath.split("/")[1] ?? projectId; if (!projectAllowed(projectId, rawSegment, allowlist)) { throw new GitLabApiRequestInputError( `Access denied: Project ${projectId} is not in the allowed project list: ${allowlist.join(", ")}` diff --git a/utils/url.ts b/utils/url.ts index b6fa26119..fe1d62a55 100644 --- a/utils/url.ts +++ b/utils/url.ts @@ -233,15 +233,33 @@ export function resolveGitLabApiRequestUrl(apiBase: string, rawPath: string): UR return resolved; } -/** Project id from `projects/:id` or `projects/:id/...`. Other paths return undefined. */ +function decodeApiPathSegment(segment: string): string { + const decoded = decodeFully(segment); + if (decoded === null) { + throw new Error("GitLab API path has an invalid project id encoding"); + } + return decoded; +} + +/** + * Project id from `projects/:id` or `projects/:id/...`. + * + * Segments are percent-decoded until stable, the same way the outgoing request + * is built. An encoded `projects` prefix still selects this route. A slash + * encoded inside the id (`group%2Fproj`) stays part of that one id. + * Other paths return undefined. + */ export function readProjectIdFromApiRelativePath(relativePath: string): string | undefined { - const match = /^projects\/([^/]+)(?:\/|$)/.exec(relativePath); - if (!match) { + const segments = relativePath.split("/"); + if (segments.length < 2) { return undefined; } - try { - return decodeURIComponent(match[1]); - } catch { - throw new Error("GitLab API path has an invalid project id encoding"); + if (decodeApiPathSegment(segments[0]) !== "projects") { + return undefined; + } + const projectId = decodeApiPathSegment(segments[1]); + if (projectId.length === 0) { + return undefined; } + return projectId; }