diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 912d9a25..6ef9db25 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -110,6 +110,12 @@ jobs: # the registry hive # - safepath: opening a leaf without following a reparse point and matching # the kernel's own final path for the handle + # - executor: Readlink on a real directory junction (ModeIrregular entry, + # NT-namespace target), which the skills detector's junction folding + # depends on + # - detector TestReal_*: the Kiro CLI registry reader against a scratch + # HKCU key, and skills junction folding over real junctions (the rest of + # the detector suite assumes a POSIX host, hence the -run filter) # This job executes them natively; scoped to these packages so the rest of the # suite (which assumes a POSIX host) is untouched. test-windows-native: @@ -125,7 +131,8 @@ jobs: - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 with: go-version-file: go.mod - - run: go test -race -count=1 ./internal/devicepolicy/ ./internal/secureuserfile/ ./internal/detector/credentials/ ./internal/safepath/ + - run: go test -race -count=1 ./internal/devicepolicy/ ./internal/secureuserfile/ ./internal/detector/credentials/ ./internal/safepath/ ./internal/executor/ + - run: go test -race -count=1 -run '^TestReal_' ./internal/detector/ smoke: name: Smoke Tests diff --git a/README.md b/README.md index b2bf53dd..6f491191 100644 --- a/README.md +++ b/README.md @@ -282,8 +282,8 @@ See [SCAN_COVERAGE.md](SCAN_COVERAGE.md) for the full catalog of supported detec | Category | Examples | | -------------------- | ---------------------------------------------------------------------------------------- | -| IDEs & Desktop Apps | VS Code, Cursor, Windsurf, Antigravity, Zed, Claude, Copilot, JetBrains suite (13 IDEs), Eclipse, Android Studio | -| AI CLI Tools | Claude Code, Codex, Gemini CLI, Kiro, GitHub Copilot CLI, Aider, OpenCode, Cursor Agent, Pi, Factory Droid, Amp | +| IDEs & Desktop Apps | VS Code, Cursor, Windsurf, Antigravity, Kiro, Zed, Claude, Copilot, JetBrains suite (13 IDEs), Eclipse, Android Studio | +| AI CLI Tools | Claude Code, Codex, Gemini CLI, Kiro CLI, GitHub Copilot CLI, Aider, OpenCode, Cursor Agent, Pi, Factory Droid, Amp, Grok Build, Kimi Code, Muse Code, Hermes Agent, Oh My Pi | | AI Agents | Claude Cowork, OpenClaw, ClawdBot, GPT-Engineer | | AI Frameworks | Ollama, LM Studio, LocalAI, Text Generation WebUI | | MCP Server Configs | Claude Desktop, Claude Code, Cursor, Windsurf, Antigravity, Zed, Open Interpreter, Codex, OpenCode | diff --git a/SCAN_COVERAGE.md b/SCAN_COVERAGE.md index 20b8be86..0b299a73 100644 --- a/SCAN_COVERAGE.md +++ b/SCAN_COVERAGE.md @@ -12,6 +12,7 @@ Detection uses platform-specific paths: `/Applications/*.app` on macOS, `%LOCALA | Cursor | Cursor | `/Applications/Cursor.app` | `%LOCALAPPDATA%\Programs\cursor` | LookPath, `.desktop` files | | Windsurf | Codeium | `/Applications/Windsurf.app` | `%LOCALAPPDATA%\Programs\Windsurf` | LookPath, `.desktop` files | | Antigravity | Google | `/Applications/Antigravity.app` | `%LOCALAPPDATA%\Programs\Antigravity` | LookPath, `.desktop` files | +| Kiro | Amazon | `/Applications/Kiro.app`‡ | `%LOCALAPPDATA%\Programs\Kiro`‡ | `/usr/share/kiro`‡ | | Zed | Zed | `/Applications/Zed.app` | `%LOCALAPPDATA%\Zed` | LookPath, `.desktop` files | | Claude Desktop | Anthropic | `/Applications/Claude.app` | `%LOCALAPPDATA%\Programs\Claude` | LookPath, `.desktop` files | | Microsoft Copilot | Microsoft | `/Applications/Copilot.app` | `%LOCALAPPDATA%\Programs\Copilot` | LookPath, `.desktop` files | @@ -41,7 +42,7 @@ Detection is cross-platform — binaries are located via `$PATH` lookup and home | Claude Code | Anthropic | `claude` | `~/.claude` | | Codex | OpenAI | `codex` | `~/.codex` | | Gemini CLI | Google | `gemini` | `~/.gemini` | -| Amazon Q / Kiro CLI | Amazon | `kiro-cli`, `kiro`, `q` | `~/.q`, `~/.kiro`, `~/.aws/q` | +| Kiro CLI | Amazon | `kiro-cli`, `kiro`, `q`§ | `~/.q`, `~/.kiro`, `~/.aws/q` | | GitHub Copilot CLI | Microsoft | `copilot`, `gh-copilot`† | `~/.config/github-copilot`, `~/.copilot` | | Microsoft AI Shell | Microsoft | `aish`, `ai` | `~/.aish` | | Aider | OpenSource| `aider` | `~/.aider` | @@ -50,10 +51,19 @@ Detection is cross-platform — binaries are located via `$PATH` lookup and home | Pi | Earendil | `pi` | `~/.pi/agent` | | Factory Droid | Factory | `droid` | `~/.factory` | | Amp | Sourcegraph| `amp` | `~/.config/amp` | +| Grok Build | xAI | `grok` | `~/.grok` | +| Kimi Code | Moonshot | `kimi` | `~/.kimi-code` | +| Muse Code | Meta | `muse` | `~/.config/muse` | +| Hermes Agent | Nous Research | `hermes` | `~/AppData/Local/hermes`, `~/.hermes` | +| Oh My Pi | Stencil | `omp` | `~/.omp/agent` | + +‡ Kiro IDE is detected only at those exact roots, and only when the root itself proves it is Kiro: macOS requires the bundle identifier `dev.kiro.desktop` in `Info.plist` (read in-process) plus the shipped CLI shim `Contents/Resources/app/bin/code`, with the version from `CFBundleShortVersionString`; Windows and Linux require `Kiro.exe` / `kiro` in the root plus `resources/app/package.json` naming `Kiro`, with the version from that manifest. A missing or malformed version is reported as `unknown`. Every path the resolver touches is first checked, component by component from the root down, for a symlink or junction before anything is followed; a link anywhere on it (the root, an intermediate directory, the metadata file, the binary) makes the install undetected rather than resolved. Also not detected: portable Linux trees reachable only through `$PATH` or `.desktop` files, Windows installs at custom paths, and installs known only from Windows Uninstall registry rows — that registry is never consulted for Kiro, since its DisplayName match is a substring and "Kiro" matches the "Kiro CLI" row. Nothing Kiro ships is ever launched. + +§ Kiro CLI (reported as `amazon-q-cli`) is identified passively, per platform, and never launched — earlier releases ran every `kiro-cli`/`kiro`/`q` on `$PATH` with `--version` to read its banner. macOS: the candidate must resolve into `Kiro CLI.app/Contents/MacOS/kiro-cli` and the bundle's `Info.plist` must carry `com.amazon.codewhisperer` (read in-process); the version is `CFBundleShortVersionString`. Windows: the candidate must be `kiro-cli.exe` inside the `InstallPath` recorded at `HKCU\SOFTWARE\Kiro\CLI`, which also supplies `ProductVersion` (`2.21.1.0`-style) and is probed directly for non-default install locations; HKCU only, since scans run as the interactive user. Linux: either the `kiro-cli` dpkg package owns the binary (its version is reported), or the binary is `kiro-cli` with the installer's `kiro-cli-chat` and `kiro-cli-term` beside it (version `unknown`). The IDE's `kiro` launcher and the `q` shell wrapper are rejected. Not covered: the AppImage build, pre-rename Amazon Q layouts, and Windows installs whose key lives in another user's hive. † `gh copilot` launches this same `@github/copilot` CLI, downloading it into gh's own data directory when it isn't already on `$PATH` — so that install never lands on `$PATH`. After the two binary names miss, Copilot is also looked for at `~/.local/share/gh/copilot/copilot`, `~/.local/bin/copilot`, `~/AppData/Local/GitHub CLI/copilot/copilot`, `~/AppData/Local/Microsoft/WinGet/Links/copilot.exe`, `~/AppData/Roaming/npm/copilot.cmd`, and the `gh-copilot` extension directory under both `~/.local/share/gh/extensions` and `~/AppData/Local/GitHub CLI/extensions`. A non-default `$XDG_DATA_HOME` is not followed, and WinGet's hashed `Packages\GitHub.Copilot_\` payload directory is not globbed — only its `Links` shim. -Pi, Factory Droid and Amp share their binary names with unrelated popular tools, so a `$PATH` hit alone does not report them — each is confirmed from an on-disk artifact (a package manifest, an installer anchor directory, a Homebrew cask root, a winget or pacman package entry), and is searched for in the common global-install prefixes as well as on `$PATH`. Pi and Amp are never executed because macOS Gatekeeper prompts on their binaries; their versions come from disk or are reported as `unknown`. +Pi, Factory Droid, Amp, Grok Build, Kimi Code, Muse Code, Hermes Agent and Oh My Pi share their binary names with unrelated popular tools, so a `$PATH` hit alone does not report them — each is confirmed from an on-disk artifact (a package manifest, an installer anchor directory plus a corroborating sidecar, virtualenv or size floor, a Homebrew Cellar or cask root, a winget or pacman package entry), and is searched for in the common global-install prefixes (including mise's Oh My Pi install tree) as well as on `$PATH`. Of these eight, all but Factory Droid are never executed — macOS Gatekeeper prompts on their binaries — so their versions come from disk (a manifest, a versioned filename, a Python `dist-info` directory name, a Homebrew version segment) or are reported as `unknown`. Every winget-installed one reports `unknown`. No agent's config, auth, session or log files are read. ## General-Purpose AI Agents @@ -115,8 +125,11 @@ On Windows, `~` refers to the user's home directory (`%USERPROFILE%`). Claude De | OpenCode (project) | `opencode.json` / `opencode.jsonc` in a project directory | _(same)_ | OpenCode | ## AI Agent Skills +Dev Machine Guard inventories every installed **agent skill** — a directory containing a `SKILL.md` manifest — across Claude Code, Codex, OpenCode, Cursor, Gemini CLI, GitHub Copilot, Pi, Factory, Amp, Grok Build (`~/.grok/skills`, `.grok/skills`), Kimi Code (`~/.kimi-code/skills`, `.kimi-code/skills`), Muse Code (`~/.config/muse/skills`), Hermes Agent (`~/.hermes/skills` or `%LOCALAPPDATA%\hermes\skills`, `.hermes/skills`), Oh My Pi (`~/.omp/agent/skills`, `~/.omp/agent/managed-skills`, `.omp/skills`), Kiro (IDE and CLI, one shared root), Windsurf, Antigravity, OpenClaw, the cross-agent `~/.agents` convention, and skills installed via [skills.sh](https://skills.sh). It probes each agent's global, system, project, and plugin skill directories; skills.sh lock files add upstream provenance (joined by symlink-resolved path). Detection is pure filesystem reads (no subprocesses), bounded by a 60-second budget and per-root caps. + +Roots added for the Kiro/Windsurf/Antigravity/OpenClaw conventions: `~/.kiro/skills` and `/.kiro/skills`; `~/.codeium/windsurf/skills`, `/.windsurf/skills`, and the managed root (`/Library/Application Support/Windsurf/skills`, `/etc/windsurf/skills`, `%ProgramData%\Windsurf\skills`); `~/.gemini/config/skills` and `~/.gemini/antigravity/skills`; `~/.openclaw/skills` and the default `~/.openclaw/workspace/skills` (named `workspace-` and relocated workspaces are not discovered); and `/.codex/skills`. JetBrains AI Assistant's embedded-agent cache roots are not scanned. The singular `.agent/skills` roots keep their `factory_agent_*` source labels but are attributed to the shared agent, since Factory and Antigravity both read them. -Dev Machine Guard inventories every installed **agent skill** — a directory containing a `SKILL.md` manifest — across Claude Code, Codex, OpenCode, Cursor, Gemini CLI, GitHub Copilot, Pi, Factory, Amp, the cross-agent `~/.agents` convention, and skills installed via [skills.sh](https://skills.sh). It probes each agent's global, system, project, and plugin skill directories; skills.sh lock files add upstream provenance (joined by symlink-resolved path). Detection is pure filesystem reads (no subprocesses), bounded by a 60-second budget and per-root caps. +A skill reached through several roots is one record: symlinks and, on Windows, the directory junctions skills.sh creates in place of symlinks are folded into the physical skill's record, with each linking root listed in `symlink_sources`. Linked skill targets are resolved component by component with `safepath`, guarding ancestor-link destinations before traversal; directory and metadata reads use its verified opens. Targets must stay within the scanned user's home, the declared skills root, or its project root. Unrelated external targets are skipped. This protects linked-skill access, not every scanner discovery path, and is not a scanner-wide no-prompt guarantee. On Windows, junction targets that name a volume by GUID, a `UNC\` path or a `\\server\share` are skipped; off Windows a target is taken literally, so no Windows spelling is applied to it. The Kiro CLI's bundle `Info.plist` and installer siblings are rejected when they are links rather than followed. **Privacy: only metadata and a single SHA-256 hash of each `SKILL.md` are collected — no other file is ever read, and file contents are never transmitted.** The file census (counts, sizes, timestamps) comes entirely from directory listings and `stat`. For skills installed from a local path, the on-disk source path is never serialized — only the skill's alias. diff --git a/internal/detector/aicli.go b/internal/detector/aicli.go index 5986c186..66314831 100644 --- a/internal/detector/aicli.go +++ b/internal/detector/aicli.go @@ -4,8 +4,11 @@ import ( "bytes" "context" "encoding/json" + "errors" + "io/fs" "os" "path/filepath" + "regexp" "slices" "sort" "strings" @@ -88,23 +91,19 @@ var cliToolDefinitions = []cliToolSpec{ ConfigDirs: []string{"~/.gemini"}, }, { - Name: "amazon-q-cli", - Vendor: "Amazon", - Binaries: []string{"kiro-cli", "kiro", "q"}, - ConfigDirs: []string{"~/.q", "~/.kiro", "~/.aws/q"}, - VerifyFunc: func(ctx context.Context, exec executor.Executor, log *progress.Logger, binary string) bool { - if safe, reason := execguard.SafeToExec(ctx, exec, binary); !safe { - log.Warn("skipping %s: %s — cannot verify identity", binary, reason) - return false - } - log.Progress("exec fallback: running %s --version (amazon-q identity check)", binary) - stdout, _, _, err := exec.RunWithTimeout(ctx, 10*time.Second, binary, "--version") - if err != nil { - return false - } - lower := strings.ToLower(stdout) - return strings.Contains(lower, "amazon") || strings.Contains(lower, "kiro") || strings.Contains(lower, "q developer") + Name: "amazon-q-cli", + Vendor: "Amazon", + // PATH names first, then the fixed macOS bundle and Windows installer + // paths. `kiro` (the IDE launcher) and `q` (a shell wrapper) are listed + // so their resolved targets are examined; the ladder rejects them. + Binaries: []string{ + "kiro-cli", "kiro", "q", + "/Applications/Kiro CLI.app/Contents/MacOS/kiro-cli", + "~/AppData/Local/Kiro-Cli/kiro-cli.exe", }, + ConfigDirs: []string{"~/.q", "~/.kiro", "~/.aws/q"}, + ResolveFunc: resolveKiroCLI, + StaticVersionOnly: true, // identity and version come from install metadata; never launched }, { Name: "github-copilot-cli", @@ -219,6 +218,76 @@ var cliToolDefinitions = []cliToolSpec{ ResolveFunc: resolveAmp, StaticVersionOnly: true, }, + { + Name: "grok-build", + Vendor: "xAI", + // Anchor first so binary_path reports the installer's own link even when + // ~/.local/bin/grok or an npm prefix also resolves to it. `agent` is a + // second launcher name Grok installs; it is generic and never searched. + Binaries: []string{ + "~/.grok/bin/grok", "~/.grok/bin/grok.exe", + "grok", "~/.local/bin/grok", + "~/AppData/Roaming/npm/grok.cmd", + "~/AppData/Local/Microsoft/WinGet/Links/grok.exe", + }, + ConfigDirs: []string{"~/.grok"}, + ResolveFunc: resolveGrok, + StaticVersionOnly: true, + }, + { + Name: "kimi-code", + Vendor: "Moonshot", + Binaries: []string{ + "~/.kimi-code/bin/kimi", "~/.kimi-code/bin/kimi.exe", + "kimi", "~/.local/bin/kimi", + "/opt/homebrew/opt/kimi-code/bin/kimi", "/usr/local/opt/kimi-code/bin/kimi", + "/home/linuxbrew/.linuxbrew/opt/kimi-code/bin/kimi", + "~/AppData/Roaming/npm/kimi.cmd", + "~/AppData/Local/Microsoft/WinGet/Links/kimi.exe", + }, + ConfigDirs: []string{"~/.kimi-code"}, + ResolveFunc: resolveKimi, + StaticVersionOnly: true, + }, + { + Name: "muse-code", + Vendor: "Meta", + Binaries: []string{ + "~/.local/bin/muse", "muse", + "/opt/homebrew/opt/muse-code/bin/muse", // not created by the cask; harmless + }, + ConfigDirs: []string{"~/.config/muse"}, + ResolveFunc: resolveMuse, + StaticVersionOnly: true, + }, + { + Name: "hermes-agent", + Vendor: "Nous Research", + Binaries: []string{ + "~/.local/bin/hermes", "/usr/local/bin/hermes", "hermes", + "/opt/homebrew/opt/hermes-agent/bin/hermes", "/usr/local/opt/hermes-agent/bin/hermes", + "/home/linuxbrew/.linuxbrew/opt/hermes-agent/bin/hermes", + "~/AppData/Local/hermes/bin/hermes.exe", "~/AppData/Local/hermes/bin/hermes.cmd", + }, + ConfigDirs: []string{"~/AppData/Local/hermes", "~/.hermes"}, + ResolveFunc: resolveHermes, + StaticVersionOnly: true, + }, + { + Name: "oh-my-pi", + Vendor: "Stencil", + Binaries: []string{ + "omp", "~/.local/bin/omp", "~/.bun/bin/omp", "~/.bun/bin/omp.exe", + "/opt/homebrew/opt/omp/bin/omp", "/usr/local/opt/omp/bin/omp", + "/home/linuxbrew/.linuxbrew/opt/omp/bin/omp", + "~/AppData/Local/omp/omp.exe", + "~/AppData/Roaming/npm/omp.cmd", + "~/AppData/Local/Microsoft/WinGet/Links/omp.exe", + }, + ConfigDirs: []string{"~/.omp/agent"}, + ResolveFunc: resolveOMP, + StaticVersionOnly: true, + }, } // AICLIDetector detects AI CLI tools. @@ -468,7 +537,8 @@ func resolveEnvPath(exec executor.Executor, path string) string { } // --------------------------------------------------------------------------- -// Shared helpers for the ResolveFunc ladders (pi, factory, amp). +// Shared helpers for the ResolveFunc ladders (pi, factory, amp here; the +// grok, kimi, muse, hermes and omp ladders below). // // Every path manipulation below is separator-agnostic, and that is a // correctness requirement rather than tidiness: these ladders run against a @@ -697,6 +767,33 @@ func (g candidateGuard) protected(path string) bool { return g.skipper.WithinProtected(cleaned) } +// resolveDerived vets a corroborator a ladder derives from an accepted +// candidate (a sidecar, a manifest, a venv). A corroborator that is itself a +// link is rejected unread — seen with Readlink, never followed, since its +// target could lie anywhere. What remains is resolved through EvalSymlinks (a +// venv path derived from $HOME may still have linked ancestors) and the result +// gets the same TCC guard resolveVerified applied to the candidate. An absent +// path passes for the caller's Stat or DirExists to decide, but a symlink loop +// or a permission error must not fall back to touching the unresolved +// spelling. +func resolveDerived(exec executor.Executor, homeDir string, skipper *tcc.Skipper, path string) (string, bool) { + if _, err := exec.Readlink(path); err == nil { + return path, false + } + resolved, err := exec.EvalSymlinks(path) + switch { + case err == nil && resolved != "": + case err == nil || errors.Is(err, fs.ErrNotExist): + resolved = path + default: + return path, false + } + if newCandidateGuard(exec, homeDir, skipper).protected(resolved) { + return resolved, false + } + return resolved, true +} + // aiCLIBinaryCandidateDirs is pmBinaryCandidateDirs plus the directories a // global *agent* install can land in that a Node package-manager probe has no // reason to know about. @@ -750,6 +847,10 @@ func aiCLIBinaryCandidateDirs(exec executor.Executor, homeDir string) []string { // all that manager needs. The rest are real install trees, globbed. dirs = append(dirs, globDirs(exec, home(".local", "share", "fnm", "node-versions", "*", "installation", "bin"))...) dirs = append(dirs, globDirs(exec, home(".local", "share", "mise", "installs", "node", "*", "bin"))...) + // mise's github backend keeps each Oh My Pi release in its own version + // dir with the binary at the root; the shim on PATH resolves to the + // mise binary itself, so this is the only way that channel is seen. + dirs = append(dirs, globDirs(exec, home(".local", "share", "mise", "installs", "github-can1357-oh-my-pi", "*"))...) dirs = append(dirs, globDirs(exec, home(".volta", "tools", "image", "packages", "*", "bin"))...) dirs = append(dirs, globDirs(exec, home(".volta", "tools", "image", "packages", "*", "*", "bin"))...) dirs = append(dirs, globDirs(exec, home(".asdf", "installs", "nodejs", "*", "bin"))...) @@ -1194,6 +1295,16 @@ func fileAtLeast(exec executor.Executor, path string, minBytes int64) bool { return info.Size() >= minBytes } +// regularFileWithin reports whether path is a regular file (not a directory, +// FIFO or device) of at most maxBytes; maxBytes <= 0 disables the size check. +func regularFileWithin(exec executor.Executor, path string, maxBytes int64) bool { + info, err := exec.Stat(path) + if err != nil || info == nil || !info.Mode().IsRegular() { + return false + } + return maxBytes <= 0 || info.Size() <= maxBytes +} + // nonEmptyDir reports whether dir exists and holds at least one entry, without // a ReadDir: these ladders never walk, and a one-level Glob is the targeted // equivalent. Call it after the cheaper checks so the Glob rarely runs. @@ -1400,3 +1511,602 @@ func resolveAmp(_ context.Context, exec executor.Executor, log *progress.Logger, return "", false }) } + +// --------------------------------------------------------------------------- +// Kiro CLI (amazon-q-cli). Identity comes from installer metadata, per +// platform; the binary is never launched. (The previous VerifyFunc ran every +// `kiro-cli`/`kiro`/`q` on PATH with --version, which the IDE's own `kiro` +// launcher answers by opening a window.) +// --------------------------------------------------------------------------- + +// kiroCLIBundleID is "Kiro CLI.app"'s CFBundleIdentifier — still the +// CodeWhisperer id it inherited from Amazon Q. The IDE is dev.kiro.desktop. +const kiroCLIBundleID = "com.amazon.codewhisperer" + +// kiroCLIDpkgPackage is the .deb name; the `kiro`/`q` aliases do not share it. +const kiroCLIDpkgPackage = "kiro-cli" + +func resolveKiroCLI(ctx context.Context, exec executor.Executor, log *progress.Logger, skipper *tcc.Skipper, spec cliToolSpec, homeDir string) (cliResolution, bool) { + switch exec.GOOS() { + case model.PlatformDarwin: + return resolveVerified(exec, log, skipper, spec, homeDir, func(found, resolved string) (string, bool) { + return kiroCLIDarwinAccept(exec, log, skipper, homeDir, found, resolved) + }) + case model.PlatformWindows: + // The installer key is the only identity source (the PE has no + // version resources, the Uninstall row no InstallLocation). No key, + // nothing to prove. Its InstallPath joins the candidates so a + // non-default install is found by the same walk. + installPath, productVersion, ok := readKiroCLIRegistry(ctx, exec) + if !ok { + log.Debug("amazon-q-cli: HKCU\\SOFTWARE\\Kiro\\CLI is absent; no Kiro CLI can be proven on this machine") + return cliResolution{}, false + } + spec.Binaries = append(slices.Clone(spec.Binaries), joinPath(installPath, "kiro-cli.exe")) + return resolveVerified(exec, log, skipper, spec, homeDir, func(found, resolved string) (string, bool) { + if !strings.EqualFold(pathBase(resolved), "kiro-cli.exe") || + !strings.EqualFold(cleanPath(pathDir(resolved)), cleanPath(installPath)) { + log.Debug("amazon-q-cli: rejecting %s — resolves to %s, not kiro-cli.exe under the registered InstallPath %s", found, resolved, installPath) + return "", false + } + return productVersion, true // "2.21.1.0" as recorded; CliVersion ("v2") is a protocol marker + }) + default: + return resolveVerified(exec, log, skipper, spec, homeDir, func(found, resolved string) (string, bool) { + return kiroCLILinuxAccept(exec, log, skipper, homeDir, found, resolved) + }) + } +} + +// kiroCLIDarwinAccept: the candidate must resolve to .app/Contents/ +// MacOS/kiro-cli and the bundle's Info.plist must carry the CLI's identifier. +// Version is CFBundleShortVersionString or unknown; CFBundleVersion is a build +// counter and is never used. +func kiroCLIDarwinAccept(exec executor.Executor, log *progress.Logger, skipper *tcc.Skipper, homeDir, found, resolved string) (string, bool) { + macos := pathDir(resolved) + contents := pathDir(macos) + if pathBase(resolved) != "kiro-cli" || pathBase(macos) != "MacOS" || pathBase(contents) != "Contents" || !strings.HasSuffix(pathDir(contents), ".app") { + log.Debug("amazon-q-cli: rejecting %s — resolves to %s, not .app/Contents/MacOS/kiro-cli", found, resolved) + return "", false + } + plistPath, ok := resolveDerived(exec, homeDir, skipper, joinPath(contents, "Info.plist")) + if !ok { + log.Debug("amazon-q-cli: rejecting %s — bundle Info.plist is unreadable or under a macOS TCC-protected path", found) + return "", false + } + bundleID, shortVersion, ok := readBundleInfo(exec, plistPath) + if !ok || bundleID != kiroCLIBundleID { + log.Debug("amazon-q-cli: rejecting %s — bundle identifier is %q, not %s", found, bundleID, kiroCLIBundleID) + return "", false + } + if !versionmeta.IsVersionLike(shortVersion) { + shortVersion = "" + } + return shortVersion, true +} + +// kiroCLILinuxAccept: rule 1, dpkg owns the binary (and carries the version). +// Rule 2, the installer layout — kiro-cli beside kiro-cli-chat and +// kiro-cli-term, all regular files — with version unknown. The IDE's +// /usr/bin/kiro launcher and the `q` wrapper resolve to other basenames. +func kiroCLILinuxAccept(exec executor.Executor, log *progress.Logger, skipper *tcc.Skipper, homeDir, found, resolved string) (string, bool) { + if v := versionmeta.DpkgPackageVersion(exec, kiroCLIDpkgPackage, found, resolved); v != "" { + return v, true + } + if pathBase(resolved) != "kiro-cli" { + log.Debug("amazon-q-cli: rejecting %s — resolves to %s, whose basename is not kiro-cli", found, resolved) + return "", false + } + if !regularFileWithin(exec, resolved, 0) { + log.Debug("amazon-q-cli: rejecting %s — %s is not a regular file", found, resolved) + return "", false + } + dir := pathDir(resolved) + for _, sibling := range []string{"kiro-cli-chat", "kiro-cli-term"} { + p, ok := resolveDerived(exec, homeDir, skipper, joinPath(dir, sibling)) + if !ok || !regularFileWithin(exec, p, 0) { + log.Debug("amazon-q-cli: rejecting %s — no %s beside it; the Kiro installer always ships the trio", found, sibling) + return "", false + } + } + return "", true +} + +// --------------------------------------------------------------------------- +// Identity ladders for Grok Build, Kimi Code, Muse Code, Hermes Agent and Oh My +// Pi. Same contract as resolvePi/resolveFactory/resolveAmp above: first match wins, +// nothing is ever executed, every reject is Debug-logged with path and reason, +// and a state directory (~/.grok, ~/.kimi-code, ~/.hermes, ~/.omp, +// ~/.config/muse) never accepts a candidate by itself. +// +// `grok`, `kimi`, `muse` and `hermes` are all shared binary names — a Homebrew +// core regex tool, a MIDI sequencer in every Debian archive, unrelated npm and +// crates.io packages — so a PATH hit proves nothing. Identity comes from an npm +// manifest, an installer anchor plus a corroborator, a Homebrew root, a winget +// package directory or a pacman ownership record. The only file reads below +// are package.json (npmIdentity), .bunx (bunxTarget), pacman `files` +// manifests (pacmanPackageOwns) and Muse's 14-byte .muse-version sidecar. +// --------------------------------------------------------------------------- + +const ( + grokPackageName = "@xai-official/grok" + kimiPackageName = "@moonshot-ai/kimi-code" + ompPackageName = "@oh-my-pi/pi-coding-agent" + + // kimiMinBinaryBytes is the floor for the Kimi Code installer binary at + // ~/.kimi-code/bin/kimi, measured at 151–181 MB across platforms. 64 MiB + // leaves 2× headroom below and is what stops a stray script dropped at + // that path from counting. + kimiMinBinaryBytes int64 = 64 << 20 + + // ompMinBinaryBytes is the floor for the Oh My Pi standalone binary, + // measured at 135–161 MB. Required on the Homebrew channel too, because a + // third-party tap token is attacker-choosable. + ompMinBinaryBytes int64 = 64 << 20 + + // museVersionMaxBytes caps the .muse-version read. The real sidecar is a + // 14-byte version string. + museVersionMaxBytes int64 = 64 +) + +// museVersionRE is Muse Code's release format: semver plus an -R[.n] +// suffix, e.g. 1.0.3-R2198.1. +var museVersionRE = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+-R[0-9]+(\.[0-9]+)?$`) + +// kimiLegacyVenvs are the uv-tool and pipx virtualenvs the Python-era Kimi CLI +// installs into; ~/.local/bin/kimi is a symlink into one of them. +var kimiLegacyVenvs = []string{ + "~/.local/share/uv/tools/kimi-cli", + "~/.local/share/pipx/venvs/kimi-cli", + "~/AppData/Local/uv/tools/kimi-cli", + "~/AppData/Local/pipx/venvs/kimi-cli", +} + +// hermesLayouts pairs each installer launcher location with the venv the same +// installer writes. The launcher is a tiny bash script (not a symlink, so +// resolved == found) and is never read: the launcher-plus-venv pair is the +// installer's own signature, and an unrelated ~/.local/bin/hermes with no venv +// beside it is rejected. +var hermesLayouts = []struct{ launcher, venv string }{ + {"~/.local/bin/hermes", "~/.hermes/hermes-agent/venv"}, + {"/usr/local/bin/hermes", "/usr/local/lib/hermes-agent/venv"}, + {"~/AppData/Local/hermes/bin", "~/AppData/Local/hermes/hermes-agent/venv"}, +} + +// ompMiseRoot is mise's install root for the github:can1357/oh-my-pi backend; +// each release lives in its own version directory under it. Windows never +// reaches the mise rule: aiCLIBinaryCandidateDirs globs the tree only on +// darwin/linux, and the Windows PATH shim resolves to mise.exe. +const ompMiseRoot = "~/.local/share/mise/installs/github-can1357-oh-my-pi" + +// versionFromFilename returns the version token that follows prefix in a +// versioned binary name — grok-1.0.13, grok-1.0.13-linux-aarch64, +// grok-1.0.13.exe, muse-bin-1.0.3-R2198.1 — stopping at the first platform +// token, and "" unless the result is version-shaped (grok-macos-aarch64 is the +// unversioned bootstrap and yields ""). +func versionFromFilename(base, prefix string) string { + if n := len(base) - len(".exe"); n > 0 && strings.EqualFold(base[n:], ".exe") { + base = base[:n] + } + rest, ok := strings.CutPrefix(base, prefix) + if !ok { + return "" + } + tokens := strings.Split(rest, "-") + end := 0 + for end < len(tokens) && !isOSToken(tokens[end]) { + end++ + } + v := strings.Join(tokens[:end], "-") + if !versionmeta.IsVersionLike(v) { + return "" + } + return v +} + +func isOSToken(s string) bool { + switch strings.ToLower(s) { + case "macos", "darwin", "linux", "windows": + return true + } + return false +} + +// distInfoVersion returns the version of the Python distribution dist installed +// in venv, read from the NAME of its single -.dist-info directory — +// one Glob, nothing opened. "" when the glob finds zero or several matches. +func distInfoVersion(exec executor.Executor, log *progress.Logger, venv, dist string) string { + sitePackages := joinPath(venv, "lib", "python*", "site-packages") + if exec.GOOS() == model.PlatformWindows { + sitePackages = joinPath(venv, "Lib", "site-packages") + } + matches, err := exec.Glob(joinPath(sitePackages, dist+"-*.dist-info")) + if err != nil || len(matches) != 1 { + log.Debug("%s: %d dist-info directories under %s, need exactly one; no dist-info version", dist, len(matches), sitePackages) + return "" + } + v := strings.TrimSuffix(strings.TrimPrefix(pathBase(matches[0]), dist+"-"), ".dist-info") + if !versionmeta.IsVersionLike(v) { + return "" + } + return v +} + +// brewKeg returns the Cellar// directory enclosing resolved, +// "" when resolved is not under a Cellar. Homebrew paths are always absolute +// POSIX paths. +func brewKeg(resolved string) string { + segments := splitPathAny(resolved) + for i := 0; i < len(segments)-2; i++ { + if segments[i] == "Cellar" { + return "/" + strings.Join(segments[:i+3], "/") + } + } + return "" +} + +// grokCopyVersion recovers the version of the Windows ~\.grok\bin\grok.exe, +// which the installer writes as a COPY of grok-.exe rather than a link. When +// exactly one versioned sibling exists and is the same size, it is the source +// of the copy. This is the one place a size equality is correct: it compares a +// file with its own copy, not with a published artifact. +func grokCopyVersion(exec executor.Executor, log *progress.Logger, resolved string) string { + dir := pathDir(resolved) + matches, err := exec.Glob(joinPath(dir, "grok-*.exe")) + if err != nil || len(matches) != 1 { + log.Debug("grok-build: %d versioned grok-*.exe siblings beside %s, need exactly one; version unknown", len(matches), resolved) + return "" + } + copyInfo, err := exec.Stat(resolved) + if err != nil { + return "" + } + srcInfo, err := exec.Stat(matches[0]) + if err != nil || srcInfo.Size() != copyInfo.Size() { + log.Debug("grok-build: %s is not the same size as %s; version unknown", resolved, matches[0]) + return "" + } + return versionFromFilename(pathBase(matches[0]), "grok-") +} + +// resolveGrok proves an xAI Grok Build install. The colliders are the unrelated +// `grok` regex log parser (Homebrew core, Debian, Arch) and cargo crates of the +// same name; none of them lives under ~/.grok or ships the @xai-official/grok +// manifest. ~/.grok/config.toml names the installer channel but is never read: +// the filename rule below already distinguishes the channels that matter. +func resolveGrok(_ context.Context, exec executor.Executor, log *progress.Logger, skipper *tcc.Skipper, spec cliToolSpec, homeDir string) (cliResolution, bool) { + return resolveVerified(exec, log, skipper, spec, homeDir, func(found, resolved string) (string, bool) { + // Rule 1 — npm: the Unix trampoline symlink, the Windows grok.cmd + // shim and every npm/pnpm/yarn/volta prefix candidatePaths probes. + name, version, ok := npmIdentity(exec, found, resolved, grokPackageName) + if ok { + return version, true + } + + // Rule 2 — Grok's own home. ~/.grok/bin/grok links to grok- after + // an npm postinstall, to ../downloads/grok--- after a + // self-update, and to the unversioned grok-- bootstrap on a + // fresh script install (reported unknown). On Windows the .exe is a + // copy, so its version comes from the same-size versioned sibling. + if underHomeDir(exec, homeDir, resolved, "~/.grok/bin") || underHomeDir(exec, homeDir, resolved, "~/.grok/downloads") { + if v := versionFromFilename(pathBase(resolved), "grok-"); v != "" { + return v, true + } + if exec.GOOS() == model.PlatformWindows { + return grokCopyVersion(exec, log, resolved), true + } + return "", true + } + + // Rule 3 — winget portable. + if exec.GOOS() == model.PlatformWindows && wingetPackage(resolved, "xAI.GrokBuild") { + return "", true + } + + // Rule 4 — pacman. /usr/bin/grok is the log parser on every distro + // unless an AUR grok-build package owns it. + if cleanPath(resolved) == "/usr/bin/grok" { + if pacmanPackageOwns(exec, []string{"grok-build", "grok-build-bin", "grok-build-git"}, "usr/bin/grok") { + return "", true + } + log.Debug("grok-build: rejecting %s — no installed grok-build package owns usr/bin/grok; the distro `grok` is the unrelated regex log parser", found) + return "", false + } + + // Rule 5 — reject. + if root, pkg := brewRoot(resolved); root == "Cellar" && pkg == "grok" { + log.Debug("grok-build: rejecting %s — Homebrew Cellar/grok is the regex log-parser formula, not Grok Build", found) + return "", false + } + if underHomeDir(exec, homeDir, resolved, "~/.cargo") { + log.Debug("grok-build: rejecting %s — under ~/.cargo, a cargo-installed grok crate", found) + return "", false + } + if name != "" { + log.Debug("grok-build: rejecting %s — npm package is %q, not %s", found, name, grokPackageName) + } else { + log.Debug("grok-build: rejecting %s — no Grok Build channel claims it (resolved %s)", found, resolved) + } + return "", false + }) +} + +// resolveKimi proves a Moonshot Kimi Code install, including the Python-era +// Kimi CLI it replaced: same vendor, same product line, and the new installer +// migrates the old one, so both report as kimi-code (the 1.x versus 0.x +// version tells them apart). The Homebrew cask `kimi` is a desktop app and +// never produces a `kimi` binary. +func resolveKimi(_ context.Context, exec executor.Executor, log *progress.Logger, skipper *tcc.Skipper, spec cliToolSpec, homeDir string) (cliResolution, bool) { + return resolveVerified(exec, log, skipper, spec, homeDir, func(found, resolved string) (string, bool) { + // Rule 1 — npm and the Homebrew formula, whose binary resolves into + // Cellar/kimi-code//libexec/lib/node_modules/@moonshot-ai/kimi-code + // and so carries the same manifest. + name, version, ok := npmIdentity(exec, found, resolved, kimiPackageName) + if ok { + return version, true + } + + // Rule 2 — the installer anchor, corroborated by the size floor. No + // on-disk version source: reported unknown. + if underHomeDir(exec, homeDir, resolved, "~/.kimi-code/bin") { + if fileAtLeast(exec, found, kimiMinBinaryBytes) { + return "", true + } + log.Debug("kimi-code: rejecting %s — at the installer target but under %d bytes", found, kimiMinBinaryBytes) + return "", false + } + + // Rule 3 — the legacy Python CLI's uv-tool or pipx venv, which + // ~/.local/bin/kimi symlinks into. Version from the dist-info name. + for _, venv := range kimiLegacyVenvs { + if underHomeDir(exec, homeDir, resolved, venv) { + return distInfoVersion(exec, log, expandTildePath(venv, homeDir), "kimi_cli"), true + } + } + + // Rule 4 — winget portable, both identifiers. + if exec.GOOS() == model.PlatformWindows && + (wingetPackage(resolved, "MoonshotAI.KimiCodeCLI") || wingetPackage(resolved, "MoonshotAI.KimiCLI")) { + return "", true + } + + // Rule 5 — reject. + if underHomeDir(exec, homeDir, resolved, "~/.cargo") { + log.Debug("kimi-code: rejecting %s — under ~/.cargo, a cargo-installed kimi crate", found) + return "", false + } + if root, pkg := brewRoot(resolved); root == "Cellar" && pkg == "kimi" { + log.Debug("kimi-code: rejecting %s — Homebrew Cellar/kimi is not the kimi-code formula", found) + return "", false + } + if name != "" { + log.Debug("kimi-code: rejecting %s — npm package is %q, not %s", found, name, kimiPackageName) + } else { + log.Debug("kimi-code: rejecting %s — no Kimi Code channel claims it (resolved %s)", found, resolved) + } + return "", false + }) +} + +// resolveMuse proves a Meta Muse Code install. The launcher is a 33 KB script +// beside a .muse-version sidecar and the muse-bin- payload it names; the +// colliders — Debian's MusE MIDI sequencer at /usr/bin/muse, the crates.io +// `muse` commit-message CLI and the unrelated npm `muse` — have neither. +func resolveMuse(_ context.Context, exec executor.Executor, log *progress.Logger, skipper *tcc.Skipper, spec cliToolSpec, homeDir string) (cliResolution, bool) { + return resolveVerified(exec, log, skipper, spec, homeDir, func(found, resolved string) (string, bool) { + // The npm and cargo colliders first: rule 1 probes for siblings in + // the resolved directory, and a collider's directory is never probed. + if versionmeta.NodeModulesPackageRoot(resolved) != "" { + log.Debug("muse-code: rejecting %s — under node_modules; npm `muse` is unrelated and Muse Code has no npm package", found) + return "", false + } + if underHomeDir(exec, homeDir, resolved, "~/.cargo") { + log.Debug("muse-code: rejecting %s — under ~/.cargo, the cargo-installed muse commit-message CLI", found) + return "", false + } + + // Rule 1 — launcher plus sidecar, directory-relative so a relocated + // MUSE_INSTALL_DIR on PATH still resolves. The sidecar is read only + // under the size cap, and it accepts only when the muse-bin- it + // names is actually there. Without a sidecar, a single muse-bin-* + // sibling carrying a Muse release version still identifies the + // install. + dir := pathDir(resolved) + sidecar, ok := resolveDerived(exec, homeDir, skipper, joinPath(dir, ".muse-version")) + if !ok { + log.Debug("muse-code: rejecting %s — its .muse-version %s could not be safely resolved or is under a macOS TCC-protected path", found, sidecar) + return "", false + } + if info, err := exec.Stat(sidecar); err == nil { + if !info.Mode().IsRegular() { + log.Debug("muse-code: rejecting %s — %s is not a regular file", found, sidecar) + return "", false + } + if info.Size() > museVersionMaxBytes { + log.Debug("muse-code: rejecting %s — %s is %d bytes, over the %d-byte cap", found, sidecar, info.Size(), museVersionMaxBytes) + return "", false + } + data, err := exec.ReadFile(sidecar) + v := strings.TrimSpace(string(data)) + if err != nil || int64(len(data)) > museVersionMaxBytes || !museVersionRE.MatchString(v) { + log.Debug("muse-code: rejecting %s — %s does not carry a Muse release version", found, sidecar) + return "", false + } + payload, ok := resolveDerived(exec, homeDir, skipper, joinPath(dir, "muse-bin-"+v)) + if !ok { + log.Debug("muse-code: rejecting %s — muse-bin-%s %s could not be safely resolved or is under a macOS TCC-protected path", found, v, payload) + return "", false + } + if !exec.FileExists(payload) { + log.Debug("muse-code: rejecting %s — %s names %s but no muse-bin-%s sits beside it", found, sidecar, v, v) + return "", false + } + return v, true + } + if bins, err := exec.Glob(joinPath(dir, "muse-bin-*")); err == nil { + var versions []string + for _, b := range bins { + if v, _ := strings.CutPrefix(pathBase(b), "muse-bin-"); museVersionRE.MatchString(v) { + versions = append(versions, v) + } + } + switch len(versions) { + case 0: + case 1: + return versions[0], true + default: + log.Debug("muse-code: %d muse-bin-* siblings beside %s and no .muse-version; version unknown", len(versions), found) + return "", true + } + } + + // Rule 2 — homebrew/cask muse-code. No version here: getVersion + // recovers it from the Caskroom segment, statically. + if root, pkg := brewRoot(resolved); root == "Caskroom" && pkg == "muse-code" { + return "", true + } + if root, pkg := brewRoot(resolved); root == "Cellar" && pkg == "muse" { + log.Debug("muse-code: rejecting %s — Homebrew Cellar/muse is not the muse-code cask", found) + return "", false + } + + // Rule 3 — pacman. Ubuntu's and Arch's `muse` package is the MusE + // sequencer; only the AUR muse-code packages make /usr/bin/muse ours. + if cleanPath(resolved) == "/usr/bin/muse" { + if pacmanPackageOwns(exec, []string{"muse-code-bin", "muse-code"}, "usr/bin/muse") { + return "", true + } + log.Debug("muse-code: rejecting %s — no installed muse-code package owns usr/bin/muse; the distro `muse` is the MusE sequencer", found) + return "", false + } + + // Rule 4 — reject. + log.Debug("muse-code: rejecting %s — no Muse Code channel claims it (resolved %s)", found, resolved) + return "", false + }) +} + +// resolveHermes proves a Nous Research Hermes Agent install. The colliders are +// npm `hermes` and PyPI `hermes`, plus an unofficial npm `hermes-agent` +// bridge; none of them writes the installer's venv. +func resolveHermes(_ context.Context, exec executor.Executor, log *progress.Logger, skipper *tcc.Skipper, spec cliToolSpec, homeDir string) (cliResolution, bool) { + return resolveVerified(exec, log, skipper, spec, homeDir, func(found, resolved string) (string, bool) { + // Rules 1 and 2 — the installer's launcher-plus-venv pairs (user, + // root and Windows layouts). The launcher is never read; the venv + // directory beside it is the corroborator, and its dist-info name is + // the version. + for _, layout := range hermesLayouts { + if !underHomeDir(exec, homeDir, resolved, layout.launcher) { + continue + } + venv, ok := resolveDerived(exec, homeDir, skipper, expandTildePath(layout.venv, homeDir)) + if !ok { + log.Debug("hermes-agent: rejecting %s — its venv %s could not be safely resolved or is under a macOS TCC-protected path", found, venv) + return "", false + } + if exec.DirExists(venv) { + return distInfoVersion(exec, log, venv, "hermes_agent"), true + } + log.Debug("hermes-agent: rejecting %s — the installer launcher is there but %s is not", found, venv) + return "", false + } + + // Rule 3 — Homebrew formula. The keg's own venv carries the upstream + // version (0.21.0, consistent with the other channels); when it is + // missing, getVersion falls back to the Cellar segment (2026.8.31). + if root, pkg := brewRoot(resolved); root == "Cellar" && pkg == "hermes-agent" { + return distInfoVersion(exec, log, joinPath(brewKeg(resolved), "libexec"), "hermes_agent"), true + } + + // Rule 4 — reject. + if versionmeta.NodeModulesPackageRoot(resolved) != "" { + log.Debug("hermes-agent: rejecting %s — under node_modules; npm `hermes` and the unofficial hermes-agent bridge are not the installer", found) + return "", false + } + if underHomeDir(exec, homeDir, resolved, "~/.cargo") { + log.Debug("hermes-agent: rejecting %s — under ~/.cargo, a cargo-installed hermes crate", found) + return "", false + } + log.Debug("hermes-agent: rejecting %s — no Hermes Agent channel claims it (resolved %s)", found, resolved) + return "", false + }) +} + +// resolveOMP proves a Stencil Oh My Pi install. `omp` has no popular collider, +// but the Homebrew tap and the standalone anchors are attacker-choosable +// paths, so both carry the size floor. +func resolveOMP(_ context.Context, exec executor.Executor, log *progress.Logger, skipper *tcc.Skipper, spec cliToolSpec, homeDir string) (cliResolution, bool) { + return resolveVerified(exec, log, skipper, spec, homeDir, func(found, resolved string) (string, bool) { + // Rule 1 — npm, pnpm, yarn, Bun and the Windows .cmd shim (which + // names bun.exe as its runner; NPMShimPackageRoot only needs the + // node_modules path). + name, version, ok := npmIdentity(exec, found, resolved, ompPackageName) + if ok { + return version, true + } + + // Rule 2 — the Windows Bun shim, exactly as resolvePi rule 2b. + if exec.GOOS() == model.PlatformWindows { + if target := bunxTarget(exec, found, homeDir); target != "" { + if _, bunVersion, bunOK := npmIdentity(exec, target, target, ompPackageName); bunOK { + return bunVersion, true + } + } + } + + // Rule 3 — Homebrew formula, corroborated by the floor. getVersion + // recovers the Cellar segment. + if root, pkg := brewRoot(resolved); root == "Cellar" && pkg == "omp" { + if fileAtLeast(exec, found, ompMinBinaryBytes) { + return "", true + } + log.Debug("oh-my-pi: rejecting %s — Homebrew Cellar/omp but under %d bytes", found, ompMinBinaryBytes) + return "", false + } + + // Rule 4 — mise. resolveVerified already followed the shim-free + // candidate from aiCLIBinaryCandidateDirs through EvalSymlinks, so an + // alias dir (18, 18.1, latest) arrives as its real version dir. + if underHomeDir(exec, homeDir, resolved, ompMiseRoot) { + if v := miseVersionSegment(resolved); v != "" { + return v, true + } + log.Debug("oh-my-pi: rejecting %s — under the mise install root but not in a version directory (resolved %s)", found, resolved) + return "", false + } + + // Rule 5 — standalone anchors with the floor; no version on disk. + if underHomeDir(exec, homeDir, resolved, "~/.local/bin/omp") || underHomeDir(exec, homeDir, resolved, "~/AppData/Local/omp/omp.exe") { + if fileAtLeast(exec, found, ompMinBinaryBytes) { + return "", true + } + log.Debug("oh-my-pi: rejecting %s — at the standalone anchor but under %d bytes", found, ompMinBinaryBytes) + return "", false + } + + // Rule 6 — winget portable. + if exec.GOOS() == model.PlatformWindows && wingetPackage(resolved, "can1357.oh-my-pi") { + return "", true + } + + // Rule 7 — reject. + if name != "" { + log.Debug("oh-my-pi: rejecting %s — npm package is %q, not %s", found, name, ompPackageName) + } else { + log.Debug("oh-my-pi: rejecting %s — no Oh My Pi channel claims it (resolved %s)", found, resolved) + } + return "", false + }) +} + +// miseVersionSegment returns the version directory segment that follows the +// mise install root in resolved, "" when that segment is not version-shaped. +func miseVersionSegment(resolved string) string { + segments := splitPathAny(resolved) + for i := 0; i < len(segments)-1; i++ { + if segments[i] == "github-can1357-oh-my-pi" && versionmeta.IsVersionLike(segments[i+1]) { + return segments[i+1] + } + } + return "" +} diff --git a/internal/detector/aicli_agents_test.go b/internal/detector/aicli_agents_test.go index e31e551e..e58b5310 100644 --- a/internal/detector/aicli_agents_test.go +++ b/internal/detector/aicli_agents_test.go @@ -3,7 +3,9 @@ package detector import ( "bytes" "context" + "errors" "io" + "io/fs" "os" "path/filepath" "runtime" @@ -50,14 +52,27 @@ type recExec struct { t *testing.T trapExec bool - execs []aicliExecCall - globs []string - reads []string // ReadFile + Stat + FileExists, i.e. every path touched - lookups []string + execs []aicliExecCall + // regReads counts the Kiro CLI registry reads. On Windows that is a native + // registry API call; only the non-Windows test twin (registry_other.go) + // spells it as `reg query`, so it is kept out of execs and never trips the + // trap — but it is fatal off Windows, where the registry must not exist. + regReads int + globs []string + reads []string // ReadFile + Stat + FileExists, i.e. every path touched + evals []string // EvalSymlinks, i.e. every path followed + lookups []string } // No mutex: Detect is single-goroutine and AGENTS.md §15.5 forbids t.Parallel. func (e *recExec) recordExec(name string, args []string) { + if name == "reg" && slices.Equal(args, []string{"query", `HKCU\SOFTWARE\Kiro\CLI`}) { + if e.GOOS() != model.PlatformWindows { + e.t.Fatalf("Kiro CLI registry read on %s: the registry is Windows-only", e.GOOS()) + } + e.regReads++ + return + } e.execs = append(e.execs, aicliExecCall{name: name, args: slices.Clone(args)}) if e.trapExec { e.t.Fatalf("unexpected exec: %s %v", name, args) @@ -109,6 +124,14 @@ func (e *recExec) FileExists(path string) bool { return e.Mock.FileExists(path) } +// EvalSymlinks follows every component; it is recorded apart from reads so a +// case can assert a path was never followed without forbidding the failed +// resolution attempt another case is about. +func (e *recExec) EvalSymlinks(path string) (string, error) { + e.evals = append(e.evals, path) + return e.Mock.EvalSymlinks(path) +} + func (e *recExec) LookPath(name string) (string, error) { e.lookups = append(e.lookups, name) return e.Mock.LookPath(name) @@ -252,7 +275,7 @@ func captureStderr(t *testing.T, fn func()) (out string) { // aicliNewSpecs are the specs this file owns. Every case asserts one row for // each spec it names in want and ZERO rows for the others, so a fixture built // for one agent cannot quietly start reporting another. -var aicliNewSpecs = []string{"pi", "factory", "amp"} +var aicliNewSpecs = []string{"pi", "factory", "amp", "amazon-q-cli", "grok-build", "kimi-code", "muse-code", "hermes-agent", "oh-my-pi"} type aicliWant struct { tool string @@ -274,12 +297,17 @@ type aicliCase struct { // accepts may set it, and they must pin wantExecs. allowExec bool - want []aicliWant - wantExecs []aicliExecCall - noReadPrefix []string // no ReadFile/Stat/FileExists path may start with these - noLookup []string // no LookPath name may contain these - wantDebug []string - noDebug []string + want []aicliWant + wantExecs []aicliExecCall + noReadPrefix []string // no ReadFile/Stat/FileExists path may start with these + noFollowPrefix []string // no EvalSymlinks path may start with these either + noLookup []string // no LookPath name may contain these + wantDebug []string + noDebug []string + // allowGlobs are the fixture-specific patterns this case may glob on top + // of aicliAllowedGlobs: a sibling probe beside an accepted anchor + // (grok-*.exe, muse-bin-*) or a venv's dist-info directory. + allowGlobs []string } func findAITool(tools []model.AITool, name string) *model.AITool { @@ -319,6 +347,7 @@ func aicliAllowedGlobs(home, goos string) map[string]bool { allowed[filepath.Join(home, ".nvm", "versions", "node", "*", "bin")] = true allowed[joinPath(home, ".local", "share", "fnm", "node-versions", "*", "installation", "bin")] = true allowed[joinPath(home, ".local", "share", "mise", "installs", "node", "*", "bin")] = true + allowed[joinPath(home, ".local", "share", "mise", "installs", "github-can1357-oh-my-pi", "*")] = true allowed[joinPath(home, ".volta", "tools", "image", "packages", "*", "bin")] = true allowed[joinPath(home, ".volta", "tools", "image", "packages", "*", "*", "bin")] = true allowed[joinPath(home, ".asdf", "installs", "nodejs", "*", "bin")] = true @@ -389,6 +418,15 @@ func runAICLICase(t *testing.T, tc aicliCase) { }) { t.Errorf("execs: got %+v, want %+v", rec.execs, tc.wantExecs) } + // The Kiro ladder reads its registry key once per resolve, whether or not a + // candidate exists (the key is also how a non-default InstallPath is found). + wantRegReads := 0 + if goos == model.PlatformWindows { + wantRegReads = 1 + } + if rec.regReads != wantRegReads { + t.Errorf("Kiro registry reads: got %d, want %d", rec.regReads, wantRegReads) + } for _, prefix := range tc.noReadPrefix { for _, read := range rec.reads { if strings.HasPrefix(read, prefix) { @@ -396,6 +434,13 @@ func runAICLICase(t *testing.T, tc aicliCase) { } } } + for _, prefix := range tc.noFollowPrefix { + for _, followed := range rec.evals { + if strings.HasPrefix(followed, prefix) { + t.Errorf("followed %q, which is under the forbidden prefix %q", followed, prefix) + } + } + } for _, frag := range tc.noLookup { for _, name := range rec.lookups { if strings.Contains(name, frag) { @@ -415,6 +460,9 @@ func runAICLICase(t *testing.T, tc aicliCase) { } allowed := aicliAllowedGlobs(home, goos) + for _, pattern := range tc.allowGlobs { + allowed[pattern] = true + } for _, pattern := range rec.globs { // versionmeta's dpkg source globs one :.list per candidate. // Matched by prefix since the tool name varies per case; it reads the @@ -1493,6 +1541,61 @@ func TestAICLIAgents_TCCGuard(t *testing.T) { want: []aicliWant{{tool: "pi", binary: "/Users/u/Documents/bin/pi", version: "0.83.0"}}, }) }) + + t.Run("(t6) a corroborator derived beside an accepted candidate is guarded after it resolves", func(t *testing.T) { + t.Run("a .muse-version symlinked into ~/Downloads is rejected before it is read", func(t *testing.T) { + requireDarwinHost(t) + runAICLICase(t, aicliCase{ + name: "~/.local/bin/.muse-version -> ~/Downloads/.muse-version", + goos: model.PlatformDarwin, + skipper: true, + setup: func(m *executor.Mock, home string) { + bin := joinPath(home, ".local", "bin") + addFile(m, joinPath(bin, "muse"), []byte("#!/usr/bin/env bash\n")) + sidecar := joinPath(bin, ".muse-version") + addFile(m, sidecar, []byte(museVersion+"\n")) + m.SetSymlink(sidecar, joinPath(home, "Downloads", ".muse-version")) + addBinary(m, joinPath(bin, "muse-bin-"+museVersion), 90<<20) + }, + noReadPrefix: []string{"/Users/u/Downloads"}, + wantDebug: []string{"under a macOS TCC-protected path"}, + }) + }) + t.Run("a muse-bin payload symlinked into ~/Downloads is rejected before it is stat'd", func(t *testing.T) { + requireDarwinHost(t) + runAICLICase(t, aicliCase{ + name: "~/.local/bin/muse-bin- -> ~/Downloads/muse-bin-", + goos: model.PlatformDarwin, + skipper: true, + setup: func(m *executor.Mock, home string) { + bin := joinPath(home, ".local", "bin") + addFile(m, joinPath(bin, "muse"), []byte("#!/usr/bin/env bash\n")) + addFile(m, joinPath(bin, ".muse-version"), []byte(museVersion+"\n")) + payload := joinPath(bin, "muse-bin-"+museVersion) + addFile(m, payload, []byte{}) + m.SetSymlink(payload, joinPath(home, "Downloads", "muse-bin-"+museVersion)) + }, + noReadPrefix: []string{"/Users/u/Downloads"}, + wantDebug: []string{"under a macOS TCC-protected path"}, + }) + }) + t.Run("a hermes venv symlinked into ~/Documents is rejected before it is globbed", func(t *testing.T) { + requireDarwinHost(t) + runAICLICase(t, aicliCase{ + name: "~/.hermes/hermes-agent/venv -> ~/Documents/venv", + goos: model.PlatformDarwin, + skipper: true, + setup: func(m *executor.Mock, home string) { + addFile(m, joinPath(home, ".local", "bin", "hermes"), []byte("#!/bin/bash\n")) + venv := joinPath(home, ".hermes", "hermes-agent", "venv") + m.SetDir(venv) + m.SetSymlink(venv, joinPath(home, "Documents", "venv")) + }, + noReadPrefix: []string{"/Users/u/Documents"}, + wantDebug: []string{"under a macOS TCC-protected path"}, + }) + }) + }) } // tccDocumentsFixture is one Pi install under ~/Documents that satisfies rule 2 @@ -1509,16 +1612,19 @@ func tccDocumentsFixture(m *executor.Mock, home string) { // Executor.ReadDir on either implementation, so the two claims are asserted // separately: recExec.ReadDir fails the test unconditionally (across this whole // file, not just here), and the glob budget is pinned exactly — one call per -// targeted install-tree pattern per resolver, three resolvers. +// targeted install-tree pattern per resolver. Every spec in aicliNewSpecs +// walks on Unix; on Windows the Kiro ladder returns before any candidate walk +// when its registry key is absent, so one fewer. func TestAICLIAgents_NoWalkAndGlobBudget(t *testing.T) { + resolvers := len(aicliNewSpecs) tests := []struct { goos string wantDistinct int + wantPerPattern int wantTotalGlobs int - }{ - {model.PlatformLinux, 6, 18}, - {model.PlatformDarwin, 7, 21}, - {model.PlatformWindows, 2, 6}, + }{{model.PlatformLinux, 7, resolvers, 7 * resolvers}, + {model.PlatformDarwin, 8, resolvers, 8 * resolvers}, + {model.PlatformWindows, 2, resolvers - 1, 2 * (resolvers - 1)}, } for _, tc := range tests { t.Run(tc.goos, func(t *testing.T) { @@ -1543,8 +1649,8 @@ func TestAICLIAgents_NoWalkAndGlobBudget(t *testing.T) { t.Errorf("distinct patterns: got %d (%v), want %d", len(counts), counts, tc.wantDistinct) } for pattern, n := range counts { - if n != 3 { - t.Errorf("Glob(%q) called %d times, want 3 (once per resolver)", pattern, n) + if n != tc.wantPerPattern { + t.Errorf("Glob(%q) called %d times, want %d (once per resolver)", pattern, n, tc.wantPerPattern) } } }) @@ -1558,7 +1664,7 @@ func TestAICLIAgents_NoWalkAndGlobBudget(t *testing.T) { func TestResolveGlobalRoots_AmpConfigAndFactoryAgentRoots(t *testing.T) { cases := []struct{ dir, source, agent string }{ {testHome + "/.config/amp/skills/ampcfg", "amp_user", "amp"}, - {testHome + "/.agent/skills/facag", "factory_agent_user", "factory"}, + {testHome + "/.agent/skills/facag", "factory_agent_user", "shared"}, // read by Factory and Antigravity } m, fs := newSkillsMock() for _, c := range cases { @@ -1595,3 +1701,1353 @@ func TestResolveGlobalRoots_NewRootsAbsentWhenDirsAbsent(t *testing.T) { t.Errorf("roots_scanned: got %v, want %v (the two new roots must not appear when absent)", info.RootsScanned, want) } } + +// --------------------------------------------------------------------------- +// Kiro CLI (amazon-q-cli). Runs under the aicli harness: the exec trap is on +// unless a case says otherwise, so every accept below is proven without +// launching the candidate. Fixture values are the ones measured on real +// installs (bundle id com.amazon.codewhisperer, version 2.21.1, Windows +// ProductVersion 2.21.1.0). +// --------------------------------------------------------------------------- + +const kiroCLIBundle = "/Applications/Kiro CLI.app" + +// kiroPlist is a minimal XML Info.plist; short == "" omits +// CFBundleShortVersionString. CFBundleVersion is always present and never the +// answer. +func kiroPlist(bundleID, short string) []byte { + var b strings.Builder + b.WriteString(`` + + `` + + ``) + b.WriteString(`CFBundleIdentifier` + bundleID + ``) + if short != "" { + b.WriteString(`CFBundleShortVersionString` + short + ``) + } + b.WriteString(`CFBundleVersion999` + + `CFBundleExecutablekiro_cli_desktop`) + return []byte(b.String()) +} + +// kiroDarwinBundle installs the CLI bundle with the given plist. +func kiroDarwinBundle(m *executor.Mock, plist []byte) { + addFile(m, kiroCLIBundle+"/Contents/MacOS/kiro-cli", []byte{}) + addFile(m, kiroCLIBundle+"/Contents/Info.plist", plist) +} + +// kiroLinuxTrio drops the installer's three binaries into dir. +func kiroLinuxTrio(m *executor.Mock, dir string) { + for _, name := range []string{"kiro-cli", "kiro-cli-chat", "kiro-cli-term"} { + addFile(m, joinPath(dir, name), []byte{}) + } +} + +// kiroRegistry stubs the installer key as the non-Windows twin queries it. +func kiroRegistry(m *executor.Mock, installPath string) { + m.SetCommand("\r\nHKEY_CURRENT_USER\\SOFTWARE\\Kiro\\CLI\r\n"+ + " InstallPath REG_SZ "+installPath+"\r\n"+ + " ProductVersion REG_SZ 2.21.1.0\r\n"+ + " CliVersion REG_SZ v2\r\n", "", 0, + "reg", "query", `HKCU\SOFTWARE\Kiro\CLI`) +} + +func TestAICLIAgents_Kiro_Darwin(t *testing.T) { + bin := kiroCLIBundle + "/Contents/MacOS/kiro-cli" + plistBuddy := aicliExecCall{name: "/usr/libexec/PlistBuddy", args: []string{"-c", "Print :CFBundleShortVersionString", kiroCLIBundle + "/Contents/Info.plist"}} + runAICLICases(t, []aicliCase{ + { + name: "PATH symlink into the bundle: identity and version from the plist", + goos: model.PlatformDarwin, + setup: func(m *executor.Mock, home string) { + kiroDarwinBundle(m, kiroPlist(kiroCLIBundleID, "2.21.1")) + link := joinPath(home, ".local", "bin", "kiro-cli") + m.SetPath("kiro-cli", link) + addFile(m, link, []byte{}) + m.SetSymlink(link, bin) + setConfigDir(m, home, "~/.kiro") + }, + want: []aicliWant{{tool: "amazon-q-cli", binary: "/Users/u/.local/bin/kiro-cli", version: "2.21.1", install: bin, configRel: "~/.kiro"}}, + }, + { + name: "nothing on PATH: the fixed bundle path is the anchor", + goos: model.PlatformDarwin, + setup: func(m *executor.Mock, home string) { + kiroDarwinBundle(m, kiroPlist(kiroCLIBundleID, "2.21.1")) + }, + want: []aicliWant{{tool: "amazon-q-cli", binary: bin, version: "2.21.1"}}, + }, + { + name: "PATH alias and fixed path resolve to one file: one row", + goos: model.PlatformDarwin, + setup: func(m *executor.Mock, home string) { + kiroDarwinBundle(m, kiroPlist(kiroCLIBundleID, "2.21.1")) + m.SetPath("kiro-cli", bin) + m.SetPath("q", bin) + }, + want: []aicliWant{{tool: "amazon-q-cli", binary: bin, version: "2.21.1"}}, + }, + { + // The only exec is Apple's PlistBuddy, from the shared static + // version ladder every bundle-shipped tool goes through; the CLI + // itself is never launched (StaticVersionOnly). + name: "plist without a short version: unknown, CLI never launched", + goos: model.PlatformDarwin, + setup: func(m *executor.Mock, home string) { + kiroDarwinBundle(m, kiroPlist(kiroCLIBundleID, "")) + }, + allowExec: true, + wantExecs: []aicliExecCall{plistBuddy}, + want: []aicliWant{{tool: "amazon-q-cli", binary: bin, version: "unknown"}}, + wantDebug: []string{"reporting version unknown (never launched)"}, + }, + { + name: "a bundle with another identifier is rejected", + goos: model.PlatformDarwin, + setup: func(m *executor.Mock, home string) { + other := "/Applications/Other.app/Contents/MacOS/kiro-cli" + addFile(m, other, []byte{}) + addFile(m, "/Applications/Other.app/Contents/Info.plist", kiroPlist("com.example.other", "9.9.9")) + m.SetPath("kiro-cli", other) + }, + wantDebug: []string{`bundle identifier is "com.example.other", not com.amazon.codewhisperer`}, + }, + { + name: "the Kiro IDE bundle is not the CLI", + goos: model.PlatformDarwin, + setup: func(m *executor.Mock, home string) { + ide := "/Applications/Kiro.app/Contents/Resources/app/bin/code" + addFile(m, ide, []byte{}) + addFile(m, "/Applications/Kiro.app/Contents/Info.plist", kiroPlist("dev.kiro.desktop", "1.0.437")) + m.SetPath("kiro", ide) + }, + wantDebug: []string{"not .app/Contents/MacOS/kiro-cli"}, + }, + { + // The link is seen with Readlink and never followed: neither its + // target nor the link path itself is stat'd, resolved or read. + name: "a plist symlinked into ~/Downloads is never followed", + goos: model.PlatformDarwin, + setup: func(m *executor.Mock, home string) { + addFile(m, bin, []byte{}) + m.SetSymlink(kiroCLIBundle+"/Contents/Info.plist", joinPath(home, "Downloads", "Info.plist")) + addFile(m, joinPath(home, "Downloads", "Info.plist"), kiroPlist(kiroCLIBundleID, "2.21.1")) + }, + skipper: true, + noReadPrefix: []string{"/Users/u/Downloads", kiroCLIBundle + "/Contents/Info.plist"}, + noFollowPrefix: []string{"/Users/u/Downloads", kiroCLIBundle + "/Contents/Info.plist"}, + wantDebug: []string{"under a macOS TCC-protected path"}, + }, + }) +} + +func TestAICLIAgents_Kiro_Linux(t *testing.T) { + const dpkgStatus = "Package: kiro-cli\nStatus: install ok installed\nVersion: 2.21.1\n\n" + runAICLICases(t, []aicliCase{ + { + name: "dpkg: the package owns the binary and carries the version", + setup: func(m *executor.Mock, home string) { + m.SetPath("kiro-cli", "/usr/bin/kiro-cli") + addFile(m, "/usr/bin/kiro-cli", []byte{}) + addFile(m, "/var/lib/dpkg/info/kiro-cli.list", []byte("/usr\n/usr/bin\n/usr/bin/kiro-cli\n")) + addFile(m, "/var/lib/dpkg/status", []byte(dpkgStatus)) + }, + want: []aicliWant{{tool: "amazon-q-cli", binary: "/usr/bin/kiro-cli", version: "2.21.1"}}, + }, + { + name: "installer trio in ~/.local/bin, archive gone: unknown version, never launched", + setup: func(m *executor.Mock, home string) { + dir := joinPath(home, ".local", "bin") + kiroLinuxTrio(m, dir) + m.SetPath("kiro-cli", joinPath(dir, "kiro-cli")) + }, + want: []aicliWant{{tool: "amazon-q-cli", binary: "/home/u/.local/bin/kiro-cli", version: "unknown"}}, + wantDebug: []string{"reporting version unknown (never launched)"}, + }, + { + name: "trio symlinked from the retained kirocli/bin tree: detected, version unknown", + setup: func(m *executor.Mock, home string) { + archive := joinPath(home, "kirocli") + kiroLinuxTrio(m, joinPath(archive, "bin")) + link := joinPath(home, ".local", "bin", "kiro-cli") + addFile(m, link, []byte{}) + m.SetSymlink(link, joinPath(archive, "bin", "kiro-cli")) + }, + want: []aicliWant{{tool: "amazon-q-cli", binary: "/home/u/.local/bin/kiro-cli", version: "unknown", install: "/home/u/kirocli/bin/kiro-cli"}}, + }, + { + name: "kiro-cli without its siblings is not a Kiro install", + setup: func(m *executor.Mock, home string) { + m.SetPath("kiro-cli", "/usr/local/bin/kiro-cli") + addFile(m, "/usr/local/bin/kiro-cli", []byte{}) + }, + wantDebug: []string{"no kiro-cli-chat beside it"}, + }, + { + name: "the IDE's /usr/bin/kiro launcher and a `q` wrapper alone prove nothing", + setup: func(m *executor.Mock, home string) { + m.SetPath("kiro", "/usr/bin/kiro") + addFile(m, "/usr/bin/kiro", []byte{}) + m.SetSymlink("/usr/bin/kiro", "/usr/share/kiro/bin/kiro") + m.SetPath("q", "/usr/local/bin/q") + addFile(m, "/usr/local/bin/q", []byte("#!/bin/sh\nexec kiro-cli chat \"$@\"\n")) + }, + wantDebug: []string{"resolves to /usr/share/kiro/bin/kiro, whose basename is not kiro-cli", "resolves to /usr/local/bin/q, whose basename is not kiro-cli"}, + }, + { + // kiro-cli is listed first, so the installer trio off PATH is + // found before either alias is examined. + name: "the same colliders on PATH do not hide the trio in ~/.local/bin", + setup: func(m *executor.Mock, home string) { + m.SetPath("kiro", "/usr/bin/kiro") + addFile(m, "/usr/bin/kiro", []byte{}) + m.SetSymlink("/usr/bin/kiro", "/usr/share/kiro/bin/kiro") + m.SetPath("q", "/usr/local/bin/q") + addFile(m, "/usr/local/bin/q", []byte{}) + kiroLinuxTrio(m, joinPath(home, ".local", "bin")) + }, + want: []aicliWant{{tool: "amazon-q-cli", binary: "/home/u/.local/bin/kiro-cli"}}, + noLookup: []string{"q"}, + }, + { + name: "a sibling that is a symlink is not followed", + setup: func(m *executor.Mock, home string) { + dir := joinPath(home, ".local", "bin") + kiroLinuxTrio(m, dir) + m.SetSymlink(joinPath(dir, "kiro-cli-chat"), "/home/u/Documents/kiro-cli-chat") + addFile(m, "/home/u/Documents/kiro-cli-chat", []byte{}) + }, + noReadPrefix: []string{"/home/u/Documents", "/home/u/.local/bin/kiro-cli-chat"}, + noFollowPrefix: []string{"/home/u/Documents", "/home/u/.local/bin/kiro-cli-chat"}, + wantDebug: []string{"no kiro-cli-chat beside it"}, + }, + { + name: "a sibling that is a directory does not count", + setup: func(m *executor.Mock, home string) { + dir := joinPath(home, ".local", "bin") + addFile(m, joinPath(dir, "kiro-cli"), []byte{}) + addFile(m, joinPath(dir, "kiro-cli-chat"), []byte{}) + m.SetFileInfo(joinPath(dir, "kiro-cli-term"), &dirInfo{n: "kiro-cli-term"}) + m.SetPath("kiro-cli", joinPath(dir, "kiro-cli")) + }, + wantDebug: []string{"no kiro-cli-term beside it"}, + }, + }) +} + +func TestAICLIAgents_Kiro_Windows(t *testing.T) { + const defaultInstall = `C:\Users\u\AppData\Local\Kiro-Cli` + exe := defaultInstall + `\kiro-cli.exe` + runAICLICases(t, []aicliCase{ + { + name: "default install: the fixed path is bound to the registry InstallPath", + goos: model.PlatformWindows, + setup: func(m *executor.Mock, home string) { + kiroRegistry(m, defaultInstall) + addFile(m, exe, []byte{}) + }, + allowExec: true, + wantExecs: []aicliExecCall{}, + want: []aicliWant{{tool: "amazon-q-cli", binary: exe, version: "2.21.1.0", install: exe}}, + }, + { + name: "PATH hit inside the registered InstallPath, spelled in another case", + goos: model.PlatformWindows, + setup: func(m *executor.Mock, home string) { + kiroRegistry(m, `c:\users\u\appdata\local\kiro-cli\`) + m.SetPath("kiro-cli", exe) + addFile(m, exe, []byte{}) + }, + allowExec: true, + wantExecs: []aicliExecCall{}, + want: []aicliWant{{tool: "amazon-q-cli", binary: exe, version: "2.21.1.0"}}, + }, + { + name: "non-default InstallPath, on no PATH: the key's path joins the candidates", + goos: model.PlatformWindows, + setup: func(m *executor.Mock, home string) { + kiroRegistry(m, `D:\Tools\KiroCli`) + addFile(m, `D:\Tools\KiroCli\kiro-cli.exe`, []byte{}) + }, + allowExec: true, + wantExecs: []aicliExecCall{}, + want: []aicliWant{{tool: "amazon-q-cli", binary: `D:\Tools\KiroCli\kiro-cli.exe`, version: "2.21.1.0"}}, + }, + { + name: "a kiro-cli.exe outside the registered InstallPath is rejected", + goos: model.PlatformWindows, + setup: func(m *executor.Mock, home string) { + kiroRegistry(m, `D:\Tools\KiroCli`) + m.SetPath("kiro-cli", `C:\stray\kiro-cli.exe`) + addFile(m, `C:\stray\kiro-cli.exe`, []byte{}) + }, + allowExec: true, + wantExecs: []aicliExecCall{}, + wantDebug: []string{`not kiro-cli.exe under the registered InstallPath D:\Tools\KiroCli`}, + }, + { + name: "registry key present but the executable is gone", + goos: model.PlatformWindows, + setup: func(m *executor.Mock, home string) { + kiroRegistry(m, defaultInstall) + }, + allowExec: true, + wantExecs: []aicliExecCall{}, + }, + { + // With no key nothing can be proven, so no candidate is even looked + // up — the PATH kiro-cli.exe stays untouched. + name: "no registry key: no candidate is examined", + goos: model.PlatformWindows, + setup: func(m *executor.Mock, home string) { + m.SetPath("kiro-cli", exe) + addFile(m, exe, []byte{}) + }, + allowExec: true, + wantExecs: []aicliExecCall{}, + noLookup: []string{"kiro"}, + wantDebug: []string{`HKCU\SOFTWARE\Kiro\CLI is absent`}, + }, + }) +} + +// dirInfo is an os.FileInfo for a directory, for the sibling-is-a-directory case. +type dirInfo struct{ n string } + +func (d *dirInfo) Name() string { return d.n } +func (d *dirInfo) Size() int64 { return 0 } +func (d *dirInfo) Mode() os.FileMode { return os.ModeDir | 0o755 } +func (d *dirInfo) ModTime() time.Time { return time.Time{} } +func (d *dirInfo) IsDir() bool { return true } +func (d *dirInfo) Sys() any { return nil } + +// Cases for the grok-build, kimi-code, muse-code, hermes-agent and oh-my-pi +// ladders, on the harness above. Every case +// traps exec: all five specs are StaticVersionOnly, so no channel — accept or +// reject — may launch anything. + +const ( + kimiRealBytes int64 = 151 << 20 // measured installer binary, low end + ompRealBytes int64 = 135 << 20 // measured standalone binary, low end + museVersion = "1.0.3-R2198.1" + hermesVersion = "0.21.0" + ompVersion = "18.1.10" +) + +// distInfoGlob is the pattern distInfoVersion issues for venv, in the goos +// spelling — what a case must SetGlob and allow. +func distInfoGlob(goos, venv, dist string) string { + if goos == model.PlatformWindows { + return joinPath(venv, "Lib", "site-packages", dist+"-*.dist-info") + } + return joinPath(venv, "lib", "python*", "site-packages", dist+"-*.dist-info") +} + +// addDistInfo registers exactly one -.dist-info under venv and returns +// the pattern the case must allow. +func addDistInfo(m *executor.Mock, goos, venv, dist, version string) string { + pattern := distInfoGlob(goos, venv, dist) + m.SetGlob(pattern, []string{joinPath(pathDir(pattern), dist+"-"+version+".dist-info")}) + return pattern +} + +// addMuseInstall lays down the installer's directory: the launcher script, the +// .muse-version sidecar and the muse-bin- payload. +func addMuseInstall(m *executor.Mock, dir, version string) { + addFile(m, joinPath(dir, "muse"), []byte("#!/usr/bin/env bash\n")) + addFile(m, joinPath(dir, ".muse-version"), []byte(version+"\n")) + addBinary(m, joinPath(dir, "muse-bin-"+version), 90<<20) +} + +func TestVersionFromFilename(t *testing.T) { + tests := []struct{ base, prefix, want string }{ + {"grok-1.0.13", "grok-", "1.0.13"}, + {"grok-1.0.13-linux-aarch64", "grok-", "1.0.13"}, + {"grok-1.0.13-macos-aarch64", "grok-", "1.0.13"}, + {"grok-1.0.13.exe", "grok-", "1.0.13"}, + {"grok-1.0.13-windows-x64.EXE", "grok-", "1.0.13"}, + {"grok-macos-aarch64", "grok-", ""}, // unversioned bootstrap + {"grok", "grok-", ""}, + {"grok.exe", "grok-", ""}, + {"muse-bin-1.0.3-R2198.1", "muse-bin-", "1.0.3-R2198.1"}, + {"muse-bin-", "muse-bin-", ""}, + {"kimi-1.0.13", "grok-", ""}, + } + for _, tc := range tests { + if got := versionFromFilename(tc.base, tc.prefix); got != tc.want { + t.Errorf("versionFromFilename(%q, %q) = %q, want %q", tc.base, tc.prefix, got, tc.want) + } + } +} + +func TestDistInfoVersion(t *testing.T) { + venv := "/home/u/.local/share/uv/tools/kimi-cli" + pattern := distInfoGlob(model.PlatformLinux, venv, "kimi_cli") + sp := pathDir(pattern) + + t.Run("one match yields its version", func(t *testing.T) { + m, _ := newAICLIMock(model.PlatformLinux) + m.SetGlob(pattern, []string{sp + "/kimi_cli-1.49.0.dist-info"}) + if got := distInfoVersion(m, progress.NewNoop(), venv, "kimi_cli"); got != "1.49.0" { + t.Errorf("got %q, want 1.49.0", got) + } + }) + t.Run("two matches yield nothing", func(t *testing.T) { + m, _ := newAICLIMock(model.PlatformLinux) + m.SetGlob(pattern, []string{sp + "/kimi_cli-1.49.0.dist-info", sp + "/kimi_cli-1.50.0.dist-info"}) + if got := distInfoVersion(m, progress.NewNoop(), venv, "kimi_cli"); got != "" { + t.Errorf("got %q, want \"\"", got) + } + }) + t.Run("no match yields nothing and nothing else is touched", func(t *testing.T) { + m, _ := newAICLIMock(model.PlatformLinux) + rec := &recExec{Mock: m, t: t, trapExec: true} + if got := distInfoVersion(rec, progress.NewNoop(), venv, "kimi_cli"); got != "" { + t.Errorf("got %q, want \"\"", got) + } + if len(rec.reads) != 0 || len(rec.globs) != 1 { + t.Errorf("reads=%v globs=%v; want no reads and one glob", rec.reads, rec.globs) + } + }) + t.Run("windows uses Lib/site-packages", func(t *testing.T) { + m, _ := newAICLIMock(model.PlatformWindows) + wv := `C:\Users\u\AppData\Local\hermes\hermes-agent\venv` + wp := distInfoGlob(model.PlatformWindows, wv, "hermes_agent") + m.SetGlob(wp, []string{wv + `\Lib\site-packages\hermes_agent-0.21.0.dist-info`}) + if got := distInfoVersion(m, progress.NewNoop(), wv, "hermes_agent"); got != "0.21.0" { + t.Errorf("got %q, want 0.21.0", got) + } + }) +} + +// --------------------------------------------------------------------------- +// grok-build +// --------------------------------------------------------------------------- + +func TestAICLIAgents_Grok(t *testing.T) { + runAICLICases(t, []aicliCase{ + { + name: "(g1) script install: the bootstrap link carries no version and reports unknown", + goos: model.PlatformDarwin, + setup: func(m *executor.Mock, home string) { + link := joinPath(home, ".grok", "bin", "grok") + addFile(m, link, []byte{}) + m.SetSymlink(link, joinPath(home, ".grok", "bin", "grok-macos-aarch64")) + setConfigDir(m, home, "~/.grok") + }, + want: []aicliWant{{tool: "grok-build", binary: "/Users/u/.grok/bin/grok", version: "unknown", configRel: "~/.grok"}}, + }, + { + name: "(g2) after npm postinstall the link names its version", + setup: func(m *executor.Mock, home string) { + link := joinPath(home, ".grok", "bin", "grok") + addFile(m, link, []byte{}) + m.SetSymlink(link, joinPath(home, ".grok", "bin", "grok-1.0.13")) + }, + want: []aicliWant{{tool: "grok-build", binary: "/home/u/.grok/bin/grok", version: "1.0.13"}}, + }, + { + name: "(g3) after a self-update the link points into downloads with a platform suffix", + setup: func(m *executor.Mock, home string) { + link := joinPath(home, ".grok", "bin", "grok") + addFile(m, link, []byte{}) + m.SetSymlink(link, joinPath(home, ".grok", "downloads", "grok-1.0.13-linux-aarch64")) + }, + want: []aicliWant{{tool: "grok-build", binary: "/home/u/.grok/bin/grok", version: "1.0.13"}}, + }, + { + name: "(g4) the npm prefix trampoline accepts from its manifest", + setup: func(m *executor.Mock, _ string) { + m.SetPath("grok", "/usr/local/bin/grok") + addNPMGlobal(m, "/usr/local/bin/grok", "/usr/local/lib/node_modules/@xai-official/grok", grokPackageName, "1.0.13") + }, + want: []aicliWant{{tool: "grok-build", binary: "/usr/local/bin/grok", version: "1.0.13"}}, + }, + { + name: "(g4a) the anchor wins over a PATH hit that resolves to the same file", + setup: func(m *executor.Mock, home string) { + link := joinPath(home, ".grok", "bin", "grok") + addFile(m, link, []byte{}) + m.SetSymlink(link, joinPath(home, ".grok", "bin", "grok-1.0.13")) + local := joinPath(home, ".local", "bin", "grok") + m.SetPath("grok", local) + addFile(m, local, []byte{}) + m.SetSymlink(local, joinPath(home, ".grok", "bin", "grok-1.0.13")) + }, + want: []aicliWant{{tool: "grok-build", binary: "/home/u/.grok/bin/grok", version: "1.0.13"}}, + }, + { + name: "(g5) /usr/bin/grok owned by an AUR grok-build package accepts", + setup: func(m *executor.Mock, _ string) { + m.SetPath("grok", "/usr/bin/grok") + addFile(m, "/usr/bin/grok", []byte{}) + m.SetGlob("/var/lib/pacman/local/*-*", []string{"/var/lib/pacman/local/grok-build-bin-1.0.13-1"}) + addFile(m, "/var/lib/pacman/local/grok-build-bin-1.0.13-1/files", pacmanFiles("usr/bin/grok")) + }, + want: []aicliWant{{tool: "grok-build", binary: "/usr/bin/grok", version: "unknown"}}, + }, + { + name: "(g5r) /usr/bin/grok owned by the distro grok is the unrelated log parser", + setup: func(m *executor.Mock, _ string) { + m.SetPath("grok", "/usr/bin/grok") + addFile(m, "/usr/bin/grok", []byte{}) + m.SetGlob("/var/lib/pacman/local/*-*", []string{"/var/lib/pacman/local/grok-1.20.2-1"}) + addFile(m, "/var/lib/pacman/local/grok-1.20.2-1/files", pacmanFiles("usr/bin/grok")) + }, + wantDebug: []string{"no installed grok-build package owns usr/bin/grok"}, + }, + { + name: "(g6r) Homebrew Cellar/grok is the regex formula", + goos: model.PlatformDarwin, + setup: func(m *executor.Mock, _ string) { + m.SetPath("grok", "/opt/homebrew/bin/grok") + addFile(m, "/opt/homebrew/bin/grok", []byte{}) + m.SetSymlink("/opt/homebrew/bin/grok", "/opt/homebrew/Cellar/grok/1.20.2/bin/grok") + }, + wantDebug: []string{"Homebrew Cellar/grok is the regex log-parser formula"}, + }, + { + name: "(g7r) the cargo grok is rejected", + setup: func(m *executor.Mock, home string) { + cargo := joinPath(home, ".cargo", "bin", "grok") + m.SetPath("grok", cargo) + addFile(m, cargo, []byte{}) + }, + wantDebug: []string{"under ~/.cargo"}, + }, + { + name: "(g8r) an npm package of another name is rejected by name", + setup: func(m *executor.Mock, _ string) { + m.SetPath("grok", "/usr/local/bin/grok") + addNPMGlobal(m, "/usr/local/bin/grok", "/usr/local/lib/node_modules/grok", "grok", "0.1.0") + }, + wantDebug: []string{`npm package is "grok", not ` + grokPackageName}, + }, + { + name: "(g9r) a plain ~/.local/bin/grok script with no corroborator is rejected", + setup: func(m *executor.Mock, home string) { + addFile(m, joinPath(home, ".local", "bin", "grok"), []byte("#!/bin/sh\n")) + }, + wantDebug: []string{"no Grok Build channel claims it"}, + }, + { + name: "(g10) ~/.grok and its generic `agent` launcher alone are not an install", + setup: func(m *executor.Mock, home string) { + setConfigDir(m, home, "~/.grok") + addFile(m, joinPath(home, ".grok", "bin", "agent"), []byte{}) + }, + }, + }) +} + +// --------------------------------------------------------------------------- +// kimi-code +// --------------------------------------------------------------------------- + +func TestAICLIAgents_Kimi(t *testing.T) { + runAICLICases(t, []aicliCase{ + { + name: "(k1) the installer binary at or above the floor accepts with version unknown", + setup: func(m *executor.Mock, home string) { + addBinary(m, joinPath(home, ".kimi-code", "bin", "kimi"), kimiRealBytes) + setConfigDir(m, home, "~/.kimi-code") + }, + want: []aicliWant{{tool: "kimi-code", binary: "/home/u/.kimi-code/bin/kimi", version: "unknown", configRel: "~/.kimi-code"}}, + }, + { + name: "(k1r) a script at the installer target is under the floor and rejected", + setup: func(m *executor.Mock, home string) { + addBinary(m, joinPath(home, ".kimi-code", "bin", "kimi"), 40<<10) + }, + wantDebug: []string{"at the installer target but under"}, + }, + { + name: "(k2) the npm prefix accepts from its manifest", + setup: func(m *executor.Mock, _ string) { + m.SetPath("kimi", "/usr/local/bin/kimi") + addNPMGlobal(m, "/usr/local/bin/kimi", "/usr/local/lib/node_modules/@moonshot-ai/kimi-code", kimiPackageName, "0.12.0") + }, + want: []aicliWant{{tool: "kimi-code", binary: "/usr/local/bin/kimi", version: "0.12.0"}}, + }, + { + name: "(k3) the Homebrew formula resolves into its libexec node_modules and needs no brew rule", + goos: model.PlatformDarwin, + setup: func(m *executor.Mock, _ string) { + m.SetPath("kimi", "/opt/homebrew/bin/kimi") + addNPMGlobal(m, "/opt/homebrew/bin/kimi", + "/opt/homebrew/Cellar/kimi-code/0.12.0/libexec/lib/node_modules/@moonshot-ai/kimi-code", kimiPackageName, "0.12.0") + }, + want: []aicliWant{{tool: "kimi-code", binary: "/opt/homebrew/bin/kimi", version: "0.12.0"}}, + }, + { + name: "(k3b) an unlinked brew install is reached through the opt anchor", + goos: model.PlatformDarwin, + setup: func(m *executor.Mock, _ string) { + addNPMGlobal(m, "/opt/homebrew/opt/kimi-code/bin/kimi", + "/opt/homebrew/Cellar/kimi-code/0.12.0/libexec/lib/node_modules/@moonshot-ai/kimi-code", kimiPackageName, "0.12.0") + }, + want: []aicliWant{{tool: "kimi-code", binary: "/opt/homebrew/opt/kimi-code/bin/kimi", version: "0.12.0"}}, + }, + { + name: "(k4) the legacy uv-tool venv accepts with its dist-info version", + goos: model.PlatformDarwin, + setup: func(m *executor.Mock, home string) { + venv := joinPath(home, ".local", "share", "uv", "tools", "kimi-cli") + link := joinPath(home, ".local", "bin", "kimi") + addFile(m, link, []byte{}) + m.SetSymlink(link, joinPath(venv, "bin", "kimi")) + addDistInfo(m, model.PlatformDarwin, venv, "kimi_cli", "1.49.0") + }, + allowGlobs: []string{distInfoGlob(model.PlatformDarwin, "/Users/u/.local/share/uv/tools/kimi-cli", "kimi_cli")}, + want: []aicliWant{{tool: "kimi-code", binary: "/Users/u/.local/bin/kimi", version: "1.49.0"}}, + }, + { + name: "(k4b) the pipx venv accepts too; two dist-info dirs degrade to unknown", + setup: func(m *executor.Mock, home string) { + venv := joinPath(home, ".local", "share", "pipx", "venvs", "kimi-cli") + link := joinPath(home, ".local", "bin", "kimi") + addFile(m, link, []byte{}) + m.SetSymlink(link, joinPath(venv, "bin", "kimi")) + pattern := distInfoGlob(model.PlatformLinux, venv, "kimi_cli") + m.SetGlob(pattern, []string{pathDir(pattern) + "/kimi_cli-1.48.0.dist-info", pathDir(pattern) + "/kimi_cli-1.49.0.dist-info"}) + }, + allowGlobs: []string{distInfoGlob(model.PlatformLinux, "/home/u/.local/share/pipx/venvs/kimi-cli", "kimi_cli")}, + want: []aicliWant{{tool: "kimi-code", binary: "/home/u/.local/bin/kimi", version: "unknown"}}, + wantDebug: []string{"2 dist-info directories"}, + }, + { + name: "(k5r) the cargo kimi is rejected", + setup: func(m *executor.Mock, home string) { + cargo := joinPath(home, ".cargo", "bin", "kimi") + m.SetPath("kimi", cargo) + addFile(m, cargo, []byte{}) + }, + wantDebug: []string{"under ~/.cargo"}, + }, + { + name: "(k6r) Homebrew Cellar/kimi is not the kimi-code formula", + goos: model.PlatformDarwin, + setup: func(m *executor.Mock, _ string) { + m.SetPath("kimi", "/opt/homebrew/bin/kimi") + addFile(m, "/opt/homebrew/bin/kimi", []byte{}) + m.SetSymlink("/opt/homebrew/bin/kimi", "/opt/homebrew/Cellar/kimi/2.0.0/bin/kimi") + }, + wantDebug: []string{"Homebrew Cellar/kimi is not the kimi-code formula"}, + }, + { + name: "(k7r) a plain ~/.local/bin/kimi script is rejected; ~/.kimi-code alone is not an install", + setup: func(m *executor.Mock, home string) { + addFile(m, joinPath(home, ".local", "bin", "kimi"), []byte("#!/bin/sh\n")) + setConfigDir(m, home, "~/.kimi-code") + }, + wantDebug: []string{"no Kimi Code channel claims it"}, + }, + }) +} + +// --------------------------------------------------------------------------- +// muse-code +// --------------------------------------------------------------------------- + +func TestAICLIAgents_Muse(t *testing.T) { + runAICLICases(t, []aicliCase{ + { + name: "(m1) launcher with sidecar and matching muse-bin accepts with the sidecar version", + setup: func(m *executor.Mock, home string) { + addMuseInstall(m, joinPath(home, ".local", "bin"), museVersion) + setConfigDir(m, home, "~/.config/muse") + }, + want: []aicliWant{{tool: "muse-code", binary: "/home/u/.local/bin/muse", version: museVersion, configRel: "~/.config/muse"}}, + }, + { + name: "(m1b) a relocated MUSE_INSTALL_DIR on PATH is directory-relative and still accepts", + setup: func(m *executor.Mock, _ string) { + addMuseInstall(m, "/opt/muse", museVersion) + m.SetPath("muse", "/opt/muse/muse") + }, + want: []aicliWant{{tool: "muse-code", binary: "/opt/muse/muse", version: museVersion}}, + }, + { + name: "(m2r) sidecar without its muse-bin payload is rejected", + setup: func(m *executor.Mock, home string) { + bin := joinPath(home, ".local", "bin") + addFile(m, joinPath(bin, "muse"), []byte("#!/usr/bin/env bash\n")) + addFile(m, joinPath(bin, ".muse-version"), []byte(museVersion+"\n")) + }, + wantDebug: []string{"no muse-bin-" + museVersion + " sits beside it"}, + }, + { + name: "(m2b) a sidecar that is not a Muse release string is rejected", + setup: func(m *executor.Mock, home string) { + bin := joinPath(home, ".local", "bin") + addFile(m, joinPath(bin, "muse"), []byte("#!/usr/bin/env bash\n")) + addFile(m, joinPath(bin, ".muse-version"), []byte("1.0.3\n")) + addBinary(m, joinPath(bin, "muse-bin-1.0.3"), 90<<20) + }, + wantDebug: []string{"does not carry a Muse release version"}, + }, + { + name: "(m2c) an oversized sidecar is refused before it is read", + setup: func(m *executor.Mock, home string) { + bin := joinPath(home, ".local", "bin") + addFile(m, joinPath(bin, "muse"), []byte("#!/usr/bin/env bash\n")) + addBinary(m, joinPath(bin, ".muse-version"), museVersionMaxBytes+1) + }, + wantDebug: []string{"over the 64-byte cap"}, + }, + { + name: "(m2d) a FIFO sidecar is refused before it is read; its zero size must not pass the cap", + setup: func(m *executor.Mock, home string) { + bin := joinPath(home, ".local", "bin") + addFile(m, joinPath(bin, "muse"), []byte("#!/usr/bin/env bash\n")) + m.SetFileInfo(joinPath(bin, ".muse-version"), pipeFileInfo{name: ".muse-version"}) + addBinary(m, joinPath(bin, "muse-bin-"+museVersion), 90<<20) + }, + wantDebug: []string{"is not a regular file"}, + }, + { + name: "(m2e) a sidecar whose resolution fails for a reason other than absence rejects instead of touching the unresolved path", + setup: func(m *executor.Mock, home string) { + bin := joinPath(home, ".local", "bin") + addMuseInstall(m, bin, museVersion) + m.SetSymlinkError(joinPath(bin, ".muse-version"), errors.New("too many levels of symbolic links")) + }, + noReadPrefix: []string{"/home/u/.local/bin/.muse-version"}, + wantDebug: []string{"could not be safely resolved"}, + }, + { + name: "(m3) launcher with a single muse-bin and no sidecar accepts from the filename", + setup: func(m *executor.Mock, home string) { + bin := joinPath(home, ".local", "bin") + addFile(m, joinPath(bin, "muse"), []byte("#!/usr/bin/env bash\n")) + addBinary(m, joinPath(bin, "muse-bin-"+museVersion), 90<<20) + m.SetGlob(joinPath(bin, "muse-bin-*"), []string{joinPath(bin, "muse-bin-"+museVersion)}) + }, + allowGlobs: []string{"/home/u/.local/bin/muse-bin-*"}, + want: []aicliWant{{tool: "muse-code", binary: "/home/u/.local/bin/muse", version: museVersion}}, + }, + { + name: "(m3a) an absent sidecar reported by EvalSymlinks still falls through to the muse-bin-* sibling", + setup: func(m *executor.Mock, home string) { + bin := joinPath(home, ".local", "bin") + addFile(m, joinPath(bin, "muse"), []byte("#!/usr/bin/env bash\n")) + addBinary(m, joinPath(bin, "muse-bin-"+museVersion), 90<<20) + m.SetGlob(joinPath(bin, "muse-bin-*"), []string{joinPath(bin, "muse-bin-"+museVersion)}) + m.SetSymlinkError(joinPath(bin, ".muse-version"), fs.ErrNotExist) + }, + allowGlobs: []string{"/home/u/.local/bin/muse-bin-*"}, + want: []aicliWant{{tool: "muse-code", binary: "/home/u/.local/bin/muse", version: museVersion}}, + }, + { + name: "(m3b) two muse-bin payloads and no sidecar accept with version unknown", + setup: func(m *executor.Mock, home string) { + bin := joinPath(home, ".local", "bin") + addFile(m, joinPath(bin, "muse"), []byte("#!/usr/bin/env bash\n")) + m.SetGlob(joinPath(bin, "muse-bin-*"), []string{joinPath(bin, "muse-bin-1.0.2-R2040.1"), joinPath(bin, "muse-bin-"+museVersion)}) + }, + allowGlobs: []string{"/home/u/.local/bin/muse-bin-*"}, + want: []aicliWant{{tool: "muse-code", binary: "/home/u/.local/bin/muse", version: "unknown"}}, + }, + { + name: "(m3r) a single muse-bin-* sibling without a Muse release suffix proves nothing", + setup: func(m *executor.Mock, home string) { + bin := joinPath(home, ".local", "bin") + addFile(m, joinPath(bin, "muse"), []byte("#!/usr/bin/env bash\n")) + m.SetGlob(joinPath(bin, "muse-bin-*"), []string{joinPath(bin, "muse-bin-readme")}) + }, + allowGlobs: []string{"/home/u/.local/bin/muse-bin-*"}, + wantDebug: []string{"no Muse Code channel claims it"}, + }, + { + name: "(m3c) one release payload beside an unversioned sibling accepts with the payload version", + setup: func(m *executor.Mock, home string) { + bin := joinPath(home, ".local", "bin") + addFile(m, joinPath(bin, "muse"), []byte("#!/usr/bin/env bash\n")) + m.SetGlob(joinPath(bin, "muse-bin-*"), []string{joinPath(bin, "muse-bin-readme"), joinPath(bin, "muse-bin-"+museVersion)}) + }, + allowGlobs: []string{"/home/u/.local/bin/muse-bin-*"}, + want: []aicliWant{{tool: "muse-code", binary: "/home/u/.local/bin/muse", version: museVersion}}, + }, + { + name: "(m4) the homebrew cask accepts and its version comes from the Caskroom segment", + goos: model.PlatformDarwin, + setup: func(m *executor.Mock, _ string) { + m.SetPath("muse", "/opt/homebrew/bin/muse") + addFile(m, "/opt/homebrew/bin/muse", []byte{}) + m.SetSymlink("/opt/homebrew/bin/muse", "/opt/homebrew/Caskroom/muse-code/1.0.2-R2040.1/muse") + }, + allowGlobs: []string{"/opt/homebrew/Caskroom/muse-code/1.0.2-R2040.1/muse-bin-*"}, + want: []aicliWant{{tool: "muse-code", binary: "/opt/homebrew/bin/muse", version: "1.0.2-R2040.1"}}, + }, + { + name: "(m5) /usr/bin/muse owned by the AUR muse-code-bin accepts", + setup: func(m *executor.Mock, _ string) { + m.SetPath("muse", "/usr/bin/muse") + addFile(m, "/usr/bin/muse", []byte{}) + m.SetGlob("/var/lib/pacman/local/*-*", []string{"/var/lib/pacman/local/muse-code-bin-1.0.3-1"}) + addFile(m, "/var/lib/pacman/local/muse-code-bin-1.0.3-1/files", pacmanFiles("usr/bin/muse")) + }, + allowGlobs: []string{"/usr/bin/muse-bin-*"}, + want: []aicliWant{{tool: "muse-code", binary: "/usr/bin/muse", version: "unknown"}}, + }, + { + name: "(m5r) /usr/bin/muse owned by the distro muse is the MusE sequencer", + setup: func(m *executor.Mock, _ string) { + m.SetPath("muse", "/usr/bin/muse") + addFile(m, "/usr/bin/muse", []byte{}) + m.SetGlob("/var/lib/pacman/local/*-*", []string{"/var/lib/pacman/local/muse-4.2.1-1"}) + addFile(m, "/var/lib/pacman/local/muse-4.2.1-1/files", pacmanFiles("usr/bin/muse")) + }, + allowGlobs: []string{"/usr/bin/muse-bin-*"}, + wantDebug: []string{"the distro `muse` is the MusE sequencer"}, + }, + { + name: "(m6r) the npm muse is rejected before its directory is probed", + setup: func(m *executor.Mock, _ string) { + m.SetPath("muse", "/usr/local/bin/muse") + addNPMGlobal(m, "/usr/local/bin/muse", "/usr/local/lib/node_modules/muse", "muse", "3.1.0") + }, + noReadPrefix: []string{"/usr/local/lib/node_modules/muse/dist"}, + wantDebug: []string{"under node_modules; npm `muse` is unrelated"}, + }, + { + name: "(m7r) the cargo muse is rejected", + setup: func(m *executor.Mock, home string) { + cargo := joinPath(home, ".cargo", "bin", "muse") + m.SetPath("muse", cargo) + addFile(m, cargo, []byte{}) + }, + wantDebug: []string{"under ~/.cargo"}, + }, + { + name: "(m8r) a bare ~/.local/bin/muse with neither sidecar nor payload is rejected", + setup: func(m *executor.Mock, home string) { + addFile(m, joinPath(home, ".local", "bin", "muse"), []byte("#!/bin/sh\n")) + setConfigDir(m, home, "~/.config/muse") + }, + allowGlobs: []string{"/home/u/.local/bin/muse-bin-*"}, + wantDebug: []string{"no Muse Code channel claims it"}, + }, + }) +} + +// --------------------------------------------------------------------------- +// hermes-agent +// --------------------------------------------------------------------------- + +func TestAICLIAgents_Hermes(t *testing.T) { + const keg = "/opt/homebrew/Cellar/hermes-agent/2026.8.31" + runAICLICases(t, []aicliCase{ + { + name: "(h1) the user launcher with its venv accepts with the dist-info version; the launcher is never read", + setup: func(m *executor.Mock, home string) { + addFile(m, joinPath(home, ".local", "bin", "hermes"), []byte("#!/bin/bash\nexec ~/.hermes/hermes-agent/venv/bin/hermes \"$@\"\n")) + venv := joinPath(home, ".hermes", "hermes-agent", "venv") + m.SetDir(venv) + addDistInfo(m, model.PlatformLinux, venv, "hermes_agent", hermesVersion) + setConfigDir(m, home, "~/.hermes") + }, + allowGlobs: []string{distInfoGlob(model.PlatformLinux, "/home/u/.hermes/hermes-agent/venv", "hermes_agent")}, + want: []aicliWant{{tool: "hermes-agent", binary: "/home/u/.local/bin/hermes", version: hermesVersion, configRel: "~/.hermes"}}, + }, + { + name: "(h1r) the same launcher without the venv is rejected", + setup: func(m *executor.Mock, home string) { + addFile(m, joinPath(home, ".local", "bin", "hermes"), []byte("#!/bin/bash\n")) + setConfigDir(m, home, "~/.hermes") + }, + wantDebug: []string{"the installer launcher is there but /home/u/.hermes/hermes-agent/venv is not"}, + }, + { + name: "(h1v) the venv alone, with no launcher, is not an install", + setup: func(m *executor.Mock, home string) { + m.SetDir(joinPath(home, ".hermes", "hermes-agent", "venv")) + setConfigDir(m, home, "~/.hermes") + }, + }, + { + // The venv path is derived from $HOME, not from a resolved candidate, + // so a link there is seen with Readlink and never followed. + name: "(h1l) a venv that is itself a symlink is rejected unread", + setup: func(m *executor.Mock, home string) { + addFile(m, joinPath(home, ".local", "bin", "hermes"), []byte("#!/bin/bash\n")) + venv := joinPath(home, ".hermes", "hermes-agent", "venv") + m.SetSymlink(venv, joinPath(home, "Documents", "venv")) + m.SetDir(joinPath(home, "Documents", "venv")) + setConfigDir(m, home, "~/.hermes") + }, + noReadPrefix: []string{"/home/u/Documents", "/home/u/.hermes/hermes-agent/venv"}, + noFollowPrefix: []string{"/home/u/Documents", "/home/u/.hermes/hermes-agent/venv"}, + wantDebug: []string{"could not be safely resolved"}, + }, + { + name: "(h2) the root layout pairs /usr/local/bin/hermes with /usr/local/lib/hermes-agent/venv", + setup: func(m *executor.Mock, _ string) { + addFile(m, "/usr/local/bin/hermes", []byte("#!/bin/bash\n")) + m.SetDir("/usr/local/lib/hermes-agent/venv") + addDistInfo(m, model.PlatformLinux, "/usr/local/lib/hermes-agent/venv", "hermes_agent", hermesVersion) + }, + allowGlobs: []string{distInfoGlob(model.PlatformLinux, "/usr/local/lib/hermes-agent/venv", "hermes_agent")}, + want: []aicliWant{{tool: "hermes-agent", binary: "/usr/local/bin/hermes", version: hermesVersion}}, + }, + { + name: "(h3) the Homebrew keg accepts with the venv's upstream version when its dist-info exists", + goos: model.PlatformDarwin, + setup: func(m *executor.Mock, _ string) { + m.SetPath("hermes", "/opt/homebrew/bin/hermes") + addFile(m, "/opt/homebrew/bin/hermes", []byte{}) + m.SetSymlink("/opt/homebrew/bin/hermes", keg+"/bin/hermes") + addDistInfo(m, model.PlatformDarwin, keg+"/libexec", "hermes_agent", hermesVersion) + }, + allowGlobs: []string{distInfoGlob(model.PlatformDarwin, keg+"/libexec", "hermes_agent")}, + want: []aicliWant{{tool: "hermes-agent", binary: "/opt/homebrew/bin/hermes", version: hermesVersion}}, + }, + { + name: "(h3b) without a dist-info the keg falls back to its Cellar version segment", + goos: model.PlatformDarwin, + setup: func(m *executor.Mock, _ string) { + m.SetPath("hermes", "/opt/homebrew/bin/hermes") + addFile(m, "/opt/homebrew/bin/hermes", []byte{}) + m.SetSymlink("/opt/homebrew/bin/hermes", keg+"/bin/hermes") + }, + allowGlobs: []string{distInfoGlob(model.PlatformDarwin, keg+"/libexec", "hermes_agent")}, + want: []aicliWant{{tool: "hermes-agent", binary: "/opt/homebrew/bin/hermes", version: "2026.8.31"}}, + }, + { + name: "(h4r) the npm hermes is rejected", + setup: func(m *executor.Mock, home string) { + addNPMGlobal(m, joinPath(home, ".local", "bin", "hermes"), joinPath(home, ".local", "lib", "node_modules", "hermes"), "hermes", "0.3.0") + }, + wantDebug: []string{"under node_modules; npm `hermes`"}, + }, + { + name: "(h5r) the cargo hermes is rejected", + setup: func(m *executor.Mock, home string) { + cargo := joinPath(home, ".cargo", "bin", "hermes") + m.SetPath("hermes", cargo) + addFile(m, cargo, []byte{}) + }, + wantDebug: []string{"under ~/.cargo"}, + }, + { + name: "(h6r) a hermes on PATH somewhere else is rejected", + setup: func(m *executor.Mock, _ string) { + m.SetPath("hermes", "/opt/hermes/hermes") + addFile(m, "/opt/hermes/hermes", []byte("#!/bin/sh\n")) + }, + wantDebug: []string{"no Hermes Agent channel claims it"}, + }, + }) +} + +// --------------------------------------------------------------------------- +// oh-my-pi +// --------------------------------------------------------------------------- + +func TestAICLIAgents_OMP(t *testing.T) { + runAICLICases(t, []aicliCase{ + { + name: "(o1) the npm prefix accepts from its manifest", + setup: func(m *executor.Mock, home string) { + m.SetPath("omp", "/usr/local/bin/omp") + addNPMGlobal(m, "/usr/local/bin/omp", "/usr/local/lib/node_modules/@oh-my-pi/pi-coding-agent", ompPackageName, ompVersion) + setConfigDir(m, home, "~/.omp/agent") + }, + want: []aicliWant{{tool: "oh-my-pi", binary: "/usr/local/bin/omp", version: ompVersion, configRel: "~/.omp/agent"}}, + }, + { + name: "(o1b) the ~/.local/bin npm symlink is an npm channel, not the standalone anchor", + setup: func(m *executor.Mock, home string) { + addNPMGlobal(m, joinPath(home, ".local", "bin", "omp"), joinPath(home, ".local", "lib", "node_modules", "@oh-my-pi", "pi-coding-agent"), ompPackageName, ompVersion) + }, + want: []aicliWant{{tool: "oh-my-pi", binary: "/home/u/.local/bin/omp", version: ompVersion}}, + }, + { + name: "(o2) the Bun global symlink accepts", + setup: func(m *executor.Mock, home string) { + addNPMGlobal(m, joinPath(home, ".bun", "bin", "omp"), joinPath(home, ".bun", "install", "global", "node_modules", "@oh-my-pi", "pi-coding-agent"), ompPackageName, ompVersion) + }, + want: []aicliWant{{tool: "oh-my-pi", binary: "/home/u/.bun/bin/omp", version: ompVersion}}, + }, + { + name: "(o3) the Homebrew formula at or above the floor accepts with the Cellar version", + goos: model.PlatformDarwin, + setup: func(m *executor.Mock, _ string) { + m.SetPath("omp", "/opt/homebrew/bin/omp") + addBinary(m, "/opt/homebrew/bin/omp", ompRealBytes) + m.SetSymlink("/opt/homebrew/bin/omp", "/opt/homebrew/Cellar/omp/18.1.10/bin/omp") + }, + want: []aicliWant{{tool: "oh-my-pi", binary: "/opt/homebrew/bin/omp", version: ompVersion}}, + }, + { + name: "(o3r) a tap token is attacker-choosable, so a small Cellar/omp is rejected", + goos: model.PlatformDarwin, + setup: func(m *executor.Mock, _ string) { + m.SetPath("omp", "/opt/homebrew/bin/omp") + addBinary(m, "/opt/homebrew/bin/omp", 2<<20) + m.SetSymlink("/opt/homebrew/bin/omp", "/opt/homebrew/Cellar/omp/18.1.10/bin/omp") + }, + wantDebug: []string{"Homebrew Cellar/omp but under"}, + }, + { + name: "(o4) the mise install tree is globbed; the alias dir resolves to the real version dir", + setup: func(m *executor.Mock, home string) { + root := joinPath(home, ".local", "share", "mise", "installs", "github-can1357-oh-my-pi") + real := joinPath(root, ompVersion) + alias := joinPath(root, "latest") + m.SetGlob(joinPath(root, "*"), []string{real, alias}) + addBinary(m, joinPath(real, "omp"), ompRealBytes) + addFile(m, joinPath(alias, "omp"), []byte{}) + m.SetSymlink(joinPath(alias, "omp"), joinPath(real, "omp")) + // The shim on PATH resolves to mise itself and proves nothing. + shim := joinPath(home, ".local", "share", "mise", "shims", "omp") + m.SetPath("omp", shim) + addFile(m, shim, []byte{}) + m.SetSymlink(shim, joinPath(home, ".local", "share", "mise", "bin", "mise")) + }, + // globDirs sorts descending, so "latest" is probed before "18.1.10"; + // its resolved form is the real dir, and the real dir then dedups. + want: []aicliWant{{tool: "oh-my-pi", binary: "/home/u/.local/share/mise/installs/github-can1357-oh-my-pi/latest/omp", version: ompVersion}}, + wantDebug: []string{"no Oh My Pi channel claims it (resolved /home/u/.local/share/mise/bin/mise)"}, + }, + { + name: "(o4b) a mise version dir alone accepts with that version", + setup: func(m *executor.Mock, home string) { + root := joinPath(home, ".local", "share", "mise", "installs", "github-can1357-oh-my-pi") + real := joinPath(root, ompVersion) + m.SetGlob(joinPath(root, "*"), []string{real}) + addBinary(m, joinPath(real, "omp"), ompRealBytes) + }, + want: []aicliWant{{tool: "oh-my-pi", binary: "/home/u/.local/share/mise/installs/github-can1357-oh-my-pi/18.1.10/omp", version: ompVersion}}, + }, + { + name: "(o4r) a non-version directory under the mise root is rejected", + setup: func(m *executor.Mock, home string) { + root := joinPath(home, ".local", "share", "mise", "installs", "github-can1357-oh-my-pi") + dev := joinPath(root, "dev") + m.SetGlob(joinPath(root, "*"), []string{dev}) + addBinary(m, joinPath(dev, "omp"), ompRealBytes) + }, + wantDebug: []string{"under the mise install root but not in a version directory"}, + }, + { + name: "(o5) the standalone ~/.local/bin/omp at or above the floor accepts with version unknown", + setup: func(m *executor.Mock, home string) { + addBinary(m, joinPath(home, ".local", "bin", "omp"), ompRealBytes) + }, + want: []aicliWant{{tool: "oh-my-pi", binary: "/home/u/.local/bin/omp", version: "unknown"}}, + }, + { + name: "(o5r) a script at the standalone anchor is under the floor and rejected", + setup: func(m *executor.Mock, home string) { + addBinary(m, joinPath(home, ".local", "bin", "omp"), 4<<10) + setConfigDir(m, home, "~/.omp/agent") + }, + wantDebug: []string{"at the standalone anchor but under"}, + }, + { + name: "(o6r) an npm package of another name is rejected by name", + setup: func(m *executor.Mock, _ string) { + m.SetPath("omp", "/usr/local/bin/omp") + addNPMGlobal(m, "/usr/local/bin/omp", "/usr/local/lib/node_modules/omp", "omp", "1.0.0") + }, + wantDebug: []string{`npm package is "omp", not ` + ompPackageName}, + }, + }) +} + +// --------------------------------------------------------------------------- +// Windows-shaped cases +// --------------------------------------------------------------------------- + +func TestAICLIAgents2_Windows(t *testing.T) { + npmDir := `C:\Users\u\AppData\Roaming\npm` + linksDir := `C:\Users\u\AppData\Local\Microsoft\WinGet\Links` + pkgsDir := `C:\Users\u\AppData\Local\Microsoft\WinGet\Packages` + grokBin := `C:\Users\u\.grok\bin` + hermesVenv := `C:\Users\u\AppData\Local\hermes\hermes-agent\venv` + + runAICLICases(t, []aicliCase{ + { + name: "(w1) the grok.cmd npm shim accepts", + goos: model.PlatformWindows, + setup: func(m *executor.Mock, _ string) { + addFile(m, npmDir+`\grok.cmd`, winNPMShim(`node_modules\@xai-official\grok\dist\cli.js`)) + addManifest(m, npmDir+`\node_modules\@xai-official\grok`, grokPackageName, "1.0.13") + }, + want: []aicliWant{{tool: "grok-build", binary: npmDir + `\grok.cmd`, version: "1.0.13"}}, + }, + { + name: "(w2) the home copy takes its version from the single same-size versioned sibling", + goos: model.PlatformWindows, + setup: func(m *executor.Mock, home string) { + addBinary(m, grokBin+`\grok.exe`, 95_000_000) + addBinary(m, grokBin+`\grok-1.0.13.exe`, 95_000_000) + m.SetGlob(grokBin+`\grok-*.exe`, []string{grokBin + `\grok-1.0.13.exe`}) + setConfigDir(m, home, "~/.grok") + }, + allowGlobs: []string{grokBin + `\grok-*.exe`}, + want: []aicliWant{{tool: "grok-build", binary: grokBin + `\grok.exe`, version: "1.0.13", configRel: "~/.grok"}}, + }, + { + name: "(w2b) two versioned siblings leave the copy's version unknown", + goos: model.PlatformWindows, + setup: func(m *executor.Mock, _ string) { + addBinary(m, grokBin+`\grok.exe`, 95_000_000) + addBinary(m, grokBin+`\grok-1.0.12.exe`, 94_000_000) + addBinary(m, grokBin+`\grok-1.0.13.exe`, 95_000_000) + m.SetGlob(grokBin+`\grok-*.exe`, []string{grokBin + `\grok-1.0.12.exe`, grokBin + `\grok-1.0.13.exe`}) + }, + allowGlobs: []string{grokBin + `\grok-*.exe`}, + want: []aicliWant{{tool: "grok-build", binary: grokBin + `\grok.exe`, version: "unknown"}}, + wantDebug: []string{"2 versioned grok-*.exe siblings"}, + }, + { + name: "(w2c) a single sibling of a different size is not the copy's source", + goos: model.PlatformWindows, + setup: func(m *executor.Mock, _ string) { + addBinary(m, grokBin+`\grok.exe`, 95_000_000) + addBinary(m, grokBin+`\grok-1.0.13.exe`, 94_000_000) + m.SetGlob(grokBin+`\grok-*.exe`, []string{grokBin + `\grok-1.0.13.exe`}) + }, + allowGlobs: []string{grokBin + `\grok-*.exe`}, + want: []aicliWant{{tool: "grok-build", binary: grokBin + `\grok.exe`, version: "unknown"}}, + wantDebug: []string{"is not the same size as"}, + }, + { + name: "(w3) winget Grok Build accepts through the Links shim", + goos: model.PlatformWindows, + setup: func(m *executor.Mock, _ string) { + addFile(m, linksDir+`\grok.exe`, []byte{}) + m.SetSymlink(linksDir+`\grok.exe`, pkgsDir+`\xAI.GrokBuild_Microsoft.Winget.Source_8wekyb3d8bbwe\grok.exe`) + }, + want: []aicliWant{{tool: "grok-build", binary: linksDir + `\grok.exe`, version: "unknown"}}, + }, + { + name: "(w4) the Kimi installer .exe at or above the floor accepts", + goos: model.PlatformWindows, + setup: func(m *executor.Mock, home string) { + addBinary(m, `C:\Users\u\.kimi-code\bin\kimi.exe`, kimiRealBytes) + setConfigDir(m, home, "~/.kimi-code") + }, + want: []aicliWant{{tool: "kimi-code", binary: `C:\Users\u\.kimi-code\bin\kimi.exe`, version: "unknown", configRel: "~/.kimi-code"}}, + }, + { + name: "(w5) winget Kimi accepts under either identifier", + goos: model.PlatformWindows, + setup: func(m *executor.Mock, _ string) { + addFile(m, linksDir+`\kimi.exe`, []byte{}) + m.SetSymlink(linksDir+`\kimi.exe`, pkgsDir+`\MoonshotAI.KimiCLI_Microsoft.Winget.Source_8wekyb3d8bbwe\kimi.exe`) + }, + want: []aicliWant{{tool: "kimi-code", binary: linksDir + `\kimi.exe`, version: "unknown"}}, + }, + { + name: "(w5b) the newer winget identifier too", + goos: model.PlatformWindows, + setup: func(m *executor.Mock, _ string) { + addFile(m, linksDir+`\kimi.exe`, []byte{}) + m.SetSymlink(linksDir+`\kimi.exe`, pkgsDir+`\MoonshotAI.KimiCodeCLI_Microsoft.Winget.Source_8wekyb3d8bbwe\kimi.exe`) + }, + want: []aicliWant{{tool: "kimi-code", binary: linksDir + `\kimi.exe`, version: "unknown"}}, + }, + { + name: "(w5r) another publisher's kimi in WinGet is rejected", + goos: model.PlatformWindows, + setup: func(m *executor.Mock, _ string) { + addFile(m, linksDir+`\kimi.exe`, []byte{}) + m.SetSymlink(linksDir+`\kimi.exe`, pkgsDir+`\SomeoneElse.Kimi_Microsoft.Winget.Source_8wekyb3d8bbwe\kimi.exe`) + }, + wantDebug: []string{"no Kimi Code channel claims it"}, + }, + { + name: "(w6) the legacy Kimi CLI's uv venv under %LOCALAPPDATA% reads Lib\\site-packages", + goos: model.PlatformWindows, + setup: func(m *executor.Mock, home string) { + venv := joinPath(home, "AppData", "Local", "uv", "tools", "kimi-cli") + addFile(m, npmDir+`\kimi.cmd`, []byte("@echo off\r\n")) + m.SetSymlink(npmDir+`\kimi.cmd`, venv+`\Scripts\kimi.exe`) + addDistInfo(m, model.PlatformWindows, venv, "kimi_cli", "1.49.0") + }, + allowGlobs: []string{distInfoGlob(model.PlatformWindows, `C:\Users\u\AppData\Local\uv\tools\kimi-cli`, "kimi_cli")}, + want: []aicliWant{{tool: "kimi-code", binary: npmDir + `\kimi.cmd`, version: "1.49.0"}}, + }, + { + name: "(w7) hermes.exe with the %LOCALAPPDATA% venv accepts", + goos: model.PlatformWindows, + setup: func(m *executor.Mock, home string) { + addFile(m, `C:\Users\u\AppData\Local\hermes\bin\hermes.exe`, []byte{}) + m.SetDir(hermesVenv) + addDistInfo(m, model.PlatformWindows, hermesVenv, "hermes_agent", hermesVersion) + setConfigDir(m, home, "~/AppData/Local/hermes") + }, + allowGlobs: []string{distInfoGlob(model.PlatformWindows, hermesVenv, "hermes_agent")}, + want: []aicliWant{{ + tool: "hermes-agent", binary: `C:\Users\u\AppData\Local\hermes\bin\hermes.exe`, + version: hermesVersion, configRel: "~/AppData/Local/hermes", + }}, + }, + { + name: "(w7b) the hermes.cmd variant accepts too", + goos: model.PlatformWindows, + setup: func(m *executor.Mock, _ string) { + addFile(m, `C:\Users\u\AppData\Local\hermes\bin\hermes.cmd`, []byte("@echo off\r\n")) + m.SetDir(hermesVenv) + addDistInfo(m, model.PlatformWindows, hermesVenv, "hermes_agent", hermesVersion) + }, + allowGlobs: []string{distInfoGlob(model.PlatformWindows, hermesVenv, "hermes_agent")}, + want: []aicliWant{{tool: "hermes-agent", binary: `C:\Users\u\AppData\Local\hermes\bin\hermes.cmd`, version: hermesVersion}}, + }, + { + name: "(w7r) hermes.exe without the venv is rejected", + goos: model.PlatformWindows, + setup: func(m *executor.Mock, _ string) { + addFile(m, `C:\Users\u\AppData\Local\hermes\bin\hermes.exe`, []byte{}) + }, + wantDebug: []string{"the installer launcher is there but " + hermesVenv + " is not"}, + }, + { + name: "(w8) the omp.cmd npm shim accepts even though its runner is bun.exe", + goos: model.PlatformWindows, + setup: func(m *executor.Mock, _ string) { + addFile(m, npmDir+`\omp.cmd`, []byte("@ECHO off\r\n\"%dp0%\\bun.exe\" \"%dp0%\\node_modules\\@oh-my-pi\\pi-coding-agent\\dist\\cli.js\" %*\r\n")) + addManifest(m, npmDir+`\node_modules\@oh-my-pi\pi-coding-agent`, ompPackageName, ompVersion) + }, + want: []aicliWant{{tool: "oh-my-pi", binary: npmDir + `\omp.cmd`, version: ompVersion}}, + }, + { + name: "(w9) the Bun .exe is identified through its .bunx pointer", + goos: model.PlatformWindows, + setup: func(m *executor.Mock, home string) { + bunBin := joinPath(home, ".bun", "bin") + pkgRoot := joinPath(home, ".bun", "install", "global", "node_modules", "@oh-my-pi", "pi-coding-agent") + addFile(m, bunBin+`\omp.exe`, []byte{}) + addFile(m, bunBin+`\omp.bunx`, utf16LE(pkgRoot+`\dist\cli.js`)) + addManifest(m, pkgRoot, ompPackageName, ompVersion) + }, + want: []aicliWant{{tool: "oh-my-pi", binary: `C:\Users\u\.bun\bin\omp.exe`, version: ompVersion}}, + }, + { + name: "(w10) the standalone %LOCALAPPDATA%\\omp\\omp.exe at or above the floor accepts", + goos: model.PlatformWindows, + setup: func(m *executor.Mock, home string) { + addBinary(m, `C:\Users\u\AppData\Local\omp\omp.exe`, ompRealBytes) + setConfigDir(m, home, "~/.omp/agent") + }, + want: []aicliWant{{tool: "oh-my-pi", binary: `C:\Users\u\AppData\Local\omp\omp.exe`, version: "unknown", configRel: "~/.omp/agent"}}, + }, + { + name: "(w11) winget Oh My Pi accepts", + goos: model.PlatformWindows, + setup: func(m *executor.Mock, _ string) { + addFile(m, linksDir+`\omp.exe`, []byte{}) + m.SetSymlink(linksDir+`\omp.exe`, pkgsDir+`\can1357.oh-my-pi_Microsoft.Winget.Source_8wekyb3d8bbwe\omp.exe`) + }, + want: []aicliWant{{tool: "oh-my-pi", binary: linksDir + `\omp.exe`, version: "unknown"}}, + }, + }) +} + +// --------------------------------------------------------------------------- +// TCC guard: one decoy per new binary name under ~/Documents, and a symlink from +// an accepted anchor into ~/Downloads. Each decoy satisfies an accept rule, so a +// green reject is the guard firing and not a ladder miss. +// --------------------------------------------------------------------------- + +func TestAICLIAgents2_TCCGuard(t *testing.T) { + requireDarwinHost(t) + docs := "/Users/u/Documents" + + decoys := []struct { + bin string + setup func(m *executor.Mock, home, dir string) + }{ + {"grok", func(m *executor.Mock, _, dir string) { + addNPMGlobal(m, dir+"/grok", dir+"/node_modules/@xai-official/grok", grokPackageName, "1.0.13") + }}, + {"kimi", func(m *executor.Mock, _, dir string) { + addNPMGlobal(m, dir+"/kimi", dir+"/node_modules/@moonshot-ai/kimi-code", kimiPackageName, "0.12.0") + }}, + {"muse", func(m *executor.Mock, _, dir string) { + addMuseInstall(m, dir, museVersion) + }}, + {"hermes", func(m *executor.Mock, _, dir string) { + addFile(m, dir+"/hermes", []byte("#!/bin/bash\n")) + }}, + {"omp", func(m *executor.Mock, _, dir string) { + addNPMGlobal(m, dir+"/omp", dir+"/node_modules/@oh-my-pi/pi-coding-agent", ompPackageName, ompVersion) + }}, + } + for _, d := range decoys { + t.Run("~/Documents/bin/"+d.bin+" on PATH is never touched", func(t *testing.T) { + runAICLICase(t, aicliCase{ + name: d.bin, + goos: model.PlatformDarwin, + skipper: true, + setup: func(m *executor.Mock, home string) { + dir := joinPath(home, "Documents", "bin") + m.SetPath(d.bin, dir+"/"+d.bin) + d.setup(m, home, dir) + }, + noReadPrefix: []string{docs}, + wantDebug: []string{"under a macOS TCC-protected path"}, + }) + }) + } + + t.Run("~/.local/bin/muse -> ~/Downloads/muse is rejected before its sidecar is read", func(t *testing.T) { + runAICLICase(t, aicliCase{ + name: "symlink into Downloads", + goos: model.PlatformDarwin, + skipper: true, + setup: func(m *executor.Mock, home string) { + addMuseInstall(m, joinPath(home, "Downloads"), museVersion) + link := joinPath(home, ".local", "bin", "muse") + addFile(m, link, []byte{}) + m.SetSymlink(link, joinPath(home, "Downloads", "muse")) + }, + noReadPrefix: []string{"/Users/u/Downloads"}, + wantDebug: []string{"under a macOS TCC-protected path"}, + }) + }) + + t.Run("~/.local/bin/grok -> ~/Downloads/grok-1.0.13 is rejected; the same link into ~/.grok accepts", func(t *testing.T) { + runAICLICase(t, aicliCase{ + name: "grok symlink into Downloads", + goos: model.PlatformDarwin, + skipper: true, + setup: func(m *executor.Mock, home string) { + link := joinPath(home, ".local", "bin", "grok") + addFile(m, link, []byte{}) + m.SetSymlink(link, joinPath(home, "Downloads", "grok-1.0.13")) + }, + noReadPrefix: []string{"/Users/u/Downloads"}, + wantDebug: []string{"under a macOS TCC-protected path"}, + }) + runAICLICase(t, aicliCase{ + name: "grok symlink into ~/.grok", + goos: model.PlatformDarwin, + skipper: true, + setup: func(m *executor.Mock, home string) { + link := joinPath(home, ".local", "bin", "grok") + addFile(m, link, []byte{}) + m.SetSymlink(link, joinPath(home, ".grok", "bin", "grok-1.0.13")) + }, + want: []aicliWant{{tool: "grok-build", binary: "/Users/u/.local/bin/grok", version: "1.0.13"}}, + }) + }) +} + +// TestAICLIAgents2_EmptyFixture: the five new specs produce nothing on the empty +// fixture on every platform, and nothing is read outside the candidate probes. +func TestAICLIAgents2_EmptyFixture(t *testing.T) { + for _, goos := range []string{model.PlatformLinux, model.PlatformDarwin, model.PlatformWindows} { + t.Run(goos, func(t *testing.T) { + m, _ := newAICLIMock(goos) + rec := &recExec{Mock: m, t: t, trapExec: true} + var tools []model.AITool + captureStderr(t, func() { tools = NewAICLIDetector(rec).Detect(context.Background()) }) + if len(tools) != 0 { + t.Errorf("empty fixture: got %d rows, want 0; %+v", len(tools), tools) + } + }) + } +} diff --git a/internal/detector/ide.go b/internal/detector/ide.go index e3b4b758..dc7118de 100644 --- a/internal/detector/ide.go +++ b/internal/detector/ide.go @@ -10,6 +10,8 @@ import ( "github.com/step-security/dev-machine-guard/internal/execguard" "github.com/step-security/dev-machine-guard/internal/executor" "github.com/step-security/dev-machine-guard/internal/model" + "github.com/step-security/dev-machine-guard/internal/versionmeta" + "howett.net/plist" ) type ideSpec struct { @@ -25,6 +27,10 @@ type ideSpec struct { LinuxBinary string // Linux: binary name to search in PATH (LookPath) VersionFlag string RegistryName string // Windows: override for registry search if DisplayName differs from AppName + + // ResolveFunc, when set, owns discovery, identity and version for this + // IDE; the generic per-platform ladder never runs for it. + ResolveFunc func(ctx context.Context, exec executor.Executor, spec ideSpec) (model.IDE, bool) } // registrySearchName returns the name to use for registry searches. @@ -94,6 +100,15 @@ var ideDefinitions = []ideSpec{ LinuxBinary: "antigravity-ide", VersionFlag: "--version", }, + { + AppName: "Kiro", IDEType: "kiro", Vendor: "Amazon", + // VS Code fork whose name prefixes "Kiro CLI"; resolveKiroIDE proves + // identity from bundle/package metadata at fixed roots. Never launched. + AppPath: "/Applications/Kiro.app", BinaryPath: "Contents/Resources/app/bin/code", + WinPaths: []string{`%LOCALAPPDATA%\Programs\Kiro`}, WinBinary: `Kiro.exe`, + LinuxPaths: []string{"/usr/share/kiro"}, LinuxBinary: "kiro", + ResolveFunc: resolveKiroIDE, + }, { AppName: "Zed", IDEType: "zed", Vendor: "Zed", AppPath: "/Applications/Zed.app", BinaryPath: "Contents/MacOS/zed", @@ -226,6 +241,12 @@ func (d *IDEDetector) Detect(ctx context.Context) []model.IDE { var results []model.IDE for _, spec := range ideDefinitions { + if spec.ResolveFunc != nil { + if ide, ok := spec.ResolveFunc(ctx, d.exec, spec); ok { + results = append(results, ide) + } + continue + } switch d.exec.GOOS() { case model.PlatformWindows: if ide, ok := d.detectWindows(ctx, spec); ok { @@ -669,6 +690,90 @@ func readPlistVersion(ctx context.Context, exec executor.Executor, plistPath str return "unknown" } +// readBundleInfo reads CFBundleIdentifier and CFBundleShortVersionString from +// an .app's Info.plist in-process (XML or binary), so proving a bundle's +// identity launches nothing. ok is false when the file is missing, over 1 MiB, +// unparsable or has no identifier; shortVersion may be "" with ok true. +func readBundleInfo(exec executor.Executor, plistPath string) (bundleID, shortVersion string, ok bool) { + if !regularFileWithin(exec, plistPath, 1<<20) { + return "", "", false + } + data, err := exec.ReadFile(plistPath) + if err != nil { + return "", "", false + } + var info struct { + BundleID string `plist:"CFBundleIdentifier"` + ShortVersion string `plist:"CFBundleShortVersionString"` + } + if _, err := plist.Unmarshal(data, &info); err != nil || info.BundleID == "" { + return "", "", false + } + return info.BundleID, info.ShortVersion, true +} + +// resolveKiroIDE is the Kiro IDE's ResolveFunc. Kiro's name prefixes "Kiro +// CLI", so the generic ladder (PATH names, .desktop files, Windows Uninstall +// DisplayName substrings) could return the wrong product; instead only the +// fixed root per platform is accepted, and only once its own metadata names +// Kiro. Every path touched is first proven link-free from the root down, so +// nothing is ever followed into a location the fixed root does not own. +// Nothing here is exec'd. +func resolveKiroIDE(_ context.Context, exec executor.Executor, spec ideSpec) (model.IDE, bool) { + root, binary, meta := spec.LinuxPaths[0], spec.LinuxBinary, "resources/app/package.json" + switch exec.GOOS() { + case model.PlatformDarwin: + root, binary, meta = spec.AppPath, spec.BinaryPath, "Contents/Info.plist" + case model.PlatformWindows: + root, binary = resolveEnvPath(exec, spec.WinPaths[0]), spec.WinBinary + } + if !linkFreeUnder(exec, root, binary) || !linkFreeUnder(exec, root, meta) { + return model.IDE{}, false + } + if !exec.FileExists(filepath.Join(root, binary)) { + return model.IDE{}, false + } + + var version string + if exec.GOOS() == model.PlatformDarwin { + id, shortVersion, ok := readBundleInfo(exec, filepath.Join(root, meta)) + if !ok || id != "dev.kiro.desktop" { + return model.IDE{}, false + } + version = shortVersion + } else if name, v := readNPMManifest(exec, filepath.Join(root, "resources", "app"), siblingManifestMaxBytes); name == "Kiro" { + version = v + } else { + return model.IDE{}, false + } + if !versionmeta.IsVersionLike(version) { + version = "unknown" + } + return model.IDE{ + IDEType: spec.IDEType, Version: version, InstallPath: root, + Vendor: spec.Vendor, IsInstalled: true, + }, true +} + +// linkFreeUnder reports whether root and each component of rel beneath it is +// not a symlink or junction, checked top-down with Readlink — which does not +// follow its final component — so a link is seen before anything is resolved +// through it. Absent components pass (Readlink fails on them too); the caller's +// own Stat decides existence afterwards, on a path now known to be plain. +func linkFreeUnder(exec executor.Executor, root, rel string) bool { + p := root + if _, err := exec.Readlink(p); err == nil { + return false + } + for _, part := range splitPathAny(rel) { + p = filepath.Join(p, part) + if _, err := exec.Readlink(p); err == nil { + return false + } + } + return true +} + // registryInstallInfo holds version and install path from Windows Uninstall registry keys. type registryInstallInfo struct { Version string diff --git a/internal/detector/ide_test.go b/internal/detector/ide_test.go index cc4d0bbb..99ca6341 100644 --- a/internal/detector/ide_test.go +++ b/internal/detector/ide_test.go @@ -3,7 +3,11 @@ package detector import ( "context" "fmt" + "os" + "path/filepath" + "strings" "testing" + "time" "github.com/step-security/dev-machine-guard/internal/executor" "github.com/step-security/dev-machine-guard/internal/model" @@ -1025,3 +1029,310 @@ func TestIDEDetector_Linux_ExecguardAllowsCLIShimInsideBundle(t *testing.T) { t.Errorf("version = %q, want 1.12.4 from the CLI shim", found.Version) } } + +// ideRunRecorder records every command the IDE detector launches and every +// file it reads. Kiro resolves from metadata alone, so nothing it does may +// exec — and on Windows, where the other specs' registry fallbacks +// legitimately run `reg query`, no query may name Kiro. +type ideRunRecorder struct { + *executor.Mock + runs []string + reads []string +} + +// Everything that follows a path — stat or read — lands in reads; Readlink, +// which does not follow, deliberately does not. +func (r *ideRunRecorder) ReadFile(path string) ([]byte, error) { + r.reads = append(r.reads, path) + return r.Mock.ReadFile(path) +} + +func (r *ideRunRecorder) Stat(path string) (os.FileInfo, error) { + r.reads = append(r.reads, path) + return r.Mock.Stat(path) +} + +func (r *ideRunRecorder) FileExists(path string) bool { + r.reads = append(r.reads, path) + return r.Mock.FileExists(path) +} + +func (r *ideRunRecorder) DirExists(path string) bool { + r.reads = append(r.reads, path) + return r.Mock.DirExists(path) +} + +func (r *ideRunRecorder) EvalSymlinks(path string) (string, error) { + r.reads = append(r.reads, path) + return r.Mock.EvalSymlinks(path) +} + +func (r *ideRunRecorder) record(name string, args []string) { + r.runs = append(r.runs, name+" "+strings.Join(args, " ")) +} + +func (r *ideRunRecorder) Run(ctx context.Context, name string, args ...string) (string, string, int, error) { + r.record(name, args) + return r.Mock.Run(ctx, name, args...) +} + +func (r *ideRunRecorder) RunWithTimeout(ctx context.Context, d time.Duration, name string, args ...string) (string, string, int, error) { + r.record(name, args) + return r.Mock.RunWithTimeout(ctx, d, name, args...) +} + +const kiroIDEPackageJSON = `{"name":"Kiro","version":"1.0.437","private":true}` + +func TestIDEDetector_Kiro_Darwin(t *testing.T) { + mock := executor.NewMock() + mock.SetDir("/Applications/Kiro.app") + mock.SetFile("/Applications/Kiro.app/Contents/Info.plist", kiroPlist("dev.kiro.desktop", "1.0.437")) + mock.SetFile("/Applications/Kiro.app/Contents/Resources/app/bin/code", []byte{}) + + rec := &ideRunRecorder{Mock: mock} + results := NewIDEDetector(rec).Detect(context.Background()) + + kiro := findIDE(results, "kiro") + if kiro == nil { + t.Fatalf("Kiro not detected; results=%+v", results) + } + if kiro.Version != "1.0.437" || kiro.Vendor != "Amazon" || kiro.InstallPath != "/Applications/Kiro.app" || !kiro.IsInstalled { + t.Errorf("unexpected record %+v", kiro) + } + if len(rec.runs) != 0 { + t.Errorf("Kiro detection must launch nothing, ran %v", rec.runs) + } +} + +func TestIDEDetector_Kiro_Darwin_IdentityRequired(t *testing.T) { + const shim = "/Applications/Kiro.app/Contents/Resources/app/bin/code" + cases := []struct { + name string + setup func(m *executor.Mock) + wantVersion string // "" = must not be detected + }{ + {"wrong bundle identifier", func(m *executor.Mock) { + m.SetFile("/Applications/Kiro.app/Contents/Info.plist", kiroPlist("com.example.impostor", "1.0.437")) + m.SetFile(shim, []byte{}) + }, ""}, + {"bundle dir only", func(m *executor.Mock) {}, ""}, + {"plist ok but no CLI shim", func(m *executor.Mock) { + m.SetFile("/Applications/Kiro.app/Contents/Info.plist", kiroPlist("dev.kiro.desktop", "1.0.437")) + }, ""}, + {"plist without short version", func(m *executor.Mock) { + m.SetFile("/Applications/Kiro.app/Contents/Info.plist", kiroPlist("dev.kiro.desktop", "")) + m.SetFile(shim, []byte{}) + }, "unknown"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + mock := executor.NewMock() + mock.SetDir("/Applications/Kiro.app") + tc.setup(mock) + rec := &ideRunRecorder{Mock: mock} + kiro := findIDE(NewIDEDetector(rec).Detect(context.Background()), "kiro") + switch { + case tc.wantVersion == "" && kiro != nil: + t.Errorf("Kiro must not be accepted; got %+v", kiro) + case tc.wantVersion != "" && kiro == nil: + t.Errorf("Kiro not detected") + case kiro != nil && kiro.Version != tc.wantVersion: + t.Errorf("version=%q want %q", kiro.Version, tc.wantVersion) + } + if len(rec.runs) != 0 { + t.Errorf("must never be exec'd for a version, ran %v", rec.runs) + } + }) + } +} + +// TestIDEDetector_Kiro_LinkedComponentRejected: a link anywhere on a path +// Kiro's resolver would touch — the root, an intermediate directory, the +// metadata file, the binary — makes the layout unsupported. The link could +// point anywhere, including a TCC-protected folder, so it must be seen with +// Readlink and nothing at or below it may be followed (stat'd or read). +func TestIDEDetector_Kiro_LinkedComponentRejected(t *testing.T) { + const winRoot = `C:\Users\testuser\AppData\Local\Programs\Kiro` + cases := []struct { + name, goos, link string + junction bool + setup func(m *executor.Mock) + }{ + {"darwin root", "darwin", "/Applications/Kiro.app", false, kiroDarwinFixture}, + {"darwin Contents dir", "darwin", "/Applications/Kiro.app/Contents", false, kiroDarwinFixture}, + {"darwin Info.plist", "darwin", "/Applications/Kiro.app/Contents/Info.plist", false, kiroDarwinFixture}, + {"darwin CLI shim", "darwin", "/Applications/Kiro.app/Contents/Resources/app/bin/code", false, kiroDarwinFixture}, + {"windows root junction", "windows", winRoot, true, kiroWindowsFixture}, + {"windows resources junction", "windows", filepath.Join(winRoot, "resources"), true, kiroWindowsFixture}, + {"linux app dir", "linux", "/usr/share/kiro/resources/app", false, kiroLinuxFixture}, + {"linux package.json", "linux", "/usr/share/kiro/resources/app/package.json", false, kiroLinuxFixture}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS(tc.goos) + mock.SetEnv("LOCALAPPDATA", `C:\Users\testuser\AppData\Local`) + tc.setup(mock) + target := "/Users/testuser/Downloads/elsewhere" + if tc.junction { + target = `\??\D:\elsewhere` + } + mock.SetReadlink(tc.link, target) + + rec := &ideRunRecorder{Mock: mock} + if results := NewIDEDetector(rec).Detect(context.Background()); findIDE(results, "kiro") != nil { + t.Errorf("a linked %s must be rejected; results=%+v", tc.name, results) + } + for _, p := range rec.reads { + if p == tc.link || strings.HasPrefix(p, tc.link+"/") || strings.HasPrefix(p, tc.link+`\`) { + t.Errorf("followed %q at or below the link %q", p, tc.link) + } + } + // Other specs' registry fallbacks legitimately run on Windows. + for _, run := range rec.runs { + if tc.goos != "windows" || strings.Contains(run, "Kiro") { + t.Errorf("Kiro must launch nothing, ran %q", run) + } + } + }) + } +} + +func kiroDarwinFixture(m *executor.Mock) { + m.SetDir("/Applications/Kiro.app") + m.SetFile("/Applications/Kiro.app/Contents/Info.plist", kiroPlist("dev.kiro.desktop", "1.0.437")) + m.SetFile("/Applications/Kiro.app/Contents/Resources/app/bin/code", []byte{}) +} + +func kiroWindowsFixture(m *executor.Mock) { + dir := `C:\Users\testuser\AppData\Local\Programs\Kiro` + m.SetDir(dir) + m.SetFile(filepath.Join(dir, "Kiro.exe"), []byte{}) + m.SetFile(filepath.Join(dir, "resources", "app", "package.json"), []byte(kiroIDEPackageJSON)) +} + +func kiroLinuxFixture(m *executor.Mock) { + m.SetDir("/usr/share/kiro") + m.SetFile("/usr/share/kiro/kiro", []byte{}) + m.SetFile("/usr/share/kiro/resources/app/package.json", []byte(kiroIDEPackageJSON)) +} + +func TestIDEDetector_Kiro_Windows_RegistryNeverConsulted(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("windows") + mock.SetEnv("LOCALAPPDATA", `C:\Users\testuser\AppData\Local`) + mock.SetEnv("PROGRAMFILES", `C:\Program Files`) + dir := `C:\Users\testuser\AppData\Local\Programs\Kiro` + mock.SetDir(dir) + mock.SetFile(filepath.Join(dir, "Kiro.exe"), []byte{}) + mock.SetFile(filepath.Join(dir, "resources", "app", "package.json"), []byte(kiroIDEPackageJSON)) + // The machine also has the Kiro CLI, whose Uninstall row "Kiro CLI" is a + // substring match for "Kiro" and carries the CLI's version. It must never + // be read for the IDE. + for _, root := range []string{ + `HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall`, + `HKLM\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall`, + `HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall`, + } { + mock.SetCommand(root+"\\{kiro-cli}\n DisplayName REG_SZ Kiro CLI\n DisplayVersion REG_SZ 2.21.1.0\n InstallLocation REG_SZ \n", "", 0, + "reg", "query", root, "/s", "/f", "Kiro", "/d") + } + + rec := &ideRunRecorder{Mock: mock} + results := NewIDEDetector(rec).Detect(context.Background()) + + kiro := findIDE(results, "kiro") + if kiro == nil { + t.Fatalf("Kiro not detected; results=%+v", results) + } + if kiro.Version != "1.0.437" || kiro.InstallPath != dir { + t.Errorf("version=%q install=%q, want 1.0.437 at %s", kiro.Version, kiro.InstallPath, dir) + } + for _, run := range rec.runs { + if strings.Contains(run, "Kiro") { + t.Errorf("Kiro must never reach the registry or an exec, ran %q", run) + } + } +} + +func TestIDEDetector_Kiro_Windows_NoRegistryDiscovery(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("windows") + mock.SetEnv("LOCALAPPDATA", `C:\Users\testuser\AppData\Local`) + mock.SetEnv("PROGRAMFILES", `C:\Program Files`) + // No install dir. A registry row that would satisfy the generic Phase 2 + // discovery exists at a custom path; Kiro's resolver never consults it. + custom := `D:\Tools\Kiro` + mock.SetDir(custom) + mock.SetFile(filepath.Join(custom, "Kiro.exe"), []byte{}) + mock.SetFile(filepath.Join(custom, "resources", "app", "package.json"), []byte(kiroIDEPackageJSON)) + mock.SetCommand("HKCU\\...\\{kiro}\n DisplayName REG_SZ Kiro (User)\n DisplayVersion REG_SZ 1.0.437\n InstallLocation REG_SZ "+custom+"\n", "", 0, + "reg", "query", `HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall`, "/s", "/f", "Kiro", "/d") + + rec := &ideRunRecorder{Mock: mock} + results := NewIDEDetector(rec).Detect(context.Background()) + if findIDE(results, "kiro") != nil { + t.Errorf("Kiro must not be discovered through the registry; results=%+v", results) + } + for _, run := range rec.runs { + if strings.Contains(run, "Kiro") { + t.Errorf("no registry query may name Kiro, ran %q", run) + } + } +} + +func TestIDEDetector_Kiro_Windows_IdentityRequired(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("windows") + mock.SetEnv("LOCALAPPDATA", `C:\Users\testuser\AppData\Local`) + mock.SetEnv("PROGRAMFILES", `C:\Program Files`) + dir := `C:\Users\testuser\AppData\Local\Programs\Kiro` + mock.SetDir(dir) + mock.SetFile(filepath.Join(dir, "resources", "app", "package.json"), []byte(kiroIDEPackageJSON)) + // package.json says Kiro, but there is no Kiro.exe: a leftover folder. + if results := NewIDEDetector(&ideRunRecorder{Mock: mock}).Detect(context.Background()); findIDE(results, "kiro") != nil { + t.Errorf("a root without Kiro.exe is not an install; results=%+v", results) + } +} + +func TestIDEDetector_Kiro_Linux_InstallDir(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("linux") + mock.SetDir("/usr/share/kiro") + mock.SetFile("/usr/share/kiro/kiro", []byte{}) + mock.SetFile("/usr/share/kiro/resources/app/package.json", []byte(kiroIDEPackageJSON)) + + rec := &ideRunRecorder{Mock: mock} + results := NewIDEDetector(rec).Detect(context.Background()) + kiro := findIDE(results, "kiro") + if kiro == nil { + t.Fatalf("Kiro not detected; results=%+v", results) + } + if kiro.Version != "1.0.437" || kiro.InstallPath != "/usr/share/kiro" { + t.Errorf("version=%q install=%q", kiro.Version, kiro.InstallPath) + } + if len(rec.runs) != 0 { + t.Errorf("Kiro detection must launch nothing, ran %v", rec.runs) + } +} + +func TestIDEDetector_Kiro_Linux_PATHOnlyNotDiscovered(t *testing.T) { + mock := executor.NewMock() + mock.SetGOOS("linux") + // A relocated tree reachable only through a PATH launcher: the .deb's + // /usr/share/kiro root is absent. Kiro is fixed-root only, so this is not + // discovered and, having no VersionFlag, is never launched. + mock.SetPath("kiro", "/usr/bin/kiro") + mock.SetSymlink("/usr/bin/kiro", "/opt/kiro/bin/kiro") + mock.SetDir("/opt/kiro") + mock.SetFile("/opt/kiro/kiro", []byte{}) + mock.SetFile("/opt/kiro/resources/app/package.json", []byte(kiroIDEPackageJSON)) + + rec := &ideRunRecorder{Mock: mock} + if results := NewIDEDetector(rec).Detect(context.Background()); findIDE(results, "kiro") != nil { + t.Errorf("a PATH-only tree must not be accepted; results=%+v", results) + } + if len(rec.runs) != 0 { + t.Errorf("must launch nothing, ran %v", rec.runs) + } +} diff --git a/internal/detector/registry_other.go b/internal/detector/registry_other.go index 8e627249..e1567a79 100644 --- a/internal/detector/registry_other.go +++ b/internal/detector/registry_other.go @@ -57,3 +57,30 @@ func readRegistryVersion(ctx context.Context, exec executor.Executor, appName st } return "unknown" } + +// readKiroCLIRegistry is the non-Windows twin of the native reader: one +// `reg query HKCU\SOFTWARE\Kiro\CLI` whose InstallPath / ProductVersion lines +// are parsed. Only mock-based tests running with SetGOOS("windows") reach it. +func readKiroCLIRegistry(ctx context.Context, exec executor.Executor) (installPath, productVersion string, ok bool) { + stdout, _, _, err := exec.Run(ctx, "reg", "query", `HKCU\SOFTWARE\Kiro\CLI`) + if err != nil { + return "", "", false + } + for _, line := range strings.Split(stdout, "\n") { + fields := strings.Fields(line) + if len(fields) < 3 || fields[1] != "REG_SZ" { + continue + } + value := strings.TrimSpace(strings.SplitN(strings.TrimSpace(line), "REG_SZ", 2)[1]) + switch fields[0] { + case "InstallPath": + installPath = value + case "ProductVersion": + productVersion = value + } + } + if installPath == "" { + return "", "", false + } + return installPath, productVersion, true +} diff --git a/internal/detector/registry_windows.go b/internal/detector/registry_windows.go index 9462edb1..164f50d7 100644 --- a/internal/detector/registry_windows.go +++ b/internal/detector/registry_windows.go @@ -71,3 +71,31 @@ func readRegistryVersion(ctx context.Context, exec executor.Executor, appName st } return "unknown" } + +// readKiroCLIRegistry reads the Kiro CLI installer's own key, +// HKCU\SOFTWARE\Kiro\CLI, returning InstallPath and ProductVersion. ok is +// false when the key or InstallPath is absent. HKCU only: Windows scans always +// run as the interactive user (NewUserAwareExecutor returns the plain executor +// on Windows and a SYSTEM-context inline scan is refused), so HKCU is the +// scanned user's hive by construction. Uninstall rows are deliberately not +// consulted — the CLI's row carries an empty InstallLocation, and its +// "Kiro CLI" DisplayName substring-matches the "Kiro" IDE. +func readKiroCLIRegistry(_ context.Context, _ executor.Executor) (installPath, productVersion string, ok bool) { + return readKiroCLIKey(registry.CURRENT_USER, `SOFTWARE\Kiro\CLI`) +} + +// readKiroCLIKey is readKiroCLIRegistry with the key injectable, so the native +// test can point it at a scratch key instead of the real installer's. +func readKiroCLIKey(root registry.Key, path string) (installPath, productVersion string, ok bool) { + k, err := registry.OpenKey(root, path, registry.QUERY_VALUE) + if err != nil { + return "", "", false + } + defer func() { _ = k.Close() }() + installPath, _, err = k.GetStringValue("InstallPath") + if err != nil || installPath == "" { + return "", "", false + } + productVersion, _, _ = k.GetStringValue("ProductVersion") + return installPath, productVersion, true +} diff --git a/internal/detector/registry_windows_test.go b/internal/detector/registry_windows_test.go new file mode 100644 index 00000000..9d046560 --- /dev/null +++ b/internal/detector/registry_windows_test.go @@ -0,0 +1,49 @@ +//go:build windows + +package detector + +import ( + "fmt" + "os" + "testing" + + "golang.org/x/sys/windows/registry" +) + +// TestReal_KiroCLIRegistryKey runs the production Kiro CLI key reader against +// a scratch HKCU key, since the mocked CLI tables only exercise the +// non-Windows `reg query` twin. Same scratch-hive pattern as +// devicepolicy/probe_windows_test.go. +func TestReal_KiroCLIRegistryKey(t *testing.T) { + base := fmt.Sprintf(`SOFTWARE\StepSecurityTest\kiro-cli-%d`, os.Getpid()) + path := base + `\CLI` + k, _, err := registry.CreateKey(registry.CURRENT_USER, path, registry.SET_VALUE|registry.QUERY_VALUE) + if err != nil { + t.Fatalf("CreateKey: %v", err) + } + t.Cleanup(func() { + _ = k.Close() + _ = registry.DeleteKey(registry.CURRENT_USER, path) + _ = registry.DeleteKey(registry.CURRENT_USER, base) + }) + + if _, _, ok := readKiroCLIKey(registry.CURRENT_USER, path+`\absent`); ok { + t.Error("an absent key must not read as installed") + } + if _, _, ok := readKiroCLIKey(registry.CURRENT_USER, path); ok { + t.Error("a key without InstallPath must not read as installed") + } + if err := k.SetStringValue("InstallPath", `C:\Users\u\AppData\Local\Kiro-Cli`); err != nil { + t.Fatal(err) + } + install, version, ok := readKiroCLIKey(registry.CURRENT_USER, path) + if !ok || install != `C:\Users\u\AppData\Local\Kiro-Cli` || version != "" { + t.Errorf("InstallPath only: got (%q, %q, %v)", install, version, ok) + } + if err := k.SetStringValue("ProductVersion", "2.21.1.0"); err != nil { + t.Fatal(err) + } + if install, version, ok = readKiroCLIKey(registry.CURRENT_USER, path); !ok || version != "2.21.1.0" { + t.Errorf("with ProductVersion: got (%q, %q, %v)", install, version, ok) + } +} diff --git a/internal/detector/skills.go b/internal/detector/skills.go index 9334baf7..1f60e487 100644 --- a/internal/detector/skills.go +++ b/internal/detector/skills.go @@ -250,18 +250,19 @@ func (d *SkillsDetector) resolveGlobalRoots(info *model.AgentSkillScanInfo) []sk win := d.exec.GOOS() == model.PlatformWindows var roots []skillsRoot - add := func(pathStr, source, agent, scope, excludeName string) { + addRoot := func(root skillsRoot) { // WithinProtected before DirExists: DirExists stats, and a stat inside a // protected tree fires the prompt. Defense-in-depth — today's global roots // (~/.claude, /etc/codex, …) never live under a protected dir, but a future // one might. - if pathStr == "" || d.skipper.WithinProtected(pathStr) || !d.exec.DirExists(pathStr) { + if root.path == "" || d.skipper.WithinProtected(root.path) || !d.exec.DirExists(root.path) { return } - roots = append(roots, skillsRoot{ - path: pathStr, source: source, agent: agent, scope: scope, excludeName: excludeName, - }) - info.RootsScanned = append(info.RootsScanned, pathStr) + roots = append(roots, root) + info.RootsScanned = append(info.RootsScanned, root.path) + } + add := func(pathStr, source, agent, scope, excludeName string) { + addRoot(skillsRoot{path: pathStr, source: source, agent: agent, scope: scope, excludeName: excludeName}) } // claude_user: ~/.claude/skills, honoring CLAUDE_CONFIG_DIR when the env @@ -319,10 +320,63 @@ func (d *SkillsDetector) resolveGlobalRoots(info *model.AgentSkillScanInfo) []sk // opencode_user above. add(filepath.Join(home, ".config", "amp", "skills"), "amp_user", "amp", "global", "") - // factory_agent_user: ~/.agent/skills — Factory's singular-.agent compat - // root. The plural ~/.agents/skills above is the shared convention, and the - // project-level .agent/skills is factory_agent_project. - add(filepath.Join(home, ".agent", "skills"), "factory_agent_user", "factory", "global", "") + // factory_agent_user: ~/.agent/skills — the singular-.agent compat root + // that Factory AND Antigravity both read, so the agent is "shared" (as + // for ~/.agents); the source label is kept for compatibility. The plural + // ~/.agents/skills above is the shared convention, and the project-level + // .agent/skills is factory_agent_project. + add(filepath.Join(home, ".agent", "skills"), "factory_agent_user", "shared", "global", "") + + // kiro_user: ~/.kiro/skills — one root shared by the Kiro IDE and CLI. + add(filepath.Join(home, ".kiro", "skills"), "kiro_user", "kiro", "global", "") + + // windsurf_user, and windsurf_system: the admin-managed root, per OS. + add(filepath.Join(home, ".codeium", "windsurf", "skills"), "windsurf_user", "windsurf", "global", "") + switch d.exec.GOOS() { + case model.PlatformDarwin: + add("/Library/Application Support/Windsurf/skills", "windsurf_system", "windsurf", "system", "") + case model.PlatformWindows: + add(resolveEnvPath(d.exec, `%ProgramData%\Windsurf\skills`), "windsurf_system", "windsurf", "system", "") + default: + add("/etc/windsurf/skills", "windsurf_system", "windsurf", "system", "") + } + + // antigravity_user: Antigravity's own root plus the skills.sh destination, + // both under ~/.gemini and neither read by the Gemini CLI (gemini_user). + add(filepath.Join(home, ".gemini", "config", "skills"), "antigravity_user", "antigravity", "global", "") + add(filepath.Join(home, ".gemini", "antigravity", "skills"), "antigravity_user", "antigravity", "global", "") + + // openclaw_user: the managed root; openclaw_project: the default agent + // workspace, with the workspace as the project path. Named + // (workspace-) and relocated workspaces are not discovered. + openclaw := filepath.Join(home, ".openclaw") + add(filepath.Join(openclaw, "skills"), "openclaw_user", "openclaw", "global", "") + workspace := filepath.Join(openclaw, "workspace") + addRoot(skillsRoot{ + path: filepath.Join(workspace, "skills"), source: "openclaw_project", agent: "openclaw", + scope: "project", projectPath: workspace, + }) + + // grok_user / kimi_user / muse_user: each agent's own global root. All + // three also read ~/.agents/skills, which stays attributed to agents_user. + add(filepath.Join(home, ".grok", "skills"), "grok_user", "grok-build", "global", "") + add(filepath.Join(home, ".kimi-code", "skills"), "kimi_user", "kimi-code", "global", "") + add(filepath.Join(home, ".config", "muse", "skills"), "muse_user", "muse-code", "global", "") + + // hermes_user: ~/.hermes/skills holds the bundled skills the installer + // copies, nested one category deep (//SKILL.md), plus + // .bundled_manifest and .hub/ metadata that enumerateRoot ignores. Windows + // keeps HERMES_HOME under %LOCALAPPDATA%. + if win { + add(resolveEnvPath(d.exec, `%LOCALAPPDATA%\hermes\skills`), "hermes_user", "hermes-agent", "global", "") + } else { + add(filepath.Join(home, ".hermes", "skills"), "hermes_user", "hermes-agent", "global", "") + } + + // omp_user / omp_managed_user: Oh My Pi's user skills and the managed + // skills it syncs, both under its ~/.omp/agent root. + add(filepath.Join(home, ".omp", "agent", "skills"), "omp_user", "oh-my-pi", "global", "") + add(filepath.Join(home, ".omp", "agent", "managed-skills"), "omp_managed_user", "oh-my-pi", "global", "") return roots } @@ -353,10 +407,17 @@ func (d *SkillsDetector) resolveProjectRoots(project string, info *model.AgentSk add([]string{".cursor", "skills"}, "cursor_project", "cursor") add([]string{".pi", "skills"}, "pi_project", "pi") add([]string{".factory", "skills"}, "factory_project", "factory") - add([]string{".agent", "skills"}, "factory_agent_project", "factory") // singular .agent — Factory legacy, distinct from .agents - add([]string{".github", "skills"}, "github_project", "copilot") // only .github/skills, never the rest of .github + add([]string{".agent", "skills"}, "factory_agent_project", "shared") // singular .agent — read by Factory and Antigravity, so shared; label kept for compatibility + add([]string{".github", "skills"}, "github_project", "copilot") // only .github/skills, never the rest of .github add([]string{".gemini", "skills"}, "gemini_project", "gemini-cli") add([]string{".aider", "skills"}, "aider_project", "aider") // community convention: loaded manually, but on-disk state is inventoried + add([]string{".kiro", "skills"}, "kiro_project", "kiro") // shared by Kiro IDE and CLI + add([]string{".windsurf", "skills"}, "windsurf_project", "windsurf") + add([]string{".codex", "skills"}, "codex_project", "codex") // Codex project skills (also the JetBrains Codex install target) + add([]string{".grok", "skills"}, "grok_project", "grok-build") + add([]string{".kimi-code", "skills"}, "kimi_project", "kimi-code") + add([]string{".hermes", "skills"}, "hermes_project", "hermes-agent") + add([]string{".omp", "skills"}, "omp_project", "oh-my-pi") // Muse has no agent-specific project root; it reads .agents/.codex/.claude return roots } @@ -367,16 +428,23 @@ func (d *SkillsDetector) resolveProjectRoots(project string, info *model.AgentSk // table is how walkForProjectRoots recognizes a project it was never told about. // A change to one MUST change the other. var projectMarkerDirs = map[string][]string{ - ".claude": {"skills"}, - ".agents": {"skills"}, - ".opencode": {"skills", "skill"}, // both spellings, same as resolveProjectRoots - ".cursor": {"skills"}, - ".pi": {"skills"}, - ".factory": {"skills"}, - ".agent": {"skills"}, // singular — Factory legacy, distinct from .agents - ".github": {"skills"}, // only .github/skills, never the rest of .github - ".gemini": {"skills"}, // Gemini CLI workspace skills - ".aider": {"skills"}, // Aider community convention (skills loaded manually) + ".claude": {"skills"}, + ".agents": {"skills"}, + ".opencode": {"skills", "skill"}, // both spellings, same as resolveProjectRoots + ".cursor": {"skills"}, + ".pi": {"skills"}, + ".factory": {"skills"}, + ".agent": {"skills"}, // singular — Factory legacy, distinct from .agents + ".github": {"skills"}, // only .github/skills, never the rest of .github + ".gemini": {"skills"}, // Gemini CLI workspace skills + ".aider": {"skills"}, // Aider community convention (skills loaded manually) + ".grok": {"skills"}, + ".kimi-code": {"skills"}, + ".hermes": {"skills"}, + ".omp": {"skills"}, + ".kiro": {"skills"}, + ".windsurf": {"skills"}, + ".codex": {"skills"}, } // walkForProjectRoots sweeps each search dir for projectMarkerDirs and returns @@ -796,8 +864,15 @@ func (d *SkillsDetector) enumerateRoot(ctx context.Context, root skillsRoot, inf continue } - if ent.Type()&os.ModeSymlink != 0 { - d.handleSymlinkEntry(ctx, &records, root, childDir, childRel, info, memo, &rootTruncated) + // A Windows directory junction (what skills.sh creates there) is + // reported by ReadDir as ModeIrregular with IsDir false; without + // this it would be skipped as a plain file and the linking root's + // association lost. Readlink reads a junction's target as it does + // a symlink's, so both take the one link path. + isSymlink := ent.Type()&os.ModeSymlink != 0 + isJunction := ent.Type()&os.ModeIrregular != 0 && d.exec.GOOS() == model.PlatformWindows + if isSymlink || isJunction { + d.handleSymlinkEntry(ctx, &records, root, childDir, childRel, isSymlink, info, memo, &rootTruncated) continue } if !ent.IsDir() { @@ -811,31 +886,46 @@ func (d *SkillsDetector) enumerateRoot(ctx context.Context, root skillsRoot, inf return records } -// handleSymlinkEntry resolves a symlinked directory entry; if its target is a -// skill dir it is recorded as a symlink shadow (the skills.sh layout) with the -// root-relative path as the link location and the resolved target as the skill -// dir path. The shadow is later folded into the physical skill's record by -// collapseSymlinkShadows. Symlinks are never descended through — cycles and ~/ -// escapes are impossible. -func (d *SkillsDetector) handleSymlinkEntry(ctx context.Context, records *[]discoveredSkill, root skillsRoot, linkPath, rel string, info *model.AgentSkillScanInfo, memo map[string]*skillScan, rootTruncated *bool) { - target, err := d.exec.EvalSymlinks(linkPath) - if err != nil || target == "" { - d.addError(info, fmt.Sprintf("dangling symlink %s: %v", linkPath, err)) +// handleSymlinkEntry resolves a linked directory entry — a symlink, or on +// Windows a directory junction (isSymlink false) — and if its target is a +// skill dir records it as a symlink shadow (the skills.sh layout), later folded +// into the physical skill's record by collapseSymlinkShadows. Links are never +// descended through — cycles and ~/ escapes are impossible. +// +// Linked targets are resolved and read through safepath, checking each link's +// destination before traversal. Normal shared links inside the user's home or +// declared project remain supported; unrelated external targets are refused. +func (d *SkillsDetector) handleSymlinkEntry(ctx context.Context, records *[]discoveredSkill, root skillsRoot, linkPath, rel string, isSymlink bool, info *model.AgentSkillScanInfo, memo map[string]*skillScan, rootTruncated *bool) { + target, ok := linkTarget(d.exec, linkPath) + if !ok { + if isSymlink { + d.addError(info, fmt.Sprintf("unreadable symlink %s", linkPath)) + } + // An irregular entry whose target cannot be read is not a junction + // (a socket, a device, a reparse point of another kind): not a skill, + // not an error. return } if d.skipper.WithinProtected(target) { - // Symlink target escapes into a TCC-protected tree — skip it before the - // DirExists/ReadDir below stat inside that tree. Residual: EvalSymlinks - // above already statted the target, so a symlink pointing directly into a - // protected dir can still prompt before this guard. Fully closing that - // needs a raw Readlink + ancestor-check before following; rare (a symlink - // from a safe skill root into a protected dir), tracked as a follow-up. return } - if !d.exec.DirExists(target) { + guarded := *d + guarded.exec = d.exec.GuardedFiles([]string{getHomeDir(d.exec), root.path, root.projectPath}, func(p string) string { + if d.skipper.WithinProtected(p) { + return "tcc_protected" + } + return "" + }, maxSkillMDReadBytes) + resolved, err := guarded.exec.EvalSymlinks(target) + if err != nil || resolved == "" { + d.addError(info, fmt.Sprintf("dangling symlink %s: %v", linkPath, err)) return } - entries, err := d.exec.ReadDir(target) + target = resolved + if d.skipper.WithinProtected(target) { + return + } + entries, err := guarded.exec.ReadDir(target) if err != nil { d.addError(info, fmt.Sprintf("read symlink target %s: %v", target, err)) return @@ -844,11 +934,45 @@ func (d *SkillsDetector) handleSymlinkEntry(ctx context.Context, records *[]disc if !ok { return // symlink to a non-skill dir — not descended } - if !d.emitSkill(ctx, records, root, target, rel, mdName, true, info, memo) { + if !guarded.emitSkill(ctx, records, root, target, rel, mdName, true, info, memo) { *rootTruncated = true } } +// linkTarget returns the absolute target a symlink or Windows junction +// stores, without following it. Relative targets are joined to the link's +// parent but never cleaned: a `..` after a symlinked component resolves +// through that link's target, so collapsing it lexically would name a +// different directory than EvalSymlinks reaches (the TCC guard cleans its own +// copy). Off Windows the target is taken literally (a backslash is an +// ordinary character there). On Windows the NT-namespace prefix junctions +// carry (`\??\`, `\\?\`) is dropped, and the volume-GUID, `UNC\` and +// `\\server\share` forms, which cannot be guarded lexically, are rejected. ok +// is false for a non-link and every rejected target. +func linkTarget(exec executor.Executor, linkPath string) (string, bool) { + raw, err := exec.Readlink(linkPath) + if err != nil || raw == "" { + return "", false + } + if exec.GOOS() != model.PlatformWindows { + if !path.IsAbs(raw) { + raw = path.Dir(linkPath) + "/" + raw + } + return raw, true + } + stripped := strings.TrimPrefix(strings.TrimPrefix(raw, `\??\`), `\\?\`) + switch { + case strings.HasPrefix(stripped, `\\`), strings.HasPrefix(stripped, "//"), + strings.HasPrefix(stripped, "Volume{"), strings.HasPrefix(stripped, `UNC\`), strings.HasPrefix(stripped, "GLOBALROOT"): + return "", false + case stripped != raw && !isAbsPath(stripped): + return "", false // a namespace prefix on a relative spelling is not a path + case !isAbsPath(stripped): + stripped = joinPath(pathDir(linkPath), stripped) + } + return stripped, true +} + // emitSkill appends one discoveredSkill for a skill directory, applying the // per-root 500-skill cap. dir is the resolved skill directory (the symlink // target when isSymlink). Returns false when the per-root cap was hit (caller diff --git a/internal/detector/skills_homewalk_test.go b/internal/detector/skills_homewalk_test.go index 548c6a2d..d52258bf 100644 --- a/internal/detector/skills_homewalk_test.go +++ b/internal/detector/skills_homewalk_test.go @@ -54,10 +54,17 @@ func TestDetect_HomeWalkMarkerConventions(t *testing.T) { {".cursor", "skills", "cursor_project", "cursor"}, // Cursor-only non-node repo {".pi", "skills", "pi_project", "pi"}, {".factory", "skills", "factory_project", "factory"}, - {".agent", "skills", "factory_agent_project", "factory"}, // singular .agent + {".agent", "skills", "factory_agent_project", "shared"}, // singular .agent — Factory and Antigravity both read it + {".kiro", "skills", "kiro_project", "kiro"}, + {".windsurf", "skills", "windsurf_project", "windsurf"}, + {".codex", "skills", "codex_project", "codex"}, {".github", "skills", "github_project", "copilot"}, {".gemini", "skills", "gemini_project", "gemini-cli"}, {".aider", "skills", "aider_project", "aider"}, + {".grok", "skills", "grok_project", "grok-build"}, + {".kimi-code", "skills", "kimi_project", "kimi-code"}, + {".hermes", "skills", "hermes_project", "hermes-agent"}, + {".omp", "skills", "omp_project", "oh-my-pi"}, } for _, c := range cases { t.Run(c.markerDir+"/"+c.child, func(t *testing.T) { diff --git a/internal/detector/skills_tcc_darwin_test.go b/internal/detector/skills_tcc_darwin_test.go index dae17d1e..ac2a76c1 100644 --- a/internal/detector/skills_tcc_darwin_test.go +++ b/internal/detector/skills_tcc_darwin_test.go @@ -5,6 +5,7 @@ package detector import ( "context" "os" + "path/filepath" "strings" "sync" "testing" @@ -14,6 +15,62 @@ import ( "github.com/step-security/dev-machine-guard/internal/tcc" ) +// ancestorTraversalRecorder uses real filesystem resolution in a temporary +// fake home; its Documents directory is not a user's TCC-protected directory. +type ancestorTraversalRecorder struct { + *executor.Real + protected string + traversed []string + guarded []string +} + +func (r *ancestorTraversalRecorder) GuardedFiles(roots []string, guard func(string) string, maxReadBytes int64) executor.Executor { + return r.Real.GuardedFiles(roots, func(p string) string { + if p == r.protected { + r.guarded = append(r.guarded, p) + } + return guard(p) + }, maxReadBytes) +} + +func (r *ancestorTraversalRecorder) EvalSymlinks(p string) (string, error) { + resolved, err := r.Real.EvalSymlinks(p) + if err == nil && (resolved == r.protected || strings.HasPrefix(resolved, r.protected+"/")) { + r.traversed = append(r.traversed, p) + } + return resolved, err +} + +func TestSkillsAncestorLinkRejectedBeforeResolution(t *testing.T) { + home, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + protected := filepath.Join(home, "Documents") + root := filepath.Join(home, ".kiro", "skills") + for _, dir := range []string{root, filepath.Join(protected, "skill")} { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + } + alias := filepath.Join(home, "ordinary-alias") + if err := os.Symlink(protected, alias); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join(alias, "skill"), filepath.Join(root, "linked")); err != nil { + t.Fatal(err) + } + rec := &ancestorTraversalRecorder{Real: executor.NewReal(), protected: protected} + d := NewSkillsDetector(rec).WithSkipper(tcc.New(home)) + d.enumerateRoot(context.Background(), skillsRoot{path: root, source: "kiro_project", agent: "kiro", scope: "project", projectPath: home}, &model.AgentSkillScanInfo{}, map[string]*skillScan{}) + if len(rec.traversed) != 0 { + t.Fatalf("protected ancestor was already traversed by EvalSymlinks before rejection: %v", rec.traversed) + } + if len(rec.guarded) == 0 { + t.Fatal("protected link destination was not checked by the guard") + } +} + // tccAccessRecorder wraps the mock executor and records every path handed to a // filesystem call that stats/reads on a real machine — the calls that fire a // macOS TCC prompt. Tests assert none of them touched a protected tree, which @@ -298,3 +355,38 @@ func TestDetect_HomeWalkRejectsSymlinkedAncestorRoot(t *testing.T) { t.Errorf("no filesystem access may occur under ~/Documents, got: %v", hits) } } + +// TestDetect_LinkIntoProtectedNeverFollowed closes the residual the old +// handleSymlinkEntry documented: a symlink from a safe skill root into +// ~/Documents used to be EvalSymlink'd (statting inside the protected tree) +// before its target was guarded. The target is now read with Readlink and +// guarded lexically first, so nothing under ~/Documents is touched — for an +// absolute target and for a relative one that only resolves there once joined. +func TestDetect_LinkIntoProtectedNeverFollowed(t *testing.T) { + protected := testHome + "/Documents" + for name, raw := range map[string]string{ + "absolute target": protected + "/secret/skill", + "relative target": "../../Documents/secret/skill", + } { + t.Run(name, func(t *testing.T) { + m, fs := newSkillsMock() + fs.addSkill(protected+"/secret/skill", "SKILL.md", validFrontmatter("secret", "d"), nil) + link := testHome + "/.claude/skills/decoy" + fs.addSymlink(link, protected+"/secret/skill") + m.SetReadlink(link, raw) + fs.commit() + + rec := &tccAccessRecorder{Mock: m} + records, info := NewSkillsDetector(rec).WithSkipper(tcc.New(testHome)).Detect(context.Background(), nil, nil) + if len(records) != 0 { + t.Errorf("a link into ~/Documents must not surface the protected skill, got %+v", records) + } + if len(info.Errors) != 0 { + t.Errorf("skipping a protected target is not an error: %v", info.Errors) + } + if hits := rec.accessedUnder(protected); len(hits) > 0 { + t.Errorf("no filesystem access may occur under %q (would fire a TCC prompt), got: %v", protected, hits) + } + }) + } +} diff --git a/internal/detector/skills_test.go b/internal/detector/skills_test.go index e9070972..05eeeca7 100644 --- a/internal/detector/skills_test.go +++ b/internal/detector/skills_test.go @@ -8,6 +8,7 @@ import ( "fmt" "os" "path/filepath" + "runtime" "slices" "strings" "testing" @@ -1082,7 +1083,8 @@ func TestDetect_DanglingSymlink(t *testing.T) { fs.mkdir(testHome + "/.claude/skills") fs.addSymlink(testHome+"/.claude/skills/broken", testHome+"/gone") fs.commit() - m.SetSymlinkError(testHome+"/.claude/skills/broken", errors.New("no such file")) + // The link itself reads fine (Readlink); following its target is what fails. + m.SetSymlinkError(testHome+"/gone", errors.New("no such file")) records, info := NewSkillsDetector(m).Detect(context.Background(), nil, nil) if len(records) != 0 { @@ -1227,6 +1229,12 @@ func TestDetect_NewAgentGlobalSources(t *testing.T) { {testHome + "/.factory/skills/facg", "factory_user", "factory"}, {testHome + "/.config/agents/skills/ampg", "amp_user", "amp"}, {testHome + "/.copilot/skills/copg", "copilot_user", "copilot"}, + {testHome + "/.grok/skills/grokg", "grok_user", "grok-build"}, + {testHome + "/.kimi-code/skills/kimig", "kimi_user", "kimi-code"}, + {testHome + "/.config/muse/skills/museg", "muse_user", "muse-code"}, + {testHome + "/.hermes/skills/hermg", "hermes_user", "hermes-agent"}, + {testHome + "/.omp/agent/skills/ompg", "omp_user", "oh-my-pi"}, + {testHome + "/.omp/agent/managed-skills/ompm", "omp_managed_user", "oh-my-pi"}, } m, fs := newSkillsMock() for _, c := range cases { @@ -1248,14 +1256,41 @@ func TestDetect_NewAgentGlobalSources(t *testing.T) { } } +// TestDetect_HermesUserNestedLayout pins Hermes's bundled layout: skills sit one +// category deep (//SKILL.md) beside .bundled_manifest and .hub/ +// metadata, so root_rel_path is two levels and the dot-entries are ignored. +func TestDetect_HermesUserNestedLayout(t *testing.T) { + m, fs := newSkillsMock() + fs.addSkill(testHome+"/.hermes/skills/apple/apple-reminders", "SKILL.md", validFrontmatter("apple-reminders", "d"), nil) + fs.addFile(testHome+"/.hermes/skills/.bundled_manifest", "{}") + fs.addFile(testHome+"/.hermes/skills/.hub/index.json", "{}") + fs.commit() + + records, _ := NewSkillsDetector(m).Detect(context.Background(), nil, nil) + if len(records) != 1 { + t.Fatalf("want exactly the nested skill, got %+v", records) + } + rec := findSkill(records, "hermes_user", "apple-reminders") + if rec == nil { + t.Fatalf("hermes_user apple-reminders not found; records=%+v", records) + } + if rec.Agent != "hermes-agent" || rec.Scope != "global" || rec.RootRelPath != "apple/apple-reminders" { + t.Errorf("agent=%q scope=%q root_rel_path=%q, want hermes-agent/global/apple/apple-reminders", rec.Agent, rec.Scope, rec.RootRelPath) + } +} + // TestDetect_NewAgentProjectSources covers the Pi/Factory/GitHub project roots, // including Factory's SINGULAR .agent/skills (distinct from the shared .agents). func TestDetect_NewAgentProjectSources(t *testing.T) { proj := testHome + "/work/proj" cases := []struct{ rel, source, agent string }{ {".pi/skills/pip", "pi_project", "pi"}, + {".grok/skills/grokp", "grok_project", "grok-build"}, + {".kimi-code/skills/kimip", "kimi_project", "kimi-code"}, + {".hermes/skills/hermp", "hermes_project", "hermes-agent"}, + {".omp/skills/ompp", "omp_project", "oh-my-pi"}, {".factory/skills/facp", "factory_project", "factory"}, - {".agent/skills/facap", "factory_agent_project", "factory"}, + {".agent/skills/facap", "factory_agent_project", "shared"}, // read by Factory and Antigravity {".github/skills/ghp", "github_project", "copilot"}, } m, fs := newSkillsMock() @@ -1577,6 +1612,24 @@ func TestDetect_WindowsCodexAdmin(t *testing.T) { } } +func TestDetect_WindowsHermesUser(t *testing.T) { + m, fs := newSkillsMock() + m.SetGOOS(model.PlatformWindows) + m.SetEnv("LOCALAPPDATA", `C:\Users\u\AppData\Local`) + base := resolveEnvPath(m, `%LOCALAPPDATA%\hermes\skills`) + fs.addSkill(filepath.Join(base, "winherm"), "SKILL.md", validFrontmatter("winherm", "d"), nil) + fs.commit() + + records, _ := NewSkillsDetector(m).Detect(context.Background(), nil, nil) + rec := findSkill(records, "hermes_user", "winherm") + if rec == nil { + t.Fatalf("windows hermes_user skill not found; records=%+v", records) + } + if rec.Scope != "global" || rec.Agent != "hermes-agent" { + t.Errorf("scope=%q agent=%q, want global/hermes-agent", rec.Scope, rec.Agent) + } +} + func TestDetect_ProjectRootFromClaudeRegistry(t *testing.T) { m, fs := newSkillsMock() proj := testHome + "/work/myproj" @@ -1648,3 +1701,332 @@ func hasErrorContaining(xs []string, sub string) bool { } return false } + +// addJunction registers a Windows directory junction: a ModeIrregular entry +// under its parent whose raw Readlink target is rawTarget (as stored, i.e. the +// NT-namespace spelling). No EvalSymlinks stub — the detector resolves the +// stripped target itself. +func (f *fakeFS) addJunction(linkPath, rawTarget string) { + dir := filepath.Dir(linkPath) + f.ensureDir(dir) + f.m.SetReadlink(linkPath, rawTarget) + f.children[dir][filepath.Base(linkPath)] = executor.MockIrregularDirEntry(filepath.Base(linkPath)) +} + +// TestDetect_KiroParityGlobalRoots covers every new global/system root with +// its exact source label, agent and scope (macOS spellings; the mock's default +// GOOS is darwin). +func TestDetect_KiroParityGlobalRoots(t *testing.T) { + cases := []struct{ dir, source, agent, scope string }{ + {testHome + "/.kiro/skills/kg", "kiro_user", "kiro", "global"}, + {testHome + "/.codeium/windsurf/skills/wg", "windsurf_user", "windsurf", "global"}, + {"/Library/Application Support/Windsurf/skills/ws", "windsurf_system", "windsurf", "system"}, + {testHome + "/.gemini/config/skills/ag1", "antigravity_user", "antigravity", "global"}, + {testHome + "/.gemini/antigravity/skills/ag2", "antigravity_user", "antigravity", "global"}, + {testHome + "/.openclaw/skills/ocg", "openclaw_user", "openclaw", "global"}, + {testHome + "/.agent/skills/legacy", "factory_agent_user", "shared", "global"}, + } + m, fs := newSkillsMock() + for _, c := range cases { + fs.addSkill(c.dir, "SKILL.md", validFrontmatter(filepath.Base(c.dir), "d"), nil) + } + fs.commit() + + records, info := NewSkillsDetector(m).Detect(context.Background(), nil, nil) + for _, c := range cases { + slug := filepath.Base(c.dir) + rec := findSkill(records, c.source, slug) + if rec == nil { + t.Errorf("%s skill %q not found; records=%+v", c.source, slug, records) + continue + } + if rec.Agent != c.agent || rec.Scope != c.scope { + t.Errorf("%s: agent=%q scope=%q, want %s/%s", c.source, rec.Agent, rec.Scope, c.agent, c.scope) + } + if root := filepath.Dir(c.dir); !slices.Contains(info.RootsScanned, root) { + t.Errorf("roots_scanned missing %q", root) + } + } +} + +func TestDetect_WindsurfSystemRoot_PerOS(t *testing.T) { + cases := []struct { + goos string + env map[string]string + system string + }{ + {model.PlatformLinux, nil, "/etc/windsurf/skills"}, + {model.PlatformWindows, map[string]string{"ProgramData": `C:\ProgramData`}, `C:\ProgramData\Windsurf\skills`}, + } + for _, tc := range cases { + t.Run(tc.goos, func(t *testing.T) { + m, fs := newSkillsMock() + m.SetGOOS(tc.goos) + for k, v := range tc.env { + m.SetEnv(k, v) + } + fs.addSkill(filepath.Join(tc.system, "ws"), "SKILL.md", validFrontmatter("ws", "d"), nil) + fs.commit() + + records, _ := NewSkillsDetector(m).Detect(context.Background(), nil, nil) + if rec := findSkill(records, "windsurf_system", "ws"); rec == nil || rec.Scope != "system" { + t.Errorf("windsurf_system on %s: %+v", tc.goos, rec) + } + }) + } +} + +// TestDetect_KiroParityProjectRoots covers the new project roots through the +// ~/.claude.json registry (the home-walk marker path is covered in +// skills_homewalk_test.go). +func TestDetect_KiroParityProjectRoots(t *testing.T) { + proj := testHome + "/work/proj" + cases := []struct{ rel, source, agent string }{ + {".kiro/skills/kp", "kiro_project", "kiro"}, + {".windsurf/skills/wp", "windsurf_project", "windsurf"}, + {".codex/skills/cp", "codex_project", "codex"}, + {".agent/skills/lp", "factory_agent_project", "shared"}, + } + m, fs := newSkillsMock() + for _, c := range cases { + fs.addSkill(filepath.Join(proj, filepath.FromSlash(c.rel)), "SKILL.md", validFrontmatter(filepath.Base(c.rel), "d"), nil) + } + fs.addFile(testHome+"/.claude.json", `{"projects":{"`+proj+`":{}}}`) + fs.commit() + + records, _ := NewSkillsDetector(m).Detect(context.Background(), nil, nil) + for _, c := range cases { + slug := filepath.Base(c.rel) + rec := findSkill(records, c.source, slug) + if rec == nil { + t.Errorf("%s skill %q not found; records=%+v", c.source, slug, records) + continue + } + if rec.Agent != c.agent || rec.Scope != "project" || rec.ProjectPath != proj { + t.Errorf("%s: agent=%q scope=%q proj=%q", c.source, rec.Agent, rec.Scope, rec.ProjectPath) + } + } +} + +// TestDetect_OpenClawDefaultWorkspace: the default workspace is a project root +// with the workspace as project_path; named workspaces are not discovered. +func TestDetect_OpenClawDefaultWorkspace(t *testing.T) { + m, fs := newSkillsMock() + oc := testHome + "/.openclaw" + fs.addSkill(oc+"/workspace/skills/dflt", "SKILL.md", validFrontmatter("dflt", "d"), nil) + fs.addSkill(oc+"/workspace-agent7/skills/named", "SKILL.md", validFrontmatter("named", "d"), nil) + fs.commit() + + records, _ := NewSkillsDetector(m).Detect(context.Background(), nil, nil) + rec := findSkill(records, "openclaw_project", "dflt") + if rec == nil { + t.Fatalf("openclaw_project not found; records=%+v", records) + } + if rec.Agent != "openclaw" || rec.Scope != "project" || rec.ProjectPath != oc+"/workspace" { + t.Errorf("agent=%q scope=%q project=%q", rec.Agent, rec.Scope, rec.ProjectPath) + } + if findSkill(records, "openclaw_project", "named") != nil { + t.Error("named workspaces are out of scope and must not be inventoried") + } +} + +// TestDetect_WindowsJunctionFolds is the skills.sh-on-Windows case: the +// canonical skill lives under ~/.agents/skills and the agent roots hold +// directory junctions to it, which ReadDir reports as ModeIrregular with the +// target in the NT namespace. Both junctions must fold into the physical +// record as symlink_sources — before this, they were skipped as plain files +// and even Claude's association was lost. +func TestDetect_WindowsJunctionFolds(t *testing.T) { + m, fs := newSkillsMock() + m.SetGOOS(model.PlatformWindows) + real := testHome + "/.agents/skills/pptx" + fs.addSkill(real, "SKILL.md", validFrontmatter("pptx", "d"), nil) + fs.addJunction(testHome+"/.claude/skills/pptx", `\??\`+real) + fs.addJunction(testHome+"/.kiro/skills/pptx", `\\?\`+real) + fs.commit() + + records, info := NewSkillsDetector(m).Detect(context.Background(), nil, nil) + if info.SkillsFound != 1 { + t.Fatalf("expected 1 folded record, got %d: %+v", info.SkillsFound, records) + } + rec := findSkill(records, "agents_user", "pptx") + if rec == nil { + t.Fatalf("canonical agents_user record missing; records=%+v", records) + } + if !equalStrings(rec.SymlinkSources, []string{"claude_user", "kiro_user"}) { + t.Errorf("symlink_sources = %v, want [claude_user kiro_user]", rec.SymlinkSources) + } + if len(info.Errors) != 0 { + t.Errorf("junctions must not be reported as errors: %v", info.Errors) + } +} + +// TestDetect_IrregularEntryOffWindowsIgnored: ModeIrregular means junction +// only on Windows; elsewhere it is a socket or device and is skipped silently, +// never Readlink'd. +func TestDetect_IrregularEntryOffWindowsIgnored(t *testing.T) { + m, fs := newSkillsMock() + m.SetGOOS(model.PlatformLinux) + real := testHome + "/.agents/skills/pptx" + fs.addSkill(real, "SKILL.md", validFrontmatter("pptx", "d"), nil) + fs.addJunction(testHome+"/.claude/skills/pptx", real) + fs.commit() + + records, info := NewSkillsDetector(m).Detect(context.Background(), nil, nil) + rec := findSkill(records, "agents_user", "pptx") + if rec == nil || len(rec.SymlinkSources) != 0 { + t.Errorf("an irregular entry on linux must not fold as a link; rec=%+v", rec) + } + if len(info.Errors) != 0 { + t.Errorf("unexpected errors: %v", info.Errors) + } +} + +// TestDetect_JunctionTargetsRejected: a junction to a volume GUID cannot be +// guarded lexically and is skipped; one whose target cannot be read is not a +// junction at all. Neither is an error. +func TestDetect_JunctionTargetsRejected(t *testing.T) { + m, fs := newSkillsMock() + m.SetGOOS(model.PlatformWindows) + fs.mkdir(testHome + "/.claude/skills") + fs.addJunction(testHome+"/.claude/skills/vol", `\??\Volume{6f2a1c3e-0000-0000-0000-000000000000}\skills\x`) + dir := testHome + "/.claude/skills" + fs.children[dir]["sock"] = executor.MockIrregularDirEntry("sock") // no Readlink stub: not a link + fs.commit() + + records, info := NewSkillsDetector(m).Detect(context.Background(), nil, nil) + if len(records) != 0 { + t.Errorf("no skill may come from a rejected junction, got %+v", records) + } + if len(info.Errors) != 0 { + t.Errorf("rejected junctions are not errors: %v", info.Errors) + } +} + +// TestDetect_RelativeSymlinkTargetFolds: a symlink stored with a relative +// target is resolved against the link's parent, so it folds like an absolute +// one. +func TestDetect_RelativeSymlinkTargetFolds(t *testing.T) { + m, fs := newSkillsMock() + real := testHome + "/.agents/skills/foo" + fs.addSkill(real, "SKILL.md", validFrontmatter("foo", "d"), nil) + link := testHome + "/.claude/skills/foo" + fs.addSymlink(link, real) + m.SetReadlink(link, "../../.agents/skills/foo") + m.SetSymlink(testHome+"/.claude/skills/../../.agents/skills/foo", real) // what EvalSymlinks sees + fs.commit() + + records, info := NewSkillsDetector(m).Detect(context.Background(), nil, nil) + if info.SkillsFound != 1 { + t.Fatalf("expected 1 folded record, got %d: %+v", info.SkillsFound, records) + } + rec := findSkill(records, "agents_user", "foo") + if rec == nil || !equalStrings(rec.SymlinkSources, []string{"claude_user"}) { + t.Errorf("relative symlink did not fold: %+v", rec) + } +} + +// TestDetect_DotDotAfterSymlinkResolvesThroughIt, on a real filesystem: a +// skill link stores `alias/../skill` where alias is itself a symlink to another +// tree. The kernel resolves `..` against alias's target, so the skill is the +// one in that other tree — a lexical clean of the stored spelling would name +// root/skill instead and attribute the wrong directory. +func TestDetect_DotDotAfterSymlinkResolvesThroughIt(t *testing.T) { + if runtime.GOOS == model.PlatformWindows { + t.Skip("symlink creation needs privilege on Windows") + } + base := t.TempDir() + root := filepath.Join(base, "root") + elsewhere := filepath.Join(base, "elsewhere") + for _, dir := range []string{filepath.Join(elsewhere, "sub"), filepath.Join(elsewhere, "skill"), filepath.Join(root, "skill")} { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + } + // Both candidates are valid skills, so only the resolved path tells them apart. + for _, dir := range []string{filepath.Join(elsewhere, "skill"), filepath.Join(root, "skill")} { + if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(validFrontmatter("skill", "d")), 0o644); err != nil { + t.Fatal(err) + } + } + if err := os.Symlink(filepath.Join(elsewhere, "sub"), filepath.Join(root, "alias")); err != nil { + t.Fatal(err) + } + if err := os.Symlink("alias/../skill", filepath.Join(root, "linked")); err != nil { + t.Fatal(err) + } + + d := NewSkillsDetector(executor.NewReal()) + records := d.enumerateRoot(context.Background(), skillsRoot{path: root, source: "claude_project", agent: "claude", scope: "project", projectPath: base}, &model.AgentSkillScanInfo{}, map[string]*skillScan{}) + + want, _ := filepath.EvalSymlinks(filepath.Join(elsewhere, "skill")) + var linked *discoveredSkill + for i := range records { + if records[i].rec.RootRelPath == "linked" { + linked = &records[i] + } + } + if linked == nil { + t.Fatalf("linked skill not recorded; records=%+v", records) + } + if linked.resolvedDir != want { + t.Errorf("linked resolves to %q, want %q (through the alias, not root/skill)", linked.resolvedDir, want) + } +} + +func TestLinkTarget(t *testing.T) { + type row struct { + link, want string + ok bool + } + check := func(t *testing.T, m *executor.Mock, cases []row) { + for _, c := range cases { + got, ok := linkTarget(m, c.link) + if got != c.want || ok != c.ok { + t.Errorf("linkTarget(%q) = (%q, %v), want (%q, %v)", c.link, got, ok, c.want, c.ok) + } + } + } + t.Run("unix targets are literal", func(t *testing.T) { + m := executor.NewMock() + m.SetGOOS(model.PlatformLinux) + m.SetReadlink("/l/abs", "/t/abs") + m.SetReadlink("/l/rel", "../x/rel") + m.SetReadlink("/l/bs", `/t/back\slash`) // a backslash is an ordinary character + m.SetReadlink("/l/vol", "Volume{abc}/x") // an ordinary relative name, not a GUID + m.SetReadlink("/l/nt", `\??\C:\x`) // never stripped off Windows + m.SetReadlink("/l/empty", "") + check(t, m, []row{ + {"/l/abs", "/t/abs", true}, + {"/l/rel", "/l/../x/rel", true}, // not cleaned: EvalSymlinks owns ".." + {"/l/bs", `/t/back\slash`, true}, + {"/l/vol", "/l/Volume{abc}/x", true}, + {"/l/nt", `/l/\??\C:\x`, true}, + {"/l/empty", "", false}, + {"/l/notalink", "", false}, + }) + }) + t.Run("windows namespaces", func(t *testing.T) { + m := executor.NewMock() + m.SetGOOS(model.PlatformWindows) + m.SetReadlink(`C:\l\nt`, `\??\C:\t\nt`) + m.SetReadlink(`C:\l\q`, `\\?\C:\t\q`) + m.SetReadlink(`C:\l\rel`, `..\t\rel`) + m.SetReadlink(`C:\l\vol`, `\??\Volume{abc}\x`) + m.SetReadlink(`C:\l\unc`, `\\?\UNC\srv\share\x`) + m.SetReadlink(`C:\l\share`, `\\srv\share\x`) + m.SetReadlink(`C:\l\dev`, `\??\GLOBALROOT\Device\HarddiskVolume1\x`) + m.SetReadlink(`C:\l\ntrel`, `\??\..\x`) + check(t, m, []row{ + {`C:\l\nt`, `C:\t\nt`, true}, + {`C:\l\q`, `C:\t\q`, true}, + {`C:\l\rel`, `C:\l\..\t\rel`, true}, + {`C:\l\vol`, "", false}, + {`C:\l\unc`, "", false}, + {`C:\l\share`, "", false}, + {`C:\l\dev`, "", false}, + {`C:\l\ntrel`, "", false}, + {`C:\l\notalink`, "", false}, + }) + }) +} diff --git a/internal/detector/skills_windows_test.go b/internal/detector/skills_windows_test.go new file mode 100644 index 00000000..a1aadc4f --- /dev/null +++ b/internal/detector/skills_windows_test.go @@ -0,0 +1,88 @@ +//go:build windows + +package detector + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/model" +) + +// TestReal_SkillsJunctionFolds drives the skills detector's root enumeration +// and shadow folding over real directory junctions with the real executor — +// the mocked TestDetect_WindowsJunctionFolds proves the logic, this proves the +// primitives it rests on (ReadDir type bits, Readlink's NT spelling, +// EvalSymlinks through a junction) compose on Windows. Roots are temp dirs, +// not the scanning user's home, so the test is hermetic. +func TestReal_SkillsJunctionFolds(t *testing.T) { + // Match the real user-home spelling even when CI's TEMP uses an 8.3 alias. + home, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + mkskill := func(dir string) { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("---\nname: pptx\ndescription: d\n---\nbody\n"), 0o644); err != nil { + t.Fatal(err) + } + } + junction := func(link, target string) { + if err := os.MkdirAll(filepath.Dir(link), 0o755); err != nil { + t.Fatal(err) + } + if out, err := exec.Command("cmd", "/c", "mklink", "/J", link, target).CombinedOutput(); err != nil { + t.Fatalf("mklink /J %s: %v: %s", link, err, out) + } + } + + real := filepath.Join(home, ".agents", "skills", "pptx") + mkskill(real) + junction(filepath.Join(home, ".claude", "skills", "pptx"), real) // shared skill linked from Claude + junction(filepath.Join(home, ".kiro", "skills", "pptx"), real) // and from Kiro + mkskill(filepath.Join(home, ".claude", "skills", "copied")) // a plain copy stays its own record + junction(filepath.Join(home, ".kiro", "skills", "notskill"), filepath.Join(home, ".agents")) // junction to a non-skill dir + junction(filepath.Join(home, ".kiro", "skills", "broken"), filepath.Join(home, "gone")) + _ = os.Remove(filepath.Join(home, "gone")) // never existed; mklink /J does not require the target + + d := NewSkillsDetector(executor.NewReal()) + info := &model.AgentSkillScanInfo{} + memo := map[string]*skillScan{} + var discovered []discoveredSkill + for _, r := range []skillsRoot{ + {path: filepath.Join(home, ".agents", "skills"), source: "agents_user", agent: "shared", scope: "global"}, + {path: filepath.Join(home, ".claude", "skills"), source: "claude_user", agent: "claude", scope: "global"}, + {path: filepath.Join(home, ".kiro", "skills"), source: "kiro_user", agent: "kiro", scope: "global"}, + } { + discovered = append(discovered, d.enumerateRoot(context.Background(), r, info, memo)...) + } + skills := collapseSymlinkShadows(discovered) + + var pptx, copied *model.AgentSkill + for i := range skills { + switch { + case skills[i].Source == "agents_user" && skills[i].SkillSlug == "pptx": + pptx = &skills[i] + case skills[i].Source == "claude_user" && skills[i].SkillSlug == "copied": + copied = &skills[i] + } + } + if len(skills) != 2 || pptx == nil || copied == nil { + t.Fatalf("want exactly the folded pptx and the copied skill, got %+v", skills) + } + if !equalStrings(pptx.SymlinkSources, []string{"claude_user", "kiro_user"}) { + t.Errorf("symlink_sources = %v, want [claude_user kiro_user]", pptx.SymlinkSources) + } + for _, e := range info.Errors { + if !strings.Contains(e, "broken") { + t.Errorf("unexpected error: %s", e) + } + } +} diff --git a/internal/executor/executor.go b/internal/executor/executor.go index 04c6d8e2..c6aa1237 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -13,12 +13,16 @@ import ( "sync" "time" + "github.com/step-security/dev-machine-guard/internal/safepath" "github.com/step-security/dev-machine-guard/internal/winproc" ) // Executor defines the interface for all OS interactions. // Every detector depends on this interface, enabling full unit-test coverage via mocks. type Executor interface { + // GuardedFiles restricts ReadFile, ReadDir, Stat and EvalSymlinks to roots + // and guard. Other operations retain their original behavior. + GuardedFiles(roots []string, guard func(string) string, maxReadBytes int64) Executor // Run executes a command and returns stdout, stderr, and exit code. Run(ctx context.Context, name string, args ...string) (stdout, stderr string, exitCode int, err error) // RunWithTimeout executes a command with a timeout. @@ -62,6 +66,10 @@ type Executor interface { // EvalSymlinks resolves symbolic links in a path. Returns the resolved // canonical path. If the path is not a symlink, returns it unchanged. EvalSymlinks(path string) (string, error) + // Readlink returns the stored target of the symlink (or Windows directory + // junction) at path without following it — possibly relative, possibly an + // NT-namespace spelling (\??\C:\...). A non-link path is an error. + Readlink(path string) (string, error) // LoggedInUser returns the actual logged-in console user. // When running as root on macOS (e.g., via LaunchDaemon), this detects the // real console user via /dev/console rather than returning root. @@ -89,6 +97,32 @@ type Real struct { func NewReal() *Real { return &Real{} } +func (r *Real) GuardedFiles(roots []string, guard func(string) string, maxReadBytes int64) Executor { + return &guardedFiles{Executor: r, resolver: safepath.NewReader(roots, guard), maxReadBytes: maxReadBytes} +} + +type guardedFiles struct { + Executor + resolver *safepath.Reader + maxReadBytes int64 +} + +func (g *guardedFiles) EvalSymlinks(path string) (string, error) { + return g.resolver.Resolve(path) +} + +func (g *guardedFiles) Stat(path string) (os.FileInfo, error) { + return g.resolver.Stat(path) +} + +func (g *guardedFiles) ReadFile(path string) ([]byte, error) { + return g.resolver.ReadFile(path, g.maxReadBytes) +} + +func (g *guardedFiles) ReadDir(path string) ([]os.DirEntry, error) { + return g.resolver.ReadDir(path) +} + // StartDetached starts the process and lets it go. It deliberately does not // Wait: the child must outlive this process. // @@ -230,6 +264,10 @@ func (r *Real) EvalSymlinks(path string) (string, error) { return filepath.EvalSymlinks(path) } +func (r *Real) Readlink(path string) (string, error) { + return os.Readlink(path) +} + func (r *Real) LoggedInUser() (*user.User, error) { if runtime.GOOS != "darwin" || !r.IsRoot() { return r.CurrentUser() diff --git a/internal/executor/mock.go b/internal/executor/mock.go index 2e7893d0..7dbac530 100644 --- a/internal/executor/mock.go +++ b/internal/executor/mock.go @@ -47,6 +47,9 @@ type Mock struct { symlinks map[string]string // Symlink resolution errors: path -> error (simulates a dangling link) symlinkErrs map[string]error + // Raw link targets: path -> target, for Readlink. Falls back to symlinks + // when a path is registered only there. + readlinks map[string]string // macOS Command Line Tools presence (false simulates a Mac without CLT // installed, where /usr/bin/python3 etc. are install-prompt shims). @@ -61,6 +64,12 @@ type Mock struct { loggedInUserErr error } +// GuardedFiles retains the in-memory filesystem. Native guarded I/O is covered +// by real-filesystem tests; mocks never access the host filesystem. +func (m *Mock) GuardedFiles(_ []string, _ func(string) string, _ int64) Executor { + return m +} + type cmdResult struct { Stdout string Stderr string @@ -80,6 +89,7 @@ func NewMock() *Mock { globs: make(map[string][]string), symlinks: make(map[string]string), symlinkErrs: make(map[string]error), + readlinks: make(map[string]string), diskCapacities: make(map[string]uint64), hostname: "test-host", username: "testuser", @@ -204,6 +214,15 @@ func (m *Mock) SetSymlinkError(path string, err error) { m.symlinkErrs[path] = err } +// SetReadlink stubs the stored target Readlink(path) returns (relative, or a +// junction's \??\ spelling). Without it Readlink falls back to the SetSymlink +// target, so a plain symlink fixture needs only one call. +func (m *Mock) SetReadlink(path, target string) { + m.mu.Lock() + defer m.mu.Unlock() + m.readlinks[path] = target +} + func (m *Mock) SetGOOS(goos string) { m.mu.Lock() defer m.mu.Unlock() @@ -385,6 +404,18 @@ func (m *Mock) EvalSymlinks(path string) (string, error) { return path, nil } +func (m *Mock) Readlink(path string) (string, error) { + m.mu.RLock() + defer m.mu.RUnlock() + if target, ok := m.readlinks[path]; ok { + return target, nil + } + if target, ok := m.symlinks[path]; ok { + return target, nil + } + return "", fmt.Errorf("mock: readlink %s: not a link", path) +} + func (m *Mock) GOOS() string { m.mu.RLock() defer m.mu.RUnlock() @@ -442,10 +473,18 @@ func MockSymlinkDirEntry(name string) os.DirEntry { return &mockDirEntry{name: name, symlink: true} } +// MockIrregularDirEntry creates an os.DirEntry whose Type() reports +// os.ModeIrregular (IsDir() and symlink both false) — what os.ReadDir reports +// for a Windows directory junction. Pair it with SetReadlink for the target. +func MockIrregularDirEntry(name string) os.DirEntry { + return &mockDirEntry{name: name, irregular: true} +} + type mockDirEntry struct { - name string - dir bool - symlink bool + name string + dir bool + symlink bool + irregular bool } func (e *mockDirEntry) Name() string { return e.name } @@ -454,6 +493,9 @@ func (e *mockDirEntry) Type() os.FileMode { if e.symlink { return os.ModeSymlink } + if e.irregular { + return os.ModeIrregular + } if e.dir { return os.ModeDir } diff --git a/internal/executor/mock_test.go b/internal/executor/mock_test.go index 5bc16ba7..5fc9e024 100644 --- a/internal/executor/mock_test.go +++ b/internal/executor/mock_test.go @@ -2,6 +2,7 @@ package executor import ( "context" + "os" "testing" ) @@ -37,3 +38,40 @@ func TestMock_IsAppleCLTStub(t *testing.T) { }) } } + +func TestMock_Readlink(t *testing.T) { + m := NewMock() + m.SetSymlink("/only-symlink", "/resolved") + m.SetSymlink("/both", "/resolved-both") + m.SetReadlink("/both", "../raw-both") + m.SetReadlink("/only-readlink", `\??\C:\raw`) + + cases := []struct { + path, want string + wantErr bool + }{ + {"/only-symlink", "/resolved", false}, // falls back to the SetSymlink target + {"/both", "../raw-both", false}, // SetReadlink wins over SetSymlink + {"/only-readlink", `\??\C:\raw`, false}, + {"/plain", "", true}, + } + for _, c := range cases { + got, err := m.Readlink(c.path) + if (err != nil) != c.wantErr || got != c.want { + t.Errorf("Readlink(%q) = (%q, %v), want (%q, err=%v)", c.path, got, err, c.want, c.wantErr) + } + } + if got, err := m.EvalSymlinks("/both"); err != nil || got != "/resolved-both" { + t.Errorf("SetReadlink must not change EvalSymlinks: got (%q, %v)", got, err) + } +} + +func TestMockIrregularDirEntry(t *testing.T) { + e := MockIrregularDirEntry("junction") + if e.Name() != "junction" || e.IsDir() || e.Type()&os.ModeIrregular == 0 || e.Type()&os.ModeSymlink != 0 { + t.Errorf("irregular entry: name=%q dir=%v type=%v", e.Name(), e.IsDir(), e.Type()) + } + if s := MockSymlinkDirEntry("s"); s.Type()&os.ModeIrregular != 0 { + t.Errorf("symlink entry must not be irregular: %v", s.Type()) + } +} diff --git a/internal/executor/readlink_windows_test.go b/internal/executor/readlink_windows_test.go new file mode 100644 index 00000000..d9924b8d --- /dev/null +++ b/internal/executor/readlink_windows_test.go @@ -0,0 +1,54 @@ +//go:build windows + +package executor + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// TestReal_ReadlinkJunction pins the two facts the skills detector's junction +// handling rests on, against a real junction: os.ReadDir reports it as +// ModeIrregular (not ModeSymlink, IsDir false), and Readlink returns its +// target — in the NT namespace, which the detector strips. +func TestReal_ReadlinkJunction(t *testing.T) { + base := t.TempDir() + target := filepath.Join(base, "target") + if err := os.Mkdir(target, 0o755); err != nil { + t.Fatal(err) + } + link := filepath.Join(base, "link") + if out, err := exec.Command("cmd", "/c", "mklink", "/J", link, target).CombinedOutput(); err != nil { + t.Fatalf("mklink /J: %v: %s", err, out) + } + + entries, err := os.ReadDir(base) + if err != nil { + t.Fatal(err) + } + var found bool + for _, e := range entries { + if e.Name() != "link" { + continue + } + found = true + if e.Type()&os.ModeIrregular == 0 || e.Type()&os.ModeSymlink != 0 || e.IsDir() { + t.Errorf("junction Type() = %v, want ModeIrregular and not ModeSymlink/dir", e.Type()) + } + } + if !found { + t.Fatal("junction entry missing from ReadDir") + } + + got, err := NewReal().Readlink(link) + if err != nil { + t.Fatalf("Readlink: %v", err) + } + stripped := strings.TrimPrefix(strings.TrimPrefix(got, `\??\`), `\\?\`) + if !strings.EqualFold(stripped, target) { + t.Errorf("Readlink = %q (stripped %q), want %q", got, stripped, target) + } +} diff --git a/internal/executor/user_aware.go b/internal/executor/user_aware.go index 4ea26670..09d5a7e7 100644 --- a/internal/executor/user_aware.go +++ b/internal/executor/user_aware.go @@ -28,6 +28,10 @@ type UserAwareExecutor struct { envErr error } +func (e *UserAwareExecutor) GuardedFiles(roots []string, guard func(string) string, maxReadBytes int64) Executor { + return NewUserAwareExecutor(e.inner.GuardedFiles(roots, guard, maxReadBytes), e.username) +} + var userEnvironmentKeys = []string{ "APPDATA", "GOAUTH", @@ -243,8 +247,9 @@ func (e *UserAwareExecutor) Glob(pattern string) ([]string, error) { return e.in func (e *UserAwareExecutor) EvalSymlinks(path string) (string, error) { return e.inner.EvalSymlinks(path) } -func (e *UserAwareExecutor) LoggedInUser() (*user.User, error) { return e.inner.LoggedInUser() } -func (e *UserAwareExecutor) GOOS() string { return e.inner.GOOS() } +func (e *UserAwareExecutor) Readlink(path string) (string, error) { return e.inner.Readlink(path) } +func (e *UserAwareExecutor) LoggedInUser() (*user.User, error) { return e.inner.LoggedInUser() } +func (e *UserAwareExecutor) GOOS() string { return e.inner.GOOS() } func (e *UserAwareExecutor) IsAppleCLTStub(ctx context.Context, binPath string) bool { return e.inner.IsAppleCLTStub(ctx, binPath) } diff --git a/internal/executor/user_aware_test.go b/internal/executor/user_aware_test.go index 9d0058e8..3d5188a9 100644 --- a/internal/executor/user_aware_test.go +++ b/internal/executor/user_aware_test.go @@ -2,11 +2,49 @@ package executor import ( "context" + "os" + "path/filepath" "strings" "testing" "time" ) +func TestReal_GuardedFiles(t *testing.T) { + // CI's Windows TEMP can use an 8.3 alias; verified opens use the full path. + root, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + for name, content := range map[string]string{"small": "ok", "large": "oversize", "blocked": "no"} { + if err := os.WriteFile(filepath.Join(root, name), []byte(content), 0o600); err != nil { + t.Fatal(err) + } + } + for _, exec := range []Executor{NewReal(), NewUserAwareExecutor(NewReal(), "test-user")} { + guarded := exec.GuardedFiles([]string{root}, func(p string) string { + if filepath.Base(p) == "blocked" { + return "test_refusal" + } + return "" + }, 4) + if data, err := guarded.ReadFile(filepath.Join(root, "small")); err != nil || string(data) != "ok" { + t.Fatalf("guarded read = %q, %v", data, err) + } + for _, name := range []string{"large", "blocked"} { + if data, err := guarded.ReadFile(filepath.Join(root, name)); err == nil || len(data) != 0 { + t.Errorf("%s: expected refusal without contents, got %q, %v", name, data, err) + } + } + if info, err := guarded.Stat(filepath.Join(root, "small")); err != nil || info.Size() != 2 { + t.Fatalf("guarded stat = %v, %v", info, err) + } + entries, err := guarded.ReadDir(root) + if err != nil || len(entries) != 3 || entries[0].Name() != "blocked" || entries[2].Name() != "small" { + t.Fatalf("guarded directory ordering = %v, %v", entries, err) + } + } +} + // TestNewUserAwareExecutor_Wrapping pins the wrapping decision. The fix dropped // the old `!inner.IsRoot()` gate so the wrapper also applies under a LaunchAgent // (the agent running as the user, not root). launchd strips PATH in both modes, diff --git a/internal/model/model.go b/internal/model/model.go index 24c0a505..b07982fc 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -787,13 +787,15 @@ type AgentSkill struct { HasShellInjection bool `json:"has_shell_injection,omitempty"` // body has !`cmd` / ```! load-time exec // Attribution - Agent string `json:"agent"` // "claude-code"|"codex"|"opencode"|"cursor"|"pi"|"factory"|"amp"|"copilot"|"gemini-cli"|"aider"|"shared" + Agent string `json:"agent"` // "claude-code"|"codex"|"opencode"|"cursor"|"pi"|"factory"|"amp"|"copilot"|"gemini-cli"|"aider"|"grok-build"|"kimi-code"|"muse-code"|"hermes-agent"|"oh-my-pi"|"shared" Source string `json:"source"` // atomic attribution key. "claude_user"|"claude_project"| // // "agents_user"|"agents_project"|"codex_user"|"codex_system"|"codex_admin"| // // "opencode_user"|"opencode_project"|"cursor_user"|"cursor_project"|"pi_user"| // // "pi_project"|"factory_user"|"factory_project"|"factory_agent_project"| // // "factory_agent_user"|"amp_user"|"copilot_user"|"github_project"| - // // "gemini_user"|"gemini_project"|"aider_project" + // // "gemini_user"|"gemini_project"|"aider_project"|"grok_user"|"grok_project"| + // // "kimi_user"|"kimi_project"|"muse_user"|"hermes_user"|"hermes_project"| + // // "omp_user"|"omp_managed_user"|"omp_project" Scope string `json:"scope"` // "global" | "project" | "system" ProjectPath string `json:"project_path,omitempty"` // project root for project scope PluginName string `json:"plugin_name,omitempty"` // owning plugin, from skills.sh lock pluginName diff --git a/internal/safepath/reader.go b/internal/safepath/reader.go new file mode 100644 index 00000000..3988c46e --- /dev/null +++ b/internal/safepath/reader.go @@ -0,0 +1,210 @@ +package safepath + +import ( + "errors" + "io" + "os" + "path/filepath" + "slices" + "strings" +) + +// Reader provides guarded filesystem access across explicit caller-selected roots. +// It is opt-in; existing Resolver callers retain their original traversal behavior. +type Reader struct { + roots []*Resolver + guard Guard +} + +// NewReader accepts scan roots chosen by the caller, never from inspected metadata. +func NewReader(roots []string, guard Guard) *Reader { + r := &Reader{guard: guard} + for _, root := range roots { + if root != "" { + r.roots = append(r.roots, New(root, guard)) + } + } + return r +} + +func (r *Reader) contains(path string, allowAncestor bool) bool { + for _, root := range r.roots { + if root.Contains(path) { + return true + } + if allowAncestor { + for _, allowed := range root.containmentRoots() { + if rel, err := filepath.Rel(path, allowed); err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return true + } + } + } + } + return false +} + +func (r *Reader) Resolve(path string) (string, error) { + resolved, _, err := r.resolveChain(path) + return resolved, err +} + +func (r *Reader) resolveChain(path string) (string, os.FileInfo, error) { + if path == "" || !filepath.IsAbs(path) { + return "", nil, refuse(ReasonUnresolved) + } + // Keep pending dot-dot components until preceding symlinks are resolved. + current := filepath.FromSlash(path) + + for hop := 0; ; hop++ { + if hop > maxHops { + return "", nil, refuse(ReasonUnresolved) + } + if reason := r.refusedBy(current); reason != "" { + return "", nil, refuse(reason) + } + + volume, comps := splitPendingPath(current) + if len(comps) > maxComponents { + return "", nil, refuse(ReasonUnresolved) + } + + prefix := volume + string(filepath.Separator) + redirected := false + var leaf os.FileInfo + for i, comp := range comps { + if leaf != nil && !leaf.IsDir() { + return "", nil, refuse(ReasonUnresolved) + } + prefix = filepath.Join(prefix, comp) + // Traversal may visit ancestors only to reach an explicitly allowed root. + if !r.contains(prefix, true) { + return "", nil, refuse(ReasonOutsideRoots) + } + if reason := r.refusedBy(prefix); reason != "" { + return "", nil, refuse(reason) + } + info, err := os.Lstat(prefix) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return "", nil, err + } + return "", nil, refuse(ReasonDenied) + } + if !isLink(info) { + leaf = info + continue + } + target, err := os.Readlink(prefix) + if err != nil { + return "", nil, refuse(ReasonUnresolved) + } + if target == "" { + return "", nil, refuse(ReasonUnresolved) + } + if !filepath.IsAbs(target) { + target = filepath.Dir(prefix) + string(filepath.Separator) + target + } + target = filepath.FromSlash(target) + if reason := r.refusedBy(target); reason != "" { + return "", nil, refuse(reason) + } + current = target + if i+1 < len(comps) { + current += string(filepath.Separator) + strings.Join(comps[i+1:], string(filepath.Separator)) + } + if !r.contains(current, false) { + return "", nil, refuse(ReasonOutsideRoots) + } + redirected = true + break + } + if redirected { + continue + } + if !r.contains(prefix, false) { + return "", nil, refuse(ReasonOutsideRoots) + } + return prefix, leaf, nil + } +} + +func (r *Reader) refusedBy(path string) string { + if r.guard == nil { + return "" + } + return r.guard(path) +} + +func (r *Reader) Stat(path string) (os.FileInfo, error) { + _, info, err := r.resolveChain(path) + if err != nil { + return nil, err + } + if info == nil { + return nil, refuse(ReasonUnresolved) + } + return info, nil +} + +// ReadFile refuses nonregular or oversized files without returning partial contents. +func (r *Reader) ReadFile(path string, max int64) ([]byte, error) { + if max <= 0 { + return nil, refuse(ReasonDenied) + } + resolved, err := r.Resolve(path) + if err != nil { + return nil, err + } + f, info, err := openVerified(resolved, false, false) + if err != nil { + return nil, err + } + defer func() { _ = f.Close() }() + if !info.Mode().IsRegular() || info.Size() > max { + return nil, refuse(ReasonDenied) + } + data, err := io.ReadAll(io.LimitReader(f, max)) + if err != nil { + return nil, refuse(ReasonDenied) + } + // Detect growth past the cap without max+1 overflowing. + var extra [1]byte + n, err := f.Read(extra[:]) + if n != 0 || (err != nil && !errors.Is(err, io.EOF)) { + return nil, refuse(ReasonDenied) + } + return data, nil +} + +func (r *Reader) ReadDir(path string) ([]os.DirEntry, error) { + resolved, err := r.Resolve(path) + if err != nil { + return nil, err + } + f, _, err := openVerified(resolved, true, false) + if err != nil { + return nil, err + } + defer func() { _ = f.Close() }() + entries, err := f.ReadDir(-1) + if err != nil { + return nil, refuse(ReasonDenied) + } + slices.SortFunc(entries, func(a, b os.DirEntry) int { return strings.Compare(a.Name(), b.Name()) }) + return entries, nil +} + +func splitPendingPath(path string) (volume string, comps []string) { + volume = filepath.VolumeName(path) + rest := strings.TrimPrefix(path[len(volume):], string(filepath.Separator)) + if rest == "" { + return volume, nil + } + for c := range strings.SplitSeq(rest, string(filepath.Separator)) { + if c == "" { + continue + } + comps = append(comps, c) + } + return volume, comps +} diff --git a/internal/safepath/reader_test.go b/internal/safepath/reader_test.go new file mode 100644 index 00000000..a83480e3 --- /dev/null +++ b/internal/safepath/reader_test.go @@ -0,0 +1,88 @@ +package safepath + +import ( + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestReaderReadFileBounds(t *testing.T) { + root := tempHome(t) + file := filepath.Join(root, "file") + writeFile(t, file, "data") + r := NewReader([]string{root}, nil) + for _, limit := range []int64{-1, 0, 3, 4, 5} { + data, err := r.ReadFile(file, limit) + if limit >= 4 { + if err != nil || string(data) != "data" { + t.Errorf("limit %d: read = %q, %v", limit, data, err) + } + } else if err == nil || len(data) != 0 { + t.Errorf("limit %d: expected refusal without contents, got %q, %v", limit, data, err) + } + } + if data, err := r.ReadFile(root, 4); err == nil || len(data) != 0 { + t.Errorf("directory read = %q, %v; want refusal", data, err) + } + if _, err := NewReader(nil, nil).Resolve(file); ReasonOf(err) != ReasonOutsideRoots { + t.Errorf("empty roots: %v; want outside-roots refusal", err) + } +} + +func TestReaderRoots(t *testing.T) { + base := tempHome(t) + home, project, outside := filepath.Join(base, "home"), filepath.Join(base, "project"), filepath.Join(base, "outside") + for _, root := range []string{home, project, outside} { + writeFile(t, filepath.Join(root, "skill"), "skill") + } + symlink(t, filepath.Join(project, "skill"), filepath.Join(home, "allowed")) + symlink(t, filepath.Join("..", "project", "skill"), filepath.Join(home, "relative")) + symlink(t, filepath.Join(outside, "skill"), filepath.Join(home, "refused")) + roots := []string{home, project} + r := NewReader(roots, nil) + roots[1] = outside // caller mutation must not broaden the resolver. + if got, err := r.Resolve(filepath.Join(home, "allowed")); err != nil || got != filepath.Join(project, "skill") { + t.Fatalf("explicit-root link = %q, %v", got, err) + } + if got, err := r.Resolve(filepath.Join(home, "relative")); err != nil || got != filepath.Join(project, "skill") { + t.Fatalf("relative explicit-root link = %q, %v", got, err) + } + if _, err := r.Resolve(filepath.Join(home, "refused")); ReasonOf(err) != ReasonOutsideRoots { + t.Fatalf("undeclared target error = %v, want outside-roots refusal", err) + } + if _, err := New(home, nil).Resolve(filepath.Join(home, "allowed")); ReasonOf(err) != ReasonOutsideRoots { + t.Fatalf("single-home resolver error = %v, want outside-roots refusal", err) + } +} + +func TestReaderDotDotAfterSymlinkPreservesTarget(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Unix symlink traversal semantics") + } + home := tempHome(t) + writeFile(t, filepath.Join(home, "actual", "skill"), "actual") + writeFile(t, filepath.Join(home, "skill"), "lexical-decoy") + if err := os.Mkdir(filepath.Join(home, "actual", "inner"), 0o755); err != nil { + t.Fatal(err) + } + symlink(t, filepath.Join(home, "actual", "inner"), filepath.Join(home, "alias")) + raw := home + "/alias/../skill" + want, err := filepath.EvalSymlinks(raw) + if err != nil { + t.Fatal(err) + } + got, err := NewReader([]string{home}, nil).Resolve(raw) + if err != nil || got != want { + t.Fatalf("Resolve(%q) = %q, %v; want real target %q", raw, got, err, want) + } + symlink(t, "alias/../skill", filepath.Join(home, "relative")) + if got, err := NewReader([]string{home}, nil).Resolve(filepath.Join(home, "relative")); err != nil || got != want { + t.Fatalf("relative target = %q, %v; want %q", got, err, want) + } + for _, suffix := range []string{"/../skill", "/."} { + if _, err := NewReader([]string{home}, nil).Resolve(filepath.Join(home, "skill") + suffix); err == nil { + t.Errorf("file followed by %q must reject non-directory traversal", suffix) + } + } +} diff --git a/internal/versionmeta/linux.go b/internal/versionmeta/linux.go index 32e1e97b..d5dea9bf 100644 --- a/internal/versionmeta/linux.go +++ b/internal/versionmeta/linux.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/step-security/dev-machine-guard/internal/executor" + "github.com/step-security/dev-machine-guard/internal/model" ) const ( @@ -117,7 +118,7 @@ func normalizeDebianVersion(v string) string { if i := strings.LastIndex(v, "-"); i > 0 { v = v[:i] } - if !isVersionLike(v) { + if !IsVersionLike(v) { return "" } return v @@ -138,7 +139,7 @@ func versionFromAppImage(resolved, base string) string { parts := strings.Split(stem, "-") for i := 1; i < len(parts); i++ { - if !isVersionLike(parts[i]) { + if !IsVersionLike(parts[i]) { continue } if matchesTool(strings.Join(parts[:i], "-"), base) { @@ -179,8 +180,21 @@ func versionFromSnap(exec executor.Executor, binaryPath string) string { version = strings.Trim(strings.TrimSpace(strings.TrimPrefix(line, "version:")), `"'`) } } - if name != snapName || !isVersionLike(version) { + if name != snapName || !IsVersionLike(version) { return "" } return strings.TrimPrefix(version, "v") } + +// DpkgPackageVersion returns the installed version of the Debian package pkg +// when that package's own file manifest lists one of paths, "" otherwise. It +// is versionFromDpkg with the package name supplied by the caller instead of +// derived from the binary's basename, for ladders that already know which +// package they are proving (kiro-cli, whose binary and package share a name +// but whose PATH aliases `kiro` and `q` do not). +func DpkgPackageVersion(exec executor.Executor, pkg string, paths ...string) string { + if exec.GOOS() != model.PlatformLinux { + return "" + } + return versionFromDpkg(exec, pkg, paths) +} diff --git a/internal/versionmeta/linux_test.go b/internal/versionmeta/linux_test.go index 8aa3fde1..1e668547 100644 --- a/internal/versionmeta/linux_test.go +++ b/internal/versionmeta/linux_test.go @@ -257,3 +257,38 @@ func TestFromBinary_SnapAliasHasNoManifest(t *testing.T) { t.Errorf("FromBinary = %q, want \"\"", got) } } + +// DpkgPackageVersion is versionFromDpkg with the package named by the caller, +// for a binary whose PATH aliases do not share the package's name. +func TestDpkgPackageVersion(t *testing.T) { + const status = "Package: kiro-cli\nStatus: install ok installed\nVersion: 2.21.1-1\n\n" + newMock := func(goos, list string) *executor.Mock { + m := executor.NewMock() + m.SetGOOS(goos) + m.SetFile("/var/lib/dpkg/info/kiro-cli.list", []byte(list)) + m.SetFile("/var/lib/dpkg/status", []byte(status)) + return m + } + cases := []struct { + name string + mock *executor.Mock + paths []string + want string + }{ + {"owns the alias's target", newMock("linux", "/usr/bin/kiro-cli\n"), []string{"/usr/local/bin/q", "/usr/bin/kiro-cli"}, "2.21.1"}, + {"owns nothing passed", newMock("linux", "/usr/bin/kiro-cli\n"), []string{"/home/u/.local/bin/kiro-cli"}, ""}, + {"not linux", newMock("darwin", "/usr/bin/kiro-cli\n"), []string{"/usr/bin/kiro-cli"}, ""}, + {"empty package name", newMock("linux", "/usr/bin/kiro-cli\n"), nil, ""}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + pkg := "kiro-cli" + if c.name == "empty package name" { + pkg = "" + } + if got := DpkgPackageVersion(c.mock, pkg, c.paths...); got != c.want { + t.Errorf("got %q, want %q", got, c.want) + } + }) + } +} diff --git a/internal/versionmeta/versionmeta.go b/internal/versionmeta/versionmeta.go index 5574f773..d8439bbd 100644 --- a/internal/versionmeta/versionmeta.go +++ b/internal/versionmeta/versionmeta.go @@ -61,7 +61,7 @@ func FromBinary(ctx context.Context, exec executor.Executor, binaryPath string) if root := packageRoot(exec, resolved); root != "" { name, version := npmManifest(exec, root) - if matchesTool(name, base) && isVersionLike(version) { + if matchesTool(name, base) && IsVersionLike(version) { return version } // Inside a node_modules tree but the manifest doesn't claim this @@ -145,7 +145,7 @@ func versionFromVersionsDir(resolved, base string) string { if segments[i] != "versions" { continue } - if matchesTool(segments[i-1], base) && isVersionLike(segments[i+1]) { + if matchesTool(segments[i-1], base) && IsVersionLike(segments[i+1]) { return segments[i+1] } } @@ -163,7 +163,7 @@ func versionFromHomebrew(resolved string) string { continue } v := stripHomebrewRevision(segments[i+2]) - if isVersionLike(v) { + if IsVersionLike(v) { return v } } @@ -189,7 +189,7 @@ func versionFromAppBundle(ctx context.Context, exec executor.Executor, resolved return "" } v := strings.TrimSpace(stdout) - if !isVersionLike(v) { + if !IsVersionLike(v) { return "" } return v @@ -221,10 +221,10 @@ func matchesTool(name, base string) bool { return name == base || strings.HasPrefix(name, base+"-") || strings.HasPrefix(name, base+"@") } -// isVersionLike reports whether s looks like a version: optional "v", then a +// IsVersionLike reports whether s looks like a version: optional "v", then a // digit, at least one dot, and only [0-9A-Za-z.+_-] throughout. Rejects // Caskroom "version,build" composites — callers fall back to exec for those. -func isVersionLike(s string) bool { +func IsVersionLike(s string) bool { s = strings.TrimPrefix(s, "v") if s == "" || s[0] < '0' || s[0] > '9' || !strings.Contains(s, ".") { return false diff --git a/internal/versionmeta/versionmeta_test.go b/internal/versionmeta/versionmeta_test.go index 4af17f8e..2c3d7c86 100644 --- a/internal/versionmeta/versionmeta_test.go +++ b/internal/versionmeta/versionmeta_test.go @@ -215,8 +215,8 @@ func TestIsVersionLike(t *testing.T) { {"", false}, } for _, tc := range tests { - if got := isVersionLike(tc.in); got != tc.want { - t.Errorf("isVersionLike(%q) = %v, want %v", tc.in, got, tc.want) + if got := IsVersionLike(tc.in); got != tc.want { + t.Errorf("IsVersionLike(%q) = %v, want %v", tc.in, got, tc.want) } } }