sysknife-cli 0.26.0

Approval-gated AI system administration CLI and MCP server
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
//! Neutralise untrusted text before it reaches the operator's terminal.
//!
//! The approval prompt is the trust boundary SysKnife's whole design rests on:
//! a human reads what is about to happen and says yes. Everything shown there is
//! untrusted text. The plan and step summaries are written by the model, whose
//! context is full of host-controlled strings (package descriptions, systemd
//! `Description=` fields, hostnames, filenames); the preview and result
//! summaries come back from the daemon carrying command output.
//!
//! Printed raw, that text can rewrite the prompt it appears in. `\x1b[2K\r`
//! erases the current line, `\x1b[1A` walks back up over the risk badge, and a
//! few hundred newlines scroll the real HIGH-risk step out of view before the
//! operator ever sees it. The bidirectional-override characters do the same job
//! without an escape sequence, reordering displayed text against its byte order.
//!
//! So this is not cosmetic ANSI stripping. It is the guarantee that what the
//! operator approves is what the operator was shown.

/// Longest untrusted string rendered on one line.
///
/// Length alone is an attack even with every control character gone: a summary
/// of ten thousand characters wraps far enough to push the plan off-screen, and
/// the operator answers a prompt whose context has scrolled away.
const MAX_RENDERED_LEN: usize = 512;

/// Marker appended when [`operator_safe`] truncates, so a shortened summary can
/// never be mistaken for the whole one.
const TRUNCATION_MARKER: &str = "… [truncated]";

/// Most lines of a multi-line block rendered above a prompt.
///
/// [`MAX_RENDERED_LEN`] bounds one line; nothing bounded how many. A preview
/// whose parameters serialise to a thousand lines scrolls the prompt — and the
/// risk level and action name printed above it — off the screen just as
/// effectively as one very long line.
const MAX_RENDERED_LINES: usize = 40;

/// Marker appended when [`operator_safe_block`] drops lines.
const LINE_TRUNCATION_MARKER: &str = "… [truncated: block too long to display]";

/// Return a multi-line block rendered safe to print above a prompt.
///
/// [`operator_safe`] collapses newlines, which is right for a one-line summary
/// and wrong for structured text: pretty-printed JSON flattened to a single line
/// is unreadable, and unreadable is its own approval hazard. So each line is
/// neutralised individually and the line structure is kept, with the number of
/// lines bounded.
///
/// This exists because `serde_json` is not a sanitiser. It escapes C0 controls
/// and quotes, so raw pretty-printed JSON looks safe, but it emits U+202E and
/// U+200B as literal UTF-8 — the two classes that reorder and hide text. A
/// preview dumped straight from `to_string_pretty` therefore carries exactly the
/// characters [`operator_safe`] exists to remove.
pub(crate) fn operator_safe_block(s: &str) -> OperatorBlock {
    let total = s.lines().count();
    let mut shortened = 0usize;
    let mut lines: Vec<String> = s
        .lines()
        .take(MAX_RENDERED_LINES)
        .map(|line| {
            // Keep leading indentation: it carries the JSON's structure, and
            // `operator_safe` would trim it away as collapsible whitespace.
            let indent_len = line.len() - line.trim_start().len();
            let rendered = operator_safe(line);
            if rendered.ends_with(TRUNCATION_MARKER) {
                shortened += 1;
            }
            format!("{}{}", &line[..indent_len], rendered)
        })
        .collect();

    let withheld = total.saturating_sub(MAX_RENDERED_LINES);
    if withheld > 0 {
        lines.push(format!("{LINE_TRUNCATION_MARKER} ({withheld} more)"));
    }
    OperatorBlock {
        text: lines.join("\n"),
        withheld_lines: withheld,
        shortened_lines: shortened,
    }
}

/// A multi-line block rendered safe to print, and what it left out.
///
/// The counts are the point. `operator_safe_block` dropped lines past
/// [`MAX_RENDERED_LINES`] and shortened lines past [`MAX_RENDERED_LEN`], marked
/// both, and returned a bare `String`, so `sysknife approve` printed a partial
/// proposed change and then asked the operator to approve it. The marker told
/// them something was missing; nothing told them how much, and nothing stopped
/// the approval. A receipt taken on that view proves a human typed a word, not
/// that a human read the change.
///
/// Returning the counts instead of a string makes the omission awkward to
/// ignore: a caller printing the text has the numbers in the same value.
pub(crate) struct OperatorBlock {
    text: String,
    withheld_lines: usize,
    shortened_lines: usize,
}

impl OperatorBlock {
    /// The rendered block, safe to print above a prompt.
    pub(crate) fn text(&self) -> &str {
        &self.text
    }

    /// Whole lines dropped past [`MAX_RENDERED_LINES`].
    pub(crate) fn withheld_lines(&self) -> usize {
        self.withheld_lines
    }

    /// Lines kept but cut short past [`MAX_RENDERED_LEN`].
    pub(crate) fn shortened_lines(&self) -> usize {
        self.shortened_lines
    }

    /// Did the operator see every byte of the original?
    ///
    /// Both counters, not just the line count. A single JSON line holding a
    /// 4 KB path list is cut at 512 characters, and the tail of that line is as
    /// unseen as a dropped one.
    pub(crate) fn is_complete(&self) -> bool {
        self.withheld_lines == 0 && self.shortened_lines == 0
    }
}

/// Render a block with no caps in either dimension.
///
/// Every character rule still applies, so the text cannot rewrite the terminal.
/// The caps are gone, and they exist to stop a long block scrolling the prompt's
/// context off the screen, so the only safe caller is one that prints the
/// context *after* the block. `sysknife approve --full` does exactly that.
pub(crate) fn operator_safe_block_full(s: &str) -> OperatorBlock {
    let text = s
        .lines()
        .map(|line| {
            let indent_len = line.len() - line.trim_start().len();
            format!("{}{}", &line[..indent_len], operator_safe_unbounded(line))
        })
        .collect::<Vec<_>>()
        .join("\n");
    OperatorBlock {
        text,
        withheld_lines: 0,
        shortened_lines: 0,
    }
}

/// Return `s` rendered safe to print on one line of the operator's terminal.
///
/// - `\t`, `\n`, `\r` collapse to a single space: benign in origin, but a
///   newline lets untrusted text forge an additional display line.
/// - Every other C0 control, `DEL`, and the C1 range `U+0080..=U+009F` is
///   dropped. C1 matters because a terminal in 8-bit mode reads `U+009B` as CSI,
///   which reintroduces cursor control without a literal `ESC`.
/// - Bidirectional overrides/isolates and zero-width formatting characters are
///   dropped. These reorder or hide text while leaving the bytes intact — the
///   "trojan source" class, which here would let a summary read
///   `remove nothing` while naming a different target.
/// - Every other invisible formatting character goes too: U+061C (a bidi
///   control alongside the U+200E/U+200F already covered), soft hyphen, the
///   combining grapheme joiner, the word joiner and invisible operators, the
///   deprecated format controls, and the TAG block used for ASCII smuggling.
///   The set is meant to be complete; a partial one reads as neutralised
///   without being so.
/// - Runs of whitespace collapse and the result is trimmed, so the layout an
///   attacker can produce is bounded.
/// - The result is capped at [`MAX_RENDERED_LEN`] and marked when cut.
pub(crate) fn operator_safe(s: &str) -> String {
    let out = operator_safe_chars(s);
    if out.chars().count() > MAX_RENDERED_LEN {
        let cut: String = out.chars().take(MAX_RENDERED_LEN).collect();
        return format!("{cut}{TRUNCATION_MARKER}");
    }
    out
}

/// The character rules, with no length bound. One definition, two callers.
fn operator_safe_chars(s: &str) -> String {
    let mut out = String::with_capacity(s.len().min(MAX_RENDERED_LEN));
    let mut pending_space = false;

    for ch in s.chars() {
        let keep = match ch {
            '\t' | '\n' | '\r' | ' ' => {
                pending_space = true;
                continue;
            }
            // C0 controls and DEL.
            c if (c as u32) < 0x20 || c == '\u{7f}' => false,
            // C1 controls: U+009B is CSI to a terminal in 8-bit mode.
            c if ('\u{80}'..='\u{9f}').contains(&c) => false,
            // Bidi overrides and isolates — reorder what is displayed.
            // U+2028/U+2029 sit in the gap below the bidi arm; terminals
            // render them as nothing, so they belong in the same drop set
            // (#274 widened the brain-side range the same way).
            '\u{2028}'..='\u{202e}' | '\u{2066}'..='\u{2069}' => false,
            // Zero-width and byte-order marks — hide text in plain sight.
            '\u{200b}'..='\u{200f}' | '\u{feff}' => false,
            // U+061C ARABIC LETTER MARK is a bidi control in exactly the way
            // U+200E/200F above are; dropping those two and keeping this one
            // left the set incomplete rather than strict.
            '\u{061c}' => false,
            // The rest of the invisible formatting characters. Each renders as
            // nothing and can therefore split a word the operator reads as
            // whole, or pad a summary that looks short.
            '\u{00ad}'                  // SOFT HYPHEN
            | '\u{034f}'                // COMBINING GRAPHEME JOINER
            | '\u{180b}'..='\u{180e}'   // Mongolian selectors + vowel separator
            | '\u{2060}'..='\u{2064}'   // WORD JOINER + invisible operators
            | '\u{206a}'..='\u{206f}'   // deprecated bidi/format controls
            | '\u{fff9}'..='\u{fffb}'   // interlinear annotation
            // TAG characters. These mirror ASCII invisibly and are the vehicle
            // for "ASCII smuggling": a tagged copy of a different target rides
            // along in a summary that displays as innocuous text.
            | '\u{e0000}'..='\u{e007f}' => false,
            _ => true,
        };
        if !keep {
            continue;
        }
        if pending_space && !out.is_empty() {
            out.push(' ');
        }
        pending_space = false;
        out.push(ch);
    }

    out
}

/// As [`operator_safe`], with no length cap.
///
/// Every character class [`operator_safe`] removes is still removed, so the text
/// cannot rewrite the terminal. Only the length bound is gone, and that bound
/// exists for a different reason: a very long string scrolls the prompt's
/// context off the screen. `sysknife approve --full` prints the context *after*
/// the block for exactly that reason, which is what makes an unbounded render
/// safe there and nowhere else.
pub(crate) fn operator_safe_unbounded(s: &str) -> String {
    operator_safe_chars(s)
}

#[cfg(test)]
mod tests {
    use super::*;

    /// `sysknife approve` printed the proposed change straight from
    /// `serde_json::to_string_pretty`, immediately above the approval prompt,
    /// while the summary beside it went through `operator_safe`. JSON escaping
    /// covers C0 controls and stops there, so a bidi override inside a string
    /// value reached the terminal intact and could reorder the very target the
    /// operator was being asked to confirm.
    #[test]
    fn a_json_block_cannot_smuggle_bidi_or_zero_width_past_the_prompt() {
        let hostile = serde_json::to_string_pretty(&serde_json::json!({
            "path": "/etc/\u{202e}gnc.d/passwd\u{202c}",
            "note": "safe\u{200b}ish",
        }))
        .expect("json");

        // Precondition: serde_json really does leave these in place, so the
        // test fails for the stated reason rather than a vacuous one.
        assert!(
            hostile.contains('\u{202e}'),
            "serde_json escaped the override"
        );

        let block = operator_safe_block(&hostile);
        let safe = block.text();
        assert!(
            !safe.contains('\u{202e}'),
            "bidi override survived: {safe:?}"
        );
        assert!(!safe.contains('\u{200b}'), "zero-width survived: {safe:?}");
        // Structure must survive, or the operator cannot read what they approve.
        assert!(safe.lines().count() > 1, "block was flattened: {safe:?}");
        assert!(safe.contains("passwd"), "visible text was lost: {safe:?}");
    }

    /// One long line was bounded; a block of many lines was not, and scrolls the
    /// action name and risk level off the screen just as effectively.
    #[test]
    fn a_tall_block_is_bounded_and_says_so() {
        let tall = (0..500)
            .map(|i| format!("line {i}"))
            .collect::<Vec<_>>()
            .join("\n");
        let block = operator_safe_block(&tall);
        let safe = block.text();
        assert_eq!(safe.lines().count(), MAX_RENDERED_LINES + 1);
        assert!(
            safe.contains(LINE_TRUNCATION_MARKER),
            "truncation unmarked: {safe:?}"
        );
        // The marker used to say only that something was dropped. How much is
        // what an operator needs in order to judge whether to ask for the rest.
        assert_eq!(block.withheld_lines(), 500 - MAX_RENDERED_LINES);
        assert!(
            safe.ends_with(&format!("({} more)", 500 - MAX_RENDERED_LINES)),
            "the count must be on screen, not only in the error: {safe:?}"
        );
        assert!(!block.is_complete());
    }

    /// A block that fits reports itself complete, or the refusal in
    /// `approval_view` would fire on every ordinary approval.
    #[test]
    fn a_block_that_fits_reports_complete() {
        let short = "line one\nline two\nline three";
        let block = operator_safe_block(short);
        assert!(block.is_complete());
        assert_eq!(block.withheld_lines(), 0);
        assert_eq!(block.shortened_lines(), 0);
        assert_eq!(block.text(), short);
    }

    /// Nothing is dropped and nothing fits: one line past the length cap is as
    /// unseen as a dropped one, and a line-count check alone misses it.
    #[test]
    fn a_wide_block_is_counted_as_incomplete() {
        let wide = format!("  \"paths\": \"{}\"", "/a/long/path".repeat(200));
        let block = operator_safe_block(&wide);
        assert_eq!(block.withheld_lines(), 0);
        assert_eq!(block.shortened_lines(), 1);
        assert!(!block.is_complete());
    }

    /// The uncapped renderer keeps every character rule and drops only the
    /// bounds. `sysknife approve --full` is its only caller, and it prints the
    /// decision context after the block so the bounds have nothing to protect.
    #[test]
    fn the_full_renderer_keeps_the_character_rules_and_drops_the_bounds() {
        let tall: String = (0..500)
            .map(|i| format!("line {i} \u{202e}\u{200b}"))
            .collect::<Vec<_>>()
            .join("\n");
        let block = operator_safe_block_full(&tall);
        assert!(block.is_complete());
        assert_eq!(block.text().lines().count(), 500);
        assert!(block.text().contains("line 499"));
        assert!(!block.text().contains('\u{202e}'));
        assert!(!block.text().contains('\u{200b}'));
        assert!(!block.text().contains(TRUNCATION_MARKER));

        // Both bounds, not just the line count. The first version of this test
        // used only short lines, so swapping the uncapped per-line render back
        // to the capped one left it green: the tall case proves the line cap is
        // gone and says nothing about the length cap.
        let wide = format!("  \"paths\": \"{}\"", "/a/long/path".repeat(200));
        let block = operator_safe_block_full(&wide);
        assert!(block.is_complete());
        assert!(
            !block.text().contains(TRUNCATION_MARKER),
            "the length cap is still cutting lines in the full view: {}",
            block.text()
        );
        assert!(
            block.text().matches("/a/long/path").count() == 200,
            "the full view dropped part of a line: {} of 200 present",
            block.text().matches("/a/long/path").count()
        );
    }

    /// The invisible-formatting set has to be complete, not merely started.
    /// U+200E/200F were dropped and U+061C was not, though all three are bidi
    /// controls; the zero-width block was dropped and U+2060 WORD JOINER was
    /// not, though both render as nothing. A partial set is an operator who
    /// believes the text was neutralised when it was not.
    #[test]
    fn every_invisible_formatting_character_is_dropped() {
        for ch in [
            '\u{061c}',  // ARABIC LETTER MARK — bidi control
            '\u{00ad}',  // SOFT HYPHEN
            '\u{034f}',  // COMBINING GRAPHEME JOINER
            '\u{180e}',  // MONGOLIAN VOWEL SEPARATOR
            '\u{2060}',  // WORD JOINER
            '\u{2064}',  // INVISIBLE PLUS
            '\u{206f}',  // NOMINAL DIGIT SHAPES
            '\u{fffb}',  // INTERLINEAR ANNOTATION TERMINATOR
            '\u{e0041}', // TAG LATIN CAPITAL LETTER A — ASCII smuggling
        ] {
            let hostile = format!("AptRemove{ch}Everything");
            let safe = operator_safe(&hostile);
            assert!(
                !safe.chars().any(|c| c == ch),
                "U+{:04X} survived operator_safe: {safe:?}",
                ch as u32
            );
            assert_eq!(
                safe, "AptRemoveEverything",
                "U+{:04X} must vanish without disturbing the visible text",
                ch as u32
            );
        }
    }

    /// The headline case: a summary that erases and rewrites its own line.
    #[test]
    fn a_summary_cannot_rewrite_the_line_it_is_printed_on() {
        let hostile = "install vim\u{1b}[2K\rremove nothing";
        let safe = operator_safe(hostile);
        assert!(!safe.contains('\u{1b}'), "ESC survived: {safe:?}");
        assert!(!safe.contains('\r'), "CR survived: {safe:?}");
        // The text is still shown — neutralised, not censored — so the operator
        // sees that something odd was proposed rather than a doctored line.
        assert_eq!(safe, "install vim[2K remove nothing");
    }

    /// Cursor movement is how the risk badge on an earlier line gets overwritten.
    #[test]
    fn a_summary_cannot_move_the_cursor_off_its_own_line() {
        for seq in ["\u{1b}[1A", "\u{1b}[10;10H", "\u{1b}[s\u{1b}[u", "\u{1b}M"] {
            let safe = operator_safe(&format!("upgrade{seq}kernel"));
            assert!(
                !safe.contains('\u{1b}'),
                "ESC survived from {seq:?}: {safe:?}"
            );
        }
    }

    /// An 8-bit terminal reads U+009B as CSI, so dropping only `ESC` is not
    /// enough — the same cursor control arrives without a literal escape.
    #[test]
    fn c1_control_characters_are_dropped_too() {
        let safe = operator_safe("upgrade\u{9b}2K\rkernel");
        assert!(!safe.contains('\u{9b}'), "C1 CSI survived: {safe:?}");
    }

    /// Newlines let untrusted text forge extra display lines — a fake "0 steps"
    /// or a fake prompt underneath the real one.
    #[test]
    fn a_summary_cannot_forge_extra_lines() {
        let safe = operator_safe("install vim\n\n  3  RemoveSwap   LOW   auto");
        assert!(!safe.contains('\n'), "newline survived: {safe:?}");
        assert_eq!(safe, "install vim 3 RemoveSwap LOW auto");
    }

    /// Trojan-source: reorder the displayed text without any control sequence.
    #[test]
    fn bidi_overrides_and_zero_width_characters_are_dropped() {
        for ch in [
            '\u{202a}', '\u{202b}', '\u{202c}', '\u{202d}', '\u{202e}', '\u{2066}', '\u{2067}',
            '\u{2068}', '\u{2069}', '\u{200b}', '\u{200e}', '\u{feff}',
            // U+2028/U+2029 sit in the gap between the U+200F and U+202A arms.
            // Terminals render them as nothing, same class as the rest.
            '\u{2028}', '\u{2029}',
        ] {
            let safe = operator_safe(&format!("remove{ch}nothing"));
            assert!(!safe.contains(ch), "U+{:04X} survived: {safe:?}", ch as u32);
        }
    }

    /// Length is its own attack: enough characters scroll the plan away before
    /// the prompt is answered.
    #[test]
    fn an_overlong_summary_is_capped_and_says_so() {
        let safe = operator_safe(&"A".repeat(10_000));
        assert!(
            safe.chars().count() <= MAX_RENDERED_LEN + TRUNCATION_MARKER.chars().count(),
            "not capped: {} chars",
            safe.chars().count()
        );
        assert!(safe.ends_with(TRUNCATION_MARKER), "truncation not marked");
    }

    /// Truncation must not split a multi-byte character.
    #[test]
    fn truncation_respects_char_boundaries() {
        // 4-byte chars, so a byte-index cut would land mid-character and panic.
        let safe = operator_safe(&"🔒".repeat(10_000));
        assert!(safe.ends_with(TRUNCATION_MARKER));
        assert!(safe.chars().count() <= MAX_RENDERED_LEN + TRUNCATION_MARKER.chars().count());
    }

    /// The guard must not mangle the ordinary case, or it will be removed.
    #[test]
    fn ordinary_summaries_pass_through_intact() {
        for s in [
            "install vim and git",
            "restart nginx.service",
            "add 2 GiB of swap at /swapfile",
            "rotate /var/log/nginx/*.log daily, keep 14",
            "café — naïve — 日本語 — emoji 🔒 all survive",
        ] {
            assert_eq!(operator_safe(s), s, "mangled an ordinary summary");
        }
    }

    #[test]
    fn whitespace_collapses_and_trims() {
        assert_eq!(operator_safe("  install    vim  \t\n "), "install vim");
        assert_eq!(operator_safe(""), "");
        assert_eq!(operator_safe("\u{1b}\u{1b}\u{1b}"), "");
    }
}