Skip to main content

kaish_types/
plan.rs

1//! The statement-plan vocabulary: pure data plus serde, no behavior.
2//!
3//! A [`Plan`] is what `plan_program` produces for one statement — the source
4//! rendered back **unexpanded**, every [`PlannedCommand`] it would run, and
5//! the variables it reads and writes. An embedder reads a plan to decide
6//! whether to run a statement; nothing here decides anything itself.
7//!
8//! [`PlannedValue`] is the one place redaction appears. The kernel redacts
9//! exactly one thing — the `--confirm=<key>` flag spelling, kaish's own
10//! convention for a confirmation credential — and a redacted value keeps a
11//! *kind*, never the credential. kaish ships no secret detector, because a
12//! shell cannot define what a secret is; an embedder that wants more redacts
13//! the plans it holds.
14
15use serde::{Deserialize, Serialize};
16
17/// A content identity for a plan — a digest over its rendered text with any
18/// presented credential stripped, so `rm x` and `rm --confirm=<key> x`
19/// digest the same. The embedder computes it (e.g. SHA-256 over the
20/// kernel's `strip_confirm_tokens(rendered)`); this type only carries the
21/// value, so `kaish-types` stays dependency-light.
22#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
23#[serde(transparent)]
24pub struct PlanDigest(String);
25
26impl PlanDigest {
27    /// Wrap an already-computed digest.
28    pub fn new(hex: impl Into<String>) -> Self {
29        Self(hex.into())
30    }
31
32    /// The digest's text form.
33    pub fn as_str(&self) -> &str {
34        &self.0
35    }
36}
37
38// ───────────────────────── Redaction ─────────────────────────
39
40/// One value inside a rendered plan. A sink serializes `PlannedValue`, never
41/// a bare `String`, so a value reaches a sink only after something decided
42/// whether it was a secret.
43///
44/// The kernel builds every `PlannedValue` at one normalization point
45/// (`kaish-kernel`'s `ast::plan::plan_statement`), before the plan reaches
46/// any consumer. A consumer added later reads the same already-decided
47/// values instead of re-deriving its own redaction.
48#[non_exhaustive]
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50#[serde(rename_all = "snake_case")]
51pub enum PlannedValue {
52    /// Not judged secret. Holds the literal text, exactly as it would render
53    /// on the command line.
54    Plain(String),
55    /// Judged secret — today only by the kernel's own confirm-key check;
56    /// the original text never reaches this variant or anything built from
57    /// it. The variant is the vocabulary an embedder-side redaction pass can
58    /// also produce over plans it holds.
59    Redacted {
60        /// What kind of secret — `"confirm-key"` for the kernel's one
61        /// built-in redaction. The kernel does not interpret this string.
62        kind: String,
63        /// Stable salted digest prefix, when the producer supplied one, so an
64        /// auditor can ask "the same credential as last time?" without
65        /// holding it.
66        #[serde(default, skip_serializing_if = "Option::is_none")]
67        fingerprint: Option<String>,
68    },
69}
70
71impl PlannedValue {
72    /// Build a value the kernel judged secret.
73    pub fn redacted(kind: impl Into<String>, fingerprint: Option<String>) -> Self {
74        Self::Redacted {
75            kind: kind.into(),
76            fingerprint,
77        }
78    }
79
80    /// The text a sink should show: the literal for `Plain`, or `<kind>` for
81    /// `Redacted` — never the redacted content itself.
82    pub fn display(&self) -> String {
83        match self {
84            Self::Plain(s) => s.clone(),
85            Self::Redacted { kind, .. } => format!("<{kind}>"),
86        }
87    }
88
89    /// Whether this value was judged secret.
90    pub fn is_redacted(&self) -> bool {
91        matches!(self, Self::Redacted { .. })
92    }
93}
94
95// ───────────────────────── The statement plan ─────────────────────────
96
97/// What one top-level statement was asked to run (spec §C.6).
98///
99/// Built from the AST after validation and **before** execution, so it is
100/// parse information and never execution information: no substitution has
101/// run, no redirect has been opened, no loop has taken its first iteration.
102/// Nested statements — loop bodies, `if` branches, user-tool bodies — belong
103/// to their enclosing top-level statement's plan and are never planned
104/// separately.
105#[non_exhaustive]
106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
107pub struct Plan {
108    /// The statement rendered back to shell text, **unexpanded**: `${HOME}`
109    /// and `$(...)` appear as written, because a classifier judges what was
110    /// asked, not what it resolved to. Truncated at
111    /// [`PLAN_RENDER_LIMIT`] bytes with a marker naming the limit.
112    pub rendered: String,
113    /// The statement's kind: `"command"`, `"pipeline"`, `"for"`,
114    /// `"and_chain"`, …
115    pub statement_kind: String,
116    /// Every command the statement contains, control-structure bodies
117    /// included.
118    pub commands: Vec<PlannedCommand>,
119    /// Session variables the statement reads and does not itself lexically
120    /// bind — sorted, deduplicated root names. Complete against the
121    /// statement's **lexical** surface — kaish has no `eval` and no indirect
122    /// expansion, so every read is visible in the source. It does not cover
123    /// names bound at runtime by a builtin that takes them as arguments:
124    /// `read`, `export`, `unset`, and `push` write session variables that
125    /// argv-level analysis cannot see, so `read TOKEN && echo $TOKEN`
126    /// reports `TOKEN` here, and the value an embedder peeks with
127    /// `Kernel::get_var` is the one from before the `read`. Special forms
128    /// (`$1`, `$?`, `$$`, `$@`, `$#`) are not session variables and are not
129    /// listed.
130    #[serde(default, skip_serializing_if = "Vec::is_empty")]
131    pub free_variables: Vec<String>,
132    /// Names the statement itself binds **lexically** — an assignment
133    /// target, a `for` variable, an env-prefix name, a tool-def parameter.
134    /// Peeking session state for these is misleading (the statement supplies
135    /// its own value), so a name that is both read and lexically bound lands
136    /// here, never in `free_variables` — the safe direction. A name written
137    /// only through a runtime binder (`read`, `export`, `unset`, `push`) is
138    /// a plain argument, not a lexical bind: it lands in `free_variables`
139    /// when the statement also reads it, and in neither set otherwise.
140    #[serde(default, skip_serializing_if = "Vec::is_empty")]
141    pub bound_variables: Vec<String>,
142}
143
144/// The byte limit [`Plan::rendered`] is truncated at: 8 KiB. A statement
145/// longer than this is a generated program, and a classifier that needs more
146/// than 8 KiB of it is reading the wrong field — [`Plan::commands`] carries
147/// the structure.
148pub const PLAN_RENDER_LIMIT: usize = 8 * 1024;
149
150impl Plan {
151    /// Assemble a plan. The only constructor for this `#[non_exhaustive]`
152    /// type — `rendered` is stored verbatim, so a producer truncates before
153    /// calling.
154    pub fn new(
155        rendered: impl Into<String>,
156        statement_kind: impl Into<String>,
157        commands: Vec<PlannedCommand>,
158    ) -> Self {
159        Self {
160            rendered: rendered.into(),
161            statement_kind: statement_kind.into(),
162            commands,
163            free_variables: Vec::new(),
164            bound_variables: Vec::new(),
165        }
166    }
167
168    /// Attach the statement's variable analysis (sorted, deduplicated).
169    pub fn with_variables(mut self, free: Vec<String>, bound: Vec<String>) -> Self {
170        self.free_variables = free;
171        self.bound_variables = bound;
172        self
173    }
174}
175
176/// One command inside a [`Plan`], as written (spec §C.6).
177#[non_exhaustive]
178#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
179pub struct PlannedCommand {
180    /// argv0 as written — never resolved through aliases, `PATH`, or the
181    /// tool registry. Never a [`PlannedValue`]: a command name is structural,
182    /// never a credential.
183    pub name: String,
184    /// The arguments, rendered unexpanded — a presented confirm key reads as
185    /// `PlannedValue::Redacted` here rather than as its literal text
186    /// (spec §A.8).
187    pub args: Vec<PlannedValue>,
188    /// The redirections this command declares.
189    pub redirects: Vec<PlannedRedirect>,
190    /// Whether the enclosing pipeline was backgrounded with `&`.
191    pub background: bool,
192    /// The heredocs this command reads on stdin, in source order. Empty for
193    /// every command that declares none.
194    #[serde(default, skip_serializing_if = "Vec::is_empty")]
195    pub heredocs: Vec<PlannedHeredoc>,
196}
197
198impl PlannedCommand {
199    /// Name one planned command. The only constructor for this
200    /// `#[non_exhaustive]` type.
201    pub fn new(
202        name: impl Into<String>,
203        args: Vec<PlannedValue>,
204        redirects: Vec<PlannedRedirect>,
205        background: bool,
206    ) -> Self {
207        Self {
208            name: name.into(),
209            args,
210            redirects,
211            background,
212            heredocs: Vec::new(),
213        }
214    }
215
216    /// Attach the heredocs this command reads on stdin.
217    pub fn with_heredocs(mut self, heredocs: Vec<PlannedHeredoc>) -> Self {
218        self.heredocs = heredocs;
219        self
220    }
221}
222
223/// One heredoc a [`PlannedCommand`] reads on stdin — the body a command is
224/// fed, published as data.
225///
226/// Agents hand whole programs to interpreters this way (`python3 <<'PY'`,
227/// `sqlite3 <<SQL`), and the shell framing is the part that has to come off
228/// before anything can look at the program. It comes off here: the command
229/// name is on the [`PlannedCommand`], the language hint is
230/// [`delimiter`](Self::delimiter), and the program is [`body`](Self::body)
231/// with no quoting or escaping applied.
232///
233/// [`literal`](Self::literal) decides what the body is worth. A quoted
234/// delimiter (`<<'PY'`) means the body reaches the command exactly as
235/// published; an unquoted one means the shell expands `${…}` and `$(…)`
236/// first, so the published text is what was *asked for* and not what runs.
237#[non_exhaustive]
238#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
239pub struct PlannedHeredoc {
240    /// The heredoc's position among every heredoc in its statement, counted
241    /// in source order across every command the statement contains. This is
242    /// the `heredoc` half of a [`FragmentAddr`].
243    pub index: usize,
244    /// The delimiter word as written, quotes removed: `PY` for both `<<PY`
245    /// and `<<'PY'`. Authors often pick it for the language they are about to
246    /// write, but roughly half the time it says nothing — a bare `EOF`
247    /// outnumbers every self-describing word combined in real agent traffic.
248    /// A weak hint worth keeping, never a classification.
249    pub delimiter: String,
250    /// Whether the delimiter was quoted (`<<'PY'`, `<<"PY"`). True means no
251    /// expansion runs and [`body`](Self::body) is exactly what the command
252    /// reads.
253    pub literal: bool,
254    /// Whether the `<<-` form was used. True means leading tabs come off each
255    /// body line before the command sees it; the published body keeps them.
256    pub strip_tabs: bool,
257    /// The body as written, verbatim: no tab stripping, no expansion, no
258    /// quoting, no kernel-internal rewriting. A generated program arrives
259    /// whole and unescaped, ready to hand to whatever reads that language.
260    pub body: PlannedValue,
261    /// Byte offset of the body's first character in the source that was
262    /// planned, for a caller attributing a finding back to a location.
263    pub body_offset: usize,
264    /// Session variables this body reads — sorted, deduplicated root names,
265    /// and always empty when [`literal`](Self::literal) is true. These are
266    /// the values that plug into the body, and the ones an expansion needs
267    /// supplied.
268    #[serde(default, skip_serializing_if = "Vec::is_empty")]
269    pub free_variables: Vec<String>,
270}
271
272impl PlannedHeredoc {
273    /// Assemble one planned heredoc. The only constructor for this
274    /// `#[non_exhaustive]` type.
275    pub fn new(
276        index: usize,
277        delimiter: impl Into<String>,
278        literal: bool,
279        strip_tabs: bool,
280        body: PlannedValue,
281        body_offset: usize,
282    ) -> Self {
283        Self {
284            index,
285            delimiter: delimiter.into(),
286            literal,
287            strip_tabs,
288            body,
289            body_offset,
290            free_variables: Vec::new(),
291        }
292    }
293
294    /// Attach the body's variable analysis (sorted, deduplicated).
295    pub fn with_free_variables(mut self, free: Vec<String>) -> Self {
296        self.free_variables = free;
297        self
298    }
299}
300
301// ───────────────────────── Fragment expansion ─────────────────────────
302
303/// Where one heredoc sits in a planned program: which statement, and which
304/// heredoc within it.
305///
306/// The heredoc index is flat across the whole statement — the same
307/// [`PlannedHeredoc::index`] the plan publishes — so a heredoc inside a loop
308/// body or an `if` branch is addressable without walking the structure.
309#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
310pub struct FragmentAddr {
311    /// The statement's position in the parsed program.
312    pub statement: usize,
313    /// The heredoc's position within that statement.
314    pub heredoc: usize,
315}
316
317impl FragmentAddr {
318    /// Name one fragment.
319    pub fn new(statement: usize, heredoc: usize) -> Self {
320        Self { statement, heredoc }
321    }
322}
323
324/// What expanding a fragment produced.
325///
326/// There are two outcomes and no third: either the text is complete, or it is
327/// blocked and no text comes back at all. Half-expanded source reads as
328/// ground truth to whatever parses it next and is not, so this type cannot
329/// represent it.
330///
331/// Deliberately **not** `#[non_exhaustive]`, unlike the record types around
332/// it. A caller must handle both arms, and that is the guarantee — a wildcard
333/// arm written today to satisfy the attribute is exactly where a third
334/// outcome would land unnoticed tomorrow. A new variant here would be a
335/// change every embedder must see, so it should break their build.
336#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
337#[serde(rename_all = "snake_case")]
338pub enum Expansion {
339    /// Every expansion resolved. This is exactly the text the command reads
340    /// on stdin, given the scope that was supplied.
341    Complete(String),
342    /// A `$(…)` stands between the body and its final text. Running it is a
343    /// decision with a clock and a blast radius, so the kernel returns the
344    /// question instead of answering it.
345    Blocked {
346        /// Every substitution the body contains, in source order.
347        holes: Vec<Hole>,
348    },
349}
350
351/// One `$(…)` inside a fragment: what it would run, as a plan.
352///
353/// A caller that decides the substitution is safe runs it in a kernel of its
354/// own construction — its own capabilities, its own timeout, its own
355/// cancellation — and expands again with the answer in the scope.
356#[non_exhaustive]
357#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
358pub struct Hole {
359    /// The substitution rendered back to shell text, unexpanded: `$(date +%s)`.
360    pub source: String,
361    /// One plan per statement in the substitution's body — the same
362    /// vocabulary the enclosing statement's plan uses, so a caller judging a
363    /// hole reads it the way it reads everything else.
364    pub plans: Vec<Plan>,
365}
366
367impl Hole {
368    /// Name one substitution. The only constructor for this
369    /// `#[non_exhaustive]` type.
370    pub fn new(source: impl Into<String>, plans: Vec<Plan>) -> Self {
371        Self {
372            source: source.into(),
373            plans,
374        }
375    }
376}
377
378/// One redirection inside a [`PlannedCommand`] (spec §C.6).
379#[non_exhaustive]
380#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
381pub struct PlannedRedirect {
382    /// The operator as written: `">"`, `">>"`, `"2>"`, `"<"`, `"<<<"`, …
383    pub kind: String,
384    /// The target, rendered unexpanded — `> ${LOG}` keeps `${LOG}` — and
385    /// through the same redaction seam every argument passes (spec §A.8).
386    pub target: PlannedValue,
387}
388
389impl PlannedRedirect {
390    /// Name one planned redirection. The only constructor for this
391    /// `#[non_exhaustive]` type.
392    pub fn new(kind: impl Into<String>, target: PlannedValue) -> Self {
393        Self {
394            kind: kind.into(),
395            target,
396        }
397    }
398}
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403
404    fn sample_plan() -> Plan {
405        Plan::new(
406            "cargo build > ${LOG}",
407            "command",
408            vec![PlannedCommand::new(
409                "cargo",
410                vec![PlannedValue::Plain("build".to_string())],
411                vec![PlannedRedirect::new(">", PlannedValue::Plain("${LOG}".to_string()))],
412                false,
413            )],
414        )
415    }
416
417    #[test]
418    fn a_plan_round_trips_with_every_planned_field() {
419        let plan = sample_plan();
420        let json = serde_json::to_value(&plan).expect("serialize");
421        let back: Plan = serde_json::from_value(json).expect("deserialize");
422        assert_eq!(plan, back);
423        assert_eq!(back.commands[0].redirects[0].kind, ">");
424        // Unexpanded: the target keeps `${LOG}` as written, because an
425        // embedder judges what was asked, not what it resolved to.
426        assert_eq!(
427            back.commands[0].redirects[0].target,
428            PlannedValue::Plain("${LOG}".to_string())
429        );
430    }
431
432    #[test]
433    fn variables_default_to_empty_and_survive_a_round_trip() {
434        let bare = sample_plan();
435        assert!(bare.free_variables.is_empty());
436        assert!(bare.bound_variables.is_empty());
437
438        let plan = sample_plan()
439            .with_variables(vec!["LOG".to_string()], vec!["OUT".to_string()]);
440        let json = serde_json::to_value(&plan).expect("serialize");
441        let back: Plan = serde_json::from_value(json).expect("deserialize");
442        assert_eq!(back.free_variables, vec!["LOG".to_string()]);
443        assert_eq!(back.bound_variables, vec!["OUT".to_string()]);
444    }
445
446    #[test]
447    fn a_redacted_value_keeps_no_text() {
448        // The kernel redacts its own confirm key and nothing else; the
449        // variant carries a kind, never the credential it replaced.
450        let json = serde_json::to_value(PlannedValue::redacted("confirm-key", None))
451            .expect("serialize");
452        assert!(
453            !json.to_string().contains("secret"),
454            "a redacted value must not carry text: {json}"
455        );
456    }
457
458    #[test]
459    fn a_plan_digest_round_trips() {
460        let digest = PlanDigest::new("abc123");
461        let json = serde_json::to_string(&digest).expect("serialize");
462        let back: PlanDigest = serde_json::from_str(&json).expect("deserialize");
463        assert_eq!(digest, back);
464    }
465}