Skip to content

M2: CA persistence + Keychain trust + system proxy toggle#73

Open
kalil0321 wants to merge 3 commits into
claude/system-proxy-monitor-UEsLpfrom
claude/proxy-monitor-m2-ca-keychain
Open

M2: CA persistence + Keychain trust + system proxy toggle#73
kalil0321 wants to merge 3 commits into
claude/system-proxy-monitor-UEsLpfrom
claude/proxy-monitor-m2-ca-keychain

Conversation

@kalil0321
Copy link
Copy Markdown
Owner

@kalil0321 kalil0321 commented May 19, 2026

Stacked on top of #72.

What's in

  • CAStore — persists root cert under Application Support, private key as PEM in the user Keychain (generic password). loadOrCreate / reset.
  • CertificateTrustInstaller — SecItemAdd + SecTrustSettingsSetTrustSettings on .user. First call triggers the system password prompt.
  • SystemProxyControllernetworksetup -listallnetworkservices, then toggles HTTP + HTTPS proxy on every enabled service. Admin escalation via NSAppleScript do shell script … with administrator privileges.
  • ProxyEngine.bootstrap(applicationSupportURL:) — factory wiring.
  • rae-proxy CLI--install-ca-trust, --enable-system-proxy, --reset-ca, signal-driven cleanup.

How to try

swift run rae-proxy --install-ca-trust --enable-system-proxy

Generated by Claude Code


Summary by cubic

Adds persistent root CA storage, user-level Keychain trust install, and a macOS system proxy toggle with admin escalation. Improves safety with snapshot-based rollback and reliable cleanup on exit.

  • New Features

    • CAStore: persists root cert (Application Support) and private key (Keychain); load/create/reset.
    • CertificateTrustInstaller: install/uninstall user trust via Security; isInstalled checks for trustRoot.
    • SystemProxyController: admin-escalated HTTP/HTTPS proxy for all services; snapshot()/restore(); isEnabled requires both.
    • ProxyEngine.bootstrap(...): builds the engine and exposes root DER/PEM helpers.
    • rae-proxy CLI: --install-ca-trust, --enable-system-proxy, --reset-ca, --exit-after-install; cleanup restores prior proxy state (falls back to disable).
  • Bug Fixes

    • Validate and quote proxy host; validate port.
    • --reset-ca: uninstall user trust using DER from disk even if the Keychain item is missing; then delete on-disk material.
    • Roll back system proxy and stop the engine if enable fails; ignore signal race before handler activation.

Written for commit 1d3b348. Summary will update on new commits. Review in cubic

Greptile Summary

This PR introduces persistent CA storage (CAStore), user-level certificate trust installation (CertificateTrustInstaller), and a system proxy toggle (SystemProxyController) with admin escalation via AppleScript, wired together through ProxyEngine.bootstrap and a new rae-proxy CLI.

  • CAStore persists the root cert to Application Support and the private key as PEM in the user Keychain; loadOrCreate now throws on load failure rather than silently rotating the CA.
  • CertificateTrustInstaller uses SecTrustSettingsSetTrustSettings on the .user domain, and isInstalled correctly inspects the returned CFArray for a trustRoot entry.
  • SystemProxyController validates and shell-quotes all inputs before building admin-escalated networksetup commands.
  • --reset-ca trust uninstall is gated on store.exists(), which silently skips the uninstall call whenever the cert file is present but the keychain item is missing.

Confidence Score: 4/5

Mostly safe to merge; one defect in the --reset-ca path can leave an orphaned trusted CA in the user trust store when the keychain item has been independently removed.

The --reset-ca path gates the trust-uninstall call on store.exists(), which requires both the cert file and the keychain item to be present. If the keychain item is gone, the uninstall is skipped and the old CA remains trusted indefinitely after reset.

macos/Sources/rae-proxy/main.swift — the --reset-ca block at lines 17-28

Important Files Changed

Filename Overview
macos/Sources/rae-proxy/main.swift CLI wiring for all new features; trust-uninstall in --reset-ca path is gated on store.exists() which silently skips the call when the keychain item is absent
macos/Sources/ReverseAPIProxy/CA/CAStore.swift New persistence layer for root CA; loadOrCreate now properly throws on load failure instead of silently rotating; keychain access pattern looks correct
macos/Sources/ReverseAPIProxy/System/CertificateTrustInstaller.swift isInstalled correctly inspects the CFArray for a trustRoot entry; install/uninstall use Security framework correctly
macos/Sources/ReverseAPIProxy/System/SystemProxyController.swift Host/port validation and shell-quoting in enable() look correct; snapshot/restore/disable implementations are clean
macos/Sources/ReverseAPIProxy/ProxyEngine+Bootstrap.swift Thin factory extension wiring CAStore into ProxyEngine; no issues
macos/Tests/ReverseAPIProxyTests/SystemProxyControllerTests.swift Unit tests cover shellQuote escaping, proxy output parsing, and input validation; good coverage of the pure-logic paths
Prompt To Fix All With AI
Fix the following 1 code review issue. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 1
macos/Sources/rae-proxy/main.swift:17-26
When the keychain item is missing but the cert file still exists, `store.exists()` returns `false` (it requires both to be present), so the entire trust-uninstall block is skipped. `store.reset()` then removes the cert file, leaving the old CA permanently installed in the user trust store with no backing files. Subsequent `--install-ca-trust` runs add a new cert alongside the orphaned trusted one. Uninstalling trust only needs the DER bytes from the cert file — not the private key — so reading `certificateURL` directly sidesteps this gap.

```suggestion
            if args.contains("--reset-ca") {
                if FileManager.default.fileExists(atPath: store.certificateURL.path),
                   let existingDER = try? Data(contentsOf: store.certificateURL) {
                    if installer.isInstalled(derBytes: existingDER) {
                        try installer.uninstall(derBytes: existingDER)
                    }
                }
                try store.reset()
```

Reviews (3): Last reviewed commit: "M2 review fixes + tests" | Re-trigger Greptile

Greptile also left 1 inline comment on this PR.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2f2389c06c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +35 to +36
"\(networksetup) -setwebproxystate \(q) off",
"\(networksetup) -setsecurewebproxystate \(q) off",
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Restore pre-existing proxy settings

In environments where a network service already has an HTTP or HTTPS proxy configured (for example a corporate proxy), enable() overwrites the proxy host/port for every service and the signal cleanup later calls disable(), which only turns both proxy states off. Exiting rae-proxy --enable-system-proxy therefore leaves those users with their original proxy disabled instead of restoring their previous host/port/enabled values; snapshot the -getwebproxy and -getsecurewebproxy state before mutating and restore it here.

Useful? React with 👍 / 👎.

Comment thread macos/Sources/rae-proxy/main.swift Outdated
Comment on lines +15 to +17
if args.contains("--reset-ca") {
try store.reset()
print("CA reset.")
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Uninstall the old trusted CA when resetting

When --reset-ca is used after the CA has previously been installed with --install-ca-trust, this deletes only the app's certificate file and private key, then immediately creates a new root. The old root certificate and its user trust settings remain in the Keychain because CertificateTrustInstaller.uninstall is never called before store.reset(), so repeated resets accumulate stale trusted MITM roots; load the existing DER and remove its trust settings before deleting the stored CA.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

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

5 issues found across 5 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread macos/Sources/rae-proxy/main.swift Outdated
Comment thread macos/Sources/ReverseAPIProxy/CA/CAStore.swift Outdated
Comment thread macos/Sources/ReverseAPIProxy/System/SystemProxyController.swift Outdated
Comment thread macos/Sources/ReverseAPIProxy/CA/CAStore.swift Outdated
Comment thread macos/Sources/ReverseAPIProxy/System/SystemProxyController.swift Outdated
Comment thread macos/Sources/ReverseAPIProxy/CA/CAStore.swift
Comment thread macos/Sources/ReverseAPIProxy/System/SystemProxyController.swift
Comment thread macos/Sources/rae-proxy/main.swift Outdated
@kind-agent
Copy link
Copy Markdown

kind-agent Bot commented May 19, 2026

⚠️ Error — The test run failed unexpectedly.

Grok 4 Fast is deprecated. xAI recommends switching to Grok 4.3 (https://openrouter.ai/x-ai/grok-4.3)

This is likely a transient issue. You can re-trigger a run from the dashboard.

kalil0321 pushed a commit that referenced this pull request May 19, 2026
Fixes for PR #73 review comments (greptile, cubic, codex):

- CAStore.loadOrCreate: only fall through to creation when the cert
  truly does not exist (silent CA rotation on transient keychain errors
  was the worst-case symptom). New explicit error cases
  (missingCertificateOnDisk, missingPrivateKeyInKeychain, etc.) and
  reset() now propagates real failures.
- CertificateTrustInstaller.isInstalled: actually inspect the trust
  settings array and only return true when a kSecTrustSettingsResult
  == trustRoot entry exists. Previously any trust settings (including
  "deny") were reported as installed.
- SystemProxyController:
  * shell-quote the host argument and validate against shell
    metacharacters before composing the admin shell command — the
    host was previously interpolated raw into a command run with
    administrator privileges
  * validate the port against 1..65535 before doing anything
  * isEnabled now requires BOTH HTTP and HTTPS proxy to be on
  * snapshot() / restore() helpers + ProxyServiceSnapshot record
    so callers can preserve user-configured proxies
  * parse() helper for -getwebproxy output, exposed for tests
- rae-proxy --reset-ca: uninstall the existing CA's user trust
  settings before deleting the on-disk material, so resets do not
  accumulate stale trusted MITM roots
- rae-proxy --enable-system-proxy: roll back system proxy + engine
  state when enable() fails partway
- rae-proxy SignalCleanup: call signal(sig, SIG_IGN) BEFORE
  source.resume() to close the race where the default handler can
  fire between resume() and the SIG_IGN call

Tests (SystemProxyControllerTests):
- shellQuote escapes single quotes and shell metacharacters
- parse(getWebProxyOutput:) handles enabled / disabled / extra
  whitespace
- enable rejects shell metacharacters, invalid ports, empty host
Copy link
Copy Markdown
Owner Author

@greptile review


Generated by Claude Code

Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

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

4 issues found across 5 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread macos/Sources/ReverseAPIProxy/CA/CAStore.swift
Comment thread macos/Tests/ReverseAPIProxyTests/SystemProxyControllerTests.swift
Comment thread macos/Tests/ReverseAPIProxyTests/SystemProxyControllerTests.swift
Comment thread macos/Tests/ReverseAPIProxyTests/SystemProxyControllerTests.swift
kalil0321 pushed a commit that referenced this pull request May 19, 2026
Fixes for PR #73 review comments (greptile, cubic, codex):

- CAStore.loadOrCreate: only fall through to creation when the cert
  truly does not exist (silent CA rotation on transient keychain errors
  was the worst-case symptom). New explicit error cases
  (missingCertificateOnDisk, missingPrivateKeyInKeychain, etc.) and
  reset() now propagates real failures.
- CertificateTrustInstaller.isInstalled: actually inspect the trust
  settings array and only return true when a kSecTrustSettingsResult
  == trustRoot entry exists. Previously any trust settings (including
  "deny") were reported as installed.
- SystemProxyController:
  * shell-quote the host argument and validate against shell
    metacharacters before composing the admin shell command — the
    host was previously interpolated raw into a command run with
    administrator privileges
  * validate the port against 1..65535 before doing anything
  * isEnabled now requires BOTH HTTP and HTTPS proxy to be on
  * snapshot() / restore() helpers + ProxyServiceSnapshot record
    so callers can preserve user-configured proxies
  * parse() helper for -getwebproxy output, exposed for tests
- rae-proxy --reset-ca: uninstall the existing CA's user trust
  settings before deleting the on-disk material, so resets do not
  accumulate stale trusted MITM roots
- rae-proxy --enable-system-proxy: roll back system proxy + engine
  state when enable() fails partway
- rae-proxy SignalCleanup: call signal(sig, SIG_IGN) BEFORE
  source.resume() to close the race where the default handler can
  fire between resume() and the SIG_IGN call

Tests (SystemProxyControllerTests):
- shellQuote escapes single quotes and shell metacharacters
- parse(getWebProxyOutput:) handles enabled / disabled / extra
  whitespace
- enable rejects shell metacharacters, invalid ports, empty host
@kalil0321 kalil0321 force-pushed the claude/proxy-monitor-m2-ca-keychain branch from e78f8e9 to fbf9bd7 Compare May 19, 2026 12:57
Comment thread macos/Sources/rae-proxy/main.swift Outdated
Copy link
Copy Markdown
Owner Author

@greptile review


Generated by Claude Code

Comment thread macos/Sources/rae-proxy/main.swift Outdated
Comment on lines +17 to +26
if args.contains("--reset-ca") {
if store.exists() {
if let existing = try? store.load() {
let existingDER = Data(try existing.derBytes())
if installer.isInstalled(derBytes: existingDER) {
try installer.uninstall(derBytes: existingDER)
}
}
}
try store.reset()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 When the keychain item is missing but the cert file still exists, store.exists() returns false (it requires both to be present), so the entire trust-uninstall block is skipped. store.reset() then removes the cert file, leaving the old CA permanently installed in the user trust store with no backing files. Subsequent --install-ca-trust runs add a new cert alongside the orphaned trusted one. Uninstalling trust only needs the DER bytes from the cert file — not the private key — so reading certificateURL directly sidesteps this gap.

Suggested change
if args.contains("--reset-ca") {
if store.exists() {
if let existing = try? store.load() {
let existingDER = Data(try existing.derBytes())
if installer.isInstalled(derBytes: existingDER) {
try installer.uninstall(derBytes: existingDER)
}
}
}
try store.reset()
if args.contains("--reset-ca") {
if FileManager.default.fileExists(atPath: store.certificateURL.path),
let existingDER = try? Data(contentsOf: store.certificateURL) {
if installer.isInstalled(derBytes: existingDER) {
try installer.uninstall(derBytes: existingDER)
}
}
try store.reset()
Prompt To Fix With AI
This is a comment left during a code review.
Path: macos/Sources/rae-proxy/main.swift
Line: 17-26

Comment:
When the keychain item is missing but the cert file still exists, `store.exists()` returns `false` (it requires both to be present), so the entire trust-uninstall block is skipped. `store.reset()` then removes the cert file, leaving the old CA permanently installed in the user trust store with no backing files. Subsequent `--install-ca-trust` runs add a new cert alongside the orphaned trusted one. Uninstalling trust only needs the DER bytes from the cert file — not the private key — so reading `certificateURL` directly sidesteps this gap.

```suggestion
            if args.contains("--reset-ca") {
                if FileManager.default.fileExists(atPath: store.certificateURL.path),
                   let existingDER = try? Data(contentsOf: store.certificateURL) {
                    if installer.isInstalled(derBytes: existingDER) {
                        try installer.uninstall(derBytes: existingDER)
                    }
                }
                try store.reset()
```

How can I resolve this? If you propose a fix, please make it concise.

kalil0321 pushed a commit that referenced this pull request May 19, 2026
…sertions

Addresses additional review comments on PR #73:

- main.swift --reset-ca: read the certificate DER directly from
  store.certificateURL instead of going through store.exists() +
  store.load(). The previous code skipped trust uninstall whenever
  the keychain private key was missing but the .cer file still
  existed, leaving an orphaned trusted CA in the user trust store
  after reset (r3266534877).
- main.swift SignalCleanup: snapshot proxy state with
  systemProxy.snapshot() before calling enable(), and restore it
  in the cleanup closure instead of disable()ing unconditionally.
  Falls back to disable() only when no snapshot could be captured.
  Preserves any pre-existing user / corporate proxy (r3266475870,
  follow-up to chatgpt-codex-connector comment).
- SystemProxyControllerTests: assert SystemProxyError.invalidHost /
  invalidPort on validation failures so the tests do not silently
  pass on unrelated shell errors (cubic comments r3266396504,
  r3266396510, r3266396519).
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

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

1 issue found across 2 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread macos/Sources/rae-proxy/main.swift Outdated
claude added 3 commits May 19, 2026 18:43
- CAStore: persists root cert to Application Support, private key
  to the user Keychain as a generic password (PEM-encoded)
- CertificateTrustInstaller: SecItem + SecTrustSettings (user domain)
  for install / uninstall / status
- SystemProxyController: networksetup wrapped via AppleScript
  'with administrator privileges' for HTTP + HTTPS proxy on every
  enabled network service
- ProxyEngine.bootstrap(applicationSupportURL:) helper for app and CLI
- rae-proxy CLI: --install-ca-trust, --enable-system-proxy, --reset-ca
  flags + signal-driven cleanup that restores network state on Ctrl-C
Fixes for PR #73 review comments (greptile, cubic, codex):

- CAStore.loadOrCreate: only fall through to creation when the cert
  truly does not exist (silent CA rotation on transient keychain errors
  was the worst-case symptom). New explicit error cases
  (missingCertificateOnDisk, missingPrivateKeyInKeychain, etc.) and
  reset() now propagates real failures.
- CertificateTrustInstaller.isInstalled: actually inspect the trust
  settings array and only return true when a kSecTrustSettingsResult
  == trustRoot entry exists. Previously any trust settings (including
  "deny") were reported as installed.
- SystemProxyController:
  * shell-quote the host argument and validate against shell
    metacharacters before composing the admin shell command — the
    host was previously interpolated raw into a command run with
    administrator privileges
  * validate the port against 1..65535 before doing anything
  * isEnabled now requires BOTH HTTP and HTTPS proxy to be on
  * snapshot() / restore() helpers + ProxyServiceSnapshot record
    so callers can preserve user-configured proxies
  * parse() helper for -getwebproxy output, exposed for tests
- rae-proxy --reset-ca: uninstall the existing CA's user trust
  settings before deleting the on-disk material, so resets do not
  accumulate stale trusted MITM roots
- rae-proxy --enable-system-proxy: roll back system proxy + engine
  state when enable() fails partway
- rae-proxy SignalCleanup: call signal(sig, SIG_IGN) BEFORE
  source.resume() to close the race where the default handler can
  fire between resume() and the SIG_IGN call

Tests (SystemProxyControllerTests):
- shellQuote escapes single quotes and shell metacharacters
- parse(getWebProxyOutput:) handles enabled / disabled / extra
  whitespace
- enable rejects shell metacharacters, invalid ports, empty host
…sertions

Addresses additional review comments on PR #73:

- main.swift --reset-ca: read the certificate DER directly from
  store.certificateURL instead of going through store.exists() +
  store.load(). The previous code skipped trust uninstall whenever
  the keychain private key was missing but the .cer file still
  existed, leaving an orphaned trusted CA in the user trust store
  after reset (r3266534877).
- main.swift SignalCleanup: snapshot proxy state with
  systemProxy.snapshot() before calling enable(), and restore it
  in the cleanup closure instead of disable()ing unconditionally.
  Falls back to disable() only when no snapshot could be captured.
  Preserves any pre-existing user / corporate proxy (r3266475870,
  follow-up to chatgpt-codex-connector comment).
- SystemProxyControllerTests: assert SystemProxyError.invalidHost /
  invalidPort on validation failures so the tests do not silently
  pass on unrelated shell errors (cubic comments r3266396504,
  r3266396510, r3266396519).
@kalil0321 kalil0321 force-pushed the claude/proxy-monitor-m2-ca-keychain branch from 6a29aaf to 1d3b348 Compare May 19, 2026 16:46
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