Adds a non-git command interceptor (the analogue of the existing git interceptor) so the Control GUI feed and logs show the ground truth of what an agent's shell actually ran inside its sandbox container. Two complementary layers, both POSTing to a new bearer-token-gated POST /command-event on the in-app approval server: - Output-capturing Node multi-call shim symlinked at /usr/local/bin/<cmd> for a curated coreutils/search/text set (cat, grep, find, ls, sed, awk, wc, diff, …). Runs the real binary transparently (streaming stdio, exit/ signal preserved) and reports argv, cwd, exit, wall-clock duration, and a capped 4 KiB head of stdout/stderr. Resolves the real binary outside the shim dir(s) so it never recurses or clobbers the node ecosystem that lives in /usr/local/bin on the base image. - A BASH_ENV DEBUG/EXIT command tracer capturing every command (metadata only) for breadth — builtins, npm/make/python, pipeline stages — batch- posted and incrementally flushed; skips shimmed commands to avoid double counting. Host side: MCPApprovalServer.handleCommandEvent/parseCommandReports -> ClaudeCodeBackend.handleCommandReport -> ConflictCoordinator.observeCommand -> AppStore.observeCommand (classify via new CommandSummary, gate to Nucleic Control sessions, capped feed + CMDTRACE os_log) -> new "Commands" section in the Control panel. Both layers gated to Control containers, like the git shim. Hardening from an adversarial multi-agent review: shim self-dir skip (no recursion), 4 MiB request cap on the approval server, pre-allocated capped capture, locale-robust EPOCHREALTIME parsing, incremental tracer flush, read-only tracer install. Tests: CommandSummary classifier (incl. never-nil contract), /command-event single + batch + bad-token, AppStore record + control-gating. Full suite green (404 tests). Shim + tracer + poster validated end-to-end against a mock collector. Co-Authored-By: Claude Opus 4.8 <[email protected]>
282 lines
17 KiB
Swift
282 lines
17 KiB
Swift
import Foundation
|
|
|
|
/// Turns a non-git shell command an agent ran — `grep`, `cat`, `find`, `ls`, `sed`, `npm`, … —
|
|
/// into a short, human phrase + glyph for the Control panel's command-activity feed and the
|
|
/// structured log. This is the non-git counterpart to ``GitCommandSummary``: where that one
|
|
/// models git subcommands, this models the ordinary commands agents lean on in Bash
|
|
/// (file inspection, search, text munging, build/run), as reported by the in-container
|
|
/// command-interceptor shim and the bash command tracer.
|
|
///
|
|
/// Unlike `GitCommandSummary.classify` (which returns nil for anything it doesn't recognize),
|
|
/// this ALWAYS yields an ``ObservedCommand`` for a non-empty argv: every command the shim/tracer
|
|
/// bothered to report is worth a feed row, so an unrecognized command falls back to a generic
|
|
/// "Run <cmd>" entry rather than being dropped. The recognized cases get a friendlier label,
|
|
/// a category, and a fitting SF Symbol.
|
|
public enum CommandSummary {
|
|
/// A command observed with certainty from a raw argv (or tokenized command line). Carries a
|
|
/// glanceable label, a coarse `category` (for grouping/coloring), an SF Symbol for the feed
|
|
/// glyph, and whether the op mutates the filesystem (so a consumer can flag writes/deletes).
|
|
public struct ObservedCommand: Equatable, Sendable {
|
|
/// The resolved command name (argv[0]'s basename), e.g. `grep`, `cat`, `npm`.
|
|
public let command: String
|
|
/// A coarse bucket: `read`, `search`, `list`, `text`, `fs`, `delete`, `build`, `run`,
|
|
/// `network`, `archive`, `info`, or `other`.
|
|
public let category: String
|
|
/// Human one-liner (e.g. `Search for "TODO"`, `Read file.txt`, `List src/`).
|
|
public let label: String
|
|
/// SF Symbol name for the op's glyph in the feed.
|
|
public let symbol: String
|
|
/// True for commands that write/delete on the filesystem (`rm`, `cp`, `mv`, `mkdir`, …),
|
|
/// so the UI can emphasize them; false for the read/search/inspect majority.
|
|
public let isMutating: Bool
|
|
/// True when the command word matched a case we specifically model (vs. the generic
|
|
/// "Run <cmd>" fallback) — lets a consumer treat recognized ops with more confidence.
|
|
public let recognized: Bool
|
|
|
|
public init(
|
|
command: String, category: String, label: String, symbol: String,
|
|
isMutating: Bool, recognized: Bool
|
|
) {
|
|
self.command = command
|
|
self.category = category
|
|
self.label = label
|
|
self.symbol = symbol
|
|
self.isMutating = isMutating
|
|
self.recognized = recognized
|
|
}
|
|
}
|
|
|
|
/// Classify a tokenized argv (the command word at index 0, then its arguments — exactly what
|
|
/// the interceptor shim reports). Returns nil only for an empty argv.
|
|
public static func classify(argv: [String]) -> ObservedCommand? {
|
|
let stripped = ShellLexer.stripLeadingPrefixes(argv)
|
|
guard let head = stripped.first, !head.isEmpty else { return nil }
|
|
let name = basename(head)
|
|
let args = Array(stripped.dropFirst())
|
|
return classify(name: name, args: args)
|
|
}
|
|
|
|
/// As `classify(argv:)`, but from a raw command line (the form the bash tracer reports). The
|
|
/// line is tokenized with the shared ``ShellLexer`` so quoting/heredocs match the rest of the
|
|
/// summarizers; only the first segment's leading command is described.
|
|
public static func classify(commandLine: String) -> ObservedCommand? {
|
|
let (flattened, _) = ShellLexer.stripHeredocs(commandLine)
|
|
guard let segment = ShellLexer.splitSegments(flattened).first else { return nil }
|
|
return classify(argv: ShellLexer.tokenize(segment))
|
|
}
|
|
|
|
// MARK: - Core mapping
|
|
|
|
private static func classify(name: String, args: [String]) -> ObservedCommand {
|
|
switch name {
|
|
// ── Reading file contents ────────────────────────────────────────────────────────────
|
|
case "cat", "bat", "less", "more", "tac":
|
|
return read("Read \(fileOperand(args) ?? "input")", name)
|
|
case "head":
|
|
return read(fileOperand(args).map { "Show start of \($0)" } ?? "Show file head", name)
|
|
case "tail":
|
|
return read(fileOperand(args).map { "Show end of \($0)" } ?? "Show file tail", name)
|
|
case "od", "xxd", "hexdump", "strings":
|
|
return read(fileOperand(args).map { "Inspect bytes of \($0)" } ?? "Inspect bytes", name)
|
|
|
|
// ── Searching ────────────────────────────────────────────────────────────────────────
|
|
case "grep", "egrep", "fgrep", "rg", "ag", "ack", "ugrep":
|
|
return op("search", searchLabel(args), "magnifyingglass", name, mutating: false)
|
|
case "find", "fd", "fdfind":
|
|
return op("search", findLabel(args), "folder.badge.questionmark", name, mutating: false)
|
|
case "locate", "mdfind", "which", "whereis", "type", "command":
|
|
return op("search", "Locate \(firstPositional(args) ?? "a command")", "magnifyingglass", name, mutating: false)
|
|
|
|
// ── Listing ──────────────────────────────────────────────────────────────────────────
|
|
case "ls", "exa", "eza", "ll", "tree", "lsd":
|
|
return op("list", listLabel(args), "list.bullet", name, mutating: false)
|
|
case "pwd", "dirname", "basename", "realpath", "readlink":
|
|
return op("info", "Resolve a path", "folder", name, mutating: false)
|
|
|
|
// ── Text processing ──────────────────────────────────────────────────────────────────
|
|
case "sed":
|
|
return op("text", "Transform text (sed)", "text.append", name, mutating: false)
|
|
case "awk", "gawk", "mawk":
|
|
return op("text", "Process text (awk)", "text.append", name, mutating: false)
|
|
case "wc":
|
|
return op("text", fileOperand(args).map { "Count lines in \($0)" } ?? "Count lines", "number", name, mutating: false)
|
|
case "sort", "uniq", "cut", "tr", "paste", "join", "comm", "column", "fold", "fmt", "rev", "nl", "expand", "tee":
|
|
return op("text", "Process text (\(name))", "text.alignleft", name, mutating: false)
|
|
case "jq", "yq", "xmllint":
|
|
return op("text", "Query structured data (\(name))", "curlybraces", name, mutating: false)
|
|
case "diff", "cmp", "comm-diff", "delta":
|
|
return op("text", "Compare files", "plusminus", name, mutating: false)
|
|
case "echo", "printf", "yes", "seq":
|
|
return op("text", "Print text", "text.bubble", name, mutating: false)
|
|
case "xargs":
|
|
return op("run", "Run a command over input (xargs)", "arrow.triangle.branch", name, mutating: false)
|
|
|
|
// ── Filesystem mutations ─────────────────────────────────────────────────────────────
|
|
case "rm":
|
|
return op("delete", deleteLabel(args), "trash", name, mutating: true)
|
|
case "rmdir":
|
|
return op("delete", "Remove directory \(firstPositional(args) ?? "")".trimmingCharacters(in: .whitespaces), "trash", name, mutating: true)
|
|
case "mkdir":
|
|
return op("fs", "Create directory \(firstPositional(args) ?? "")".trimmingCharacters(in: .whitespaces), "folder.badge.plus", name, mutating: true)
|
|
case "touch":
|
|
return op("fs", "Touch \(firstPositional(args) ?? "a file")", "doc.badge.plus", name, mutating: true)
|
|
case "cp", "rsync", "install":
|
|
return op("fs", "Copy files", "doc.on.doc", name, mutating: true)
|
|
case "mv", "rename":
|
|
return op("fs", "Move/rename files", "arrow.left.arrow.right", name, mutating: true)
|
|
case "ln":
|
|
return op("fs", "Create a link", "link", name, mutating: true)
|
|
case "chmod", "chown", "chgrp", "umask":
|
|
return op("fs", "Change permissions", "lock.shield", name, mutating: true)
|
|
|
|
// ── Build / package / run ────────────────────────────────────────────────────────────
|
|
case "npm", "pnpm", "yarn", "bun", "npx", "pip", "pip3", "poetry", "uv", "gem", "bundle", "cargo", "go", "mvn", "gradle", "brew", "apt", "apt-get":
|
|
return op("build", "Package/build: \(name) \(firstPositional(args) ?? "")".trimmingCharacters(in: .whitespaces), "shippingbox", name, mutating: true)
|
|
case "make", "cmake", "ninja", "swift", "xcodebuild", "tsc", "webpack", "vite", "rollup", "esbuild":
|
|
return op("build", "Build (\(name))", "hammer", name, mutating: true)
|
|
case "gcc", "g++", "clang", "clang++", "cc", "rustc", "javac", "ld":
|
|
return op("build", "Compile (\(name))", "hammer", name, mutating: true)
|
|
case "node", "deno", "python", "python3", "ruby", "perl", "php", "bash", "sh", "zsh":
|
|
return op("run", runLabel(name, args), "terminal", name, mutating: false)
|
|
case "pytest", "jest", "vitest", "mocha", "rspec", "tox":
|
|
return op("run", "Run tests (\(name))", "checkmark.diamond", name, mutating: false)
|
|
|
|
// ── Network ──────────────────────────────────────────────────────────────────────────
|
|
case "curl", "wget", "http", "https":
|
|
return op("network", networkLabel(name, args), "network", name, mutating: false)
|
|
case "ssh", "scp", "sftp", "rsync-remote", "nc", "ncat", "telnet", "ping", "dig", "nslookup", "host":
|
|
return op("network", "Network (\(name))", "network", name, mutating: false)
|
|
|
|
// ── Archives ─────────────────────────────────────────────────────────────────────────
|
|
case "tar", "zip", "unzip", "gzip", "gunzip", "bzip2", "xz", "zstd", "7z", "unrar":
|
|
return op("archive", "Archive (\(name))", "archivebox", name, mutating: true)
|
|
|
|
// ── Process / system info ────────────────────────────────────────────────────────────
|
|
case "ps", "top", "htop", "kill", "pkill", "killall", "jobs", "wait", "nohup", "timeout":
|
|
return op("info", "Process control (\(name))", "gauge", name, mutating: false)
|
|
case "df", "du", "free", "uname", "hostname", "whoami", "id", "uptime", "date", "env", "printenv", "lsof", "stat", "file":
|
|
return op("info", "System info (\(name))", "info.circle", name, mutating: false)
|
|
case "cd", "pushd", "popd", "export", "set", "unset", "source", "alias", "true", "false", "test", "[", "[[", "read":
|
|
return op("other", "Shell builtin (\(name))", "chevron.left.forwardslash.chevron.right", name, mutating: false)
|
|
|
|
default:
|
|
// Unknown command: keep it in the feed with a generic label so nothing the agent ran
|
|
// is silently invisible. Include the first operand for a touch of context.
|
|
let detail = firstPositional(args).map { " \($0)" } ?? ""
|
|
return ObservedCommand(
|
|
command: name, category: "other",
|
|
label: "Run \(name)\(clip(detail, 32))", symbol: "terminal",
|
|
isMutating: false, recognized: false)
|
|
}
|
|
}
|
|
|
|
// MARK: - Label builders
|
|
|
|
/// `Search for "<pattern>"` — the first non-flag operand is the pattern for grep-family tools.
|
|
private static func searchLabel(_ args: [String]) -> String {
|
|
guard let pattern = firstPositional(args) else { return "Search files" }
|
|
return "Search for \(quote(clip(pattern, 40)))"
|
|
}
|
|
|
|
/// `Find files matching <glob>` for `find -name <pat>`, else `Find in <dir>` / `Find files`.
|
|
private static func findLabel(_ args: [String]) -> String {
|
|
if let glob = ShellLexer.flagValue(args, short: "-name", long: "-iname")
|
|
?? ShellLexer.flagValue(args, short: "-path", long: "-ipath") {
|
|
return "Find files matching \(quote(clip(glob, 36)))"
|
|
}
|
|
if let dir = firstPositional(args) { return "Find files in \(clip(dir, 36))" }
|
|
return "Find files"
|
|
}
|
|
|
|
/// `List <dir>` naming the directory operand when present, else a bare `List directory`.
|
|
private static func listLabel(_ args: [String]) -> String {
|
|
guard let dir = firstPositional(args) else { return "List directory" }
|
|
return "List \(clip(dir, 40))"
|
|
}
|
|
|
|
/// `Delete <target>` with `(recursive, forced)` notes for the dangerous flags — mirrors
|
|
/// ``ShellCommandSummary`` so an `rm` reads the same everywhere it surfaces.
|
|
private static func deleteLabel(_ args: [String]) -> String {
|
|
let recursive = hasShortOrLong(args, shortChars: ["r", "R"], long: ["--recursive"])
|
|
let force = hasShortOrLong(args, shortChars: ["f"], long: ["--force"])
|
|
var notes: [String] = []
|
|
if recursive { notes.append("recursive") }
|
|
if force { notes.append("forced") }
|
|
let suffix = notes.isEmpty ? "" : " (\(notes.joined(separator: ", ")))"
|
|
let target: String
|
|
let paths = ShellLexer.positionals(args, valueFlags: [])
|
|
switch paths.count {
|
|
case 0: target = "files"
|
|
case 1: target = clip(paths[0], 40)
|
|
default: target = "\(paths.count) paths"
|
|
}
|
|
return "Delete \(target)\(suffix)"
|
|
}
|
|
|
|
/// `Run <script> (python)` naming the first script-like operand for interpreters.
|
|
private static func runLabel(_ name: String, _ args: [String]) -> String {
|
|
if let script = firstPositional(args) { return "Run \(clip(script, 36)) (\(name))" }
|
|
return "Run \(name)"
|
|
}
|
|
|
|
/// `Fetch <url>` naming the first URL-ish operand for curl/wget.
|
|
private static func networkLabel(_ name: String, _ args: [String]) -> String {
|
|
if let url = ShellLexer.positionals(args, valueFlags: []).first(where: { $0.contains("://") || $0.contains(".") }) {
|
|
return "Fetch \(clip(url, 44))"
|
|
}
|
|
return "Network (\(name))"
|
|
}
|
|
|
|
// MARK: - Small helpers
|
|
|
|
private static func op(
|
|
_ category: String, _ label: String, _ symbol: String, _ command: String, mutating: Bool
|
|
) -> ObservedCommand {
|
|
ObservedCommand(
|
|
command: command, category: category, label: label, symbol: symbol,
|
|
isMutating: mutating, recognized: true)
|
|
}
|
|
|
|
private static func read(_ label: String, _ command: String) -> ObservedCommand {
|
|
op("read", label, "doc.text", command, mutating: false)
|
|
}
|
|
|
|
/// The first operand that looks like a file (the search/read target), preferring an operand
|
|
/// with a path separator or extension so `grep -n foo bar.txt` names `bar.txt`, not `foo`.
|
|
private static func fileOperand(_ args: [String]) -> String? {
|
|
let positionals = ShellLexer.positionals(args, valueFlags: [])
|
|
if let pathLike = positionals.first(where: { $0.contains("/") || $0.contains(".") }) {
|
|
return clip(pathLike, 40)
|
|
}
|
|
return positionals.first.map { clip($0, 40) }
|
|
}
|
|
|
|
private static func firstPositional(_ args: [String]) -> String? {
|
|
ShellLexer.positionals(args, valueFlags: []).first
|
|
}
|
|
|
|
/// The command word's basename, sans any path (`/usr/bin/grep` → `grep`).
|
|
private static func basename(_ command: String) -> String {
|
|
command.split(separator: "/").last.map(String.init) ?? command
|
|
}
|
|
|
|
private static func quote(_ s: String) -> String { "\"\(s)\"" }
|
|
|
|
private static func clip(_ s: String, _ max: Int) -> String {
|
|
s.count <= max ? s : String(s.prefix(max - 1)) + "…"
|
|
}
|
|
|
|
/// Whether any token sets one of `shortChars` (bundled like `-rf`) or one of the `long` flags.
|
|
private static func hasShortOrLong(_ args: [String], shortChars: Set<Character>, long: Set<String>) -> Bool {
|
|
for a in args {
|
|
if a == "--" { break }
|
|
if long.contains(a) { return true }
|
|
if a.hasPrefix("--") { continue }
|
|
if a.hasPrefix("-"), a.count > 1, a.dropFirst().contains(where: { shortChars.contains($0) }) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
}
|