Skip to content

Commit df2e41c

Browse files
author
lijiuyang.5137
committed
Bridge SDK file permission prompts
1 parent faac586 commit df2e41c

7 files changed

Lines changed: 781 additions & 20 deletions

File tree

README.md

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,16 @@ responses against the real interactive Claude TTY:
8181
CCTTY_LIVE_PERMISSION=1 cargo test --test claude_differential live_permission_prompt_stdio_honors_project_ask_rules -- --ignored --nocapture
8282
```
8383

84+
Live SDK game tests install the official Python and TypeScript SDKs, run them
85+
against real `cctty`/Claude, keep `permissionMode` at `default`, approve file
86+
edits through SDK `can_use_tool` callbacks, and verify that a small browser game
87+
is actually written:
88+
89+
```sh
90+
CCTTY_LIVE_SDK_GAME=1 cargo test --test sdk_integration live_python_sdk_builds_game_with_cctty_permissions -- --ignored --nocapture
91+
CCTTY_LIVE_SDK_GAME=1 cargo test --test sdk_integration live_typescript_sdk_builds_game_with_cctty_permissions -- --ignored --nocapture
92+
```
93+
8494
## Compatibility Matrix
8595

8696
Captured from `claude --help` on Claude Code `2.1.144`.
@@ -123,7 +133,7 @@ Status legend:
123133
| `--ide` | Pass-through | Forwarded to interactive Claude. | Parser coverage only. |
124134
| `--include-hook-events` | Partial | Forwarded. Hook events appear only if interactive transcript writes them; stream semantics are not guaranteed to match `--print`. | Parser coverage only. |
125135
| `--include-partial-messages` | Unsupported | Consumed by `cctty`; no partial assistant chunks are emitted because transcript tailing only sees persisted messages. | Parser coverage marks it consumed. |
126-
| `--input-format` | Supported | `text` prompts are read from argv/stdin. `stream-json` SDK input is read from stdin. | Fake-PTY test, Python SDK test, TypeScript SDK test. |
136+
| `--input-format` | Supported | `text` prompts are read from argv/stdin. `stream-json` SDK input is read from stdin. | Fake-PTY test, Python SDK test, TypeScript SDK test, plus live Python/TypeScript SDK game tests. |
127137
| `--json-schema` | Partial | Forwarded, but `cctty` synthesizes result frames when interactive transcript lacks one; `structured_output` parity is not proven. | Parser coverage only. Needs structured-output differential. |
128138
| `--max-budget-usd` | Partial | Forwarded, but Claude documents this as print-only. Underlying interactive behavior is not proven equivalent. | Parser coverage only. |
129139
| `--mcp-config` | Pass-through | Forwarded to interactive Claude. | Parser coverage only. SDK MCP control messages are not bridged yet. |
@@ -133,7 +143,7 @@ Status legend:
133143
| `--no-chrome` | Pass-through | Forwarded to interactive Claude. | Parser coverage only. |
134144
| `--no-session-persistence` | Supported | Consumed by `cctty`. The underlying interactive run uses the normal Claude config/auth, then `cctty` removes the generated transcript and empty project directories after the run. | Parser coverage plus fake-PTY persistence cleanup test. This preserves auth better than replacing `CLAUDE_CONFIG_DIR`. |
135145
| `--output-format` | Partial | `text`, `json`, and `stream-json` are emitted by `cctty`. `stream-json` includes transcript frames plus a synthetic `result` frame if interactive Claude did not write one. | Fake-PTY and live stream-json differential pass. Result metadata is partial. |
136-
| `--permission-mode` | Partial | Forwarded to interactive Claude for all documented modes: `acceptEdits`, `auto`, `bypassPermissions`, `default`, `dontAsk`, `plan`. SDK permission callbacks are bridged when the caller also supplies hidden `--permission-prompt-tool stdio`. | Parser coverage for all modes plus fake-PTY argv capture for all modes. Live differential covers `bypassPermissions`; live permission coverage also exercises `default` with a project-local `permissions.ask` rule. Other modes still need focused live tests. |
146+
| `--permission-mode` | Partial | Forwarded to interactive Claude for all documented modes: `acceptEdits`, `auto`, `bypassPermissions`, `default`, `dontAsk`, `plan`. SDK permission callbacks are bridged when the caller also supplies hidden `--permission-prompt-tool stdio`. | Parser coverage for all modes plus fake-PTY argv capture for all modes. Live differential covers `bypassPermissions`; live permission coverage exercises `default` with project-local `permissions.ask` rules for Bash and file writes through Python/TypeScript SDK callbacks. Other modes still need focused live tests. |
137147
| `--plugin-dir` | Pass-through | Forwarded to interactive Claude. | Parser coverage only. |
138148
| `--plugin-url` | Pass-through | Forwarded to interactive Claude. | Parser coverage only. |
139149
| `-p`, `--print` | Supported | Consumed by `cctty`; underlying Claude is intentionally launched interactively through a PTY. | Fake-PTY and live differential pass for basic query. |
@@ -145,7 +155,7 @@ Status legend:
145155
| `--setting-sources` | Pass-through | Forwarded to interactive Claude. | Parser coverage, including `--setting-sources=project`. |
146156
| `--settings` | Pass-through | Forwarded to interactive Claude. | Parser coverage only. |
147157
| `--strict-mcp-config` | Pass-through | Forwarded to interactive Claude. | Parser coverage only. |
148-
| `--system-prompt` | Pass-through | Forwarded to interactive Claude. | Parser coverage only. |
158+
| `--system-prompt` | Pass-through | Non-empty values are forwarded to interactive Claude. Empty or whitespace-only SDK values are consumed so they do not erase Claude Code's built-in interactive system prompt. | Parser coverage for non-empty pass-through and empty-value consumption. |
149159
| `--tmux` | Pass-through | Forwarded. `--tmux=classic` is preserved as an equals-form flag; plain `--tmux` does not swallow the prompt. | Parser regression test. |
150160
| `--tools` | Pass-through | Forwarded to interactive Claude. | Parser coverage only. |
151161
| `--verbose` | Pass-through | Forwarded. `cctty` itself does not require it for stream-json, but real Claude does, so SDK callers usually include it. | Parser and live differential coverage. |
@@ -158,16 +168,17 @@ Some SDKs pass flags that are not listed in current `claude --help`.
158168

159169
| Option(s) | Status | Current handling | Notes |
160170
| --- | --- | --- | --- |
161-
| `--permission-prompt-tool stdio` | Partial | Consumed by `cctty`, not forwarded to interactive Claude. In `stream-json` mode, `cctty` watches transcript `assistant.tool_use` entries and also recognizes real TTY permission forms when Claude has not persisted the transcript yet. It emits SDK-style `control_request` / `can_use_tool`, waits for the matching `control_response`, then drives the interactive permission UI by keyboard: allow confirms the selected approval row; deny selects menu item `2` and pastes the SDK denial message into Claude's follow-up form when present. If interactive Claude returns to the prompt after a rejected tool without writing a final result, `cctty` emits a synthetic error result with `result: "Permission denied"`. | Fake-PTY tests cover transcript-first allow/deny, TTY-form-before-transcript, and transcript-vs-TTY description precedence. Live Claude Code `2.1.144` coverage forces `Bash(printf:*)` approval with project-local settings and verifies both allow and deny. Still partial: non-Bash TTY forms, exact `permission_suggestions`, and exact `blocked_path` parity are not complete. |
171+
| `--permission-prompt-tool stdio` | Partial | Consumed by `cctty`, not forwarded to interactive Claude. In `stream-json` mode, `cctty` watches transcript `assistant.tool_use` entries and also recognizes real TTY permission forms when Claude has not persisted the transcript yet. It emits SDK-style `control_request` / `can_use_tool`, waits for the matching `control_response`, then drives the interactive permission UI by keyboard. Bash allow confirms the selected row; Bash deny selects menu item `2` and pastes the SDK denial message into Claude's follow-up form when present. File create/write/edit/update forms are mapped to `Write`/`Edit` with `file_path`; allow confirms the selected row, while deny selects the file prompt's `3. No` row to avoid accidentally choosing "allow all edits during this session". If interactive Claude returns to the prompt after a rejected tool without writing a final result, `cctty` emits a synthetic error result with `result: "Permission denied"`. | Fake-PTY tests cover transcript-first allow/deny, Bash TTY-form-before-transcript, file Write TTY-form-before-transcript, and transcript-vs-TTY description precedence. Live Claude Code `2.1.144` coverage forces `Bash(printf:*)` approval with project-local settings and verifies both allow and deny. Live Python and TypeScript SDK game tests exercise `Write` approval through real file-creation TTY forms. Still partial: broader `Edit`/`MultiEdit` TTY variants, exact `permission_suggestions`, and exact `blocked_path` parity are not complete. |
162172
| `--permission-prompt-tool <name>` | Pass-through | Non-`stdio` values are forwarded to interactive Claude. `cctty` does not emulate custom permission prompt tools itself. | Parser coverage only. |
163173
| `--system-prompt-file` | Pass-through | Forwarded to interactive Claude. | Parser coverage because SDKs/older CLIs may emit it. |
164174
| `--task-budget`, `--max-thinking-tokens`, `--thinking`, `--thinking-display`, `--managed-settings`, `--resume-session-at` | Pass-through | Forwarded to interactive Claude. | Parser coverage only. These are SDK/newer-CLI compatibility entries, not from the captured help output above. |
165175

166176
### Current High-Risk Gaps
167177

168-
- Permission callbacks now have fake-PTY allow/deny coverage and a live
169-
`Bash(printf:*)` approval differential for both allow and deny. Remaining
170-
risk is breadth: non-Bash permission forms, `allowedTools`/`disallowedTools`
178+
- Permission callbacks now have fake-PTY allow/deny coverage, a live
179+
`Bash(printf:*)` approval differential for both allow and deny, and live
180+
Python/TypeScript SDK coverage for file creation approvals. Remaining risk is
181+
breadth: more `Edit`/`MultiEdit` TTY variants, `allowedTools`/`disallowedTools`
171182
interactions, and exact SDK metadata fields still need live coverage.
172183
- `--include-partial-messages` is not implemented because transcript tailing does
173184
not expose partial assistant chunks.

src/args.rs

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,22 @@ impl Invocation {
120120
index += 2;
121121
continue;
122122
}
123+
if let Some(value) = long_equals_value(&arg, "--system-prompt") {
124+
if !value.trim().is_empty() {
125+
passthrough_args.push(arg);
126+
}
127+
index += 1;
128+
continue;
129+
}
130+
if arg == "--system-prompt" {
131+
let value = take_value(&args, index, "--system-prompt")?.to_owned();
132+
if !value.trim().is_empty() {
133+
passthrough_args.push(arg);
134+
passthrough_args.push(value);
135+
}
136+
index += 2;
137+
continue;
138+
}
123139

124140
if let Some(value) = long_equals_value(&arg, "--session-id") {
125141
session_id = Some(value.to_owned());
@@ -512,6 +528,44 @@ mod tests {
512528
);
513529
}
514530

531+
#[test]
532+
fn parse_drops_empty_sdk_system_prompt_to_preserve_claude_defaults() {
533+
let invocation = Invocation::parse(vec![
534+
"cctty".to_owned(),
535+
"--input-format".to_owned(),
536+
"stream-json".to_owned(),
537+
"--output-format".to_owned(),
538+
"stream-json".to_owned(),
539+
"--system-prompt".to_owned(),
540+
"".to_owned(),
541+
])
542+
.unwrap();
543+
544+
assert!(
545+
!invocation
546+
.passthrough_args
547+
.iter()
548+
.any(|arg| arg == "--system-prompt")
549+
);
550+
551+
let invocation = Invocation::parse(vec![
552+
"cctty".to_owned(),
553+
"--input-format".to_owned(),
554+
"stream-json".to_owned(),
555+
"--output-format".to_owned(),
556+
"stream-json".to_owned(),
557+
"--system-prompt= ".to_owned(),
558+
])
559+
.unwrap();
560+
561+
assert!(
562+
!invocation
563+
.passthrough_args
564+
.iter()
565+
.any(|arg| arg.starts_with("--system-prompt"))
566+
);
567+
}
568+
515569
#[test]
516570
fn parse_tracks_no_session_persistence_without_forwarding_it() {
517571
let invocation = Invocation::parse(vec![

src/pty.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ pub struct PtySpawnSpec {
1515
pub args: Vec<String>,
1616
pub cwd: PathBuf,
1717
pub env: HashMap<String, String>,
18+
pub unset_env: Vec<String>,
1819
}
1920

2021
pub struct PtyProcess {
@@ -87,6 +88,14 @@ fn spawn_unix_pty(spec: &PtySpawnSpec) -> Result<PtyProcess> {
8788
))
8889
})
8990
.collect::<Result<Vec<_>>>()?;
91+
let unset_env = spec
92+
.unset_env
93+
.iter()
94+
.map(|key| {
95+
CString::new(key.as_str())
96+
.map_err(|_| CcttyError::Tty("environment key contains NUL byte".to_owned()))
97+
})
98+
.collect::<Result<Vec<_>>>()?;
9099

91100
let mut master_fd: libc::c_int = -1;
92101
let mut winsize = libc::winsize {
@@ -114,6 +123,9 @@ fn spawn_unix_pty(spec: &PtySpawnSpec) -> Result<PtyProcess> {
114123
unsafe {
115124
libc::chdir(cwd.as_ptr());
116125
libc::unsetenv(c"CLAUDECODE".as_ptr());
126+
for key in &unset_env {
127+
libc::unsetenv(key.as_ptr());
128+
}
117129
for (key, value) in &env {
118130
libc::setenv(key.as_ptr(), value.as_ptr(), 1);
119131
}

0 commit comments

Comments
 (0)