Skip to content

Commit 615b1ec

Browse files
committed
fix: delimit remote resolve records safely
1 parent 95d652e commit 615b1ec

2 files changed

Lines changed: 76 additions & 14 deletions

File tree

internal/ssh/resolve.go

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package ssh
33
import (
44
"context"
55
"fmt"
6+
"path"
67
"strings"
78

89
"go.kenn.io/agentsview/internal/parser"
@@ -13,6 +14,8 @@ import (
1314
// is not a valid agent type, so parseResolvedDirs routes it separately.
1415
const resolveFilePrefix = "@file"
1516

17+
const resolveRecordSep = "\x00"
18+
1619
func aiderSkipDirCasePattern() string {
1720
return strings.Join(parser.AiderDiscoverySkipDirNames(), "|")
1821
}
@@ -34,7 +37,7 @@ func buildAiderResolveSnippet(envVar string) string {
3437
"[ \"$av_aider_files\" -ge %d ] && return; "+
3538
"[ \"$av_aider_dirs\" -ge %d ] && return; "+
3639
"elif [ -f \"$av_entry\" ] && [ \"$av_base\" = '%s' ]; then "+
37-
"echo \"%s:$av_entry\"; "+
40+
"printf '%%s\\000' \"%s:$av_entry\"; "+
3841
"av_aider_files=$((av_aider_files + 1)); "+
3942
"[ \"$av_aider_files\" -ge %d ] && return; "+
4043
"fi; "+
@@ -100,7 +103,8 @@ func buildResolveScript() string {
100103
dirExpr = fmt.Sprintf("${%s:-%s}", def.EnvVar, defaultDir)
101104
}
102105
fmt.Fprintf(&b,
103-
"dir=\"%s\"; [ -d \"$dir\" ] && echo \"%s:$dir\"\n",
106+
"dir=\"%s\"; [ -d \"$dir\" ] && "+
107+
"printf '%%s\\000' \"%s:$dir\"\n",
104108
dirExpr, string(def.Type),
105109
)
106110
// Codex stores renameable session titles in
@@ -110,7 +114,8 @@ func buildResolveScript() string {
110114
if def.Type == parser.AgentCodex {
111115
fmt.Fprintf(&b,
112116
"idx=\"${dir%%/*}/%s\"; "+
113-
"[ -f \"$idx\" ] && echo \"%s:$idx\"\n",
117+
"[ -f \"$idx\" ] && "+
118+
"printf '%%s\\000' \"%s:$idx\"\n",
114119
parser.CodexSessionIndexFilename,
115120
resolveFilePrefix,
116121
)
@@ -124,23 +129,26 @@ func buildResolveScript() string {
124129
}
125130

126131
// parseResolvedDirs parses script output into a map of agent type to transfer
127-
// target paths plus a deduplicated list of extra files (lines tagged with
128-
// resolveFilePrefix). Most agent targets are directories; Aider targets are
129-
// individual .aider.chat.history.md files. Skips empty lines and entries with
130-
// empty values.
132+
// target paths plus a deduplicated list of extra files (records tagged with
133+
// resolveFilePrefix). Generated resolver output is NUL-delimited so remote
134+
// paths containing newlines cannot inject extra records; newline-delimited input
135+
// is accepted only for older tests and defensive compatibility. Most agent
136+
// targets are directories; Aider targets are individual .aider.chat.history.md
137+
// files. Skips empty records, empty values, and values containing record
138+
// separators.
131139
func parseResolvedDirs(
132140
output string,
133141
) (map[parser.AgentType][]string, []string) {
134142
dirs := make(map[parser.AgentType][]string)
135143
var extraFiles []string
136144
seenFile := make(map[string]struct{})
137-
for line := range strings.SplitSeq(output, "\n") {
138-
line = strings.TrimSpace(line)
139-
if line == "" {
145+
for _, record := range resolveOutputRecords(output) {
146+
record = strings.TrimSpace(record)
147+
if record == "" {
140148
continue
141149
}
142-
key, value, ok := strings.Cut(line, ":")
143-
if !ok || value == "" {
150+
key, value, ok := strings.Cut(record, ":")
151+
if !ok || invalidResolvedPath(value) {
144152
continue
145153
}
146154
if key == resolveFilePrefix {
@@ -152,11 +160,26 @@ func parseResolvedDirs(
152160
continue
153161
}
154162
at := parser.AgentType(key)
163+
if at == parser.AgentAider &&
164+
path.Base(value) != parser.AiderHistoryFileName() {
165+
continue
166+
}
155167
dirs[at] = append(dirs[at], value)
156168
}
157169
return dirs, extraFiles
158170
}
159171

172+
func resolveOutputRecords(output string) []string {
173+
if strings.Contains(output, resolveRecordSep) {
174+
return strings.Split(output, resolveRecordSep)
175+
}
176+
return strings.Split(output, "\n")
177+
}
178+
179+
func invalidResolvedPath(value string) bool {
180+
return value == "" || strings.ContainsAny(value, "\x00\r\n")
181+
}
182+
160183
// resolveDirs runs the resolve script on the remote host via SSH and
161184
// returns the discovered agent directories plus extra sibling files
162185
// (such as Codex's session_index.jsonl) to include in the transfer.

internal/ssh/resolve_test.go

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ func TestBuildResolveScript(t *testing.T) {
2323
if def.FileBased || def.DiscoverFunc != nil {
2424
continue
2525
}
26-
marker := "echo \"" + string(def.Type) + ":"
26+
marker := "\"" + string(def.Type) + ":"
2727
assert.NotContains(t, script, marker,
2828
"non-file-based agent %s in script", def.Type)
2929
}
@@ -33,7 +33,7 @@ func TestBuildResolveScript(t *testing.T) {
3333
if !def.FileBased || def.DiscoverFunc == nil {
3434
continue
3535
}
36-
marker := "echo \"" + string(def.Type) + ":"
36+
marker := "\"" + string(def.Type) + ":"
3737
assert.Contains(t, script, marker,
3838
"file-based agent %s missing from script", def.Type)
3939
}
@@ -183,6 +183,30 @@ func TestResolveScriptAiderScopedByEnvFindsHistoryFiles(t *testing.T) {
183183
"remote aider discovery must enforce the local depth cap")
184184
}
185185

186+
func TestResolveScriptAiderNewlinePathCannotInjectTarget(t *testing.T) {
187+
home := t.TempDir()
188+
codeRoot := filepath.Join(home, "code")
189+
injected := "/home/victim/" + parser.AiderHistoryFileName()
190+
maliciousDir := filepath.Join(codeRoot, "repo\naider:", "home", "victim")
191+
require.NoError(t, os.MkdirAll(maliciousDir, 0o755), "mkdir malicious dir")
192+
maliciousHistory := filepath.Join(maliciousDir, parser.AiderHistoryFileName())
193+
require.NoError(t, os.WriteFile(maliciousHistory, []byte("# aider\n"), 0o644))
194+
195+
script := buildResolveScript()
196+
cmd := exec.Command("sh", "-c", script)
197+
cmd.Env = []string{"HOME=" + home, "AIDER_DIR=" + codeRoot}
198+
out, err := cmd.CombinedOutput()
199+
require.NoError(t, err, "resolve script failed: output: %s", out)
200+
201+
dirs, _ := parseResolvedDirs(string(out))
202+
assert.NotContains(t, dirs[parser.AgentAider], injected,
203+
"newline-bearing repository paths must not inject a second transfer target")
204+
for _, target := range dirs[parser.AgentAider] {
205+
assert.NotContains(t, target, "\n",
206+
"aider transfer target must not contain record separators")
207+
}
208+
}
209+
186210
// TestResolveScriptAiderRejectsHomeOverride verifies that setting AIDER_DIR
187211
// to literal $HOME (the very thing the home-default skip prevents) is also
188212
// dropped, so an unscoped override cannot reintroduce a whole-home tar.
@@ -227,3 +251,18 @@ func TestParseResolvedDirs(t *testing.T) {
227251
assert.Equal(t,
228252
[]string{"/home/wes/.codex/session_index.jsonl"}, extraFiles)
229253
}
254+
255+
func TestParseResolvedDirsNULRecords(t *testing.T) {
256+
input := "claude:/home/wes/.claude/projects\x00" +
257+
"aider:/home/wes/code/repo/.aider.chat.history.md\x00" +
258+
"@file:/home/wes/.codex/session_index.jsonl\x00"
259+
260+
dirs, extraFiles := parseResolvedDirs(input)
261+
262+
assert.Equal(t, []string{"/home/wes/.claude/projects"}, dirs[parser.AgentClaude])
263+
assert.Equal(t,
264+
[]string{"/home/wes/code/repo/.aider.chat.history.md"},
265+
dirs[parser.AgentAider])
266+
assert.Equal(t,
267+
[]string{"/home/wes/.codex/session_index.jsonl"}, extraFiles)
268+
}

0 commit comments

Comments
 (0)