kaish-types 0.16.0

Pure data types for kaish — structured output, values, tool schemas
Documentation
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
//! The statement-plan vocabulary: pure data plus serde, no behavior.
//!
//! A [`Plan`] is what `plan_program` produces for one statement — the source
//! rendered back **unexpanded**, every [`PlannedCommand`] it would run, and
//! the variables it reads and writes. An embedder reads a plan to decide
//! whether to run a statement; nothing here decides anything itself.
//!
//! [`PlannedValue`] is the one place redaction appears. The kernel redacts
//! exactly one thing — the `--confirm=<key>` flag spelling, kaish's own
//! convention for a confirmation credential — and a redacted value keeps a
//! *kind*, never the credential. kaish ships no secret detector, because a
//! shell cannot define what a secret is; an embedder that wants more redacts
//! the plans it holds.

use serde::{Deserialize, Serialize};

/// A content identity for a plan — a digest over its rendered text with any
/// presented credential stripped, so `rm x` and `rm --confirm=<key> x`
/// digest the same. The embedder computes it (e.g. SHA-256 over the
/// kernel's `strip_confirm_tokens(rendered)`); this type only carries the
/// value, so `kaish-types` stays dependency-light.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct PlanDigest(String);

impl PlanDigest {
    /// Wrap an already-computed digest.
    pub fn new(hex: impl Into<String>) -> Self {
        Self(hex.into())
    }

    /// The digest's text form.
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

// ───────────────────────── Redaction ─────────────────────────

/// One value inside a rendered plan. A sink serializes `PlannedValue`, never
/// a bare `String`, so a value reaches a sink only after something decided
/// whether it was a secret.
///
/// The kernel builds every `PlannedValue` at one normalization point
/// (`kaish-kernel`'s `ast::plan::plan_statement`), before the plan reaches
/// any consumer. A consumer added later reads the same already-decided
/// values instead of re-deriving its own redaction.
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PlannedValue {
    /// Not judged secret. Holds the literal text, exactly as it would render
    /// on the command line.
    Plain(String),
    /// Judged secret — today only by the kernel's own confirm-key check;
    /// the original text never reaches this variant or anything built from
    /// it. The variant is the vocabulary an embedder-side redaction pass can
    /// also produce over plans it holds.
    Redacted {
        /// What kind of secret — `"confirm-key"` for the kernel's one
        /// built-in redaction. The kernel does not interpret this string.
        kind: String,
        /// Stable salted digest prefix, when the producer supplied one, so an
        /// auditor can ask "the same credential as last time?" without
        /// holding it.
        #[serde(default, skip_serializing_if = "Option::is_none")]
        fingerprint: Option<String>,
    },
}

impl PlannedValue {
    /// Build a value the kernel judged secret.
    pub fn redacted(kind: impl Into<String>, fingerprint: Option<String>) -> Self {
        Self::Redacted {
            kind: kind.into(),
            fingerprint,
        }
    }

    /// The text a sink should show: the literal for `Plain`, or `<kind>` for
    /// `Redacted` — never the redacted content itself.
    pub fn display(&self) -> String {
        match self {
            Self::Plain(s) => s.clone(),
            Self::Redacted { kind, .. } => format!("<{kind}>"),
        }
    }

    /// Whether this value was judged secret.
    pub fn is_redacted(&self) -> bool {
        matches!(self, Self::Redacted { .. })
    }
}

// ───────────────────────── The statement plan ─────────────────────────

/// What one top-level statement was asked to run (spec §C.6).
///
/// Built from the AST after validation and **before** execution, so it is
/// parse information and never execution information: no substitution has
/// run, no redirect has been opened, no loop has taken its first iteration.
/// Nested statements — loop bodies, `if` branches, user-tool bodies — belong
/// to their enclosing top-level statement's plan and are never planned
/// separately.
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Plan {
    /// The statement rendered back to shell text, **unexpanded**: `${HOME}`
    /// and `$(...)` appear as written, because a classifier judges what was
    /// asked, not what it resolved to. Truncated at
    /// [`PLAN_RENDER_LIMIT`] bytes with a marker naming the limit.
    pub rendered: String,
    /// The statement's kind: `"command"`, `"pipeline"`, `"for"`,
    /// `"and_chain"`, …
    pub statement_kind: String,
    /// Every command the statement contains, control-structure bodies
    /// included.
    pub commands: Vec<PlannedCommand>,
    /// Session variables the statement reads and does not itself lexically
    /// bind — sorted, deduplicated root names. Complete against the
    /// statement's **lexical** surface — kaish has no `eval` and no indirect
    /// expansion, so every read is visible in the source. It does not cover
    /// names bound at runtime by a builtin that takes them as arguments:
    /// `read`, `export`, `unset`, and `push` write session variables that
    /// argv-level analysis cannot see, so `read TOKEN && echo $TOKEN`
    /// reports `TOKEN` here, and the value an embedder peeks with
    /// `Kernel::get_var` is the one from before the `read`. Special forms
    /// (`$1`, `$?`, `$$`, `$@`, `$#`) are not session variables and are not
    /// listed.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub free_variables: Vec<String>,
    /// Names the statement itself binds **lexically** — an assignment
    /// target, a `for` variable, an env-prefix name, a tool-def parameter.
    /// Peeking session state for these is misleading (the statement supplies
    /// its own value), so a name that is both read and lexically bound lands
    /// here, never in `free_variables` — the safe direction. A name written
    /// only through a runtime binder (`read`, `export`, `unset`, `push`) is
    /// a plain argument, not a lexical bind: it lands in `free_variables`
    /// when the statement also reads it, and in neither set otherwise.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub bound_variables: Vec<String>,
}

/// The byte limit [`Plan::rendered`] is truncated at: 8 KiB. A statement
/// longer than this is a generated program, and a classifier that needs more
/// than 8 KiB of it is reading the wrong field — [`Plan::commands`] carries
/// the structure.
pub const PLAN_RENDER_LIMIT: usize = 8 * 1024;

impl Plan {
    /// Assemble a plan. The only constructor for this `#[non_exhaustive]`
    /// type — `rendered` is stored verbatim, so a producer truncates before
    /// calling.
    pub fn new(
        rendered: impl Into<String>,
        statement_kind: impl Into<String>,
        commands: Vec<PlannedCommand>,
    ) -> Self {
        Self {
            rendered: rendered.into(),
            statement_kind: statement_kind.into(),
            commands,
            free_variables: Vec::new(),
            bound_variables: Vec::new(),
        }
    }

    /// Attach the statement's variable analysis (sorted, deduplicated).
    pub fn with_variables(mut self, free: Vec<String>, bound: Vec<String>) -> Self {
        self.free_variables = free;
        self.bound_variables = bound;
        self
    }
}

/// One command inside a [`Plan`], as written (spec §C.6).
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PlannedCommand {
    /// argv0 as written — never resolved through aliases, `PATH`, or the
    /// tool registry. Never a [`PlannedValue`]: a command name is structural,
    /// never a credential.
    pub name: String,
    /// The arguments, rendered unexpanded — a presented confirm key reads as
    /// `PlannedValue::Redacted` here rather than as its literal text
    /// (spec §A.8).
    pub args: Vec<PlannedValue>,
    /// The redirections this command declares.
    pub redirects: Vec<PlannedRedirect>,
    /// Whether the enclosing pipeline was backgrounded with `&`.
    pub background: bool,
    /// The heredocs this command reads on stdin, in source order. Empty for
    /// every command that declares none.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub heredocs: Vec<PlannedHeredoc>,
}

impl PlannedCommand {
    /// Name one planned command. The only constructor for this
    /// `#[non_exhaustive]` type.
    pub fn new(
        name: impl Into<String>,
        args: Vec<PlannedValue>,
        redirects: Vec<PlannedRedirect>,
        background: bool,
    ) -> Self {
        Self {
            name: name.into(),
            args,
            redirects,
            background,
            heredocs: Vec::new(),
        }
    }

    /// Attach the heredocs this command reads on stdin.
    pub fn with_heredocs(mut self, heredocs: Vec<PlannedHeredoc>) -> Self {
        self.heredocs = heredocs;
        self
    }
}

/// One heredoc a [`PlannedCommand`] reads on stdin — the body a command is
/// fed, published as data.
///
/// Agents hand whole programs to interpreters this way (`python3 <<'PY'`,
/// `sqlite3 <<SQL`), and the shell framing is the part that has to come off
/// before anything can look at the program. It comes off here: the command
/// name is on the [`PlannedCommand`], the language hint is
/// [`delimiter`](Self::delimiter), and the program is [`body`](Self::body)
/// with no quoting or escaping applied.
///
/// [`literal`](Self::literal) decides what the body is worth. A quoted
/// delimiter (`<<'PY'`) means the body reaches the command exactly as
/// published; an unquoted one means the shell expands `${…}` and `$(…)`
/// first, so the published text is what was *asked for* and not what runs.
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PlannedHeredoc {
    /// The heredoc's position among every heredoc in its statement, counted
    /// in source order across every command the statement contains. This is
    /// the `heredoc` half of a [`FragmentAddr`].
    pub index: usize,
    /// The delimiter word as written, quotes removed: `PY` for both `<<PY`
    /// and `<<'PY'`. Authors often pick it for the language they are about to
    /// write, but roughly half the time it says nothing — a bare `EOF`
    /// outnumbers every self-describing word combined in real agent traffic.
    /// A weak hint worth keeping, never a classification.
    pub delimiter: String,
    /// Whether the delimiter was quoted (`<<'PY'`, `<<"PY"`). True means no
    /// expansion runs and [`body`](Self::body) is exactly what the command
    /// reads.
    pub literal: bool,
    /// Whether the `<<-` form was used. True means leading tabs come off each
    /// body line before the command sees it; the published body keeps them.
    pub strip_tabs: bool,
    /// The body as written, verbatim: no tab stripping, no expansion, no
    /// quoting, no kernel-internal rewriting. A generated program arrives
    /// whole and unescaped, ready to hand to whatever reads that language.
    pub body: PlannedValue,
    /// Byte offset of the body's first character in the source that was
    /// planned, for a caller attributing a finding back to a location.
    pub body_offset: usize,
    /// Session variables this body reads — sorted, deduplicated root names,
    /// and always empty when [`literal`](Self::literal) is true. These are
    /// the values that plug into the body, and the ones an expansion needs
    /// supplied.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub free_variables: Vec<String>,
}

impl PlannedHeredoc {
    /// Assemble one planned heredoc. The only constructor for this
    /// `#[non_exhaustive]` type.
    pub fn new(
        index: usize,
        delimiter: impl Into<String>,
        literal: bool,
        strip_tabs: bool,
        body: PlannedValue,
        body_offset: usize,
    ) -> Self {
        Self {
            index,
            delimiter: delimiter.into(),
            literal,
            strip_tabs,
            body,
            body_offset,
            free_variables: Vec::new(),
        }
    }

    /// Attach the body's variable analysis (sorted, deduplicated).
    pub fn with_free_variables(mut self, free: Vec<String>) -> Self {
        self.free_variables = free;
        self
    }
}

// ───────────────────────── Fragment expansion ─────────────────────────

/// Where one heredoc sits in a planned program: which statement, and which
/// heredoc within it.
///
/// The heredoc index is flat across the whole statement — the same
/// [`PlannedHeredoc::index`] the plan publishes — so a heredoc inside a loop
/// body or an `if` branch is addressable without walking the structure.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct FragmentAddr {
    /// The statement's position in the parsed program.
    pub statement: usize,
    /// The heredoc's position within that statement.
    pub heredoc: usize,
}

impl FragmentAddr {
    /// Name one fragment.
    pub fn new(statement: usize, heredoc: usize) -> Self {
        Self { statement, heredoc }
    }
}

/// What expanding a fragment produced.
///
/// There are two outcomes and no third: either the text is complete, or it is
/// blocked and no text comes back at all. Half-expanded source reads as
/// ground truth to whatever parses it next and is not, so this type cannot
/// represent it.
///
/// Deliberately **not** `#[non_exhaustive]`, unlike the record types around
/// it. A caller must handle both arms, and that is the guarantee — a wildcard
/// arm written today to satisfy the attribute is exactly where a third
/// outcome would land unnoticed tomorrow. A new variant here would be a
/// change every embedder must see, so it should break their build.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Expansion {
    /// Every expansion resolved. This is exactly the text the command reads
    /// on stdin, given the scope that was supplied.
    Complete(String),
    /// A `$(…)` stands between the body and its final text. Running it is a
    /// decision with a clock and a blast radius, so the kernel returns the
    /// question instead of answering it.
    Blocked {
        /// Every substitution the body contains, in source order.
        holes: Vec<Hole>,
    },
}

/// One `$(…)` inside a fragment: what it would run, as a plan.
///
/// A caller that decides the substitution is safe runs it in a kernel of its
/// own construction — its own capabilities, its own timeout, its own
/// cancellation — and expands again with the answer in the scope.
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Hole {
    /// The substitution rendered back to shell text, unexpanded: `$(date +%s)`.
    pub source: String,
    /// One plan per statement in the substitution's body — the same
    /// vocabulary the enclosing statement's plan uses, so a caller judging a
    /// hole reads it the way it reads everything else.
    pub plans: Vec<Plan>,
}

impl Hole {
    /// Name one substitution. The only constructor for this
    /// `#[non_exhaustive]` type.
    pub fn new(source: impl Into<String>, plans: Vec<Plan>) -> Self {
        Self {
            source: source.into(),
            plans,
        }
    }
}

/// One redirection inside a [`PlannedCommand`] (spec §C.6).
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PlannedRedirect {
    /// The operator as written: `">"`, `">>"`, `"2>"`, `"<"`, `"<<<"`, …
    pub kind: String,
    /// The target, rendered unexpanded — `> ${LOG}` keeps `${LOG}` — and
    /// through the same redaction seam every argument passes (spec §A.8).
    pub target: PlannedValue,
}

impl PlannedRedirect {
    /// Name one planned redirection. The only constructor for this
    /// `#[non_exhaustive]` type.
    pub fn new(kind: impl Into<String>, target: PlannedValue) -> Self {
        Self {
            kind: kind.into(),
            target,
        }
    }
}

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

    fn sample_plan() -> Plan {
        Plan::new(
            "cargo build > ${LOG}",
            "command",
            vec![PlannedCommand::new(
                "cargo",
                vec![PlannedValue::Plain("build".to_string())],
                vec![PlannedRedirect::new(">", PlannedValue::Plain("${LOG}".to_string()))],
                false,
            )],
        )
    }

    #[test]
    fn a_plan_round_trips_with_every_planned_field() {
        let plan = sample_plan();
        let json = serde_json::to_value(&plan).expect("serialize");
        let back: Plan = serde_json::from_value(json).expect("deserialize");
        assert_eq!(plan, back);
        assert_eq!(back.commands[0].redirects[0].kind, ">");
        // Unexpanded: the target keeps `${LOG}` as written, because an
        // embedder judges what was asked, not what it resolved to.
        assert_eq!(
            back.commands[0].redirects[0].target,
            PlannedValue::Plain("${LOG}".to_string())
        );
    }

    #[test]
    fn variables_default_to_empty_and_survive_a_round_trip() {
        let bare = sample_plan();
        assert!(bare.free_variables.is_empty());
        assert!(bare.bound_variables.is_empty());

        let plan = sample_plan()
            .with_variables(vec!["LOG".to_string()], vec!["OUT".to_string()]);
        let json = serde_json::to_value(&plan).expect("serialize");
        let back: Plan = serde_json::from_value(json).expect("deserialize");
        assert_eq!(back.free_variables, vec!["LOG".to_string()]);
        assert_eq!(back.bound_variables, vec!["OUT".to_string()]);
    }

    #[test]
    fn a_redacted_value_keeps_no_text() {
        // The kernel redacts its own confirm key and nothing else; the
        // variant carries a kind, never the credential it replaced.
        let json = serde_json::to_value(PlannedValue::redacted("confirm-key", None))
            .expect("serialize");
        assert!(
            !json.to_string().contains("secret"),
            "a redacted value must not carry text: {json}"
        );
    }

    #[test]
    fn a_plan_digest_round_trips() {
        let digest = PlanDigest::new("abc123");
        let json = serde_json::to_string(&digest).expect("serialize");
        let back: PlanDigest = serde_json::from_str(&json).expect("deserialize");
        assert_eq!(digest, back);
    }
}