Skip to main content

execkit/
checkpoint.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Remote workspace checkpoints: git-backed "undo" for an agent's file changes.
3//!
4//! Pure logic only - command strings, output parsing, and read-only command
5//! classification. `Session` runs the commands over its transport (see
6//! `session.rs`); keeping the logic transport-free makes it unit-testable.
7
8use serde::Serialize;
9
10/// A checkpoint identifier: a commit SHA in the shadow git repo.
11#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
12pub struct CheckpointId(pub String);
13
14/// One checkpoint in the linear history.
15#[derive(Debug, Clone, Serialize)]
16pub struct Checkpoint {
17    /// Commit SHA (the checkpoint id).
18    pub id: String,
19    pub label: String,
20    /// Unix timestamp (seconds since the epoch) as a decimal string (git `%ct`).
21    pub created: String,
22}
23
24/// What a restore changed.
25#[derive(Debug, Clone, Serialize)]
26pub struct RestoreReport {
27    pub restored_to: String,
28    pub files_changed: usize,
29}
30
31/// Programs that never write the filesystem in normal use. Conservative:
32/// anything not here, any redirection, or command substitution => snapshot.
33// Note: `find` (-delete/-exec), `env`/`printenv` (run an arbitrary command),
34// `uniq` (writes via its OUTPUT positional arg), and `sort`/`sed`/`awk`/`tee` are
35// deliberately EXCLUDED - they can write or execute, so they always snapshot.
36// Membership here assumes default (filesystem-read) usage of the program.
37pub(crate) const READ_ONLY: &[&str] = &[
38    "ls", "cat", "head", "tail", "grep", "egrep", "fgrep", "pwd", "echo", "printf", "which",
39    "whoami", "id", "hostname", "uname", "date", "stat", "file", "wc", "cut", "ps", "df", "du",
40    "free", "uptime",
41];
42
43/// True if `command` is unambiguously read-only (auto-snapshot can be skipped).
44pub(crate) fn is_read_only(command: &str) -> bool {
45    // Redirections, command substitution `$(...)`/backtick, and process
46    // substitution `<(...)` can write or hide writes/execution. (`>(...)` is
47    // covered by the `>` check.)
48    if command.contains('>')
49        || command.contains("$(")
50        || command.contains('`')
51        || command.contains("<(")
52    {
53        return false;
54    }
55    // Every segment of a pipeline / chain / multi-line script must start with an
56    // allowlisted program. Newlines count: run_framed wraps the whole command in
57    // `{ ...; }`, so every line runs.
58    #[allow(clippy::manual_pattern_char_comparison)]
59    for seg in command.split(|c| matches!(c, '|' | ';' | '&' | '\n' | '\r')) {
60        let seg = seg.trim();
61        if seg.is_empty() {
62            continue;
63        }
64        let prog = seg.split_whitespace().next().unwrap_or("");
65        let base = prog.rsplit('/').next().unwrap_or(prog);
66        if !READ_ONLY.contains(&base) {
67            return false;
68        }
69    }
70    true
71}
72
73/// Regenerable build/cache output: written BEFORE user patterns so users may
74/// legitimately override them (e.g. a project that tracks its `dist/` folder).
75const BUILD_IGNORES: &[&str] = &[
76    ".git",
77    "node_modules",
78    "target",
79    ".venv",
80    "__pycache__",
81    ".mypy_cache",
82    "dist",
83    "build",
84];
85
86/// Secret/credential paths: written AFTER user patterns so they are always the
87/// LAST matching rules. gitignore semantics are "last match wins," so placing
88/// these after any user-supplied negations (e.g. `!.ssh`) ensures a negation
89/// cannot re-include them. Never capture or restore these paths.
90///
91/// `.execkit` is included here to prevent self-capture: if workspace == $HOME,
92/// `git add` would walk into the live shadow git-dir and corrupt the index.
93/// A user `!.execkit` negation CANNOT re-enable self-capture because SECRET_IGNORES
94/// trails all user patterns.
95const SECRET_IGNORES: &[&str] = &[
96    ".cache",
97    ".ssh",
98    ".gnupg",
99    ".aws",
100    ".netrc",
101    ".execkit",
102    ".env",
103    ".env.*",
104    "*.pem",
105    "*.key",
106    "id_rsa*",
107    "id_ed25519*",
108];
109
110/// Single-quote a value for safe use in a `/bin/sh` command.
111pub(crate) fn shq(s: &str) -> String {
112    format!("'{}'", s.replace('\'', "'\\''"))
113}
114
115/// Config + lazy state for a session's checkpoints. Builds command strings; the
116/// Session runs them and feeds outputs back to the parsers.
117pub(crate) struct Checkpointer {
118    token: String,
119    pub auto: bool,
120    pub workspace: Option<String>, // explicit root; REQUIRED (no cwd fallback)
121    paths: Vec<String>,            // sub-paths under root; default ["."]
122    ignores: Vec<String>,          // extra exclude patterns (additive to defaults)
123    pub root: Option<String>,      // resolved root (set on first snapshot)
124    pub initialized: bool,
125    pub git_unavailable: bool,
126    pub last: Option<String>, // last checkpoint id
127}
128
129impl Checkpointer {
130    pub fn new(token: &str, auto: bool, workspace: Option<String>, paths: Vec<String>) -> Self {
131        // token is interpolated into a double-quoted shell string in git_dir(); it
132        // MUST be all hex so no shell-special characters can sneak in. The caller
133        // (unique_token) always produces hex, so this fires only in tests/dev.
134        debug_assert!(
135            token.bytes().all(|b| b.is_ascii_hexdigit()),
136            "checkpoint token must be hex"
137        );
138        let paths = if paths.is_empty() {
139            vec![".".into()]
140        } else {
141            paths
142        };
143        Self {
144            token: token.to_string(),
145            auto,
146            workspace,
147            paths,
148            ignores: Vec::new(),
149            root: None,
150            initialized: false,
151            git_unavailable: false,
152            last: None,
153        }
154    }
155
156    fn git_dir(&self) -> String {
157        // $HOME expands on the remote; token is hex-safe so the rest is literal.
158        format!("\"$HOME/.execkit/ckpt-{}.git\"", self.token)
159    }
160
161    /// The shadow repo's basename under `~/.execkit` (`ckpt-<token>.git`). Lets
162    /// callers (test hooks) target the repo directly without the private token.
163    pub(crate) fn shadow_repo_name(&self) -> String {
164        format!("ckpt-{}.git", self.token)
165    }
166
167    fn pathspec(&self) -> String {
168        self.paths
169            .iter()
170            .map(|p| shq(p))
171            .collect::<Vec<_>>()
172            .join(" ")
173    }
174
175    fn git(&self, root: &str) -> String {
176        // -C <root> so git runs WITH cwd = the work-tree: the session's cwd is not
177        // necessarily the workspace root, and pathspecs (`.`, `src`, ...) resolve
178        // relative to cwd. Without this, `add -- .` captures nothing.
179        format!(
180            "git -C {root} --git-dir={gd} --work-tree={root}",
181            root = shq(root),
182            gd = self.git_dir()
183        )
184    }
185
186    /// One-time: detect git, ensure the shadow repo exists with excludes set.
187    pub fn init_cmd(&self, root: &str) -> String {
188        // Order matters: gitignore uses "last match wins."
189        // BUILD_IGNORES first (users may override), then user patterns, then
190        // SECRET_IGNORES last (non-negotiable: any user negation in the middle
191        // is overridden by the trailing secret rules).
192        let mut all: Vec<&str> = BUILD_IGNORES.to_vec();
193        all.extend(self.ignores.iter().map(String::as_str));
194        all.extend_from_slice(SECRET_IGNORES);
195        let excludes = all.join("\n");
196        let g = self.git(root);
197        format!(
198            "mkdir -p \"$HOME/.execkit\" && {g} init -q && \
199             printf '%s\\n' {ex} > {gd}/info/exclude",
200            g = g,
201            ex = shq(&excludes),
202            gd = self.git_dir(),
203        )
204    }
205
206    // Always commits with --allow-empty: auto-snapshot runs BEFORE each changing
207    // command (to capture the pre-change baseline), so even an empty/unchanged
208    // workspace must produce a restorable point (e.g. to undo the first file
209    // creation in an empty workspace). The cost is one tiny empty commit per
210    // unchanged write-ish command; pruning that growth without losing the baseline
211    // is a future improvement.
212    pub fn snapshot_cmd(&self, root: &str, label: &str) -> String {
213        let g = self.git(root);
214        format!(
215            "{g} add -- {paths}; \
216             {g} -c user.email=execkit@local -c user.name=execkit \
217             commit -q --allow-empty -m {label} && {g} rev-parse HEAD",
218            paths = self.pathspec(),
219            label = shq(label),
220        )
221    }
222
223    pub fn restore_cmd(&self, root: &str, id: &str) -> String {
224        // id is agent-controlled (untrusted): single-quote it so a malicious value
225        // becomes a harmless literal ref (git just fails to find it).
226        //
227        // --no-overlay: a plain `checkout <id> -- <paths>` only writes files
228        // present in <id>; a file that didn't exist at <id> but was tracked by a
229        // LATER checkpoint (and never untracked-removed by `clean`, since it IS
230        // tracked) would survive a restore to the earlier id. --no-overlay makes
231        // checkout also remove tracked files that are absent from <id>.
232        let g = self.git(root);
233        format!(
234            "{g} checkout --no-overlay {id} -- {paths} && {g} clean -fdq -- {paths}",
235            id = shq(id),
236            paths = self.pathspec(),
237        )
238    }
239
240    pub fn list_cmd(&self, root: &str) -> String {
241        // "<sha> <unixtime> <subject>" per line, newest first. Space-delimited
242        // (NOT a control char): SHA and unixtime are space-free, so splitn(3, ' ')
243        // is unambiguous, and it survives the PTY + framing unmangled.
244        format!("{} log --format='%H %ct %s'", self.git(root))
245    }
246
247    pub fn set_ignores(&mut self, ignores: Vec<String>) {
248        self.ignores = ignores;
249    }
250
251    pub fn set_paths(&mut self, paths: Vec<String>) {
252        self.paths = paths;
253    }
254
255    /// Remove this session's shadow repo entirely. Run (best-effort) on
256    /// `Session` drop so `~/.execkit` doesn't accumulate one directory per
257    /// session ever opened against the remote host.
258    pub fn cleanup_cmd(&self) -> String {
259        format!("rm -rf {}", self.git_dir())
260    }
261
262    /// Count files differing from `id` within the checkpoint paths.
263    pub fn diff_count_cmd(&self, root: &str, id: &str) -> String {
264        format!(
265            "{} diff --name-only {} -- {} | wc -l",
266            self.git(root),
267            shq(id),
268            self.pathspec()
269        )
270    }
271}
272
273/// First whitespace-delimited token of git output (a commit SHA), or None.
274pub(crate) fn parse_sha(out: &str) -> Option<String> {
275    out.split_whitespace().next().map(|s| s.to_string())
276}
277
278/// Parse `list_cmd` output into checkpoints (newest first).
279pub(crate) fn parse_log(out: &str) -> Vec<Checkpoint> {
280    out.lines()
281        .filter_map(|line| {
282            let mut it = line.splitn(3, ' ');
283            let id = it.next()?.trim();
284            // SHA is 40 hex chars; ignore any non-commit noise lines.
285            if id.len() != 40 || !id.bytes().all(|b| b.is_ascii_hexdigit()) {
286                return None;
287            }
288            let created = it.next().unwrap_or("").to_string();
289            let label = it.next().unwrap_or("").to_string();
290            Some(Checkpoint {
291                id: id.to_string(),
292                label,
293                created,
294            })
295        })
296        .collect()
297}
298
299#[cfg(test)]
300mod builder_tests {
301    use super::{shq, Checkpointer};
302
303    fn cp() -> Checkpointer {
304        Checkpointer::new("abc123", true, None, vec![".".into()])
305    }
306
307    #[test]
308    fn shell_quote_is_safe() {
309        assert_eq!(shq("/srv/app"), "'/srv/app'");
310        assert_eq!(shq("a b"), "'a b'");
311        assert_eq!(shq("it's"), "'it'\\''s'");
312    }
313
314    #[test]
315    fn snapshot_and_restore_commands_are_scoped() {
316        let c = cp();
317        let root = "/srv/app";
318        let snap = c.snapshot_cmd(root, "before");
319        assert!(snap.contains("--git-dir=\"$HOME/.execkit/ckpt-abc123.git\""));
320        assert!(snap.contains("--work-tree='/srv/app'"));
321        assert!(snap.contains("add -- '.'"));
322        assert!(snap.contains("commit -q --allow-empty"));
323
324        let restore = c.restore_cmd(root, "deadbeef");
325        assert!(restore.contains("checkout --no-overlay 'deadbeef' -- '.'"));
326        assert!(restore.contains("clean -fdq -- '.'"));
327    }
328
329    #[test]
330    fn cleanup_cmd_removes_shadow_repo() {
331        let c = cp();
332        assert_eq!(c.cleanup_cmd(), "rm -rf \"$HOME/.execkit/ckpt-abc123.git\"");
333    }
334
335    #[test]
336    fn init_cmd_excludes_env_and_key_files() {
337        let cp = Checkpointer::new("abc123", true, Some("/srv/app".into()), vec![".".into()]);
338        let cmd = cp.init_cmd("/srv/app");
339        for pat in [".env", ".env.*", "*.pem", "*.key", "id_rsa*", "id_ed25519*"] {
340            assert!(cmd.contains(pat), "missing secret ignore pattern: {pat}");
341        }
342    }
343
344    #[test]
345    fn multi_path_scopes_each_path() {
346        let c = Checkpointer::new(
347            "deadbeef",
348            true,
349            Some("/srv/app".into()),
350            vec!["src".into(), "migrations".into()],
351        );
352        let snap = c.snapshot_cmd("/srv/app", "x");
353        assert!(snap.contains("add -- 'src' 'migrations'"));
354    }
355
356    #[test]
357    fn parse_commit_sha_takes_first_token() {
358        assert_eq!(super::parse_sha("a1b2c3d4\n"), Some("a1b2c3d4".to_string()));
359        assert_eq!(super::parse_sha("  \n"), None);
360    }
361
362    #[test]
363    fn parse_log_reads_records() {
364        // "<40-hex sha> <unixtime> <subject>" per line.
365        let a = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
366        let b = "cafebabecafebabecafebabecafebabecafebabe";
367        let out = format!("{a} 1700000000 before refactor\n{b} 1699999999 init\n");
368        let list = super::parse_log(&out);
369        assert_eq!(list.len(), 2);
370        assert_eq!(list[0].id, a);
371        assert_eq!(list[0].created, "1700000000");
372        assert_eq!(list[0].label, "before refactor");
373        assert_eq!(list[1].id, b);
374        // non-commit noise lines are ignored (only 40-hex-sha lines count).
375        let noisy = format!("{a} 1700000000 x\n~~~ junk ~~~\n");
376        assert_eq!(super::parse_log(&noisy).len(), 1);
377    }
378
379    #[test]
380    fn init_cmd_excludes_defaults_plus_user_ignores() {
381        let mut cp = Checkpointer::new("abc123", true, Some("/srv/app".into()), vec![".".into()]);
382        cp.set_ignores(vec!["*.log".into(), "secrets".into()]);
383        let cmd = cp.init_cmd("/srv/app");
384        // a build default, a secret default, and both user patterns must appear.
385        assert!(cmd.contains("node_modules"));
386        assert!(cmd.contains(".ssh"));
387        assert!(cmd.contains("*.log"));
388        assert!(cmd.contains("secrets"));
389        assert!(cmd.contains("info/exclude"));
390        // SEC-6: .execkit must be excluded so workspace==$HOME does not self-capture.
391        assert!(
392            cmd.contains(".execkit"),
393            ".execkit must appear in the excludes to prevent shadow git-dir self-capture"
394        );
395    }
396
397    #[test]
398    fn execkit_dir_excluded_as_non_negotiable_secret_ignore() {
399        // Even if a user passes "!.execkit" as an ignore (trying to re-enable
400        // self-capture), the trailing SECRET_IGNORES entry ".execkit" must win.
401        let mut cp = Checkpointer::new("abc123", true, Some("/home/user".into()), vec![".".into()]);
402        cp.set_ignores(vec!["!.execkit".into()]);
403        let cmd = cp.init_cmd("/home/user");
404
405        // Both the negation and the secret rule must be present.
406        assert!(cmd.contains("!.execkit"), "user negation must appear");
407        assert!(cmd.contains(".execkit"), "secret exclude must appear");
408
409        // The LAST occurrence of ".execkit" in the exclude blob must NOT be
410        // a negation -- i.e. SECRET_IGNORES ".execkit" wins over "!.execkit".
411        let last_pos = cmd
412            .rfind(".execkit")
413            .expect(".execkit not found in command");
414        let byte_before = if last_pos > 0 {
415            cmd.as_bytes().get(last_pos - 1).copied()
416        } else {
417            None
418        };
419        assert_ne!(
420            byte_before,
421            Some(b'!'),
422            "last occurrence of .execkit must not be a negation; SECRET_IGNORES must trail user patterns"
423        );
424    }
425
426    #[test]
427    fn init_cmd_secret_excludes_appear_after_user_negation() {
428        // SEC-4: a user-supplied negation like `!.ssh` must be overridden by the
429        // trailing SECRET_IGNORES entry.  gitignore last-match-wins means the rule
430        // written LAST controls; we verify the position invariant here.
431        let mut cp = Checkpointer::new("abc123", true, Some("/srv/app".into()), vec![".".into()]);
432        cp.set_ignores(vec!["!.ssh".into()]);
433        let cmd = cp.init_cmd("/srv/app");
434
435        // Both the negation and the secret rule must be present.
436        assert!(
437            cmd.contains("!.ssh"),
438            "user negation must appear in command"
439        );
440        assert!(
441            cmd.contains(".ssh"),
442            "secret exclude must appear in command"
443        );
444
445        // The LAST occurrence of `.ssh` in the exclude blob must not be preceded
446        // by `!` -- i.e. the SECRET_IGNORES `.ssh` wins over the user `!.ssh`.
447        let last_ssh_pos = cmd.rfind(".ssh").expect("`.ssh` not found in command");
448        // Check the byte immediately before the match (if any).
449        let byte_before = if last_ssh_pos > 0 {
450            cmd.as_bytes().get(last_ssh_pos - 1).copied()
451        } else {
452            None
453        };
454        assert_ne!(
455            byte_before,
456            Some(b'!'),
457            "last occurrence of `.ssh` must not be a negation (`!.ssh`); \
458             SECRET_IGNORES must trail user patterns"
459        );
460    }
461}
462
463#[cfg(test)]
464mod read_only_tests {
465    use super::is_read_only;
466
467    #[test]
468    fn classifies_commands() {
469        // read-only -> true
470        assert!(is_read_only("ls -la"));
471        assert!(is_read_only("cat f | grep x"));
472        assert!(is_read_only("ls; cat f"));
473        assert!(is_read_only("/bin/ls /tmp"));
474        assert!(is_read_only("ps aux | grep nginx"));
475        // writing / ambiguous -> false
476        assert!(!is_read_only("rm -rf build"));
477        assert!(!is_read_only("echo hi > f")); // redirection
478        assert!(!is_read_only("sed -i s/a/b/ f")); // sed not allowlisted
479        assert!(!is_read_only("ls && rm x")); // a segment writes
480        assert!(!is_read_only("cat $(whoami)")); // command substitution
481        assert!(!is_read_only("tee f")); // tee can write
482        assert!(!is_read_only("npm install")); // unknown program
483                                               // regression (final review): command/file gateways + newline-separated writes
484        assert!(!is_read_only("find . -delete")); // find can delete / -exec
485        assert!(!is_read_only("env X=1 rm -rf y")); // env runs a command
486        assert!(!is_read_only("uptime\nrm -rf /tmp/x")); // newline -> second line writes
487        assert!(!is_read_only("cat <(rm x)")); // process substitution executes rm
488        assert!(!is_read_only("uniq in out")); // uniq OUTPUT positional writes a file
489    }
490}