Skip to content

Commit 345944d

Browse files
committed
fix(permissions): deny env and exec -a wrapper invocations of the remote shell utilities (Gentleman-Programming#4324)
Close the remaining resolution bypasses flagged by the Gentleman-Programming#4330 review: env(1) execs the utility after optional flags and NAME=VALUE assignments, and exec -a renames argv[0] before executing it, so both fell through the wildcard allow as single parsed command nodes. Both overlays now enumerate, per tool: the env exact/prefix forms (env X, env X:*) and the internal-glob forms (env * X *, exec -a * X *) covering env -i X and env NAME=VALUE X. Verified against OpenCode wildcard.ts (every * compiles to .*) and Claude Code glob docs (internal wildcards supported; bare forms kept separate per #26229). The boundary fixtures flip with the policy: env/-i/assignment and exec -a invocations are denied matcher inputs, while &&-chains and interpreter programs remain out of scope. Glob entries are deliberately conservative: benign commands like env LC_ALL=C sort ssh_keys.txt also match and are denied — the safe side of a remote-execution boundary.
1 parent a9b8307 commit 345944d

2 files changed

Lines changed: 78 additions & 6 deletions

File tree

internal/components/permissions/inject.go

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,15 @@ func TargetPath(homeDir string, adapter agents.Adapter) string {
3838
// custom /usr/local/bin, Apple Silicon /opt/homebrew/bin, NixOS
3939
// /run/current-system/sw/bin). Agents whose ssh lives elsewhere still hit the
4040
// always-on routing guidance "Remote execution boundary" section.
41+
// The env(1) and exec -a wrapper entries (#4330 review follow-up) close the
42+
// remaining resolution bypasses: env execs the utility after optional flags
43+
// and NAME=VALUE assignments (env ssh, env -i ssh, env FOO=1 ssh ...), and
44+
// exec -a renames argv[0] before executing it. "env X" keeps its own exact
45+
// and ":*" prefix forms because the internal-glob "env * X *" pattern needs
46+
// a space-delimited " X" token. The glob entries are deliberately
47+
// conservative: a benign command like `env LC_ALL=C sort ssh_keys.txt` also
48+
// matches "env * ssh *" and is denied — a false positive toward deny is the
49+
// safe side of a remote-execution boundary.
4150
var claudeCodeOverlayJSON = []byte(`{
4251
"permissions": {
4352
"defaultMode": "bypassPermissions",
@@ -76,6 +85,10 @@ var claudeCodeOverlayJSON = []byte(`{
7685
"Bash(\\ssh:*)",
7786
"Bash(command ssh:*)",
7887
"Bash(exec ssh:*)",
88+
"Bash(env ssh)",
89+
"Bash(env ssh:*)",
90+
"Bash(env * ssh *)",
91+
"Bash(exec -a * ssh *)",
7992
"Bash(scp)",
8093
"Bash(scp:*)",
8194
"Bash(/bin/scp:*)",
@@ -86,6 +99,10 @@ var claudeCodeOverlayJSON = []byte(`{
8699
"Bash(\\scp:*)",
87100
"Bash(command scp:*)",
88101
"Bash(exec scp:*)",
102+
"Bash(env scp)",
103+
"Bash(env scp:*)",
104+
"Bash(env * scp *)",
105+
"Bash(exec -a * scp *)",
89106
"Bash(sftp)",
90107
"Bash(sftp:*)",
91108
"Bash(/bin/sftp:*)",
@@ -96,6 +113,10 @@ var claudeCodeOverlayJSON = []byte(`{
96113
"Bash(\\sftp:*)",
97114
"Bash(command sftp:*)",
98115
"Bash(exec sftp:*)",
116+
"Bash(env sftp)",
117+
"Bash(env sftp:*)",
118+
"Bash(env * sftp *)",
119+
"Bash(exec -a * sftp *)",
99120
"Bash(rsync)",
100121
"Bash(rsync:*)",
101122
"Bash(/bin/rsync:*)",
@@ -105,7 +126,11 @@ var claudeCodeOverlayJSON = []byte(`{
105126
"Bash(/run/current-system/sw/bin/rsync:*)",
106127
"Bash(\\rsync:*)",
107128
"Bash(command rsync:*)",
108-
"Bash(exec rsync:*)"
129+
"Bash(exec rsync:*)",
130+
"Bash(env rsync)",
131+
"Bash(env rsync:*)",
132+
"Bash(env * rsync *)",
133+
"Bash(exec -a * rsync *)"
109134
]
110135
}
111136
}
@@ -122,6 +147,17 @@ var claudeCodeOverlayJSON = []byte(`{
122147
// Absolute paths get one entry per canonical install prefix; the "/X *"
123148
// entries additionally cover backslash-escaped invocations (\ssh ...) because
124149
// the matcher normalizes backslashes to forward slashes before matching.
150+
// The env(1) and exec -a wrapper entries (#4330 review follow-up) close the
151+
// remaining resolution bypasses: env execs the utility after optional flags
152+
// and NAME=VALUE assignments (env ssh, env -i ssh, env FOO=1 ssh ...), and
153+
// exec -a renames argv[0] before executing it. wildcard.ts compiles every
154+
// "*" to ".*" (only the trailing " *" becomes an optional group), so
155+
// "env * X *" matches "env -i X ..." and "env NAME=VALUE X ..." while
156+
// "env X *" covers the flag-free form including the bare invocation. The
157+
// glob entries are deliberately conservative: a benign command like
158+
// `env LC_ALL=C sort ssh_keys.txt` also matches "env * ssh *" and is denied
159+
// — a false positive toward deny is the safe side of a remote-execution
160+
// boundary.
125161
var openCodeOverlayJSON = []byte(`{
126162
"permission": {
127163
"bash": {
@@ -142,6 +178,9 @@ var openCodeOverlayJSON = []byte(`{
142178
"/ssh *": "deny",
143179
"command ssh *": "deny",
144180
"exec ssh *": "deny",
181+
"env ssh *": "deny",
182+
"env * ssh *": "deny",
183+
"exec -a * ssh *": "deny",
145184
"scp": "deny",
146185
"scp *": "deny",
147186
"/bin/scp *": "deny",
@@ -152,6 +191,9 @@ var openCodeOverlayJSON = []byte(`{
152191
"/scp *": "deny",
153192
"command scp *": "deny",
154193
"exec scp *": "deny",
194+
"env scp *": "deny",
195+
"env * scp *": "deny",
196+
"exec -a * scp *": "deny",
155197
"sftp": "deny",
156198
"sftp *": "deny",
157199
"/bin/sftp *": "deny",
@@ -162,6 +204,9 @@ var openCodeOverlayJSON = []byte(`{
162204
"/sftp *": "deny",
163205
"command sftp *": "deny",
164206
"exec sftp *": "deny",
207+
"env sftp *": "deny",
208+
"env * sftp *": "deny",
209+
"exec -a * sftp *": "deny",
165210
"rsync": "deny",
166211
"rsync *": "deny",
167212
"/bin/rsync *": "deny",
@@ -171,7 +216,10 @@ var openCodeOverlayJSON = []byte(`{
171216
"/run/current-system/sw/bin/rsync *": "deny",
172217
"/rsync *": "deny",
173218
"command rsync *": "deny",
174-
"exec rsync *": "deny"
219+
"exec rsync *": "deny",
220+
"env rsync *": "deny",
221+
"env * rsync *": "deny",
222+
"exec -a * rsync *": "deny"
175223
},
176224
"read": {
177225
"*": "allow",

internal/components/permissions/inject_test.go

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,15 +67,21 @@ func TestRemoteMatcherBoundaryFixtures(t *testing.T) {
6767
// wrapper invocations of the remote shell utilities are denied by the
6868
// overlay itself. Pattern-only rules are bypassable through these forms
6969
// (the command token is not the bare utility name), so the deny entries
70-
// enumerate them explicitly instead of relying on guidance alone.
71-
for _, input := range []string{"/usr/bin/ssh example.invalid", "/bin/scp file example.invalid:file", "\\ssh example.invalid", "command ssh example.invalid", "exec rsync -a src dst"} {
70+
// enumerate them explicitly instead of relying on guidance alone. The
71+
// env(1) and exec -a forms join the boundary after the #4330 review: env
72+
// execs the utility as one parsed command node, so the full node text
73+
// ("env -i ssh ...", "env NAME=VALUE ssh ...", "exec -a alias ssh ...")
74+
// reaches the matcher and must be denied there.
75+
for _, input := range []string{"/usr/bin/ssh example.invalid", "/bin/scp file example.invalid:file", "\\ssh example.invalid", "command ssh example.invalid", "exec rsync -a src dst", "env ssh example.invalid", "env -i scp file example.invalid:file", "env CUSTOM=1 sftp example.invalid", "exec -a benign rsync -a src dst"} {
7276
if got := remoteAction(t, openCodeOverlayJSON, input); got != "deny" {
7377
t.Errorf("bypass invocation %q not denied: %s", input, got)
7478
}
7579
}
7680
// These remain matcher inputs, not shell programs. bash.ts extracts command
77-
// nodes separately; no claim is made about parsing arbitrary shell syntax.
78-
for _, input := range []string{"env ssh example.invalid", "true && ssh example.invalid", `python -c 'import subprocess'`} {
81+
// nodes separately, so the "&&" chain never reaches the matcher as one
82+
// string and the python program is a different command entirely; no claim
83+
// is made about parsing arbitrary shell syntax.
84+
for _, input := range []string{"true && ssh example.invalid", `python -c 'import subprocess'`} {
7985
if got := remoteAction(t, openCodeOverlayJSON, input); got != "allow" {
8086
t.Errorf("unsupported matcher input %q unexpectedly intercepted: %s", input, got)
8187
}
@@ -719,6 +725,20 @@ func remoteShellEscapeForms(tool, openCodeSuffix, claudeCodeSuffix string) (open
719725
return openCode, claudeCode
720726
}
721727

728+
// remoteShellEnvWrapperForms returns the env(1) and exec -a wrapper deny
729+
// surfaces one tool needs (#4330 review follow-up): env execs the utility
730+
// after optional flags and NAME=VALUE assignments, and exec -a renames
731+
// argv[0] before executing it. "env T" keeps its own exact and prefix forms
732+
// because the internal-glob patterns require a space-delimited " T" token,
733+
// which a bare "env T" invocation does not contain. Claude Code entries use
734+
// the space-star glob style for internal wildcards (documented glob syntax);
735+
// OpenCode entries rely on wildcard.ts compiling every "*" to ".*".
736+
func remoteShellEnvWrapperForms(tool string) (openCode []string, claudeCode []string) {
737+
openCode = []string{"env " + tool + " *", "env * " + tool + " *", "exec -a * " + tool + " *"}
738+
claudeCode = []string{"Bash(env " + tool + ")", "Bash(env " + tool + ":*)", "Bash(env * " + tool + " *)", "Bash(exec -a * " + tool + " *)"}
739+
return openCode, claudeCode
740+
}
741+
722742
// TestInjectOpenCodeDeniesRemoteShellUtilities verifies that the remote shell
723743
// utilities used to reach machines outside the workspace (ssh, scp, sftp,
724744
// rsync) are denied in the OpenCode/Kilocode bash permission map in their
@@ -739,6 +759,8 @@ func TestInjectOpenCodeDeniesRemoteShellUtilities(t *testing.T) {
739759
for _, tool := range remoteShellTools {
740760
openCode, _ := remoteShellEscapeForms(tool, " *", ":*")
741761
remoteDenyRules = append(remoteDenyRules, openCode...)
762+
envOpenCode, _ := remoteShellEnvWrapperForms(tool)
763+
remoteDenyRules = append(remoteDenyRules, envOpenCode...)
742764
}
743765

744766
tests := []struct {
@@ -816,6 +838,8 @@ func TestInjectClaudeCodeDeniesRemoteShellUtilities(t *testing.T) {
816838
for _, tool := range remoteShellTools {
817839
_, claudeCode := remoteShellEscapeForms(tool, " *", ":*")
818840
remoteDenyRules = append(remoteDenyRules, claudeCode...)
841+
_, envClaudeCode := remoteShellEnvWrapperForms(tool)
842+
remoteDenyRules = append(remoteDenyRules, envClaudeCode...)
819843
}
820844

821845
home := t.TempDir()

0 commit comments

Comments
 (0)