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