# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Hardened subprocess helper for agent CLI providers (claude, codex, gemini). This is the single security chokepoint for all agent-CLI calls. Per-CLI knowledge (argv, output parsing, auth check) lives in a small ``CliSpec`` registry (see ``_REGISTRY`` / "HOW TO ADD A NEW AGENT CLI" below); the security core is CLI-agnostic. Every call goes through :func:`run_agent_cli` which enforces: - **No shell**: ``shell=False`` with an explicit argv list. - **Untrusted content via stdin only**: the prompt (which may contain adversarial skill content) is written to the process stdin, never injected into argv. - **Capability stripping** (per-binary): tools disabled, MCP disabled, no extra directories, deny permission mode (claude); read-only sandbox (codex). ``--dangerously-skip-permissions`` is NEVER used. - **Environment scrubbing**: API keys, SSH keys, cloud credentials, and other secrets are stripped from the child environment. - **Timeout enforcement**: the call raises ``TimeoutError`` rather than hanging indefinitely. - **Input / output caps**: prompt exceeding ``MAX_INPUT_BYTES`` is rejected; stdout is capped at ``MAX_OUTPUT_BYTES``. - **Fail-closed**: non-zero exit, timeout, missing binary, or bad output all raise ``AgentCLIError``. - **Prompt-layer hardening**: the caller wraps untrusted content in clear DATA delimiters before passing it here (defense-in-depth on top of capability removal). The JSON output envelope (``claude -p --output-format json``) is parsed and the assistant text is returned. ``codex exec --json`` produces JSONL events; the last assistant message is extracted. """ from __future__ import annotations import json import os import shutil import subprocess import tempfile import threading from collections.abc import Callable from dataclasses import dataclass from typing import Any from skillspector.logging_config import get_logger logger = get_logger(__name__) # --------------------------------------------------------------------------- # Constants # --------------------------------------------------------------------------- # Reuse the same cap as static_runner so a skill that's too big for static # analysis is also too big to send to the CLI. MAX_INPUT_BYTES = 1_000_000 # 1 MB — mirrors MAX_FILE_BYTES in static_runner.py MAX_OUTPUT_BYTES = 10_000_000 # 10 MB safety cap on stdout MAX_STDERR_BYTES = 64_000 # stderr is only used for error snippets CLI_TIMEOUT_SECONDS = 300 # 5-minute per-call hard limit # Environment variables that must NOT be forwarded to child processes. # Includes API keys, cloud creds, SSH agent, and SkillSpector's own keys. _SECRET_ENV_PREFIXES: tuple[str, ...] = ( "ANTHROPIC_API_KEY", "OPENAI_API_KEY", "NVIDIA_INFERENCE_KEY", "NVIDIA_INFERENCE_METADATA_KEY", "AWS_", "AZURE_", "GOOGLE_", "GCLOUD_", "GCP_", "SSH_", "GPG_", "GITHUB_TOKEN", "GITLAB_TOKEN", "HUGGINGFACE_TOKEN", "HF_TOKEN", "COHERE_API_KEY", "REPLICATE_API_TOKEN", "MISTRAL_API_KEY", "TOGETHER_API_KEY", "GROQ_API_KEY", "FIREWORKS_API_KEY", "LANGCHAIN_API_KEY", "LANGSMITH_API_KEY", ) class AgentCLIError(RuntimeError): """Raised when an agent CLI call fails for any reason (fail-closed).""" # --------------------------------------------------------------------------- # Environment scrubbing # --------------------------------------------------------------------------- def _scrub_env() -> dict[str, str]: """Return a copy of ``os.environ`` with secret variables removed. Any variable whose name starts with a prefix in ``_SECRET_ENV_PREFIXES`` is stripped. The resulting environment is passed to the subprocess. """ clean: dict[str, str] = {} for key, val in os.environ.items(): upper = key.upper() if any(upper.startswith(p.upper()) for p in _SECRET_ENV_PREFIXES): continue clean[key] = val return clean # --------------------------------------------------------------------------- # Binary lookup # --------------------------------------------------------------------------- def find_binary(name: str) -> str | None: """Return the absolute path of *name* on PATH, or ``None`` if absent.""" return shutil.which(name) # --------------------------------------------------------------------------- # Argument validation # --------------------------------------------------------------------------- def _validate_model_label(model: str) -> str: """Ensure *model* cannot be used as an argument injection vector. Model labels come from ``SKILLSPECTOR_MODEL`` (user-controlled) or the provider's defaults. We verify the label does not start with ``-`` (which would look like a flag to the CLI) and contains only safe characters. Raises: AgentCLIError: when the label fails validation. """ if not model: raise AgentCLIError("model label must be a non-empty string") if model.startswith("-"): raise AgentCLIError( f"model label {model!r} starts with '-'; this looks like an argument injection attempt" ) # Allow alphanumeric, dash, dot, slash, colon, underscore (covers all # known claude/codex model identifiers). allowed = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-./: _") bad = [c for c in model if c not in allowed] if bad: raise AgentCLIError(f"model label {model!r} contains disallowed characters: {bad!r}") return model # --------------------------------------------------------------------------- # Claude CLI invocation # --------------------------------------------------------------------------- def _build_claude_argv(binary: str, model: str, max_output_tokens: int) -> list[str]: """Build the argv list for a capability-stripped ``claude -p`` call. ``-p`` / ``--print`` Non-interactive single-shot mode. The prompt is read from stdin; the response is written to stdout and the process exits. ``--output-format text`` Emit the assistant's response as plain text — nothing else. This is the most stable format the claude CLI offers: it has been the canonical headless contract since ``-p`` was introduced, predates the JSON envelope formats, and is unaffected by changes to the event-stream schema. The envelope formats (``json`` / ``stream-json``) have changed shape across builds (single dict → JSON array → JSONL); ``text`` never has. Because we need only the response text and not the metadata (session ID, stop reason, etc.) that the envelope carries, ``text`` is the right choice here: the format we request defines exactly what we parse, with no version detection and no fallbacks. ``--model