gage 0.3.6

Hand context between agents — files, prompts and decisions — with a trust boundary that survives the hop.
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
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
//! Telling you something arrived.
//!
//! Polling is the only option: a relay that pushes would have to hold an open
//! connection per mailbox, and the agents are not processes that stay running
//! anyway -- they are invoked. So `gage watch` pulls on an interval and fires a
//! desktop notification when something genuinely new lands.
//!
//! "Genuinely new" means unread AND not previously announced. Without the
//! second half, every poll re-announces the same bundle until you open it,
//! which trains you to dismiss the notification -- the failure mode that makes
//! a notifier worse than nothing.
//!
//! What the notification says is deliberately thin: who it is from, what type,
//! and the subject. Not the contents. A desktop notification is rendered by
//! another process, may be logged, and can sit on a lock screen, so a bundle's
//! payload has no business in one.

use std::collections::BTreeSet;
use std::process::Command;

/// Ids already announced, so a repeat poll stays quiet.
fn announced_path() -> std::path::PathBuf {
    crate::identity::home().join("announced.json")
}

/// Announced ids, **oldest first**.
///
/// It used to be a `BTreeSet`, and a reviewer found what that cost. A set has
/// no arrival order, so trimming it to a bound kept the lexicographically
/// largest ids rather than the newest -- and bundle ids are hex, so the ids
/// dropped first were the ones beginning `0`, `1`, `2`. Dropped from this
/// file, a bundle is announced again on the next poll, and with
/// `--on-arrival` it is *acted on* again. The comment said "the oldest ids
/// stop mattering once collected", which was true of the intent and false of
/// the code.
///
/// A list in insertion order trims the end that the comment always meant.
/// The old format still loads: a set of ids read back as a list is in sorted
/// rather than arrival order, which is wrong but harmless, because every id
/// in it has already been announced and the only cost is trimming the wrong
/// one once more before the file turns over.
fn load_announced() -> Vec<String> {
    let Ok(text) = std::fs::read_to_string(announced_path()) else {
        return Vec::new();
    };
    if let Ok(v) = serde_json::from_str::<Vec<String>>(&text) {
        return v;
    }
    serde_json::from_str::<BTreeSet<String>>(&text)
        .map(|s| s.into_iter().collect())
        .unwrap_or_default()
}

fn save_announced(v: &[String]) {
    // Trim the FRONT, which is the old end. 2000 is a bound on the file, not
    // a policy about what matters: every id in here has been announced, and
    // the only thing dropping one costs is announcing that bundle twice.
    let start = v.len().saturating_sub(2000);
    if let Ok(bytes) = serde_json::to_vec(&v[start..]) {
        let _ = std::fs::write(announced_path(), bytes);
    }
}

pub struct Pending {
    pub id: String,
    pub from: String,
    pub msg_type: String,
    pub subject: String,
    pub expects_reply: bool,
    /// The sender's signing key, as `gage whoami` prints it. Carried so the
    /// hook can be given it without re-deriving it from `from`, which is a
    /// contact name or a quoted claim and is not an identity.
    pub key: String,
    /// The grant in the receiver's own authority file that covers this
    /// message, rendered as the line it came from. `None` is the normal case
    /// and means no hook runs: see `run_hook`.
    pub grant: Option<String>,
}

/// Unread, un-announced bundles sitting in pending.
pub fn new_arrivals() -> Vec<Pending> {
    let st = crate::state::LocalState::load();
    let contacts = crate::trust::Contacts::load();
    let authority = crate::authority::Authority::load();
    let seen = load_announced();
    let home = crate::identity::home();
    let mut out = Vec::new();
    for (_, l) in crate::routes::list(&crate::routes::pending_dir(&home)) {
        let m = &l.manifest;
        // A bundle that does not verify is not announced at all.
        //
        // Announce-once is keyed on the bundle id, so a bundle carrying
        // somebody else's id got announced, `mark_announced` recorded that
        // id, and the genuine arrival was then skipped as already seen -- a
        // real message silenced by a fake one. `load` already reports the id
        // mismatch; this is the part that was ignoring it.
        if !l.tampered.is_empty() || matches!(l.trust, crate::trust::Trust::Unverified) {
            continue;
        }
        if st.is_read(&m.id) || seen.contains(&m.id) {
            continue;
        }
        // The same lookup `gage open` performs, on the same inputs. A
        // reviewer was right that the old comment here -- "a hook can never
        // fire on an authority the reader was not shown" -- claimed more
        // than the code does: this is a fresh snapshot taken at poll time,
        // and contacts or the authority file can change before anyone reads
        // the frame. What is true is that the two call the one function, so
        // they cannot disagree about the same inputs.
        let grant = authority
            .lookup(
                &contacts,
                l.trust,
                &m.from.key,
                m.msg_type,
                crate::authority::answers_something_i_sent(&home, m).is_some(),
            )
            .ok()
            .map(|g| g.to_string());
        out.push(Pending {
            key: m.from.key.clone(),
            grant,
            id: m.id.clone(),
            from: contacts
                .name_for(&m.from.key)
                .map(|s| s.to_string())
                // An unlisted key's claimed handle is quoted, because a
                // notification is exactly where a forged name would be
                // believed fastest.
                .unwrap_or_else(|| format!("\"{}\" (unlisted)", one_line(&m.from.handle, 40))),
            msg_type: m.msg_type.label().to_string(),
            subject: one_line(&m.subject, 120),
            expects_reply: m.msg_type.expects_reply(),
        });
    }
    out
}

pub fn mark_announced(ids: &[String]) {
    let mut v = load_announced();
    for i in ids {
        if !v.iter().any(|x| x == i) {
            v.push(i.clone());
        }
    }
    save_announced(&v);
}

/// Flatten sender-controlled text to a single bounded line.
///
/// The subject and an unlisted handle go straight into a desktop
/// notification, and both are written by the sender. Newlines were the
/// problem: a subject containing them could forge extra arrival lines, or an
/// "awaiting your reply" that nobody asked for, in a popup the reader has
/// every reason to treat as the tool's own voice. Markup is stripped for the
/// same reason -- some notification daemons render it -- and the length is
/// capped so one subject cannot push the rest off the screen.
fn one_line(s: &str, max: usize) -> String {
    let flat: String = s
        .chars()
        .map(|c| if c.is_control() { ' ' } else { c })
        .filter(|c| *c != '<' && *c != '>' && *c != '&')
        .collect();
    let flat = flat.split_whitespace().collect::<Vec<_>>().join(" ");
    if flat.chars().count() > max {
        let cut: String = flat.chars().take(max).collect();
        format!("{cut}…")
    } else {
        flat
    }
}

/// Fire a desktop notification. Absence of a notifier is not an error: on a
/// server there is no desktop, and `gage watch` still has to work there.
pub fn desktop(title: &str, body: &str, urgent: bool) -> bool {
    if cfg!(target_os = "macos") {
        // Notification Center via AppleScript, with the text passed as
        // argv -- never interpolated into the script, so a sender-written
        // subject cannot become script. `urgent` has no equivalent there
        // and is dropped rather than faked.
        let mut c = Command::new("osascript");
        c.arg("-e")
            .arg("on run argv")
            .arg("-e")
            .arg("display notification (item 2 of argv) with title (item 1 of argv)")
            .arg("-e")
            .arg("end run")
            .arg("--")
            .arg(one_line(title, 160))
            .arg(one_line(body, 300));
        return matches!(c.status(), Ok(s) if s.success());
    }
    let mut c = Command::new("notify-send");
    c.arg("--app-name=gage")
        .arg(format!(
            "--urgency={}",
            if urgent { "critical" } else { "normal" }
        ))
        // `--` before the positional arguments, so a subject that begins with
        // a dash is a subject rather than an option to notify-send. The
        // arguments are passed as argv and never through a shell, so nothing
        // here is executed; the risk was option injection, not command
        // injection.
        .arg("--")
        .arg(one_line(title, 160))
        .arg(body);
    matches!(c.status(), Ok(s) if s.success())
}

/// Connection decisions waiting on a person, as (id, title, body) triples
/// for the watcher to announce: keys that sent mail and are not on the
/// list, and contacts nobody has confirmed yet.
///
/// Computed from the same JSON the viewer renders, deliberately. The
/// buddies page already works out who is asking and who is unconfirmed --
/// with the signature checks and the refusal exclusions that make the
/// answer mean anything -- and a second implementation here is how the
/// two would come to disagree. Ids are prefixed so a decision and a
/// bundle can never collide in the announced set, and a decision is
/// announced once: it stays visible in the app until it is made, and a
/// watcher that re-announced it every two minutes would train the person
/// to ignore the watcher.
pub fn new_decisions() -> Vec<(String, String, String)> {
    let Ok(v) = serde_json::from_str::<serde_json::Value>(&crate::ui::buddies_json()) else {
        return Vec::new();
    };
    let announced = load_announced();
    let mut out = Vec::new();
    if let Some(reqs) = v.get("requests").and_then(|x| x.as_array()) {
        for r in reqs {
            let (Some(k), Some(label)) = (
                r.get("key").and_then(|x| x.as_str()),
                r.get("label").and_then(|x| x.as_str()),
            ) else {
                continue;
            };
            let id = format!("req:{k}");
            if announced.iter().any(|a| a == &id) {
                continue;
            }
            let n = r.get("bundles").and_then(|x| x.as_u64()).unwrap_or(0);
            let what = if n == 1 {
                "a bundle".to_string()
            } else {
                format!("{n} bundles")
            };
            out.push((
                id,
                "gage: connection request".to_string(),
                format!(
                    "{} sent you {what} and is not on your list. Open Gage to decide.",
                    one_line(label, 40)
                ),
            ));
        }
    }
    if let Some(buds) = v.get("buddies").and_then(|x| x.as_array()) {
        for b in buds {
            if b.get("confirmed").and_then(|x| x.as_bool()) != Some(false) {
                continue;
            }
            if b.get("stale").and_then(|x| x.as_bool()) == Some(true) {
                continue;
            }
            let (Some(k), Some(name)) = (
                b.get("key").and_then(|x| x.as_str()),
                b.get("name").and_then(|x| x.as_str()),
            ) else {
                continue;
            };
            let id = format!("unconf:{k}");
            if announced.iter().any(|a| a == &id) {
                continue;
            }
            out.push((
                id,
                "gage: finish connecting".to_string(),
                format!(
                    "{} is on your list but not confirmed. Open Gage and compare the words to finish.",
                    one_line(name, 40)
                ),
            ));
        }
    }
    out
}

pub fn summarise(p: &[Pending]) -> (String, String) {
    if p.len() == 1 {
        let m = &p[0];
        let t = format!("gage: {} from {}", m.msg_type, m.from);
        let b = if m.expects_reply {
            format!("{}\nawaiting your reply", m.subject)
        } else {
            m.subject.clone()
        };
        (t, b)
    } else {
        let needs = p.iter().filter(|x| x.expects_reply).count();
        let t = format!("gage: {} new bundles", p.len());
        let mut b: Vec<String> = p
            .iter()
            .take(4)
            .map(|x| format!("{} — {}", x.from, x.subject))
            .collect();
        if p.len() > 4 {
            b.push(format!("and {} more", p.len() - 4));
        }
        if needs > 0 {
            b.push(format!("{needs} awaiting your reply"));
        }
        (t, b.join("\n"))
    }
}

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

    fn p(from: &str, ty: &str, subj: &str, expects: bool) -> Pending {
        Pending {
            id: "x".into(),
            from: from.into(),
            msg_type: ty.into(),
            subject: subj.into(),
            expects_reply: expects,
            key: "k".into(),
            grant: None,
        }
    }

    #[test]
    fn a_single_arrival_names_sender_and_type() {
        let (t, b) = summarise(&[p("codex", "question", "key rotation", true)]);
        assert!(t.contains("question") && t.contains("codex"), "{t}");
        assert!(b.contains("awaiting your reply"), "{b}");
    }

    #[test]
    fn many_arrivals_collapse_rather_than_spam() {
        let v: Vec<Pending> = (0..7)
            .map(|i| p("grok", "share", &format!("s{i}"), false))
            .collect();
        let (t, b) = summarise(&v);
        assert!(t.contains('7'), "{t}");
        assert!(b.contains("and 3 more"), "{b}");
    }

    #[test]
    fn sender_text_cannot_forge_extra_notification_lines() {
        // A subject with newlines in it could write its own "awaiting your
        // reply" line, or invent further arrivals, inside a popup the reader
        // treats as the tool's own voice.
        let forged = "innocent\nawaiting your reply\nctx: 4 new bundles";
        let flat = one_line(forged, 120);
        assert!(!flat.contains('\n'), "newlines survived: {flat:?}");
        assert!(flat.starts_with("innocent"));
    }

    #[test]
    fn notification_text_is_bounded_and_unmarked_up() {
        let long = "x".repeat(500);
        assert!(one_line(&long, 120).chars().count() <= 121);
        assert_eq!(one_line("<b>bold</b> &amp;", 120), "bbold/b amp;");
        // control characters go, including the ones that move a cursor
        // a control character becomes a space, then whitespace collapses
        assert_eq!(one_line("a\u{1b}[2Kb\tc", 120), "a [2Kb c");
    }

    #[test]
    fn a_notification_never_carries_payload_contents() {
        // summarise only ever sees metadata; this pins that it cannot grow a
        // contents field without the test failing.
        let (t, b) = summarise(&[p("dana", "share", "quarterly numbers", false)]);
        for s in [&t, &b] {
            assert!(
                !s.contains("items/"),
                "notification referenced bundle contents: {s}"
            );
        }
    }
}

/// Run the receiver's own command for one arrival.
///
/// This is the only place in gage where something arriving causes something to
/// happen without a human in the loop, so the shape of it is the whole
/// security argument:
///
/// 1. **It fires only where a grant matched at the moment of the poll.** Not
///    "verified", which only says whose key signed it -- the authority file
///    is the only thing in the tool that says an agent may act, and this is
///    the unattended path, so it is the one caller that must not settle for
///    less. An unlisted sender, or a verified sender with no grant, notifies
///    exactly as before and runs nothing.
///
///    Stated that precisely because a reviewer was right that the looser
///    phrasing ("a grant already covers it") implies a freshness the lookup
///    does not establish. It is a snapshot of local files: the grant has no
///    expiry, it was not necessarily written recently, and any process
///    running as this user can edit the file. What it proves is that a line
///    the user's own machine holds names this sender and this kind -- which
///    is the strongest thing available locally and less than "a human just
///    approved this".
/// 2. **The hook never receives the bundle's items.** It gets the id, the
///    sender's key, the contact name, the type and the subject. A bundle's
///    contents are the thing the accept gate exists to hold back; a hook that
///    handed them over would have quietly removed the gate for every arrival
///    that happened to match a grant.
///
///    Not "never receives message content", which a reviewer rightly called
///    out: the subject is written by the sender and travels here. It is one
///    bounded line rather than a file, and it is already in the notification
///    the human sees, so it leaks nothing the desktop popup does not -- but
///    it is the sender's text, and a hook that treats it as trustworthy input
///    is a hook with a problem.
/// 3. **This code does not accept the bundle.** `run_hook` moves nothing.
///
///    With `--auto-accept` ALSO given, though, the bundle may already be in
///    `accepted` before this runs: the watcher does its auto-accept pass
///    first, deliberately, so that a hook written for an unattended loop
///    finds the bundle settled rather than racing the watcher for it. So
///    "still unread, still in `pending`" -- which this note and the README
///    both used to say flatly -- is true of `--on-arrival` alone and false of
///    the two flags together. `CTX_AUTO_ACCEPTED` tells the hook which world
///    it is in, and was already being set when the claim above still read as
///    unconditional.
///
///    Stated about this code rather than about the world, because a reviewer
///    was right that the stronger phrasing was false. The hook is handed
///    `CTX_BUNDLE_ID` and can perfectly well run `gage accept && gage open`
///    itself -- and for an agent loop, that is the point. What gage guarantees
///    is that *nothing here* crosses the gate, so crossing it remains an act
///    the receiver's own hook performs, under its own name, with a journal
///    entry to show it. The gate is not removed; it is left where it is, for
///    the hook to walk through deliberately.
/// 4. **gage does not interpolate sender text into the command string.** The subject is written by whoever sent it. Pasted
///    into a shell line it would be a command injection with extra steps, so
///    the command the receiver wrote is passed to `sh -c` untouched and every
///    sender-chosen value arrives as `$CTX_SUBJECT` and friends -- already
///    flattened to one bounded line by `one_line`, because a newline in an
///    environment variable is still a newline in whatever the hook writes it
///    to.
///
///    Phrased as what gage does rather than "sender text is data, never
///    command", which a reviewer rightly called hook-dependent. `"$CTX_SUBJECT"`
///    quoted is data. `eval "$CTX_SUBJECT"`, an unquoted expansion inviting
///    word-splitting and globbing, a value used where a command expects an
///    option, or a second shell string built from it -- each hands the sender
///    a shell, and nothing here can prevent any of them, because the hook is
///    the receiver's own code. Environment values are also inherited by the
///    hook's children and readable from the process environment by anything
///    running as this user. The guarantee is one link in the chain, not the
///    chain.
///
/// A non-zero exit or a timeout is reported and otherwise ignored, and the
/// arrival is still marked announced.
///
/// That is an **at-most-once** policy, and it is a choice rather than the
/// only safe direction -- the first version of this comment claimed the
/// latter and a reviewer was right to object. The real trade is between
/// losing a hook and running one twice: marking first means a crash between
/// the mark and the hook loses it silently, while marking after would re-fire
/// a failing hook on every poll. There is no separate "hook completed" state
/// that would allow at-least-once without that loop, and adding one is the
/// honest fix if a hook ever does something that must not be skipped.
/// Until then: at most once, stated as such.
pub fn run_hook(cmd: &str, p: &Pending, timeout: u64, auto_accepted: bool) -> Result<(), String> {
    use std::process::Stdio;
    let mut child = Command::new("sh")
        .arg("-c")
        .arg(cmd)
        .env("CTX_BUNDLE_ID", &p.id)
        .env("CTX_FROM", one_line(&p.from, 80))
        .env("CTX_FROM_KEY", &p.key)
        .env("CTX_TYPE", &p.msg_type)
        .env("CTX_SUBJECT", one_line(&p.subject, 200))
        .env("CTX_EXPECTS_REPLY", if p.expects_reply { "1" } else { "0" })
        .env("CTX_GRANT", p.grant.clone().unwrap_or_default())
        // So a hook does not have to guess where the bundle is, or race the
        // watcher for it. "1" means it is already in `accepted` and `gage
        // accept` would now fail; "0" means it is still pending and a hook
        // that accepts it is spending nothing a grant counted.
        .env("CTX_AUTO_ACCEPTED", if auto_accepted { "1" } else { "0" })
        // No payload on stdin either: a hook that read stdin expecting the
        // bundle would get EOF, which is the right answer to that question.
        .stdin(Stdio::null())
        .spawn()
        .map_err(|e| format!("could not start the hook: {e}"))?;

    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout);
    loop {
        match child.try_wait() {
            Ok(Some(st)) if st.success() => return Ok(()),
            Ok(Some(st)) => return Err(format!("the hook exited {}", code_of(st))),
            Ok(None) => {}
            Err(e) => return Err(format!("could not wait for the hook: {e}")),
        }
        if std::time::Instant::now() >= deadline {
            let _ = child.kill();
            let _ = child.wait();
            return Err(format!(
                "the hook ran longer than {timeout}s and was killed"
            ));
        }
        std::thread::sleep(std::time::Duration::from_millis(100));
    }
}

fn code_of(st: std::process::ExitStatus) -> String {
    match st.code() {
        Some(c) => c.to_string(),
        None => "on a signal".to_string(),
    }
}

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

    fn p() -> Pending {
        Pending {
            id: "abc123".into(),
            from: "mac".into(),
            msg_type: "share".into(),
            subject: "one\ntwo".into(),
            expects_reply: false,
            key: "KEY".into(),
            grant: Some("allow mac share".into()),
        }
    }

    #[test]
    fn the_hook_gets_sender_text_as_data_not_as_shell() {
        let out = std::env::temp_dir().join(format!("gage-hook-{}", std::process::id()));
        let _ = std::fs::remove_file(&out);
        let mut hostile = p();
        // A subject that would run a command if it were ever pasted into the
        // command string instead of handed over in the environment.
        hostile.subject = format!("$(touch {})", out.display());
        run_hook(
            "printf '%s' \"$CTX_SUBJECT\" > /dev/null",
            &hostile,
            10,
            false,
        )
        .unwrap();
        assert!(!out.exists(), "a subject executed as shell");
    }

    #[test]
    fn a_newline_in_the_subject_cannot_forge_a_second_line() {
        // The hook writes one line per value; a subject holding a newline
        // would otherwise become two lines in whatever the hook writes to.
        let flat = one_line(&p().subject, 200);
        assert!(!flat.contains('\n'));
        assert_eq!(flat, "one two");
    }

    #[test]
    fn a_hook_that_hangs_is_killed_rather_than_stalling_the_watcher() {
        let err = run_hook("sleep 30", &p(), 1, false).unwrap_err();
        assert!(err.contains("was killed"), "{err}");
    }

    #[test]
    fn a_failing_hook_is_reported_with_its_code() {
        let err = run_hook("exit 7", &p(), 10, false).unwrap_err();
        assert!(err.contains("exited 7"), "{err}");
    }
}