Skip to content

Harden get_api_key() to match the credential precedence #295 gave discovery - #296

Open
anonpran wants to merge 4 commits into
stagingfrom
nanda/harden-get-api-key-machine-env
Open

Harden get_api_key() to match the credential precedence #295 gave discovery#296
anonpran wants to merge 4 commits into
stagingfrom
nanda/harden-get-api-key-machine-env

Conversation

@anonpran

@anonpran anonpran commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Summary

#295 fixed discovery's credential resolution: file-first, _machine_env() instead of os.getenv() (to stop a non-admin user's per-user setx from shadowing the admin-provisioned value via Windows' HKCU-over-HKLM merge). get_api_key() — the credential behind almost every other backend call the hook makes (policy decisions, transcript uploads, skill sync, error reporting) — had the exact same two gaps and was left untouched at the time, out of scope for that PR.

This applies the identical fix to get_api_key() in all 5 hooks.

Changes

  • claude-code/hooks/unbound.py, copilot/hooks/unbound.py, augment/hooks/unbound.py, cursor/unbound.py: flipped get_api_key() to check config.json first, falling back to _machine_env() only when the file can't supply a key. Exception handling shape preserved per file, just reordered.
  • codex/hooks/unbound.py: get_api_key() never had a file fallback, so it keeps that shape — only the os.getenv() call is replaced with _machine_env(). Also fixed two other call sites that bypassed get_api_key() with their own raw os.getenv(): main()'s duplicate inline lookup, and a third site in _repo_gate_report.

Test plan

  • Full suite: 2629 passed, 5 pre-existing failures (verified unrelated — same ones present before this change, e.g. a machine-dependent test and unrelated frozen-session tests)
  • Swept all 5 files for any remaining unprotected os.getenv('UNBOUND_*_API_KEY') — none left

Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com

Greptile Summary

This PR standardizes credential precedence across the five hook integrations and routes discovery credentials through a shared pair resolver.

  • Prefers administrator-controlled HKLM credentials on managed Windows devices.
  • Uses shared config before environment credentials on non-Windows systems.
  • Adds Codex shared-config support and replaces direct environment lookups.
  • Adds cross-agent tests for managed, personal Windows, and POSIX precedence.

Confidence Score: 3/5

This PR is not yet safe to merge because Codex can discard a successfully installed personal Windows credential and silently fail open when its shared config copy is unavailable.

Codex personal setup requires the HKCU setx write but treats the shared-config write as best effort; the new runtime resolver reads only HKLM and that config file, leaving policy incident reporting, telemetry, and synchronization without credentials in a supported completed-install state.

Files Needing Attention: codex/hooks/unbound.py and tests/test_discovery_dispatch.py

Security Review

Codex can still lose its valid personal Windows credential when the redundant config-file write fails or the file later becomes unreadable. Because the hook never consults HKCU after replacing os.getenv, policy incident reporting and other backend work can silently fail open.

Important Files Changed

Filename Overview
codex/hooks/unbound.py Adds config and HKLM-aware credential resolution, but drops the only direct path to a valid HKCU credential when personal setup’s config copy is unavailable.
tests/test_discovery_dispatch.py Covers normal managed, personal Windows, and POSIX precedence, but not the supported state where personal setx succeeds and the best-effort config write fails.
augment/hooks/unbound.py Aligns API-key and scan credential precedence with the managed Windows credential boundary.
claude-code/hooks/unbound.py Aligns API-key and scan credential precedence with the managed Windows credential boundary.
copilot/hooks/unbound.py Aligns API-key and scan credential precedence with the managed Windows credential boundary.
cursor/unbound.py Aligns API-key and scan credential precedence with the managed Windows credential boundary.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Codex personal Windows setup] --> B[Write key with per-user setx to HKCU]
  A --> C[Attempt shared config write]
  C -->|Success| D[get_api_key reads config]
  C -->|Failure or later corruption| E[get_api_key checks HKLM]
  E -->|No managed key| F[Returns no credential]
  F --> G[Policy and incident reports fail open]
  F --> H[Error reporting and skill sync are skipped]
Loading

Reviews (4): Last reviewed commit: "Close the discovery half of the same hol..." | Re-trigger Greptile

Greptile also left 1 inline comment on this PR.

Context used:

…covery

get_api_key() authenticates almost everything a hook sends to the
backend -- policy decisions, transcript uploads, skill sync, error
reporting -- not just discovery. It carried the same two gaps #295
just fixed there: env-first precedence (a stale personal-install env
var can outrank a fresh config.json) and a plain os.getenv() call
(which on Windows lets a non-admin user's own setx silently shadow
the admin-provisioned value via HKCU).

File is now checked first in all 5 hooks; env is consulted only when
the file can't supply a key, via _machine_env() instead of os.getenv().
codex's get_api_key() never had a file fallback at all, so it keeps
that shape -- just swapped its os.getenv() (and main()'s duplicate
inline lookup, and a third call site in _repo_gate_report that
bypassed get_api_key() entirely) for the hardened read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@anonpran
anonpran requested a review from a team September 3, 2026 20:09

@vigneshsubbiah16 vigneshsubbiah16 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

✅ Security consensus: no issues found. (reviewers: Cursor, Claude, Semgrep, Gitleaks)


🤖 consensus review · reviewers: Cursor,Claude,Semgrep,Gitleaks · head 8400e52a · 2026-09-03T20:10Z

Comment thread codex/hooks/unbound.py Outdated
…hooks have

Greptile caught a real regression: codex's personal (non-MDM) installer
writes the API key via setx with no /M -- HKCU, not HKLM -- same as the
other 4 tools' personal installers. Those are safe because their
get_api_key() checks config.json first, so HKCU vs HKLM never matters.
codex's get_api_key() had no file fallback at all; switching its env
read from os.getenv() to _machine_env() (HKLM-only, by design) meant a
personal Windows Codex install's key became unreachable by either
route -- HKCU is no longer read, and the file was never read.

codex's personal setup.py already writes ~/.unbound/config.json
(write_unbound_config, "shared with unbound-cli") -- get_api_key()
just never used it. Give it the same file-first shape as the other 4
hooks. Also simplifies main() to call get_api_key() instead of
duplicating the lookup inline, matching the other 4.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread codex/hooks/unbound.py

@vigneshsubbiah16 vigneshsubbiah16 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🛡️ Automated Security Review (consensus)

1 finding — 0 high-confidence, 1 to triage. Reviewers: Cursor, Claude, Semgrep, Gitleaks.

Findings

  • 🟡 [LOW] Malformed config.json parse errors may leak key material into logscodex/hooks/unbound.py:3942
    • Impact: The new file-read path catches json.JSONDecodeError in the broad except Exception handler and logs {e}; Python's decode error text can include a snippet of the malformed line, which may contain the API key if the file was corrupted mid-write — and log_error(..., 'config') may ship that text off-box.
    • Fix: Add a dedicated except json.JSONDecodeError: branch with a fixed message (no {e} interpolation), matching augment/claude-code; reserve the generic handler for I/O failures only.
    • Flagged by: Claude, Cursor

Previously acknowledged (not re-flagged)

  • File-first ~/.unbound/config.json precedence over _machine_env() / HKLM (Greptile P1) — Intentional design: mirrors the credential-resolution order shipped in #295 for discovery; config.json is treated as authoritative/freshest (rewritten on every MDM run), with machine env as fallback for launch contexts that never see the file.

🤖 consensus review · reviewers: Cursor, Claude, Semgrep, Gitleaks · head 30e3d4b1 · 2026-09-04T04:01Z

Greptile flagged the other side of the codex fix: making config.json
authoritative lets a managed Windows user override the
administrator-provisioned credential, since #295's mode=0o700 removal
means ~/.unbound is now user-writable by design. The MDM hook itself
sits in C:\Program Files\<Tool>\hooks (admin-only) for claude-code,
codex, augment and cursor, so the config file is the one remaining
path a managed user has to swap the key -- and with file-first, it won.

Precedence is now split by whether a tamper-proof source exists:
Windows reads HKLM first (admin-only; a user's writable config.json
can't override it), falling through to the file when HKLM is empty,
which is what a personal install looks like. POSIX has no admin-only
env scope -- MDM writes shell rc files in the user's own home -- so
there the file stays first as the freshest source, per #295.

Adds tests pinning all three cases; verified the tampered-config test
fails against the previous file-first ordering.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread codex/hooks/unbound.py
get_api_key() now prefers the admin-only HKLM value on Windows, but
_dispatch_discovery and _dispatch_mcp_server_scan still read the
user-writable config.json first -- so a managed user could still swap
the key or redirect scans there. Leaving two adjacent functions with
opposite precedence is its own hazard, so close it here rather than
in a follow-up.

The two call sites were byte-identical copy-paste, so this extracts
_resolve_credentials() instead of duplicating the fix: HKLM pair wins
on Windows, file wins elsewhere, resolved as a unit, with the existing
https check on the env-sourced URL preserved.

Reworks one #295 test whose premise doesn't hold on Windows: it
asserted a "stale personal-install env var" must not beat config.json,
but a personal install writes HKCU, which _machine_env never reads --
the fixture's os.environ stub conflated the two scopes. Now stubs no
machine value (the real personal-install case) and adds the managed
counterpart, asserting the HKLM pair beats a tampered config.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread codex/hooks/unbound.py
Comment on lines +3940 to +3953
machine_key = _machine_env('UNBOUND_CODEX_API_KEY')
if _is_windows() and machine_key:
return machine_key
try:
config_file = Path.home() / ".unbound" / "config.json"
with open(config_file, 'r', encoding='utf-8') as f:
key = json.loads(f.read()).get('api_key')
if key:
return key
except FileNotFoundError:
pass
except Exception as e:
log_error(f"Failed to read config file: {e}", 'config')
return machine_key

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 security Codex drops personal Windows credentials

When a personal Windows installation successfully writes UNBOUND_CODEX_API_KEY with setx but the best-effort config write fails or the file later becomes unreadable, get_api_key() checks only HKLM and the unavailable config file, causing policy incident reports and error reports to be dropped and skill synchronization to be skipped despite the valid HKCU credential. How this was verified: Personal setup requires the HKCU write but ignores config-write failure, while the changed resolver has no HKCU lookup and its missing-key call paths return without reporting.

Knowledge Base Used:

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Agentic security review of credential-precedence hardening in PR 296. One high-severity issue remains: _resolve_credentials() only treats HKLM as authoritative when both the API key and UNBOUND_BACKEND_URL are present, which does not match binary managed Windows setup.

Open in Web View Automation 

Sent by Cursor Security Agent: Security Reviewer

Comment thread cursor/unbound.py
if machine_url and not machine_url.startswith("https://"):
log_error("env base_url is not https, ignoring", category)
machine_key = machine_url = None
if _is_windows() and machine_key and machine_url:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Agentic Security Review
Severity: HIGH

_resolve_credentials() treats the admin-only HKLM values as authoritative only when both UNBOUND_*_API_KEY and UNBOUND_BACKEND_URL are present. unbound-hook setup writes the API key system-wide and the backend URL only into user-writable ~/.unbound/config.json, so the complete-pair check is skipped on that managed path and discovery/MCP dispatch fall through to the config pair.

A non-admin managed Windows user can keep the provisioned api_key in that file, change base_url to an attacker origin, and send discovery/MCP traffic (including the admin-provisioned key, and MCP server secrets via UNBOUND_MCP_SERVER_JSON) to that origin. The same helper is copied into all five hooks. get_api_key() correctly prefers a key-only HKLM value; this helper does not. Python MDM scripts that also set UNBOUND_BACKEND_URL in HKLM are not affected unless that URL is missing or fails the https:// prefix check (which zeros both machine fields and again falls through to config).

Impact: A less-privileged Windows user can redirect discovery and MCP-scan traffic that authenticates with the admin-provisioned API key. PreToolUse policy still uses get_api_key() and is not bypassed by this gap.

Fix in Cursor Fix in Web

Reviewed by Cursor Security Reviewer for commit e3c677f. Configure here.

@vigneshsubbiah16 vigneshsubbiah16 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🛡️ Automated Security Review (consensus)

3 findings — 2 high-confidence, 1 to triage. Reviewers: Cursor, Claude, Semgrep, Gitleaks.

  • 🔴 [HIGH] Managed-Windows _resolve_credentials() falls through to user-writable config.jsoncursor/unbound.py:3521 (identical helper in all 5 hooks)

    • Impact: When HKLM has the admin-provisioned API key but no UNBOUND_BACKEND_URL, a non-admin user can keep api_key in ~/.unbound/config.json and swap base_url to an attacker origin — discovery/MCP-scan subprocesses then ship the admin key (and MCP secrets) to that host; get_api_key() is not affected.
    • Fix: Gate on the machine key alone (match get_api_key()): if _is_windows() and machine_key is set, never accept config.json's base_url; use HKLM URL or fail closed. Longer term, write UNBOUND_BACKEND_URL to HKLM during managed setup.
    • Flagged by: Cursor, Claude
  • 🔴 [HIGH] Codex personal Windows installs can lose credentials and fail opencodex/hooks/unbound.py:3937

    • Impact: Replacing os.getenv() with HKLM-only _machine_env() drops the HKCU setx path; if the best-effort config.json write fails or the file is later unreadable, get_api_key() returns nothing and policy checks, incident reports, telemetry, and skill sync silently degrade.
    • Fix: Add an HKCU/process-env lookup as a final tier after HKLM + config, or make the personal-install config write mandatory (fail loudly on error).
    • Flagged by: Claude, Greptile
  • 🟡 [MEDIUM] config.json base_url bypasses HTTPS validationcursor/unbound.py:3530 (all 5 hooks)

    • Impact: The https:// scheme check applies only to the HKLM/env URL; a user-writable http:// base_url in config sends the API key and discovery/MCP payloads over cleartext on POSIX and on Windows paths that hit the config pair.
    • Fix: Apply the same startswith("https://") check to config-sourced URLs before returning the pair; log and reject on failure.
    • Flagged by: Claude

🤖 consensus review · reviewers: Cursor, Claude, Semgrep, Gitleaks · head e3c677fb · 2026-09-04T05:03Z

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