Skip to main content

aft/
gh_shim.rs

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