Skip to content

Commit e76d670

Browse files
committed
Document macOS desktop flow and split slow e2e loop
1 parent 59ea9a0 commit e76d670

File tree

12 files changed

+274
-7
lines changed

12 files changed

+274
-7
lines changed

.github/pull_request_template.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
- [ ] `cargo fmt --check`
88
- [ ] `cargo clippy --all-targets --all-features -- -D warnings`
99
- [ ] `cargo build --locked`
10-
- [ ] `cargo test --locked -- --test-threads=1`
10+
- [ ] `cargo test --locked`
11+
- [ ] `cargo test --locked --test e2e_cli -- --ignored --test-threads=1`
1112
- [ ] `cargo doc --no-deps`
1213

1314
## Docs

.github/workflows/ci.yml

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,13 +66,27 @@ jobs:
6666
- uses: actions/checkout@v4
6767
- uses: dtolnay/rust-toolchain@stable
6868
- uses: Swatinem/rust-cache@v2
69-
- name: cargo test --locked -- --test-threads=1
70-
run: cargo test --locked -- --test-threads=1
69+
- name: cargo test --locked
70+
run: cargo test --locked
71+
72+
slow-daemon-tests:
73+
name: slow daemon tests (${{ matrix.os }})
74+
runs-on: ${{ matrix.os }}
75+
strategy:
76+
fail-fast: false
77+
matrix:
78+
os: ["ubuntu-latest", "macos-latest"]
79+
steps:
80+
- uses: actions/checkout@v4
81+
- uses: dtolnay/rust-toolchain@stable
82+
- uses: Swatinem/rust-cache@v2
83+
- name: cargo test --locked --test e2e_cli -- --ignored --test-threads=1
84+
run: cargo test --locked --test e2e_cli -- --ignored --test-threads=1
7185

7286
publish-dry-run:
7387
name: publish dry-run
7488
runs-on: ubuntu-latest
75-
needs: [fmt, clippy, build, test]
89+
needs: [fmt, clippy, build, test, slow-daemon-tests]
7690
steps:
7791
- uses: actions/checkout@v4
7892
- uses: dtolnay/rust-toolchain@stable

CONTRIBUTING.md

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,17 +37,27 @@ For the codebase module map, see [AGENTS.md](AGENTS.md) or [CLAUDE.md](CLAUDE.md
3737

3838
## Local Validation
3939

40-
Run these before you open a pull request:
40+
### Routine local iteration
41+
42+
Use this as the default local loop:
4143

4244
```bash
4345
cargo fmt --check
4446
cargo clippy --all-targets --all-features -- -D warnings
4547
cargo build --locked
46-
cargo test --locked -- --test-threads=1
48+
cargo test --locked
4749
cargo doc --no-deps
4850
```
4951

50-
The test suite includes cases that temporarily override `HOME`, so the documented full-suite command runs the tests serially to avoid cross-test environment races.
52+
### Full verification before a pull request
53+
54+
Run the slow daemon/proxy tier explicitly before you open a pull request:
55+
56+
```bash
57+
cargo test --locked --test e2e_cli -- --ignored --test-threads=1
58+
```
59+
60+
The default `cargo test --locked` path skips the slow daemon/proxy lifecycle e2e scenarios so day-to-day iteration stays tighter. CI still runs that ignored tier explicitly.
5161

5262
If you are changing release packaging, also run:
5363

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ KeyClaw ships first-class wrappers for Claude Code and Codex. In generic proxy m
7272
|------|------|-------|
7373
| Claude Code | `keyclaw claude ...` wrapper or `source ~/.keyclaw/env.sh` | First-class CLI path |
7474
| Codex | `keyclaw codex ...` wrapper or `source ~/.keyclaw/env.sh` | First-class CLI path |
75+
| macOS desktop apps | System proxy + trusted KeyClaw CA | Finder-launched apps do not inherit shell proxy env by default; see [docs/macos-gui-apps.md](docs/macos-gui-apps.md) |
7576
| ChatGPT / OpenAI web traffic | Local proxy + trusted CA | In scope at the host layer for `chatgpt.com` / `chat.openai.com` when traffic truly traverses the proxy |
7677
| Direct API clients | `HTTP_PROXY` / `HTTPS_PROXY` + KeyClaw CA | Default hosts include OpenAI, Anthropic, Google, Together, Groq, Mistral, Cohere, and DeepSeek |
7778

@@ -160,6 +161,8 @@ For tools outside the built-in `claude` / `codex` wrappers:
160161
3. Route the client through `http://127.0.0.1:8877`, either via shell env vars or app/OS proxy settings.
161162
4. Verify with `keyclaw doctor` and a real request that traffic is actually being intercepted.
162163

164+
GUI desktop apps launched outside your shell do not necessarily inherit `source ~/.keyclaw/env.sh`. On macOS, the reliable path for `Claude.app`, `Codex.app`, `ChatGPT.app`, and similar desktop apps is to trust `~/.keyclaw/ca.crt` in the login keychain and use the macOS system proxy instead of assuming shell env injection will reach them. See [docs/macos-gui-apps.md](docs/macos-gui-apps.md) for the current supported flow and rollback steps.
165+
163166
Built-in generic-proxy hosts are:
164167

165168
- `api.openai.com`, `chat.openai.com`, `chatgpt.com`
@@ -229,6 +232,20 @@ If you want the proxy attached to the current terminal instead, use `keyclaw pro
229232

230233
> **Tip:** Add `[ -f ~/.keyclaw/env.sh ] && source ~/.keyclaw/env.sh` to your `~/.bashrc` or `~/.zshrc` to auto-route through KeyClaw in new shells while the proxy is already running. The detached proxy does not auto-start after reboot unless you enable `keyclaw proxy autostart enable`, so after a reboot you may need to start it again.
231234
235+
### macOS desktop apps
236+
237+
The CLI wrappers (`keyclaw claude ...`, `keyclaw codex ...`) are the preferred path on macOS because they inject proxy settings and CA trust into the child process directly.
238+
239+
Finder-launched apps are different: they are started by macOS, not by your shell, so they usually bypass `~/.keyclaw/env.sh` unless you configure the OS proxy itself. The current supported desktop-app flow is:
240+
241+
1. `keyclaw init`
242+
2. start a healthy proxy and confirm `keyclaw proxy status`
243+
3. trust `~/.keyclaw/ca.crt` in the macOS login keychain for SSL
244+
4. enable the macOS HTTP and HTTPS system proxy on the active network service
245+
5. fully quit and relaunch the desktop app
246+
247+
If the system proxy is enabled while no healthy KeyClaw listener is actually serving the advertised address, desktop apps and browsers can fail with proxy-connection errors. Use [docs/macos-gui-apps.md](docs/macos-gui-apps.md) for the exact commands, verification steps, and rollback path.
248+
232249
## How It Works
233250

234251
### Before: a real-looking request with a leaked AWS key

docs/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ KeyClaw's README is optimized for first contact. This directory holds the deeper
66

77
- [Architecture overview](architecture.md): request/response flow, major modules, and runtime trust boundaries
88
- [Configuration reference](configuration.md): config file sections, environment variables, allowlists, audit log behavior, and daemon restart semantics
9+
- [macOS desktop-app guide](macos-gui-apps.md): system-proxy and CA-trust setup for Finder-launched apps such as Claude.app, Codex.app, and ChatGPT.app
910
- [Supported secret patterns](secret-patterns.md): what the bundled rules catch, how entropy detection fits in, and how to add or override rules
1011
- [Threat model](threat-model.md): what KeyClaw protects against, what it does not, and how to deploy it safely
1112

docs/configuration.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,18 @@ include = ["*my-custom-api.com*"]
6767
rule_ids = ["generic-api-key"]
6868
patterns = ["^sk-test-"]
6969
secret_sha256 = ["0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"]
70+
71+
[[hooks]]
72+
event = "secret_detected"
73+
rule_ids = ["generic-api-key"]
74+
action = "exec"
75+
command = "notify-slack --channel security"
76+
77+
[[hooks]]
78+
event = "request_redacted"
79+
rule_ids = ["*"]
80+
action = "log"
81+
path = "~/.keyclaw/hooks.log"
7082
```
7183

7284
Supported top-level sections today:
@@ -79,6 +91,7 @@ Supported top-level sections today:
7991
- `audit`
8092
- `hosts`
8193
- `allowlist`
94+
- `hooks`
8295

8396
## Environment Variables
8497

@@ -129,13 +142,26 @@ By default, KeyClaw writes one JSON line per redacted secret to `~/.keyclaw/audi
129142
- Set `KEYCLAW_AUDIT_LOG=/path/to/file` or `[audit] path = "/path/to/file"` to relocate it.
130143
- Rotation is the operator's job; KeyClaw appends and does not rotate automatically.
131144

145+
## Hooks
146+
147+
Hooks let you trigger local actions from request-side rewrite events without exposing the raw secret value.
148+
149+
- `event = "secret_detected"` fires when KeyClaw finds a secret during request rewriting
150+
- `event = "request_redacted"` fires after the request has been rewritten, just before forwarding upstream
151+
- `action = "exec"` runs a local command with sanitized metadata in env vars and a JSON payload on `stdin`
152+
- `action = "log"` appends a JSON line to the configured file
153+
- `action = "block"` rejects matching `secret_detected` requests with `hook_blocked`
154+
155+
Hook payloads include only `event`, `rule_id`, `placeholder`, and `request_host`. Raw secrets are not passed to hook commands or hook log files.
156+
132157
## Daemon Restart Rules
133158

134159
If you run KeyClaw as a detached daemon with `keyclaw proxy`, daemon-side settings are read when that process starts. After changing `~/.keyclaw/config.toml` or variables such as `KEYCLAW_PROXY_ADDR`, `KEYCLAW_LOG_LEVEL`, `KEYCLAW_GITLEAKS_CONFIG`, `KEYCLAW_NOTICE_MODE`, or `KEYCLAW_REQUIRE_MITM_EFFECTIVE`, restart the proxy so the running daemon picks them up.
135160

136161
## Notes
137162

138163
- Repeated `--include` flags are available on `keyclaw proxy`, `keyclaw proxy start`, `keyclaw mitm`, `keyclaw codex`, and `keyclaw claude`. They are merged into the effective interception list for that process and accept `*` / `?` glob patterns.
164+
- GUI desktop apps launched by the OS usually do not inherit the shell proxy environment from `~/.keyclaw/env.sh`; on macOS, use the system proxy path documented in [macOS desktop-app guide](macos-gui-apps.md).
139165
- KeyClaw does not use or require `KEYCLAW_GITLEAKS_BIN`.
140166
- By default, KeyClaw creates a machine-local `vault.key` next to the vault instead of relying on a built-in shared passphrase.
141167
- `KEYCLAW_UNSAFE_LOG=true` is strictly for debugging and may expose raw secret material in logs.

docs/macos-gui-apps.md

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
# macOS Desktop-App Guide
2+
3+
KeyClaw's CLI wrappers are the preferred macOS path because they inject proxy settings and CA trust into the child process directly.
4+
5+
Finder-launched apps are different. `Claude.app`, `Codex.app`, `ChatGPT.app`, and similar GUI clients are launched by macOS, not by your shell, so they do not reliably inherit the proxy environment you get from `source ~/.keyclaw/env.sh`.
6+
7+
The current supported path for macOS desktop apps is:
8+
9+
1. initialize KeyClaw normally
10+
2. trust `~/.keyclaw/ca.crt` in the login keychain for SSL
11+
3. run a healthy KeyClaw proxy
12+
4. enable the macOS HTTP and HTTPS system proxy on the active network service
13+
5. fully relaunch the desktop app
14+
15+
## Prerequisites
16+
17+
Run the normal first-run setup first:
18+
19+
```bash
20+
keyclaw init
21+
keyclaw proxy
22+
keyclaw proxy status
23+
```
24+
25+
If `keyclaw proxy status` is not healthy, do not enable the macOS system proxy yet. A system proxy that points at a dead KeyClaw listener can break browser and desktop-app connectivity.
26+
27+
## Trust The KeyClaw CA
28+
29+
Trust `~/.keyclaw/ca.crt` in the login keychain for SSL:
30+
31+
```bash
32+
security add-trusted-cert -r trustRoot -p ssl -k ~/Library/Keychains/login.keychain-db ~/.keyclaw/ca.crt
33+
killall trustd || true
34+
```
35+
36+
Verify it:
37+
38+
```bash
39+
security verify-cert -c ~/.keyclaw/ca.crt -k ~/Library/Keychains/login.keychain-db -p ssl
40+
```
41+
42+
Use the user login keychain path above. The desktop-app trace showed that an incorrect trust-domain shape can still leave Electron/Chromium apps rejecting the KeyClaw CA with certificate-authority errors.
43+
44+
## Enable The System Proxy
45+
46+
Find the active network service if needed:
47+
48+
```bash
49+
route get default
50+
networksetup -listnetworkserviceorder
51+
```
52+
53+
Then enable the proxy on that service. Example for `Wi-Fi`:
54+
55+
```bash
56+
networksetup -setwebproxy "Wi-Fi" 127.0.0.1 8877 off
57+
networksetup -setsecurewebproxy "Wi-Fi" 127.0.0.1 8877 off
58+
networksetup -setwebproxystate "Wi-Fi" on
59+
networksetup -setsecurewebproxystate "Wi-Fi" on
60+
networksetup -setproxybypassdomains "Wi-Fi" localhost 127.0.0.1 "*.local" "169.254/16"
61+
```
62+
63+
Check the live state:
64+
65+
```bash
66+
networksetup -getwebproxy "Wi-Fi"
67+
networksetup -getsecurewebproxy "Wi-Fi"
68+
scutil --proxy
69+
```
70+
71+
You want both HTTP and HTTPS proxies enabled and pointing at `127.0.0.1:8877`.
72+
73+
## Relaunch And Verify
74+
75+
Fully quit and relaunch the desktop app after changing the proxy:
76+
77+
```bash
78+
osascript -e 'tell application "Claude" to quit' || true
79+
open -a Claude
80+
```
81+
82+
Use the same pattern for `Codex` or `ChatGPT`.
83+
84+
Useful verification checks:
85+
86+
```bash
87+
keyclaw proxy status
88+
lsof -nP -iTCP:8877
89+
tail -n 50 ~/.keyclaw/audit.log
90+
```
91+
92+
Healthy signs:
93+
94+
- the app or its network helper has a live `127.0.0.1:* -> 127.0.0.1:8877` connection
95+
- `keyclaw proxy status` reports the proxy as healthy
96+
- audit-log entries or runtime logs show real provider hosts instead of only `stdin` or localhost test traffic
97+
98+
## Roll Back
99+
100+
To stop forcing macOS desktop traffic through KeyClaw:
101+
102+
```bash
103+
networksetup -setwebproxystate "Wi-Fi" off
104+
networksetup -setsecurewebproxystate "Wi-Fi" off
105+
```
106+
107+
Then relaunch the desktop app again.
108+
109+
## Notes
110+
111+
- The CLI wrappers remain the simpler and higher-confidence path when they are available.
112+
- Desktop-app support depends on both correct CA trust and a healthy system-proxy listener.
113+
- If traffic still \"feels low,\" inspect `keyclaw proxy stats`, `~/.keyclaw/audit.log`, and runtime logs before assuming detection is broken.

tests/docs_placeholder_contract.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,37 @@ fn readme_project_structure_shows_split_proxy_and_launcher_modules() {
201201
}
202202
}
203203

204+
#[test]
205+
fn docs_cover_hooks_and_macos_desktop_app_flow() {
206+
let readme = std::fs::read_to_string("README.md").expect("read README.md");
207+
let docs_readme = std::fs::read_to_string("docs/README.md").expect("read docs/README.md");
208+
let config =
209+
std::fs::read_to_string("docs/configuration.md").expect("read docs/configuration.md");
210+
let macos =
211+
std::fs::read_to_string("docs/macos-gui-apps.md").expect("read docs/macos-gui-apps.md");
212+
213+
assert!(
214+
readme.contains("macOS desktop apps")
215+
&& readme.contains("Finder-launched apps")
216+
&& readme.contains("docs/macos-gui-apps.md"),
217+
"README.md should explain the supported macOS desktop-app path: {readme}"
218+
);
219+
assert!(
220+
docs_readme.contains("[macOS desktop-app guide](macos-gui-apps.md)"),
221+
"docs/README.md should link the macOS desktop-app guide: {docs_readme}"
222+
);
223+
assert!(
224+
config.contains("[[hooks]]") && config.contains("- `hooks`"),
225+
"docs/configuration.md should document hooks as a top-level config section: {config}"
226+
);
227+
assert!(
228+
macos.contains("security add-trusted-cert")
229+
&& macos.contains("networksetup -setsecurewebproxystate")
230+
&& macos.contains("Roll Back"),
231+
"docs/macos-gui-apps.md should document CA trust, system proxy setup, and rollback: {macos}"
232+
);
233+
}
234+
204235
#[test]
205236
fn proxy_docs_prefer_sourcing_env_script_and_describe_reboot_behavior() {
206237
let agents = std::fs::read_to_string("AGENTS.md").expect("read AGENTS.md");

tests/e2e_cli/process_lifecycle.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ use crate::support::{can_bind, free_addr, install_fake_tool, prepend_path, wait_
1111

1212
#[cfg(unix)]
1313
#[test]
14+
#[ignore = "slow daemon/proxy e2e"]
1415
fn mitm_releases_proxy_port_immediately_on_sigint() {
1516
let addr = free_addr();
1617
let socket_addr: SocketAddr = addr.parse().expect("parse socket addr");
@@ -67,6 +68,7 @@ fn mitm_releases_proxy_port_immediately_on_sigint() {
6768

6869
#[cfg(unix)]
6970
#[test]
71+
#[ignore = "slow daemon/proxy e2e"]
7072
fn mitm_returns_control_to_interactive_shell_after_child_exit() {
7173
let bin = assert_cmd::cargo::cargo_bin!("keyclaw");
7274
let temp = tempfile::tempdir().expect("tempdir");

tests/e2e_cli/proxy.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ fn proxy_fails_fast_on_broken_generated_ca_pair() {
3535

3636
#[cfg(unix)]
3737
#[test]
38+
#[ignore = "slow daemon/proxy e2e"]
3839
fn proxy_detaches_by_default_and_prints_stop_instructions() {
3940
struct ProxyGuard(Vec<i32>);
4041

@@ -108,6 +109,7 @@ fn proxy_detaches_by_default_and_prints_stop_instructions() {
108109

109110
#[cfg(unix)]
110111
#[test]
112+
#[ignore = "slow daemon/proxy e2e"]
111113
fn proxy_relaunch_on_same_addr_replaces_existing_daemon() {
112114
fn process_alive(pid: i32) -> bool {
113115
unsafe { libc::kill(pid, 0) == 0 }
@@ -208,6 +210,7 @@ fn proxy_relaunch_on_same_addr_replaces_existing_daemon() {
208210

209211
#[cfg(unix)]
210212
#[test]
213+
#[ignore = "slow daemon/proxy e2e"]
211214
fn proxy_relaunch_on_different_addr_keeps_existing_daemon() {
212215
fn process_alive(pid: i32) -> bool {
213216
unsafe { libc::kill(pid, 0) == 0 }
@@ -326,6 +329,7 @@ fn proxy_relaunch_on_different_addr_keeps_existing_daemon() {
326329
}
327330

328331
#[test]
332+
#[ignore = "slow daemon/proxy e2e"]
329333
fn proxy_detached_fails_fast_when_configured_port_is_busy() {
330334
let temp = tempfile::tempdir().expect("tempdir");
331335
let addr = free_addr();

0 commit comments

Comments
 (0)