feat(providers): local agent-CLI providers (claude/codex/gemini), no API key#52
feat(providers): local agent-CLI providers (claude/codex/gemini), no API key#52AbhiramDwivedi wants to merge 8 commits into
Conversation
…API key
Add four agent-CLI LLM providers driven by locally-installed, already-authenticated
CLI binaries (claude, codex, gemini, agy) instead of metered HTTP endpoints:
SKILLSPECTOR_PROVIDER=claude_cli -> local `claude` OAuth session, no API key
SKILLSPECTOR_PROVIDER=codex_cli -> local `codex` session
SKILLSPECTOR_PROVIDER=gemini_cli -> local `gemini` session
SKILLSPECTOR_PROVIDER=antigravity_cli -> registered but DISABLED (fail-closed;
agy is TTY-only, uncapturable on a pipe)
Security chokepoint: all subprocess I/O goes through run_agent_cli() in
_agent_cli.py (shell=False, prompt via stdin only, capability-stripped argv,
env scrub, temp CWD, bounded streaming, fail-closed). _agent_cli_base.py
is the shared provider base class; the four concrete providers are ~5-line
subclasses.
No pinned model: CLI providers forward no --model by default so the user's
own configured model is used; SKILLSPECTOR_MODEL overrides. model_registry.yaml
files are absent; metadata methods return None and fall through to default
token budgets.
The AgentCLIChatModel adapter in llm_utils.py mimics the ChatOpenAI
interface (.invoke / .ainvoke / .with_structured_output) backed by the
provider's complete() subprocess transport, so existing LLM analyzers
(meta_analyzer, semantic_*) work with no code changes.
Rebased and adapted onto upstream provider refactor (a5092dd):
- providers/base.py: adds AgentCLICapable + has_cli_capability alongside
upstream's new ChatModelProvider / LLMProvider protocols.
- providers/__init__.py: registers CLI providers in _select_active_provider;
preserves upstream's create_chat_model / resolve_chat_model_credentials /
NO_LLM_API_KEY_MESSAGE / raise_no_llm_api_key_configured; CLI branch skips
create_chat_model (no HTTP transport) and calls raise_no_llm_api_key_configured.
- llm_utils.py: get_chat_model branches on has_cli_capability — returns
AgentCLIChatModel for CLI providers; delegates to providers.create_chat_model
(which uses upstream's native-client path, e.g. ChatAnthropic) for HTTP ones.
- Tests: merged upstream's ChatAnthropic/create_chat_model/NO_LLM_API_KEY_MESSAGE
coverage with PR#52's CLI dispatch/adapter/antigravity/no-pinned-model tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Ram Dwivedi <abhiram.dwivedi@yahoo.com>
0dbea44 to
c4a92ff
Compare
|
Rebased onto the latest Integration summary
Re-verified end-to-end after the rebase: |
|
Addresses review feedback on NVIDIA#52 (reporter: @486). The claude provider requested `--output-format json`, whose envelope shape is not a stable contract across Claude Code builds (single object in 2.1.177, JSON array of stream events in 2.1.178). `_parse_claude_output` only handled the dict shape, so on newer builds it raised "expected a JSON object from claude, got list" and every Stage-2 semantic analyzer silently degraded to static-only. Switch to `--output-format text`, the stable headless contract: stdout is the assistant's reply with no envelope, unchanged since `-p` was introduced. The structured output the analyzers need is produced by the model (schema appended to the prompt) and validated by Pydantic in the adapter layer, so it is unaffected by the transport change. `_parse_claude_output` collapses to strip-and-return (raise on empty). Also dedup: `_parse_agy_output` was a verbatim copy of `_parse_gemini_output` and dead code (since `_build_agy_argv` fails closed). Point agy's registry entry at `_parse_gemini_output` (agy's backend is Gemini). Re-verified agy 1.0.10 still cannot be driven via a pipe (hangs, empty stdout) — the disabled status remains correct. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Ram Dwivedi <abhiram.dwivedi@yahoo.com>
Addresses the second review point on NVIDIA#52 (reporter: @486): when use_llm is requested but every LLM-backed analyzer fails at runtime (transport/parse/ auth error), each node swallows its exception and returns no findings while the report still claimed llm_available=true -- so a deep scan silently became static-only with no visible signal. - Add SkillspectorState['llm_call_log'] (additive list, same reducer as findings) plus the llm_call_record() helper. Each LLM-backed node (the 3 semantic analyzers + meta_analyzer) appends one record per run: ok=True on success, ok=False with the error on the fallback paths. Intentional skips (use_llm=False, empty inputs) record nothing, so they are never mistaken for failures. - report._build_metadata computes a "degraded" state (use_llm requested, >=1 call attempted, 0 succeeded) and then sets llm_available=false, adds llm_degraded=true and llm_calls_attempted/succeeded, and an llm_error explaining the static-only fallback with deduped reasons. - Visible warning in terminal and markdown output; a single aggregate warning is logged in report() regardless of output format. Tests: degradation aggregation across output formats (test_report.py) and per-node telemetry emission (success / failure / intentional-skip) for the semantic analyzers. No net-new failures vs the pre-existing suite baseline. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Ram Dwivedi <abhiram.dwivedi@yahoo.com>
…d records) Hardening pass on the degradation signal so no LLM call site or output format can hide a degraded scan: - Instrument mcp_tool_poisoning's TP4 (the 5th LLM-backed call site, previously uninstrumented). _check_tp4 now returns (findings, record): ok=True/False only when an LLM call was actually attempted, None for the no-description / no-code early-outs so an intentional no-op is never counted as a degraded stage. - Surface degradation in the default SARIF output via the standard invocations[].toolExecutionNotifications (warning level); add SarifInvocation / SarifNotification models. executionSuccessful stays true (the scan completed; only the LLM sub-stage degraded). Healthy scans emit no invocations block, so existing SARIF output is unchanged. - Replace the untyped dict telemetry record with an LLMCallRecord TypedDict (node/ok/error), matching the repo's TypedDict-based state convention. Tests: SARIF degradation notification + schema validity and the unchanged healthy case; TP4 telemetry (success / failure / no-call / use_llm=false). No net-new failures vs the pre-existing suite baseline; ruff + format clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Ram Dwivedi <abhiram.dwivedi@yahoo.com>
…es, not crashes Found by an end-to-end graph run with all LLM transports mocked to fail: meta_analyzer constructed LLMMetaAnalyzer (which calls get_chat_model) BEFORE its try/except, unlike the semantic analyzers. So a chat-model construction failure propagated uncaught and crashed the whole graph instead of being recorded as a degraded LLM call. Move construction inside the try. Tests: - tests/nodes/test_meta_analyzer.py (fast, default suite): success records ok=True; construction failure is caught (preserves findings, records ok=False, no raise) — the regression guard; use_llm=false / no-findings record nothing. - tests/integration/test_graph.py: full-graph e2e proving the operator.add reducer accumulates llm_call_log across the parallel analyzer fan-out AND the meta node, that the run completes without crashing, and that degradation surfaces in metadata + SARIF. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Ram Dwivedi <abhiram.dwivedi@yahoo.com>
Security review finding. risk_score / recommendation are computed from findings only, with no degradation input, so a scan whose LLM stage was requested but wholly failed could still report SAFE / exit 0 on the strength of static analysis alone. An attacker can trigger this deliberately (content that breaks the LLM call) to dodge the semantic analyzers — fail-open, and contrary to the project's fail-closed rule and reviewer @486's intent. When degraded, floor the recommendation at CAUTION (so a documented install-gate ASKS instead of auto-allowing). risk_score and severity are left untouched — they honestly reflect what static analysis found — and the existing llm_degraded / llm_error fields explain why the verdict was raised. The floor only lifts SAFE; CAUTION / DO_NOT_INSTALL verdicts are unchanged. Tests: degraded clean scan -> CAUTION (score still 0); non-degraded clean scan -> SAFE (no over-flooring); degraded CRITICAL scan -> stays DO_NOT_INSTALL. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Ram Dwivedi <abhiram.dwivedi@yahoo.com>
|
@486 — thanks for the detailed report; the repro and the 1. The parser break — fixed at the root ( You're right that 2. Partial-degradation signal — did both ( Agreed this is the dangerous part. Each LLM-backed node (the 3 semantic analyzers, the meta-analyzer, and
Two things that fell out of implementing it:
Happy to split any of this into its own PR if you'd prefer to keep #52 scoped to the providers — just say the word. |
rng1995
left a comment
There was a problem hiding this comment.
Review: APPROVE
This is a large but cohesive and unusually well-engineered feature. I read the security-critical paths closely (_agent_cli.py, the llm_utils adapter, provider wiring, and the report.py degradation handling) and the posture is excellent.
Security (the part that matters most for this tool)
- Single chokepoint (
run_agent_cli) withshell=Falseand an explicit argv list; untrusted prompt content is delivered via stdin only and never reaches argv. The model label is the only dynamic argv element and it's validated (_validate_model_labelrejects leading-and restricts the charset), closing the obvious argument-injection vector. - Capability stripping is per-CLI and deny-by-default: claude
--allowed-tools "" --permission-mode dontAsk --strict-mcp-config --disable-slash-commands, codex--sandbox read-only --ephemeral --ignore-user-config --ignore-rules, gemini--approval-mode plan;--dangerously-skip-permissions/auto-approve are never used. Good that this is asserted in tests rather than just documented. - Defense-in-depth is thorough: scrubbed env, fresh temp CWD, hard timeout, bounded concurrent stdout/stderr draining with an overflow-kill (no unbounded buffering), and fail-closed on every error path (missing binary, non-zero exit, timeout, empty/garbage output).
- The disabled
antigravity_clifails closed in_build_agy_argvandis_available(), which is the right way to register-but-disable an incompatible CLI. - Nice touch:
report.pyfloors aSAFEverdict toCAUTIONwhen the LLM stage was requested but every call failed, so an attacker can't dodge semantic scrutiny by breaking the LLM call. That's a genuine fail-closed improvement, and the blocking-verdict case is preserved (tested).
Correctness / quality
- Provider selection, the
AgentCLICapableduck-typed capability check, and theget_chat_modeldispatch are consistent with the existing provider abstraction; HTTP providers are untouched. - The
with_structured_outputadapter augments the prompt with the schema and validates via Pydantic, fail-closing (ValueError) on unparseable output;_extract_json_objecthandles fenced/prose-wrapped JSON. - Output parsers are version-tolerant (codex nested/flat shapes, gemini key fallbacks) with empty-output raising.
Tests — strong and on-point: the subprocess invariants (shell=False, stdin-not-argv, injection-stays-on-stdin, no dangerous flags, env scrubbed, input-size guard, overflow-kill, timeout), model-label validation, registry, adapter structured-output fail-closed, and the degraded-scan matrix are all covered. Keeping the live CLI suite opt-in/integration so the default suite runs without any CLI installed is the right call.
Minor / optional (non-blocking)
- The secret-env scrub is a prefix denylist, so provider tokens that don't match a listed prefix (e.g.
GEMINI_API_KEY,ANTHROPIC_AUTH_TOKEN) are still forwarded. Given tools are disabled / sandbox is read-only the model can't read env anyway, so this is defense-in-depth only — but an allowlist (forward only what the CLIs need) would be strictly tighter and wouldn't silently miss new tokens. - codex
--sandbox read-onlystill permits filesystem reads by model-generated commands (claude/gemini block tool use entirely). It's the most restrictive codex mode available and there's no attacker exfil channel here (output returns to the operator's own report, no network egress), so this is informational rather than a gap — worth a one-line note in the codex provider docstring. AgentCLIChatModelimplements only theinvoke/ainvoke/with_structured_outputslice ofBaseChatModel. That matches current analyzer usage, but any future analyzer that reaches for.batch()/.stream()/callbacks will break under CLI providers — a comment or aNotImplementedErrorstub would make that boundary explicit.- In
_run_bounded, the drain threads are joined with a 5s timeout before reading the buffers; in a pathological case where a thread outlives the join the captured stdout could be truncated. The process has already exited (pipe EOF) by then, so this is very unlikely, just flagging it.
Given the scope, a reviewer with the CLIs installed running the opt-in live harness once would be a good final gate, but nothing here blocks. Excellent work — approving.
…batch/stream boundary explicit Addresses two non-blocking reviewer notes on PR NVIDIA#52 (agent-CLI providers): - _agent_cli.py: expand the codex `--sandbox read-only` docstring to note that, unlike claude/gemini (which block model tool use entirely), codex's strictest mode still permits read-only filesystem reads by model-generated commands. Informational only: isolated empty temp CWD, output returns to the operator's report, no network egress. - llm_utils.py: add explicit batch()/stream() stubs to AgentCLIChatModel that raise NotImplementedError, so a future analyzer reaching for the unsupported BaseChatModel surface fails loudly with a clear message instead of a confusing AttributeError. No behavior change for existing invoke / ainvoke / with_structured_output callers. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Ram Dwivedi <abhiram.dwivedi@yahoo.com>
|
@rng1995 Thanks for the thorough security review. Addressed the two cheap clarity notes in 714e266:
I left the env-scrub denylist and the codex read-only behavior itself as-is, per your "informational, not a gap" framing (tools disabled / sandbox read-only, no exfil channel). Happy to switch the scrub to an allowlist as a follow-up if you'd prefer the strictly-tighter version. |
|
Looks good to me. Please resolve merge conflicts to merge the PR. |
rng1995
left a comment
There was a problem hiding this comment.
[Automated SkillSpector Review]
Re-review — re-confirming approval.
The one commit since my approval is documentation + a clarity hardening that addresses two of my non-blocking notes: the codex --sandbox read-only docstring now states it still permits model-driven filesystem reads (unlike claude/gemini), and AgentCLIChatModel.batch()/stream() now raise NotImplementedError (-> NoReturn) so a future analyzer reaching for the unsupported BaseChatModel surface fails loudly instead of with a confusing AttributeError. No behavior change to the security-critical run_agent_cli chokepoint (still shell=False, stdin-only untrusted input, validated model label, deny-by-default capability stripping, fail-closed). Approving.
|
Please resolve merge conflicts |
Resolve conflicts so the (approved) agent-CLI providers PR sits on the latest main. Both feature sets are preserved side by side: - NVIDIA#52: local agent-CLI providers (claude/codex/gemini, antigravity disabled), the AgentCLIChatModel adapter, and the LLM silent-degradation signal (llm_call_log telemetry -> report llm_degraded / fail-closed CAUTION floor / SARIF toolExecutionNotifications). - main: anthropic_proxy provider, the provider model-resolution refactor (MODEL_CONFIG -> provider.resolve_model()), baseline/suppression, SARIF rules[] + suppressions, and analysis_completeness. Notable resolutions: - get_chat_model: CLI branch defaults the model via provider.resolve_model() (MODEL_CONFIG was removed upstream); HTTP branch keeps _resolve_default_chat_model(). - meta_analyzer failure path: upstream's fail-closed _passthrough_with_defaults plus NVIDIA#52's ok=False degradation record. - report: _build_sarif takes both suppressed and degraded_notice; _build_metadata merges meta_analysis_applied/filtering_mode with the degradation fields (a degraded scan is no longer counted as meta_analysis_applied). - Adjusted two pre-existing meta_analyzer filter tests to use a sub-floor (MEDIUM) severity so they exercise the drop path; CRITICAL/HIGH are kept by PR NVIDIA#54's severity floor regardless of LLM verdict. Signed-off-by: Ram Dwivedi <abhiram.dwivedi@yahoo.com>
|
@rng1995 Resolved conflicts against the latest A few notable resolutions:
Full unit suite is green (1123 passed). One heads-up that's independent of this PR: |
Closes #57
What
Local agent-CLI providers that route Stage-2 LLM analysis through a locally-installed, already-authenticated agent CLI instead of a metered HTTP API — no API key needed, the CLI's own login session is used. Supported today:
claude,codex,gemini(viaSKILLSPECTOR_PROVIDER=claude_cli/codex_cli/gemini_cli). Adding another CLI is a small registry entry + a ~5-line provider subclass — see the "HOW TO ADD A NEW AGENT CLI" guide inproviders/_agent_cli.py.How
providers/_agent_cli.py):shell=False; untrusted prompt via stdin only, never argv; capability stripping verified against the real CLIs — claude:--allowed-tools ""deny-by-default +--permission-mode dontAsk+--strict-mcp-config+--disable-slash-commands; codex:--sandbox read-only+--ephemeral+--ignore-user-config; gemini:--approval-mode plan(read-only, no tool execution).--dangerously-skip-permissions/ auto-approve is never used. Scrubbed env; temp CWD; per-call timeout; streamed stdout with a hard size cap (process killed on overflow — no unbounded buffering); model-label validated against argument injection; fail-closed on every error path. A per-CLICliSpecregistry keeps all CLI-specific argv/parse/auth behind one lookup, so the shared security core is unchanged when a CLI is added.get_chat_model(); for CLI providers that returns a minimalChatOpenAI-compatible adapter backed byprovider.complete(), with structured output via prompt-for-JSON + Pydantic validation (fail-closed). HTTP providers are unchanged.--modelby default, so it runs with the user's OWN configured model and thinking level; setSKILLSPECTOR_MODELto override. (No bundledmodel_registry.yaml; CLI providers use the package-wide default token budgets.)is_available()does a real local auth probe (claude auth status/codex login status) so a report'sllm_availablenever claims availability when the CLI is logged out.Antigravity (
agy) — registered but disabledantigravity_cliis wired into the registry but fail-closed and disabled. Tested end-to-end against the realagy: its--printmode renders to a TTY and returns empty stdout on a pipe (how the runner must capture it), and it takes the prompt as an argv value rather than stdin — so it cannot be driven programmatically. Its backend is Gemini, so usegemini_clifor that capability. The registry entry documents the finding in one place; enabling it later is a one-function change ifagygains a headless stdout mode.Test
Fully-mocked unit tests (no CLI required) cover the subprocess invariants, the bounded reader (real subprocesses: normal / overflow-kill / timeout), the adapter + structured-output parsing, provider selection, the registry, no-pinned-model resolution, and the disabled-antigravity guard — so a contributor without any of these CLIs installed runs the entire default suite. An opt-in live harness (
tests/integration/test_agent_cli_live.py, markedintegration, excluded by default) is parametrized over claude/codex/gemini and skips per-CLI when a binary is absent/unauthenticated. Verified end-to-end: claude/codex/gemini each return real output with no pinned model.🤖 Generated with Claude Code