security: supply chain hardening — CI pinning, dep pinning, and code fixes (#9801)

CI/CD Hardening:
- Pin all 12 GitHub Actions to full commit SHAs (was mutable @vN tags)
- Add explicit permissions: {contents: read} to 4 workflows
- Pin CI pip installs to exact versions (pyyaml==6.0.2, httpx==0.28.1)
- Extend supply-chain-audit.yml to scan workflow, Dockerfile, dependency
  manifest, and Actions version changes

Dependency Pinning:
- Pin git-based Python deps to commit SHAs (atroposlib, tinker, yc-bench)
- Pin WhatsApp Baileys from mutable branch to commit SHA

Tool Registry:
- Reject tool name shadowing from different tool families (plugins/MCP
  cannot overwrite built-in tools). MCP-to-MCP overwrites still allowed.

MCP Security:
- Add tool description content scanning for prompt injection patterns
- Log detailed change diff on dynamic tool refresh at WARNING level

Skill Manager:
- Fix dangerous verdict bug: agent-created skills with dangerous
  findings were silently allowed (ask->None->allow). Now blocked.
This commit is contained in:
Teknium 2026-04-14 14:23:37 -07:00 committed by GitHub
parent 9bbf7659e9
commit eed891f1bb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 214 additions and 53 deletions

View file

@ -9,11 +9,14 @@ on:
- '**/*.py' - '**/*.py'
- '.github/workflows/contributor-check.yml' - '.github/workflows/contributor-check.yml'
permissions:
contents: read
jobs: jobs:
check-attribution: check-attribution:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with: with:
fetch-depth: 0 # Full history needed for git log fetch-depth: 0 # Full history needed for git log

View file

@ -28,20 +28,20 @@ jobs:
name: github-pages name: github-pages
url: ${{ steps.deploy.outputs.page_url }} url: ${{ steps.deploy.outputs.page_url }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with: with:
node-version: 20 node-version: 20
cache: npm cache: npm
cache-dependency-path: website/package-lock.json cache-dependency-path: website/package-lock.json
- uses: actions/setup-python@v5 - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with: with:
python-version: '3.11' python-version: '3.11'
- name: Install PyYAML for skill extraction - name: Install PyYAML for skill extraction
run: pip install pyyaml httpx run: pip install pyyaml==6.0.2 httpx==0.28.1
- name: Extract skill metadata for dashboard - name: Extract skill metadata for dashboard
run: python3 website/scripts/extract-skills.py run: python3 website/scripts/extract-skills.py
@ -73,10 +73,10 @@ jobs:
echo "hermes-agent.nousresearch.com" > _site/CNAME echo "hermes-agent.nousresearch.com" > _site/CNAME
- name: Upload artifact - name: Upload artifact
uses: actions/upload-pages-artifact@v3 uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3
with: with:
path: _site path: _site
- name: Deploy to GitHub Pages - name: Deploy to GitHub Pages
id: deploy id: deploy
uses: actions/deploy-pages@v4 uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4

View file

@ -23,21 +23,21 @@ jobs:
timeout-minutes: 60 timeout-minutes: 60
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with: with:
submodules: recursive submodules: recursive
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
# Build amd64 only so we can `load` the image for smoke testing. # Build amd64 only so we can `load` the image for smoke testing.
# `load: true` cannot export a multi-arch manifest to the local daemon. # `load: true` cannot export a multi-arch manifest to the local daemon.
# The multi-arch build follows on push to main / release. # The multi-arch build follows on push to main / release.
- name: Build image (amd64, smoke test) - name: Build image (amd64, smoke test)
uses: docker/build-push-action@v6 uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
with: with:
context: . context: .
file: Dockerfile file: Dockerfile
@ -56,14 +56,14 @@ jobs:
- name: Log in to Docker Hub - name: Log in to Docker Hub
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release' if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
uses: docker/login-action@v3 uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Push multi-arch image (main branch) - name: Push multi-arch image (main branch)
if: github.event_name == 'push' && github.ref == 'refs/heads/main' if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: docker/build-push-action@v6 uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
with: with:
context: . context: .
file: Dockerfile file: Dockerfile
@ -75,7 +75,7 @@ jobs:
- name: Push multi-arch image (release) - name: Push multi-arch image (release)
if: github.event_name == 'release' if: github.event_name == 'release'
uses: docker/build-push-action@v6 uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
with: with:
context: . context: .
file: Dockerfile file: Dockerfile

View file

@ -7,13 +7,16 @@ on:
- '.github/workflows/docs-site-checks.yml' - '.github/workflows/docs-site-checks.yml'
workflow_dispatch: workflow_dispatch:
permissions:
contents: read
jobs: jobs:
docs-site-checks: docs-site-checks:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with: with:
node-version: 20 node-version: 20
cache: npm cache: npm
@ -23,7 +26,7 @@ jobs:
run: npm ci run: npm ci
working-directory: website working-directory: website
- uses: actions/setup-python@v5 - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with: with:
python-version: '3.11' python-version: '3.11'

View file

@ -14,6 +14,9 @@ on:
- 'run_agent.py' - 'run_agent.py'
- 'acp_adapter/**' - 'acp_adapter/**'
permissions:
contents: read
concurrency: concurrency:
group: nix-${{ github.ref }} group: nix-${{ github.ref }}
cancel-in-progress: true cancel-in-progress: true
@ -26,7 +29,7 @@ jobs:
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
timeout-minutes: 30 timeout-minutes: 30
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: DeterminateSystems/nix-installer-action@ef8a148080ab6020fd15196c2084a2eea5ff2d25 # v22 - uses: DeterminateSystems/nix-installer-action@ef8a148080ab6020fd15196c2084a2eea5ff2d25 # v22
- uses: DeterminateSystems/magic-nix-cache-action@565684385bcd71bad329742eefe8d12f2e765b39 # v13 - uses: DeterminateSystems/magic-nix-cache-action@565684385bcd71bad329742eefe8d12f2e765b39 # v13
- name: Check flake - name: Check flake

View file

@ -20,14 +20,14 @@ jobs:
if: github.repository == 'NousResearch/hermes-agent' if: github.repository == 'NousResearch/hermes-agent'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-python@v5 - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with: with:
python-version: '3.11' python-version: '3.11'
- name: Install dependencies - name: Install dependencies
run: pip install httpx pyyaml run: pip install httpx==0.28.1 pyyaml==6.0.2
- name: Build skills index - name: Build skills index
env: env:
@ -35,7 +35,7 @@ jobs:
run: python scripts/build_skills_index.py run: python scripts/build_skills_index.py
- name: Upload index artifact - name: Upload index artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with: with:
name: skills-index name: skills-index
path: website/static/api/skills-index.json path: website/static/api/skills-index.json
@ -53,25 +53,25 @@ jobs:
# Only deploy on schedule or manual trigger (not on every push to the script) # Only deploy on schedule or manual trigger (not on every push to the script)
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/download-artifact@v4 - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with: with:
name: skills-index name: skills-index
path: website/static/api/ path: website/static/api/
- uses: actions/setup-node@v4 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with: with:
node-version: 20 node-version: 20
cache: npm cache: npm
cache-dependency-path: website/package-lock.json cache-dependency-path: website/package-lock.json
- uses: actions/setup-python@v5 - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with: with:
python-version: '3.11' python-version: '3.11'
- name: Install PyYAML for skill extraction - name: Install PyYAML for skill extraction
run: pip install pyyaml run: pip install pyyaml==6.0.2
- name: Extract skill metadata for dashboard - name: Extract skill metadata for dashboard
run: python3 website/scripts/extract-skills.py run: python3 website/scripts/extract-skills.py
@ -92,10 +92,10 @@ jobs:
echo "hermes-agent.nousresearch.com" > _site/CNAME echo "hermes-agent.nousresearch.com" > _site/CNAME
- name: Upload artifact - name: Upload artifact
uses: actions/upload-pages-artifact@v3 uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3
with: with:
path: _site path: _site
- name: Deploy to GitHub Pages - name: Deploy to GitHub Pages
id: deploy id: deploy
uses: actions/deploy-pages@v4 uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4

View file

@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with: with:
fetch-depth: 0 fetch-depth: 0
@ -149,6 +149,62 @@ jobs:
" "
fi fi
# --- CI/CD workflow files modified ---
WORKFLOW_HITS=$(git diff --name-only "$BASE".."$HEAD" | grep -E '\.github/workflows/.*\.ya?ml$' || true)
if [ -n "$WORKFLOW_HITS" ]; then
FINDINGS="${FINDINGS}
### ⚠️ WARNING: CI/CD workflow files modified
Changes to workflow files can alter build pipelines, inject steps, or modify permissions. Verify no unauthorized actions or secrets access were added.
**Files:**
\`\`\`
${WORKFLOW_HITS}
\`\`\`
"
fi
# --- Dockerfile / container build files modified ---
DOCKER_HITS=$(git diff --name-only "$BASE".."$HEAD" | grep -iE '(Dockerfile|\.dockerignore|docker-compose)' || true)
if [ -n "$DOCKER_HITS" ]; then
FINDINGS="${FINDINGS}
### ⚠️ WARNING: Container build files modified
Changes to Dockerfiles or compose files can alter base images, add build steps, or expose ports. Verify base image pins and build commands.
**Files:**
\`\`\`
${DOCKER_HITS}
\`\`\`
"
fi
# --- Dependency manifest files modified ---
DEP_HITS=$(git diff --name-only "$BASE".."$HEAD" | grep -E '(pyproject\.toml|requirements.*\.txt|package\.json|Gemfile|go\.mod|Cargo\.toml)$' || true)
if [ -n "$DEP_HITS" ]; then
FINDINGS="${FINDINGS}
### ⚠️ WARNING: Dependency manifest files modified
Changes to dependency files can introduce new packages or change version pins. Verify all dependency changes are intentional and from trusted sources.
**Files:**
\`\`\`
${DEP_HITS}
\`\`\`
"
fi
# --- GitHub Actions version unpinning (mutable tags instead of SHAs) ---
ACTIONS_UNPIN=$(echo "$DIFF" | grep -n '^\+' | grep 'uses:' | grep -v '#' | grep -E '@v[0-9]' | head -10 || true)
if [ -n "$ACTIONS_UNPIN" ]; then
FINDINGS="${FINDINGS}
### ⚠️ WARNING: GitHub Actions with mutable version tags
Actions should be pinned to full commit SHAs (not \`@v4\`, \`@v5\`). Mutable tags can be retargeted silently if a maintainer account is compromised.
**Matches:**
\`\`\`
${ACTIONS_UNPIN}
\`\`\`
"
fi
# --- Output results --- # --- Output results ---
if [ -n "$FINDINGS" ]; then if [ -n "$FINDINGS" ]; then
echo "found=true" >> "$GITHUB_OUTPUT" echo "found=true" >> "$GITHUB_OUTPUT"

View file

@ -6,6 +6,9 @@ on:
pull_request: pull_request:
branches: [main] branches: [main]
permissions:
contents: read
# Cancel in-progress runs for the same PR/branch # Cancel in-progress runs for the same PR/branch
concurrency: concurrency:
group: tests-${{ github.ref }} group: tests-${{ github.ref }}
@ -17,13 +20,13 @@ jobs:
timeout-minutes: 10 timeout-minutes: 10
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Install system dependencies - name: Install system dependencies
run: sudo apt-get update && sudo apt-get install -y ripgrep run: sudo apt-get update && sudo apt-get install -y ripgrep
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@v5 uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
- name: Set up Python 3.11 - name: Set up Python 3.11
run: uv python install 3.11 run: uv python install 3.11
@ -49,10 +52,10 @@ jobs:
timeout-minutes: 10 timeout-minutes: 10
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@v5 uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
- name: Set up Python 3.11 - name: Set up Python 3.11
run: uv python install 3.11 run: uv python install 3.11

View file

@ -78,13 +78,13 @@ dingtalk = ["dingtalk-stream>=0.1.0,<1"]
feishu = ["lark-oapi>=1.5.3,<2"] feishu = ["lark-oapi>=1.5.3,<2"]
web = ["fastapi>=0.104.0,<1", "uvicorn[standard]>=0.24.0,<1"] web = ["fastapi>=0.104.0,<1", "uvicorn[standard]>=0.24.0,<1"]
rl = [ rl = [
"atroposlib @ git+https://github.com/NousResearch/atropos.git", "atroposlib @ git+https://github.com/NousResearch/atropos.git@c20c85256e5a45ad31edf8b7276e9c5ee1995a30",
"tinker @ git+https://github.com/thinking-machines-lab/tinker.git", "tinker @ git+https://github.com/thinking-machines-lab/tinker.git@30517b667f18a3dfb7ef33fb56cf686d5820ba2b",
"fastapi>=0.104.0,<1", "fastapi>=0.104.0,<1",
"uvicorn[standard]>=0.24.0,<1", "uvicorn[standard]>=0.24.0,<1",
"wandb>=0.15.0,<1", "wandb>=0.15.0,<1",
] ]
yc-bench = ["yc-bench @ git+https://github.com/collinear-ai/yc-bench.git ; python_version >= '3.12'"] yc-bench = ["yc-bench @ git+https://github.com/collinear-ai/yc-bench.git@bfb0c88062450f46341bd9a5298903fc2e952a5c ; python_version >= '3.12'"]
all = [ all = [
"hermes-agent[modal]", "hermes-agent[modal]",
"hermes-agent[daytona]", "hermes-agent[daytona]",

View file

@ -8,7 +8,7 @@
"start": "node bridge.js" "start": "node bridge.js"
}, },
"dependencies": { "dependencies": {
"@whiskeysockets/baileys": "WhiskeySockets/Baileys#fix/abprops-abt-fetch", "@whiskeysockets/baileys": "WhiskeySockets/Baileys#01047debd81beb20da7b7779b08edcb06aa03770",
"express": "^4.21.0", "express": "^4.21.0",
"qrcode-terminal": "^0.12.0", "qrcode-terminal": "^0.12.0",
"pino": "^9.0.0" "pino": "^9.0.0"

View file

@ -2837,7 +2837,7 @@ class TestRegistryCollisionWarning:
"""registry.register() warns when a tool name is overwritten by a different toolset.""" """registry.register() warns when a tool name is overwritten by a different toolset."""
def test_overwrite_different_toolset_logs_warning(self, caplog): def test_overwrite_different_toolset_logs_warning(self, caplog):
"""Overwriting a tool from a different toolset emits a warning.""" """Overwriting a tool from a different toolset is REJECTED with an error."""
from tools.registry import ToolRegistry from tools.registry import ToolRegistry
import logging import logging
@ -2847,11 +2847,13 @@ class TestRegistryCollisionWarning:
reg.register(name="my_tool", toolset="builtin", schema=schema, handler=handler) reg.register(name="my_tool", toolset="builtin", schema=schema, handler=handler)
with caplog.at_level(logging.WARNING, logger="tools.registry"): with caplog.at_level(logging.ERROR, logger="tools.registry"):
reg.register(name="my_tool", toolset="mcp-ext", schema=schema, handler=handler) reg.register(name="my_tool", toolset="mcp-ext", schema=schema, handler=handler)
assert any("collision" in r.message.lower() for r in caplog.records) assert any("rejected" in r.message.lower() for r in caplog.records)
assert any("builtin" in r.message and "mcp-ext" in r.message for r in caplog.records) assert any("builtin" in r.message and "mcp-ext" in r.message for r in caplog.records)
# The original tool should still be from 'builtin', not overwritten
assert reg.get_toolset_for_tool("my_tool") == "builtin"
def test_overwrite_same_toolset_no_warning(self, caplog): def test_overwrite_same_toolset_no_warning(self, caplog):
"""Re-registering within the same toolset is silent (e.g. reconnect).""" """Re-registering within the same toolset is silent (e.g. reconnect)."""

View file

@ -219,6 +219,58 @@ def _sanitize_error(text: str) -> str:
return _CREDENTIAL_PATTERN.sub("[REDACTED]", text) return _CREDENTIAL_PATTERN.sub("[REDACTED]", text)
# ---------------------------------------------------------------------------
# MCP tool description content scanning
# ---------------------------------------------------------------------------
# Patterns that indicate potential prompt injection in MCP tool descriptions.
# These are WARNING-level — we log but don't block, since false positives
# would break legitimate MCP servers.
_MCP_INJECTION_PATTERNS = [
(re.compile(r"ignore\s+(all\s+)?previous\s+instructions", re.I),
"prompt override attempt ('ignore previous instructions')"),
(re.compile(r"you\s+are\s+now\s+a", re.I),
"identity override attempt ('you are now a...')"),
(re.compile(r"your\s+new\s+(task|role|instructions?)\s+(is|are)", re.I),
"task override attempt"),
(re.compile(r"system\s*:\s*", re.I),
"system prompt injection attempt"),
(re.compile(r"<\s*(system|human|assistant)\s*>", re.I),
"role tag injection attempt"),
(re.compile(r"do\s+not\s+(tell|inform|mention|reveal)", re.I),
"concealment instruction"),
(re.compile(r"(curl|wget|fetch)\s+https?://", re.I),
"network command in description"),
(re.compile(r"base64\.(b64decode|decodebytes)", re.I),
"base64 decode reference"),
(re.compile(r"exec\s*\(|eval\s*\(", re.I),
"code execution reference"),
(re.compile(r"import\s+(subprocess|os|shutil|socket)", re.I),
"dangerous import reference"),
]
def _scan_mcp_description(server_name: str, tool_name: str, description: str) -> List[str]:
"""Scan an MCP tool description for prompt injection patterns.
Returns a list of finding strings (empty = clean).
"""
findings = []
if not description:
return findings
for pattern, reason in _MCP_INJECTION_PATTERNS:
if pattern.search(description):
findings.append(reason)
if findings:
logger.warning(
"MCP server '%s' tool '%s': suspicious description content — %s. "
"Description: %.200s",
server_name, tool_name, "; ".join(findings),
description,
)
return findings
def _prepend_path(env: dict, directory: str) -> dict: def _prepend_path(env: dict, directory: str) -> dict:
"""Prepend *directory* to env PATH if it is not already present.""" """Prepend *directory* to env PATH if it is not already present."""
updated = dict(env or {}) updated = dict(env or {})
@ -798,6 +850,9 @@ class MCPServerTask:
from toolsets import TOOLSETS from toolsets import TOOLSETS
async with self._refresh_lock: async with self._refresh_lock:
# Capture old tool names for change diff
old_tool_names = set(self._registered_tool_names)
# 1. Fetch current tool list from server # 1. Fetch current tool list from server
tools_result = await self.session.list_tools() tools_result = await self.session.list_tools()
new_mcp_tools = tools_result.tools if hasattr(tools_result, "tools") else [] new_mcp_tools = tools_result.tools if hasattr(tools_result, "tools") else []
@ -817,10 +872,26 @@ class MCPServerTask:
self.name, self, self._config self.name, self, self._config
) )
logger.info( # 5. Log what changed (user-visible notification)
"MCP server '%s': dynamically refreshed %d tool(s)", new_tool_names = set(self._registered_tool_names)
self.name, len(self._registered_tool_names), added = new_tool_names - old_tool_names
) removed = old_tool_names - new_tool_names
changes = []
if added:
changes.append(f"added: {', '.join(sorted(added))}")
if removed:
changes.append(f"removed: {', '.join(sorted(removed))}")
if changes:
logger.warning(
"MCP server '%s': tools changed dynamically — %s. "
"Verify these changes are expected.",
self.name, "; ".join(changes),
)
else:
logger.info(
"MCP server '%s': dynamically refreshed %d tool(s) (no changes)",
self.name, len(self._registered_tool_names),
)
async def _run_stdio(self, config: dict): async def _run_stdio(self, config: dict):
"""Run the server using stdio transport.""" """Run the server using stdio transport."""
@ -1838,6 +1909,10 @@ def _register_server_tools(name: str, server: MCPServerTask, config: dict) -> Li
if not _should_register(mcp_tool.name): if not _should_register(mcp_tool.name):
logger.debug("MCP server '%s': skipping tool '%s' (filtered by config)", name, mcp_tool.name) logger.debug("MCP server '%s': skipping tool '%s' (filtered by config)", name, mcp_tool.name)
continue continue
# Scan tool description for prompt injection patterns
_scan_mcp_description(name, mcp_tool.name, mcp_tool.description or "")
schema = _convert_mcp_schema(name, mcp_tool) schema = _convert_mcp_schema(name, mcp_tool)
tool_name_prefixed = schema["name"] tool_name_prefixed = schema["name"]

View file

@ -117,11 +117,27 @@ class ToolRegistry:
with self._lock: with self._lock:
existing = self._tools.get(name) existing = self._tools.get(name)
if existing and existing.toolset != toolset: if existing and existing.toolset != toolset:
logger.warning( # Allow MCP-to-MCP overwrites (legitimate: server refresh,
"Tool name collision: '%s' (toolset '%s') is being " # or two MCP servers with overlapping tool names).
"overwritten by toolset '%s'", both_mcp = (
name, existing.toolset, toolset, existing.toolset.startswith("mcp-")
and toolset.startswith("mcp-")
) )
if both_mcp:
logger.debug(
"Tool '%s': MCP toolset '%s' overwriting MCP toolset '%s'",
name, toolset, existing.toolset,
)
else:
# Reject shadowing — prevent plugins/MCP from overwriting
# built-in tools or vice versa.
logger.error(
"Tool registration REJECTED: '%s' (toolset '%s') would "
"shadow existing tool from toolset '%s'. Deregister the "
"existing tool first if this is intentional.",
name, toolset, existing.toolset,
)
return
self._tools[name] = ToolEntry( self._tools[name] = ToolEntry(
name=name, name=name,
toolset=toolset, toolset=toolset,

View file

@ -64,11 +64,11 @@ def _security_scan_skill(skill_dir: Path) -> Optional[str]:
report = format_scan_report(result) report = format_scan_report(result)
return f"Security scan blocked this skill ({reason}):\n{report}" return f"Security scan blocked this skill ({reason}):\n{report}"
if allowed is None: if allowed is None:
# "ask" — allow but include the warning so the user sees the findings # "ask" verdict — for agent-created skills this means dangerous
# findings were detected. Block the skill and include the report.
report = format_scan_report(result) report = format_scan_report(result)
logger.warning("Agent-created skill has security findings: %s", reason) logger.warning("Agent-created skill blocked (dangerous findings): %s", reason)
# Don't block — return None to allow, but log the warning return f"Security scan blocked this skill ({reason}):\n{report}"
return None
except Exception as e: except Exception as e:
logger.warning("Security scan failed for %s: %s", skill_dir, e, exc_info=True) logger.warning("Security scan failed for %s: %s", skill_dir, e, exc_info=True)
return None return None