From 9f8f9c6c768c4a1c91dfdd28b63659705e300c0d Mon Sep 17 00:00:00 2001 From: Yoseph Zuskin Date: Wed, 16 Sep 2026 21:30:11 -0400 Subject: [PATCH 1/5] feat(providers): add copilot_cli semantic-scan provider - Add providers/copilot_cli/provider.py and __init__.py mirroring opencode_cli; register copilot in _agent_cli.py CliSpec with _prepare_copilot_env wired in - Transport is flags + piped stdin: copilot -s --no-ask-user, prompt via stdin (verified by nonce round-trip); --available-tools names a fixed implausible tool (verified live: model left tool-less, no side effect) plus --deny-tool shell,write belt-and-braces; never --allow-all* - Auth probe is copilot --version (must equal pinned 1.0.85), scrubbed env, fail-closed; login session or COPILOT_GITHUB_TOKEN/GH_TOKEN/ GITHUB_TOKEN auth, everything else COPILOT_* stripped - Update docs trio: README provider table (+1.0.85 pin note), .env.example, docs/DEVELOPMENT.md - Add tests/provider/test_copilot_cli.py (argv, auth, parser, wiring, adversarial fake-host with POSIX-skip) + registry coverage - Verified: live probe on Copilot Free (CLI-default model), llm_available=true, 3/3 calls, 0/LOW, 0 findings; 375 passed / 14 skipped; ruff + format + diff-check clean Signed-off-by: Yoseph Zuskin Co-Authored-By: OpenCode Muse Spark 1.3 Free (1M context) --- .env.example | 4 +- README.md | 7 +- docs/DEVELOPMENT.md | 11 +- src/skillspector/cli.py | 7 +- src/skillspector/inference_usage.py | 1 + src/skillspector/llm_utils.py | 10 +- src/skillspector/providers/__init__.py | 11 +- src/skillspector/providers/_agent_cli.py | 162 ++++++- src/skillspector/providers/_agent_cli_base.py | 2 +- .../providers/copilot_cli/__init__.py | 27 ++ .../providers/copilot_cli/provider.py | 45 ++ tests/provider/test_copilot_cli.py | 439 ++++++++++++++++++ tests/unit/test_agent_cli.py | 11 +- 13 files changed, 713 insertions(+), 24 deletions(-) create mode 100644 src/skillspector/providers/copilot_cli/__init__.py create mode 100644 src/skillspector/providers/copilot_cli/provider.py create mode 100644 tests/provider/test_copilot_cli.py diff --git a/.env.example b/.env.example index 581461142..a06a92e93 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 | claude_cli | -# codex_cli | gemini_cli | opencode_cli +# codex_cli | copilot_cli | gemini_cli | opencode_cli SKILLSPECTOR_PROVIDER= # Aggregate deadline for one complete scan workflow. Defaults to 600 seconds; @@ -67,7 +67,7 @@ AZURE_OPENAI_ENDPOINT= SKILLSPECTOR_COMPAT_API_KEY= SKILLSPECTOR_COMPAT_BASE_URL= -# claude_cli, codex_cli, gemini_cli, and opencode_cli use their CLI's existing local +# claude_cli, codex_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/README.md b/README.md index d6d6b314e..7bd89ffd5 100644 --- a/README.md +++ b/README.md @@ -241,6 +241,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` | _(none — uses local CLI auth)_ | local `codex` binary | local Codex runtime fallback, or `SKILLSPECTOR_MODEL` | +| `copilot_cli` | _(none — uses local CLI auth)_ | local `copilot` 1.0.85 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.31 binary | local OpenCode runtime fallback, or `SKILLSPECTOR_MODEL` | @@ -605,7 +606,7 @@ Issues (2) | Variable | Description | Required | |----------|-------------|----------| -| `SKILLSPECTOR_PROVIDER` | Active LLM provider: `openai`, `anthropic`, `anthropic_proxy`, `bedrock`, `nv_build`, `ollama`, `azure_openai`, `openai_compatible`, `claude_cli`, `codex_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`, `ollama`, `azure_openai`, `openai_compatible`, `claude_cli`, `codex_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 | | `NVIDIA_INFERENCE_KEY` | Credential for the `nv_build` provider (build.nvidia.com). | Required for LLM analysis when `SKILLSPECTOR_PROVIDER=nv_build` | | `OPENAI_API_KEY` | Credential for the OpenAI provider (`SKILLSPECTOR_PROVIDER=openai`). Also serves as the tier-2 fallback in the credential waterfall when the active provider returns no credentials. | Required for LLM analysis when `SKILLSPECTOR_PROVIDER=openai` | | `OPENAI_BASE_URL` | Override the OpenAI endpoint (e.g. point at Ollama). | Optional | @@ -631,9 +632,11 @@ Issues (2) | `SKILLSPECTOR_MODEL_REGISTRY` | Override the bundled per-provider YAML registry (`src/skillspector/providers//model_registry.yaml`) with a custom path. | Optional | | `SKILLSPECTOR_LOG_LEVEL` | Log level: `DEBUG`, `INFO`, `WARNING`, `ERROR` (default: `WARNING`). | Optional | -> **CLI providers** (`claude_cli`, `codex_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`, `codex_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` / `GH_TOKEN` / `GITHUB_TOKEN` (its documented headless auth) while dropping every other `COPILOT_*`. 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.31`, 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.85`, the version whose tool-deny behavior is verified by this release. ### CLI Options diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 4b2022c53..a49805e61 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`, `codex_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`, `codex_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` \| `codex_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` \| `codex_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` | @@ -312,7 +312,7 @@ Copy [.env.example](../.env.example) to `.env` in the project root and set value | `SKILLSPECTOR_COMPAT_BASE_URL` | OpenAI-compatible endpoint base URL. | `https://api.groq.com/openai/v1` | | `SKILLSPECTOR_MODEL` | Override the active provider's bundled default model (see [README.md](../README.md) for per-provider defaults). CLI providers forward it as `--model`. | `gpt-5.2` | -> **CLI providers** (`claude_cli`, `codex_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`, `codex_cli`, `copilot_cli`, `gemini_cli`, `opencode_cli`): no credential env var is needed, except `copilot_cli` deliberately preserves only its three documented token variables (see `_prepare_copilot_env`). 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). ### Live provider tests @@ -344,14 +344,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/` — **local `codex` binary; no API key**. Uses the CLI's own auth session (`codex login`). Set `SKILLSPECTOR_PROVIDER=codex_cli`. + - `copilot_cli/` — **local `copilot` 1.0.85 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.31 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`, `codex_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`, `codex_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`, `codex_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`, `codex_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 4cb6ef33c..d82a05031 100644 --- a/src/skillspector/cli.py +++ b/src/skillspector/cli.py @@ -552,8 +552,8 @@ def scan( SKILLSPECTOR_PROVIDER Active LLM provider: openai | anthropic | anthropic_proxy | bedrock | nv_build | nv_inference | ollama | azure_openai | - openai_compatible | claude_cli | codex_cli | - gemini_cli | opencode_cli. Defaults to the NVIDIA path + openai_compatible | claude_cli | codex_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 @@ -577,7 +577,8 @@ def scan( ollama uses the local Ollama service. claude_cli, codex_cli, gemini_cli, and opencode_cli use their CLI's existing local - authentication session. + authentication session. copilot_cli uses the CLI login session or + one of COPILOT_GITHUB_TOKEN / GH_TOKEN / GITHUB_TOKEN. """ if mcp_registry: if recursive or baseline is not None or show_suppressed or yara_rules_dir is not None: diff --git a/src/skillspector/inference_usage.py b/src/skillspector/inference_usage.py index 56cf6320b..56dff391a 100644 --- a/src/skillspector/inference_usage.py +++ b/src/skillspector/inference_usage.py @@ -115,6 +115,7 @@ def provider_name(provider: object) -> str: "BedrockProvider": "bedrock", "ClaudeCLIProvider": "claude_cli", "CodexCLIProvider": "codex_cli", + "CopilotCLIProvider": "copilot_cli", "GeminiCLIProvider": "gemini_cli", "NvBuildProvider": "nv_build", "NvInferenceProvider": "nv_inference", diff --git a/src/skillspector/llm_utils.py b/src/skillspector/llm_utils.py index e9924498a..765faef43 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 @@ -127,7 +127,7 @@ def _resolve_default_chat_model() -> str: def is_llm_available() -> 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 @@ -362,7 +362,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 30ca116c5..b234aa2da 100644 --- a/src/skillspector/providers/__init__.py +++ b/src/skillspector/providers/__init__.py @@ -32,6 +32,7 @@ openai_compatible → OpenAICompatibleProvider (Groq, Together AI, Mistral, etc.) claude_cli → ClaudeCLIProvider (local ``claude`` binary, no API key) codex_cli → CodexCLIProvider (local ``codex`` binary, no API key) + 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 @@ -39,7 +40,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 @@ -144,6 +145,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 @@ -170,7 +175,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, " - "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)." ) @@ -251,7 +256,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 085280e66..1ab7b15be 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`` @@ -874,6 +875,158 @@ def _opencode_auth_check(binary: str) -> tuple[bool, str | None]: return True, None +# --------------------------------------------------------------------------- +# GitHub Copilot CLI invocation (verified against copilot 1.0.85) +# --------------------------------------------------------------------------- + + +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 the three + documented token variables (``COPILOT_GITHUB_TOKEN``, ``GH_TOKEN``, + ``GITHUB_TOKEN`` — re-read from the operator environment because the + shared scrub strips ``GITHUB_TOKEN``), which are the CLI's supported + headless auth path and therefore work 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. ``COPILOT_AUTO_UPDATE`` is forced off so the + version gate cannot be invalidated mid-scan. ``COPILOT_HOME`` is + deliberately left alone: pinning it would hide the login session this + provider relies on when no token is set. + """ + env = {key: value for key, value in base_env.items() if not key.upper().startswith("COPILOT_")} + for name in ("COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"): + value = os.environ.get(name, "").strip() + if value: + env[name] = value + env["COPILOT_AUTO_UPDATE"] = "false" + 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.85 ``--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. + + ``--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