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