-
-
Notifications
You must be signed in to change notification settings - Fork 2.2k
Replay the launcher PATH into remote Claude Teams teammate panes #10042
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -131,6 +131,9 @@ func TestConfigureAgentEnvironmentClearsRejectedRoutingIdentity(t *testing.T) { | |||||||||||||||||||||||||||||||||||||||||||
| "COLORTERM", | ||||||||||||||||||||||||||||||||||||||||||||
| "CMUX_AGENT_LAUNCH_TEST_BIN", | ||||||||||||||||||||||||||||||||||||||||||||
| "CMUX_AGENT_LAUNCH_TEST_TERM", | ||||||||||||||||||||||||||||||||||||||||||||
| "CLAUDE_CODE_SANDBOXED", | ||||||||||||||||||||||||||||||||||||||||||||
| "CMUX_CLAUDE_TEAMS_SANDBOXED", | ||||||||||||||||||||||||||||||||||||||||||||
| claudeTeamsRespawnEnvironmentKey, | ||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+134
to
+136
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win Assert that inherited sandbox state is removed. The test only preserves these variables. It does not set non-empty values or verify that Proposed test extension t.Setenv("PATH", "/usr/bin:/bin")
+ t.Setenv("CLAUDE_CODE_SANDBOXED", "1")
+ t.Setenv("CMUX_CLAUDE_TEAMS_SANDBOXED", "1")
+ t.Setenv(claudeTeamsRespawnEnvironmentKey, "transport")
configureAgentEnvironment(agentConfig{
@@
})
+ for _, key := range []string{
+ "CLAUDE_CODE_SANDBOXED",
+ "CMUX_CLAUDE_TEAMS_SANDBOXED",
+ claudeTeamsRespawnEnvironmentKey,
+ } {
+ if value, present := os.LookupEnv(key); present {
+ t.Errorf("%s leaked inherited value %q", key, value)
+ }
+ }📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||
| } { | ||||||||||||||||||||||||||||||||||||||||||||
| t.Setenv(key, os.Getenv(key)) | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| package main | ||
|
|
||
| import ( | ||
| "encoding/base64" | ||
| "encoding/json" | ||
| "strings" | ||
| ) | ||
|
|
||
| // claudeTeamsRespawnEnvironmentKey carries the encoded launcher environment from | ||
| // the claude-teams relay to the `__tmux-compat` process that respawns a teammate | ||
| // pane. Same key and wire format as the Swift | ||
| // ClaudeTeamsRespawnEnvironmentTransport, so either end can produce the value. | ||
| const claudeTeamsRespawnEnvironmentKey = "CMUX_CLAUDE_TEAMS_RESPAWN_ENV_B64" | ||
|
|
||
| // claudeTeamsRespawnEnvironmentAllowlist is deliberately narrower than the Swift | ||
| // AgentLaunchEnvironmentPolicy: PATH is the only value a remote teammate needs | ||
| // replayed, and a second hand-copied allowlist would drift from the Swift one. | ||
| var claudeTeamsRespawnEnvironmentAllowlist = []string{"PATH"} | ||
|
|
||
| // encodeClaudeTeamsRespawnEnvironment encodes the replay-safe subset of a | ||
| // launcher environment as base64 JSON, or "" when there is nothing to replay. | ||
| func encodeClaudeTeamsRespawnEnvironment(environ []string) string { | ||
| environment, _ := envMapWithOrder(environ) | ||
| selected := selectClaudeTeamsRespawnEnvironment(environment) | ||
| if len(selected) == 0 { | ||
| return "" | ||
| } | ||
| // json.Marshal sorts map keys, matching the Swift encoder's .sortedKeys. | ||
| encoded, err := json.Marshal(selected) | ||
| if err != nil { | ||
| return "" | ||
| } | ||
| return base64.StdEncoding.EncodeToString(encoded) | ||
| } | ||
|
|
||
| // decodeClaudeTeamsRespawnEnvironment decodes a transport value and reapplies the | ||
| // allowlist, so a forged value cannot promote arbitrary variables into a teammate | ||
| // pane. Invalid data yields no values rather than a partial environment. | ||
| func decodeClaudeTeamsRespawnEnvironment(encoded string) map[string]string { | ||
| data, err := base64.StdEncoding.DecodeString(strings.TrimSpace(encoded)) | ||
| if err != nil { | ||
| return nil | ||
| } | ||
| var transported map[string]string | ||
| if err := json.Unmarshal(data, &transported); err != nil { | ||
| return nil | ||
| } | ||
| return selectClaudeTeamsRespawnEnvironment(transported) | ||
| } | ||
|
|
||
| func selectClaudeTeamsRespawnEnvironment(environment map[string]string) map[string]string { | ||
| selected := make(map[string]string, len(claudeTeamsRespawnEnvironmentAllowlist)) | ||
| for _, key := range claudeTeamsRespawnEnvironmentAllowlist { | ||
| // An empty value would emit `export PATH=''` and leave the pane worse off. | ||
| if value := environment[key]; value != "" { | ||
| selected[key] = value | ||
| } | ||
| } | ||
| return selected | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,165 @@ | ||
| package main | ||
|
|
||
| import ( | ||
| "encoding/base64" | ||
| "os" | ||
| "testing" | ||
| ) | ||
|
|
||
| func TestEncodeClaudeTeamsRespawnEnvironmentRoundTripsPath(t *testing.T) { | ||
| encoded := encodeClaudeTeamsRespawnEnvironment([]string{ | ||
| "PATH=/opt/homebrew/bin:/usr/bin:/bin", | ||
| "HOME=/home/user", | ||
| }) | ||
| if encoded == "" { | ||
| t.Fatal("encode returned empty for an environment containing PATH") | ||
| } | ||
|
|
||
| decoded := decodeClaudeTeamsRespawnEnvironment(encoded) | ||
| if got := decoded["PATH"]; got != "/opt/homebrew/bin:/usr/bin:/bin" { | ||
| t.Errorf("PATH = %q, want the launcher PATH", got) | ||
| } | ||
| if len(decoded) != 1 { | ||
| t.Errorf("decoded = %v, want PATH only", decoded) | ||
| } | ||
| } | ||
|
|
||
| func TestEncodeClaudeTeamsRespawnEnvironmentDropsNonAllowlistedKeys(t *testing.T) { | ||
| encoded := encodeClaudeTeamsRespawnEnvironment([]string{ | ||
| "PATH=/usr/bin", | ||
| "ANTHROPIC_API_KEY=secret", | ||
| "CMUX_SURFACE_ID=surface-1", | ||
| "malformed-entry-without-separator", | ||
| }) | ||
|
|
||
| decoded := decodeClaudeTeamsRespawnEnvironment(encoded) | ||
| for _, key := range []string{"ANTHROPIC_API_KEY", "CMUX_SURFACE_ID"} { | ||
| if _, ok := decoded[key]; ok { | ||
| t.Errorf("%s crossed the respawn boundary", key) | ||
| } | ||
| } | ||
| if decoded["PATH"] != "/usr/bin" { | ||
| t.Errorf("PATH = %q, want /usr/bin", decoded["PATH"]) | ||
| } | ||
| } | ||
|
|
||
| func TestEncodeClaudeTeamsRespawnEnvironmentWithoutPathEncodesNothing(t *testing.T) { | ||
| for _, environ := range [][]string{ | ||
| {"HOME=/home/user"}, | ||
| {"PATH="}, | ||
| nil, | ||
| } { | ||
| if got := encodeClaudeTeamsRespawnEnvironment(environ); got != "" { | ||
| t.Errorf("encode(%v) = %q, want empty", environ, got) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // A forged or truncated transport value must yield nothing, never a partial or | ||
| // attacker-chosen environment. | ||
| func TestDecodeClaudeTeamsRespawnEnvironmentFailsClosed(t *testing.T) { | ||
| tests := []struct { | ||
| name string | ||
| encoded string | ||
| }{ | ||
| {"empty", ""}, | ||
| {"not base64", "!!!not-base64!!!"}, | ||
| {"base64 of non-JSON", base64.StdEncoding.EncodeToString([]byte("PATH=/usr/bin"))}, | ||
| {"base64 of a JSON array", base64.StdEncoding.EncodeToString([]byte(`["PATH"]`))}, | ||
| {"base64 of a JSON string", base64.StdEncoding.EncodeToString([]byte(`"PATH"`))}, | ||
| {"non-string JSON values", base64.StdEncoding.EncodeToString([]byte(`{"PATH":42}`))}, | ||
| {"allowlisted key absent", base64.StdEncoding.EncodeToString([]byte(`{"LD_PRELOAD":"/tmp/evil.so"}`))}, | ||
| } | ||
| for _, tc := range tests { | ||
| t.Run(tc.name, func(t *testing.T) { | ||
| if decoded := decodeClaudeTeamsRespawnEnvironment(tc.encoded); len(decoded) != 0 { | ||
| t.Errorf("decoded = %v, want no values", decoded) | ||
| } | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| func TestTmuxClaudeTeamsRespawnEnvironmentReplaysTransportedPath(t *testing.T) { | ||
| encoded := encodeClaudeTeamsRespawnEnvironment([]string{"PATH=/opt/homebrew/bin:/usr/bin"}) | ||
| t.Setenv(claudeTeamsRespawnEnvironmentKey, encoded) | ||
| t.Setenv("CMUX_CLAUDE_TEAMS_SANDBOXED", "") | ||
|
|
||
| pairs := tmuxClaudeTeamsRespawnEnvironment() | ||
| want := []tmuxEnvPair{{key: "PATH", value: "/opt/homebrew/bin:/usr/bin"}} | ||
| if len(pairs) != len(want) || pairs[0] != want[0] { | ||
| t.Errorf("pairs = %v, want %v", pairs, want) | ||
| } | ||
| } | ||
|
|
||
| func TestTmuxClaudeTeamsRespawnEnvironmentOrdersKeysDeterministically(t *testing.T) { | ||
| t.Setenv(claudeTeamsRespawnEnvironmentKey, encodeClaudeTeamsRespawnEnvironment([]string{"PATH=/usr/bin"})) | ||
| t.Setenv("CMUX_CLAUDE_TEAMS_SANDBOXED", "1") | ||
|
|
||
| pairs := tmuxClaudeTeamsRespawnEnvironment() | ||
| want := []tmuxEnvPair{ | ||
| {key: "CLAUDE_CODE_SANDBOXED", value: "1"}, | ||
| {key: "PATH", value: "/usr/bin"}, | ||
| } | ||
| if len(pairs) != len(want) { | ||
| t.Fatalf("pairs = %v, want %v", pairs, want) | ||
| } | ||
| for i := range want { | ||
| if pairs[i] != want[i] { | ||
| t.Errorf("pairs[%d] = %v, want %v", i, pairs[i], want[i]) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| func TestTmuxClaudeTeamsRespawnEnvironmentWithoutOptInOrTransportIsEmpty(t *testing.T) { | ||
| t.Setenv(claudeTeamsRespawnEnvironmentKey, "garbage") | ||
| t.Setenv("CMUX_CLAUDE_TEAMS_SANDBOXED", "") | ||
|
|
||
| if pairs := tmuxClaudeTeamsRespawnEnvironment(); pairs != nil { | ||
| t.Errorf("pairs = %v, want nil", pairs) | ||
| } | ||
| } | ||
|
|
||
| // `cmux omc`/`omo`/`omx` launched from inside a claude-teams process tree must | ||
| // not replay the lead's PATH into its own pane respawns. | ||
| func TestConfigureAgentEnvironmentClearsInheritedRespawnTransport(t *testing.T) { | ||
| for _, key := range []string{ | ||
| "PATH", | ||
| "TMUX", | ||
| "TMUX_PANE", | ||
| "TERM", | ||
| "CMUX_SOCKET_PATH", | ||
| "CMUX_SOCKET", | ||
| "TERM_PROGRAM", | ||
| "COLORTERM", | ||
| "CMUX_WORKSPACE_ID", | ||
| "CMUX_SURFACE_ID", | ||
| "CMUX_PANEL_ID", | ||
| "CMUX_TAB_ID", | ||
| "CMUX_PANE_ID", | ||
| "CMUX_RESPAWN_TRANSPORT_TEST_BIN", | ||
| "CMUX_RESPAWN_TRANSPORT_TEST_TERM", | ||
| } { | ||
| t.Setenv(key, os.Getenv(key)) | ||
| } | ||
| t.Setenv("PATH", "/omc/own/bin:/usr/bin") | ||
| t.Setenv("CMUX_CLAUDE_TEAMS_SANDBOXED", "") | ||
| t.Setenv(claudeTeamsRespawnEnvironmentKey, | ||
| encodeClaudeTeamsRespawnEnvironment([]string{"PATH=/claude-teams/lead/bin"})) | ||
|
|
||
| configureAgentEnvironment(agentConfig{ | ||
| shimDir: t.TempDir(), | ||
| socketPath: "/tmp/cmux-respawn-transport-test.sock", | ||
| launchContext: nil, | ||
| tmuxPathPrefix: "cmux-omc", | ||
| cmuxBinEnvVar: "CMUX_RESPAWN_TRANSPORT_TEST_BIN", | ||
| termEnvVar: "CMUX_RESPAWN_TRANSPORT_TEST_TERM", | ||
| extraEnv: map[string]string{}, | ||
| }) | ||
|
|
||
| if value, present := os.LookupEnv(claudeTeamsRespawnEnvironmentKey); present { | ||
| t.Errorf("%s survived as %q", claudeTeamsRespawnEnvironmentKey, value) | ||
| } | ||
| if pairs := tmuxClaudeTeamsRespawnEnvironment(); pairs != nil { | ||
| t.Errorf("pairs = %v, want nil", pairs) | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: manaflow-ai/cmux
Length of output: 22929
🏁 Script executed:
Repository: manaflow-ai/cmux
Length of output: 50372
🏁 Script executed:
Repository: manaflow-ai/cmux
Length of output: 9149
Preserve the sandbox opt-in for teammate respawns.
configureClaudeTeamsShellWrapperdoes not restoreCMUX_CLAUDE_TEAMS_SANDBOXED. AfterconfigureAgentEnvironmentclears it,tmuxClaudeTeamsRespawnEnvironmentcannot emitCLAUDE_CODE_SANDBOXED=1. Pass the opt-in throughagentConfig.extraEnvor another authoritative post-cleanup source.🤖 Prompt for AI Agents