Skip to main content

aft/
gh_shim.rs

1//! Credential-free routing shim for the `gh` argv[0] entry point.
2//!
3//! The shim is intentionally a small process boundary: R1/R2 and declared
4//! mechanical R3 operations replace this process with upstream `gh`, while the
5//! governed path is the only path that interprets a declared command shape.
6//!
7//! Governed invocations are executed seam-side by the route holder under full
8//! GitHub App installation tokens held in custody; the shim carries a routed
9//! request one way and a result-or-refusal the other, and holds no token in
10//! either direction. Operation gating is holder-side classification over the
11//! routed request, not a property of any token the shim can see or hold.
12
13use std::collections::{BTreeMap, BTreeSet};
14use std::ffi::{OsStr, OsString};
15use std::fs::{self, OpenOptions};
16use std::io::{self, Write};
17use std::path::{Path, PathBuf};
18use std::process::Command;
19use std::time::{Duration, SystemTime, UNIX_EPOCH};
20
21use base64::Engine;
22use ring::signature::{UnparsedPublicKey, ED25519};
23use serde::{Deserialize, Serialize};
24use serde_json::{json, Map, Value};
25use subc_client_rs::{CallOptions, CloseRouteOptions, ConsumerOptions, SubcConsumer};
26use subc_protocol::manifest::ProviderRole;
27use subc_protocol::{BindIdentity, RouteTarget};
28
29pub const SCHEMA_FLOOR: u64 = 1;
30/// Envelope version that carries the manifest as exact signed bytes. Envelope
31/// v1 re-serialized the parsed manifest at verify time; envelope v2 verifies
32/// the distributed bytes themselves (see the verifier-site contract).
33pub const ENVELOPE_VERSION: u64 = 2;
34pub const REFUSAL_EXIT_STATUS: i32 = 86;
35const DISCOVERY_BUDGET: Duration = Duration::from_millis(150);
36const DISCOVERY_CACHE_TTL: Duration = Duration::from_secs(15);
37const MANIFEST_TTL: Duration = Duration::from_secs(15 * 60);
38const MANIFEST_STALE_GRACE: Duration = Duration::from_secs(24 * 60 * 60);
39/// Clock skew tolerated before a manifest's signed issue time counts as being
40/// in the future and therefore invalid.
41const ISSUED_AT_FUTURE_SKEW: Duration = Duration::from_secs(300);
42const ROUTING_OPERATION: &str = "gh.route";
43const ROUTING_HOLDER_MODULE_ID: &str = "prefrontal-core";
44const MANIFEST_ARTIFACT_ID: &str = "gh-routing-manifest";
45const V1_GOVERNED_TUPLES: &[&str] = &["issue comment", "pr comment", "pr review", "issue reaction"];
46const V1_ADMIN_TUPLES: &[&str] = &["issue close", "pr close", "pr merge", "release create"];
47const RESERVED_SELF_REPORT: &[&str] = &["--status", "--shim-version"];
48const GOVERNANCE_UNAVAILABLE_TEXT: &str = "the governance daemon is unreachable and this repository's actions are identity-governed; retry after the daemon returns";
49
50/// The only shim-originated refusal identifiers. Keep this enumeration closed:
51/// callers must parse these identifiers rather than human prose.
52#[derive(Clone, Copy, Debug, Eq, PartialEq)]
53pub enum RefusalCode {
54    Unclassified,
55    AdminTier,
56    ManifestStale,
57    ManifestBelowFloor,
58    ManifestRegressed,
59    SeamSchemaMismatch,
60    UnboundIdentity,
61    BypassAuditUnavailable,
62    NoRealGh,
63    GovernanceUnavailable,
64    SeamUnavailable,
65    SeamRefusal,
66}
67
68impl RefusalCode {
69    pub const ALL: [Self; 12] = [
70        Self::Unclassified,
71        Self::AdminTier,
72        Self::ManifestStale,
73        Self::ManifestBelowFloor,
74        Self::ManifestRegressed,
75        Self::SeamSchemaMismatch,
76        Self::UnboundIdentity,
77        Self::BypassAuditUnavailable,
78        Self::NoRealGh,
79        Self::GovernanceUnavailable,
80        Self::SeamUnavailable,
81        Self::SeamRefusal,
82    ];
83
84    pub const fn as_str(self) -> &'static str {
85        match self {
86            Self::Unclassified => "gh_shim_unclassified",
87            Self::AdminTier => "gh_shim_admin_tier",
88            Self::ManifestStale => "gh_shim_manifest_stale",
89            Self::ManifestBelowFloor => "gh_shim_manifest_below_floor",
90            Self::ManifestRegressed => "gh_shim_manifest_regressed",
91            Self::SeamSchemaMismatch => "gh_shim_seam_schema_mismatch",
92            Self::UnboundIdentity => "gh_shim_unbound_identity",
93            Self::BypassAuditUnavailable => "gh_shim_bypass_audit_unavailable",
94            Self::NoRealGh => "gh_shim_no_real_gh",
95            Self::GovernanceUnavailable => "gh_shim_governance_unavailable",
96            Self::SeamUnavailable => "gh_shim_seam_unavailable",
97            Self::SeamRefusal => "gh_shim_seam_refusal",
98        }
99    }
100}
101
102/// Offline self-report uses diagnostic identifiers distinct from invocation
103/// refusals. A report can therefore describe historical local-state trouble
104/// without pretending that an upstream `gh` invocation was refused.
105#[derive(Clone, Copy, Debug, Eq, PartialEq)]
106pub enum SelfReportDiagnostic {
107    ManifestUnavailable,
108    ManifestInvalid,
109    ManifestBelowFloor,
110    ManifestStale,
111    ManifestRegressed,
112    ManifestRollback,
113    RungUnavailable,
114}
115
116impl SelfReportDiagnostic {
117    pub const ALL: [Self; 7] = [
118        Self::ManifestUnavailable,
119        Self::ManifestInvalid,
120        Self::ManifestBelowFloor,
121        Self::ManifestStale,
122        Self::ManifestRegressed,
123        Self::ManifestRollback,
124        Self::RungUnavailable,
125    ];
126
127    pub const fn as_str(self) -> &'static str {
128        match self {
129            Self::ManifestUnavailable => "gh_shim_status_manifest_unavailable",
130            Self::ManifestInvalid => "gh_shim_status_manifest_invalid",
131            Self::ManifestBelowFloor => "gh_shim_status_manifest_below_floor",
132            Self::ManifestStale => "gh_shim_status_manifest_stale",
133            Self::ManifestRegressed => "gh_shim_status_manifest_regressed",
134            Self::ManifestRollback => "gh_shim_status_manifest_rollback",
135            Self::RungUnavailable => "gh_shim_status_rung_unavailable",
136        }
137    }
138}
139
140#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
141#[serde(rename_all = "lowercase")]
142pub enum Tier {
143    Mechanical,
144    Governed,
145    Admin,
146}
147
148impl Tier {
149    fn rank(self) -> u8 {
150        match self {
151            Self::Mechanical => 0,
152            Self::Governed => 1,
153            Self::Admin => 2,
154        }
155    }
156}
157
158#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
159#[serde(rename_all = "UPPERCASE")]
160pub enum Rung {
161    R1,
162    R2,
163    R3,
164}
165
166impl Rung {
167    const fn label(self) -> &'static str {
168        match self {
169            Self::R1 => "R1",
170            Self::R2 => "R2",
171            Self::R3 => "R3",
172        }
173    }
174}
175
176/// Return true when the process was invoked through the `gh` symlink or the
177/// explicit `aft gh-shim` development entry point. This is public so the binary
178/// can perform it before its own global `--version` and `--subc` scans.
179pub fn is_shim_invocation(program: &OsStr, args: &[OsString]) -> bool {
180    Path::new(program)
181        .file_name()
182        .is_some_and(|name| name == OsStr::new("gh"))
183        || args.first().is_some_and(|arg| arg == OsStr::new("gh-shim"))
184}
185
186pub fn is_shim_invocation_from_env() -> bool {
187    let mut argv = std::env::args_os();
188    let Some(program) = argv.next() else {
189        return false;
190    };
191    is_shim_invocation(&program, &argv.collect::<Vec<_>>())
192}
193
194/// Execute the shim for either supported entry form. This intentionally runs
195/// before logging initialization so delegating invocations cannot add shim bytes
196/// to upstream stderr.
197pub fn run_from_env() -> i32 {
198    let mut argv = std::env::args_os();
199    let Some(program) = argv.next() else {
200        return refuse(RefusalCode::NoRealGh, "the executing image was unavailable");
201    };
202    let raw_args = argv.collect::<Vec<_>>();
203    let shim_args = if Path::new(&program)
204        .file_name()
205        .is_some_and(|name| name == OsStr::new("gh"))
206    {
207        raw_args
208    } else {
209        raw_args.into_iter().skip(1).collect()
210    };
211    run(&shim_args)
212}
213
214fn run(args: &[OsString]) -> i32 {
215    let paths = StatePaths::from_process();
216    if is_reserved_self_report(args) {
217        print_self_report(&paths);
218        return 0;
219    }
220
221    let now = unix_seconds();
222    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
223
224    // Presence-based regressed-manifest arm. Decided from the installed
225    // artifact BEFORE any rung probe so a governed refusal never depends on
226    // daemon reachability: past the last-valid grace window, governed/admin
227    // tuples refuse and mechanical operations pass through whatever the rung
228    // cache says.
229    if let ManifestResolution::Regressed(manifest) = resolve_manifest(&paths, now) {
230        return match regressed_disposition(args, &manifest, current_platform()) {
231            RegressedDisposition::Passthrough => delegate(args),
232            RegressedDisposition::Refuse { code, text } => refuse(code, &text),
233        };
234    }
235
236    let determination = determine_rung(&paths, &cwd, now);
237    if determination.rung != Rung::R3 {
238        return match sticky_governance_disposition(
239            &paths,
240            &cwd,
241            now,
242            &determination,
243            args,
244            current_platform(),
245        ) {
246            Some(StickyGovernanceDisposition::Unavailable(agent_binding)) => {
247                refuse_governance_unavailable(&paths, &agent_binding, now)
248            }
249            Some(StickyGovernanceDisposition::Unclassified { manifest_version }) => refuse(
250                RefusalCode::Unclassified,
251                &format!(
252                    "no manifest declaration for this invocation (manifest {manifest_version})"
253                ),
254            ),
255            None => delegate(args),
256        };
257    }
258
259    // A valid manifest gates R3 both during fresh discovery and when a cached
260    // R3 determination is reused. If it disappears or expires between those
261    // two moments, the whole invocation falls back to R2 passthrough instead
262    // of a classification-shaped refusal.
263    let manifest = match resolve_manifest(&paths, now) {
264        ManifestResolution::Active(manifest) | ManifestResolution::GraceCache(manifest) => manifest,
265        ManifestResolution::Regressed(manifest) => {
266            return match regressed_disposition(args, &manifest, current_platform()) {
267                RegressedDisposition::Passthrough => delegate(args),
268                RegressedDisposition::Refuse { code, text } => refuse(code, &text),
269            }
270        }
271        ManifestResolution::Dormant => return delegate(args),
272    };
273    let Some(agent_binding) = resolved_agent_binding(&manifest, &cwd) else {
274        return delegate(args);
275    };
276
277    match classify(args, &manifest, current_platform()) {
278        Classification::Mechanical => delegate(args),
279        Classification::Admin { tuple } => {
280            if std::env::var_os("GH_SHIM_BYPASS").as_deref() == Some(OsStr::new("operator")) {
281                let repository = explicit_repo(args).or_else(infer_repository_from_git);
282                if let Err(error) = append_bypass_audit(&paths, &tuple, repository.as_deref(), now)
283                {
284                    return refuse(
285                        RefusalCode::BypassAuditUnavailable,
286                        &format!("operator bypass audit could not be appended: {error}"),
287                    );
288                }
289                delegate(args)
290            } else {
291                refuse(
292                    RefusalCode::AdminTier,
293                    "this action requires GH_SHIM_BYPASS=operator",
294                )
295            }
296        }
297        Classification::Governed { tuple, canonical } => {
298            let request =
299                match canonicalize_governed(args, &tuple, &canonical, manifest.manifest_version) {
300                    Ok(request) => request,
301                    Err(_) => {
302                        return refuse(
303                            RefusalCode::Unclassified,
304                            &format!(
305                                "undeclared shape for {tuple} in manifest {}",
306                                manifest.manifest_version
307                            ),
308                        )
309                    }
310                };
311            let outcome = route_governed(&paths, &determination, &agent_binding, request, now);
312            governed_outcome_status(&paths, &agent_binding, now, outcome)
313        }
314        Classification::Unclassified => refuse(
315            RefusalCode::Unclassified,
316            &format!(
317                "no manifest declaration for this invocation (manifest {})",
318                manifest.manifest_version
319            ),
320        ),
321    }
322}
323
324fn governed_outcome_status(
325    paths: &StatePaths,
326    agent_binding: &AgentBinding,
327    now: u64,
328    outcome: RouteOutcome,
329) -> i32 {
330    match outcome {
331        RouteOutcome::Result(output) => {
332            print!("{output}");
333            0
334        }
335        RouteOutcome::Refusal(code) => refuse(RefusalCode::SeamRefusal, &seam_refusal_text(&code)),
336        RouteOutcome::UnboundIdentity => refuse(
337            RefusalCode::UnboundIdentity,
338            "the project binding was unavailable at route time",
339        ),
340        RouteOutcome::SchemaMismatch(message) => refuse(RefusalCode::SeamSchemaMismatch, &message),
341        RouteOutcome::GovernanceUnavailable => {
342            refuse_governance_unavailable(paths, agent_binding, now)
343        }
344        RouteOutcome::Unavailable(message) => refuse(RefusalCode::SeamUnavailable, &message),
345    }
346}
347
348fn seam_refusal_text(code: &str) -> String {
349    format!("governance seam refused the action: {code}")
350}
351
352fn is_reserved_self_report(args: &[OsString]) -> bool {
353    args.first()
354        .and_then(|arg| arg.to_str())
355        .is_some_and(|arg| RESERVED_SELF_REPORT.contains(&arg))
356}
357
358#[derive(Clone, Debug)]
359struct StatePaths {
360    root: PathBuf,
361    manifest: PathBuf,
362    rung: PathBuf,
363    bypass_audit: PathBuf,
364    unexpected_gh_route_advertisers: PathBuf,
365    seam_state: PathBuf,
366    last_valid_manifest: PathBuf,
367    version_high_water: PathBuf,
368}
369
370impl StatePaths {
371    fn from_process() -> Self {
372        let root = std::env::var_os("XDG_STATE_HOME")
373            .map(PathBuf::from)
374            .filter(|path| path.is_absolute())
375            .or_else(|| {
376                std::env::var_os("HOME")
377                    .or_else(|| std::env::var_os("USERPROFILE"))
378                    .map(|home| PathBuf::from(home).join(".local/state"))
379            })
380            .unwrap_or_else(|| std::env::temp_dir())
381            .join("cortexkit")
382            .join("aft")
383            .join("gh-shim");
384        Self::from_root(root)
385    }
386
387    fn from_root(root: PathBuf) -> Self {
388        Self {
389            manifest: root.join("gh-routing-manifest.json"),
390            rung: root.join("rung-cache.json"),
391            bypass_audit: root.join("operator-bypass.jsonl"),
392            unexpected_gh_route_advertisers: root.join("unexpected-gh-route-advertisers.json"),
393            seam_state: root.join("seam-state.json"),
394            last_valid_manifest: root.join("last-valid-manifest.json"),
395            version_high_water: root.join("manifest-version-high-water.json"),
396            root,
397        }
398    }
399}
400
401#[derive(Clone, Debug, Deserialize, Serialize)]
402struct RungRecord {
403    rung: Rung,
404    as_of_unix_secs: u64,
405    #[serde(default)]
406    inputs: BTreeMap<String, String>,
407    #[serde(default)]
408    manifest_version: Option<u64>,
409}
410
411impl RungRecord {
412    fn r1(now: u64, reason: &str) -> Self {
413        Self {
414            rung: Rung::R1,
415            as_of_unix_secs: now,
416            inputs: BTreeMap::from([("connection_file".to_string(), reason.to_string())]),
417            manifest_version: None,
418        }
419    }
420
421    fn r2(now: u64, reason: &str, manifest_version: Option<u64>) -> Self {
422        Self {
423            rung: Rung::R2,
424            as_of_unix_secs: now,
425            inputs: BTreeMap::from([
426                ("connection_file".to_string(), "ready".to_string()),
427                (reason.to_string(), "failed".to_string()),
428            ]),
429            manifest_version,
430        }
431    }
432
433    fn r3(now: u64, manifest_version: u64) -> Self {
434        Self {
435            rung: Rung::R3,
436            as_of_unix_secs: now,
437            inputs: BTreeMap::from([
438                ("connection_file".to_string(), "ready".to_string()),
439                ("catalog_gh_route".to_string(), "ready".to_string()),
440                ("agent_binding".to_string(), "ready".to_string()),
441                ("manifest".to_string(), "ready".to_string()),
442                (
443                    "agent_credentials_present".to_string(),
444                    "absent".to_string(),
445                ),
446            ]),
447            manifest_version: Some(manifest_version),
448        }
449    }
450
451    fn fresh_at(&self, now: u64) -> bool {
452        now.saturating_sub(self.as_of_unix_secs) < DISCOVERY_CACHE_TTL.as_secs()
453    }
454
455    fn governance_infrastructure_unavailable(&self) -> bool {
456        matches!(
457            self.inputs.get("connection_file").map(String::as_str),
458            Some("unreachable" | "discovery_budget_exhausted")
459        ) || self.inputs.get("daemon_unreachable").map(String::as_str) == Some("failed")
460            || self
461                .inputs
462                .get("catalog_gh_route_absent")
463                .map(String::as_str)
464                == Some("failed")
465    }
466}
467
468enum StickyGovernanceDisposition {
469    Unavailable(AgentBinding),
470    Unclassified { manifest_version: u64 },
471}
472
473fn sticky_governance_disposition(
474    paths: &StatePaths,
475    cwd: &Path,
476    now: u64,
477    determination: &RungRecord,
478    args: &[OsString],
479    platform: &str,
480) -> Option<StickyGovernanceDisposition> {
481    if !determination.governance_infrastructure_unavailable() {
482        return None;
483    }
484    let manifest = match resolve_manifest(paths, now) {
485        ManifestResolution::Active(manifest) | ManifestResolution::GraceCache(manifest) => manifest,
486        ManifestResolution::Regressed(_) | ManifestResolution::Dormant => return None,
487    };
488    let agent_binding = resolved_agent_binding(&manifest, cwd)?;
489
490    // Losing governance infrastructure must never make classification more
491    // permissive than when the daemon is reachable. Mechanical reads alone pass
492    // through because they neither write nor assert governed identity; known
493    // writes refuse when governance is unavailable, while unknown shapes remain
494    // fail-closed.
495    match classify(args, &manifest, platform) {
496        Classification::Governed { .. } | Classification::Admin { .. } => {
497            Some(StickyGovernanceDisposition::Unavailable(agent_binding))
498        }
499        Classification::Unclassified => Some(StickyGovernanceDisposition::Unclassified {
500            manifest_version: manifest.manifest_version,
501        }),
502        Classification::Mechanical => None,
503    }
504}
505
506fn determine_rung(paths: &StatePaths, cwd: &Path, now: u64) -> RungRecord {
507    // The budget starts before the config read and connection-file stat. This
508    // keeps a slow filesystem from silently extending discovery beyond 150ms.
509    let deadline = std::time::Instant::now() + DISCOVERY_BUDGET;
510    let config_doc = read_user_config_doc();
511    determine_rung_from_doc(paths, cwd, now, deadline, config_doc.as_deref())
512}
513
514/// Pure rung determination over the user config document. `config_doc` is the
515/// raw user-tier `aft.jsonc` text (already read by the caller); `None` means the
516/// config file was absent or unreadable. Splitting the config read from the
517/// decision keeps the disabled short-circuit testable without mutating process
518/// env (which races under the parallel test runner).
519fn determine_rung_from_doc(
520    paths: &StatePaths,
521    cwd: &Path,
522    now: u64,
523    deadline: std::time::Instant,
524    config_doc: Option<&str>,
525) -> RungRecord {
526    // Operator hard-off: when the user disables the shim, short-circuit to
527    // byte-transparent passthrough (R1) before any daemon/catalog probing, so a
528    // disabled shim performs zero subc traffic. This is a structural gate for
529    // fleet rollout safety, not a rung the manifest can reach.
530    if gh_shim_enabled_from_config_doc(config_doc.unwrap_or("")) == Some(false) {
531        return RungRecord::r1(now, "disabled_by_config");
532    }
533
534    let Some(connection_file) = connection_file_from_config_doc(config_doc.unwrap_or("")) else {
535        // R1 has no daemon dial and no durable determination write.
536        return RungRecord::r1(now, "absent_or_unparseable");
537    };
538    if !connection_file.is_file() {
539        return RungRecord::r1(now, "unreachable");
540    }
541
542    let cached = load_rung_record(paths);
543    if std::time::Instant::now() >= deadline {
544        return cached
545            .filter(|record| record.fresh_at(now))
546            .unwrap_or_else(|| RungRecord::r1(now, "discovery_budget_exhausted"));
547    }
548    if let Some(record) = cached.as_ref().filter(|record| record.fresh_at(now)) {
549        if record.rung != Rung::R3
550            || resolve_manifest(paths, now)
551                .manifest()
552                .and_then(|manifest| resolved_agent_binding(manifest, cwd))
553                .is_some()
554        {
555            return record.clone();
556        }
557    }
558
559    // The signed manifest supplies the binding before the probe opens a route, so
560    // rate accounting and audit records use the same agent session on every run.
561    // A failing artifact inside the last-valid grace window still supplies the
562    // cached manifest; the regressed arm itself is decided in `run` before any
563    // probe.
564    let Some(manifest) = resolve_manifest(paths, now).into_manifest() else {
565        let record = RungRecord::r2(now, "manifest_unavailable", None);
566        write_rung_record_silently(paths, &record);
567        return record;
568    };
569    let Some(agent_binding) = resolved_agent_binding(&manifest, cwd) else {
570        let record = RungRecord::r2(
571            now,
572            "agent_binding_unavailable",
573            Some(manifest.manifest_version),
574        );
575        write_rung_record_silently(paths, &record);
576        return record;
577    };
578
579    let discovery = probe_governance(
580        paths,
581        &connection_file,
582        cwd,
583        deadline,
584        &agent_binding.agent_id,
585    );
586    let record = match discovery {
587        ProbeResult::Ready { module_id } => {
588            match find_ambient_agent_credential(&manifest.detectors) {
589                Some(source) => {
590                    let mut record = RungRecord::r2(
591                        now,
592                        "agent_credentials_present",
593                        Some(manifest.manifest_version),
594                    );
595                    record
596                        .inputs
597                        .insert("agent_credentials_present".to_string(), source);
598                    record
599                        .inputs
600                        .insert("catalog_holder".to_string(), module_id);
601                    record
602                }
603                None => RungRecord::r3(now, manifest.manifest_version),
604            }
605        }
606        ProbeResult::Unreachable => RungRecord::r2(now, "daemon_unreachable", None),
607        ProbeResult::NoRoute => RungRecord::r2(now, "catalog_gh_route_absent", None),
608        ProbeResult::Unbound => RungRecord::r2(now, "agent_binding_unavailable", None),
609        ProbeResult::TimedOut => cached
610            .filter(|record| record.fresh_at(now))
611            .unwrap_or_else(|| RungRecord::r1(now, "discovery_budget_exhausted")),
612    };
613
614    if record.rung != Rung::R1 {
615        write_rung_record_silently(paths, &record);
616    }
617    record
618}
619
620fn configured_connection_file() -> Option<PathBuf> {
621    let xdg_config_home = std::env::var_os("XDG_CONFIG_HOME");
622    let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"));
623    configured_connection_file_from(xdg_config_home.as_deref(), home.as_deref())
624}
625
626fn configured_connection_file_from(
627    xdg_config_home: Option<&OsStr>,
628    home: Option<&OsStr>,
629) -> Option<PathBuf> {
630    // The shim uses the same user-tier resolver as subc: `$XDG_CONFIG_HOME/cortexkit/aft.jsonc`,
631    // then `~/.config/cortexkit/aft.jsonc`. XDG selects only the trusted user's
632    // config location; it cannot select a project file or alter the configured
633    // connection. An invalid path resolves to `None`; the caller decides whether
634    // that means structural passthrough or an unavailable governed route.
635    let config_path = crate::subc_config::user_config_path_from(xdg_config_home, home)?;
636    let doc = fs::read_to_string(config_path).ok()?;
637    connection_file_from_config_doc(&doc).filter(|path| path.is_file())
638}
639
640/// Read the raw user-tier `aft.jsonc` document for the shim's config gates.
641/// `None` means the config file was absent or unreadable, which the rung
642/// determination treats as "no user config" (structural rungs decide).
643fn read_user_config_doc() -> Option<String> {
644    let xdg_config_home = std::env::var_os("XDG_CONFIG_HOME");
645    let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"));
646    let config_path =
647        crate::subc_config::user_config_path_from(xdg_config_home.as_deref(), home.as_deref())?;
648    fs::read_to_string(config_path).ok()
649}
650
651/// Read the `gh_shim.enabled` operator gate from the user config document.
652/// `None` means the key is absent or the document is unparseable, in which case
653/// the shim stays enabled (default true). Only an explicit `false` disables.
654fn gh_shim_enabled_from_config_doc(doc: &str) -> Option<bool> {
655    let value: Value = serde_json::from_str(&crate::jsonc::strip_jsonc(doc)).ok()?;
656    value.get("gh_shim")?.get("enabled")?.as_bool()
657}
658
659fn connection_file_from_config_doc(doc: &str) -> Option<PathBuf> {
660    let value: Value = serde_json::from_str(&crate::jsonc::strip_jsonc(doc)).ok()?;
661    let raw = value.get("subc")?.get("connection_file")?.as_str()?.trim();
662    let path = PathBuf::from(raw);
663    (!raw.is_empty() && path.is_absolute()).then_some(path)
664}
665
666fn load_rung_record(paths: &StatePaths) -> Option<RungRecord> {
667    serde_json::from_slice(&fs::read(&paths.rung).ok()?).ok()
668}
669
670fn write_rung_record_silently(paths: &StatePaths, record: &RungRecord) {
671    let Ok(bytes) = serde_json::to_vec(record) else {
672        return;
673    };
674    let _ = fs::create_dir_all(&paths.root);
675    let temporary = paths.root.join("rung-cache.json.tmp");
676    if fs::write(&temporary, bytes).is_ok() {
677        let _ = fs::rename(temporary, &paths.rung);
678    }
679}
680
681#[derive(Debug)]
682enum ProbeResult {
683    Ready { module_id: String },
684    Unreachable,
685    NoRoute,
686    Unbound,
687    TimedOut,
688}
689
690fn probe_governance(
691    paths: &StatePaths,
692    connection_file: &Path,
693    cwd: &Path,
694    deadline: std::time::Instant,
695    agent_id: &str,
696) -> ProbeResult {
697    let remaining = deadline.saturating_duration_since(std::time::Instant::now());
698    if remaining.is_zero() {
699        return ProbeResult::TimedOut;
700    }
701    let connection_file = connection_file.to_path_buf();
702    let project_root = project_root_for(cwd);
703    let record_paths = paths.clone();
704    let agent_id = agent_id.to_string();
705    let Ok(runtime) = tokio::runtime::Builder::new_current_thread()
706        .enable_io()
707        .enable_time()
708        .build()
709    else {
710        return ProbeResult::Unreachable;
711    };
712
713    // `tokio::time::timeout` creates its timer immediately. Building that
714    // future as a `block_on` argument happens before the runtime enters its
715    // context, so the timer's reactor lookup panics in this synchronous CLI.
716    // Construct it from inside the entered future instead.
717    match runtime.block_on(async move {
718        tokio::time::timeout(remaining, async move {
719            let options = ConsumerOptions {
720                call_timeout: remaining,
721                ..ConsumerOptions::default()
722            };
723            let consumer = SubcConsumer::connect(&connection_file, options)
724                .await
725                .map_err(|_| ProbeResult::Unreachable)?;
726            let catalog = consumer
727                .catalog_list()
728                .await
729                .map_err(|_| ProbeResult::Unreachable)?;
730            let holder = route_holder(&catalog.modules);
731            record_unexpected_gh_route_advertisers(&record_paths, &holder.unexpected_advertisers);
732            let Some(module_id) = holder.module_id else {
733                return Err(ProbeResult::NoRoute);
734            };
735            let identity = BindIdentity {
736                project_root: project_root.to_string_lossy().into_owned().into(),
737                harness: "aft-gh-shim".to_string(),
738                session: gh_session_id(&agent_id),
739            };
740            let route = consumer
741                .open_route(
742                    RouteTarget::ManagementSurface {
743                        module_id: module_id.clone(),
744                    },
745                    identity,
746                    CallOptions::default(),
747                )
748                .await
749                .map_err(|_| ProbeResult::Unbound)?;
750            let _ = consumer
751                .close_handle(&route, CloseRouteOptions::default())
752                .await;
753            Ok(module_id)
754        })
755        .await
756    }) {
757        Ok(Ok(module_id)) => ProbeResult::Ready { module_id },
758        Ok(Err(result)) => result,
759        Err(_) => ProbeResult::TimedOut,
760    }
761}
762
763#[derive(Debug, Default, Eq, PartialEq)]
764struct RouteHolder {
765    module_id: Option<String>,
766    unexpected_advertisers: Vec<String>,
767}
768
769fn route_holder(entries: &[subc_client_rs::CatalogEntry]) -> RouteHolder {
770    select_route_holder(entries.iter().filter_map(|entry| {
771        entry
772            .roles
773            .iter()
774            .any(|role| {
775                matches!(
776                    role,
777                    ProviderRole::ManagementSurface { operations, .. }
778                        if operations.iter().any(|operation| operation.name == ROUTING_OPERATION)
779                )
780            })
781            .then(|| entry.module_id.clone())
782    }))
783}
784
785fn select_route_holder(advertisers: impl IntoIterator<Item = String>) -> RouteHolder {
786    let mut holder = None;
787    let mut unexpected_advertisers = BTreeSet::new();
788    for advertiser in advertisers {
789        // Governed routes carry identity-bearing writes, so only prefrontal-core may
790        // hold `gh.route`; another module advertising it must not capture the route.
791        // The holder module identifies the routing server, not the bound agent. Using
792        // its module ID would merge all agents into one audit and rate-accounting session.
793        if advertiser == ROUTING_HOLDER_MODULE_ID {
794            holder.get_or_insert(advertiser);
795        } else {
796            unexpected_advertisers.insert(advertiser);
797        }
798    }
799    RouteHolder {
800        module_id: holder,
801        unexpected_advertisers: unexpected_advertisers.into_iter().collect(),
802    }
803}
804
805fn project_root_for(cwd: &Path) -> PathBuf {
806    let canonical = cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf());
807    canonical
808        .ancestors()
809        .find(|path| path.join(".git").exists())
810        .map(Path::to_path_buf)
811        .unwrap_or(canonical)
812}
813
814#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
815struct AgentBinding {
816    repo: String,
817    agent_id: String,
818}
819
820fn resolved_agent_binding(manifest: &Manifest, cwd: &Path) -> Option<AgentBinding> {
821    let project_root = project_root_for(cwd);
822    let repo = repository_key_from_origin(&project_root)?;
823    manifest
824        .bindings
825        .get(&repo)
826        .cloned()
827        .map(|agent_id| AgentBinding { repo, agent_id })
828}
829
830fn repository_key_from_origin(project_root: &Path) -> Option<String> {
831    // The binding key comes from parsing the local origin remote, not a network
832    // lookup, so a signed manifest selects the same agent when offline.
833    let remote = origin_remote(project_root)?;
834    canonical_repository_key(&remote)
835}
836
837fn origin_remote(cwd: &Path) -> Option<String> {
838    let output = Command::new("git")
839        .current_dir(cwd)
840        .args(["remote", "get-url", "origin"])
841        .output()
842        .ok()?;
843    output
844        .status
845        .success()
846        .then(|| String::from_utf8(output.stdout).ok())
847        .flatten()
848        .map(|remote| remote.trim().to_string())
849        .filter(|remote| !remote.is_empty())
850}
851
852fn canonical_repository_key(value: &str) -> Option<String> {
853    let remote = value.trim().trim_end_matches('/');
854    let path = [
855        "https://github.com/",
856        "http://github.com/",
857        "ssh://git@github.com/",
858        "git://github.com/",
859        "git@github.com:",
860        "github.com/",
861    ]
862    .iter()
863    .find_map(|prefix| remote.strip_prefix(prefix))
864    .unwrap_or(remote)
865    .trim_end_matches(".git")
866    .trim_matches('/');
867    let mut parts = path.split('/');
868    let owner = parts.next()?.trim();
869    let repository = parts.next()?.trim();
870    (!owner.is_empty() && !repository.is_empty() && parts.next().is_none()).then(|| {
871        format!(
872            "{}/{}",
873            owner.to_ascii_lowercase(),
874            repository.to_ascii_lowercase()
875        )
876    })
877}
878
879fn gh_session_id(agent_id: &str) -> String {
880    format!("gh-shim:{agent_id}")
881}
882
883#[derive(Clone, Debug, Default, Deserialize, Serialize)]
884struct Detectors {
885    #[serde(default)]
886    wrapper_config_dirs: Vec<String>,
887    #[serde(default)]
888    credential_env_names: Vec<String>,
889}
890
891fn find_ambient_agent_credential(detectors: &Detectors) -> Option<String> {
892    for name in &detectors.credential_env_names {
893        if std::env::var_os(name).is_some() {
894            return Some(format!("env:{name}"));
895        }
896    }
897
898    let home = std::env::var_os("HOME")
899        .or_else(|| std::env::var_os("USERPROFILE"))
900        .map(PathBuf::from);
901    for raw_pattern in &detectors.wrapper_config_dirs {
902        let pattern = expand_home_pattern(raw_pattern, home.as_deref());
903        if let Ok(paths) = glob::glob(&pattern) {
904            for path in paths.flatten() {
905                if path.is_dir() {
906                    return Some(format!("path:{}", path.display()));
907                }
908            }
909        }
910    }
911
912    // `GH_CONFIG_DIR` is only inspected as a metadata path. The basename is
913    // compared to the manifest's declared wrapper-dir glob, so the operator's
914    // normal gh configuration remains outside this detector inventory.
915    let configured = std::env::var_os("GH_CONFIG_DIR").map(PathBuf::from)?;
916    if !configured.is_dir() {
917        return None;
918    }
919    let name = configured.file_name()?.to_string_lossy();
920    detectors
921        .wrapper_config_dirs
922        .iter()
923        .any(|pattern| {
924            Path::new(pattern).file_name().is_some_and(|glob_name| {
925                glob::Pattern::new(&glob_name.to_string_lossy()).is_ok_and(|p| p.matches(&name))
926            })
927        })
928        .then(|| format!("path:{}", configured.display()))
929}
930
931fn expand_home_pattern(pattern: &str, home: Option<&Path>) -> String {
932    pattern
933        .strip_prefix("~/")
934        .and_then(|suffix| home.map(|home| home.join(suffix).to_string_lossy().into_owned()))
935        .unwrap_or_else(|| pattern.to_string())
936}
937
938#[derive(Clone, Debug, Deserialize, Serialize)]
939#[serde(untagged)]
940enum TupleDecl {
941    Name(String),
942    Details {
943        tuple: String,
944        #[serde(default)]
945        platform: Vec<String>,
946        #[serde(default)]
947        api_match: Option<String>,
948        #[serde(default)]
949        rationale: Option<String>,
950    },
951}
952
953impl TupleDecl {
954    fn tuple(&self) -> &str {
955        match self {
956            Self::Name(name) => name,
957            Self::Details { tuple, .. } => tuple,
958        }
959    }
960
961    fn platform(&self) -> &[String] {
962        match self {
963            Self::Name(_) => &[],
964            Self::Details { platform, .. } => platform,
965        }
966    }
967
968    fn empty_api_match_has_rationale(&self) -> bool {
969        match self {
970            Self::Details {
971                api_match: Some(api_match),
972                rationale,
973                ..
974            } if api_match.is_empty() => rationale
975                .as_deref()
976                .is_some_and(|text| !text.trim().is_empty()),
977            _ => true,
978        }
979    }
980}
981
982#[derive(Clone, Debug, Deserialize, Serialize)]
983struct ApiRule {
984    method: String,
985    path_glob: String,
986    tier: Tier,
987    #[serde(default)]
988    platform: Vec<String>,
989    #[serde(default)]
990    rationale: Option<String>,
991}
992
993#[derive(Clone, Debug, Default, Deserialize, Serialize)]
994struct Canonicalization {
995    #[serde(default)]
996    argv_forms: Vec<String>,
997    #[serde(default)]
998    target_fields: Vec<String>,
999    #[serde(default)]
1000    body_fields: Vec<String>,
1001}
1002
1003#[derive(Clone, Debug, Default, Deserialize, Serialize)]
1004struct RepositorySection {
1005    #[serde(default)]
1006    tiers: BTreeMap<Tier, Vec<TupleDecl>>,
1007    #[serde(default, alias = "remove")]
1008    removed_tuples: Vec<String>,
1009}
1010
1011#[derive(Clone, Debug, Deserialize, Serialize)]
1012struct Manifest {
1013    artifact_id: String,
1014    manifest_version: u64,
1015    schema_floor: u64,
1016    /// When the signer issued this manifest. Lives inside the signed bytes so
1017    /// freshness cannot be re-stamped locally; the verifier keys TTL/grace off
1018    /// this field and never off the envelope-side fetch time.
1019    issued_at_unix_secs: u64,
1020    #[serde(default)]
1021    detectors: Detectors,
1022    #[serde(default)]
1023    tiers: BTreeMap<Tier, Vec<TupleDecl>>,
1024    #[serde(default)]
1025    api_rules: Vec<ApiRule>,
1026    #[serde(default)]
1027    canonicalization: BTreeMap<String, Canonicalization>,
1028    #[serde(default)]
1029    repository_sections: BTreeMap<String, RepositorySection>,
1030    #[serde(default)]
1031    bindings: BTreeMap<String, String>,
1032}
1033
1034impl Manifest {
1035    fn validate(&self) -> Result<(), String> {
1036        if self.artifact_id != MANIFEST_ARTIFACT_ID {
1037            return Err(format!("unexpected artifact id {}", self.artifact_id));
1038        }
1039        if self.manifest_version == 0 {
1040            return Err("manifest_version must be positive".to_string());
1041        }
1042
1043        let mut declared = BTreeMap::<String, Tier>::new();
1044        for (tier, entries) in &self.tiers {
1045            for entry in entries {
1046                let tuple = normalized_tuple(entry.tuple())?;
1047                if entry.platform().is_empty() {
1048                    return Err(format!("tuple {tuple} is missing its platform declaration"));
1049                }
1050                if !entry.empty_api_match_has_rationale() {
1051                    return Err(format!(
1052                        "tuple {tuple} has an empty api_match without rationale"
1053                    ));
1054                }
1055                if let Some(previous) = declared.insert(tuple.clone(), *tier) {
1056                    return Err(format!(
1057                        "tuple {tuple} is declared in both {previous:?} and {tier:?}"
1058                    ));
1059                }
1060            }
1061        }
1062
1063        let mut api_declared = BTreeSet::new();
1064        for rule in &self.api_rules {
1065            if rule.method.trim().is_empty() || rule.path_glob.trim().is_empty() {
1066                if rule.path_glob.is_empty()
1067                    && rule
1068                        .rationale
1069                        .as_deref()
1070                        .is_some_and(|text| !text.trim().is_empty())
1071                {
1072                    continue;
1073                }
1074                return Err("api rule requires method and non-empty path_glob".to_string());
1075            }
1076            if rule.platform.is_empty() {
1077                return Err(format!(
1078                    "api rule {} {} is missing its platform declaration",
1079                    rule.method, rule.path_glob
1080                ));
1081            }
1082            let key = format!("{} {}", rule.method.to_ascii_uppercase(), rule.path_glob);
1083            if !api_declared.insert(key.clone()) {
1084                return Err(format!("api rule {key} is declared more than once"));
1085            }
1086        }
1087
1088        let governed = self.tiers.get(&Tier::Governed).cloned().unwrap_or_default();
1089        for entry in &governed {
1090            let tuple = normalized_tuple(entry.tuple())?;
1091            let Some(canonical) = self.canonicalization.get(&tuple) else {
1092                return Err(format!("governed tuple {tuple} lacks canonicalization"));
1093            };
1094            if canonical.argv_forms.is_empty() || canonical.target_fields.is_empty() {
1095                return Err(format!(
1096                    "governed tuple {tuple} has incomplete canonicalization"
1097                ));
1098            }
1099        }
1100        for tuple in self.canonicalization.keys() {
1101            if declared.get(tuple) != Some(&Tier::Governed) {
1102                return Err(format!(
1103                    "canonicalization {tuple} does not name a governed tuple"
1104                ));
1105            }
1106        }
1107
1108        for (repository, agent_id) in &self.bindings {
1109            if canonical_repository_key(repository).as_deref() != Some(repository.as_str()) {
1110                return Err(format!(
1111                    "binding repository {repository} is not canonical owner/name"
1112                ));
1113            }
1114            if agent_id.trim().is_empty() || agent_id.trim() != agent_id {
1115                return Err(format!(
1116                    "binding repository {repository} has an invalid agent id"
1117                ));
1118            }
1119        }
1120
1121        for (repository, section) in &self.repository_sections {
1122            for removed in &section.removed_tuples {
1123                if !declared.contains_key(&normalized_tuple(removed)?) {
1124                    return Err(format!(
1125                        "repository section {repository} removes undeclared tuple {removed}"
1126                    ));
1127                }
1128            }
1129            for (tier, entries) in &section.tiers {
1130                for entry in entries {
1131                    let tuple = normalized_tuple(entry.tuple())?;
1132                    let Some(base) = declared.get(&tuple) else {
1133                        return Err(format!(
1134                            "repository section {repository} adds tuple {tuple}"
1135                        ));
1136                    };
1137                    if tier.rank() < base.rank() {
1138                        return Err(format!(
1139                            "repository section {repository} lowers tuple {tuple}"
1140                        ));
1141                    }
1142                }
1143            }
1144        }
1145        Ok(())
1146    }
1147
1148    fn tier_for_tuple(&self, tuple: &str, platform: &str) -> Option<Tier> {
1149        self.tiers.iter().find_map(|(tier, entries)| {
1150            entries
1151                .iter()
1152                .any(|entry| {
1153                    normalized_tuple(entry.tuple()).ok().as_deref() == Some(tuple)
1154                        && platform_matches(entry.platform(), platform)
1155                })
1156                .then_some(*tier)
1157        })
1158    }
1159}
1160
1161fn normalized_tuple(value: &str) -> Result<String, String> {
1162    let words = value
1163        .split_whitespace()
1164        .map(|word| word.to_ascii_lowercase())
1165        .collect::<Vec<_>>();
1166    (!words.is_empty())
1167        .then(|| words.join(" "))
1168        .ok_or_else(|| "tuple cannot be empty".to_string())
1169}
1170
1171fn platform_matches(platforms: &[String], current: &str) -> bool {
1172    platforms
1173        .iter()
1174        .any(|platform| platform.eq_ignore_ascii_case(current))
1175}
1176
1177/// Envelope v2: the manifest body travels as the EXACT bytes the signer
1178/// published. `manifest_bytes` is an opaque string holding that file's
1179/// contents verbatim; the verifier checks the signature over those bytes
1180/// BEFORE parsing them, so the signature contract is "the signer signed the
1181/// file it publishes" and no canonicalization rule exists on this side.
1182#[derive(Clone, Debug, Deserialize, Serialize)]
1183struct SignedManifest {
1184    artifact_id: String,
1185    envelope_version: u64,
1186    key_id: String,
1187    /// Advisory local metadata only: when this machine stored the artifact.
1188    /// Freshness is keyed off the signed `issued_at_unix_secs` inside
1189    /// `manifest_bytes`; this field is never consulted for staleness, so
1190    /// re-stamping it locally buys nothing.
1191    fetched_at_unix_secs: u64,
1192    signature: String,
1193    manifest_bytes: String,
1194}
1195
1196#[derive(Clone, Debug)]
1197enum ManifestProblem {
1198    Missing,
1199    Invalid(String),
1200    BelowFloor {
1201        manifest_floor: u64,
1202    },
1203    Stale {
1204        manifest_version: u64,
1205    },
1206    /// The manifest is validly signed but its version is below the newest
1207    /// version ever accepted on this machine: a rollback incident, never an
1208    /// ordinary out-of-order arrival.
1209    RolledBack {
1210        manifest_version: u64,
1211        newest_accepted: u64,
1212    },
1213}
1214
1215impl ManifestProblem {
1216    fn diagnostic(&self) -> SelfReportDiagnostic {
1217        match self {
1218            Self::Missing => SelfReportDiagnostic::ManifestUnavailable,
1219            Self::Invalid(_) => SelfReportDiagnostic::ManifestInvalid,
1220            Self::BelowFloor { .. } => SelfReportDiagnostic::ManifestBelowFloor,
1221            Self::Stale { .. } => SelfReportDiagnostic::ManifestStale,
1222            Self::RolledBack { .. } => SelfReportDiagnostic::ManifestRollback,
1223        }
1224    }
1225
1226    fn status_label(&self) -> String {
1227        match self {
1228            Self::Missing => "unavailable".to_string(),
1229            Self::Invalid(error) => format!("invalid ({error})"),
1230            Self::BelowFloor { manifest_floor } => format!(
1231                "{} (manifest floor {manifest_floor}, shim floor {SCHEMA_FLOOR})",
1232                RefusalCode::ManifestBelowFloor.as_str()
1233            ),
1234            Self::Stale { manifest_version } => format!(
1235                "{} (manifest version {manifest_version})",
1236                RefusalCode::ManifestStale.as_str()
1237            ),
1238            Self::RolledBack {
1239                manifest_version,
1240                newest_accepted,
1241            } => format!(
1242                "{} (manifest version {manifest_version}, newest accepted version {newest_accepted})",
1243                SelfReportDiagnostic::ManifestRollback.as_str()
1244            ),
1245        }
1246    }
1247}
1248
1249/// Verifier-site contract for the signed routing manifest.
1250///
1251/// RAW DISTRIBUTED BYTES. The manifest body travels inside the envelope as the
1252/// exact bytes the signer published (`manifest_bytes`). This function verifies
1253/// the received bytes FIRST and parses them into a `Manifest` SECOND. The
1254/// signer signs the file it publishes; no canonicalization rule exists on this
1255/// side, so no field reorder, re-indent, or re-encode can break (or silently
1256/// reshape) the signature contract across languages. Verifying a parsed and
1257/// re-serialized struct instead would make every serializer a party to the
1258/// signature.
1259///
1260/// SIGNED FRESHNESS. Freshness is keyed off `issued_at_unix_secs`, which lives
1261/// inside the signed bytes. The envelope-side fetch time is advisory local
1262/// metadata only: an envelope-side timestamp can be re-stamped locally, which
1263/// would let an old signed manifest replay forever and dodge exactly the
1264/// governed-set additions shipped in a newer manifest.
1265///
1266/// TWO-SIDED BOUND (the custody bar stays at config integrity). The governed
1267/// EXECUTION vocabulary is compiled into the route holder (vendored
1268/// classification); no manifest can widen what the holder executes. The
1269/// manifest governs shim-side routing selection only. Manifest tampering is
1270/// therefore bounded above by the holder's vendored set and below by the
1271/// shim's refusal arms. If classification ever moves INTO the manifest, the
1272/// trust root flips from integrity to authority and the custody design must be
1273/// revisited first.
1274///
1275/// Delta property, phrased for the manifest approver: NARROWING a manifest
1276/// WIDENS the key-compromise surface. The delta is the compiled vocabulary
1277/// minus what this manifest routes; every operation a manifest stops routing
1278/// joins the set a compromised signing key could re-enable. The approval
1279/// question for a narrowing change is "am I content that a key compromise
1280/// re-enables exactly the operations this manifest stops routing", not "does
1281/// this look tighter". The holder-side vendored vocabulary guards the widening
1282/// direction; this line guards the narrowing one.
1283///
1284/// DORMANCY VALVE AND LOCAL STATE. With no manifest artifact on disk the shim
1285/// is dormant and passes invocations through (R2, reason
1286/// `manifest_unavailable`). That valve's weakness — a local downgrade to
1287/// dormant by deleting the artifact — is only reachable by an adversary with
1288/// local write access, who can equally patch the compiled-in trust set or this
1289/// verifier itself; the weakness is only reachable by an adversary the design
1290/// already cannot survive. The same argument covers the local state this
1291/// verifier maintains: the last-valid manifest cache and the monotonic version
1292/// high-water mark are enforcement conveniences, not a security boundary, and
1293/// an adversary who can delete, forge, or lower them can patch the verifier.
1294///
1295/// TOKEN LANGUAGE. The holder executes governed calls under full-installation
1296/// GitHub App tokens held in custody; operation gating is holder-side
1297/// classification over the routed request. The shim never holds any token in
1298/// either direction.
1299fn load_manifest(paths: &StatePaths, now: u64) -> Result<Manifest, ManifestProblem> {
1300    let bytes = fs::read(&paths.manifest).map_err(|_| ManifestProblem::Missing)?;
1301    let envelope: SignedManifest = serde_json::from_slice(&bytes)
1302        .map_err(|error| ManifestProblem::Invalid(error.to_string()))?;
1303    if envelope.artifact_id != MANIFEST_ARTIFACT_ID {
1304        return Err(ManifestProblem::Invalid("artifact id mismatch".to_string()));
1305    }
1306    if envelope.envelope_version != ENVELOPE_VERSION {
1307        return Err(ManifestProblem::Invalid(format!(
1308            "unsupported envelope version {} (this shim verifies envelope version {ENVELOPE_VERSION})",
1309            envelope.envelope_version
1310        )));
1311    }
1312    // Verify the received bytes FIRST, parse SECOND (contract above).
1313    let manifest = verify_manifest_signature(&envelope)?;
1314    manifest.validate().map_err(ManifestProblem::Invalid)?;
1315    if manifest.schema_floor < SCHEMA_FLOOR {
1316        return Err(ManifestProblem::BelowFloor {
1317            manifest_floor: manifest.schema_floor,
1318        });
1319    }
1320    // Monotonic version high-water mark: a manifest older than the newest ever
1321    // accepted here is refused as a rollback incident. Checked before the
1322    // issue-time rules so a rollback is never masked by staleness: any past
1323    // manifest that once carried a wider vocabulary still verifies inside the
1324    // staleness window, so replaying it must fail on version, not time.
1325    let newest_accepted = version_high_water(paths);
1326    if manifest.manifest_version < newest_accepted {
1327        return Err(ManifestProblem::RolledBack {
1328            manifest_version: manifest.manifest_version,
1329            newest_accepted,
1330        });
1331    }
1332    if manifest.issued_at_unix_secs > now + ISSUED_AT_FUTURE_SKEW.as_secs() {
1333        return Err(ManifestProblem::Invalid(format!(
1334            "issued_at_unix_secs {} is more than {} seconds in the future",
1335            manifest.issued_at_unix_secs,
1336            ISSUED_AT_FUTURE_SKEW.as_secs()
1337        )));
1338    }
1339    if now.saturating_sub(manifest.issued_at_unix_secs)
1340        > MANIFEST_TTL.as_secs() + MANIFEST_STALE_GRACE.as_secs()
1341    {
1342        return Err(ManifestProblem::Stale {
1343            manifest_version: manifest.manifest_version,
1344        });
1345    }
1346    // Accepted: advance the high-water mark and refresh the last-valid cache
1347    // that the regressed-manifest arm classifies from. Both are local state
1348    // under the dormancy-valve argument documented above.
1349    if manifest.manifest_version > newest_accepted {
1350        write_version_high_water(paths, manifest.manifest_version);
1351    }
1352    write_last_valid_manifest(paths, now, &manifest);
1353    Ok(manifest)
1354}
1355
1356/// Verify the signature over the envelope's exact manifest bytes, then parse.
1357/// No manifest content is interpreted before its bytes verify.
1358fn verify_manifest_signature(envelope: &SignedManifest) -> Result<Manifest, ManifestProblem> {
1359    verify_manifest_signature_with(envelope, compiled_manifest_trust_set())
1360}
1361
1362fn verify_manifest_signature_with(
1363    envelope: &SignedManifest,
1364    trust_set: &[Option<ManifestTrustKey>],
1365) -> Result<Manifest, ManifestProblem> {
1366    let Some(key) = trust_set
1367        .iter()
1368        .flatten()
1369        .find(|slot| slot.key_id == envelope.key_id)
1370        .map(|slot| slot.public_key)
1371    else {
1372        return Err(ManifestProblem::Invalid(format!(
1373            "untrusted manifest key id {}",
1374            envelope.key_id
1375        )));
1376    };
1377    let signature = base64::engine::general_purpose::STANDARD
1378        .decode(&envelope.signature)
1379        .map_err(|_| ManifestProblem::Invalid("invalid detached signature encoding".to_string()))?;
1380    UnparsedPublicKey::new(&ED25519, key)
1381        .verify(envelope.manifest_bytes.as_bytes(), &signature)
1382        .map_err(|_| {
1383            ManifestProblem::Invalid("detached signature verification failed".to_string())
1384        })?;
1385    serde_json::from_str(&envelope.manifest_bytes).map_err(|error| {
1386        ManifestProblem::Invalid(format!("signed manifest bytes failed to parse: {error}"))
1387    })
1388}
1389
1390/// One trusted manifest signing key: a stable key id plus the Ed25519 public
1391/// key bytes that id binds.
1392#[derive(Clone, Copy)]
1393struct ManifestTrustKey {
1394    key_id: &'static str,
1395    public_key: &'static [u8],
1396}
1397
1398// A manifest signature is the barrier preventing an agent from editing its own
1399// cache to turn a governed verb into a mechanical one. The development key is
1400// deliberately compiled only in debug builds so fixtures can exercise R3. A
1401// release build has no trust root until the separately reviewed CKCRED custody
1402// release supplies one, which keeps release binaries at R2 rather than making a
1403// governance claim with a test key.
1404//
1405// TWO-KEY TRUST SET. The release trust array ships with TWO key slots from day
1406// one: the live signing key and a cold standby with a distinct key id. The dev
1407// set keeps its single test key.
1408//
1409// The production signing keys are minted and held by the key-custody process
1410// outside this repository; a separately reviewed release copies each approved
1411// public key into these slots (the private half never approaches the build).
1412// Until that happens both slots stay empty and release binaries remain at R2.
1413//
1414// Two-release rotation procedure once the slots are filled:
1415//   1. Ship a release whose standby slot carries the standby key. Both slots
1416//      verify; the live key still signs. The standby key comes from custody,
1417//      never from a self-generated filler.
1418//   2. Promote the standby by shipping a manifest signed by it. The trust set
1419//      already accepts it, so promotion does not depend on updating binaries
1420//      first.
1421//   3. One release later, remove the old key. Between promotion and removal a
1422//      compromise of the old key can still sign, so the removal release is
1423//      part of the rotation rather than optional cleanup.
1424//
1425// Slot layout: index 0 is the LIVE signing key, index 1 is the COLD STANDBY.
1426// The first manifest signature verifying under a newly installed live key is
1427// the stored-key-equals-published-half acceptance test for that installation.
1428#[cfg(not(debug_assertions))]
1429const RELEASE_MANIFEST_TRUST_SET: &[Option<ManifestTrustKey>; 2] = &[
1430    None, // live
1431    None, // cold standby
1432];
1433
1434#[cfg(debug_assertions)]
1435const DEV_MANIFEST_KEY_ID: &str = "gh-routing-dev-test-key-v1";
1436#[cfg(debug_assertions)]
1437const DEV_MANIFEST_PUBLIC_KEY: [u8; 32] = [
1438    0xd7, 0x5a, 0x98, 0x01, 0x82, 0xb1, 0x0a, 0xb7, 0xd5, 0x4b, 0xfe, 0xd3, 0xc9, 0x64, 0x07, 0x3a,
1439    0x0e, 0xe1, 0x72, 0xf3, 0xda, 0xa6, 0x23, 0x25, 0xaf, 0x02, 0x1a, 0x68, 0xf7, 0x07, 0x51, 0x1a,
1440];
1441#[cfg(debug_assertions)]
1442const DEV_MANIFEST_TRUST_SET: &[Option<ManifestTrustKey>; 1] = &[Some(ManifestTrustKey {
1443    key_id: DEV_MANIFEST_KEY_ID,
1444    public_key: &DEV_MANIFEST_PUBLIC_KEY,
1445})];
1446
1447fn compiled_manifest_trust_set() -> &'static [Option<ManifestTrustKey>] {
1448    #[cfg(debug_assertions)]
1449    {
1450        DEV_MANIFEST_TRUST_SET
1451    }
1452    #[cfg(not(debug_assertions))]
1453    {
1454        RELEASE_MANIFEST_TRUST_SET
1455    }
1456}
1457
1458/// Outcome of resolving the installed manifest artifact for an invocation.
1459///
1460/// The state is keyed on what is INSTALLED on disk, never on memory of past
1461/// validation: every call re-reads the artifact and re-derives its
1462/// disposition from the artifact plus the local last-valid cache.
1463#[derive(Debug)]
1464enum ManifestResolution {
1465    /// The installed artifact verified: normal classification.
1466    Active(Manifest),
1467    /// The installed artifact FAILED verification, but the last-valid manifest
1468    /// cache is inside its grace window: classify from that cache.
1469    GraceCache(Manifest),
1470    /// The installed artifact FAILED verification and the grace window of the
1471    /// last-valid cache has expired: governed/admin tuples the cache
1472    /// classifies are refused; mechanical operations pass through.
1473    Regressed(Manifest),
1474    /// No manifest artifact on disk (dormant), or a failing artifact on a
1475    /// machine that never validated one: whole-invocation R2 passthrough.
1476    Dormant,
1477}
1478
1479impl ManifestResolution {
1480    fn manifest(&self) -> Option<&Manifest> {
1481        match self {
1482            Self::Active(manifest) | Self::GraceCache(manifest) | Self::Regressed(manifest) => {
1483                Some(manifest)
1484            }
1485            Self::Dormant => None,
1486        }
1487    }
1488
1489    fn into_manifest(self) -> Option<Manifest> {
1490        match self {
1491            Self::Active(manifest) | Self::GraceCache(manifest) | Self::Regressed(manifest) => {
1492                Some(manifest)
1493            }
1494            Self::Dormant => None,
1495        }
1496    }
1497}
1498
1499fn resolve_manifest(paths: &StatePaths, now: u64) -> ManifestResolution {
1500    match load_manifest(paths, now) {
1501        Ok(manifest) => return ManifestResolution::Active(manifest),
1502        Err(ManifestProblem::Missing) => return ManifestResolution::Dormant,
1503        Err(_) => {}
1504    }
1505    // Artifact present but failing. Classify from the last-valid cache while
1506    // inside its grace window; past grace the refusal arm takes over. No cache
1507    // at all means this machine never validated a manifest, so there is
1508    // nothing to regress from and the shim stays dormant.
1509    match read_last_valid_manifest(paths) {
1510        Some(cache) if cache_within_grace(&cache, now) => {
1511            ManifestResolution::GraceCache(cache.manifest)
1512        }
1513        Some(cache) => ManifestResolution::Regressed(cache.manifest),
1514        None => ManifestResolution::Dormant,
1515    }
1516}
1517
1518/// Disposition of one invocation under the regressed-manifest arm.
1519///
1520/// Governed and admin tuples, as classified by the last-valid manifest, fail
1521/// closed with a stable refusal; mechanical operations pass through
1522/// byte-transparently. The operator bypass does not apply here: a broken
1523/// manifest means the classification itself is untrusted, so no bypass can
1524/// promote it.
1525fn regressed_disposition(
1526    args: &[OsString],
1527    manifest: &Manifest,
1528    platform: &str,
1529) -> RegressedDisposition {
1530    match classify(args, manifest, platform) {
1531        Classification::Mechanical => RegressedDisposition::Passthrough,
1532        Classification::Governed { tuple, .. } | Classification::Admin { tuple } => {
1533            RegressedDisposition::Refuse {
1534                code: RefusalCode::ManifestRegressed,
1535                text: format!(
1536                    "the manifest artifact fails validation past the last-valid grace window; {tuple} is refused until the manifest is repaired"
1537                ),
1538            }
1539        }
1540        Classification::Unclassified => RegressedDisposition::Refuse {
1541            code: RefusalCode::Unclassified,
1542            text: "no manifest declaration for this invocation (manifest artifact fails validation)"
1543                .to_string(),
1544        },
1545    }
1546}
1547
1548#[derive(Debug)]
1549enum RegressedDisposition {
1550    Passthrough,
1551    Refuse { code: RefusalCode, text: String },
1552}
1553
1554/// Last manifest that fully verified on this machine, plus when it was last
1555/// accepted. Local state under the dormancy-valve argument at the verifier
1556/// site: it lets the regressed-manifest arm keep classifying while a broken
1557/// artifact is repaired, and it is not a security boundary.
1558#[derive(Clone, Debug, Deserialize, Serialize)]
1559struct LastValidManifest {
1560    accepted_at_unix_secs: u64,
1561    manifest: Manifest,
1562}
1563
1564fn cache_within_grace(cache: &LastValidManifest, now: u64) -> bool {
1565    now.saturating_sub(cache.accepted_at_unix_secs) <= MANIFEST_STALE_GRACE.as_secs()
1566}
1567
1568fn read_last_valid_manifest(paths: &StatePaths) -> Option<LastValidManifest> {
1569    serde_json::from_slice(&fs::read(&paths.last_valid_manifest).ok()?).ok()
1570}
1571
1572fn write_last_valid_manifest(paths: &StatePaths, accepted_at_unix_secs: u64, manifest: &Manifest) {
1573    let record = LastValidManifest {
1574        accepted_at_unix_secs,
1575        manifest: manifest.clone(),
1576    };
1577    let Ok(bytes) = serde_json::to_vec(&record) else {
1578        return;
1579    };
1580    let _ = fs::create_dir_all(&paths.root);
1581    let temporary = paths.last_valid_manifest.with_extension("tmp");
1582    if fs::write(&temporary, bytes).is_ok() {
1583        let _ = fs::rename(temporary, &paths.last_valid_manifest);
1584    }
1585}
1586
1587/// Monotonic high-water mark: the newest `manifest_version` ever accepted on
1588/// this machine. A manifest below it is refused as a rollback incident. Local
1589/// state under the same dormancy-valve argument as the last-valid cache: an
1590/// adversary who can lower it can patch this verifier.
1591#[derive(Clone, Debug, Deserialize, Serialize)]
1592struct VersionHighWater {
1593    newest_accepted_version: u64,
1594}
1595
1596fn version_high_water(paths: &StatePaths) -> u64 {
1597    fs::read(&paths.version_high_water)
1598        .ok()
1599        .and_then(|bytes| serde_json::from_slice::<VersionHighWater>(&bytes).ok())
1600        .map(|record| record.newest_accepted_version)
1601        .unwrap_or(0)
1602}
1603
1604fn write_version_high_water(paths: &StatePaths, newest_accepted_version: u64) {
1605    let Ok(bytes) = serde_json::to_vec(&VersionHighWater {
1606        newest_accepted_version,
1607    }) else {
1608        return;
1609    };
1610    let _ = fs::create_dir_all(&paths.root);
1611    let temporary = paths.version_high_water.with_extension("tmp");
1612    if fs::write(&temporary, bytes).is_ok() {
1613        let _ = fs::rename(temporary, &paths.version_high_water);
1614    }
1615}
1616
1617#[derive(Debug)]
1618enum Classification {
1619    Mechanical,
1620    Governed {
1621        tuple: String,
1622        canonical: Canonicalization,
1623    },
1624    Admin {
1625        tuple: String,
1626    },
1627    Unclassified,
1628}
1629
1630fn classify(args: &[OsString], manifest: &Manifest, platform: &str) -> Classification {
1631    let Some((verb, subcommand, _)) = command_head(args) else {
1632        return Classification::Unclassified;
1633    };
1634    if verb == "api" {
1635        return classify_api(args, manifest, platform);
1636    }
1637    let tuple = match subcommand {
1638        Some(subcommand) => format!("{verb} {subcommand}"),
1639        None => verb,
1640    };
1641    match manifest.tier_for_tuple(&tuple, platform) {
1642        Some(Tier::Mechanical) => Classification::Mechanical,
1643        Some(Tier::Admin) if V1_ADMIN_TUPLES.contains(&tuple.as_str()) => {
1644            Classification::Admin { tuple }
1645        }
1646        Some(Tier::Governed) if V1_GOVERNED_TUPLES.contains(&tuple.as_str()) => manifest
1647            .canonicalization
1648            .get(&tuple)
1649            .cloned()
1650            .map(|canonical| Classification::Governed { tuple, canonical })
1651            .unwrap_or(Classification::Unclassified),
1652        // Manifest entries outside the v1 tuple set do not acquire a policy from
1653        // nearby command names. A reviewed manifest and implementation change are
1654        // both required before another write shape can be classified.
1655        Some(Tier::Governed | Tier::Admin) | None => Classification::Unclassified,
1656    }
1657}
1658
1659fn command_head(args: &[OsString]) -> Option<(String, Option<String>, usize)> {
1660    let mut positionals = Vec::new();
1661    let mut skip_next = false;
1662    for (index, raw) in args.iter().enumerate() {
1663        let value = raw.to_str()?;
1664        if skip_next {
1665            skip_next = false;
1666            continue;
1667        }
1668        if matches!(value, "--repo" | "-R" | "--hostname" | "--config-dir") {
1669            skip_next = true;
1670            continue;
1671        }
1672        if value.starts_with('-') {
1673            continue;
1674        }
1675        positionals.push((value.to_ascii_lowercase(), index));
1676        if positionals.len() == 2 || positionals[0].0 == "api" {
1677            break;
1678        }
1679    }
1680    let (verb, index) = positionals.first()?.clone();
1681    let subcommand = positionals.get(1).map(|(value, _)| value.clone());
1682    Some((verb, subcommand, index))
1683}
1684
1685fn classify_api(args: &[OsString], manifest: &Manifest, platform: &str) -> Classification {
1686    let Some((method, path)) = api_method_and_path(args) else {
1687        return Classification::Unclassified;
1688    };
1689    let matches = manifest
1690        .api_rules
1691        .iter()
1692        .filter(|rule| {
1693            rule.method.eq_ignore_ascii_case(&method)
1694                && platform_matches(&rule.platform, platform)
1695                && glob::Pattern::new(&rule.path_glob).is_ok_and(|pattern| pattern.matches(&path))
1696        })
1697        .collect::<Vec<_>>();
1698    if matches.len() != 1 {
1699        return Classification::Unclassified;
1700    }
1701    // The v1 manifest declares only mechanical API passthrough. API writes are
1702    // not normalized into governed or ADMIN equivalents before their exact argv
1703    // forms have an audited, reviewed parser contract.
1704    match matches[0].tier {
1705        Tier::Mechanical => Classification::Mechanical,
1706        Tier::Governed | Tier::Admin => Classification::Unclassified,
1707    }
1708}
1709
1710fn api_method_and_path(args: &[OsString]) -> Option<(String, String)> {
1711    let mut method = "GET".to_string();
1712    let mut path = None;
1713    let mut index = 1;
1714    while index < args.len() {
1715        let value = args[index].to_str()?;
1716        if matches!(value, "--method" | "-X") {
1717            method = args.get(index + 1)?.to_str()?.to_ascii_uppercase();
1718            index += 2;
1719            continue;
1720        }
1721        if let Some(method_value) = value.strip_prefix("--method=") {
1722            method = method_value.to_ascii_uppercase();
1723            index += 1;
1724            continue;
1725        }
1726        if is_api_field_argument(value) {
1727            // Field-bearing API forms can change request semantics independently
1728            // of the endpoint. Until an audited form has a reviewed parser, they
1729            // remain unclassified rather than inheriting a read-like API rule.
1730            return None;
1731        }
1732        if value.starts_with('-') {
1733            index += 1;
1734            continue;
1735        }
1736        if path.is_none() {
1737            path = Some(value.to_string());
1738        }
1739        index += 1;
1740    }
1741    let path = path?;
1742    (path != "-").then_some((method, path))
1743}
1744
1745fn is_api_field_argument(value: &str) -> bool {
1746    ["--input", "--raw-field", "--field"]
1747        .iter()
1748        .any(|flag| value == *flag || value.starts_with(&format!("{flag}=")))
1749        || value == "-F"
1750        || value.starts_with("-F")
1751        || value == "-f"
1752        || value.starts_with("-f")
1753}
1754
1755#[derive(Clone, Debug)]
1756struct GovernedRequest {
1757    action: String,
1758    target: Map<String, Value>,
1759    body: Map<String, Value>,
1760    repository: Option<String>,
1761    manifest_version: u64,
1762}
1763
1764fn canonicalize_governed(
1765    args: &[OsString],
1766    tuple: &str,
1767    canonical: &Canonicalization,
1768    manifest_version: u64,
1769) -> Result<GovernedRequest, String> {
1770    let (_, _, head_index) =
1771        command_head(args).ok_or_else(|| "missing command head".to_string())?;
1772    let subcommand_index = if tuple.starts_with("api ") {
1773        head_index
1774    } else {
1775        head_index + 1
1776    };
1777    let mut positional = Vec::new();
1778    let mut body = Map::new();
1779    let mut explicit_repository = None;
1780    let mut index = subcommand_index + 1;
1781    while index < args.len() {
1782        let value = args[index]
1783            .to_str()
1784            .ok_or_else(|| "non-UTF-8 governed arguments are undeclared".to_string())?;
1785        if value == "--repo" || value == "-R" {
1786            index += 1;
1787            let repository = args
1788                .get(index)
1789                .and_then(|arg| arg.to_str())
1790                .ok_or_else(|| "--repo requires a value".to_string())?;
1791            explicit_repository = Some(repository.to_string());
1792        } else if let Some(repository) = value.strip_prefix("--repo=") {
1793            explicit_repository = Some(repository.to_string());
1794        } else if let Some((field, supplied)) =
1795            declared_body_value(value, canonical, args.get(index + 1))?
1796        {
1797            body.insert(field, Value::String(supplied));
1798            if !value.contains('=') && !value.starts_with('-') {
1799                // Kept for completeness; declared_body_value only returns flags.
1800                positional.push(value.to_string());
1801            }
1802            if !value.contains('=') {
1803                index += 1;
1804            }
1805        } else if value.starts_with('-') {
1806            return Err(format!("undeclared flag {value}"));
1807        } else {
1808            positional.push(value.to_string());
1809        }
1810        index += 1;
1811    }
1812
1813    if positional.len() != canonical.target_fields.len() {
1814        return Err("target positional form is undeclared".to_string());
1815    }
1816    if canonical
1817        .body_fields
1818        .iter()
1819        .any(|field| !body.contains_key(field))
1820    {
1821        return Err("required declared body field is absent".to_string());
1822    }
1823    let target = canonical
1824        .target_fields
1825        .iter()
1826        .cloned()
1827        .zip(positional)
1828        .map(|(field, value)| (field, Value::String(value)))
1829        .collect::<Map<_, _>>();
1830    // A global `--repo` may precede the command head, so inspect the original
1831    // argv before falling back to a command-local flag or remote inference.
1832    let repository = explicit_repo(args)
1833        .or(explicit_repository)
1834        .or_else(infer_repository_from_git);
1835    Ok(GovernedRequest {
1836        action: tuple.to_string(),
1837        target,
1838        body,
1839        repository,
1840        manifest_version,
1841    })
1842}
1843
1844fn declared_body_value(
1845    value: &str,
1846    canonical: &Canonicalization,
1847    next: Option<&OsString>,
1848) -> Result<Option<(String, String)>, String> {
1849    for field in &canonical.body_fields {
1850        let long = format!("--{field}");
1851        let short = match field.as_str() {
1852            "body" => Some("-b"),
1853            "reaction" => Some("-r"),
1854            _ => None,
1855        };
1856        if value == long || short == Some(value) {
1857            let supplied = next
1858                .and_then(|arg| arg.to_str())
1859                .ok_or_else(|| format!("{value} requires a value"))?;
1860            return Ok(Some((field.clone(), supplied.to_string())));
1861        }
1862        if let Some(supplied) = value.strip_prefix(&(long + "=")) {
1863            return Ok(Some((field.clone(), supplied.to_string())));
1864        }
1865    }
1866    Ok(None)
1867}
1868
1869fn explicit_repo(args: &[OsString]) -> Option<String> {
1870    let mut args = args.iter();
1871    while let Some(arg) = args.next() {
1872        let value = arg.to_str()?;
1873        if value == "--repo" || value == "-R" {
1874            return args.next()?.to_str().map(str::to_string);
1875        }
1876        if let Some(repository) = value.strip_prefix("--repo=") {
1877            return Some(repository.to_string());
1878        }
1879    }
1880    None
1881}
1882
1883fn infer_repository_from_git() -> Option<String> {
1884    let cwd = std::env::current_dir().ok()?;
1885    origin_remote(&cwd)
1886}
1887
1888#[derive(Debug)]
1889enum RouteOutcome {
1890    Result(String),
1891    Refusal(String),
1892    UnboundIdentity,
1893    SchemaMismatch(String),
1894    GovernanceUnavailable,
1895    Unavailable(String),
1896}
1897
1898#[derive(Clone, Debug, Default, Deserialize, Serialize)]
1899struct SeamState {
1900    bound_holder: Option<String>,
1901    agent_binding: Option<AgentBinding>,
1902    last_seam_refusal: Option<LastSeamRefusal>,
1903}
1904
1905#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1906struct LastSeamRefusal {
1907    code: String,
1908    at_unix_secs: u64,
1909}
1910
1911fn route_governed(
1912    paths: &StatePaths,
1913    determination: &RungRecord,
1914    agent_binding: &AgentBinding,
1915    request: GovernedRequest,
1916    now: u64,
1917) -> RouteOutcome {
1918    if let Err(error) = write_seam_state(paths, governed_seam_state(paths, None, agent_binding)) {
1919        return RouteOutcome::Unavailable(format!("governed self-report update failed: {error}"));
1920    }
1921
1922    let Some(connection_file) = configured_connection_file() else {
1923        return RouteOutcome::GovernanceUnavailable;
1924    };
1925    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1926    let project_root = project_root_for(&cwd);
1927    let record_paths = paths.clone();
1928    let agent_binding = agent_binding.clone();
1929    let runtime = match tokio::runtime::Builder::new_current_thread()
1930        .enable_io()
1931        .enable_time()
1932        .build()
1933    {
1934        Ok(runtime) => runtime,
1935        Err(error) => return RouteOutcome::Unavailable(error.to_string()),
1936    };
1937    runtime
1938        .block_on(async move {
1939            let options = ConsumerOptions {
1940                call_timeout: Duration::from_secs(5),
1941                ..ConsumerOptions::default()
1942            };
1943            let consumer = SubcConsumer::connect(&connection_file, options)
1944                .await
1945                .map_err(|_| RouteOutcome::GovernanceUnavailable)?;
1946            let catalog = consumer
1947                .catalog_list()
1948                .await
1949                .map_err(|_| RouteOutcome::GovernanceUnavailable)?;
1950            let holder = route_holder(&catalog.modules);
1951            record_unexpected_gh_route_advertisers(&record_paths, &holder.unexpected_advertisers);
1952            let module_id = holder
1953                .module_id
1954                .ok_or(RouteOutcome::GovernanceUnavailable)?;
1955            let route = consumer
1956                .open_route(
1957                    RouteTarget::ManagementSurface {
1958                        module_id: module_id.clone(),
1959                    },
1960                    BindIdentity {
1961                        project_root: project_root.to_string_lossy().into_owned().into(),
1962                        harness: "aft-gh-shim".to_string(),
1963                        session: gh_session_id(&agent_binding.agent_id),
1964                    },
1965                    CallOptions::default(),
1966                )
1967                .await
1968                .map_err(|_| RouteOutcome::UnboundIdentity)?;
1969            if let Err(error) = write_seam_state(
1970                &record_paths,
1971                governed_seam_state(&record_paths, Some(module_id.clone()), &agent_binding),
1972            ) {
1973                let _ = consumer
1974                    .close_handle(&route, CloseRouteOptions::default())
1975                    .await;
1976                return Err(RouteOutcome::Unavailable(format!(
1977                    "governed self-report update failed: {error}"
1978                )));
1979            }
1980            let wire_request =
1981                governed_wire_request(determination, &agent_binding.agent_id, request);
1982            let body = serde_json::to_vec(&wire_request)
1983                .map_err(|error| RouteOutcome::SchemaMismatch(error.to_string()))?;
1984            let response = consumer
1985                .request(&route, body, CallOptions::default())
1986                .await
1987                .map_err(|error| RouteOutcome::Unavailable(error.to_string()));
1988            let _ = consumer
1989                .close_handle(&route, CloseRouteOptions::default())
1990                .await;
1991            let response = response?;
1992            let outcome = parse_governed_response(&response)?;
1993            if let RouteOutcome::Refusal(code) = &outcome {
1994                write_seam_state(
1995                    &record_paths,
1996                    SeamState {
1997                        bound_holder: Some(module_id),
1998                        agent_binding: Some(agent_binding),
1999                        last_seam_refusal: Some(LastSeamRefusal {
2000                            code: code.clone(),
2001                            at_unix_secs: now,
2002                        }),
2003                    },
2004                )
2005                .map_err(|error| {
2006                    RouteOutcome::Unavailable(format!(
2007                        "governed self-report update failed: {error}"
2008                    ))
2009                })?;
2010            }
2011            Ok(outcome)
2012        })
2013        .unwrap_or_else(|outcome| outcome)
2014}
2015
2016fn refuse_governance_unavailable(
2017    paths: &StatePaths,
2018    agent_binding: &AgentBinding,
2019    now: u64,
2020) -> i32 {
2021    let state = SeamState {
2022        bound_holder: None,
2023        agent_binding: Some(agent_binding.clone()),
2024        last_seam_refusal: Some(LastSeamRefusal {
2025            code: RefusalCode::GovernanceUnavailable.as_str().to_string(),
2026            at_unix_secs: now,
2027        }),
2028    };
2029    if let Err(error) = write_seam_state(paths, state) {
2030        return refuse(
2031            RefusalCode::SeamUnavailable,
2032            &format!("governed self-report update failed: {error}"),
2033        );
2034    }
2035    refuse(
2036        RefusalCode::GovernanceUnavailable,
2037        GOVERNANCE_UNAVAILABLE_TEXT,
2038    )
2039}
2040
2041fn governed_seam_state(
2042    paths: &StatePaths,
2043    bound_holder: Option<String>,
2044    agent_binding: &AgentBinding,
2045) -> SeamState {
2046    SeamState {
2047        bound_holder,
2048        agent_binding: Some(agent_binding.clone()),
2049        // A successful route is not a refusal event, so it must retain the last
2050        // holder refusal for operators to inspect its timestamp and code.
2051        last_seam_refusal: seam_state(paths).last_seam_refusal,
2052    }
2053}
2054
2055fn write_seam_state(paths: &StatePaths, state: SeamState) -> io::Result<()> {
2056    fs::create_dir_all(&paths.root)?;
2057    let bytes = serde_json::to_vec(&state).map_err(io::Error::other)?;
2058    let temporary = paths.seam_state.with_extension("tmp");
2059    let mut file = OpenOptions::new()
2060        .create(true)
2061        .truncate(true)
2062        .write(true)
2063        .open(&temporary)?;
2064    file.write_all(&bytes)?;
2065    // A governed result is visible only after its self-report transition is
2066    // durable enough to survive a process exit. Failure stays on the seam path
2067    // and is surfaced as a refusal instead of falling through to real `gh`.
2068    file.sync_data()?;
2069    fs::rename(temporary, &paths.seam_state)
2070}
2071
2072fn seam_state(paths: &StatePaths) -> SeamState {
2073    fs::read(&paths.seam_state)
2074        .ok()
2075        .and_then(|bytes| serde_json::from_slice(&bytes).ok())
2076        .unwrap_or_default()
2077}
2078
2079fn governed_wire_request(
2080    determination: &RungRecord,
2081    agent_id: &str,
2082    request: GovernedRequest,
2083) -> Value {
2084    json!({
2085        "operation": ROUTING_OPERATION,
2086        "gh_route_schema": 1,
2087        "action": request.action,
2088        "target": request.target,
2089        "body": request.body,
2090        "repository": request.repository,
2091        "manifest_version": request.manifest_version,
2092        "rung_as_of_unix_secs": determination.as_of_unix_secs,
2093        "metadata": {
2094            "agent_id": agent_id,
2095            "pid": std::process::id(),
2096        },
2097    })
2098}
2099
2100fn parse_governed_response(bytes: &[u8]) -> Result<RouteOutcome, RouteOutcome> {
2101    let value: Value = serde_json::from_slice(bytes).map_err(|_| {
2102        RouteOutcome::SchemaMismatch(
2103            "governance seam returned malformed or non-UTF-8 JSON".to_string(),
2104        )
2105    })?;
2106    let object = value.as_object().ok_or_else(|| {
2107        RouteOutcome::SchemaMismatch("governance seam response must be an object".to_string())
2108    })?;
2109    match object.get("outcome").and_then(Value::as_str) {
2110        Some("result") => {
2111            let schema = object
2112                .get("gh_route_schema")
2113                .and_then(Value::as_u64)
2114                .ok_or_else(|| {
2115                    RouteOutcome::SchemaMismatch(
2116                        "governance seam omitted gh_route_schema".to_string(),
2117                    )
2118                })?;
2119            if schema > 1 {
2120                return Err(RouteOutcome::SchemaMismatch(format!(
2121                    "governance seam schema {schema} is newer than supported schema 1"
2122                )));
2123            }
2124            let result = object.get("result").ok_or_else(|| {
2125                RouteOutcome::SchemaMismatch("governance seam omitted result".to_string())
2126            })?;
2127            let field_order = object
2128                .get("field_order")
2129                .and_then(Value::as_array)
2130                .ok_or_else(|| {
2131                    RouteOutcome::SchemaMismatch("governance seam omitted field_order".to_string())
2132                })?;
2133            render_governed_response(result, field_order).map(RouteOutcome::Result)
2134        }
2135        Some("refusal") => {
2136            let refusal_code = object
2137                .get("refusal_code")
2138                .and_then(Value::as_str)
2139                .ok_or_else(|| {
2140                    RouteOutcome::SchemaMismatch(
2141                        "governance refusal omitted a string refusal_code".to_string(),
2142                    )
2143                })?;
2144            Ok(RouteOutcome::Refusal(refusal_code.to_string()))
2145        }
2146        Some("unbound_identity") => Ok(RouteOutcome::UnboundIdentity),
2147        _ => Err(RouteOutcome::SchemaMismatch(
2148            "governance seam returned an unknown outcome".to_string(),
2149        )),
2150    }
2151}
2152
2153fn render_governed_response(result: &Value, field_order: &[Value]) -> Result<String, RouteOutcome> {
2154    let object = result.as_object().ok_or_else(|| {
2155        RouteOutcome::SchemaMismatch("governance result must be an object".to_string())
2156    })?;
2157    let mut output = String::new();
2158    let mut rendered = BTreeSet::new();
2159    for field in field_order {
2160        let field = field.as_str().ok_or_else(|| {
2161            RouteOutcome::SchemaMismatch("field_order must contain string fields".to_string())
2162        })?;
2163        let value = object.get(field).ok_or_else(|| {
2164            RouteOutcome::SchemaMismatch(format!(
2165                "field_order references absent result field {field}"
2166            ))
2167        })?;
2168        if !rendered.insert(field) {
2169            return Err(RouteOutcome::SchemaMismatch(format!(
2170                "field_order repeats result field {field}"
2171            )));
2172        }
2173        render_field(&mut output, field, value)?;
2174    }
2175    if rendered.len() != object.len() {
2176        return Err(RouteOutcome::SchemaMismatch(
2177            "field_order does not cover every governed result field".to_string(),
2178        ));
2179    }
2180    Ok(output)
2181}
2182
2183fn render_field(output: &mut String, field: &str, value: &Value) -> Result<(), RouteOutcome> {
2184    match value {
2185        Value::Array(values) => {
2186            output.push_str(field);
2187            output.push_str(":\n");
2188            for value in values {
2189                output.push_str("  ");
2190                output.push_str(&render_scalar(value)?);
2191                output.push('\n');
2192            }
2193        }
2194        _ => {
2195            output.push_str(field);
2196            output.push_str(": ");
2197            output.push_str(&render_scalar(value)?);
2198            output.push('\n');
2199        }
2200    }
2201    Ok(())
2202}
2203
2204fn render_scalar(value: &Value) -> Result<String, RouteOutcome> {
2205    match value {
2206        Value::String(value) => serde_json::to_string(value)
2207            .map_err(|error| RouteOutcome::SchemaMismatch(error.to_string())),
2208        Value::Number(_) | Value::Bool(_) | Value::Null => Ok(value.to_string()),
2209        Value::Object(_) | Value::Array(_) => serde_json::to_string(value)
2210            .map_err(|error| RouteOutcome::SchemaMismatch(error.to_string())),
2211    }
2212}
2213
2214fn append_bypass_audit(
2215    paths: &StatePaths,
2216    tuple: &str,
2217    repository: Option<&str>,
2218    now: u64,
2219) -> io::Result<()> {
2220    fs::create_dir_all(&paths.root)?;
2221    let mut record = serde_json::to_vec(&json!({
2222        "as_of_unix_secs": now,
2223        "tuple": tuple,
2224        "repository": repository,
2225    }))
2226    .map_err(io::Error::other)?;
2227    record.push(b'\n');
2228    let mut file = OpenOptions::new()
2229        .create(true)
2230        .append(true)
2231        .open(&paths.bypass_audit)?;
2232    file.write_all(&record)?;
2233    // An operator bypass is allowed only after the audit record is durable enough
2234    // to survive a process replacement. If this returns an error we do not exec.
2235    file.sync_data()
2236}
2237
2238#[derive(Serialize)]
2239struct SelfReport {
2240    shim_version: &'static str,
2241    gh_routing_schema_floor: u64,
2242    unexpected_gh_route_advertiser: Option<Vec<String>>,
2243    bound_holder: Option<String>,
2244    agent_binding: Option<AgentBinding>,
2245    last_seam_refusal: Option<LastSeamRefusal>,
2246    cached_manifest: CachedManifestReport,
2247    last_rung: LastRungReport,
2248    bypass_audit: Option<Vec<Value>>,
2249    bypass_audit_error: Option<String>,
2250    executing_image: Option<String>,
2251    executing_image_error: Option<String>,
2252    real_gh_resolution: Option<RealGhResolution>,
2253    real_gh_resolution_error: Option<String>,
2254}
2255
2256#[derive(Serialize)]
2257struct CachedManifestReport {
2258    version: Option<u64>,
2259    version_error: Option<String>,
2260    state: Option<&'static str>,
2261    state_error: Option<String>,
2262    diagnostics: Vec<&'static str>,
2263}
2264
2265#[derive(Serialize)]
2266struct LastRungReport {
2267    rung: Option<&'static str>,
2268    rung_error: Option<String>,
2269    as_of_unix_secs: Option<u64>,
2270    as_of_unix_secs_error: Option<String>,
2271    determination_inputs: Option<BTreeMap<String, String>>,
2272    determination_inputs_error: Option<String>,
2273}
2274
2275#[derive(Serialize)]
2276struct RealGhResolution {
2277    path: String,
2278    shim_path_positions: Vec<usize>,
2279}
2280
2281fn print_self_report(paths: &StatePaths) {
2282    // This is deliberately one JSON document, rather than status lines, so a
2283    // later forensic process can consume it with jq while every dependency is down.
2284    if let Ok(document) = render_self_report(paths) {
2285        let mut stdout = io::stdout().lock();
2286        let _ = stdout.write_all(document.as_bytes());
2287    }
2288}
2289
2290fn render_self_report(paths: &StatePaths) -> Result<String, serde_json::Error> {
2291    let report = build_self_report(paths);
2292    let mut document = serde_json::to_string(&report)?;
2293    document.push('\n');
2294    Ok(document)
2295}
2296
2297fn build_self_report(paths: &StatePaths) -> SelfReport {
2298    let image = self_report_executing_image();
2299    let (real_gh_resolution, real_gh_resolution_error) = match image.as_ref() {
2300        Ok(image) => match resolve_real_gh(image) {
2301            Some(path) => (
2302                Some(RealGhResolution {
2303                    path: path.to_string_lossy().into_owned(),
2304                    shim_path_positions: executing_image_path_positions(image),
2305                }),
2306                None,
2307            ),
2308            None => (
2309                None,
2310                Some(
2311                    "PATH contains no upstream gh after skipping the executing shim image"
2312                        .to_string(),
2313                ),
2314            ),
2315        },
2316        Err(error) => (None, Some(format!("executing image unavailable: {error}"))),
2317    };
2318    let (bypass_audit, bypass_audit_error) = read_bypass_audit(paths);
2319    let seam_state = seam_state(paths);
2320    // When the operator hard-off is set, the shim is byte-transparent passthrough
2321    // and never probes the daemon or catalog, so the status report reflects that
2322    // disabled determination instead of whatever stale rung/manifest cache exists.
2323    let disabled = gh_shim_enabled_from_config_doc(read_user_config_doc().as_deref().unwrap_or(""))
2324        == Some(false);
2325    let (cached_manifest, last_rung) = if disabled {
2326        (disabled_manifest_report(), disabled_last_rung_report())
2327    } else {
2328        (cached_manifest_report(paths), last_rung_report(paths))
2329    };
2330    SelfReport {
2331        shim_version: env!("CARGO_PKG_VERSION"),
2332        gh_routing_schema_floor: SCHEMA_FLOOR,
2333        unexpected_gh_route_advertiser: unexpected_gh_route_advertisers(paths),
2334        bound_holder: seam_state.bound_holder,
2335        agent_binding: seam_state.agent_binding,
2336        last_seam_refusal: seam_state.last_seam_refusal,
2337        cached_manifest,
2338        last_rung,
2339        bypass_audit,
2340        bypass_audit_error,
2341        executing_image: image
2342            .as_ref()
2343            .ok()
2344            .map(|path| path.to_string_lossy().into_owned()),
2345        executing_image_error: image.err(),
2346        real_gh_resolution,
2347        real_gh_resolution_error,
2348    }
2349}
2350
2351/// Self-report for the disabled-by-config state: the shim is a hard passthrough
2352/// and never consults the manifest, so the cached-manifest slot reports that
2353/// disabled state rather than a stale on-disk manifest.
2354fn disabled_manifest_report() -> CachedManifestReport {
2355    CachedManifestReport {
2356        version: None,
2357        version_error: None,
2358        state: Some("disabled"),
2359        state_error: None,
2360        diagnostics: Vec::new(),
2361    }
2362}
2363
2364/// Self-report for the disabled-by-config state: R1 passthrough with the
2365/// disabled determination input, matching what `determine_rung` would produce.
2366fn disabled_last_rung_report() -> LastRungReport {
2367    LastRungReport {
2368        rung: Some(Rung::R1.label()),
2369        rung_error: None,
2370        as_of_unix_secs: Some(unix_seconds()),
2371        as_of_unix_secs_error: None,
2372        determination_inputs: Some(BTreeMap::from([(
2373            "connection_file".to_string(),
2374            "disabled_by_config".to_string(),
2375        )])),
2376        determination_inputs_error: None,
2377    }
2378}
2379
2380fn cached_manifest_report(paths: &StatePaths) -> CachedManifestReport {
2381    cached_manifest_report_at(paths, unix_seconds())
2382}
2383
2384fn cached_manifest_report_at(paths: &StatePaths, now: u64) -> CachedManifestReport {
2385    match load_manifest(paths, now) {
2386        Ok(manifest) => CachedManifestReport {
2387            version: Some(manifest.manifest_version),
2388            version_error: None,
2389            state: Some("valid"),
2390            state_error: None,
2391            diagnostics: Vec::new(),
2392        },
2393        Err(ManifestProblem::Missing) => {
2394            let error = ManifestProblem::Missing.status_label();
2395            CachedManifestReport {
2396                version: None,
2397                version_error: Some(error.clone()),
2398                state: None,
2399                state_error: Some(error),
2400                diagnostics: vec![SelfReportDiagnostic::ManifestUnavailable.as_str()],
2401            }
2402        }
2403        Err(problem) => {
2404            // Artifact present but failing. The regressed-manifest arm is loud
2405            // in self-report: name the arm state first, then the artifact
2406            // fault that triggered it.
2407            match read_last_valid_manifest(paths) {
2408                Some(cache) if cache_within_grace(&cache, now) => CachedManifestReport {
2409                    version: Some(cache.manifest.manifest_version),
2410                    version_error: None,
2411                    state: Some("regressed_grace"),
2412                    state_error: None,
2413                    diagnostics: vec![
2414                        SelfReportDiagnostic::ManifestRegressed.as_str(),
2415                        problem.diagnostic().as_str(),
2416                    ],
2417                },
2418                Some(cache) => CachedManifestReport {
2419                    version: Some(cache.manifest.manifest_version),
2420                    version_error: None,
2421                    state: Some("regressed"),
2422                    state_error: None,
2423                    diagnostics: vec![
2424                        SelfReportDiagnostic::ManifestRegressed.as_str(),
2425                        problem.diagnostic().as_str(),
2426                    ],
2427                },
2428                None => {
2429                    let error = problem.status_label();
2430                    CachedManifestReport {
2431                        version: None,
2432                        version_error: Some(error.clone()),
2433                        state: None,
2434                        state_error: Some(error),
2435                        diagnostics: vec![problem.diagnostic().as_str()],
2436                    }
2437                }
2438            }
2439        }
2440    }
2441}
2442
2443fn last_rung_report(paths: &StatePaths) -> LastRungReport {
2444    match fs::read(&paths.rung) {
2445        Ok(bytes) => match serde_json::from_slice::<RungRecord>(&bytes) {
2446            Ok(record) => LastRungReport {
2447                rung: Some(record.rung.label()),
2448                rung_error: None,
2449                as_of_unix_secs: Some(record.as_of_unix_secs),
2450                as_of_unix_secs_error: None,
2451                determination_inputs: Some(record.inputs),
2452                determination_inputs_error: None,
2453            },
2454            Err(error) => unavailable_last_rung(format!("corrupt rung cache: {error}")),
2455        },
2456        Err(error) if error.kind() == io::ErrorKind::NotFound => {
2457            unavailable_last_rung("rung cache is unavailable".to_string())
2458        }
2459        Err(error) => unavailable_last_rung(format!("rung cache is unavailable: {error}")),
2460    }
2461}
2462
2463fn unavailable_last_rung(error: String) -> LastRungReport {
2464    LastRungReport {
2465        rung: None,
2466        rung_error: Some(error.clone()),
2467        as_of_unix_secs: None,
2468        as_of_unix_secs_error: Some(error.clone()),
2469        determination_inputs: None,
2470        determination_inputs_error: Some(error),
2471    }
2472}
2473
2474fn read_bypass_audit(paths: &StatePaths) -> (Option<Vec<Value>>, Option<String>) {
2475    let contents = match fs::read_to_string(&paths.bypass_audit) {
2476        Ok(contents) => contents,
2477        Err(error) if error.kind() == io::ErrorKind::NotFound => return (Some(Vec::new()), None),
2478        Err(error) => return (None, Some(format!("bypass audit is unavailable: {error}"))),
2479    };
2480    let mut records = Vec::new();
2481    for (line_number, line) in contents.lines().enumerate() {
2482        match serde_json::from_str(line) {
2483            Ok(record) => records.push(record),
2484            Err(error) => {
2485                return (
2486                    None,
2487                    Some(format!(
2488                        "bypass audit is corrupt at line {}: {error}",
2489                        line_number + 1
2490                    )),
2491                )
2492            }
2493        }
2494    }
2495    (Some(records), None)
2496}
2497
2498fn unexpected_gh_route_advertisers(paths: &StatePaths) -> Option<Vec<String>> {
2499    serde_json::from_slice(&fs::read(&paths.unexpected_gh_route_advertisers).ok()?)
2500        .ok()
2501        .filter(|advertisers: &Vec<String>| !advertisers.is_empty())
2502}
2503
2504fn record_unexpected_gh_route_advertisers(paths: &StatePaths, advertisers: &[String]) {
2505    if advertisers.is_empty() {
2506        return;
2507    }
2508    let mut recorded = unexpected_gh_route_advertisers(paths)
2509        .unwrap_or_default()
2510        .into_iter()
2511        .collect::<BTreeSet<_>>();
2512    recorded.extend(advertisers.iter().cloned());
2513    let Ok(bytes) = serde_json::to_vec(&recorded.into_iter().collect::<Vec<_>>()) else {
2514        return;
2515    };
2516    let _ = fs::create_dir_all(&paths.root);
2517    let temporary = paths.unexpected_gh_route_advertisers.with_extension("tmp");
2518    if fs::write(&temporary, bytes).is_ok() {
2519        let _ = fs::rename(temporary, &paths.unexpected_gh_route_advertisers);
2520    }
2521}
2522
2523fn self_report_executing_image() -> Result<PathBuf, String> {
2524    let path = std::env::current_exe().map_err(|error| error.to_string())?;
2525    Ok(path.canonicalize().unwrap_or(path))
2526}
2527
2528fn executing_image() -> PathBuf {
2529    std::env::current_exe()
2530        .ok()
2531        .and_then(|path| path.canonicalize().ok().or(Some(path)))
2532        .unwrap_or_else(|| PathBuf::from("unavailable"))
2533}
2534
2535fn executing_image_path_positions(image: &Path) -> Vec<usize> {
2536    let path = std::env::var_os("PATH").unwrap_or_default();
2537    std::env::split_paths(&path)
2538        .enumerate()
2539        .filter_map(|(index, directory)| same_image(&directory.join("gh"), image).then_some(index))
2540        .collect()
2541}
2542
2543fn delegate(args: &[OsString]) -> i32 {
2544    let image = executing_image();
2545    let Some(real_gh) = resolve_real_gh(&image) else {
2546        return refuse(
2547            RefusalCode::NoRealGh,
2548            "PATH contains no upstream gh after skipping the executing shim image",
2549        );
2550    };
2551    exec_real_gh(real_gh, args)
2552}
2553
2554fn resolve_real_gh(executing_image: &Path) -> Option<PathBuf> {
2555    let path = std::env::var_os("PATH")?;
2556    std::env::split_paths(&path).find_map(|directory| {
2557        let candidate = directory.join("gh");
2558        (is_executable_file(&candidate) && !same_image(&candidate, executing_image))
2559            .then_some(candidate)
2560    })
2561}
2562
2563fn is_executable_file(path: &Path) -> bool {
2564    if !path.is_file() {
2565        return false;
2566    }
2567    #[cfg(unix)]
2568    {
2569        use std::os::unix::fs::PermissionsExt;
2570        return fs::metadata(path).is_ok_and(|metadata| metadata.permissions().mode() & 0o111 != 0);
2571    }
2572    #[cfg(not(unix))]
2573    true
2574}
2575
2576fn same_image(left: &Path, right: &Path) -> bool {
2577    let left_canonical = left.canonicalize().ok();
2578    let right_canonical = right.canonicalize().ok();
2579    if left_canonical.is_some() && left_canonical == right_canonical {
2580        return true;
2581    }
2582    #[cfg(unix)]
2583    {
2584        use std::os::unix::fs::MetadataExt;
2585        if let (Ok(left), Ok(right)) = (fs::metadata(left), fs::metadata(right)) {
2586            return left.dev() == right.dev() && left.ino() == right.ino();
2587        }
2588    }
2589    false
2590}
2591
2592#[cfg(unix)]
2593fn exec_real_gh(real_gh: PathBuf, args: &[OsString]) -> i32 {
2594    use std::os::unix::process::CommandExt;
2595    let error = Command::new(real_gh).args(args).exec();
2596    // `exec` returns only if a candidate disappeared after the PATH scan. This
2597    // remains a shim refusal, rather than silently treating a failed exec as a
2598    // successful no-op.
2599    refuse(
2600        RefusalCode::NoRealGh,
2601        &format!("unable to exec upstream gh: {error}"),
2602    )
2603}
2604
2605#[cfg(not(unix))]
2606fn exec_real_gh(real_gh: PathBuf, args: &[OsString]) -> i32 {
2607    match Command::new(real_gh).args(args).status() {
2608        Ok(status) => status.code().unwrap_or(1),
2609        Err(error) => refuse(
2610            RefusalCode::NoRealGh,
2611            &format!("unable to exec upstream gh: {error}"),
2612        ),
2613    }
2614}
2615
2616fn refuse(code: RefusalCode, text: &str) -> i32 {
2617    let text = text.replace(['\n', '\r'], " ");
2618    eprintln!("gh-shim: {}: {text}", code.as_str());
2619    REFUSAL_EXIT_STATUS
2620}
2621
2622fn current_platform() -> &'static str {
2623    if cfg!(target_os = "macos") {
2624        "macos"
2625    } else if cfg!(target_os = "linux") {
2626        "linux"
2627    } else {
2628        "unsupported"
2629    }
2630}
2631
2632fn unix_seconds() -> u64 {
2633    SystemTime::now()
2634        .duration_since(UNIX_EPOCH)
2635        .unwrap_or_default()
2636        .as_secs()
2637}
2638
2639#[cfg(test)]
2640mod tests {
2641    use super::*;
2642    use ring::signature::{Ed25519KeyPair, KeyPair};
2643
2644    const TEST_SEED: [u8; 32] = [
2645        0x9d, 0x61, 0xb1, 0x9d, 0xef, 0xfd, 0x5a, 0x60, 0xba, 0x84, 0x4a, 0xf4, 0x92, 0xec, 0x2c,
2646        0xc4, 0x44, 0x49, 0xc5, 0x69, 0x7b, 0x32, 0x69, 0x19, 0x70, 0x3b, 0xac, 0x03, 0x1c, 0xae,
2647        0x7f, 0x60,
2648    ];
2649    /// Seed for the standby-slot fixture key. Test-only material: the compiled
2650    /// dev trust set keeps exactly one key, and this second keypair exists so
2651    /// the two-slot trust-set mechanics (standby accepted, unknown refused)
2652    /// can be exercised against an injected set.
2653    const STANDBY_TEST_SEED: [u8; 32] = *b"gh-shim-standby-fixture-seed-001";
2654    const DEV_STANDBY_MANIFEST_KEY_ID: &str = "gh-routing-dev-standby-key-v1";
2655    /// Issue time baked into the canonical manifest fixture; every test clock
2656    /// is expressed relative to it because freshness is keyed off the signed
2657    /// `issued_at_unix_secs`.
2658    const FIXTURE_ISSUED_AT: u64 = 1_787_184_000;
2659    const TEST_NOW: u64 = FIXTURE_ISSUED_AT + 60;
2660    const FIXTURE_ACCEPTED_SEAM_REFUSAL_CODES: &[&str] = &[
2661        "identity_mismatch",
2662        "unmapped_operation",
2663        "custody_unavailable",
2664        "schema_unsupported",
2665        "rate_limited",
2666    ];
2667
2668    fn fixture_manifest() -> Manifest {
2669        serde_json::from_str(include_str!(
2670            "../tests/fixtures/gh_shim/initial-manifest-v1.json"
2671        ))
2672        .expect("initial manifest fixture")
2673    }
2674
2675    fn signed_with(
2676        manifest: &Manifest,
2677        fetched_at_unix_secs: u64,
2678        seed: &[u8; 32],
2679        key_id: &str,
2680    ) -> SignedManifest {
2681        let key = Ed25519KeyPair::from_seed_unchecked(seed).expect("test key");
2682        let bytes = serde_json::to_vec(manifest).expect("manifest bytes");
2683        SignedManifest {
2684            artifact_id: MANIFEST_ARTIFACT_ID.to_string(),
2685            envelope_version: ENVELOPE_VERSION,
2686            key_id: key_id.to_string(),
2687            fetched_at_unix_secs,
2688            signature: base64::engine::general_purpose::STANDARD.encode(key.sign(&bytes).as_ref()),
2689            manifest_bytes: String::from_utf8(bytes).expect("manifest bytes are UTF-8"),
2690        }
2691    }
2692
2693    fn signed(manifest: &Manifest, fetched_at_unix_secs: u64) -> SignedManifest {
2694        let key = Ed25519KeyPair::from_seed_unchecked(&TEST_SEED).expect("test key");
2695        assert_eq!(key.public_key().as_ref(), DEV_MANIFEST_PUBLIC_KEY);
2696        signed_with(
2697            manifest,
2698            fetched_at_unix_secs,
2699            &TEST_SEED,
2700            DEV_MANIFEST_KEY_ID,
2701        )
2702    }
2703
2704    fn write_signed_manifest(paths: &StatePaths, manifest: Manifest, now: u64) {
2705        fs::create_dir_all(&paths.root).expect("state root");
2706        fs::write(
2707            &paths.manifest,
2708            serde_json::to_vec(&signed(&manifest, now)).expect("signed manifest"),
2709        )
2710        .expect("manifest cache");
2711    }
2712
2713    fn write_envelope_fixture(paths: &StatePaths, envelope_json: &str) {
2714        fs::create_dir_all(&paths.root).expect("state root");
2715        fs::write(&paths.manifest, envelope_json.as_bytes()).expect("manifest cache");
2716    }
2717
2718    #[test]
2719    fn shim_dispatch_precedes_global_argument_scans_for_both_forms() {
2720        assert!(is_shim_invocation(
2721            OsStr::new("gh"),
2722            &[OsString::from("--version")]
2723        ));
2724        assert!(is_shim_invocation(
2725            OsStr::new("aft"),
2726            &[OsString::from("gh-shim"), OsString::from("--version")]
2727        ));
2728        assert!(!is_shim_invocation(
2729            OsStr::new("aft"),
2730            &[OsString::from("--version")]
2731        ));
2732    }
2733
2734    #[test]
2735    fn reserved_self_report_tokens_are_exactly_the_two_first_arguments() {
2736        assert_eq!(RESERVED_SELF_REPORT, ["--status", "--shim-version"]);
2737        assert!(is_reserved_self_report(&[OsString::from("--status")]));
2738        assert!(is_reserved_self_report(&[OsString::from("--shim-version")]));
2739        assert!(!is_reserved_self_report(&[OsString::from("status")]));
2740        assert!(!is_reserved_self_report(&[
2741            OsString::from("issue"),
2742            OsString::from("--status")
2743        ]));
2744    }
2745
2746    #[test]
2747    fn status_serializes_one_json_document_with_the_exact_top_level_schema() {
2748        let directory = tempfile::tempdir().unwrap();
2749        let paths = StatePaths::from_root(directory.path().to_path_buf());
2750        let document = render_self_report(&paths).expect("self report serialization");
2751        assert!(document.ends_with('\n'));
2752        let value: Value = serde_json::from_str(&document).expect("self report JSON");
2753        let keys = value
2754            .as_object()
2755            .expect("self report object")
2756            .keys()
2757            .cloned()
2758            .collect::<Vec<_>>();
2759        assert_eq!(
2760            keys,
2761            vec![
2762                "shim_version",
2763                "gh_routing_schema_floor",
2764                "unexpected_gh_route_advertiser",
2765                "bound_holder",
2766                "agent_binding",
2767                "last_seam_refusal",
2768                "cached_manifest",
2769                "last_rung",
2770                "bypass_audit",
2771                "bypass_audit_error",
2772                "executing_image",
2773                "executing_image_error",
2774                "real_gh_resolution",
2775                "real_gh_resolution_error",
2776            ]
2777        );
2778    }
2779
2780    #[test]
2781    fn route_holder_is_pinned_and_records_other_advertisers() {
2782        let holder = select_route_holder([
2783            "other-module".to_string(),
2784            ROUTING_HOLDER_MODULE_ID.to_string(),
2785            "another-module".to_string(),
2786        ]);
2787        assert_eq!(holder.module_id.as_deref(), Some(ROUTING_HOLDER_MODULE_ID));
2788        assert_eq!(
2789            holder.unexpected_advertisers,
2790            vec!["another-module", "other-module"]
2791        );
2792
2793        let holder = select_route_holder(["other-module".to_string()]);
2794        assert_eq!(holder.module_id, None);
2795        assert_eq!(holder.unexpected_advertisers, vec!["other-module"]);
2796    }
2797
2798    #[test]
2799    fn unexpected_route_advertisers_are_persisted_for_self_report() {
2800        let directory = tempfile::tempdir().unwrap();
2801        let paths = StatePaths::from_root(directory.path().to_path_buf());
2802        record_unexpected_gh_route_advertisers(&paths, &["other-module".to_string()]);
2803        record_unexpected_gh_route_advertisers(&paths, &["another-module".to_string()]);
2804
2805        assert_eq!(
2806            unexpected_gh_route_advertisers(&paths),
2807            Some(vec![
2808                "another-module".to_string(),
2809                "other-module".to_string(),
2810            ])
2811        );
2812        assert_eq!(
2813            build_self_report(&paths).unexpected_gh_route_advertiser,
2814            Some(vec![
2815                "another-module".to_string(),
2816                "other-module".to_string(),
2817            ])
2818        );
2819    }
2820
2821    #[test]
2822    fn disabled_by_config_short_circuits_to_r1_without_connection_file_read() {
2823        let directory = tempfile::tempdir().unwrap();
2824        let paths = StatePaths::from_root(directory.path().to_path_buf());
2825        // A disabled shim must resolve R1 with the named reason even when a
2826        // connection file is configured, and must not touch the daemon/catalog.
2827        let doc = serde_json::json!({
2828            "gh_shim": { "enabled": false },
2829            "subc": { "connection_file": "/nonexistent/connection.json" }
2830        })
2831        .to_string();
2832        let record = determine_rung_from_doc(
2833            &paths,
2834            Path::new("/cwd"),
2835            123,
2836            std::time::Instant::now() + DISCOVERY_BUDGET,
2837            Some(&doc),
2838        );
2839        assert_eq!(record.rung, Rung::R1);
2840        assert_eq!(
2841            record.inputs.get("connection_file").map(String::as_str),
2842            Some("disabled_by_config")
2843        );
2844        // R1 is never written durably.
2845        assert!(!paths.root.join("rung-cache.json").exists());
2846    }
2847
2848    #[test]
2849    fn configured_but_unreachable_connection_file_is_distinct_from_absence() {
2850        let directory = tempfile::tempdir().unwrap();
2851        let paths = StatePaths::from_root(directory.path().to_path_buf());
2852        let connection_file = directory.path().join("missing-connection.json");
2853        let doc = serde_json::json!({
2854            "subc": { "connection_file": connection_file }
2855        })
2856        .to_string();
2857        let record = determine_rung_from_doc(
2858            &paths,
2859            Path::new("/cwd"),
2860            1,
2861            std::time::Instant::now() + DISCOVERY_BUDGET,
2862            Some(&doc),
2863        );
2864        assert_eq!(record.rung, Rung::R1);
2865        assert_eq!(
2866            record.inputs.get("connection_file").map(String::as_str),
2867            Some("unreachable")
2868        );
2869    }
2870
2871    #[test]
2872    fn enabled_default_keeps_structural_rungs() {
2873        let directory = tempfile::tempdir().unwrap();
2874        let paths = StatePaths::from_root(directory.path().to_path_buf());
2875        // No gh_shim key (default true) and no connection file → structural R1.
2876        let record = determine_rung_from_doc(
2877            &paths,
2878            Path::new("/cwd"),
2879            1,
2880            std::time::Instant::now() + DISCOVERY_BUDGET,
2881            Some("{}"),
2882        );
2883        assert_eq!(record.rung, Rung::R1);
2884        assert_eq!(
2885            record.inputs.get("connection_file").map(String::as_str),
2886            Some("absent_or_unparseable")
2887        );
2888    }
2889
2890    #[test]
2891    fn xdg_connection_config_precedes_home_config() {
2892        let directory = tempfile::tempdir().unwrap();
2893        let xdg = directory.path().join("xdg");
2894        let home = directory.path().join("home");
2895        let xdg_connection = directory.path().join("xdg-connection.json");
2896        let home_connection = directory.path().join("home-connection.json");
2897        fs::write(&xdg_connection, "{}").unwrap();
2898        fs::write(&home_connection, "{}").unwrap();
2899        let xdg_config = xdg.join("cortexkit/aft.jsonc");
2900        let home_config = home.join(".config/cortexkit/aft.jsonc");
2901        fs::create_dir_all(xdg_config.parent().unwrap()).unwrap();
2902        fs::create_dir_all(home_config.parent().unwrap()).unwrap();
2903        // Serialize through serde_json so Windows backslash paths are
2904        // JSON-escaped; a raw format! of Path::display() writes `C:\Users\...`
2905        // into the string, which is invalid JSON and parses to None.
2906        fs::write(
2907            &xdg_config,
2908            serde_json::json!({"subc": {"connection_file": xdg_connection}}).to_string(),
2909        )
2910        .unwrap();
2911        fs::write(
2912            &home_config,
2913            serde_json::json!({"subc": {"connection_file": home_connection}}).to_string(),
2914        )
2915        .unwrap();
2916
2917        assert_eq!(
2918            configured_connection_file_from(Some(xdg.as_os_str()), Some(home.as_os_str())),
2919            Some(xdg_connection)
2920        );
2921    }
2922
2923    #[test]
2924    fn initial_manifest_is_complete_and_valid() {
2925        fixture_manifest()
2926            .validate()
2927            .expect("valid initial manifest");
2928    }
2929
2930    #[test]
2931    fn manifest_rejects_duplicate_tiers_and_empty_api_rationales() {
2932        let mut duplicate = fixture_manifest();
2933        duplicate
2934            .tiers
2935            .get_mut(&Tier::Admin)
2936            .unwrap()
2937            .push(TupleDecl::Details {
2938                tuple: "issue comment".to_string(),
2939                platform: vec!["macos".to_string()],
2940                api_match: None,
2941                rationale: None,
2942            });
2943        assert!(duplicate.validate().unwrap_err().contains("both"));
2944
2945        let mut empty_api = fixture_manifest();
2946        empty_api
2947            .tiers
2948            .get_mut(&Tier::Admin)
2949            .unwrap()
2950            .push(TupleDecl::Details {
2951                tuple: "api patch close".to_string(),
2952                platform: vec!["macos".to_string()],
2953                api_match: Some(String::new()),
2954                rationale: None,
2955            });
2956        assert!(empty_api.validate().unwrap_err().contains("rationale"));
2957
2958        let mut malformed_binding = fixture_manifest();
2959        malformed_binding.bindings.insert(
2960            "https://github.com/cortexkit/aft.git".to_string(),
2961            "alfonso-aft".to_string(),
2962        );
2963        assert!(malformed_binding
2964            .validate()
2965            .unwrap_err()
2966            .contains("canonical owner/name"));
2967    }
2968
2969    #[test]
2970    fn binding_keys_and_governed_session_identity_are_stable() {
2971        assert_eq!(
2972            canonical_repository_key("https://github.com/CortexKit/aft.git"),
2973            Some("cortexkit/aft".to_string())
2974        );
2975        assert_eq!(
2976            canonical_repository_key("git@github.com:cortexkit/aft.git"),
2977            Some("cortexkit/aft".to_string())
2978        );
2979        assert_eq!(gh_session_id("alfonso-aft"), "gh-shim:alfonso-aft");
2980
2981        let request = GovernedRequest {
2982            action: "issue comment".to_string(),
2983            target: Map::new(),
2984            body: Map::new(),
2985            repository: Some("cortexkit/aft".to_string()),
2986            manifest_version: 1,
2987        };
2988        let wire = governed_wire_request(&RungRecord::r3(7, 1), "alfonso-aft", request);
2989        assert_eq!(wire["metadata"]["agent_id"], "alfonso-aft");
2990        assert_eq!(wire["metadata"]["pid"], std::process::id());
2991    }
2992
2993    #[test]
2994    fn manifest_rejects_repo_sections_that_add_or_lower_a_tuple() {
2995        let mut manifest = fixture_manifest();
2996        manifest.repository_sections.insert(
2997            "owner/repo".to_string(),
2998            RepositorySection {
2999                tiers: BTreeMap::from([(
3000                    Tier::Mechanical,
3001                    vec![TupleDecl::Details {
3002                        tuple: "issue comment".to_string(),
3003                        platform: vec!["macos".to_string()],
3004                        api_match: None,
3005                        rationale: None,
3006                    }],
3007                )]),
3008                removed_tuples: Vec::new(),
3009            },
3010        );
3011        assert!(manifest.validate().unwrap_err().contains("lowers"));
3012
3013        manifest.repository_sections.insert(
3014            "owner/repo".to_string(),
3015            RepositorySection {
3016                tiers: BTreeMap::from([(
3017                    Tier::Admin,
3018                    vec![TupleDecl::Details {
3019                        tuple: "workflow dispatch".to_string(),
3020                        platform: vec!["macos".to_string()],
3021                        api_match: None,
3022                        rationale: None,
3023                    }],
3024                )]),
3025                removed_tuples: Vec::new(),
3026            },
3027        );
3028        assert!(manifest.validate().unwrap_err().contains("adds"));
3029    }
3030
3031    #[test]
3032    fn signed_cache_rejects_tampering_staleness_and_old_schema_floor() {
3033        let directory = tempfile::tempdir().unwrap();
3034        let paths = StatePaths::from_root(directory.path().to_path_buf());
3035        let now = TEST_NOW;
3036        write_signed_manifest(&paths, fixture_manifest(), now);
3037        assert_eq!(load_manifest(&paths, now).unwrap().manifest_version, 1);
3038
3039        // Tamper with the signed manifest bytes inside the envelope: the
3040        // signature verifies the distributed bytes, so any edit is fatal.
3041        let mut value: Value = serde_json::from_slice(&fs::read(&paths.manifest).unwrap()).unwrap();
3042        let tampered =
3043            value["manifest_bytes"]
3044                .as_str()
3045                .unwrap()
3046                .replacen("issue view", "issue View", 1);
3047        value["manifest_bytes"] = Value::String(tampered);
3048        fs::write(&paths.manifest, serde_json::to_vec(&value).unwrap()).unwrap();
3049        assert!(matches!(
3050            load_manifest(&paths, now),
3051            Err(ManifestProblem::Invalid(_))
3052        ));
3053        // The accepted copy is still inside its grace window, so the self
3054        // report names the regressed arm first and the artifact fault second.
3055        assert_eq!(
3056            cached_manifest_report(&paths).diagnostics,
3057            vec![
3058                SelfReportDiagnostic::ManifestRegressed.as_str(),
3059                SelfReportDiagnostic::ManifestInvalid.as_str(),
3060            ]
3061        );
3062
3063        let mut below_floor = fixture_manifest();
3064        below_floor.schema_floor = 0;
3065        write_signed_manifest(&paths, below_floor, now);
3066        assert!(matches!(
3067            load_manifest(&paths, now),
3068            Err(ManifestProblem::BelowFloor { manifest_floor: 0 })
3069        ));
3070
3071        // Freshness keys off the signed issued_at, not the envelope fetch time.
3072        let mut stale = fixture_manifest();
3073        stale.issued_at_unix_secs =
3074            now - MANIFEST_TTL.as_secs() - MANIFEST_STALE_GRACE.as_secs() - 1;
3075        write_signed_manifest(&paths, stale, now);
3076        assert!(matches!(
3077            load_manifest(&paths, now),
3078            Err(ManifestProblem::Stale { .. })
3079        ));
3080
3081        // An issue time inside the small future skew stays acceptable; beyond
3082        // it the manifest is invalid.
3083        let mut skewed = fixture_manifest();
3084        skewed.issued_at_unix_secs = now + ISSUED_AT_FUTURE_SKEW.as_secs();
3085        write_signed_manifest(&paths, skewed, now);
3086        assert!(load_manifest(&paths, now).is_ok());
3087
3088        let mut future = fixture_manifest();
3089        future.issued_at_unix_secs = now + ISSUED_AT_FUTURE_SKEW.as_secs() + 1;
3090        write_signed_manifest(&paths, future, now);
3091        assert!(matches!(
3092            load_manifest(&paths, now),
3093            Err(ManifestProblem::Invalid(_))
3094        ));
3095    }
3096
3097    #[test]
3098    fn classification_is_allowlist_driven_without_a_write_heuristic() {
3099        let manifest = fixture_manifest();
3100        assert!(matches!(
3101            classify(
3102                &[OsString::from("issue"), OsString::from("view")],
3103                &manifest,
3104                "macos"
3105            ),
3106            Classification::Mechanical
3107        ));
3108        assert!(matches!(
3109            classify(
3110                &[OsString::from("api"), OsString::from("/repos/a/b")],
3111                &manifest,
3112                "macos"
3113            ),
3114            Classification::Mechanical
3115        ));
3116        assert!(matches!(
3117            classify(
3118                &[
3119                    OsString::from("api"),
3120                    OsString::from("--method=POST"),
3121                    OsString::from("/repos/a/b")
3122                ],
3123                &manifest,
3124                "macos"
3125            ),
3126            Classification::Unclassified
3127        ));
3128        assert!(matches!(
3129            classify(
3130                &[
3131                    OsString::from("api"),
3132                    OsString::from("--method"),
3133                    OsString::from("POST"),
3134                    OsString::from("/repos/a/b")
3135                ],
3136                &manifest,
3137                "macos"
3138            ),
3139            Classification::Unclassified
3140        ));
3141        assert!(matches!(
3142            classify(
3143                &[OsString::from("alias"), OsString::from("set")],
3144                &manifest,
3145                "macos"
3146            ),
3147            Classification::Unclassified
3148        ));
3149        assert!(matches!(
3150            classify(
3151                &[
3152                    OsString::from("alias"),
3153                    OsString::from("set"),
3154                    OsString::from("--write")
3155                ],
3156                &manifest,
3157                "macos"
3158            ),
3159            Classification::Unclassified
3160        ));
3161    }
3162
3163    #[test]
3164    fn governed_canonicalization_normalizes_flags_and_explicit_repo_wins() {
3165        let manifest = fixture_manifest();
3166        let canonical = manifest.canonicalization["issue comment"].clone();
3167        let request = canonicalize_governed(
3168            &[
3169                OsString::from("--repo=owner/explicit"),
3170                OsString::from("issue"),
3171                OsString::from("comment"),
3172                OsString::from("42"),
3173                OsString::from("--body"),
3174                OsString::from("hello"),
3175            ],
3176            "issue comment",
3177            &canonical,
3178            1,
3179        )
3180        .unwrap();
3181        assert_eq!(request.repository.as_deref(), Some("owner/explicit"));
3182        assert_eq!(request.target["number"], "42");
3183        assert_eq!(request.body["body"], "hello");
3184    }
3185
3186    #[test]
3187    fn governed_renderer_is_deterministic_for_scalars_arrays_and_escapes() {
3188        let result = json!({"message":"snowman ☃\n", "items":["a", 2], "ok":true});
3189        let order = vec![json!("ok"), json!("message"), json!("items")];
3190        assert_eq!(
3191            render_governed_response(&result, &order).unwrap(),
3192            "ok: true\nmessage: \"snowman ☃\\n\"\nitems:\n  \"a\"\n  2\n"
3193        );
3194        assert!(matches!(
3195            render_governed_response(&json!("scalar"), &order),
3196            Err(RouteOutcome::SchemaMismatch(_))
3197        ));
3198    }
3199
3200    #[test]
3201    fn lower_rungs_are_cached_durably_but_r1_is_not_written() {
3202        let directory = tempfile::tempdir().unwrap();
3203        let paths = StatePaths::from_root(directory.path().to_path_buf());
3204        let record = RungRecord::r2(123, "daemon_unreachable", None);
3205        write_rung_record_silently(&paths, &record);
3206        assert_eq!(load_rung_record(&paths).unwrap().rung, Rung::R2);
3207        assert!(!paths.root.join("r1-cache.json").exists());
3208    }
3209
3210    #[test]
3211    fn governance_unavailable_is_per_determination_and_does_not_latch_r3() {
3212        assert!(RungRecord::r1(1, "unreachable").governance_infrastructure_unavailable());
3213        assert!(
3214            RungRecord::r1(1, "discovery_budget_exhausted").governance_infrastructure_unavailable()
3215        );
3216        assert!(
3217            RungRecord::r2(1, "daemon_unreachable", None).governance_infrastructure_unavailable()
3218        );
3219        assert!(RungRecord::r2(1, "catalog_gh_route_absent", None)
3220            .governance_infrastructure_unavailable());
3221        assert!(!RungRecord::r2(1, "agent_credentials_present", Some(1))
3222            .governance_infrastructure_unavailable());
3223        assert!(!RungRecord::r3(2, 1).governance_infrastructure_unavailable());
3224    }
3225
3226    #[cfg(unix)]
3227    #[test]
3228    fn resolved_image_identity_skips_a_shim_reached_through_a_symlinked_parent() {
3229        use std::os::unix::fs::symlink;
3230
3231        let directory = tempfile::tempdir().unwrap();
3232        let image = directory.path().join("aft");
3233        fs::write(&image, b"shim image").unwrap();
3234        let bin = directory.path().join("bin");
3235        fs::create_dir(&bin).unwrap();
3236        symlink(&image, bin.join("gh")).unwrap();
3237        let linked_parent = directory.path().join("linked-bin");
3238        symlink(&bin, &linked_parent).unwrap();
3239
3240        assert!(same_image(&linked_parent.join("gh"), &image));
3241    }
3242
3243    #[test]
3244    fn bypass_audit_is_visible_to_a_later_self_report_reader() {
3245        let directory = tempfile::tempdir().unwrap();
3246        let paths = StatePaths::from_root(directory.path().to_path_buf());
3247        append_bypass_audit(&paths, "issue close", Some("owner/repo"), 99).unwrap();
3248        let (records, error) = read_bypass_audit(&paths);
3249        assert!(error.is_none());
3250        let records = records.unwrap();
3251        assert_eq!(records.len(), 1);
3252        assert_eq!(records[0]["tuple"], "issue close");
3253    }
3254
3255    #[test]
3256    fn refusal_and_self_report_codes_are_separate_closed_sets() {
3257        assert_eq!(RefusalCode::ALL.len(), 12);
3258        assert!(RefusalCode::ALL
3259            .iter()
3260            .all(|code| code.as_str().starts_with("gh_shim_")));
3261        assert_eq!(
3262            RefusalCode::GovernanceUnavailable.as_str(),
3263            "gh_shim_governance_unavailable"
3264        );
3265        assert_eq!(
3266            GOVERNANCE_UNAVAILABLE_TEXT,
3267            "the governance daemon is unreachable and this repository's actions are identity-governed; retry after the daemon returns"
3268        );
3269        assert_eq!(SelfReportDiagnostic::ALL.len(), 7);
3270        assert!(SelfReportDiagnostic::ALL
3271            .iter()
3272            .all(|code| code.as_str().starts_with("gh_shim_status_")));
3273        assert_eq!(REFUSAL_EXIT_STATUS, 86);
3274    }
3275
3276    #[test]
3277    fn v1_write_classification_accepts_only_the_reviewed_tuple_sets() {
3278        let manifest = fixture_manifest();
3279        for tuple in V1_GOVERNED_TUPLES {
3280            let args = tuple
3281                .split_whitespace()
3282                .map(OsString::from)
3283                .collect::<Vec<_>>();
3284            assert!(matches!(
3285                classify(&args, &manifest, "macos"),
3286                Classification::Governed { .. }
3287            ));
3288        }
3289        for tuple in V1_ADMIN_TUPLES {
3290            let args = tuple
3291                .split_whitespace()
3292                .map(OsString::from)
3293                .collect::<Vec<_>>();
3294            assert!(matches!(
3295                classify(&args, &manifest, "macos"),
3296                Classification::Admin { .. }
3297            ));
3298        }
3299        for args in [
3300            ["release", "publish"].as_slice(),
3301            ["issue", "create"].as_slice(),
3302            ["pr", "reopen"].as_slice(),
3303        ] {
3304            let args = args.iter().map(OsString::from).collect::<Vec<_>>();
3305            assert!(matches!(
3306                classify(&args, &manifest, "macos"),
3307                Classification::Unclassified
3308            ));
3309        }
3310    }
3311
3312    #[test]
3313    fn field_bearing_api_forms_remain_unclassified_without_an_audited_parser() {
3314        let manifest = fixture_manifest();
3315        for field_flag in [
3316            "--field=name=value",
3317            "--raw-field=name=value",
3318            "--input=body.json",
3319            "-fname=value",
3320            "-Fname=value",
3321        ] {
3322            let args = vec![
3323                OsString::from("api"),
3324                OsString::from("/repos/owner/repo"),
3325                OsString::from(field_flag),
3326            ];
3327            assert!(matches!(
3328                classify(&args, &manifest, "macos"),
3329                Classification::Unclassified
3330            ));
3331        }
3332    }
3333
3334    #[test]
3335    fn holder_refusals_preserve_any_string_code_and_reject_non_strings() {
3336        for code in FIXTURE_ACCEPTED_SEAM_REFUSAL_CODES {
3337            let response = json!({"outcome": "refusal", "refusal_code": code});
3338            let outcome = parse_governed_response(&serde_json::to_vec(&response).unwrap()).unwrap();
3339            assert!(matches!(outcome, RouteOutcome::Refusal(ref actual) if actual == code));
3340            assert_eq!(RefusalCode::SeamRefusal.as_str(), "gh_shim_seam_refusal");
3341            assert_eq!(
3342                seam_refusal_text(code),
3343                format!("governance seam refused the action: {code}")
3344            );
3345            assert_eq!(REFUSAL_EXIT_STATUS, 86);
3346        }
3347        let unknown = "quota_exhausted_v2";
3348        let response = json!({"outcome": "refusal", "refusal_code": unknown});
3349        let outcome = parse_governed_response(&serde_json::to_vec(&response).unwrap()).unwrap();
3350        assert!(matches!(outcome, RouteOutcome::Refusal(ref actual) if actual == unknown));
3351        assert_eq!(RefusalCode::SeamRefusal.as_str(), "gh_shim_seam_refusal");
3352        assert_eq!(
3353            seam_refusal_text(unknown),
3354            "governance seam refused the action: quota_exhausted_v2"
3355        );
3356        assert_eq!(REFUSAL_EXIT_STATUS, 86);
3357
3358        for response in [
3359            json!({"outcome": "refusal", "refusal_code": 7}),
3360            json!({"outcome": "refusal", "refusal_code": null}),
3361            json!({"outcome": "refusal"}),
3362        ] {
3363            assert!(matches!(
3364                parse_governed_response(&serde_json::to_vec(&response).unwrap()),
3365                Err(RouteOutcome::SchemaMismatch(_))
3366            ));
3367        }
3368    }
3369
3370    #[test]
3371    fn governed_self_report_transitions_are_durable_and_mechanical_classification_preserves_them() {
3372        let directory = tempfile::tempdir().unwrap();
3373        let paths = StatePaths::from_root(directory.path().to_path_buf());
3374        let binding = AgentBinding {
3375            repo: "owner/repo".to_string(),
3376            agent_id: "agent-7".to_string(),
3377        };
3378        write_seam_state(
3379            &paths,
3380            SeamState {
3381                bound_holder: None,
3382                agent_binding: Some(binding.clone()),
3383                last_seam_refusal: None,
3384            },
3385        )
3386        .unwrap();
3387        let report = build_self_report(&paths);
3388        assert_eq!(report.bound_holder, None);
3389        assert_eq!(report.agent_binding, Some(binding.clone()));
3390        assert_eq!(report.last_seam_refusal, None);
3391
3392        write_seam_state(
3393            &paths,
3394            SeamState {
3395                bound_holder: Some(ROUTING_HOLDER_MODULE_ID.to_string()),
3396                agent_binding: Some(binding.clone()),
3397                last_seam_refusal: Some(LastSeamRefusal {
3398                    code: "rate_limited".to_string(),
3399                    at_unix_secs: 77,
3400                }),
3401            },
3402        )
3403        .unwrap();
3404        let report = build_self_report(&paths);
3405        assert_eq!(
3406            report.bound_holder.as_deref(),
3407            Some(ROUTING_HOLDER_MODULE_ID)
3408        );
3409        assert_eq!(report.agent_binding, Some(binding.clone()));
3410        assert_eq!(
3411            report
3412                .last_seam_refusal
3413                .as_ref()
3414                .map(|refusal| refusal.code.as_str()),
3415            Some("rate_limited")
3416        );
3417
3418        write_seam_state(
3419            &paths,
3420            governed_seam_state(&paths, Some(ROUTING_HOLDER_MODULE_ID.to_string()), &binding),
3421        )
3422        .unwrap();
3423        assert_eq!(
3424            seam_state(&paths)
3425                .last_seam_refusal
3426                .as_ref()
3427                .map(|refusal| refusal.code.as_str()),
3428            Some("rate_limited")
3429        );
3430
3431        let mechanical = [OsString::from("issue"), OsString::from("view")];
3432        assert!(matches!(
3433            classify(&mechanical, &fixture_manifest(), "macos"),
3434            Classification::Mechanical
3435        ));
3436        assert_eq!(
3437            seam_state(&paths)
3438                .last_seam_refusal
3439                .as_ref()
3440                .map(|refusal| refusal.at_unix_secs),
3441            Some(77)
3442        );
3443    }
3444
3445    #[test]
3446    fn governed_self_report_persistence_failure_is_loud() {
3447        let directory = tempfile::tempdir().unwrap();
3448        let state_root = directory.path().join("not-a-directory");
3449        fs::write(&state_root, b"file").unwrap();
3450        let paths = StatePaths::from_root(state_root);
3451        assert!(write_seam_state(&paths, SeamState::default()).is_err());
3452    }
3453
3454    #[test]
3455    fn raw_bytes_round_trip_verifies_then_parses_from_the_fixture_envelope() {
3456        let envelope: SignedManifest = serde_json::from_str(include_str!(
3457            "../tests/fixtures/gh_shim/signed-envelope-v2.json"
3458        ))
3459        .expect("signed envelope fixture");
3460        // The embedded bytes are exactly the published manifest file.
3461        assert_eq!(
3462            envelope.manifest_bytes,
3463            include_str!("../tests/fixtures/gh_shim/initial-manifest-v1.json")
3464        );
3465        // Verify the received bytes first, parse second.
3466        let manifest = verify_manifest_signature(&envelope).expect("fixture signature verifies");
3467        assert_eq!(manifest.manifest_version, 1);
3468        assert_eq!(manifest.issued_at_unix_secs, FIXTURE_ISSUED_AT);
3469        manifest.validate().expect("fixture manifest validates");
3470    }
3471
3472    #[test]
3473    fn tampered_single_byte_fixture_fails_signature_verification() {
3474        let canonical: SignedManifest = serde_json::from_str(include_str!(
3475            "../tests/fixtures/gh_shim/signed-envelope-v2.json"
3476        ))
3477        .expect("canonical envelope fixture");
3478        let tampered: SignedManifest = serde_json::from_str(include_str!(
3479            "../tests/fixtures/gh_shim/signed-envelope-v2-tampered.json"
3480        ))
3481        .expect("tampered envelope fixture");
3482        // The tampering is exactly one substituted byte inside the signed
3483        // bytes; the signature is untouched.
3484        assert_eq!(
3485            canonical.manifest_bytes.len(),
3486            tampered.manifest_bytes.len()
3487        );
3488        assert_eq!(
3489            canonical
3490                .manifest_bytes
3491                .bytes()
3492                .zip(tampered.manifest_bytes.bytes())
3493                .filter(|(left, right)| left != right)
3494                .count(),
3495            1
3496        );
3497        assert_eq!(canonical.signature, tampered.signature);
3498        assert!(matches!(
3499            verify_manifest_signature(&tampered),
3500            Err(ManifestProblem::Invalid(_))
3501        ));
3502    }
3503
3504    #[test]
3505    fn future_and_stale_issued_at_fixtures_are_refused() {
3506        let directory = tempfile::tempdir().unwrap();
3507        let paths = StatePaths::from_root(directory.path().to_path_buf());
3508
3509        write_envelope_fixture(
3510            &paths,
3511            include_str!("../tests/fixtures/gh_shim/signed-envelope-v2-future-issued-at.json"),
3512        );
3513        match load_manifest(&paths, TEST_NOW) {
3514            Err(ManifestProblem::Invalid(error)) => {
3515                assert!(error.contains("future"), "unexpected error: {error}")
3516            }
3517            other => panic!("expected future issued_at refusal, got {other:?}"),
3518        }
3519
3520        write_envelope_fixture(
3521            &paths,
3522            include_str!("../tests/fixtures/gh_shim/signed-envelope-v2-stale-issued-at.json"),
3523        );
3524        assert!(matches!(
3525            load_manifest(&paths, TEST_NOW),
3526            Err(ManifestProblem::Stale {
3527                manifest_version: 1
3528            })
3529        ));
3530    }
3531
3532    #[test]
3533    fn standby_key_fixture_verifies_under_a_two_slot_trust_set_and_unknown_key_ids_are_refused() {
3534        let envelope: SignedManifest = serde_json::from_str(include_str!(
3535            "../tests/fixtures/gh_shim/signed-envelope-v2-standby-key.json"
3536        ))
3537        .expect("standby envelope fixture");
3538
3539        let standby = Ed25519KeyPair::from_seed_unchecked(&STANDBY_TEST_SEED).expect("standby key");
3540        assert_ne!(standby.public_key().as_ref(), DEV_MANIFEST_PUBLIC_KEY);
3541        let standby_public: &'static [u8] =
3542            Box::leak(standby.public_key().as_ref().to_vec().into_boxed_slice());
3543        let trust_set = [
3544            Some(ManifestTrustKey {
3545                key_id: DEV_MANIFEST_KEY_ID,
3546                public_key: &DEV_MANIFEST_PUBLIC_KEY,
3547            }),
3548            Some(ManifestTrustKey {
3549                key_id: DEV_STANDBY_MANIFEST_KEY_ID,
3550                public_key: standby_public,
3551            }),
3552        ];
3553
3554        // A standby-signed manifest is accepted under the two-slot set.
3555        let manifest =
3556            verify_manifest_signature_with(&envelope, &trust_set).expect("standby slot verifies");
3557        assert_eq!(
3558            manifest.manifest_version,
3559            fixture_manifest().manifest_version
3560        );
3561
3562        // A third, unknown key id is refused by the same set.
3563        let mut unknown = envelope.clone();
3564        unknown.key_id = "gh-routing-unknown-key".to_string();
3565        assert!(matches!(
3566            verify_manifest_signature_with(&unknown, &trust_set),
3567            Err(ManifestProblem::Invalid(_))
3568        ));
3569    }
3570
3571    #[test]
3572    fn compiled_trust_set_shape_matches_the_two_slot_design() {
3573        let slots = compiled_manifest_trust_set();
3574        #[cfg(debug_assertions)]
3575        {
3576            // The dev set keeps exactly one test key.
3577            assert_eq!(slots.len(), 1);
3578            assert_eq!(slots[0].unwrap().key_id, DEV_MANIFEST_KEY_ID);
3579        }
3580        #[cfg(not(debug_assertions))]
3581        {
3582            // The release set ships two slots (live + cold standby), empty
3583            // until the custody ceremony release fills them.
3584            assert_eq!(slots.len(), 2);
3585            assert!(slots.iter().all(Option::is_none));
3586        }
3587    }
3588
3589    #[test]
3590    fn envelope_v1_shapes_are_refused_by_the_v2_verifier() {
3591        let directory = tempfile::tempdir().unwrap();
3592        let paths = StatePaths::from_root(directory.path().to_path_buf());
3593        let manifest = fixture_manifest();
3594        let bytes = serde_json::to_vec(&manifest).unwrap();
3595        let key = Ed25519KeyPair::from_seed_unchecked(&TEST_SEED).unwrap();
3596        let signature = base64::engine::general_purpose::STANDARD.encode(key.sign(&bytes).as_ref());
3597
3598        // The pre-v2 shape carried the parsed manifest object in the envelope.
3599        let v1_object = json!({
3600            "artifact_id": MANIFEST_ARTIFACT_ID,
3601            "key_id": DEV_MANIFEST_KEY_ID,
3602            "fetched_at_unix_secs": TEST_NOW,
3603            "signature": signature,
3604            "manifest": serde_json::to_value(&manifest).unwrap(),
3605        });
3606        fs::write(&paths.manifest, serde_json::to_vec(&v1_object).unwrap()).unwrap();
3607        assert!(matches!(
3608            load_manifest(&paths, TEST_NOW),
3609            Err(ManifestProblem::Invalid(_))
3610        ));
3611
3612        // An envelope naming an older version is refused even with raw bytes.
3613        let mut old_version = signed(&manifest, TEST_NOW);
3614        old_version.envelope_version = 1;
3615        fs::write(&paths.manifest, serde_json::to_vec(&old_version).unwrap()).unwrap();
3616        match load_manifest(&paths, TEST_NOW) {
3617            Err(ManifestProblem::Invalid(error)) => {
3618                assert!(
3619                    error.contains("envelope version"),
3620                    "unexpected error: {error}"
3621                )
3622            }
3623            other => panic!("expected envelope version refusal, got {other:?}"),
3624        }
3625    }
3626
3627    #[test]
3628    fn dormant_resolution_is_presence_based() {
3629        let directory = tempfile::tempdir().unwrap();
3630        let paths = StatePaths::from_root(directory.path().to_path_buf());
3631        // No artifact on disk: dormant.
3632        assert!(matches!(
3633            resolve_manifest(&paths, TEST_NOW),
3634            ManifestResolution::Dormant
3635        ));
3636
3637        // A failing artifact with no last-valid cache is also dormant: nothing
3638        // was ever governed on this machine, so there is nothing to regress
3639        // from.
3640        let untrusted = signed_with(
3641            &fixture_manifest(),
3642            TEST_NOW,
3643            &STANDBY_TEST_SEED,
3644            "gh-routing-unknown-key",
3645        );
3646        fs::write(&paths.manifest, serde_json::to_vec(&untrusted).unwrap()).unwrap();
3647        assert!(matches!(
3648            resolve_manifest(&paths, TEST_NOW),
3649            ManifestResolution::Dormant
3650        ));
3651    }
3652
3653    #[test]
3654    fn regressed_past_grace_refuses_governed_and_admin_and_passes_mechanical() {
3655        let directory = tempfile::tempdir().unwrap();
3656        let paths = StatePaths::from_root(directory.path().to_path_buf());
3657        let accepted_at = TEST_NOW;
3658
3659        // Accept the canonical manifest; this writes the last-valid cache.
3660        write_signed_manifest(&paths, fixture_manifest(), accepted_at);
3661        load_manifest(&paths, accepted_at).expect("canonical manifest verifies");
3662
3663        // Break the installed artifact: signed bytes tampered after signing.
3664        write_envelope_fixture(
3665            &paths,
3666            include_str!("../tests/fixtures/gh_shim/signed-envelope-v2-tampered.json"),
3667        );
3668
3669        // Inside the last-valid grace window the cache still classifies.
3670        assert!(matches!(
3671            resolve_manifest(&paths, accepted_at + MANIFEST_STALE_GRACE.as_secs()),
3672            ManifestResolution::GraceCache(_)
3673        ));
3674
3675        // Past grace the regressed arm takes over.
3676        let now = accepted_at + MANIFEST_STALE_GRACE.as_secs() + 1;
3677        let ManifestResolution::Regressed(manifest) = resolve_manifest(&paths, now) else {
3678            panic!("expected the regressed arm");
3679        };
3680        let governed = [
3681            OsString::from("issue"),
3682            OsString::from("comment"),
3683            OsString::from("42"),
3684            OsString::from("--body"),
3685            OsString::from("hello"),
3686        ];
3687        assert!(matches!(
3688            regressed_disposition(&governed, &manifest, "macos"),
3689            RegressedDisposition::Refuse {
3690                code: RefusalCode::ManifestRegressed,
3691                ..
3692            }
3693        ));
3694        let admin = [
3695            OsString::from("pr"),
3696            OsString::from("merge"),
3697            OsString::from("1"),
3698        ];
3699        assert!(matches!(
3700            regressed_disposition(&admin, &manifest, "macos"),
3701            RegressedDisposition::Refuse {
3702                code: RefusalCode::ManifestRegressed,
3703                ..
3704            }
3705        ));
3706        let mechanical = [OsString::from("issue"), OsString::from("view")];
3707        assert!(matches!(
3708            regressed_disposition(&mechanical, &manifest, "macos"),
3709            RegressedDisposition::Passthrough
3710        ));
3711        let undeclared = [OsString::from("alias"), OsString::from("set")];
3712        assert!(matches!(
3713            regressed_disposition(&undeclared, &manifest, "macos"),
3714            RegressedDisposition::Refuse {
3715                code: RefusalCode::Unclassified,
3716                ..
3717            }
3718        ));
3719
3720        // The self report is loud about the regressed state.
3721        let report = cached_manifest_report_at(&paths, now);
3722        assert_eq!(report.state, Some("regressed"));
3723        assert_eq!(report.version, Some(1));
3724        assert_eq!(
3725            report.diagnostics,
3726            vec![
3727                SelfReportDiagnostic::ManifestRegressed.as_str(),
3728                SelfReportDiagnostic::ManifestInvalid.as_str(),
3729            ]
3730        );
3731    }
3732
3733    #[test]
3734    fn version_high_water_refuses_rollbacks_and_the_incident_is_visible_in_self_report() {
3735        let directory = tempfile::tempdir().unwrap();
3736        let paths = StatePaths::from_root(directory.path().to_path_buf());
3737
3738        // Accept the newer manifest first.
3739        write_envelope_fixture(
3740            &paths,
3741            include_str!("../tests/fixtures/gh_shim/signed-envelope-v2-version-2.json"),
3742        );
3743        assert_eq!(load_manifest(&paths, TEST_NOW).unwrap().manifest_version, 2);
3744        assert_eq!(version_high_water(&paths), 2);
3745
3746        // A validly-signed OLDER manifest is then refused as a rollback
3747        // incident, never as ordinary out-of-order arrival.
3748        write_envelope_fixture(
3749            &paths,
3750            include_str!("../tests/fixtures/gh_shim/signed-envelope-v2.json"),
3751        );
3752        assert!(matches!(
3753            load_manifest(&paths, TEST_NOW),
3754            Err(ManifestProblem::RolledBack {
3755                manifest_version: 1,
3756                newest_accepted: 2,
3757            })
3758        ));
3759        let report = cached_manifest_report_at(&paths, TEST_NOW);
3760        assert_eq!(
3761            report.diagnostics,
3762            vec![
3763                SelfReportDiagnostic::ManifestRegressed.as_str(),
3764                SelfReportDiagnostic::ManifestRollback.as_str(),
3765            ]
3766        );
3767        // That rollback is also visible through the --status document.
3768        let document = render_self_report(&paths).expect("self report");
3769        assert!(document.contains(SelfReportDiagnostic::ManifestRollback.as_str()));
3770
3771        // Re-presenting the newest accepted version is not a rollback.
3772        write_envelope_fixture(
3773            &paths,
3774            include_str!("../tests/fixtures/gh_shim/signed-envelope-v2-version-2.json"),
3775        );
3776        assert_eq!(load_manifest(&paths, TEST_NOW).unwrap().manifest_version, 2);
3777    }
3778
3779    fn fixture_dir() -> PathBuf {
3780        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/gh_shim")
3781    }
3782
3783    fn canonical_manifest_bytes() -> Vec<u8> {
3784        fs::read(fixture_dir().join("initial-manifest-v1.json"))
3785            .expect("canonical manifest fixture")
3786    }
3787
3788    fn envelope_json(envelope: &SignedManifest) -> Vec<u8> {
3789        let mut bytes = serde_json::to_vec_pretty(envelope).expect("envelope serialization");
3790        bytes.push(b'\n');
3791        bytes
3792    }
3793
3794    /// Deterministic generator for every dev-signed envelope fixture. The
3795    /// canonical fixture's signature covers the exact bytes of the checked-in
3796    /// manifest file; variant fixtures re-sign their serialized variant bytes.
3797    fn generate_envelope_fixtures() -> Vec<(String, Vec<u8>)> {
3798        let sign = |bytes: &[u8], seed: &[u8; 32]| {
3799            let key = Ed25519KeyPair::from_seed_unchecked(seed).expect("fixture key");
3800            base64::engine::general_purpose::STANDARD.encode(key.sign(bytes).as_ref())
3801        };
3802        let envelope = |key_id: &str, seed: &[u8; 32], manifest_bytes: String| {
3803            envelope_json(&SignedManifest {
3804                artifact_id: MANIFEST_ARTIFACT_ID.to_string(),
3805                envelope_version: ENVELOPE_VERSION,
3806                key_id: key_id.to_string(),
3807                fetched_at_unix_secs: FIXTURE_ISSUED_AT,
3808                signature: sign(manifest_bytes.as_bytes(), seed),
3809                manifest_bytes,
3810            })
3811        };
3812
3813        let canonical = canonical_manifest_bytes();
3814        let canonical_text = String::from_utf8(canonical.clone()).expect("UTF-8 manifest");
3815        let canonical_signature = sign(&canonical, &TEST_SEED);
3816
3817        let mut fixtures = Vec::new();
3818        // Raw-bytes round-trip golden: signature over the published file.
3819        fixtures.push((
3820            "signed-envelope-v2.json".to_string(),
3821            envelope_json(&SignedManifest {
3822                artifact_id: MANIFEST_ARTIFACT_ID.to_string(),
3823                envelope_version: ENVELOPE_VERSION,
3824                key_id: DEV_MANIFEST_KEY_ID.to_string(),
3825                fetched_at_unix_secs: FIXTURE_ISSUED_AT,
3826                signature: canonical_signature.clone(),
3827                manifest_bytes: canonical_text.clone(),
3828            }),
3829        ));
3830        // Tampered-single-byte case: one substitution inside the signed bytes,
3831        // keeping the ORIGINAL signature so verification must fail.
3832        let tampered = canonical_text.replacen("issue view", "issue View", 1);
3833        assert_ne!(tampered, canonical_text);
3834        fixtures.push((
3835            "signed-envelope-v2-tampered.json".to_string(),
3836            envelope_json(&SignedManifest {
3837                artifact_id: MANIFEST_ARTIFACT_ID.to_string(),
3838                envelope_version: ENVELOPE_VERSION,
3839                key_id: DEV_MANIFEST_KEY_ID.to_string(),
3840                fetched_at_unix_secs: FIXTURE_ISSUED_AT,
3841                signature: canonical_signature,
3842                manifest_bytes: tampered,
3843            }),
3844        ));
3845
3846        let mut variant = |name: &str, mutate: fn(&mut Manifest), seed: &[u8; 32], key_id: &str| {
3847            let mut manifest = fixture_manifest();
3848            mutate(&mut manifest);
3849            let bytes = serde_json::to_vec(&manifest).expect("variant manifest bytes");
3850            fixtures.push((
3851                name.to_string(),
3852                envelope(
3853                    key_id,
3854                    seed,
3855                    String::from_utf8(bytes).expect("UTF-8 variant bytes"),
3856                ),
3857            ));
3858        };
3859        variant(
3860            "signed-envelope-v2-future-issued-at.json",
3861            |manifest| {
3862                manifest.issued_at_unix_secs =
3863                    FIXTURE_ISSUED_AT + ISSUED_AT_FUTURE_SKEW.as_secs() + 3300;
3864            },
3865            &TEST_SEED,
3866            DEV_MANIFEST_KEY_ID,
3867        );
3868        variant(
3869            "signed-envelope-v2-stale-issued-at.json",
3870            |manifest| {
3871                manifest.issued_at_unix_secs = FIXTURE_ISSUED_AT - 2_000_000;
3872            },
3873            &TEST_SEED,
3874            DEV_MANIFEST_KEY_ID,
3875        );
3876        variant(
3877            "signed-envelope-v2-version-2.json",
3878            |manifest| {
3879                manifest.manifest_version = 2;
3880            },
3881            &TEST_SEED,
3882            DEV_MANIFEST_KEY_ID,
3883        );
3884        variant(
3885            "signed-envelope-v2-standby-key.json",
3886            |_manifest| {},
3887            &STANDBY_TEST_SEED,
3888            DEV_STANDBY_MANIFEST_KEY_ID,
3889        );
3890        fixtures
3891    }
3892
3893    #[test]
3894    fn signed_envelope_fixtures_match_their_generator() {
3895        let regen = std::env::var_os("AFT_GH_SHIM_REGEN").is_some();
3896        for (name, bytes) in generate_envelope_fixtures() {
3897            let path = fixture_dir().join(&name);
3898            if regen {
3899                fs::write(&path, &bytes).expect("write fixture");
3900                continue;
3901            }
3902            let disk = fs::read(&path)
3903                .unwrap_or_else(|error| panic!("fixture {name} is missing: {error}"));
3904            assert_eq!(
3905                disk, bytes,
3906                "fixture {name} drifted from its generator; rerun with AFT_GH_SHIM_REGEN=1"
3907            );
3908        }
3909    }
3910}