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