pushkin 0.2.1

Schema-first enforcement harness that gates AI coding agents' file writes against project contracts
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
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
//! CLI verb implementations.

pub mod affected;
pub mod authoring;
pub mod board;
pub mod check;
pub mod compile;
pub mod daemon;
pub mod db;
pub mod doctor;
pub mod external;
pub mod features;
pub mod floor;
pub mod git;
pub mod hook;
pub mod init;
pub mod instructions;
pub mod policy;
pub mod report;
pub mod stats;
pub mod waive;

use anyhow::{Context, Result};
use pushkin_core::envelope::CheckResult;
use pushkin_core::manifest::Manifest;
use std::path::{Path, PathBuf};

pub const MANIFEST_FILE: &str = "pushkin.toml";
pub const EVENTS_DB: &str = ".pushkin/events.db";
pub const CONSENT_FILE: &str = ".pushkin/consent.json";
pub const CLAUDE_SETTINGS: &str = ".claude/settings.json";
// v2: the pushkin naming pass changed the consented footprint (filenames,
// marker, binary name) — pre-rename consent is re-prompted (rem. pass 3).
pub const CONSENT_VERSION: u32 = 2;
pub const PUSHKIN_MARKER: &str = "pushkin-v1";

/// Environment override: the manifest to use, whatever the working directory.
pub const MANIFEST_ENV: &str = "PUSHKIN_MANIFEST";

/// How the manifest was resolved. Carried rather than re-derived, so the two
/// renderings and the fallback notice all describe the same probe.
pub enum ManifestSource {
    /// `PUSHKIN_MANIFEST`, holding the operator's string verbatim.
    Override { raw: String },
    /// `git rev-parse --show-toplevel`.
    RepoRoot,
    /// The working directory. `git_unavailable` is `Some(reason)` when `git`
    /// could not be RUN — distinct from simply not being in a repository.
    WorkingDirectory { git_unavailable: Option<String> },
}

/// A resolved manifest and the probe that found it.
pub struct Resolved {
    pub path: PathBuf,
    pub source: ManifestSource,
    /// The repository root, when there was one — used to render the path
    /// relative to it for agent-facing prose.
    pub root: Option<PathBuf>,
}

/// Where the manifest actually lives (F73).
///
/// Before this existed, `MANIFEST_FILE` was read as a bare relative path, so
/// **the manifest that governed a verdict was whichever one sat in the process's
/// working directory** — `cd` into a subdirectory and the gate read a different
/// file, or none. Resolution order:
///
/// 1. `PUSHKIN_MANIFEST`, if set — the explicit escape. A missing target here is
///    a NAMED ERROR, never a silent fall back to the repository's manifest: the
///    point of an explicit override is that a typo does not quietly gate you
///    against rules you did not choose.
/// 2. The repository root, via `git rev-parse --show-toplevel`.
/// 3. The working directory — reached when this is **not** a repository, or when
///    `git` could not be run at all.
///
/// **Case 3 covers two conditions and treats them alike, deliberately.** Not
/// being in a repository and not being able to run `git` are the same
/// epistemic state: the tool positively probed and cannot determine whether a
/// root exists. N13 reserves fail-open for exactly that — *"positively probed
/// absence"* — and this repo already applies it to a missing binary in the
/// lefthook contract. Erroring instead would make `git` a hard runtime
/// dependency of every manifest-reading verb, including `instructions` and
/// `compile`, which have never needed it.
///
/// The consequence is stated rather than minimized: **wherever `git` is
/// missing, F73's bypass remains reachable.** That is today's behaviour
/// unchanged, not a regression — but F73 is fixed where `git` is available and
/// unchanged where it is not, and `load_manifest` says so out loud.
///
/// Walk-up-to-nearest was rejected: it is F73 generalized, because a nested
/// manifest would still win and the walk would only make the win look
/// deliberate. Refuse-outside-a-root was rejected: it breaks a workflow agents
/// use constantly and does not address in-repo nesting anyway.
///
/// # Errors
/// A `PUSHKIN_MANIFEST` that names a path which is not a readable file.
pub fn resolve_manifest() -> Result<Resolved> {
    if let Ok(raw) = std::env::var(MANIFEST_ENV) {
        let path = PathBuf::from(&raw);
        if !path.is_file() {
            anyhow::bail!(
                "{MANIFEST_ENV} is set to {raw}, which is not a readable file. \
                 Resolution stops here rather than falling back to the repository's \
                 manifest — an explicit override that silently pointed somewhere else \
                 would gate you against rules you did not choose. Fix the path or unset \
                 {MANIFEST_ENV}."
            );
        }
        return Ok(Resolved {
            path,
            source: ManifestSource::Override { raw },
            root: None,
        });
    }
    match git::repo_root() {
        Ok(Some(root)) => Ok(Resolved {
            path: root.join(MANIFEST_FILE),
            source: ManifestSource::RepoRoot,
            root: Some(root),
        }),
        Ok(None) => Ok(Resolved {
            path: PathBuf::from(MANIFEST_FILE),
            source: ManifestSource::WorkingDirectory {
                git_unavailable: None,
            },
            root: None,
        }),
        Err(error) => Ok(Resolved {
            path: PathBuf::from(MANIFEST_FILE),
            source: ManifestSource::WorkingDirectory {
                git_unavailable: Some(error.to_string()),
            },
            root: None,
        }),
    }
}

/// The resolved path alone.
///
/// # Errors
/// Propagates [`resolve_manifest`].
pub fn manifest_path() -> Result<PathBuf> {
    resolve_manifest().map(|resolved| resolved.path)
}

/// The resolved manifest as an **agent-facing** string — a path the agent can
/// act on, never a machine-specific one.
///
/// `bash_read_gate_normalization.rs` pins the rule this obeys: *"a deny naming
/// `/private/var/folders/...` teaches a machine-specific path the agent cannot
/// act on, while the repo-relative one is exactly what the retrieval tool wants
/// next."* So the form follows the reader (ruling §2.2 Addendum 1):
///
/// | resolved from | renders |
/// |---|---|
/// | the repository root | relative to that root — `pushkin.toml` |
/// | `PUSHKIN_MANIFEST` | the operator's string **verbatim** — they typed it, and it is what they will grep for |
/// | the working directory | as given |
///
/// Note there is no `canonicalize` here at all: under this rule the absolute
/// form is never constructed for this surface, so there is nothing to strip.
/// The machine-read surfaces use [`manifest_display`] instead.
#[must_use]
pub fn manifest_agent_display() -> String {
    let Ok(resolved) = resolve_manifest() else {
        return MANIFEST_FILE.to_owned();
    };
    match resolved.source {
        ManifestSource::Override { raw } => raw,
        ManifestSource::RepoRoot => resolved
            .root
            .and_then(|root| {
                resolved
                    .path
                    .strip_prefix(&root)
                    .ok()
                    .map(|rel| rel.display().to_string())
            })
            .unwrap_or_else(|| MANIFEST_FILE.to_owned()),
        ManifestSource::WorkingDirectory { .. } => resolved.path.display().to_string(),
    }
}

/// The resolved manifest as an absolute string, for the `--json` envelope and
/// the floor header — the surfaces an operator or an auditor reads, where
/// ambiguity about *which* tree costs more than verbosity. Agent-facing prose
/// uses [`manifest_agent_display`] instead: render per surface, resolve once.
#[must_use]
pub fn manifest_display() -> String {
    manifest_path().map_or_else(
        |_| MANIFEST_FILE.to_owned(),
        |path| path.canonicalize().unwrap_or(path).display().to_string(),
    )
}

pub fn load_manifest() -> Result<Manifest> {
    let resolved = resolve_manifest()?;
    // N13's contract is fail open *with a loud, actionable notice*, never
    // silently. When `git` cannot be run we fall back to the working directory
    // — the same treatment as not-being-in-a-repository, because it is the same
    // epistemic state — and say so, naming the manifest actually used so the
    // reader can tell this state from a normal run at a glance.
    if let ManifestSource::WorkingDirectory {
        git_unavailable: Some(reason),
    } = &resolved.source
    {
        eprintln!(
            "pushkin: {reason} Falling back to {} in the working directory; \
             resolution is not pinned to a repository root.",
            resolved.path.display()
        );
    }
    let path = resolved.path;
    let text = std::fs::read_to_string(&path)
        .with_context(|| format!("cannot read {}", path.display()))?;
    // The rejection names WHICH manifest it rejected — same §2.2 principle as the
    // envelope. Before F73 there was only ever one candidate and "manifest
    // rejected" was unambiguous; now that resolution has an override and a repo
    // root, an unqualified rejection would leave the reader guessing which file
    // the message is about.
    Manifest::parse(&text).with_context(|| format!("manifest rejected: {}", path.display()))
}

/// F71 Phase B: the manifest load outcome, classified by provenance for the
/// write-time gate. Ruled by `docs/charters/2026-08-19-f71-manifest-skew.md`
/// Addendum 1 and scoped by Addendum 2 §1: the deny is only honest where
/// "cannot load" cannot also mean "you are standing in the wrong directory".
pub enum GateManifest {
    // Boxed: `Manifest` dwarfs the other variants (clippy `large_enum_variant`).
    Loaded(Box<Manifest>),
    /// Pinned resolution (`PUSHKIN_MANIFEST` or the repository root) whose
    /// rules cannot be loaded — absent, unreadable, or rejected alike (ruled
    /// question 3: one rule, one behaviour). The gate DENIES.
    Deny {
        error: String,
    },
    /// Working-directory fallback: today's error behaviour is kept, and the
    /// scope notice has already been printed.
    Unpinned {
        error: anyhow::Error,
    },
}

pub fn load_manifest_for_gate() -> GateManifest {
    let resolved = match resolve_manifest() {
        Ok(resolved) => resolved,
        // Only an unusable `PUSHKIN_MANIFEST` errors here — Override
        // provenance by definition, and the message already names both the
        // variable and the path.
        Err(error) => {
            return GateManifest::Deny {
                error: format!("{error:#}"),
            }
        }
    };
    if let ManifestSource::WorkingDirectory {
        git_unavailable: Some(reason),
    } = &resolved.source
    {
        eprintln!(
            "pushkin: {reason} Falling back to {} in the working directory; \
             resolution is not pinned to a repository root.",
            resolved.path.display()
        );
    }
    let outcome = std::fs::read_to_string(&resolved.path)
        .with_context(|| format!("cannot read {}", resolved.path.display()))
        .and_then(|text| {
            Manifest::parse(&text)
                .with_context(|| format!("manifest rejected: {}", resolved.path.display()))
        });
    match outcome {
        Ok(manifest) => GateManifest::Loaded(Box::new(manifest)),
        Err(error) => match resolved.source {
            ManifestSource::Override { .. } | ManifestSource::RepoRoot => GateManifest::Deny {
                error: format!("{error:#}"),
            },
            ManifestSource::WorkingDirectory { .. } => {
                eprintln!(
                    "pushkin: not denying — the manifest was resolved from the working \
                     directory, where a load failure can still mean a wrong directory; \
                     the F71 deny is scoped to pinned resolution."
                );
                GateManifest::Unpinned { error }
            }
        },
    }
}

pub fn events_db_path() -> Result<PathBuf> {
    let path = PathBuf::from(EVENTS_DB);
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent).context("cannot create .pushkin/")?;
    }
    Ok(path)
}

/// The CLI-owned half of the read-only-paths gate (S1c): appends the
/// `pushkin.read_only_path` violation when `path` falls under a
/// `[gates] read_only_paths` glob AND is committed — present in git HEAD,
/// which is N10's own boundary ("committed first, read-only hereafter").
/// A new file, and every edit while it stays uncommitted, passes
/// untouched: that is the RED-suite authoring window. Applied uniformly
/// to the single-write, staged, and hook (warm and cold) paths; the Stop
/// sweep is exempt by design — it inspects committed files at rest, not
/// writes.
#[must_use]
pub fn gate_read_only(manifest: &Manifest, mut result: CheckResult, path: &str) -> CheckResult {
    if manifest.is_read_only(path) && committed_in_head(path) {
        result
            .violations
            .push(pushkin_core::pipeline::read_only_violation(path));
        result.decision = pushkin_core::envelope::Decision::Block;
    }
    result
}

/// F75 — the CLI-owned half of the nested-manifest rule: appends
/// `pushkin.nested_manifest` when an agent write targets a `pushkin.toml` that is
/// not the governing manifest. Sibling of [`gate_read_only`], shaped the same
/// way: core owns the rule text, the CLI owns the predicate — because *which*
/// path is the governing manifest is a resolution question, and core stays free
/// of the filesystem.
///
/// The predicate is deliberately name-anchored, not glob-anchored: the rule keys
/// on the filename `pushkin.toml` wherever it sits, which is exactly what
/// `protected_paths` could not express without being widened repo-wide (F75
/// charter). The governing manifest is exempt, so:
///
/// - a write to the root `pushkin.toml` under `RepoRoot` resolution is allowed —
///   it keeps whatever treatment `protected_paths` already gives it, and this
///   rule does not double-report;
/// - a write to a `PUSHKIN_MANIFEST`-named manifest is allowed — the override is
///   a supported workflow, and that file IS the governing one.
///
/// Everything else named `pushkin.toml` is denied. A file with a different name
/// nested anywhere is untouched.
#[must_use]
pub fn gate_nested_manifest(mut result: CheckResult, path: &str) -> CheckResult {
    if !path_is_manifest_named(path) {
        return result;
    }
    if is_governing_manifest(path) {
        return result;
    }
    result
        .violations
        .push(pushkin_core::pipeline::nested_manifest_violation(path));
    result.decision = pushkin_core::envelope::Decision::Block;
    result
}

/// Whether `path`'s final component is the manifest filename.
fn path_is_manifest_named(path: &str) -> bool {
    std::path::Path::new(path)
        .file_name()
        .is_some_and(|name| name == std::ffi::OsStr::new(MANIFEST_FILE))
}

/// Whether `path` names the manifest that governs this repository — the one
/// resolution would load. Compared as absolute paths, canonicalizing each
/// existing ancestor so a symlinked prefix (`/tmp` -> `/private/tmp`, a common
/// macOS case) does not read as a different file. A resolution failure means we
/// cannot claim `path` is governing, so the rule falls through to a deny — the
/// conservative direction for a gate.
fn is_governing_manifest(path: &str) -> bool {
    let Ok(resolved) = resolve_manifest() else {
        return false;
    };
    let candidate = match resolved.root.as_ref() {
        Some(root) => root.join(path),
        None => PathBuf::from(path),
    };
    normalize_existing(&candidate) == normalize_existing(&resolved.path)
}

/// An absolute, prefix-canonicalized form of `path` that does not require the
/// leaf to exist: canonicalize the longest existing ancestor, then re-attach the
/// remainder. Lets a not-yet-created `sub/pushkin.toml` compare correctly.
fn normalize_existing(path: &Path) -> PathBuf {
    let absolute = if path.is_absolute() {
        path.to_path_buf()
    } else {
        std::env::current_dir().map_or_else(|_| path.to_path_buf(), |cwd| cwd.join(path))
    };
    let mut ancestor = absolute.as_path();
    let mut tail: Vec<&std::ffi::OsStr> = Vec::new();
    loop {
        if let Ok(canonical) = ancestor.canonicalize() {
            let mut out = canonical;
            for component in tail.iter().rev() {
                out.push(component);
            }
            return out;
        }
        match (ancestor.file_name(), ancestor.parent()) {
            (Some(name), Some(parent)) => {
                tail.push(name);
                ancestor = parent;
            }
            _ => return absolute,
        }
    }
}

/// SPIKE — the CLI-owned half of the read contract: appends the
/// `pushkin.retrieval.raw_read` violation when an UNBOUNDED read targets a
/// `[gates] retrieval_paths` glob. Sibling of `gate_read_only`, and
/// deliberately shaped the same way: core owns the rule text, the CLI owns
/// the predicate. A ranged read never reaches here.
#[must_use]
pub fn gate_raw_read(manifest: &Manifest, mut result: CheckResult, path: &str) -> CheckResult {
    if manifest.is_retrieval_gated(path) {
        result
            .violations
            .push(pushkin_core::pipeline::raw_read_violation(
                path,
                manifest.retrieval_tool(),
            ));
        result.decision = pushkin_core::envelope::Decision::Block;
    }
    result
}

/// Option C of the hook-matcher-gap charter — the shell half of the read
/// contract. A `Bash` call naming a retrieval-gated path is denied with the
/// same rule and prose the `Read` path uses.
///
/// Two questions, in order. **What is the verb?** — `shell_read_scope`
/// classifies the command by position (token zero, and for `git` token one)
/// and returns `Skip` for everything that is not a read, so `git add`,
/// `git log --oneline`, `wc`, `rm` and the whole build surface never reach
/// the scan. **Which tokens are paths?** — unchanged and deliberately crude:
/// split on whitespace, strip surrounding quotes, test each token.
///
/// There is no shell grammar here and there must not be — quoting, pipes,
/// heredocs and `$()` make a parser an adversarial surface, and a wrong parse
/// either blocks real work or admits a crafted command. Verb classification
/// is positional, not grammatical; the charter's non-goal 1 was amended on
/// 2026-08-17 to permit exactly that and nothing more.
///
/// Scoping the scan is what makes the crudeness affordable. C matched any
/// command *mentioning* a gated path, which denied writes it had no business
/// denying (Addendum D, HM-9: `git add`, `rm`) under a read rule whose prose
/// could not be complied with. C′ denies only where content would actually
/// reach the agent, so the accepted trade — overblock a read, never a write —
/// is now what the code does rather than only what it claimed.
///
/// Tokens are normalized first, but only for the two spellings an agent
/// reaches for by accident: a leading `./` (repeatedly, so `././` normalizes
/// too) and a repo-root absolute prefix. That is the `ignore` crate's shape —
/// `GitignoreBuilder` strips `./` from its root and `Gitignore::matched`
/// relativizes before globbing — and it is the whole of it. Quoting, `$()`,
/// brace expansion, `..`, and a symlinked spelling of the repo root remain
/// accepted evasions by charter: the crudeness is the design, not a gap
/// awaiting a parser.
#[must_use]
pub fn gate_shell_read(manifest: &Manifest, mut result: CheckResult, command: &str) -> CheckResult {
    let scope = shell_read_scope(command);
    if scope == Scope::Skip {
        return result;
    }
    let already_denied = |r: &CheckResult| {
        r.violations
            .iter()
            .any(|v| v.rule == pushkin_core::pipeline::RULE_RAW_READ)
    };
    let root = std::env::current_dir().ok();
    let mut destination_follows = false;
    for token in command.split_whitespace() {
        let quoted = token.trim_matches(|c| c == '\'' || c == '"' || c == '`');
        // F57 — the token after `>` is where output GOES.
        if destination_follows {
            destination_follows = false;
            continue;
        }
        // F57 — everything after a heredoc operator is the BODY: content being
        // written, not paths being read. A generated file routinely cites the
        // paths it is about, and denying the write for what the file SAYS is
        // the same wrongly-issued deny as denying it for its destination.
        //
        // Stopping here can miss an argument spelled after the delimiter, which
        // is a MISSED deny — the direction this gate accepts, unlike the one it
        // is fixing.
        if quoted.starts_with("<<") {
            break;
        }
        if let Some(attached) = redirect_destination(quoted) {
            // Empty means the destination is the next token; otherwise it was
            // written attached to the operator and is consumed here.
            destination_follows = attached.is_empty();
            continue;
        }
        let candidate = normalize_token(quoted, root.as_deref());
        let gated = manifest.is_retrieval_gated(candidate)
            || (scope == Scope::FilesAndDirs && is_gated_directory(manifest, candidate));
        if candidate.is_empty() || !gated {
            continue;
        }
        if already_denied(&result) {
            break;
        }
        result
            .violations
            .push(pushkin_core::pipeline::raw_read_violation(
                candidate,
                manifest.retrieval_tool(),
            ));
        result.decision = pushkin_core::envelope::Decision::Block;
    }
    result
}

/// F48 Phase B — decide a content-absent mutation by RECONSTRUCTING the file
/// it would produce, falling back to Phase A's interim refusal when a faithful
/// reconstruction is impossible.
///
/// Order matters and is load-bearing. Path-decidable rules run FIRST and are
/// unaffected by synthesis — a `read_only_paths` target denies whatever the
/// edit would have produced, so a reconstruction failure can never mask a path
/// rule. Only then is the file read and the edits applied.
///
/// The read is the one filesystem touch on this path (charter B3). It is
/// deliberately `read_to_string` on the target alone: no walk, no canonicalize,
/// nothing that scales with repo size. A file that cannot be read — absent,
/// unreadable, not UTF-8 — is a refusal, never an allow.
#[must_use]
pub fn gate_mutation(
    manifest: &Manifest,
    file: &crate::agents::FileWrite,
    tool: &str,
) -> CheckResult {
    let started = std::time::Instant::now();
    let mut violations = pushkin_core::pipeline::check_mutation_path_rules(manifest, &file.path);
    violations.extend(gate_read_only(manifest, empty_result(), &file.path).violations);
    violations.extend(gate_nested_manifest(empty_result(), &file.path).violations);
    if !violations.is_empty() {
        return finish(violations, started);
    }

    // F52 — a file that CARRIES content needs no reconstruction: judge the
    // bytes it brought. This is the `*** Add File:` section of a patch that also
    // updates something — the whole payload takes the mutation intent, but an
    // added file is a whole file and was being refused for lacking edits it
    // never needed. Empty content with no edits stays a refusal: that emptiness
    // passing silently was F48's original defect.
    if file.edits.is_empty() && !file.content.is_empty() {
        let request = pushkin_core::pipeline::WriteRequest {
            file_path: file.path.clone(),
            content: file.content.clone(),
        };
        return finish(
            pushkin_core::pipeline::check_write(manifest, &request).violations,
            started,
        );
    }

    let on_disk = std::fs::read_to_string(&file.path).ok();
    match pushkin_core::pipeline::synthesize(on_disk.as_deref(), &file.edits) {
        pushkin_core::pipeline::Synthesis::Content(content) => {
            let request = pushkin_core::pipeline::WriteRequest {
                file_path: file.path.clone(),
                content,
            };
            finish(
                pushkin_core::pipeline::check_write(manifest, &request).violations,
                started,
            )
        }
        pushkin_core::pipeline::Synthesis::Refused(why) => {
            let mut result =
                pushkin_core::pipeline::check_mutation_without_content(manifest, &file.path, tool);
            annotate_refusal(&mut result, &why);
            result
        }
    }
}

/// F58 — the verdict for a DELETE. Path rules only, and deliberately nothing
/// else: a file that is going away has no content to judge now and never will.
///
/// Sibling of `gate_mutation`, and the difference between them is the whole
/// point. `gate_mutation` falls back to `content_unavailable` when it cannot
/// reconstruct — a refusal that says "this rule could not be EVALUATED".
/// Routing deletes through that would be safe in DIRECTION and false in
/// SUBSTANCE, because no content rule applies to a deletion in the first place.
#[must_use]
pub fn gate_delete(manifest: &Manifest, path: &str) -> CheckResult {
    let started = std::time::Instant::now();
    finish(unwaivable_path_violations(manifest, path), started)
}

/// The two rules this project declares UNWAIVABLE: `protected_paths` and
/// `read_only_paths`. Named as a unit because more than one caller needs
/// exactly this set and no more — a delete (F58) and an unreadable payload
/// (F60) are both decided by it.
fn unwaivable_path_violations(
    manifest: &Manifest,
    path: &str,
) -> Vec<pushkin_core::envelope::Violation> {
    let mut violations = pushkin_core::pipeline::check_mutation_path_rules(manifest, path);
    violations.extend(gate_read_only(manifest, empty_result(), path).violations);
    violations
}

/// F60 — the unreadable-payload gate. A payload the normalizer could not read
/// is one whose EFFECT is unknown; when it names a path under an unwaivable
/// rule, the honest answer is to refuse rather than to shrug.
///
/// **Why this exists.** F48, F58 and F59 were three instances of one shape: a
/// WRITE the normalizer did not recognize became a payload it declined to
/// judge, and the fail-open branch did the work of a default. Each fix taught
/// the parser one more shape; a fourth would have too. Human decision,
/// 2026-08-18: fail closed.
///
/// **The bound is the design.** Refusing everything unrecognized would deny
/// every tool call the normalizer does not model and make the gate unusable, so
/// this is scoped to the two unwaivable PATH rules. `retrieval_paths` is
/// excluded: it is a READ rule, and any payload mentioning a source file would
/// deny. Mapped contract globs are excluded: they are CONTENT rules, undecidable
/// without content, and refusing on them would state a reason that does not hold
/// — the mistake F58 was careful not to make.
///
/// **The scan is crude, exactly as `gate_shell_read` is crude, and for the same
/// reason.** The premise is that the payload's shape is UNKNOWN, so every string
/// in it and every whitespace-separated token inside those strings is a
/// candidate — F58's delete path lived in patch text, not in a field, and a scan
/// reading only known keys would have missed the case that motivated the rule.
/// The accepted cost is that a payload merely MENTIONING a gated path is
/// refused. That is a loud over-refusal on a payload the gate already could not
/// judge, which is not in the same class as a silent write.
#[must_use]
pub fn gate_unreadable_payload(manifest: &Manifest, raw: &str) -> CheckResult {
    let started = std::time::Instant::now();
    let Ok(value) = serde_json::from_str::<serde_json::Value>(raw) else {
        // Not even JSON: there is nothing to scan, so the pre-existing
        // fail-open stands.
        return empty_result();
    };
    let root = std::env::current_dir().ok();
    let mut strings = Vec::new();
    collect_strings(&value, &mut strings);
    let mut seen = std::collections::BTreeSet::new();
    let mut violations = Vec::new();
    for text in &strings {
        for token in std::iter::once(text.as_str()).chain(text.split_whitespace()) {
            let quoted = token.trim_matches(|c| c == '\'' || c == '"' || c == '`');
            let candidate = normalize_token(quoted, root.as_deref());
            if candidate.is_empty() || !seen.insert(candidate.to_owned()) {
                continue;
            }
            violations.extend(unwaivable_path_violations(manifest, candidate));
        }
    }
    finish(violations, started)
}

/// Every string value in the payload, at any depth. Keys are not collected:
/// a JSON key is structure, and a path never appears as one.
fn collect_strings(value: &serde_json::Value, out: &mut Vec<String>) {
    match value {
        serde_json::Value::String(text) => out.push(text.clone()),
        serde_json::Value::Array(items) => {
            for item in items {
                collect_strings(item, out);
            }
        }
        serde_json::Value::Object(map) => {
            for nested in map.values() {
                collect_strings(nested, out);
            }
        }
        _ => {}
    }
}

fn empty_result() -> CheckResult {
    CheckResult {
        decision: pushkin_core::envelope::Decision::Allow,
        violations: Vec::new(),
        duration_ms: 0.0,
    }
}

fn finish(
    violations: Vec<pushkin_core::envelope::Violation>,
    started: std::time::Instant,
) -> CheckResult {
    CheckResult {
        decision: if violations.is_empty() {
            pushkin_core::envelope::Decision::Allow
        } else {
            pushkin_core::envelope::Decision::Block
        },
        violations,
        duration_ms: started.elapsed().as_secs_f64() * 1000.0,
    }
}

/// Says WHY the reconstruction failed, so the refusal is actionable rather
/// than a wall. A missing `old_string` and an unreadable file call
/// for different fixes from the agent.
fn annotate_refusal(result: &mut CheckResult, why: &str) {
    for violation in &mut result.violations {
        if violation.rule == pushkin_core::pipeline::RULE_CONTENT_UNAVAILABLE {
            violation.fix_hint = format!("{} Reconstruction failed: {why}.", violation.fix_hint);
        }
    }
}

/// F57 — the OUTPUT-redirect operator hiding in a token, if any, plus whatever
/// destination was written attached to it.
///
/// `>` and `>>`, optionally carrying a file descriptor (`2>`, `&>`) and
/// optionally the `>|` clobber form. Returns the attached destination, which is
/// EMPTY when the destination is the next token instead.
///
/// Input redirection is deliberately absent. `<` reads, and a read is the whole
/// point of this gate — only the direction that WRITES is exempt.
fn redirect_destination(token: &str) -> Option<&str> {
    let after_descriptor = token.trim_start_matches(|c: char| c.is_ascii_digit() || c == '&');
    let rest = after_descriptor
        .strip_prefix(">>")
        .or_else(|| after_descriptor.strip_prefix('>'))?;
    Some(rest.strip_prefix('|').unwrap_or(rest))
}

/// What a command's verb permits the path scan to match.
#[derive(Clone, Copy, PartialEq, Eq)]
enum Scope {
    /// Not a read. The scan never runs.
    Skip,
    /// Gated files named directly.
    Files,
    /// Gated files, plus directories a recursive reader walks into them from.
    FilesAndDirs,
}

/// Readers that put file content on stdout.
///
/// `head`, `tail` and `sed -n 1,50p` are here despite carrying an apparent
/// range, and that asymmetry with the `Read` surface is deliberate rather
/// than an oversight (Addendum D, HM-10). On `Read` the host supplies
/// `offset`/`limit` as structured fields the gate can *verify*; in a command
/// string a bound is only ever inferred, and `head -999999` reads exactly as
/// bounded as `head -50`. The surfaces differ because the guarantee differs.
/// `tests/bash_read_gate.rs` pins both spellings as denials.
const CONTENT_READERS: &[&str] = &[
    "cat", "less", "more", "bat", "nl", "od", "xxd", "strings", "tac", "rev", "awk", "head",
    "tail", "sed",
];

/// Content searchers. Recursive ones reach every gated file under a directory
/// without ever naming one, which is how HM-7 slipped past C.
const SEARCHERS: &[&str] = &["grep", "egrep", "fgrep", "rg", "ag", "ack", "ugrep"];
const RECURSIVE_BY_DEFAULT: &[&str] = &["rg", "ag", "ack", "ugrep"];

/// Classifies a command by its verb alone. Unknown verbs are `Skip`: the
/// charter's accepted failure direction is a missed deny, never a wrongly
/// issued one, so a tool this does not recognize is not treated as a reader.
fn shell_read_scope(command: &str) -> Scope {
    let Some(head) = command.split_whitespace().next() else {
        return Scope::Skip;
    };
    let verb = head.rsplit('/').next().unwrap_or(head);
    if verb == "git" {
        return git_scope(command);
    }
    if SEARCHERS.contains(&verb) {
        return if RECURSIVE_BY_DEFAULT.contains(&verb) || has_recursive_flag(command) {
            Scope::FilesAndDirs
        } else {
            Scope::Files
        };
    }
    if CONTENT_READERS.contains(&verb) {
        return Scope::Files;
    }
    Scope::Skip
}

/// `git` is both the repo's metadata tool and one of its readers, so it is
/// the one verb worth a second token. Only the content-emitting subcommands
/// are scanned; `status`, `add`, `commit`, `checkout`, `rev-parse` and the
/// rest pass untouched.
fn git_scope(command: &str) -> Scope {
    let mut tokens = command.split_whitespace().skip(1);
    let mut subcommand = None;
    while let Some(token) = tokens.next() {
        // `-C <dir>` and `-c <k=v>` carry an argument that is not the verb.
        if token == "-C" || token == "-c" {
            tokens.next();
        } else if !token.starts_with('-') {
            subcommand = Some(token);
            break;
        }
    }
    // Reductions to names or counts reveal no content, whatever the verb.
    let names_only = ["--stat", "--name-only", "--name-status", "--shortstat"]
        .iter()
        .any(|flag| has_flag(command, flag));
    match subcommand {
        Some("grep") => Scope::FilesAndDirs,
        Some("blame" | "cat-file" | "annotate") => Scope::Files,
        Some("show" | "diff" | "diff-tree") if !names_only => Scope::Files,
        Some("log")
            if ["-p", "-u", "--patch", "-S", "-G"]
                .iter()
                .any(|f| has_flag(command, f)) =>
        {
            Scope::Files
        }
        _ => Scope::Skip,
    }
}

/// Whole-token flag test. Never a substring match: `--name-only` must not be
/// found inside `--name-only-ish`, and `-p` must not be found inside a path.
fn has_flag(command: &str, flag: &str) -> bool {
    command
        .split_whitespace()
        .any(|token| token == flag || token.split_once('=').is_some_and(|(k, _)| k == flag))
}

/// `-r`/`-R`, bundled (`-rn`) or alone. Long forms are spelled out.
fn has_recursive_flag(command: &str) -> bool {
    command.split_whitespace().any(|token| {
        (token.starts_with('-')
            && !token.starts_with("--")
            && token.chars().skip(1).any(|c| c == 'r' || c == 'R'))
            || token == "--recursive"
            || token == "--dereference-recursive"
    })
}

/// Whether `token` names a directory a recursive reader would reach gated
/// files from. True when the token and a glob's literal prefix are the same
/// path or one contains the other: `crates/` reaches `crates/**/*.rs`, and so
/// does `crates/pushkin-core`. An empty prefix means the glob gates the whole
/// tree, so any directory reaches it.
fn is_gated_directory(manifest: &Manifest, token: &str) -> bool {
    let token = token.trim_end_matches('/');
    if token.is_empty() {
        return false;
    }
    manifest.gates.retrieval_paths.iter().any(|glob| {
        let prefix = literal_prefix(glob);
        prefix.is_empty() || contains_path(prefix, token) || contains_path(token, prefix)
    })
}

/// Component-wise prefix test — `crates` contains `crates/core`, never
/// `crates-other`.
fn contains_path(ancestor: &str, descendant: &str) -> bool {
    descendant == ancestor
        || (descendant.starts_with(ancestor) && descendant[ancestor.len()..].starts_with('/'))
}

/// The leading components of a glob that carry no metacharacter —
/// `crates/**/*.rs` is rooted at `crates`. Empty when the glob is rooted at
/// the tree itself (`**/*.rs`).
fn literal_prefix(glob: &str) -> &str {
    match glob
        .split('/')
        .position(|component| component.contains(['*', '?', '[', '{']))
    {
        Some(0) => "",
        Some(n) => {
            let end: usize = glob.split('/').take(n).map(|c| c.len() + 1).sum();
            &glob[..end - 1]
        }
        None => glob,
    }
}

/// `globset` matches text, not paths, and does no normalization of its own,
/// so `./crates/x.rs` and an absolute spelling of the same file both miss a
/// repo-relative glob. Anything that is neither spelling is returned
/// untouched — an unrelated absolute path then fails the glob, which is the
/// right answer rather than a missed match.
///
/// Stripping is component-wise (`Path::strip_prefix`), never byte-wise: a
/// sibling directory whose name merely begins with the root's — `/repo-other`
/// against `/repo` — is not inside it, and a `str::strip_prefix` here would
/// quietly gate reads outside the repo entirely.
///
/// `root` is the cwd as `getcwd(3)` reports it, which carries no symlink
/// components. A token written through a symlinked spelling of that same
/// directory (macOS `/var/folders/…` for `/private/var/folders/…`) therefore
/// shares no prefix and is NOT relativized. Closing that would mean
/// canonicalizing every token — filesystem I/O on the hottest tool in the
/// loop — for a spelling that only arises from a symlinked checkout; the
/// charter's latency budget does not buy it. `None` when the cwd is
/// unreadable: normalization degrades to the relative spellings rather than
/// panicking inside a hook meant to be invisible.
fn normalize_token<'a>(token: &'a str, root: Option<&Path>) -> &'a str {
    let mut relative = token;
    // `git show HEAD:crates/x.rs` is a whole-file read whose revision prefix
    // defeats a repo-relative glob (Addendum D, HM-8). Only a prefix that
    // could be a revision is stripped: it carries no `/`, and what follows is
    // relative, so `https://host/crates/x.rs` keeps its shape and still misses.
    if let Some((rev, rest)) = relative.split_once(':') {
        if !rev.contains('/') && !rest.is_empty() && !rest.starts_with('/') {
            relative = rest;
        }
    }
    while let Some(rest) = relative.strip_prefix("./") {
        relative = rest;
    }
    let path = Path::new(relative);
    root.and_then(|root| path.strip_prefix(root).ok())
        .and_then(Path::to_str)
        .unwrap_or(relative)
}

/// Commit time of the last commit that touched `path`, UTC ISO-8601 — the
/// evidence boundary for the pre-commit floor's protected-path check. Moved to
/// the git facade in R1; re-exported so `super::` callers are stable.
///
/// Per-PATH rather than `HEAD`-wide on purpose: `HEAD` moves on every
/// commit, so an unrelated commit would silently clear a real bypass, while
/// a commit OF this file is exactly the human taking ownership of it.
///
/// `None` when the path has no commit yet (new file, or empty repo), which
/// widens the window to all of history — correct, since an uncommitted path
/// has nothing resolved to exclude.
pub use git::last_commit_touching;

/// Positive evidence of committed-ness (`git cat-file -e HEAD:<path>`), moved to
/// the git facade in R1 and re-exported for `super::` callers. No git, no HEAD,
/// or an untracked path all mean "not committed" — the deny requires the file to
/// actually be in history.
pub(crate) use git::committed_in_head;

/// Waiver pass over a gate result (spec §7): unexpired scoped waivers
/// suppress their violations. A broken waivers file suppresses NOTHING —
/// the gate fails closed and doctor reports the parse error.
#[must_use]
pub fn apply_waivers(result: pushkin_core::envelope::CheckResult) -> CheckResult {
    match pushkin_core::waivers::WaiverSet::load(std::path::Path::new(waive::WAIVERS_FILE)) {
        Ok(set) => set.apply_now(result),
        Err(_) => result,
    }
}

/// Which gate surface is asking (Shape 2, charter 2026-08-18 Addendum 1). The
/// rule dispatch below is single-sourced; this parameter names the one — and
/// ONLY the one — point where the surfaces legitimately diverge:
///
/// 1. **Write verdict source** (`decide_write`): `AgentWriteTime` serves the
///    verdict through the warm daemon path (`daemon::check_or_cold`, F74 stderr
///    disclosure); `Floor` serves it cold (`check_write`). Same verdict either
///    way — the daemon's parity contract, pinned by `gate_dispatch_conformance`.
///    The nested-manifest rule is composed on the pre-waiver side on BOTH
///    surfaces, so a waiver reaches it identically (F83 resolved, ADR-0006).
///
/// The Stop leaf used to be a second point (`Floor` swept bare and unwaived
/// while `AgentWriteTime` ran the opted-in on-stop floor and applied waivers —
/// DESIGN-FINDINGS F82). ADR-0009 ruled that away: `decide_stop` is now
/// surface-independent, pinned by `stop_floor_parity`.
///
/// Adding a SECOND point that branches on `Surface` is a STOP: it is a change to
/// gate semantics and requires a decision record (docs/decisions/), not a
/// refactor. A `Surface` parameter is a licence to diverge; it is bounded here.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Surface {
    /// `pushkin hook <agent>` — the live agent write-time gate.
    AgentWriteTime,
    /// `pushkin check` — the pre-commit floor / CI surface.
    Floor,
}

/// One evaluation request: the manifest, the normalized action, and the asking
/// surface. Each surface keeps its own payload parser and its own rendering
/// (charter Addendum 1); only this rule evaluation is shared.
pub struct DecideRequest<'a> {
    pub manifest: &'a Manifest,
    pub action: &'a crate::agents::ToolAction,
    pub surface: Surface,
}

/// The single rule-evaluation path both gate surfaces call. The dispatch (which
/// leaf a `ToolAction` reaches) lives here and nowhere else — that is the F48
/// class this extraction closes. Per-surface divergence is confined to the two
/// arms `Surface` documents; everything else is identical for both callers.
///
/// F67 is NOT an invariant of this function: `decide` has no knowledge of it.
/// It survives as an invariant of `check`'s PARSER, which never constructs a
/// `Delete`/`ReadWhole`/`Shell` action, and is held by the two `Expect::Diverges`
/// rows in `gate_dispatch_conformance` — which fail the day someone teaches that
/// parser to build a `Read`.
///
/// # Errors
/// Propagates `sweep_repo`'s error on the Stop leaf (repo walk / IO).
pub fn decide(req: &DecideRequest) -> Result<CheckResult> {
    if req.action.is_stop {
        return decide_stop(req);
    }
    // Waivers wrap every non-Stop leaf on both surfaces (hook via `run`'s
    // `apply_waivers(evaluate)`, check per-branch) EXCEPT the Write leaf, which
    // interleaves waivers with the nested-manifest rule per surface (F83) and so
    // owns its own application.
    Ok(match req.action.intent {
        crate::agents::Intent::MutateNoContent(tool) => apply_waivers(decide_mutate(req, tool)),
        crate::agents::Intent::Delete => apply_waivers(decide_delete(req)),
        crate::agents::Intent::Write => decide_write(req),
        crate::agents::Intent::ReadWhole
        | crate::agents::Intent::ReadRange
        | crate::agents::Intent::Shell => apply_waivers(decide_read(req)),
    })
}

/// Stop leaf: the whole-repo sweep, plus the opted-in on-stop floor and
/// waivers — on BOTH surfaces (ADR-0009; F82 was the `Floor`-skips-it
/// divergence). A Stop verdict must not depend on which command delivered it.
fn decide_stop(req: &DecideRequest) -> Result<CheckResult> {
    let mut result = check::sweep_repo(req.manifest)?;
    let floor = floor::on_stop_violations(req.manifest);
    if !floor.is_empty() {
        result.decision = pushkin_core::envelope::Decision::Block;
        result.violations.extend(floor);
    }
    Ok(apply_waivers(result))
}

/// Write leaf: the write verdict composed with `gate_read_only` and
/// `gate_nested_manifest`, single-sourced, with the verdict source and the
/// nested-manifest waiver scope branched per surface (see `Surface` docs, 1-2).
fn decide_write(req: &DecideRequest) -> CheckResult {
    let started = std::time::Instant::now();
    let mut violations = Vec::new();
    for file in &req.action.files {
        let request = pushkin_core::pipeline::WriteRequest {
            file_path: file.path.clone(),
            content: file.content.clone(),
        };
        let per_file = match req.surface {
            // Warm path composes read_only + nested internally (daemon.rs), so
            // the waivers below cover nested too.
            Surface::AgentWriteTime => daemon::check_or_cold(req.manifest, &request),
            // Cold path composes read_only + nested here, BEFORE waivers — so a
            // nested-manifest waiver reaches the rule on the floor exactly as it
            // does at write time (F83 resolved, ADR-0006 Option A).
            Surface::Floor => gate_nested_manifest(
                gate_read_only(
                    req.manifest,
                    pushkin_core::pipeline::check_write(req.manifest, &request),
                    &file.path,
                ),
                &file.path,
            ),
        };
        violations.extend(per_file.violations);
    }
    apply_waivers(finish(violations, started))
}

/// Content-absent mutation leaf (F48 Phase A/B): path rules from the payload,
/// content rules refuse rather than skip. Identical for both surfaces.
fn decide_mutate(req: &DecideRequest, tool: &str) -> CheckResult {
    let started = std::time::Instant::now();
    let mut violations = Vec::new();
    for file in &req.action.files {
        violations.extend(gate_mutation(req.manifest, file, tool).violations);
    }
    finish(violations, started)
}

/// Delete leaf (F58): path rules only. Identical for both surfaces.
fn decide_delete(req: &DecideRequest) -> CheckResult {
    let started = std::time::Instant::now();
    let mut violations = Vec::new();
    for file in &req.action.files {
        violations.extend(gate_delete(req.manifest, &file.path).violations);
    }
    finish(violations, started)
}

/// Read / shell leaf: only the read contract applies, and only to the unbounded
/// shape. A ranged read and any other non-write intent are an empty allow.
fn decide_read(req: &DecideRequest) -> CheckResult {
    let started = std::time::Instant::now();
    let mut result = empty_result();
    if req.action.intent == crate::agents::Intent::ReadWhole {
        for file in &req.action.files {
            result = gate_raw_read(req.manifest, result, &file.path);
        }
    }
    if let (crate::agents::Intent::Shell, Some(command)) =
        (req.action.intent, req.action.command.as_deref())
    {
        result = gate_shell_read(req.manifest, result, command);
    }
    result.duration_ms = started.elapsed().as_secs_f64() * 1000.0;
    result
}