gsh/libgsh/src/classifier.rs
Tyler J King 63a6c0c520 feat: gsh human mode — interactive governed shell with reedline
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>
2026-04-02 15:44:34 -04:00

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
));
}
}