Skip to content

Commit 491efbc

Browse files
diillsondiillson
andauthored
fix(agent): the allowlist covers the whole line, and coder test stops being the way around it (#1531)
Strict mode checked the first word of a command and nothing else. A line is a sequence of invocations, so that made any allowed command a passphrase for the rest of it: ls followed by an ampersand pair carried an arbitrary command straight past the gate. "Only commands from the allowlist can be executed" was not true of any line with an operator in it. Every segment is held to the same rule now, decomposed by the shell parser that was already in the tree, so a quoted operator stays a quoted operator and is not mistaken for a chain. Two things kept that from becoming a regression. A line the parser cannot read falls back to the previous single-command check rather than failing closed, because a host whose shell is not bash would otherwise lose every command. And the navigation builtins joined the list: cd, pwd, pushd and their neighbours carry no capability of their own, and refusing them once every segment is checked would have broken a directory change followed by a build while withholding nothing. Nineteen commands the documentation lists as allowed were not on the list at all, so strict mode refused npx, base64, openssl, poetry, zig, istioctl and the rest while the page said otherwise. They are on it now. Two settings did nothing when written the way they were documented. The custom allowlist split on commas while the documented example used semicolons, which registered one command with semicolons in its name; extra read paths split on the native separator while the documented example used semicolons, which produced one path that exists nowhere. Both accept either spelling now, except that a colon is still never a separator on Windows, where it separates a drive letter from its path. The coder test subcommand takes an arbitrary command and ran it through none of the guards its sibling applies: not the dangerous-pattern check, not the sandbox, and not the upstream gate, which only ever looked at exec. The same payload the exec path refuses executed through test. It is guarded now, with the same escape hatches, so a suite that legitimately needs them is not newly refused, and the upstream gate covers both subcommands rather than the one that happens to be named after running commands. Co-authored-by: diillson <diiilllson@gmail.com>
1 parent 5abe224 commit 491efbc

7 files changed

Lines changed: 380 additions & 27 deletions

File tree

cli/agent/allowlist_chain_test.go

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
/*
2+
* ChatCLI - Command Line Interface for LLM interaction
3+
* Copyright (c) 2024 Edilson Freitas
4+
* License: Apache-2.0
5+
*/
6+
package agent
7+
8+
import (
9+
"os"
10+
"path/filepath"
11+
"runtime"
12+
"testing"
13+
)
14+
15+
// Prefixing a benign command used to be a passphrase for the rest of the
16+
// line: only the first word was checked, so anything after && or | rode in.
17+
func TestAllowlist_ChecksEveryCommandOnTheLine(t *testing.T) {
18+
t.Setenv("CHATCLI_AGENT_SECURITY_MODE", "strict")
19+
al := NewCommandAllowlist()
20+
21+
bypasses := []string{
22+
"ls && nmap -sS 10.0.0.0/8",
23+
"echo hi; nmap localhost",
24+
"ls | nmap -",
25+
"ls || nmap -",
26+
"ls && ls && nmap -",
27+
"(cd /tmp && nmap -)",
28+
"ls & nmap -",
29+
}
30+
for _, cmd := range bypasses {
31+
if ok, _, _ := al.IsAllowed(cmd); ok {
32+
t.Errorf("bypass still open: %q passed the strict allowlist", cmd)
33+
}
34+
}
35+
}
36+
37+
// Everything legitimate must keep working — a chain of allowed commands is
38+
// the normal case, not an attack.
39+
func TestAllowlist_AllowsChainsOfAllowedCommands(t *testing.T) {
40+
t.Setenv("CHATCLI_AGENT_SECURITY_MODE", "strict")
41+
al := NewCommandAllowlist()
42+
43+
fine := []string{
44+
"ls -la",
45+
"go build ./... && go test ./...",
46+
"cat go.mod | grep module",
47+
"git status; git diff",
48+
"grep -r foo . | sort | uniq -c | head",
49+
"FOO=bar go test ./...",
50+
"cd /tmp && ls",
51+
}
52+
for _, cmd := range fine {
53+
if ok, _, reason := al.IsAllowed(cmd); !ok {
54+
t.Errorf("legitimate command refused: %q (%s)", cmd, reason)
55+
}
56+
}
57+
}
58+
59+
// Quoting must not be mistaken for a chain: the shell parser is the point.
60+
func TestAllowlist_QuotedOperatorsAreNotSegments(t *testing.T) {
61+
t.Setenv("CHATCLI_AGENT_SECURITY_MODE", "strict")
62+
al := NewCommandAllowlist()
63+
64+
for _, cmd := range []string{
65+
`echo "a && b"`,
66+
`grep "foo | bar" file.txt`,
67+
`echo 'nmap is a word here'`,
68+
} {
69+
if ok, _, reason := al.IsAllowed(cmd); !ok {
70+
t.Errorf("quoted operator treated as a chain: %q (%s)", cmd, reason)
71+
}
72+
}
73+
}
74+
75+
// The sudo prefix keeps being handled by the denylist, not by demanding
76+
// that "sudo" itself be an allowlisted command.
77+
func TestAllowlist_SudoPrefixStillResolvesToTheRealCommand(t *testing.T) {
78+
t.Setenv("CHATCLI_AGENT_SECURITY_MODE", "strict")
79+
al := NewCommandAllowlist()
80+
81+
if ok, _, reason := al.IsAllowed("sudo ls -la"); !ok {
82+
t.Errorf("sudo prefix changed the allowlist verdict: %s", reason)
83+
}
84+
if ok, _, _ := al.IsAllowed("sudo nmap -"); ok {
85+
t.Error("sudo hid a command that is not on the allowlist")
86+
}
87+
}
88+
89+
// Every command the documentation lists as allowed must actually be allowed.
90+
func TestAllowlist_CoversTheDocumentedCommands(t *testing.T) {
91+
t.Setenv("CHATCLI_AGENT_SECURITY_MODE", "strict")
92+
al := NewCommandAllowlist()
93+
94+
documented := []string{
95+
"ag", "argocd", "base64", "cal", "clear", "cmp", "csvtool", "flux",
96+
"istioctl", "kotlinc", "look", "npx", "openssl", "poetry", "reset",
97+
"stty", "tput", "xmllint", "zig",
98+
}
99+
for _, cmd := range documented {
100+
if ok, _, _ := al.IsAllowed(cmd + " --version"); !ok {
101+
t.Errorf("%q is documented as allowed and is not", cmd)
102+
}
103+
}
104+
}
105+
106+
// The value shape the documentation gave has to work, or the setting looks
107+
// applied and is not.
108+
func TestAllowlist_CustomListAcceptsBothSeparators(t *testing.T) {
109+
for _, value := range []string{
110+
"mycli;internal-tool;company-deploy",
111+
"mycli,internal-tool,company-deploy",
112+
"mycli; internal-tool ,company-deploy",
113+
} {
114+
t.Setenv("CHATCLI_AGENT_ALLOWLIST", value)
115+
al := NewCommandAllowlist()
116+
for _, cmd := range []string{"mycli", "internal-tool", "company-deploy"} {
117+
if ok, _, _ := al.IsAllowed(cmd + " run"); !ok {
118+
t.Errorf("CHATCLI_AGENT_ALLOWLIST=%q did not register %q", value, cmd)
119+
}
120+
}
121+
}
122+
}
123+
124+
func TestExtraReadPaths_AcceptsBothSeparators(t *testing.T) {
125+
if runtime.GOOS == "windows" {
126+
t.Skip("':' is a drive-letter separator on Windows")
127+
}
128+
for _, value := range []string{
129+
"/etc/hosts;/usr/local/share/config",
130+
"/etc/hosts:/usr/local/share/config",
131+
} {
132+
t.Setenv("CHATCLI_AGENT_EXTRA_READ_PATHS", value)
133+
s := NewSensitiveReadPaths()
134+
if len(s.extraReadPaths) != 2 {
135+
t.Errorf("CHATCLI_AGENT_EXTRA_READ_PATHS=%q parsed to %v", value, s.extraReadPaths)
136+
continue
137+
}
138+
if s.extraReadPaths[0] != "/etc/hosts" || s.extraReadPaths[1] != "/usr/local/share/config" {
139+
t.Errorf("CHATCLI_AGENT_EXTRA_READ_PATHS=%q parsed to %v", value, s.extraReadPaths)
140+
}
141+
}
142+
}
143+
144+
// A path list that genuinely needs a semicolon in a name still resolves via
145+
// the native separator, so nothing that worked stops working.
146+
func TestExtraReadPaths_NativeSeparatorStillWorks(t *testing.T) {
147+
dir := t.TempDir()
148+
a := filepath.Join(dir, "a")
149+
b := filepath.Join(dir, "b")
150+
t.Setenv("CHATCLI_AGENT_EXTRA_READ_PATHS", a+string(os.PathListSeparator)+b)
151+
s := NewSensitiveReadPaths()
152+
if len(s.extraReadPaths) != 2 {
153+
t.Fatalf("native separator parsed to %v", s.extraReadPaths)
154+
}
155+
}

cli/agent/command_allowlist.go

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ func DefaultAllowedCommands() map[string]string {
4141
"ln": "file", "chmod": "file", "chown": "file", "basename": "file",
4242
"dirname": "file", "realpath": "file", "readlink": "file",
4343
"md5sum": "file", "sha256sum": "file", "sha1sum": "file",
44+
"cmp": "file",
4445

4546
// Text processing
4647
"grep": "text", "rg": "text", "sed": "text", "awk": "text",
@@ -50,6 +51,8 @@ func DefaultAllowedCommands() map[string]string {
5051
"fold": "text", "expand": "text", "unexpand": "text",
5152
"comm": "text", "join": "text", "nl": "text", "rev": "text",
5253
"strings": "text", "od": "text", "xxd": "text", "hexdump": "text",
54+
"ag": "text", "look": "text", "base64": "text", "xmllint": "text",
55+
"csvtool": "text", "openssl": "text",
5356

5457
// Development tools
5558
"go": "dev", "git": "dev", "make": "dev", "npm": "dev",
@@ -65,6 +68,7 @@ func DefaultAllowedCommands() map[string]string {
6568
"gofmt": "dev", "golint": "dev", "gopls": "dev",
6669
"eslint": "dev", "prettier": "dev", "black": "dev",
6770
"pytest": "dev", "jest": "dev", "mocha": "dev",
71+
"npx": "dev", "poetry": "dev", "zig": "dev", "kotlinc": "dev",
6872

6973
// Container / Infrastructure
7074
"docker": "container", "podman": "container",
@@ -75,6 +79,7 @@ func DefaultAllowedCommands() map[string]string {
7579
"kustomize": "container", "oc": "container",
7680
"eksctl": "container", "gcloud": "container",
7781
"aws": "container", "az": "container",
82+
"istioctl": "container", "argocd": "container", "flux": "container",
7883

7984
// Network (read-only oriented)
8085
"curl": "network", "wget": "network",
@@ -92,6 +97,7 @@ func DefaultAllowedCommands() map[string]string {
9297
"id": "sysinfo", "groups": "sysinfo", "lsof": "sysinfo",
9398
"ulimit": "sysinfo", "locale": "sysinfo", "getconf": "sysinfo",
9499
"arch": "sysinfo", "nproc": "sysinfo", "lscpu": "sysinfo",
100+
"cal": "sysinfo",
95101
"lsblk": "sysinfo", "mount": "sysinfo", "lsusb": "sysinfo",
96102

97103
// Editors / Viewers
@@ -107,6 +113,15 @@ func DefaultAllowedCommands() map[string]string {
107113
"export": "shell", "set": "shell", "unset": "shell",
108114
"alias": "shell", "type": "shell", "command": "shell",
109115
"source": "shell", "eval": "shell", "exec": "shell",
116+
"clear": "shell", "reset": "shell", "tput": "shell", "stty": "shell",
117+
118+
// Navigation and no-op builtins. They carry no capability of their
119+
// own, and they are how real command lines are written: refusing
120+
// them once every segment is checked would break "cd sub && build"
121+
// without withholding anything.
122+
"cd": "shell", "pwd": "shell", "pushd": "shell", "popd": "shell",
123+
"dirs": "shell", "wait": "shell", "read": "shell", "shift": "shell",
124+
"jobs": "shell", ":": "shell", "[": "shell",
110125
"sh": "shell", "bash": "shell", "zsh": "shell",
111126
}
112127
return commands
@@ -124,9 +139,13 @@ func NewCommandAllowlist() *CommandAllowlist {
124139
mode: mode,
125140
}
126141

127-
// Add custom commands from CHATCLI_AGENT_ALLOWLIST env var (comma-separated)
142+
// Add custom commands from CHATCLI_AGENT_ALLOWLIST. Both separators are
143+
// accepted: the sibling denylist variable is semicolon-separated, the
144+
// documentation said semicolon here too, and a value that silently
145+
// registers one command named "a;b;c" is a configuration that looks
146+
// applied and is not.
128147
if extra := os.Getenv("CHATCLI_AGENT_ALLOWLIST"); extra != "" {
129-
for _, cmd := range strings.Split(extra, ",") {
148+
for _, cmd := range strings.FieldsFunc(extra, func(r rune) bool { return r == ',' || r == ';' }) {
130149
cmd = strings.TrimSpace(cmd)
131150
if cmd != "" {
132151
al.allowedCommands[cmd] = "custom"
@@ -137,8 +156,16 @@ func NewCommandAllowlist() *CommandAllowlist {
137156
return al
138157
}
139158

140-
// IsAllowed checks if a command is in the allowlist.
159+
// IsAllowed checks whether every command on the line is in the allowlist.
141160
// Returns (allowed, category, reason).
161+
//
162+
// Every command, not the first one: a line is a sequence of invocations, and
163+
// checking only the leading word means any allowed command is a passphrase
164+
// for the rest of the line. Prefixing with a benign command was enough to
165+
// carry an arbitrary one past the gate, which is the opposite of what an
166+
// allowlist is for.
167+
//
168+
// The category returned is the leading command's, which is what callers log.
142169
func (al *CommandAllowlist) IsAllowed(fullCommand string) (bool, string, string) {
143170
al.mu.RLock()
144171
defer al.mu.RUnlock()
@@ -148,11 +175,34 @@ func (al *CommandAllowlist) IsAllowed(fullCommand string) (bool, string, string)
148175
return false, "", "empty command"
149176
}
150177

151-
if category, ok := al.allowedCommands[baseCmd]; ok {
178+
category, ok := al.allowedCommands[baseCmd]
179+
if !ok {
180+
return false, "", "command '" + baseCmd + "' is not in the security allowlist"
181+
}
182+
183+
// Decompose with a real shell parser and hold every segment to the same
184+
// rule. On a line the parser could not read, the legacy single-command
185+
// check above stands on its own: a line bash cannot parse is one the
186+
// executor's shell is unlikely to run either, and failing closed here
187+
// would refuse every command on a host whose shell is not bash.
188+
segments, parsed := ParseShellSegmentsChecked(fullCommand)
189+
if !parsed {
152190
return true, category, ""
153191
}
154192

155-
return false, "", "command '" + baseCmd + "' is not in the security allowlist"
193+
for _, seg := range segments {
194+
segCmd := extractBaseCommand(seg.Full)
195+
if segCmd == "" {
196+
// A segment that carries no invocation — a bare assignment, say.
197+
// Nothing to authorize.
198+
continue
199+
}
200+
if _, ok := al.allowedCommands[segCmd]; !ok {
201+
return false, "", "command '" + segCmd + "' is not in the security allowlist"
202+
}
203+
}
204+
205+
return true, category, ""
156206
}
157207

158208
// GetMode returns the current security mode.

cli/agent/read_path_validator.go

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ package agent
88
import (
99
"os"
1010
"path/filepath"
11+
"runtime"
1112
"strings"
1213

1314
"github.com/diillson/chatcli/pkg/fspath"
@@ -29,10 +30,15 @@ func NewSensitiveReadPaths() *SensitiveReadPaths {
2930
allowKubeconfig: strings.EqualFold(os.Getenv("CHATCLI_AGENT_ALLOW_KUBECONFIG"), "true"),
3031
}
3132

32-
// Parse extra allowed read paths (separated by os.PathListSeparator:
33-
// ':' on Unix, ';' on Windows — ':' would split drive letters apart)
33+
// Parse extra allowed read paths. The native separator always works
34+
// (':' on Unix, ';' on Windows), and ';' is additionally accepted on
35+
// Unix because every sibling agent variable uses it and the
36+
// documentation said so here too — a value that silently becomes one
37+
// path named "/a;/b" is a setting that looks applied and is not.
38+
// ':' is never a separator on Windows: it would split drive letters
39+
// apart.
3440
if extra := os.Getenv("CHATCLI_AGENT_EXTRA_READ_PATHS"); extra != "" {
35-
for _, p := range filepath.SplitList(extra) {
41+
for _, p := range splitReadPaths(extra) {
3642
p = strings.TrimSpace(p)
3743
if p != "" {
3844
s.extraReadPaths = append(s.extraReadPaths, p)
@@ -213,3 +219,14 @@ func (s *SensitiveReadPaths) isSensitivePath(path string) (bool, string) {
213219

214220
return false, ""
215221
}
222+
223+
// splitReadPaths splits a path list on the separators that are unambiguous
224+
// for the host: the native one everywhere, plus ';' on Unix.
225+
func splitReadPaths(value string) []string {
226+
if runtime.GOOS == "windows" {
227+
return filepath.SplitList(value)
228+
}
229+
return strings.FieldsFunc(value, func(r rune) bool {
230+
return r == os.PathListSeparator || r == ';'
231+
})
232+
}

cli/agent/shell_parser.go

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,17 +46,29 @@ type ShellSegment struct {
4646
// the dangerous-pattern matcher still runs against the full line as a
4747
// belt-and-suspenders measure.
4848
func ParseShellSegments(line string) []ShellSegment {
49+
segments, _ := ParseShellSegmentsChecked(line)
50+
return segments
51+
}
52+
53+
// ParseShellSegmentsChecked is ParseShellSegments plus whether the line
54+
// actually parsed.
55+
//
56+
// The distinction matters to a caller that decides policy per segment: the
57+
// fallback returns the whole line as one segment, which is indistinguishable
58+
// from a genuine single command, and a gate that cannot tell them apart is a
59+
// gate that can be talked out of decomposing a line at all.
60+
func ParseShellSegmentsChecked(line string) (segments []ShellSegment, parsed bool) {
4961
trimmed := strings.TrimSpace(line)
5062
if trimmed == "" {
51-
return nil
63+
return nil, true
5264
}
5365
parser := syntax.NewParser(syntax.Variant(syntax.LangBash))
5466
file, err := parser.Parse(strings.NewReader(trimmed), "")
5567
if err != nil {
5668
// Couldn't parse — return whole line as one segment so callers can
5769
// still run their regex matchers. Returning nil would silently bypass
5870
// the dangerous-pattern check.
59-
return []ShellSegment{singleSegment(trimmed, false, 0)}
71+
return []ShellSegment{singleSegment(trimmed, false, 0)}, false
6072
}
6173

6274
var out []ShellSegment
@@ -67,7 +79,7 @@ func ParseShellSegments(line string) []ShellSegment {
6779
if len(out) == 0 {
6880
out = append(out, singleSegment(trimmed, false, 0))
6981
}
70-
return out
82+
return out, true
7183
}
7284

7385
// walkStmt recursively flattens a statement into its constituent simple

cli/agent_coder_validation.go

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,27 @@ import (
1111
"strings"
1212
)
1313

14-
// isCoderExecDangerous checks if a @coder exec command contains a dangerous
15-
// shell command. It extracts the actual shell command from the parsed args
16-
// and validates it against the agent's CommandValidator.IsDangerous().
14+
// coderShellSubcommands are the @coder subcommands that run an arbitrary
15+
// shell line given to them.
16+
//
17+
// `test` belongs here as much as `exec` does: it takes the same --cmd and
18+
// runs it through the same shell. Guarding only the subcommand named after
19+
// running commands left the one named after running tests as an unguarded
20+
// path to the same place.
21+
var coderShellSubcommands = map[string]bool{"exec": true, "test": true}
22+
23+
// isCoderExecDangerous checks if a @coder subcommand that runs a shell line
24+
// carries a dangerous command. It extracts the actual shell command from the
25+
// parsed args and validates it against the agent's
26+
// CommandValidator.IsDangerous().
1727
// This is the critical security guard that prevents destructive commands
18-
// from executing through @coder exec even when the policy says "allow".
28+
// from executing through @coder even when the policy says "allow".
1929
func (a *AgentMode) isCoderExecDangerous(toolArgs []string) (bool, string) {
2030
if len(toolArgs) == 0 {
2131
return false, ""
2232
}
2333
sub := strings.ToLower(strings.TrimSpace(toolArgs[0]))
24-
if sub != "exec" {
34+
if !coderShellSubcommands[sub] {
2535
return false, ""
2636
}
2737
// Extract the --cmd value from parsed args

0 commit comments

Comments
 (0)