Skip to content

feat: guard installation verb#252

Open
lbeurerkellner wants to merge 11 commits intomainfrom
feat/agent-guard
Open

feat: guard installation verb#252
lbeurerkellner wants to merge 11 commits intomainfrom
feat/agent-guard

Conversation

@lbeurerkellner
Copy link
Copy Markdown
Contributor

No description provided.

@lbeurerkellner lbeurerkellner requested a review from a team as a code owner April 1, 2026 09:23
@qodo-merge-etso
Copy link
Copy Markdown

Review Summary by Qodo

✨ Enhancement

Grey Divider

Walkthroughs

Description
• Add guard command for managing Agent Guard hooks
• Support install/uninstall/status for Claude Code and Cursor
• Extract push key minting/revocation into reusable module
• Refactor EVO command to use new push key utilities
Diagram
flowchart LR
  CLI["CLI guard command"]
  Guard["guard.py module"]
  PushKeys["pushkeys.py module"]
  Claude["Claude Code config"]
  Cursor["Cursor config"]
  Script["snyk-agent-guard.sh script"]
  
  CLI -- "install/uninstall/status" --> Guard
  Guard -- "mint/revoke keys" --> PushKeys
  Guard -- "manage hooks" --> Claude
  Guard -- "manage hooks" --> Cursor
  Guard -- "copy/execute" --> Script
Loading

Grey Divider

File Changes

1. src/agent_scan/cli.py ✨ Enhancement +83/-46

Add guard command and refactor EVO push key logic

• Add guard subcommand with install/uninstall subparsers
• Remove unused aiohttp import and verify_api utilities
• Refactor evo() function to use new pushkeys module
• Route guard command to run_guard() entry point

src/agent_scan/cli.py


2. src/agent_scan/guard.py ✨ Enhancement +657/-0

Complete Agent Guard hook management implementation

• New module for managing Agent Guard hooks installation/uninstall
• Support Claude Code (settings.json) and Cursor (hooks.json) config formats
• Detect existing installations and preserve non-agent-scan hooks
• Send test events to verify connectivity before committing changes
• Mint/revoke push keys with optional headless mode via PUSH_KEY env var

src/agent_scan/guard.py


3. src/agent_scan/pushkeys.py ✨ Enhancement +86/-0

New push key minting and revocation utilities

• Extract push key minting and revocation into standalone module
• Use standard library urllib instead of aiohttp
• Support custom base URLs and optional tenant descriptions
• Provide reusable functions for EVO and guard commands

src/agent_scan/pushkeys.py


View more (3)
4. tests/unit/test_guard.py 🧪 Tests +1008/-0

Extensive test coverage for guard functionality

• Comprehensive unit tests for guard module functions
• Test install/uninstall/detect for both Claude Code and Cursor
• Verify hook filtering, preservation of non-agent-scan hooks
• Test command parsing, environment variable extraction
• Validate idempotency and backup file creation

tests/unit/test_guard.py


5. src/agent_scan/hooks/snyk-agent-guard.sh ✨ Enhancement +157/-0

Thin-client hook script for event forwarding

• Bash hook script for forwarding events to Agent Guard endpoint
• Base64-encodes JSON payloads and sends via curl
• Supports both Claude Code and Cursor clients
• Includes hostname/username detection and error handling

src/agent_scan/hooks/snyk-agent-guard.sh


6. src/agent_scan/hooks/__init__.py Additional files +0/-0

...

src/agent_scan/hooks/init.py


Grey Divider

Qodo Logo

@qodo-merge-etso
Copy link
Copy Markdown

qodo-merge-etso bot commented Apr 1, 2026

Code Review by Qodo

🐞 Bugs (3) 📘 Rule violations (3) 📎 Requirement gaps (0) 🎨 UX Issues (0)

Grey Divider


Action required

1. _run_install() too long 📘 Rule violation ⚙ Maintainability
Description
The new _run_install() function is ~80+ lines long, exceeding the 40 SLOC limit and making the
install flow harder to maintain and review. This should be split into smaller helpers (e.g.,
token/tenant collection, mint/revoke, test event, config write).
Code

src/agent_scan/guard.py[R98-181]

+def _run_install(args) -> None:
+    client: str = args.client
+    url: str = args.url
+    push_key = os.environ.get("PUSH_KEY", "")
+    headless = bool(push_key)
+    tenant_id: str = getattr(args, "tenant_id", None) or ""
+
+    label = _client_label(client)
+    snyk_token = ""
+
+    if not headless:
+        # Interactive flow — mint a push key
+        rich.print(f"Installing [bold magenta]Agent Guard[/bold magenta] hooks for [bold]{label}[/bold]")
+        rich.print()
+
+        snyk_token = os.environ.get("SNYK_TOKEN", "")
+        if not snyk_token:
+            rich.print("Paste your Snyk API token ( from https://app.snyk.io/account ):")
+            snyk_token = input().strip()
+        if not snyk_token:
+            rich.print("[bold red]Error:[/bold red] SNYK_TOKEN is required to mint a push key.")
+            sys.exit(1)
+
+        if not tenant_id:
+            tenant_id = os.environ.get("TENANT_ID", "")
+        if not tenant_id:
+            rich.print("Enter your Snyk Tenant ID ( from the URL at https://app.snyk.io ):")
+            tenant_id = input().strip()
+        if not tenant_id:
+            rich.print("[bold red]Error:[/bold red] Tenant ID is required to mint a push key.")
+            sys.exit(1)
+
+        description = _get_machine_description(client)
+        rich.print(f"[dim]Minting push key for {description}...[/dim]")
+        try:
+            push_key = mint_push_key(url, tenant_id, snyk_token, description=description)
+        except RuntimeError as e:
+            rich.print(f"[bold red]Error:[/bold red] {e}")
+            sys.exit(1)
+        rich.print(f"[green]\u2713[/green]  Push key minted  [yellow]{_mask_key(push_key)}[/yellow]")
+
+    hook_client = "claude-code" if client == "claude" else "cursor"
+    minted = not headless  # True if we minted the key in this run
+    config_path = _config_path(client, getattr(args, "file", None))
+    # Copy hook script first so we can use it for the test event
+    dest_path, script_existed, script_updated = _copy_hook_script(client)
+
+    first_install = not config_path.exists() or not script_existed
+    run_test = first_install or minted or getattr(args, "test", False)
+
+    # Verify connectivity by invoking the actual hook script
+    if run_test and not _send_test_event(push_key, url, hook_client, dest_path):
+        # Clean up copied script only if it didn't exist before
+        if not script_existed:
+            dest_path.unlink(missing_ok=True)
+        if minted:
+            rich.print("[dim]Revoking minted push key...[/dim]")
+            try:
+                revoke_push_key(url, tenant_id, snyk_token, push_key)
+                rich.print("[green]\u2713[/green]  Push key revoked")
+            except RuntimeError as e:
+                rich.print(f"[yellow]Warning:[/yellow] Could not revoke push key: {e}")
+        rich.print("[bold red]Aborting install — test event failed.[/bold red]")
+        raise SystemExit(1)
+
+    # Build command string and edit client config
+    command = _build_hook_command(push_key, url, dest_path, hook_client, tenant_id=tenant_id)
+
+    if client == "claude":
+        config_changed = _install_claude(command, config_path)
+    elif client == "cursor":
+        config_changed = _install_cursor(command, config_path)
+
+    if script_updated or config_changed or minted:
+        rich.print(f"[green]\u2713[/green]  Hooks installed for [bold]{label}[/bold]")
+    else:
+        rich.print(f"[green]\u2713[/green]  {label} hook integration up to date")
+    rich.print(f"   Config:     [dim]{config_path}[/dim]")
+    rich.print(f"   Script:     [dim]{dest_path}[/dim]")
+    rich.print(f"   Remote URL: [dim]{url}[/dim]")
+    rich.print(f"   Push Key:   [yellow]{_mask_key(push_key)}[/yellow]")
+    rich.print()
+
+
Evidence
PR Compliance ID 45 requires functions to be ≤ 40 lines; the newly added _run_install() spans from
line 98 through line 181 in the added file, clearly exceeding this limit.

Rule 45: Limit function length to ≤ 40 lines (SLOC)
src/agent_scan/guard.py[98-181]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`_run_install()` exceeds the ≤40 line function-length compliance limit, making the installation flow difficult to maintain.

## Issue Context
The function currently mixes: interactive credential collection, push-key minting/revocation, test-event sending, config/script path resolution, and config file mutation.

## Fix Focus Areas
- src/agent_scan/guard.py[98-181]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Hook can hang indefinitely 🐞 Bug ☼ Reliability
Description
The installed hook script calls curl without connect/overall timeouts, so network stalls can block
Claude/Cursor while the hook is executing. Because hooks run frequently, this can freeze the client
on transient network problems.
Code

src/agent_scan/hooks/snyk-agent-guard.sh[R133-146]

+  local -a curl_args
+  curl_args=(
+    -sS
+    -X POST
+    "$url"
+    -H "User-Agent: ${user_agent}"
+    -H "X-User: ${x_user}"
+    -H "Content-Type: text/plain"
+    -H "X-Client-Id: ${pushkey}"
+    --data-binary "${encoded_body}"
+  )
+
+  resp="$(curl "${curl_args[@]}" -w $'\n'"${marker}%{http_code}")" || die "Request failed"
+  http_code="${resp##*$'\n'"${marker}"}"
Evidence
The hook runs with set -euo pipefail, then invokes curl with -sS but no
--connect-timeout/--max-time, meaning the process can wait indefinitely for DNS/connect/response
and block the calling client.

src/agent_scan/hooks/snyk-agent-guard.sh[14-15]
src/agent_scan/hooks/snyk-agent-guard.sh[133-146]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
The hook script can block the IDE/client indefinitely because `curl` has no connection or total timeout.

### Issue Context
Hooks are executed inline by Claude Code/Cursor; a hung hook can stall the entire user workflow.

### Fix Focus Areas
- src/agent_scan/hooks/snyk-agent-guard.sh[133-146]

### Suggested change
Add sane defaults like `--connect-timeout 5` and `--max-time 15` (and optionally a small retry) to the curl invocation. Consider making these overridable via env vars (e.g., `SNYK_AGENT_GUARD_CONNECT_TIMEOUT`, `SNYK_AGENT_GUARD_MAX_TIME`) so enterprise environments can tune them.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

3. RuntimeError leaks API body 📘 Rule violation ⛨ Security
Description
On HTTP failures, mint_push_key()/revoke_push_key() embed the raw HTTP response body into the
raised RuntimeError, which is then displayed to the user by callers. This can leak internal
service details returned by the API into user-facing output.
Code

src/agent_scan/pushkeys.py[R48-56]

+    except HTTPError as e:
+        body_text = e.read().decode(errors="replace")
+        raise RuntimeError(f"Push key minting failed: HTTP {e.code} — {body_text}") from e
+    except (TimeoutError, URLError) as e:
+        raise RuntimeError(f"Push key minting failed: {e}") from e
+
+    client_id = data.get("client_id")
+    if not client_id:
+        raise RuntimeError(f"Unexpected push key response: {data}")
Evidence
PR Compliance ID 6 requires error messages exposed to users to be generic and not leak internals;
the new code includes raw API response text in exceptions via body_text and also includes full
unexpected JSON payloads in errors.

Rule 6: Handle errors explicitly; don't swallow; clean up; don't leak internals
src/agent_scan/pushkeys.py[48-56]
src/agent_scan/pushkeys.py[82-84]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The push-key API error handling includes raw HTTP response bodies (and full JSON objects) in raised exceptions that can be printed to end users, risking internal information disclosure.

## Issue Context
`HTTPError` handlers read and include `body_text` in the `RuntimeError` message. Also, missing `client_id` raises an error containing the full parsed response object.

## Fix Focus Areas
- src/agent_scan/pushkeys.py[48-56]
- src/agent_scan/pushkeys.py[82-84]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


4. _send_test_event() swallows exceptions 📘 Rule violation ☼ Reliability
Description
_send_test_event() uses a broad except Exception as e and converts unexpected errors into a
simple False return, which can hide real failures and complicate debugging. Compliance requires
targeting specific exceptions rather than catching everything without re-raising.
Code

src/agent_scan/guard.py[R436-456]

+    try:
+        result = subprocess.run(
+            ["bash", str(script_path), "--client", hook_client],
+            input=payload,
+            capture_output=True,
+            text=True,
+            timeout=15,
+            env=env,
+        )
+        if result.returncode == 0:
+            rich.print("[green]\u2713[/green]  Test event sent  [green]\u2192 OK[/green]")
+            return True
+        stderr = result.stderr.strip()
+        rich.print(f"[red]\u2717[/red]  Test event failed: {stderr or f'exit code {result.returncode}'}")
+        return False
+    except subprocess.TimeoutExpired:
+        rich.print("[red]\u2717[/red]  Test event failed: timeout")
+        return False
+    except Exception as e:
+        rich.print(f"[red]\u2717[/red]  Test event failed: {e}")
+        return False
Evidence
PR Compliance ID 6 flags broad exception handling that swallows errors; the new code catches all
exceptions and returns False, rather than handling specific expected exceptions or re-raising
unexpected ones.

Rule 6: Handle errors explicitly; don't swallow; clean up; don't leak internals
src/agent_scan/guard.py[436-456]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`_send_test_event()` catches `Exception` broadly and swallows unexpected failures by returning `False`, which violates the guidance to target specific exceptions (or re-raise unexpected ones).

## Issue Context
The function already handles `subprocess.TimeoutExpired`, but still has a broad catch-all that can mask programming errors or environment failures.

## Fix Focus Areas
- src/agent_scan/guard.py[436-456]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. Claude hook group data loss 🐞 Bug ≡ Correctness
Description
_filter_claude_hooks() drops an entire Claude hook group when any hook entry in that group matches
the Agent Guard marker, which can delete unrelated hooks that happen to be in the same group. This
contradicts the uninstall/install messaging that other hooks will be preserved.
Code

src/agent_scan/guard.py[R468-476]

+def _filter_claude_hooks(hooks: dict) -> dict:
+    result = {}
+    for event, groups in hooks.items():
+        filtered = [
+            g for g in groups if not any(_is_agent_scan_command(h.get("command", "")) for h in g.get("hooks", []))
+        ]
+        if filtered:
+            result[event] = filtered
+    return result
Evidence
The tool explicitly tells users other hooks will be preserved, but the filter keeps or removes whole
groups based on whether *any* hook within g['hooks'] matches; if a group contains multiple hooks,
non-Agent-Guard hooks in that group will be removed as collateral.

src/agent_scan/guard.py[242-244]
src/agent_scan/guard.py[468-476]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
Claude hook filtering removes entire groups if any contained hook matches the Agent Guard marker, which can unintentionally remove unrelated hooks.

### Issue Context
Claude groups are represented as objects containing a `hooks` list; current logic treats the group as the removal unit.

### Fix Focus Areas
- src/agent_scan/guard.py[468-476]
- src/agent_scan/guard.py[488-494]
- src/agent_scan/guard.py[284-308]

### Suggested change
Change `_filter_claude_hooks` to:
1) For each group, filter `group['hooks']` to remove only entries whose `command` contains the detection marker.
2) Keep the group if it still has any hooks left; otherwise drop the group.
Also update `_count_non_agent_scan_claude` and uninstall removed-count calculations to align with per-hook removal semantics (so messaging remains accurate).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (1)
6. Evo drops SSL-skip support 🐞 Bug ☼ Reliability
Description
The evo command still accepts --skip-ssl-verify (via setup_scan_parser) but push-key mint/revoke no
longer uses that flag after switching to urllib, so evo can fail in environments that relied on
skipping cert verification. The new urllib calls also run synchronously inside an async function,
blocking the event loop during network I/O.
Code

src/agent_scan/cli.py[R516-540]

+    from agent_scan.pushkeys import mint_push_key, revoke_push_key
+
    rich.print(
-        "Go to https://app.snyk.io and select the tenant on the left nav bar. Copy the Tenant ID from the URL and paste it here: "
+        "Go to https://app.snyk.io and select the tenant on the left nav bar. "
+        "Copy the Tenant ID from the URL and paste it here: "
    )
    tenant_id = input().strip()
    rich.print("Paste the Authorization token from https://app.snyk.io/account (API Token -> KEY -> click to show): ")
    token = input().strip()

-    push_key_url = f"https://api.snyk.io/hidden/tenants/{tenant_id}/mcp-scan/push-key?version=2025-08-28"
-    push_scan_url = "https://api.snyk.io/hidden/mcp-scan/push?version=2025-08-28"
+    base_url = "https://api.snyk.io"
+    push_scan_url = f"{base_url}/hidden/mcp-scan/push?version=2025-08-28"

-    # create a client_id (shared secret)
-    client_id = None
-    skip_ssl_verify = getattr(args, "skip_ssl_verify", False)
-    trace_configs = setup_aiohttp_debug_logging(verbose=False)
+    # Mint a push key
    try:
-        async with aiohttp.ClientSession(
-            trace_configs=trace_configs,
-            connector=setup_tcp_connector(skip_ssl_verify=skip_ssl_verify),
-            trust_env=True,
-        ) as session:
-            async with session.post(
-                push_key_url, data="", headers={"Content-Type": "application/json", "Authorization": f"token {token}"}
-            ) as resp:
-                if resp.status not in (200, 201):
-                    text = await resp.text()
-                    rich.print(f"[bold red]Request failed[/bold red]: HTTP {resp.status} - {text}")
-                    return
-                data = await resp.json()
-                client_id = data.get("client_id")
-                if not client_id:
-                    rich.print(f"[bold red]Unexpected response[/bold red]: {data}")
-                    return
-                rich.print("Client ID created")
-    except Exception as e:
+        client_id = mint_push_key(base_url, tenant_id, token)
+        rich.print("Client ID created")
+    except RuntimeError as e:
        rich.print(f"[bold red]Error calling Snyk API[/bold red]: {e}")
        return

-    # Update the default scan args
+    # Run scan with the push key
    args.control_servers = [
        ControlServer(
            url=push_scan_url,
Evidence
The CLI defines --skip-ssl-verify, but evo() does not read/pass it; mint/revoke use urllib.urlopener
without any SSL-context/verification controls, so the earlier capability to disable verification for
evo’s push-key operations is gone.

src/agent_scan/cli.py[187-193]
src/agent_scan/cli.py[516-553]
src/agent_scan/pushkeys.py[36-52]
src/agent_scan/pushkeys.py[70-86]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`evo` exposes `--skip-ssl-verify` but push-key mint/revoke now uses `urllib.request.urlopen()` with no way to disable certificate verification, causing a behavior regression.

### Issue Context
`evo()` is async; the new synchronous network calls also block the event loop.

### Fix Focus Areas
- src/agent_scan/cli.py[508-553]
- src/agent_scan/pushkeys.py[25-86]

### Suggested change
1) Add an optional `skip_ssl_verify: bool = False` parameter to `mint_push_key()` and `revoke_push_key()`.
2) When `skip_ssl_verify` is true, pass an unverified SSL context to `urlopen(..., context=...)`.
3) In `evo()`, read `args.skip_ssl_verify` and pass it through.
4) (Optional) To avoid blocking the event loop, call the push-key functions via `await asyncio.to_thread(...)`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

ⓘ The new review experience is currently in Beta. Learn more

Grey Divider

Qodo Logo

@qodo-merge-etso
Copy link
Copy Markdown

CI Feedback 🧐

A test triggered by this PR failed. Here is an AI-generated analysis of the failure:

Action: test (windows-latest)

Failed stage: Run tests [❌]

Failed test name: tests/unit/test_guard.py::TestBuildHookCommand::test_without_tenant

Failure summary:

The action failed because a pytest unit test assertion failed:
-
tests/unit/test_guard.py::TestBuildHookCommand::test_without_tenant failed at
tests\unit\test_guard.py:151.
- The test expected the hook command to contain bash '/x/hook.sh', but
the actual command built on the Windows runner used backslash path escaping: bash '\x\hook.sh'
--client claude-code.
- This mismatch caused the pytest run to exit non-zero (make: ***
[Makefile:28: ci] Error 1), failing the GitHub Action.

Relevant error logs:
1:  ##[group]Runner Image Provisioner
2:  Hosted Compute Agent
...

147:  ignore-empty-workdir: false
148:  add-problem-matchers: true
149:  env:
150:  AGENT_SCAN_ENVIRONMENT: ci
151:  AGENT_SCAN_CI_HOSTNAME: ***
152:  SNYK_TOKEN: ***
153:  ##[endgroup]
154:  Trying to find version for uv in: D:\a\agent-scan\agent-scan\uv.toml
155:  Could not find file: D:\a\agent-scan\agent-scan\uv.toml
156:  Trying to find version for uv in: D:\a\agent-scan\agent-scan\pyproject.toml
157:  Could not determine uv version from uv.toml or pyproject.toml. Falling back to latest.
158:  Getting latest version from GitHub API...
159:  manifest-file not provided, reading from local file.
160:  manifest-file does not contain version 0.11.2, arch x86_64, platform pc-windows-msvc. Falling back to GitHub releases.
161:  Downloading uv from "https://github.com/astral-sh/uv/releases/download/0.11.2/uv-x86_64-pc-windows-msvc.zip" ...
162:  [command]"C:\Program Files\PowerShell\7\pwsh.exe" -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Unrestricted -Command "$ErrorActionPreference = 'Stop' ; try { Add-Type -AssemblyName System.IO.Compression.ZipFile } catch { } ; try { [System.IO.Compression.ZipFile]::ExtractToDirectory('D:\a\_temp\63ae66bd-d08d-4375-8a39-3c6a47706e26.zip', 'D:\a\_temp\5efa47c0-3749-433b-bb7b-130b40250cf9', $true) } catch { if (($_.Exception.GetType().FullName -eq 'System.Management.Automation.MethodException') -or ($_.Exception.GetType().FullName -eq 'System.Management.Automation.RuntimeException') ){ Expand-Archive -LiteralPath 'D:\a\_temp\63ae66bd-d08d-4375-8a39-3c6a47706e26.zip' -DestinationPath 'D:\a\_temp\5efa47c0-3749-433b-bb7b-130b40250cf9' -Force } else { throw $_ } } ;"
163:  Set UV_TOOL_BIN_DIR to D:\a\_temp\uv-tool-bin-dir
...

460:  tests/unit/test_cli_parsing.py::TestControlServerParsing::test_parse_control_servers[options_before_first_server_ignored] PASSED [ 11%]
461:  tests/unit/test_cli_parsing.py::TestControlServerParsing::test_parse_control_servers[no_control_servers] PASSED [ 11%]
462:  tests/unit/test_cli_parsing.py::TestControlServerParsing::test_parse_control_servers[control_server_without_url] PASSED [ 12%]
463:  tests/unit/test_cli_parsing.py::TestControlServerParsing::test_parse_control_servers[url_starts_with_dash] PASSED [ 12%]
464:  tests/unit/test_cli_parsing.py::TestControlServerParsing::test_parse_control_servers[with_other_cli_args] PASSED [ 12%]
465:  tests/unit/test_cli_parsing.py::TestControlServerParsing::test_parse_control_servers[single_server_with_multiple_headers] PASSED [ 12%]
466:  tests/unit/test_cli_parsing.py::TestControlServerParsing::test_parse_control_servers_missing_identifier[single_server_no_identifier] PASSED [ 13%]
467:  tests/unit/test_cli_parsing.py::TestControlServerParsing::test_parse_control_servers_missing_identifier[single_server_headers_only_no_identifier] PASSED [ 13%]
468:  tests/unit/test_cli_parsing.py::TestControlServerParsing::test_parse_control_servers_missing_identifier[multiple_servers_one_missing_identifier] PASSED [ 13%]
469:  tests/unit/test_cli_parsing.py::TestControlServerParsing::test_parse_control_servers_missing_identifier[options_only_apply_to_preceding_server] PASSED [ 14%]
470:  tests/unit/test_cli_parsing.py::TestCLIArgumentParsing::test_scan_with_multiple_control_servers_parses_correctly PASSED [ 14%]
471:  tests/unit/test_cli_parsing.py::TestControlServerHeaderParsing::test_parse_headers_single_header PASSED [ 14%]
472:  tests/unit/test_cli_parsing.py::TestControlServerHeaderParsing::test_parse_headers_multiple_headers PASSED [ 15%]
473:  tests/unit/test_cli_parsing.py::TestControlServerHeaderParsing::test_parse_headers_none_input PASSED [ 15%]
474:  tests/unit/test_cli_parsing.py::TestControlServerHeaderParsing::test_parse_headers_empty_list PASSED [ 15%]
475:  tests/unit/test_cli_parsing.py::TestControlServerHeaderParsing::test_parse_headers_invalid_format_raises_error PASSED [ 16%]
476:  tests/unit/test_cli_parsing.py::TestControlServerUploadIntegration::test_control_servers_passed_to_pipeline PASSED [ 16%]
...

487:  tests/unit/test_config_scan.py::test_scan_mcp_config[vscode_mcp_config_file] PASSED [ 19%]
488:  tests/unit/test_config_scan.py::test_scan_mcp_config[vscode_config_file] PASSED [ 20%]
489:  tests/unit/test_config_scan.py::test_scan_mcp_config[vscode_settings_file_with_empty_mcp] PASSED [ 20%]
490:  tests/unit/test_config_scan.py::test_scan_mcp_config[vscode_settings_file_without_mcp] PASSED [ 20%]
491:  tests/unit/test_config_scan.py::test_check_server_mocked PASSED          [ 20%]
492:  tests/unit/test_config_scan.py::test_math_server PASSED                  [ 21%]
493:  tests/unit/test_config_scan.py::test_all_server PASSED                   [ 21%]
494:  tests/unit/test_config_scan.py::test_weather_server PASSED               [ 21%]
495:  tests/unit/test_config_scan.py::test_vscode_settings_file_without_mcp PASSED [ 22%]
496:  tests/unit/test_config_scan.py::test_vscode_settings_file_with_empty_mcp PASSED [ 22%]
497:  tests/unit/test_control_server.py::test_upload_payload_excludes_hostname_and_username PASSED [ 22%]
498:  tests/unit/test_control_server.py::test_upload_sends_username_as_list_without_scanned_usernames PASSED [ 23%]
499:  tests/unit/test_control_server.py::test_upload_sends_username_as_list_with_empty_scanned_usernames PASSED [ 23%]
500:  tests/unit/test_control_server.py::test_upload_sends_username_list_with_single_user PASSED [ 23%]
501:  tests/unit/test_control_server.py::test_upload_sends_username_list_with_multiple_users PASSED [ 24%]
502:  tests/unit/test_control_server.py::test_upload_includes_scan_error_in_payload PASSED [ 24%]
503:  tests/unit/test_control_server.py::test_upload_file_not_found_error_in_payload PASSED [ 24%]
504:  tests/unit/test_control_server.py::test_upload_parse_error_in_payload PASSED [ 25%]
505:  tests/unit/test_control_server.py::test_upload_server_http_error_in_payload PASSED [ 25%]
506:  tests/unit/test_control_server.py::test_upload_server_startup_error_in_payload PASSED [ 25%]
507:  tests/unit/test_control_server.py::test_upload_retries_on_network_error PASSED [ 25%]
508:  tests/unit/test_control_server.py::test_upload_retries_on_server_error PASSED [ 26%]
509:  tests/unit/test_control_server.py::test_upload_does_not_retry_on_client_error PASSED [ 26%]
510:  tests/unit/test_control_server.py::test_upload_succeeds_on_second_attempt PASSED [ 26%]
511:  tests/unit/test_control_server.py::test_upload_custom_max_retries PASSED [ 27%]
512:  tests/unit/test_control_server.py::test_upload_exponential_backoff PASSED [ 27%]
513:  tests/unit/test_control_server.py::test_upload_does_not_retry_on_unexpected_error PASSED [ 27%]
514:  tests/unit/test_control_server.py::test_upload_unknown_mcp_config_error_in_payload PASSED [ 28%]
515:  tests/unit/test_entity_to_tool.py::test_entity_to_tool[tests/mcp_servers/signatures/math_server_signature.json] PASSED [ 28%]
...

523:  tests/unit/test_guard.py::TestShellQuote::test_with_single_quote PASSED  [ 30%]
524:  tests/unit/test_guard.py::TestShellQuote::test_empty PASSED              [ 31%]
525:  tests/unit/test_guard.py::TestMaskKey::test_short_key PASSED             [ 31%]
526:  tests/unit/test_guard.py::TestMaskKey::test_exactly_8 PASSED             [ 31%]
527:  tests/unit/test_guard.py::TestMaskKey::test_long_key PASSED              [ 32%]
528:  tests/unit/test_guard.py::TestCompactEvents::test_empty PASSED           [ 32%]
529:  tests/unit/test_guard.py::TestCompactEvents::test_one PASSED             [ 32%]
530:  tests/unit/test_guard.py::TestCompactEvents::test_two PASSED             [ 33%]
531:  tests/unit/test_guard.py::TestCompactEvents::test_three PASSED           [ 33%]
532:  tests/unit/test_guard.py::TestCompactEvents::test_nine PASSED            [ 33%]
533:  tests/unit/test_guard.py::TestExtractEnvFromCmd::test_single_quoted PASSED [ 33%]
534:  tests/unit/test_guard.py::TestExtractEnvFromCmd::test_unquoted PASSED    [ 34%]
535:  tests/unit/test_guard.py::TestExtractEnvFromCmd::test_missing PASSED     [ 34%]
536:  tests/unit/test_guard.py::TestExtractEnvFromCmd::test_multiple_keys PASSED [ 34%]
537:  tests/unit/test_guard.py::TestExtractEnvFromCmd::test_tenant_id PASSED   [ 35%]
538:  tests/unit/test_guard.py::TestBuildHookCommand::test_without_tenant FAILED [ 35%]
539:  tests/unit/test_guard.py::TestBuildHookCommand::test_with_tenant PASSED  [ 35%]
...

697:  tests/unit/test_utils.py::TestRebalanceCommandArgsWithSpacesInPath::test_full_command_is_path_with_spaces_no_args PASSED [ 84%]
698:  tests/unit/test_utils.py::test_calculate_distance PASSED                 [ 84%]
699:  tests/unit/test_utils.py::TestSuppressStdout::test_suppress_stdout_suppresses_print PASSED [ 85%]
700:  tests/unit/test_utils.py::TestSuppressStdout::test_suppress_stdout_restores_stdout_after_context PASSED [ 85%]
701:  tests/unit/test_utils.py::TestSuppressStdout::test_suppress_stdout_works_with_multiple_prints PASSED [ 85%]
702:  tests/unit/test_verify_api.py::TestProxySupport::test_analyze_machine_honors_http_proxy_env PASSED [ 86%]
703:  tests/unit/test_verify_api.py::TestProxySupport::test_analyze_machine_honors_https_proxy_env PASSED [ 86%]
704:  tests/unit/test_verify_api.py::TestProxySupport::test_analyze_machine_works_without_proxy PASSED [ 86%]
705:  tests/unit/test_verify_api.py::TestProxySupport::test_analyze_machine_with_skip_ssl_verify_and_proxy PASSED [ 87%]
706:  tests/unit/test_verify_api.py::TestProxySupport::test_setup_tcp_connector_with_ssl_verify PASSED [ 87%]
707:  tests/unit/test_verify_api.py::TestProxySupport::test_setup_tcp_connector_without_ssl_verify PASSED [ 87%]
708:  tests/unit/test_verify_api.py::TestAnalyzeMachineRetries::test_analyze_machine_retries_on_timeout PASSED [ 87%]
709:  tests/unit/test_verify_api.py::TestAnalyzeMachineHeaders::test_analyze_machine_includes_additional_headers PASSED [ 88%]
710:  tests/unit/test_verify_api.py::TestAnalyzeMachineScanMetadata::test_analyze_machine_includes_scan_metadata_when_scan_context_provided PASSED [ 88%]
711:  tests/unit/test_verify_api.py::TestAnalyzeMachineScanMetadata::test_analyze_machine_omits_scan_metadata_when_scan_context_not_provided PASSED [ 88%]
712:  tests/unit/test_verify_api.py::TestAnalyzeMachineHttpErrors::test_analyze_machine_http_error_responses[400] PASSED [ 89%]
713:  tests/unit/test_verify_api.py::TestAnalyzeMachineHttpErrors::test_analyze_machine_http_error_responses[401] PASSED [ 89%]
714:  tests/unit/test_verify_api.py::TestAnalyzeMachineHttpErrors::test_analyze_machine_http_error_responses[403] PASSED [ 89%]
715:  tests/unit/test_verify_api.py::TestAnalyzeMachineHttpErrors::test_analyze_machine_http_error_responses[413] PASSED [ 90%]
716:  tests/unit/test_verify_api.py::TestAnalyzeMachineHttpErrors::test_analyze_machine_http_error_responses[422] PASSED [ 90%]
717:  tests/unit/test_verify_api.py::TestAnalyzeMachineHttpErrors::test_analyze_machine_http_error_responses[429] PASSED [ 90%]
718:  tests/unit/test_verify_api.py::TestAnalyzeMachineHttpErrors::test_analyze_machine_http_error_responses[500] PASSED [ 91%]
719:  tests/unit/test_verify_api.py::TestAnalyzeMachineHttpErrors::test_analyze_machine_http_error_responses[502] PASSED [ 91%]
720:  tests/unit/test_verify_api.py::TestAnalyzeMachineHttpErrors::test_analyze_machine_http_error_responses[503] PASSED [ 91%]
721:  tests/unit/test_verify_api.py::TestAnalyzeMachineHttpErrors::test_analyze_machine_http_error_responses[504] PASSED [ 91%]
722:  tests/v4compatibility/test_inspect.py::test_get_mcp_config_per_client[client0-valid-True] PASSED [ 92%]
...

733:  tests/v4compatibility/test_inspect.py::test_inspect_skill[canvas-design-skill_server2] PASSED [ 95%]
734:  tests/v4compatibility/test_inspect.py::test_inspect_skill[doc-coauthoring-skill_server3] PASSED [ 95%]
735:  tests/v4compatibility/test_inspect.py::test_inspect_skill[docx-skill_server4] PASSED [ 96%]
736:  tests/v4compatibility/test_inspect.py::test_inspect_skill[frontend-design-skill_server5] PASSED [ 96%]
737:  tests/v4compatibility/test_inspect.py::test_inspect_skill[internal-comms-skill_server6] PASSED [ 96%]
738:  tests/v4compatibility/test_inspect.py::test_inspect_skill[malicious-skill-skill_server7] PASSED [ 97%]
739:  tests/v4compatibility/test_inspect.py::test_inspect_skill[mcp-builder-skill_server8] PASSED [ 97%]
740:  tests/v4compatibility/test_inspect.py::test_inspect_skill[pdf-skill_server9] PASSED [ 97%]
741:  tests/v4compatibility/test_inspect.py::test_inspect_skill[pptx-skill_server10] PASSED [ 98%]
742:  tests/v4compatibility/test_inspect.py::test_inspect_skill[skill-creator-skill_server11] PASSED [ 98%]
743:  tests/v4compatibility/test_inspect.py::test_inspect_skill[slack-gif-creator-skill_server12] PASSED [ 98%]
744:  tests/v4compatibility/test_inspect.py::test_inspect_skill[theme-factory-skill_server13] PASSED [ 99%]
745:  tests/v4compatibility/test_inspect.py::test_inspect_skill[web-artifacts-builder-skill_server14] PASSED [ 99%]
746:  tests/v4compatibility/test_inspect.py::test_inspect_skill[webapp-testing-skill_server15] PASSED [ 99%]
747:  tests/v4compatibility/test_inspect.py::test_inspect_skill[xlsx-skill_server16] PASSED [100%]
748:  ================================== FAILURES ===================================
749:  __________________ TestBuildHookCommand.test_without_tenant ___________________
750:  self = <tests.unit.test_guard.TestBuildHookCommand object at 0x0000021D7AFD6C10>
751:  def test_without_tenant(self):
752:  cmd = _build_hook_command("pk", "https://api.snyk.io", Path("/x/hook.sh"), "claude-code")
753:  assert "PUSH_KEY='pk'" in cmd
754:  assert "REMOTE_HOOKS_BASE_URL='https://api.snyk.io'" in cmd
755:  assert "TENANT_ID" not in cmd
756:  >       assert "bash '/x/hook.sh'" in cmd
757:  E       assert "bash '/x/hook.sh'" in "PUSH_KEY='pk' REMOTE_HOOKS_BASE_URL='https://api.snyk.io' bash '\\x\\hook.sh' --client claude-code"
758:  tests\unit\test_guard.py:151: AssertionError
759:  ============================== warnings summary ===============================
760:  tests/unit/test_control_server.py: 29 warnings
761:  tests/unit/test_verify_api.py: 20 warnings
762:  D:\a\agent-scan\agent-scan\.venv\Lib\site-packages\aiohttp\connector.py:993: DeprecationWarning: enable_cleanup_closed ignored because https://github.com/python/cpython/pull/118960 is fixed in Python version sys.version_info(major=3, minor=13, micro=12, releaselevel='final', serial=0)
763:  super().__init__(
764:  -- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
765:  =========================== short test summary info ===========================
766:  FAILED tests/unit/test_guard.py::TestBuildHookCommand::test_without_tenant - assert "bash '/x/hook.sh'" in "PUSH_KEY='pk' REMOTE_HOOKS_BASE_URL='https://api.snyk.io' bash '\\x\\hook.sh' --client claude-code"
767:  ==== 1 failed, 323 passed, 34 deselected, 49 warnings in 210.21s (0:03:30) ====
768:  make: *** [Makefile:28: ci] Error 1
769:  ##[error]Process completed with exit code 1.
770:  Post job cleanup.

Comment on lines +98 to +181
def _run_install(args) -> None:
client: str = args.client
url: str = args.url
push_key = os.environ.get("PUSH_KEY", "")
headless = bool(push_key)
tenant_id: str = getattr(args, "tenant_id", None) or ""

label = _client_label(client)
snyk_token = ""

if not headless:
# Interactive flow — mint a push key
rich.print(f"Installing [bold magenta]Agent Guard[/bold magenta] hooks for [bold]{label}[/bold]")
rich.print()

snyk_token = os.environ.get("SNYK_TOKEN", "")
if not snyk_token:
rich.print("Paste your Snyk API token ( from https://app.snyk.io/account ):")
snyk_token = input().strip()
if not snyk_token:
rich.print("[bold red]Error:[/bold red] SNYK_TOKEN is required to mint a push key.")
sys.exit(1)

if not tenant_id:
tenant_id = os.environ.get("TENANT_ID", "")
if not tenant_id:
rich.print("Enter your Snyk Tenant ID ( from the URL at https://app.snyk.io ):")
tenant_id = input().strip()
if not tenant_id:
rich.print("[bold red]Error:[/bold red] Tenant ID is required to mint a push key.")
sys.exit(1)

description = _get_machine_description(client)
rich.print(f"[dim]Minting push key for {description}...[/dim]")
try:
push_key = mint_push_key(url, tenant_id, snyk_token, description=description)
except RuntimeError as e:
rich.print(f"[bold red]Error:[/bold red] {e}")
sys.exit(1)
rich.print(f"[green]\u2713[/green] Push key minted [yellow]{_mask_key(push_key)}[/yellow]")

hook_client = "claude-code" if client == "claude" else "cursor"
minted = not headless # True if we minted the key in this run
config_path = _config_path(client, getattr(args, "file", None))
# Copy hook script first so we can use it for the test event
dest_path, script_existed, script_updated = _copy_hook_script(client)

first_install = not config_path.exists() or not script_existed
run_test = first_install or minted or getattr(args, "test", False)

# Verify connectivity by invoking the actual hook script
if run_test and not _send_test_event(push_key, url, hook_client, dest_path):
# Clean up copied script only if it didn't exist before
if not script_existed:
dest_path.unlink(missing_ok=True)
if minted:
rich.print("[dim]Revoking minted push key...[/dim]")
try:
revoke_push_key(url, tenant_id, snyk_token, push_key)
rich.print("[green]\u2713[/green] Push key revoked")
except RuntimeError as e:
rich.print(f"[yellow]Warning:[/yellow] Could not revoke push key: {e}")
rich.print("[bold red]Aborting install — test event failed.[/bold red]")
raise SystemExit(1)

# Build command string and edit client config
command = _build_hook_command(push_key, url, dest_path, hook_client, tenant_id=tenant_id)

if client == "claude":
config_changed = _install_claude(command, config_path)
elif client == "cursor":
config_changed = _install_cursor(command, config_path)

if script_updated or config_changed or minted:
rich.print(f"[green]\u2713[/green] Hooks installed for [bold]{label}[/bold]")
else:
rich.print(f"[green]\u2713[/green] {label} hook integration up to date")
rich.print(f" Config: [dim]{config_path}[/dim]")
rich.print(f" Script: [dim]{dest_path}[/dim]")
rich.print(f" Remote URL: [dim]{url}[/dim]")
rich.print(f" Push Key: [yellow]{_mask_key(push_key)}[/yellow]")
rich.print()


Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. _run_install() too long 📘 Rule violation ⚙ Maintainability

The new _run_install() function is ~80+ lines long, exceeding the 40 SLOC limit and making the
install flow harder to maintain and review. This should be split into smaller helpers (e.g.,
token/tenant collection, mint/revoke, test event, config write).
Agent Prompt
## Issue description
`_run_install()` exceeds the ≤40 line function-length compliance limit, making the installation flow difficult to maintain.

## Issue Context
The function currently mixes: interactive credential collection, push-key minting/revocation, test-event sending, config/script path resolution, and config file mutation.

## Fix Focus Areas
- src/agent_scan/guard.py[98-181]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +133 to +146
local -a curl_args
curl_args=(
-sS
-X POST
"$url"
-H "User-Agent: ${user_agent}"
-H "X-User: ${x_user}"
-H "Content-Type: text/plain"
-H "X-Client-Id: ${pushkey}"
--data-binary "${encoded_body}"
)

resp="$(curl "${curl_args[@]}" -w $'\n'"${marker}%{http_code}")" || die "Request failed"
http_code="${resp##*$'\n'"${marker}"}"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

2. Hook can hang indefinitely 🐞 Bug ☼ Reliability

The installed hook script calls curl without connect/overall timeouts, so network stalls can block
Claude/Cursor while the hook is executing. Because hooks run frequently, this can freeze the client
on transient network problems.
Agent Prompt
### Issue description
The hook script can block the IDE/client indefinitely because `curl` has no connection or total timeout.

### Issue Context
Hooks are executed inline by Claude Code/Cursor; a hung hook can stall the entire user workflow.

### Fix Focus Areas
- src/agent_scan/hooks/snyk-agent-guard.sh[133-146]

### Suggested change
Add sane defaults like `--connect-timeout 5` and `--max-time 15` (and optionally a small retry) to the curl invocation. Consider making these overridable via env vars (e.g., `SNYK_AGENT_GUARD_CONNECT_TIMEOUT`, `SNYK_AGENT_GUARD_MAX_TIME`) so enterprise environments can tune them.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

lbeurerkellner and others added 10 commits April 1, 2026 14:47
Trailing \r\n from [Console]::In.ReadToEnd() was getting base64-encoded,
causing the server to reject the payload as invalid JSON. Also send the
HTTP body as explicit UTF-8 bytes to avoid PowerShell's default encoding.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@mmilanta mmilanta left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good. Installed hooks on claude code with it and it looks fine.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants