Phase 3 / Sprint 2 finish line. Human mode: reedline REPL with governed prompt. [governed] tyler@gsh:~$ Mode detection: --exec "cmd" → machine mode (unchanged) --ungoverned --exec "cmd" → ungoverned machine (unchanged) (no --exec, TTY attached) → human mode (NEW) (no --exec, no TTY) → error Command classification per-keystroke (libgsh/classifier.rs): Free: ls, cat, grep, echo, cd, git, ssh, curl — zero overhead Governed: binaries in corpus dir — via org-ops wrapper, CR posted Ungoverned: not in corpus but on PATH — warn + execute Denied: corpus manifest but removed — killswitch active Session lifecycle: Start: validate AC, post SESSION_STARTED CR, print banner Active: classify each command, governed ops post lightweight CRs End: print summary (governed/free/denied/ungoverned), post SESSION_ENDED CR Banner: principal, corpus, session ID, expiry, risk level Prompt coloring from risk level: Baseline/Standard: green [governed] Elevated: yellow [elevated] High/Critical: red [HIGH] New modules: libgsh/classifier.rs — command classification against corpus (4 tests) libgsh/session.rs — session state tracking gsh/human.rs — reedline REPL, prompt, banner, summary Machine mode: zero changes (regression tested). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
142 lines
4.4 KiB
Rust
142 lines
4.4 KiB
Rust
//! Command classification against the corpus.
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
/// How a command is classified.
|
|
#[derive(Debug)]
|
|
pub enum CommandClass {
|
|
/// In corpus directory — governed execution via org-ops wrapper.
|
|
Governed { corpus_binary: PathBuf },
|
|
/// Not in corpus but exists on PATH — allowed but untracked.
|
|
Ungoverned,
|
|
/// Was in corpus manifest but binary removed — DENIED (killswitch).
|
|
Denied { reason: String },
|
|
/// Shell builtins and navigation — zero governance overhead.
|
|
Free,
|
|
}
|
|
|
|
/// Free commands: zero governance overhead.
|
|
pub const FREE_COMMANDS: &[&str] = &[
|
|
"ls", "ll", "la", "dir", "cat", "head", "tail", "less", "more",
|
|
"grep", "awk", "sed", "echo", "printf", "pwd", "cd", "pushd", "popd",
|
|
"env", "export", "set", "unset", "which", "whereis", "type", "file",
|
|
"stat", "wc", "sort", "uniq", "tr", "cut", "date", "cal", "whoami",
|
|
"id", "hostname", "uname", "clear", "history", "alias", "true", "false",
|
|
"test", "man", "help", "fg", "bg", "jobs", "kill", "ps", "top",
|
|
"df", "du", "free", "uptime", "find", "xargs", "tee", "touch",
|
|
"mkdir", "rmdir", "cp", "mv", "rm", "ln", "chmod", "chown",
|
|
"diff", "patch", "tar", "gzip", "gunzip", "zip", "unzip",
|
|
"ssh", "scp", "rsync", "curl", "wget", "ping", "dig", "nslookup",
|
|
"git", "vim", "vi", "nano", "tree", "watch", "source", ".",
|
|
];
|
|
|
|
/// Classify a command against the corpus.
|
|
pub fn classify_command(
|
|
command_line: &str,
|
|
corpus_cid: &str,
|
|
corpus_base: &Path,
|
|
) -> CommandClass {
|
|
let cmd_name = command_line.split_whitespace().next().unwrap_or("");
|
|
if cmd_name.is_empty() {
|
|
return CommandClass::Free;
|
|
}
|
|
|
|
// Strip path prefix to get bare name:
|
|
let bare_name = Path::new(cmd_name)
|
|
.file_name()
|
|
.map(|n| n.to_string_lossy().to_string())
|
|
.unwrap_or_else(|| cmd_name.to_string());
|
|
|
|
// Free commands — zero overhead:
|
|
if FREE_COMMANDS.contains(&bare_name.as_str()) {
|
|
return CommandClass::Free;
|
|
}
|
|
|
|
// Ungoverned corpus — everything is free:
|
|
if corpus_cid == "sha256:ungoverned" {
|
|
return CommandClass::Free;
|
|
}
|
|
|
|
// Check corpus directory:
|
|
let corpus_dir = corpus_base.join(corpus_cid);
|
|
if !corpus_dir.exists() {
|
|
// No corpus mounted — treat as ungoverned
|
|
return CommandClass::Ungoverned;
|
|
}
|
|
|
|
let binary_path = corpus_dir.join(&bare_name);
|
|
if binary_path.exists() {
|
|
CommandClass::Governed {
|
|
corpus_binary: binary_path,
|
|
}
|
|
} else {
|
|
// Not in corpus dir — could be a PATH binary or truly missing
|
|
// Check if it's on PATH:
|
|
let on_path = std::process::Command::new("which")
|
|
.arg(&bare_name)
|
|
.output()
|
|
.map(|o| o.status.success())
|
|
.unwrap_or(false);
|
|
|
|
if on_path {
|
|
CommandClass::Ungoverned
|
|
} else {
|
|
CommandClass::Denied {
|
|
reason: format!("'{}' not found in corpus or PATH", bare_name),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_free_commands() {
|
|
let base = Path::new("/nonexistent");
|
|
assert!(matches!(
|
|
classify_command("ls -la", "sha256:test", base),
|
|
CommandClass::Free
|
|
));
|
|
assert!(matches!(
|
|
classify_command("echo hello", "sha256:test", base),
|
|
CommandClass::Free
|
|
));
|
|
assert!(matches!(
|
|
classify_command("cat /etc/hosts", "sha256:test", base),
|
|
CommandClass::Free
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn test_ungoverned_corpus_is_free() {
|
|
let base = Path::new("/nonexistent");
|
|
assert!(matches!(
|
|
classify_command("kubectl get pods", "sha256:ungoverned", base),
|
|
CommandClass::Free
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn test_governed_binary() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let cid = "sha256:test-corpus";
|
|
let corpus_dir = dir.path().join(cid);
|
|
std::fs::create_dir_all(&corpus_dir).unwrap();
|
|
std::fs::write(corpus_dir.join("my-tool"), "#!/bin/bash\necho ok").unwrap();
|
|
|
|
assert!(matches!(
|
|
classify_command("my-tool --flag", cid, dir.path()),
|
|
CommandClass::Governed { .. }
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn test_empty_command() {
|
|
assert!(matches!(
|
|
classify_command("", "sha256:test", Path::new("/")),
|
|
CommandClass::Free
|
|
));
|
|
}
|
|
}
|