Skip to main content

agent_float_term/
harness.rs

1//! Deliberately small allowlists, not executable-name or prompt heuristics.
2//! Installation paths identify supported layouts; they do not authenticate code.
3//! Overwritten process titles/argv are not reconstructed from names or environment.
4//! If the remaining argv is unrecognizable, even a known installation is rejected.
5
6use std::ffi::OsString;
7use std::path::Path;
8
9use crate::config::HarnessMapping;
10
11#[derive(Clone, Copy, Debug, Eq, PartialEq)]
12pub(crate) enum Harness {
13    Claude,
14    Codex,
15    OpenCode,
16}
17
18#[derive(Clone, Copy, Debug, Eq, PartialEq)]
19pub(crate) struct Recognized {
20    pub harness: Harness,
21    pub node_wrapper: bool,
22    pub args_offset: usize,
23}
24
25fn named(name: &str) -> Option<Harness> {
26    match name {
27        "claude" => Some(Harness::Claude),
28        "codex" => Some(Harness::Codex),
29        "opencode" => Some(Harness::OpenCode),
30        _ => None,
31    }
32}
33
34fn mapped(path: &Path, mappings: &[HarnessMapping]) -> Option<Harness> {
35    let mut matches = mappings.iter().filter(|m| m.path == path);
36    let harness = named(&matches.next()?.harness)?;
37    // Ambiguous mappings must not silently select a parser.
38    if matches.any(|m| named(&m.harness) != Some(harness)) {
39        return None;
40    }
41    Some(harness)
42}
43
44fn home_relative(path: &str) -> Option<&str> {
45    if let Some(rest) = path.strip_prefix("/root/") {
46        return Some(rest);
47    }
48    let rest = path
49        .strip_prefix("/Users/")
50        .or_else(|| path.strip_prefix("/home/"))?;
51    let (user, rest) = rest.split_once('/')?;
52    (!user.is_empty()).then_some(rest)
53}
54
55fn version(value: &str) -> bool {
56    value.starts_with(|c: char| c.is_ascii_digit())
57        && value
58            .bytes()
59            .all(|c| c.is_ascii_alphanumeric() || b".-_+".contains(&c))
60}
61
62fn codex_target(value: &str) -> bool {
63    matches!(
64        value,
65        "aarch64-apple-darwin"
66            | "x86_64-apple-darwin"
67            | "aarch64-unknown-linux-musl"
68            | "x86_64-unknown-linux-musl"
69            | "aarch64-unknown-linux-gnu"
70            | "x86_64-unknown-linux-gnu"
71    )
72}
73
74fn platform_package(value: &str, prefix: &str) -> bool {
75    let Some(value) = value.strip_prefix(prefix) else {
76        return false;
77    };
78    matches!(
79        value,
80        "darwin-arm64"
81            | "darwin-x64"
82            | "linux-arm64"
83            | "linux-x64"
84            | "linux-arm64-musl"
85            | "linux-x64-musl"
86            | "linux-x64-baseline"
87            | "linux-x64-baseline-musl"
88            | "darwin-x64-baseline"
89    )
90}
91
92pub(crate) fn native_layout(path: &str) -> Option<Harness> {
93    if let Some(rest) = home_relative(path) {
94        if rest
95            .strip_prefix(".local/share/claude/versions/")
96            .is_some_and(version)
97        {
98            return Some(Harness::Claude);
99        }
100        if rest == ".opencode/bin/opencode" {
101            return Some(Harness::OpenCode);
102        }
103    }
104    if let Some(rest) = path
105        .strip_prefix("/opt/homebrew/Cellar/")
106        .or_else(|| path.strip_prefix("/usr/local/Cellar/"))
107    {
108        let parts: Vec<_> = rest.split('/').collect();
109        if let [package, release, "bin", binary] = parts.as_slice() {
110            if package == binary && version(release) {
111                return match *package {
112                    "codex" => Some(Harness::Codex),
113                    "opencode" => Some(Harness::OpenCode),
114                    _ => None,
115                };
116            }
117        }
118    }
119    if let Some(rest) = path
120        .strip_prefix("/opt/homebrew/Caskroom/")
121        .or_else(|| path.strip_prefix("/usr/local/Caskroom/"))
122    {
123        let parts: Vec<_> = rest.split('/').collect();
124        if let ["codex", release, "bin", "codex"] = parts.as_slice() {
125            if version(release) {
126                return Some(Harness::Codex);
127            }
128        }
129        if let [package, release, binary] = parts.as_slice() {
130            if version(release) {
131                if *package == "claude-code" && *binary == "claude" {
132                    return Some(Harness::Claude);
133                }
134                if *package == "codex" && binary.strip_prefix("codex-").is_some_and(codex_target) {
135                    return Some(Harness::Codex);
136                }
137            }
138        }
139    }
140    // Match the complete package-relative executable, never a package-name substring.
141    let (_, package) = path.rsplit_once("/node_modules/")?;
142    let parts: Vec<_> = package.split('/').collect();
143    match parts.as_slice() {
144        ["@openai", pkg, "vendor", target, "codex" | "bin", "codex"]
145            if (*pkg == "codex" || platform_package(pkg, "codex-")) && codex_target(target) =>
146        {
147            Some(Harness::Codex)
148        }
149        [pkg, "bin", "opencode"] if platform_package(pkg, "opencode-") => Some(Harness::OpenCode),
150        ["opencode-ai", "bin", ".opencode"] => Some(Harness::OpenCode),
151        ["@anthropic-ai", pkg, "claude"] if platform_package(pkg, "claude-code-") => {
152            Some(Harness::Claude)
153        }
154        _ => None,
155    }
156}
157
158fn node_entrypoint(path: &Path) -> Option<Harness> {
159    let path = path.to_str()?;
160    let (_, package) = path.rsplit_once("/node_modules/")?;
161    match package {
162        "@anthropic-ai/claude-code/cli.js" => Some(Harness::Claude),
163        "@openai/codex/bin/codex.js" => Some(Harness::Codex),
164        "opencode-ai/bin/opencode" => Some(Harness::OpenCode),
165        _ => None,
166    }
167}
168
169/// Inputs are canonical executable/entrypoint/mapping paths supplied by the inspector.
170/// No process environments are read, and argument contents never enter diagnostics.
171pub(crate) fn classify(
172    executable: &Path,
173    argv: &[OsString],
174    mappings: &[HarnessMapping],
175) -> Option<Recognized> {
176    if !executable.is_absolute() || argv.is_empty() {
177        return None;
178    }
179    let node = matches!(executable.file_name()?.to_str()?, "node" | "nodejs");
180    let (harness, offset) = if node {
181        // Node flags, eval, loaders, and relative entrypoints are deliberately unsupported.
182        let entry = Path::new(argv.get(1)?);
183        if !entry.is_absolute() {
184            return None;
185        }
186        let harness = if mappings.iter().any(|m| m.path == entry) {
187            mapped(entry, mappings)?
188        } else {
189            node_entrypoint(entry)?
190        };
191        (harness, 2)
192    } else {
193        let harness = if mappings.iter().any(|m| m.path == executable) {
194            mapped(executable, mappings)?
195        } else {
196            native_layout(executable.to_str()?)?
197        };
198        (harness, 1)
199    };
200    let argv0 = Path::new(&argv[0]).file_name()?;
201    let expected_name = if node {
202        "node"
203    } else {
204        match harness {
205            Harness::Claude => "claude",
206            Harness::Codex => "codex",
207            Harness::OpenCode => "opencode",
208        }
209    };
210    // Besides rejecting rewritten argv, this prevents a malformed macOS argv[0]
211    // from shifting an option into the otherwise ignored program-name slot.
212    if argv0 != expected_name && Some(argv0) != executable.file_name() {
213        return None;
214    }
215    interactive(harness, &argv[offset..]).then_some(Recognized {
216        harness,
217        node_wrapper: node,
218        args_offset: offset,
219    })
220}
221
222fn interactive(harness: Harness, args: &[OsString]) -> bool {
223    let mut index = 0;
224    let mut positional = false;
225    let mut command = false;
226    while index < args.len() {
227        let Some(arg) = args[index].to_str() else {
228            return false;
229        };
230        index += 1;
231        if arg == "--" {
232            // Supporting arbitrary arguments after -- would obscure subcommand selection.
233            return false;
234        }
235        if arg.starts_with('-') {
236            let (flag, inline) = arg
237                .split_once('=')
238                .map_or((arg, None), |(k, v)| (k, Some(v)));
239            if harness == Harness::Claude && matches!(flag, "--resume" | "-r") {
240                // Commander-style optional selector: consume one non-option value,
241                // not the prompt slot. Later options/subcommands still need checking.
242                if let Some(value) = inline {
243                    if flag != "--resume" || value.is_empty() {
244                        return false;
245                    }
246                } else if let Some(value) = args.get(index) {
247                    let Some(value) = value.to_str() else {
248                        return false;
249                    };
250                    if value.is_empty() {
251                        return false;
252                    }
253                    if !value.starts_with('-') {
254                        index += 1;
255                    }
256                }
257                continue;
258            }
259            let (switches, values): (&[&str], &[&str]) = match harness {
260                Harness::Claude => (
261                    &[
262                        "--continue",
263                        "-c",
264                        "--verbose",
265                        "--dangerously-skip-permissions",
266                        "--allow-dangerously-skip-permissions",
267                        "--fork-session",
268                        "--ide",
269                        "--strict-mcp-config",
270                        "--disable-slash-commands",
271                    ],
272                    &[
273                        "--model",
274                        "--permission-mode",
275                        "--system-prompt",
276                        "--append-system-prompt",
277                        "--settings",
278                        "--setting-sources",
279                        "--session-id",
280                        "--agent",
281                        "--agents",
282                        "--mcp-config",
283                    ],
284                ),
285                Harness::Codex => (
286                    &[
287                        "--full-auto",
288                        "--dangerously-bypass-approvals-and-sandbox",
289                        "--oss",
290                        "--search",
291                        "--no-alt-screen",
292                    ],
293                    &[
294                        "--profile",
295                        "-p",
296                        "--model",
297                        "-m",
298                        "--config",
299                        "-c",
300                        "--sandbox",
301                        "-s",
302                        "--ask-for-approval",
303                        "-a",
304                        "--cd",
305                        "-C",
306                        "--image",
307                        "-i",
308                        "--add-dir",
309                        "--enable",
310                        "--disable",
311                        "--local-provider",
312                    ],
313                ),
314                Harness::OpenCode => (
315                    &["--print-logs", "--continue", "-c", "--fork"],
316                    &[
317                        "--model",
318                        "-m",
319                        "--agent",
320                        "--session",
321                        "-s",
322                        "--prompt",
323                        "--port",
324                        "--hostname",
325                        "--log-level",
326                        "--password",
327                        "--dir",
328                    ],
329                ),
330            };
331            if harness == Harness::Codex
332                && command
333                && matches!(flag, "--last" | "--all")
334                && inline.is_none()
335            {
336                continue;
337            }
338            if switches.contains(&flag) && inline.is_none() {
339                continue;
340            }
341            if !values.contains(&flag) {
342                // Includes Claude -p/--print, help/version, and every unknown flag.
343                return false;
344            }
345            if let Some(value) = inline {
346                if !flag.starts_with("--") || value.is_empty() {
347                    return false;
348                }
349            } else {
350                let Some(value) = args.get(index).and_then(|v| v.to_str()) else {
351                    return false;
352                };
353                if value.is_empty() || value.starts_with('-') {
354                    return false;
355                }
356                index += 1;
357            }
358        } else {
359            if !positional && !command {
360                let allowed_command = match harness {
361                    Harness::Claude => false,
362                    Harness::Codex => matches!(arg, "resume" | "fork"),
363                    Harness::OpenCode => arg == "attach",
364                };
365                if allowed_command {
366                    command = true;
367                    continue;
368                }
369                let forbidden_command = match harness {
370                    Harness::Claude => matches!(
371                        arg,
372                        "auth"
373                            | "help"
374                            | "agents"
375                            | "remote-control"
376                            | "rc"
377                            | "bridge"
378                            | "config"
379                            | "doctor"
380                            | "install"
381                            | "mcp"
382                            | "plugin"
383                            | "setup-token"
384                            | "update"
385                            | "upgrade"
386                    ),
387                    Harness::Codex => matches!(
388                        arg,
389                        "exec"
390                            | "e"
391                            | "review"
392                            | "login"
393                            | "logout"
394                            | "mcp"
395                            | "mcp-server"
396                            | "app"
397                            | "app-server"
398                            | "completion"
399                            | "sandbox"
400                            | "debug"
401                            | "apply"
402                            | "a"
403                            | "cloud"
404                            | "features"
405                            | "help"
406                    ),
407                    Harness::OpenCode => matches!(
408                        arg,
409                        "run"
410                            | "serve"
411                            | "web"
412                            | "auth"
413                            | "mcp"
414                            | "models"
415                            | "upgrade"
416                            | "uninstall"
417                            | "stats"
418                            | "export"
419                            | "import"
420                            | "github"
421                            | "pr"
422                            | "session"
423                            | "agent"
424                            | "debug"
425                            | "completion"
426                            | "help"
427                            | "acp"
428                    ),
429                };
430                if forbidden_command {
431                    return false;
432                }
433            }
434            // One initial prompt/project/selector is supported; no guesses about extra operands.
435            if positional || arg.is_empty() {
436                return false;
437            }
438            positional = true;
439        }
440    }
441    true
442}
443
444pub(crate) fn is_shell(executable: &Path) -> bool {
445    let Some(path) = executable.to_str() else {
446        return false;
447    };
448    let Some(name) = executable.file_name().and_then(|v| v.to_str()) else {
449        return false;
450    };
451    if !matches!(name, "sh" | "bash" | "zsh" | "fish" | "dash" | "ksh") {
452        return false;
453    }
454    if matches!(
455        executable.parent().and_then(|p| p.to_str()),
456        Some("/bin" | "/usr/bin" | "/usr/local/bin")
457    ) {
458        return true;
459    }
460    let Some(rest) = path
461        .strip_prefix("/opt/homebrew/Cellar/")
462        .or_else(|| path.strip_prefix("/usr/local/Cellar/"))
463    else {
464        return false;
465    };
466    let parts: Vec<_> = rest.split('/').collect();
467    matches!(parts.as_slice(), [package, release, "bin", binary] if *package == name && *binary == name && version(release))
468}
469
470#[cfg(test)]
471mod tests {
472    use super::*;
473
474    fn args(values: &[&str]) -> Vec<OsString> {
475        values.iter().map(OsString::from).collect()
476    }
477
478    #[test]
479    fn mode_parsing_is_harness_specific() {
480        for (harness, argv, expected) in [
481            (Harness::Claude, vec![], true),
482            (Harness::Claude, vec!["--print", "hello"], false),
483            (Harness::Claude, vec!["-p", "hello"], false),
484            (Harness::Claude, vec!["-phello"], false),
485            (Harness::Claude, vec!["--model=sonnet", "hello"], true),
486            (Harness::Claude, vec!["--resume"], true),
487            (Harness::Claude, vec!["--resume=session"], true),
488            (Harness::Claude, vec!["mcp", "serve"], false),
489            (Harness::Claude, vec!["remote-control"], false),
490            (Harness::Claude, vec!["help"], false),
491            (Harness::Codex, vec!["-p", "work"], true),
492            (
493                Harness::Codex,
494                vec!["--profile=work", "exec", "hello"],
495                false,
496            ),
497            (Harness::Codex, vec!["e", "hello"], false),
498            (Harness::Codex, vec!["resume", "abc"], true),
499            (Harness::Codex, vec!["resume", "--last"], true),
500            (Harness::Codex, vec!["--unknown"], false),
501            (Harness::Codex, vec!["--model"], false),
502            (Harness::Codex, vec!["--model", "--help"], false),
503            (Harness::Codex, vec!["--", "exec"], false),
504            (Harness::OpenCode, vec!["run", "hello"], false),
505            (
506                Harness::OpenCode,
507                vec!["attach", "http://localhost:4096"],
508                true,
509            ),
510            (Harness::OpenCode, vec!["--model", "provider/model"], true),
511            (Harness::OpenCode, vec!["serve"], false),
512        ] {
513            assert_eq!(
514                interactive(harness, &args(&argv)),
515                expected,
516                "{harness:?} {argv:?}"
517            );
518        }
519    }
520
521    #[test]
522    fn native_layouts_not_basenames() {
523        for (path, expected) in [
524            ("/tmp/claude", None),
525            ("/usr/local/bin/codex", None),
526            ("/tmp/.opencode/bin/opencode", None),
527            ("/home/alice/.opencode/bin/opencode", Some(Harness::OpenCode)),
528            ("/Users/alice/.local/share/claude/versions/2.1.1", Some(Harness::Claude)),
529            ("/opt/homebrew/Caskroom/codex/0.98.0/codex-aarch64-apple-darwin", Some(Harness::Codex)),
530            ("/opt/homebrew/Caskroom/codex/0.153.4/bin/codex", Some(Harness::Codex)),
531            ("/usr/local/lib/node_modules/@openai/codex/vendor/x86_64-unknown-linux-musl/codex/codex", Some(Harness::Codex)),
532            ("/usr/local/lib/node_modules/@openai/codex-linux-x64/vendor/x86_64-unknown-linux-musl/codex/codex", Some(Harness::Codex)),
533            ("/usr/local/lib/node_modules/@openai/codex-linux-x64/vendor/x86_64-unknown-linux-musl/bin/codex", Some(Harness::Codex)),
534            ("/tmp/node_modules/opencode-linux-x64/bin/opencode", Some(Harness::OpenCode)),
535            ("/tmp/node_modules/not-codex/bin/codex", None),
536        ] {
537            assert_eq!(native_layout(path), expected, "{path}");
538        }
539    }
540
541    #[test]
542    fn claude_resume_consumes_only_its_optional_selector() {
543        for (argv, expected) in [
544            (vec!["-r"], true),
545            (vec!["--resume", "session-id"], true),
546            (vec!["-r", "session-id", "hello"], true),
547            (vec!["--resume=session-id", "hello"], true),
548            (vec!["--resume", "--model", "sonnet"], true),
549            (vec!["--resume", "mcp"], true), // A selector here, not a subcommand.
550            (vec!["--resume", "session-id", "mcp"], false),
551            (vec!["-r", "session-id", "auth"], false),
552            (vec!["--resume=session-id", "help"], false),
553            (vec!["--resume", "session-id", "--unknown"], false),
554            (vec!["--resume", "--unknown"], false),
555            (vec!["-r", "--print"], false),
556            (vec!["-r", "session-id", "-p", "hello"], false),
557            (vec!["--resume="], false),
558            (vec!["-r", ""], false),
559            (vec!["-rsession-id"], false),
560            (vec!["-r=session-id"], false),
561        ] {
562            assert_eq!(
563                interactive(Harness::Claude, &args(&argv)),
564                expected,
565                "{argv:?}"
566            );
567        }
568    }
569
570    #[test]
571    fn unrecognizable_overwritten_titles_are_not_inferred_from_installation() {
572        assert!(classify(
573            Path::new("/home/alice/.local/share/claude/versions/2.1.1"),
574            &args(&["claude: working"]),
575            &[]
576        )
577        .is_none());
578        assert!(classify(Path::new("/usr/bin/node"), &args(&["opencode"]), &[]).is_none());
579    }
580
581    #[test]
582    fn node_requires_exact_entrypoint() {
583        let node = Path::new("/usr/bin/node");
584        assert!(classify(
585            node,
586            &args(&[
587                "node",
588                "/usr/lib/node_modules/@openai/codex/bin/codex.js",
589                "-p",
590                "work"
591            ]),
592            &[]
593        )
594        .is_some());
595        for argv in [
596            vec!["node", "/tmp/codex.js"],
597            vec!["node", "--eval", "codex"],
598            vec!["node", "node_modules/@openai/codex/bin/codex.js"],
599            vec![
600                "node",
601                "/usr/lib/node_modules/@openai/codex/bin/codex.js",
602                "exec",
603            ],
604        ] {
605            assert!(classify(node, &args(&argv), &[]).is_none());
606        }
607    }
608
609    #[test]
610    fn custom_mapping_opts_in_but_does_not_bypass_mode_check() {
611        let executable = Path::new("/opt/custom/ai");
612        let mappings = vec![HarnessMapping {
613            harness: "codex".into(),
614            path: executable.into(),
615        }];
616        assert!(classify(executable, &args(&["ai"]), &[]).is_none());
617        assert!(classify(executable, &args(&["ai", "-p", "work"]), &mappings).is_some());
618        assert!(classify(executable, &args(&["ai", "exec"]), &mappings).is_none());
619        let ambiguous = vec![
620            HarnessMapping {
621                harness: "codex".into(),
622                path: executable.into(),
623            },
624            HarnessMapping {
625                harness: "claude".into(),
626                path: executable.into(),
627            },
628        ];
629        assert!(classify(executable, &args(&["ai"]), &ambiguous).is_none());
630    }
631}