1414//! --kill SIGKILL the traced tree on detection
1515//! --match <substr> (scan) attach to processes whose name/cmdline matches
1616//! --all (scan) attach to every process we're allowed to trace
17+ //! --ui live full-screen dashboard instead of the log stream
1718//! --no-stack-pivot disable the ROP stack-pivot heuristic
1819//! --audit-sensitive also log sensitive syscalls from legitimate code
1920//! --quiet suppress the human event stream (use with --json)
@@ -26,6 +27,7 @@ use std::process::ExitCode;
2627use wraith:: detect:: { Config , Enforcement } ;
2728use wraith:: event:: { Event , Severity } ;
2829use wraith:: tracer:: Tracer ;
30+ use wraith:: ui:: { Dashboard , TerminalGuard } ;
2931
3032fn main ( ) -> ExitCode {
3133 let args: Vec < String > = std:: env:: args ( ) . skip ( 1 ) . collect ( ) ;
@@ -42,6 +44,7 @@ struct Opts {
4244 json : Option < String > ,
4345 min : Severity ,
4446 quiet : bool ,
47+ ui : bool ,
4548 cfg : Config ,
4649}
4750
@@ -58,6 +61,7 @@ fn run(args: Vec<String>) -> io::Result<ExitCode> {
5861 json : None ,
5962 min : Severity :: Warn ,
6063 quiet : false ,
64+ ui : false ,
6165 cfg : Config :: default ( ) ,
6266 } ;
6367
@@ -98,6 +102,7 @@ fn run(args: Vec<String>) -> io::Result<ExitCode> {
98102 scan_matches. push ( v. clone ( ) ) ;
99103 }
100104 "--all" => scan_all = true ,
105+ "--ui" | "--dashboard" => opts. ui = true ,
101106 "--no-stack-pivot" => opts. cfg . detect_stack_pivot = false ,
102107 "--audit-sensitive" => opts. cfg . audit_sensitive = true ,
103108 "--quiet" => opts. quiet = true ,
@@ -114,45 +119,52 @@ fn run(args: Vec<String>) -> io::Result<ExitCode> {
114119
115120 // Set up output sinks.
116121 let color = io:: stderr ( ) . is_terminal ( ) ;
117- let mut json_sink: Option < Box < dyn Write > > = match opts. json . as_deref ( ) {
122+ let json_sink: Option < Box < dyn Write > > = match opts. json . as_deref ( ) {
118123 None => None ,
119124 Some ( "-" ) => Some ( Box :: new ( io:: stdout ( ) ) ) ,
120125 Some ( path) => Some ( Box :: new ( File :: create ( path) ?) ) ,
121126 } ;
122127 let min = opts. min ;
123128 let quiet = opts. quiet ;
129+ let ui = opts. ui ;
130+ let enforcement = opts. cfg . enforcement ;
124131
125- let mut on_event = |ev : & Event | {
126- if ev. severity >= min {
127- if !quiet {
128- let _ = writeln ! ( io:: stderr( ) , "{}" , ev. to_line( color) ) ;
129- }
130- if let Some ( sink) = json_sink. as_mut ( ) {
131- let _ = writeln ! ( sink, "{}" , ev. to_json( ) ) ;
132- }
133- }
134- } ;
132+ if ui && !io:: stderr ( ) . is_terminal ( ) {
133+ return Err ( bad (
134+ "--ui needs an interactive terminal on stderr; drop --ui, or use --json for a stream" ,
135+ ) ) ;
136+ }
135137
136- let enforce_note = match opts . cfg . enforcement {
138+ let enforce_note = match enforcement {
137139 Enforcement :: Observe => "" ,
138140 Enforcement :: Block => " [enforcing: block]" ,
139141 Enforcement :: Kill => " [enforcing: kill]" ,
140142 } ;
141143
144+ // A short label for the run, used by the dashboard header. Assigned by
145+ // every non-returning arm below.
146+ let ui_label;
147+
142148 let tracer = match mode. as_str ( ) {
143149 "run" => {
144150 if target. is_empty ( ) {
145151 return Err ( bad ( "no program to run; use: wraith run -- <program> [args...]" ) ) ;
146152 }
147- eprintln ! (
148- "wraith: monitoring `{}` (provenance mode){enforce_note}" ,
149- target. join( " " )
150- ) ;
153+ ui_label = format ! ( "run — {}" , target. join( " " ) ) ;
154+ if !ui {
155+ eprintln ! (
156+ "wraith: monitoring `{}` (provenance mode){enforce_note}" ,
157+ target. join( " " )
158+ ) ;
159+ }
151160 Tracer :: spawn ( & target, opts. cfg ) ?
152161 }
153162 "attach" => {
154163 let pid = attach_pid. ok_or_else ( || bad ( "attach needs a pid" ) ) ?;
155- eprintln ! ( "wraith: attaching to pid {pid}{enforce_note}" ) ;
164+ ui_label = format ! ( "attach — pid {pid}" ) ;
165+ if !ui {
166+ eprintln ! ( "wraith: attaching to pid {pid}{enforce_note}" ) ;
167+ }
156168 Tracer :: attach ( pid, opts. cfg ) ?
157169 }
158170 "scan" => {
@@ -165,15 +177,18 @@ fn run(args: Vec<String>) -> io::Result<ExitCode> {
165177 if pids. is_empty ( ) {
166178 return Err ( bad ( "scan matched no running processes" ) ) ;
167179 }
168- eprintln ! (
169- "wraith: scanning {} process(es){}{enforce_note}" ,
170- pids. len( ) ,
171- if scan_all {
172- " (--all)" . to_string( )
173- } else {
174- format!( " matching {scan_matches:?}" )
175- } ,
176- ) ;
180+ let filter = if scan_all {
181+ "--all" . to_string ( )
182+ } else {
183+ format ! ( "--match {}" , scan_matches. join( "," ) )
184+ } ;
185+ ui_label = format ! ( "scan {filter}" ) ;
186+ if !ui {
187+ eprintln ! (
188+ "wraith: scanning {} process(es) {filter}{enforce_note}" ,
189+ pids. len( ) ,
190+ ) ;
191+ }
177192 Tracer :: attach_many ( & pids, opts. cfg ) ?
178193 }
179194 other => {
@@ -183,7 +198,27 @@ fn run(args: Vec<String>) -> io::Result<ExitCode> {
183198 }
184199 } ;
185200
186- let summary = tracer. run ( & mut on_event) ?;
201+ let summary = if ui {
202+ // Live dashboard: the tracer drives a Dashboard reporter inside a guard
203+ // that restores the terminal on exit (and on Ctrl-C via a signal handler).
204+ let dash = Dashboard :: new ( ui_label, enforcement, min, json_sink) ;
205+ let _guard = TerminalGuard :: enter ( ) ?;
206+ tracer. run_with ( dash) ?
207+ } else {
208+ // Plain stream: colored log lines to stderr, optional JSONL to the sink.
209+ let mut json_sink = json_sink;
210+ let mut on_event = |ev : & Event | {
211+ if ev. severity >= min {
212+ if !quiet {
213+ let _ = writeln ! ( io:: stderr( ) , "{}" , ev. to_line( color) ) ;
214+ }
215+ if let Some ( sink) = json_sink. as_mut ( ) {
216+ let _ = writeln ! ( sink, "{}" , ev. to_json( ) ) ;
217+ }
218+ }
219+ } ;
220+ tracer. run ( & mut on_event) ?
221+ } ;
187222
188223 // A short verdict on stderr so a human sees the bottom line.
189224 eprintln ! (
@@ -212,11 +247,13 @@ fn verdict(sev: Option<Severity>) -> &'static str {
212247}
213248
214249/// Walk `/proc` and return the PIDs to scan. A process is selected when `all`
215- /// is set, or when any `needle` is a substring of its `comm` or `cmdline`. The
216- /// scanner's own PID and PID 1 are always excluded; the attach itself (in
217- /// [`Tracer::attach_many`]) skips anything we lack permission to trace.
250+ /// is set, or when any `needle` is a substring of its `comm` or `cmdline`. Our
251+ /// own process, its whole ancestor chain (the shell/terminal that launched us),
252+ /// and PID 1 are excluded — a `scan` should watch its targets, never the tools
253+ /// that started it. The attach itself (in [`Tracer::attach_many`]) then skips
254+ /// anything we lack permission to trace.
218255fn enumerate_scan_pids ( needles : & [ String ] , all : bool ) -> Vec < i32 > {
219- let self_pid = std :: process :: id ( ) as i32 ;
256+ let excluded = ancestor_pids ( ) ;
220257 let mut out = Vec :: new ( ) ;
221258 let Ok ( entries) = std:: fs:: read_dir ( "/proc" ) else {
222259 return out;
@@ -226,7 +263,7 @@ fn enumerate_scan_pids(needles: &[String], all: bool) -> Vec<i32> {
226263 let Some ( pid) = name. to_str ( ) . and_then ( |n| n. parse :: < i32 > ( ) . ok ( ) ) else {
227264 continue ;
228265 } ;
229- if pid == self_pid || pid == 1 {
266+ if pid == 1 || excluded . contains ( & pid) {
230267 continue ;
231268 }
232269 let comm = std:: fs:: read_to_string ( format ! ( "/proc/{pid}/comm" ) ) . unwrap_or_default ( ) ;
@@ -241,6 +278,37 @@ fn enumerate_scan_pids(needles: &[String], all: bool) -> Vec<i32> {
241278 out
242279}
243280
281+ /// The set of PIDs from us up to the root of the process tree — our own PID and
282+ /// every ancestor. Used to keep `scan` from attaching to the shell, terminal,
283+ /// or supervisor that launched it (which would otherwise match a broad filter
284+ /// and, being long-lived, keep the trace running forever).
285+ fn ancestor_pids ( ) -> std:: collections:: HashSet < i32 > {
286+ let mut set = std:: collections:: HashSet :: new ( ) ;
287+ let mut pid = std:: process:: id ( ) as i32 ;
288+ // Bounded walk: real trees are shallow, and this guards against a cycle.
289+ for _ in 0 ..128 {
290+ if !set. insert ( pid) {
291+ break ;
292+ }
293+ match read_ppid ( pid) {
294+ Some ( ppid) if ppid > 1 => pid = ppid,
295+ _ => break ,
296+ }
297+ }
298+ set
299+ }
300+
301+ /// The parent PID of `pid` from `/proc/<pid>/status`, if readable.
302+ fn read_ppid ( pid : i32 ) -> Option < i32 > {
303+ let status = std:: fs:: read_to_string ( format ! ( "/proc/{pid}/status" ) ) . ok ( ) ?;
304+ for line in status. lines ( ) {
305+ if let Some ( rest) = line. strip_prefix ( "PPid:" ) {
306+ return rest. trim ( ) . parse ( ) . ok ( ) ;
307+ }
308+ }
309+ None
310+ }
311+
244312/// Pure predicate: does a process with this `comm`/`cmdline` pass the filter?
245313/// Split out from the `/proc` walk so it can be unit-tested without a live
246314/// process table.
@@ -298,6 +366,7 @@ OPTIONS:\n \
298366 --kill SIGKILL the traced tree on exploitation (CRITICAL)\n \
299367 --match <substr> (scan) attach to processes whose name/cmdline matches (repeatable)\n \
300368 --all (scan) attach to every process we're allowed to trace\n \
369+ --ui live full-screen dashboard (per-process rows + event feed)\n \
301370 --no-stack-pivot disable the ROP stack-pivot heuristic\n \
302371 --audit-sensitive also log sensitive syscalls from legitimate code\n \
303372 --quiet suppress the human stream (pair with --json)\n \
0 commit comments