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