diff --git a/.env.example b/.env.example index ed663d03e..f97e07a19 100644 --- a/.env.example +++ b/.env.example @@ -4,7 +4,7 @@ ENV=dev # options: dev|s # metadata, and default-model lookups. Leave unset to default to nv_build. # Options: openai | anthropic | anthropic_proxy | bedrock | nv_build | # ollama | azure_openai | openai_compatible | gemini | claude_cli | -# gemini_cli | opencode_cli +# copilot_cli | gemini_cli | opencode_cli # codex_cli is registered but disabled: its read-only sandbox permits host-file reads. SKILLSPECTOR_PROVIDER= @@ -78,7 +78,7 @@ AZURE_OPENAI_ENDPOINT= SKILLSPECTOR_COMPAT_API_KEY= SKILLSPECTOR_COMPAT_BASE_URL= -# claude_cli, gemini_cli, and opencode_cli use their CLI's existing local +# claude_cli, copilot_cli, gemini_cli, and opencode_cli use their CLI's existing local # authentication session and do not need an API key here. # SkillSpector config diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0cf005ea2..c4e49c029 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,6 +19,34 @@ by the [Developer Certificate of Origin](#developer-certificate-of-origin). - New analyzers should include corresponding unit tests and, where applicable, test fixtures. +## Provider CLI Validation Expectations + +A new or re-verified agent-CLI provider scans untrusted skills, so its +safety boundary must be enforcement, not documentation. Proposals that +only describe a gap without closing it will be sent back. Concretely: + +- **Exact-version preflight before stdin, on every completion path.** + Pin the verified CLI release and re-check it immediately before each + inference call — not just in the availability probe, which direct + `complete()` calls never touch. A synthetic future-version binary must + be rejected before any prompt bytes move (test this). +- **No hook material, no hooks.** User/plugin lifecycle hooks usually + have no argv off-switch. Where home isolation is usable, redirect the + CLI's config, plugin, and hook directories to per-invocation temp + dirs; where the CLI refuses to run isolated (probed and documented), + refuse inference when hook-capable material such as a non-empty + `installed-plugins/` tree is present instead. Either way, carry over + the minimum auth material and never a whole home directory. +- **Adversarial fake-host tests proving zero side effects.** A fake + binary asserting the exact argv posture plus a marker for any executed + tool, hook, or weakened env var; empty marker directory or the test + fails. +- **Synthetic-version gate tests.** A fake binary reporting an + unverified version must fail closed, including stdin-never-delivered + where the transport allows asserting it. +- **No silent fallbacks.** Unknown agents, unparseable versions, missing + auth, and empty output all raise — never degrade to a weaker policy. + ## Commit Sign-Off All contributions must include a `Signed-off-by` line in the commit message, diff --git a/README.md b/README.md index 40fe48c16..401fdde79 100644 --- a/README.md +++ b/README.md @@ -297,6 +297,7 @@ inference gateways. | `openai_compatible` | `SKILLSPECTOR_COMPAT_API_KEY` + `SKILLSPECTOR_COMPAT_BASE_URL` | Any OpenAI-compatible endpoint | `llama-3.1-70b-versatile` | | `claude_cli` | _(none — uses local CLI auth)_ | local `claude` binary | local Claude runtime fallback, or `SKILLSPECTOR_MODEL` | | `codex_cli` | Disabled | Registered for compatibility; its read-only sandbox permits host-file reads | Use an HTTP API provider or another supported CLI provider | +| `copilot_cli` | _(none — uses local CLI auth)_ | local `copilot` 1.0.95 binary | local Copilot runtime fallback, or `SKILLSPECTOR_MODEL` | | `gemini_cli` | _(none — uses local CLI auth)_ | local `gemini` binary | local Gemini runtime fallback, or `SKILLSPECTOR_MODEL` | | `opencode_cli` | _(none — uses local CLI auth)_ | local `opencode` 1.18.33 binary | local OpenCode runtime fallback, or `SKILLSPECTOR_MODEL` | @@ -719,7 +720,7 @@ Issues (2) | Variable | Description | Required | |----------|-------------|----------| -| `SKILLSPECTOR_PROVIDER` | Active LLM provider: `openai`, `anthropic`, `anthropic_proxy`, `bedrock`, `nv_build`, `gemini`, `ollama`, `azure_openai`, `openai_compatible`, `claude_cli`, `gemini_cli`, or `opencode_cli`. Hosted providers use bundled `model_registry.yaml` defaults; CLI providers fall back to the local runtime's default model unless `SKILLSPECTOR_MODEL` is set. Defaults to `nv_build`. | Optional | +| `SKILLSPECTOR_PROVIDER` | Active LLM provider: `openai`, `anthropic`, `anthropic_proxy`, `bedrock`, `nv_build`, `gemini`, `ollama`, `azure_openai`, `openai_compatible`, `claude_cli`, `copilot_cli`, `gemini_cli`, or `opencode_cli`. Hosted providers use bundled `model_registry.yaml` defaults; CLI providers fall back to the local runtime's default model unless `SKILLSPECTOR_MODEL` is set. Defaults to `nv_build`. | Optional | | `GOOGLE_CLOUD_PROJECT` | Google Cloud project ID for the `gemini` provider. Authenticates via Google Cloud Application Default Credentials (ADC) or GKE Workload Identity. | Required for LLM analysis when `SKILLSPECTOR_PROVIDER=gemini` | | `GOOGLE_CLOUD_LOCATION` | Google Cloud location for the `gemini` provider endpoint (e.g. `global`, `us`, `eu`, `us-central1`). Defaults to `global`. | Optional (used when `SKILLSPECTOR_PROVIDER=gemini`) | | `GOOGLE_APPLICATION_CREDENTIALS` | Optional path to ADC credential/config file (e.g. Workload or Workforce Identity Federation config; exported service account keys are discouraged). For local development, use `gcloud auth application-default login`; for GKE, use Workload Identity. | Optional (used when `SKILLSPECTOR_PROVIDER=gemini`) | @@ -753,9 +754,13 @@ Issues (2) > **Disabled provider:** `codex_cli` remains registered but cannot run LLM analysis because its read-only sandbox allows host-file reads. Existing users should select an HTTP API provider or another supported CLI provider. -> **CLI providers** (`claude_cli`, `gemini_cli`, `opencode_cli`): No API key is needed. Authentication is managed entirely by the agent CLI's own login session. SkillSpector never reads or forwards API keys when these providers are active. The subprocess is run with capabilities restricted, and untrusted skill content is delivered only via stdin. +> **CLI providers** (`claude_cli`, `copilot_cli`, `gemini_cli`, `opencode_cli`): No API key is needed. Authentication is managed entirely by the agent CLI's own login session. SkillSpector never reads or forwards API keys when these providers are active, except `copilot_cli` deliberately preserves only `COPILOT_GITHUB_TOKEN` (its documented headless auth) while dropping every other `COPILOT_*`, all `GITHUB_COPILOT_*`, and the `GH_TOKEN` family. The subprocess is run with capabilities restricted, and untrusted skill content is delivered only via stdin. > > `opencode_cli` currently fails closed unless the installed OpenCode version is exactly `1.18.33`, the version whose configuration precedence and deny-all semantics are verified by this release. +> +> `copilot_cli` currently fails closed unless the installed Copilot CLI version is exactly `1.0.95`, the version whose tool-deny behavior is verified by this release. +> +> Residual: Copilot CLI falls back to `gh auth token` when no other credential is available, so a stored gh login can still reach the child. This is inherent to Copilot auth. ### CLI Options diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 690e7fe05..ae95a61df 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -34,7 +34,7 @@ make install-dev - **Python**: 3.12+ (see [pyproject.toml](../pyproject.toml)). `make install` and `make install-dev` use **uv** if available (`uv sync` / `uv sync --all-extras`), otherwise **pip** (`pip install -e .` / `pip install -e ".[dev]"`). You must create and activate the virtual environment yourself before running any make target. - **Environment**: Optional `.env` in the project root. The LangGraph dev server loads it (see [langgraph.json](../langgraph.json) `"env": ".env"`). Key variables: - - **`SKILLSPECTOR_PROVIDER`**: Selects the active LLM provider — `openai`, `anthropic`, `anthropic_proxy`, `bedrock`, `nv_build`, `ollama`, `azure_openai`, `openai_compatible`, `claude_cli`, `gemini_cli`, or `opencode_cli`. Defaults to `nv_build` when unset. + - **`SKILLSPECTOR_PROVIDER`**: Selects the active LLM provider — `openai`, `anthropic`, `anthropic_proxy`, `bedrock`, `nv_build`, `ollama`, `azure_openai`, `openai_compatible`, `claude_cli`, `copilot_cli`, `gemini_cli`, or `opencode_cli`. Defaults to `nv_build` when unset. - **Provider credential**: depends on the active provider. Hosted providers use the matching variables in [.env.example](../.env.example); Ollama and CLI providers do not require an API key. See [providers/](../src/skillspector/providers/). - **`OPENAI_BASE_URL`**: Override the OpenAI endpoint (e.g. point at Ollama). - **`SKILLSPECTOR_MODEL`**: Override default model; see [constants.py](../src/skillspector/constants.py). @@ -296,7 +296,7 @@ Copy [.env.example](../.env.example) to `.env` in the project root and set value | Variable | Description | Example | |----------|-------------|---------| -| `SKILLSPECTOR_PROVIDER` | Active LLM provider: `openai` \| `anthropic` \| `anthropic_proxy` \| `bedrock` \| `nv_build` \| `ollama` \| `azure_openai` \| `openai_compatible` \| `claude_cli` \| `gemini_cli` \| `opencode_cli`. Defaults to `nv_build`. | `claude_cli` | +| `SKILLSPECTOR_PROVIDER` | Active LLM provider: `openai` \| `anthropic` \| `anthropic_proxy` \| `bedrock` \| `nv_build` \| `ollama` \| `azure_openai` \| `openai_compatible` \| `claude_cli` \| `copilot_cli` \| `gemini_cli` \| `opencode_cli`. Defaults to `nv_build`. | `claude_cli` | | `NVIDIA_INFERENCE_KEY` | Credential for `nv_build`. | `nvapi-...` | | `OPENAI_API_KEY` | Credential for `SKILLSPECTOR_PROVIDER=openai`. Also tier-2 fallback for non-OpenAI providers. | `sk-...` | | `OPENAI_BASE_URL` | Override the OpenAI endpoint (e.g. point at Ollama). | `http://localhost:11434/v1` | @@ -314,7 +314,8 @@ Copy [.env.example](../.env.example) to `.env` in the project root and set value > **Disabled provider:** `codex_cli` remains registered for compatibility but refuses inference until a complete no-tools policy is verified. -> **CLI providers** (`claude_cli`, `gemini_cli`, `opencode_cli`): no credential env var is needed. Authentication is managed by the agent CLI's own session. The subprocess is heavily sandboxed — see [providers/_agent_cli.py](../src/skillspector/providers/_agent_cli.py). +> **CLI providers** (`claude_cli`, `copilot_cli`, `gemini_cli`, `opencode_cli`): no credential env var is needed, except `copilot_cli` deliberately +preserves only `COPILOT_GITHUB_TOKEN` (see `_prepare_copilot_env`). Authentication is managed by the agent CLI's own session. Residual: Copilot CLI falls back to `gh auth token` when no other credential is available, so a stored gh login can still reach the child. The subprocess is heavily sandboxed — see [providers/_agent_cli.py](../src/skillspector/providers/_agent_cli.py). ### Live provider tests @@ -348,14 +349,15 @@ Base URL env vars are not needed for live provider tests; the tests intentionall - `openai_compatible/` — generic compatible endpoint (`SKILLSPECTOR_COMPAT_API_KEY`, `SKILLSPECTOR_COMPAT_BASE_URL`) - `claude_cli/` — **local `claude` binary; no API key**. Uses the CLI's own auth session (`claude auth login`). Set `SKILLSPECTOR_PROVIDER=claude_cli`. - `codex_cli/` — **registered but disabled**. Its read-only sandbox permits host-file reads. Select an HTTP API provider or another supported CLI provider for LLM analysis. + - `copilot_cli/` — **local `copilot` 1.0.95 binary; no API key**. Uses the CLI's own auth session (`copilot login`) or token env, stdin prompt transport, deny-all tool posture; fails closed on every other runtime version. Set `SKILLSPECTOR_PROVIDER=copilot_cli`. - `gemini_cli/` — **local `gemini` binary; no API key**. Uses the CLI's own auth session. Set `SKILLSPECTOR_PROVIDER=gemini_cli`. - `opencode_cli/` — **local `opencode` 1.18.33 binary; no API key**. Uses the CLI's own auth session (`opencode auth login`) and fails closed on every other runtime version because the deny-all policy is verified against that exact release. Set `SKILLSPECTOR_PROVIDER=opencode_cli`. - CLI providers (`claude_cli`, `gemini_cli`, `opencode_cli`) implement the optional `AgentCLICapable` interface (`is_available()` + `complete()`) defined in [providers/base.py](../src/skillspector/providers/base.py). `has_cli_capability(provider)` detects this at runtime. All subprocess calls go through the hardened helper [providers/_agent_cli.py](../src/skillspector/providers/_agent_cli.py) which enforces: no shell (`shell=False`), untrusted content via stdin only, capability stripping (tools disabled / sandboxed), environment scrubbing (no API keys forwarded), per-call timeout, and fail-closed error handling. + CLI providers (`claude_cli`, `copilot_cli`, `gemini_cli`, `opencode_cli`) implement the optional `AgentCLICapable` interface (`is_available()` + `complete()`) defined in [providers/base.py](../src/skillspector/providers/base.py). `has_cli_capability(provider)` detects this at runtime. All subprocess calls go through the hardened helper [providers/_agent_cli.py](../src/skillspector/providers/_agent_cli.py) which enforces: no shell (`shell=False`), untrusted content via stdin only, capability stripping (tools disabled / sandboxed), environment scrubbing (no API keys forwarded), per-call timeout, and fail-closed error handling. - **LLM calls** ([llm_utils.py](../src/skillspector/llm_utils.py)): **`get_chat_model()`** and **`chat_completion()`** dispatch based on the active provider: - **HTTP providers**: resolve credentials in two tiers — active provider (`NVIDIA_INFERENCE_KEY` / `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` → endpoint) — against any OpenAI-compatible endpoint. `max_tokens` is auto-bound to `get_max_output_tokens(model)` from `model_info`. - - **CLI providers** (`claude_cli`, `gemini_cli`, `opencode_cli`): `get_chat_model()` returns an `AgentCLIChatModel` adapter backed by `provider.complete()`, so the analyzers' `.invoke()` / `.with_structured_output(schema).invoke()` calls work with no API key (structured output is produced by prompting for JSON, then Pydantic-validating). `chat_completion()` routes through `get_chat_model()` as well. `is_llm_available()` calls `provider.is_available()` instead of credential resolution. + - **CLI providers** (`claude_cli`, `copilot_cli`, `gemini_cli`, `opencode_cli`): `get_chat_model()` returns an `AgentCLIChatModel` adapter backed by `provider.complete()`, so the analyzers' `.invoke()` / `.with_structured_output(schema).invoke()` calls work with no API key (structured output is produced by prompting for JSON, then Pydantic-validating). `chat_completion()` routes through `get_chat_model()` as well. `is_llm_available()` calls `provider.is_available()` instead of credential resolution. - **LLM analyzer base** ([llm_analyzer_base.py](../src/skillspector/nodes/llm_analyzer_base.py)): `LLMAnalyzerBase` provides per-file/per-chunk batching, token-budget-aware chunking, and a run loop for all LLM-based analyzers. `LLMMetaAnalyzer` extends it for filter/enrich (meta_analyzer node). Future semantic analyzers extend `LLMAnalyzerBase` for discovery mode. --- diff --git a/src/skillspector/cli.py b/src/skillspector/cli.py index ea4d12f88..4303483d4 100644 --- a/src/skillspector/cli.py +++ b/src/skillspector/cli.py @@ -620,8 +620,9 @@ def scan( SKILLSPECTOR_PROVIDER Active LLM provider: openai | anthropic | anthropic_proxy | bedrock | nv_build | nv_inference | ollama | azure_openai | - openai_compatible | gemini | claude_cli | - gemini_cli | opencode_cli. Defaults to the NVIDIA path + openai_compatible | gemini | claude_cli | + copilot_cli | gemini_cli | + opencode_cli. Defaults to the NVIDIA path (nv_inference, falling back to nv_build in OSS builds). SKILLSPECTOR_MODEL Override the active provider's default @@ -648,7 +649,8 @@ def scan( ollama uses the local Ollama service. claude_cli, gemini_cli, and opencode_cli use their CLI's existing local - authentication session. codex_cli is registered but disabled because + authentication session. copilot_cli uses the CLI login session or + COPILOT_GITHUB_TOKEN. codex_cli is registered but disabled because its read-only sandbox permits host-file reads; use another provider. """ if mcp_registry_compare is not None and not mcp_registry: diff --git a/src/skillspector/inference_usage.py b/src/skillspector/inference_usage.py index 933ac58bb..f79c0f21a 100644 --- a/src/skillspector/inference_usage.py +++ b/src/skillspector/inference_usage.py @@ -326,6 +326,7 @@ def provider_name(provider: object) -> str: "BedrockProvider": "bedrock", "ClaudeCLIProvider": "claude_cli", "CodexCLIProvider": "codex_cli", + "CopilotCLIProvider": "copilot_cli", "GeminiCLIProvider": "gemini_cli", "GeminiProvider": "gemini", "NvBuildProvider": "nv_build", diff --git a/src/skillspector/llm_utils.py b/src/skillspector/llm_utils.py index 5335a9eab..f361785e5 100644 --- a/src/skillspector/llm_utils.py +++ b/src/skillspector/llm_utils.py @@ -17,9 +17,9 @@ Credentials are resolved in this order: 1. The active provider (see :mod:`skillspector.providers`): - - CLI providers (``claude_cli``, ``codex_cli``, ``gemini_cli``, - ``opencode_cli``): use ``is_available()`` and ``complete()`` — no - API key needed. + - CLI providers (``claude_cli``, ``codex_cli``, ``copilot_cli``, + ``gemini_cli``, ``opencode_cli``): use ``is_available()`` and + ``complete()`` — no API key needed. - HTTP providers (``anthropic``, ``openai``, ``nv_build``): read their respective credential env vars and supply a base URL. 2. ``OPENAI_API_KEY`` / ``OPENAI_BASE_URL`` (the langchain-openai @@ -138,7 +138,7 @@ def _resolve_default_chat_model() -> str: def is_llm_available(timeout: float | None = 120) -> tuple[bool, str | None]: """Return ``(available, error_message)`` describing LLM availability. - CLI providers (``claude_cli``, ``codex_cli``, ``gemini_cli``, + CLI providers (``claude_cli``, ``codex_cli``, ``copilot_cli``, ``gemini_cli``, ``opencode_cli``) are checked through their ``is_available()`` method first. Other providers probe the same native chat-model path used by :func:`get_chat_model`; unbound HTTP providers keep the @@ -563,7 +563,7 @@ def get_chat_model( ) -> BaseChatModel | AgentCLIChatModel: """Return a chat model for the active provider. - For CLI providers (``claude_cli``, ``codex_cli``, ``gemini_cli``, + For CLI providers (``claude_cli``, ``codex_cli``, ``copilot_cli``, ``gemini_cli``, ``opencode_cli``) this returns an :class:`AgentCLIChatModel` adapter backed by the provider's ``complete()`` subprocess transport — so the LLM analyzers (which use ``.invoke()`` and ``.with_structured_output()``) diff --git a/src/skillspector/providers/__init__.py b/src/skillspector/providers/__init__.py index 2c2bd7e02..77c07358c 100644 --- a/src/skillspector/providers/__init__.py +++ b/src/skillspector/providers/__init__.py @@ -33,6 +33,7 @@ gemini → GeminiProvider (Google Cloud ADC / Workload Identity) claude_cli → ClaudeCLIProvider (local ``claude`` binary, no API key) codex_cli → CodexCLIProvider (registered but disabled: host-file reads) + copilot_cli → CopilotCLIProvider (local ``copilot`` binary, no API key) gemini_cli → GeminiCLIProvider (local ``gemini`` binary, no API key) opencode_cli → OpencodeCLIProvider (local ``opencode`` binary, no API key) antigravity_cli → AntigravityCLIProvider (local ``agy`` binary; registered @@ -40,7 +41,7 @@ When unset, the selector defaults to ``nv_build``. -CLI providers (``claude_cli``, ``codex_cli``, ``gemini_cli``, ``opencode_cli``) implement the +CLI providers (``claude_cli``, ``codex_cli``, ``copilot_cli``, ``gemini_cli``, ``opencode_cli``) implement the optional :class:`~skillspector.providers.base.AgentCLICapable` interface — they expose ``is_available()`` and ``complete()`` so that :func:`skillspector.llm_utils.get_chat_model` uses the local CLI subprocess @@ -153,6 +154,10 @@ def _select_active_provider() -> LLMProvider: from .codex_cli import CodexCLIProvider return CodexCLIProvider() + if name == "copilot_cli": + from .copilot_cli import CopilotCLIProvider + + return CopilotCLIProvider() if name == "gemini_cli": from .gemini_cli import GeminiCLIProvider @@ -179,7 +184,7 @@ def _select_active_provider() -> LLMProvider: f"Unknown SKILLSPECTOR_PROVIDER: {name!r}. " "Expected one of: openai, anthropic, anthropic_proxy, bedrock, nv_build, " "ollama, azure_openai, openai_compatible, gemini, " - "claude_cli, codex_cli, gemini_cli, opencode_cli, antigravity_cli (or unset)." + "claude_cli, codex_cli, copilot_cli, gemini_cli, opencode_cli, antigravity_cli (or unset)." ) @@ -265,7 +270,7 @@ def create_chat_model_with_provider( ) -> tuple[BaseChatModel, LLMProvider]: """Create a chat model and return the provider that actually built it. - CLI providers (``claude_cli``, ``codex_cli``, ``gemini_cli``, + CLI providers (``claude_cli``, ``codex_cli``, ``copilot_cli``, ``gemini_cli``, ``opencode_cli``) do not have a native LangChain chat model — callers that need CLI transport should use :func:`skillspector.llm_utils.get_chat_model` instead (which returns an diff --git a/src/skillspector/providers/_agent_cli.py b/src/skillspector/providers/_agent_cli.py index a2f9e02b0..70a5ca47a 100644 --- a/src/skillspector/providers/_agent_cli.py +++ b/src/skillspector/providers/_agent_cli.py @@ -13,7 +13,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Hardened subprocess helper for agent CLI providers (claude, codex, gemini). +"""Hardened subprocess helper for agent CLI providers (claude, codex, gemini, +opencode, and copilot). This is the single security chokepoint for all agent-CLI calls. Per-CLI knowledge (argv, output parsing, auth check) lives in a small ``CliSpec`` @@ -843,6 +844,572 @@ def _opencode_auth_check(binary: str) -> tuple[bool, str | None]: return True, None +# --------------------------------------------------------------------------- +# GitHub Copilot CLI invocation (verified against copilot 1.0.95) +# --------------------------------------------------------------------------- + + +# Single pin both gates compare against, so a bump cannot update one +# gate but not the other (mirrors _OPENCODE_SUPPORTED_VERSION). +_COPILOT_SUPPORTED_VERSION = "1.0.95" + +# Token variables the Copilot CLI silently accepts as credentials, in +# preference order after COPILOT_GITHUB_TOKEN. GH_TOKEN commonly carries +# a CI workflow token for ``gh`` — it must never become the agent's +# credential. Dropped explicitly here (not only by the shared scrub) so +# the guarantee holds even for a hand-built base env. +_COPILOT_DROPPED_AUTH_VARS: tuple[str, ...] = ( + "GH_TOKEN", + "GH_ENTERPRISE_TOKEN", + "GITHUB_ENTERPRISE_TOKEN", +) + + +def _parse_copilot_version(raw: bytes) -> str | None: + """Parse an exact stable semantic version from ``copilot --version``.""" + lines = raw.decode("utf-8", errors="replace").strip().splitlines() or [""] + match = re.fullmatch( + r"(?:github\s+copilot\s+cli\s+)?v?(\d+\.\d+\.\d+)\.?", + lines[0].strip(), + flags=re.IGNORECASE, + ) + return match.group(1) if match is not None else None + + +def _prepare_copilot_env( + base_env: dict[str, str], temp_root: str, argv: list[str] +) -> dict[str, str]: + """Return the child environment for a copilot invocation. + + ``temp_root``/``argv`` are unused (CliSpec signature uniformity). + Starts from the already-scrubbed base and applies an explicit + allowlist to ``COPILOT_*``: every such variable is dropped EXCEPT + ``COPILOT_GITHUB_TOKEN`` (re-read from the operator environment + because the shared scrub strips ``GITHUB_TOKEN``) and + ``COPILOT_HOME`` (a path, not a policy control — forwarded + scanner-resolved absolute so the audit and the child name the + same tree; and, as probed 2026-09-19, the CLI silently refuses + inference under ANY redirected home, even a byte-identical + copy, so home isolation is not a usable lever; argv-level deny + rules take precedence over anything a config file could add). + ``GITHUB_COPILOT_*`` prompt-mode opt-ins (extensions, repo + hooks, workspace MCP) are dropped outright: they reach past the + argv posture straight into CLI behavior. ``GH_TOKEN`` and the + enterprise token variables are dropped outright too: the CLI + reads them as credentials (after ``COPILOT_GITHUB_TOKEN``) and + an env token silently overrides the stored login, so a CI ``gh`` + token must never reach the child. The token is the CLI's + supported headless auth path and therefore works at inference + time. In particular ``COPILOT_ALLOW_ALL`` never reaches the + child, so ambient shell config cannot re-enable tools; + ``COPILOT_PROVIDER_*`` cannot redirect inference to an + arbitrary endpoint; and ``COPILOT_CUSTOM_INSTRUCTIONS_DIRS`` + cannot inject instructions. + Deliberately NOT forced: ``COPILOT_AUTO_UPDATE``. The CLI keeps + several cached runtime generations on disk and disabling + updates selects an older cached one (probed 2026-09-23: both + the env flag and ``--no-auto-update`` report and run an older + generation than the newest install), which would brick the + version pin. Mid-scan updates are covered instead by the + per-completion version preflight: an update landing mid-scan + fails loud on the next call, never drifts silent. + + User/plugin lifecycle hooks are handled NOT by home isolation + (broken as above) but by the preflight audits: inference + refuses to run when hook material (``installed-plugins/``, + ``extensions/``, ``hooks/*.json``, inline ``hooks`` in + ``settings.json``) is present under the resolved copilot home, + or when repo-level hook material appears in the fresh temp + working dir. No hook material on disk means no hooks load. + """ + env = { + key: value + for key, value in base_env.items() + if key.upper() not in _COPILOT_DROPPED_AUTH_VARS + and not key.upper().startswith("COPILOT_") + and not key.upper().startswith("GITHUB_COPILOT_") + } + token = os.environ.get("COPILOT_GITHUB_TOKEN", "").strip() + if token: + env["COPILOT_GITHUB_TOKEN"] = token + home = os.environ.get("COPILOT_HOME", "") + if home.strip(): + # Absolute, untrimmed, unexpanded: a relative home resolves + # in the scanner cwd for the audit but in the temp cwd for + # the CLI, and a trailing space names a distinct directory + # the CLI resolves as-is. Forwarding the scanner-resolved + # absolute form keeps both sides on the same tree. No + # expanduser: the CLI does not expand a leading `~` either. + env["COPILOT_HOME"] = os.path.abspath(home) + # NOTE: COPILOT_AUTO_UPDATE is deliberately neither forwarded nor + # forced (see docstring): updates stay enabled so the CLI runs its + # newest cached generation, and the per-completion preflight + # fail-closes any mid-scan drift. + return env + + +def _build_copilot_argv(binary: str, model: str, max_output_tokens: int = 0) -> list[str]: + """Build the argv list for a non-interactive ``copilot`` call. + + Flags chosen (verified against Copilot CLI 1.0.95 ``--help``): + + (no ``-p``) + With no prompt flag, the prompt is piped to stdin by run_agent_cli — + untrusted content never reaches argv (verified by nonce round-trip). + + ``-s`` + Suppress stats and decoration, emitting only the agent's response. + + ``--no-ask-user`` + Disable the ask_user tool so the agent cannot pause for input. + + ``--no-custom-instructions`` + Disable loading of custom instructions from AGENTS.md and related + files, so ambient instruction files cannot steer the semantic + verdict. (Belt-and-braces alongside the preflight audits: + user and plugin lifecycle hooks have no argv off-switch, so inference + refuses to run when hook material is present under the resolved + copilot home or in the temp working dir.) + + ``--disable-builtin-mcps`` + Disable all built-in MCP servers as defense in depth alongside the + tool allowlist below. + + ``--disallow-temp-dir`` + Prevent automatic access to the system temporary directory + (verified live on 1.0.92: inference from a temp working dir + still answers exactly; --help surface re-verified on 1.0.95). + + Deliberately NOT included: + - ``--allow-all*`` / ``--yolo`` — auto-approve permissions (dangerous); never use them. + - ``--no-auto-update`` — disabling updates runs an older cached + generation instead of the newest install (probed 2026-09-23), + which would brick the version pin; mid-scan drift is covered + by the per-completion preflight instead. + - ``max_output_tokens`` — copilot has no token flag (accepted for + CliSpec uniformity and ignored, like codex/gemini). + + ``--available-tools skillspector-no-tools`` + Allowlist holding a fixed implausible name, so the model is offered + no usable tools (verified: a file-creation request was refused with + no side effects). A fictitious name fails closed if a future CLI + ever rejects unknown tool names. + + ``--deny-tool shell,write`` + Belt-and-braces deny of the shell and file-writing tool kinds in the + documented ``Kind(argument)`` form; deny rules take precedence over + allow rules. (No wildcard deny exists; single-tool deny alone does + not stop reads through other tools, hence the allowlist above.) + + ``--model