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. Its shape
5410        // still belongs to the pinned contract, so assert the shape here rather
5411        // than letting the copy below accept a missing or malformed value
5412        // (issue #278 asked for exactly this: presence and format, not value).
5413        let actual_repository = actual["repository"]
5414            .as_str()
5415            .expect("wire request must carry a repository string");
5416        let (owner, name) = actual_repository
5417            .split_once('/')
5418            .expect("repository must be owner/name");
5419        assert!(
5420            !owner.is_empty() && !name.is_empty() && !name.contains('/'),
5421            "repository must be a single owner/name pair: {actual_repository}"
5422        );
5423        expected["repository"] = actual["repository"].clone();
5424        actual["metadata"]
5425            .as_object_mut()
5426            .expect("actual metadata object")
5427            .remove("agent_id");
5428        assert_eq!(
5429            actual["body"]
5430                .as_object()
5431                .expect("actual request body")
5432                .keys()
5433                .cloned()
5434                .collect::<Vec<_>>(),
5435            happy_body_fields,
5436            "consumer body fields drifted from producer shape"
5437        );
5438        assert_eq!(
5439            actual, expected,
5440            "consumer request drifted from producer shape"
5441        );
5442        assert_eq!(
5443            actual["edit_last"], true,
5444            "edit_last marker must be present"
5445        );
5446
5447        for case_name in ["edit_last_no_own_comment", "edit_last_unsupported_action"] {
5448            let code = vector_case(case_name)["response"]["refusal_code"]
5449                .as_str()
5450                .expect("producer refusal code");
5451            let response = json!({"outcome": "refusal", "refusal_code": code});
5452            let outcome = parse_governed_response(&serde_json::to_vec(&response).unwrap())
5453                .expect("producer refusal should parse");
5454            assert!(matches!(outcome, RouteOutcome::Refusal(ref actual) if actual == code));
5455            assert_eq!(
5456                RefusalCode::SeamRefusal.as_str(),
5457                "gh_shim_seam_refusal",
5458                "open-world holder refusal codes must use the seam refusal classification"
5459            );
5460            assert_eq!(
5461                seam_refusal_text(code),
5462                format!("governance seam refused the action: {code}"),
5463                "holder refusal code must pass through without remapping"
5464            );
5465        }
5466    }
5467
5468    /// Signed v10 manifests key in-row prose as `reasoning`. This test pins the
5469    /// exact signed row shape through parse AND a full parse->serialize->parse
5470    /// round trip, so a field rename can never again silently drop signed
5471    /// justification text. Mutation control: removing the `reasoning` alias on
5472    /// `TupleDecl::Details::rationale` must turn this test red by name.
5473    #[test]
5474    fn signed_v10_reasoning_prose_survives_parse_and_cache_round_trip() {
5475        // Byte shape lifted from the signed v10 artifact (admin tier row).
5476        let signed_row = r#"{
5477            "tuple": "workflow run",
5478            "platform": ["macos", "linux"],
5479            "reasoning": "Administration: dispatching a workflow runs code but carries no public attribution surface; operator identity under explicit bypass."
5480        }"#;
5481        let parsed: TupleDecl = serde_json::from_str(signed_row).expect("signed row parses");
5482        let TupleDecl::Details { rationale, .. } = &parsed else {
5483            panic!("signed row must parse as a detailed declaration");
5484        };
5485        let prose = rationale
5486            .as_deref()
5487            .expect("signed `reasoning` prose must survive the parse, not default to None");
5488        assert!(
5489            prose.starts_with("Administration:"),
5490            "prose intact: {prose}"
5491        );
5492
5493        // The cache view is a re-serialization of the parsed struct; the prose
5494        // must survive that full round trip too (this is the view that showed
5495        // rationale: null for every signed row before the alias existed).
5496        let cache_bytes = serde_json::to_string(&parsed).expect("cache serialization");
5497        let reparsed: TupleDecl = serde_json::from_str(&cache_bytes).expect("cache view reparses");
5498        let TupleDecl::Details {
5499            rationale: cached, ..
5500        } = &reparsed
5501        else {
5502            panic!("cache view must stay a detailed declaration");
5503        };
5504        assert_eq!(
5505            cached.as_deref(),
5506            Some(prose),
5507            "prose must survive the parse->serialize->parse cache round trip verbatim"
5508        );
5509    }
5510
5511    #[test]
5512    fn manifest_rejects_duplicate_tiers_and_empty_api_rationales() {
5513        let mut duplicate = fixture_manifest();
5514        duplicate
5515            .tiers
5516            .get_mut(&Tier::Admin)
5517            .unwrap()
5518            .push(TupleDecl::Details {
5519                tuple: "issue comment".to_string(),
5520                platform: vec!["macos".to_string()],
5521                api_match: None,
5522                rationale: None,
5523            });
5524        assert!(duplicate.validate().unwrap_err().contains("both"));
5525
5526        let mut empty_api = fixture_manifest();
5527        empty_api
5528            .tiers
5529            .get_mut(&Tier::Admin)
5530            .unwrap()
5531            .push(TupleDecl::Details {
5532                tuple: "api patch close".to_string(),
5533                platform: vec!["macos".to_string()],
5534                api_match: Some(String::new()),
5535                rationale: None,
5536            });
5537        assert!(empty_api.validate().unwrap_err().contains("rationale"));
5538
5539        let mut malformed_binding = fixture_manifest();
5540        malformed_binding.bindings.insert(
5541            "https://github.com/cortexkit/aft.git".to_string(),
5542            "alfonso-aft".to_string(),
5543        );
5544        assert!(malformed_binding
5545            .validate()
5546            .unwrap_err()
5547            .contains("canonical owner/name"));
5548    }
5549
5550    #[test]
5551    fn manifest_rejects_api_rules_for_unknown_host_platforms() {
5552        let mut manifest = branch_protection_manifest("PUT", Tier::Admin);
5553        manifest
5554            .api_rules
5555            .last_mut()
5556            .expect("branch protection API rule")
5557            .platform = vec!["github".to_string()];
5558        assert_eq!(
5559            manifest.validate().unwrap_err(),
5560            "api rule PUT /repos/*/*/branches/*/protection names unknown host platform github"
5561        );
5562    }
5563
5564    #[test]
5565    fn binding_keys_and_governed_session_identity_are_stable() {
5566        assert_eq!(
5567            canonical_repository_key("https://github.com/CortexKit/aft.git"),
5568            Some("cortexkit/aft".to_string())
5569        );
5570        assert_eq!(
5571            canonical_repository_key("git@github.com:cortexkit/aft.git"),
5572            Some("cortexkit/aft".to_string())
5573        );
5574        assert_eq!(gh_session_id("alfonso-aft"), "gh-shim:alfonso-aft");
5575
5576        let request = GovernedRequest {
5577            action: "issue comment".to_string(),
5578            target: Map::new(),
5579            body: Map::new(),
5580            repository: Some("cortexkit/aft".to_string()),
5581            manifest_version: 1,
5582            edit_last: false,
5583        };
5584        let determination = RungDetermination::r3(7, 1, &test_rung_provenance());
5585        let wire = governed_wire_request(&determination.record, "alfonso-aft", request);
5586        assert_eq!(wire["metadata"]["agent_id"], "alfonso-aft");
5587        assert_eq!(wire["metadata"]["pid"], std::process::id());
5588    }
5589
5590    #[test]
5591    fn manifest_rejects_repo_sections_that_add_or_lower_a_tuple() {
5592        let mut manifest = fixture_manifest();
5593        manifest.repository_sections.insert(
5594            "owner/repo".to_string(),
5595            RepositorySection {
5596                tiers: BTreeMap::from([(
5597                    Tier::Mechanical,
5598                    vec![TupleDecl::Details {
5599                        tuple: "issue comment".to_string(),
5600                        platform: vec!["macos".to_string()],
5601                        api_match: None,
5602                        rationale: None,
5603                    }],
5604                )]),
5605                removed_tuples: Vec::new(),
5606            },
5607        );
5608        assert!(manifest.validate().unwrap_err().contains("lowers"));
5609
5610        manifest.repository_sections.insert(
5611            "owner/repo".to_string(),
5612            RepositorySection {
5613                tiers: BTreeMap::from([(
5614                    Tier::Admin,
5615                    vec![TupleDecl::Details {
5616                        tuple: "workflow dispatch".to_string(),
5617                        platform: vec!["macos".to_string()],
5618                        api_match: None,
5619                        rationale: None,
5620                    }],
5621                )]),
5622                removed_tuples: Vec::new(),
5623            },
5624        );
5625        assert!(manifest.validate().unwrap_err().contains("adds"));
5626    }
5627
5628    #[cfg(debug_assertions)] // verifies under the dev test key, which release trust sets exclude
5629    #[test]
5630    fn signed_cache_rejects_tampering_and_old_schema_floor() {
5631        let directory = tempfile::tempdir().unwrap();
5632        let paths = StatePaths::from_root(directory.path().to_path_buf());
5633        let now = TEST_NOW;
5634        write_signed_manifest(&paths, fixture_manifest(), now);
5635        assert_eq!(load_manifest(&paths, now).unwrap().manifest_version, 1);
5636
5637        // Tamper with the signed manifest bytes inside the envelope: the
5638        // signature verifies the distributed bytes, so any edit is fatal.
5639        let mut value: Value = serde_json::from_slice(&fs::read(&paths.manifest).unwrap()).unwrap();
5640        let tampered =
5641            value["manifest_bytes"]
5642                .as_str()
5643                .unwrap()
5644                .replacen("issue view", "issue View", 1);
5645        value["manifest_bytes"] = Value::String(tampered);
5646        fs::write(&paths.manifest, serde_json::to_vec(&value).unwrap()).unwrap();
5647        assert!(matches!(
5648            load_manifest(&paths, now),
5649            Err(ManifestProblem::Invalid(_))
5650        ));
5651        // A validation failure immediately enters the regressed arm, so status
5652        // names that state first and the artifact fault second.
5653        assert_eq!(
5654            cached_manifest_report_at(&paths, now).diagnostics,
5655            vec![
5656                SelfReportDiagnostic::ManifestRegressed.as_str(),
5657                SelfReportDiagnostic::ManifestInvalid.as_str(),
5658            ]
5659        );
5660
5661        let mut below_floor = fixture_manifest();
5662        below_floor.schema_floor = 0;
5663        write_signed_manifest(&paths, below_floor, now);
5664        assert!(matches!(
5665            load_manifest(&paths, now),
5666            Err(ManifestProblem::BelowFloor { manifest_floor: 0 })
5667        ));
5668    }
5669
5670    #[test]
5671    fn no_verb_and_help_invocations_are_mechanical_on_a_governed_manifest() {
5672        let manifest = fixture_manifest();
5673        for args in [
5674            Vec::new(),
5675            vec![OsString::from("--version")],
5676            vec![OsString::from("--help")],
5677            vec![OsString::from("-h")],
5678            vec![OsString::from("help"), OsString::from("pr")],
5679        ] {
5680            assert!(
5681                matches!(
5682                    classify(&args, &manifest, "macos"),
5683                    Classification::Mechanical
5684                ),
5685                "expected passthrough classification for {args:?}"
5686            );
5687        }
5688    }
5689
5690    #[test]
5691    fn unmapped_get_and_actions_reads_are_mechanical_but_writes_remain_unclassified() {
5692        let mut manifest = fixture_manifest();
5693        manifest.api_rules.clear();
5694
5695        for args in [
5696            vec![
5697                OsString::from("api"),
5698                OsString::from("/repos/cortexkit/aft/actions/runs"),
5699            ],
5700            vec![
5701                OsString::from("api"),
5702                OsString::from("--method"),
5703                OsString::from("GET"),
5704                OsString::from("/repos/cortexkit/aft/actions/runs"),
5705            ],
5706            vec![
5707                OsString::from("api"),
5708                OsString::from("-X"),
5709                OsString::from("GET"),
5710                OsString::from("/repos/cortexkit/aft/actions/runs"),
5711            ],
5712            vec![OsString::from("run"), OsString::from("view")],
5713            vec![OsString::from("run"), OsString::from("list")],
5714            vec![OsString::from("run"), OsString::from("watch")],
5715            vec![OsString::from("workflow"), OsString::from("view")],
5716            vec![OsString::from("workflow"), OsString::from("list")],
5717        ] {
5718            assert!(
5719                matches!(
5720                    classify(&args, &manifest, "macos"),
5721                    Classification::Mechanical
5722                ),
5723                "expected read passthrough classification for {args:?}"
5724            );
5725        }
5726
5727        for args in [
5728            vec![
5729                OsString::from("api"),
5730                OsString::from("-X"),
5731                OsString::from("POST"),
5732                OsString::from("/repos/cortexkit/aft/actions/runs"),
5733            ],
5734            vec![
5735                OsString::from("api"),
5736                OsString::from("-f"),
5737                OsString::from("key=value"),
5738                OsString::from("/repos/cortexkit/aft/actions/runs"),
5739            ],
5740        ] {
5741            assert!(
5742                matches!(
5743                    classify(&args, &manifest, "macos"),
5744                    Classification::Unclassified
5745                ),
5746                "expected fail-closed classification for {args:?}"
5747            );
5748        }
5749    }
5750
5751    #[test]
5752    fn classification_is_allowlist_driven_without_a_write_heuristic() {
5753        let manifest = fixture_manifest();
5754        assert!(matches!(
5755            classify(
5756                &[OsString::from("issue"), OsString::from("view")],
5757                &manifest,
5758                "macos"
5759            ),
5760            Classification::Mechanical
5761        ));
5762        assert!(matches!(
5763            classify(
5764                &[OsString::from("api"), OsString::from("/repos/a/b")],
5765                &manifest,
5766                "macos"
5767            ),
5768            Classification::Mechanical
5769        ));
5770        assert!(matches!(
5771            classify(
5772                &[
5773                    OsString::from("api"),
5774                    OsString::from("--method=POST"),
5775                    OsString::from("/repos/a/b")
5776                ],
5777                &manifest,
5778                "macos"
5779            ),
5780            Classification::Unclassified
5781        ));
5782        assert!(matches!(
5783            classify(
5784                &[
5785                    OsString::from("api"),
5786                    OsString::from("--method"),
5787                    OsString::from("POST"),
5788                    OsString::from("/repos/a/b")
5789                ],
5790                &manifest,
5791                "macos"
5792            ),
5793            Classification::Unclassified
5794        ));
5795        assert!(matches!(
5796            classify(
5797                &[OsString::from("alias"), OsString::from("set")],
5798                &manifest,
5799                "macos"
5800            ),
5801            Classification::Unclassified
5802        ));
5803        assert!(matches!(
5804            classify(
5805                &[
5806                    OsString::from("alias"),
5807                    OsString::from("set"),
5808                    OsString::from("--write")
5809                ],
5810                &manifest,
5811                "macos"
5812            ),
5813            Classification::Unclassified
5814        ));
5815    }
5816
5817    #[test]
5818    fn canonical_repository_key_parses_github_remotes_and_rejects_foreign_hosts() {
5819        for remote in [
5820            "https://github.com/CortexKit/Aft",
5821            "https://github.com/cortexkit/aft.git",
5822            "https://github.com/cortexkit/aft/",
5823            "https://github.com/cortexkit/aft.git/",
5824            "git@github.com:cortexkit/aft.git",
5825            "ssh://git@github.com/cortexkit/aft",
5826            "cortexkit/aft",
5827        ] {
5828            assert_eq!(
5829                canonical_repository_key(remote).as_deref(),
5830                Some("cortexkit/aft")
5831            );
5832        }
5833        for remote in [
5834            "https://gitlab.com/cortexkit/aft.git",
5835            "ssh://git@gitlab.com/cortexkit/aft",
5836            "git@gitlab.com:cortexkit/aft.git",
5837        ] {
5838            assert_eq!(canonical_repository_key(remote), None);
5839        }
5840    }
5841
5842    #[test]
5843    fn invalid_repository_argument_refuses_before_seam_routing() {
5844        let manifest = fixture_manifest();
5845        let canonical = manifest.canonicalization["issue comment"].clone();
5846        let error = canonicalize_governed(
5847            &[
5848                OsString::from("--repo"),
5849                OsString::from("not/an/owner-name"),
5850                OsString::from("issue"),
5851                OsString::from("comment"),
5852                OsString::from("42"),
5853                OsString::from("--body"),
5854                OsString::from("hello"),
5855            ],
5856            "issue comment",
5857            &canonical,
5858            1,
5859        )
5860        .expect_err("an unparseable repository must abort before seam routing");
5861        assert_eq!(error, "repository not/an/owner-name is not owner/name");
5862        assert_eq!(
5863            refuse_governed_canonicalization(&error),
5864            REFUSAL_EXIT_STATUS,
5865            "a pre-routing governance refusal must have a nonzero exit status"
5866        );
5867    }
5868
5869    #[test]
5870    fn governed_canonicalization_normalizes_flags_and_explicit_repo_wins() {
5871        let manifest = fixture_manifest();
5872        let canonical = manifest.canonicalization["issue comment"].clone();
5873        let request = canonicalize_governed(
5874            &[
5875                OsString::from("--repo=owner/explicit"),
5876                OsString::from("issue"),
5877                OsString::from("comment"),
5878                OsString::from("42"),
5879                OsString::from("--body"),
5880                OsString::from("hello"),
5881            ],
5882            "issue comment",
5883            &canonical,
5884            1,
5885        )
5886        .unwrap();
5887        assert_eq!(request.repository.as_deref(), Some("owner/explicit"));
5888        assert_eq!(request.target["number"], "42");
5889        assert_eq!(request.body["body"], "hello");
5890    }
5891
5892    #[test]
5893    fn speech_body_file_forms_are_allowed_and_forward_fixture_contents() {
5894        let manifest = fixture_manifest();
5895        let body_file = fixture_dir().join("governed-speech.md");
5896        let expected_body = fs::read_to_string(&body_file).expect("speech body fixture");
5897
5898        for (expected_tuple, verb, subcommand, target) in [
5899            ("issue comment", "issue", "comment", "42"),
5900            ("pr comment", "pr", "comment", "7"),
5901            ("pr review", "pr", "review", "7"),
5902        ] {
5903            let canonical = manifest.canonicalization[expected_tuple].clone();
5904            for (flag, suffix) in [("--body-file", ""), ("-F", "")]
5905                .into_iter()
5906                .chain([("--body-file=", "equals"), ("-F=", "equals")])
5907            {
5908                let file_arg = if suffix.is_empty() {
5909                    body_file.to_string_lossy().into_owned()
5910                } else {
5911                    format!("{flag}{}", body_file.display())
5912                };
5913                let args = if suffix.is_empty() {
5914                    vec![
5915                        OsString::from(verb),
5916                        OsString::from(subcommand),
5917                        OsString::from(target),
5918                        OsString::from(flag),
5919                        OsString::from(file_arg),
5920                    ]
5921                } else {
5922                    vec![
5923                        OsString::from(verb),
5924                        OsString::from(subcommand),
5925                        OsString::from(target),
5926                        OsString::from(file_arg),
5927                    ]
5928                };
5929                assert!(matches!(
5930                    classify(&args, &manifest, "macos"),
5931                    Classification::Governed { ref tuple, .. } if tuple == expected_tuple
5932                ));
5933                let request = canonicalize_governed(&args, expected_tuple, &canonical, 1)
5934                    .expect("body-file form should canonicalize");
5935                let determination = RungDetermination::r3(1, 1, &test_rung_provenance());
5936                let wire = governed_wire_request(&determination.record, "agent-7", request);
5937                assert_eq!(wire["body"]["body"], expected_body);
5938            }
5939        }
5940
5941        let reaction = manifest.canonicalization["issue reaction"].clone();
5942        let error = canonicalize_governed(
5943            &[
5944                OsString::from("issue"),
5945                OsString::from("reaction"),
5946                OsString::from("42"),
5947                OsString::from("--body-file"),
5948                OsString::from(body_file),
5949            ],
5950            "issue reaction",
5951            &reaction,
5952            1,
5953        )
5954        .expect_err("body-file is speech-only vocabulary");
5955        assert_eq!(error, "undeclared flag --body-file");
5956    }
5957
5958    #[test]
5959    fn body_file_failures_refuse_instead_of_forwarding_an_empty_body() {
5960        let manifest = fixture_manifest();
5961        let canonical = manifest.canonicalization["pr comment"].clone();
5962        let directory = tempfile::tempdir().unwrap();
5963        let missing = directory.path().join("missing.md");
5964        let invalid = directory.path().join("invalid-utf8.md");
5965        fs::write(&invalid, [0xff, 0xfe]).unwrap();
5966
5967        for path in [missing, invalid] {
5968            let error = canonicalize_governed(
5969                &[
5970                    OsString::from("pr"),
5971                    OsString::from("comment"),
5972                    OsString::from("7"),
5973                    OsString::from("--body-file"),
5974                    OsString::from(&path),
5975                ],
5976                "pr comment",
5977                &canonical,
5978                1,
5979            )
5980            .expect_err("an unreadable body file must refuse");
5981            assert!(error.starts_with("--body-file: could not read body file "));
5982            assert!(error.contains(&path.display().to_string()));
5983            assert_eq!(
5984                refuse_governed_canonicalization(&error),
5985                REFUSAL_EXIT_STATUS
5986            );
5987        }
5988    }
5989
5990    #[test]
5991    fn body_file_dash_reads_stdin_under_the_caller_permissions() {
5992        let mut stdin = std::io::Cursor::new("body supplied through stdin");
5993        assert_eq!(
5994            read_body_file_from(Path::new("-"), &mut stdin).unwrap(),
5995            "body supplied through stdin"
5996        );
5997    }
5998
5999    #[test]
6000    fn pr_review_action_and_body_matrix_reaches_the_governed_payload() {
6001        let manifest = fixture_manifest();
6002        let canonical = manifest.canonicalization["pr review"].clone();
6003        let body_file = fixture_dir().join("governed-speech.md");
6004        let expected_body = fs::read_to_string(&body_file).expect("speech body fixture");
6005
6006        for (action_flag, event) in [
6007            ("--approve", "APPROVE"),
6008            ("--comment", "COMMENT"),
6009            ("--request-changes", "REQUEST_CHANGES"),
6010        ] {
6011            for (body_flag, body_value) in [("--body", "inline review"), ("-b", "short review")] {
6012                let args = vec![
6013                    OsString::from("pr"),
6014                    OsString::from("review"),
6015                    OsString::from("7"),
6016                    OsString::from(action_flag),
6017                    OsString::from(body_flag),
6018                    OsString::from(body_value),
6019                ];
6020                assert!(matches!(
6021                    classify(&args, &manifest, "macos"),
6022                    Classification::Governed { ref tuple, .. } if tuple == "pr review"
6023                ));
6024                let request = canonicalize_governed(&args, "pr review", &canonical, 1)
6025                    .expect("review action with inline body should canonicalize");
6026                assert_eq!(request.body["event"], event);
6027                assert_eq!(request.body["body"], body_value);
6028            }
6029
6030            let args = vec![
6031                OsString::from("pr"),
6032                OsString::from("review"),
6033                OsString::from("7"),
6034                OsString::from(action_flag),
6035                OsString::from("--body-file"),
6036                OsString::from(&body_file),
6037            ];
6038            let request = canonicalize_governed(&args, "pr review", &canonical, 1)
6039                .expect("review action with body-file should canonicalize");
6040            assert_eq!(request.body["event"], event);
6041            assert_eq!(request.body["body"], expected_body);
6042        }
6043
6044        for action_flag in ["--approve", "--request-changes"] {
6045            let args = vec![
6046                OsString::from("pr"),
6047                OsString::from("review"),
6048                OsString::from("7"),
6049                OsString::from(action_flag),
6050            ];
6051            let request = canonicalize_governed(&args, "pr review", &canonical, 1)
6052                .expect("approve/request-changes may omit review prose");
6053            assert_eq!(
6054                request.body["event"],
6055                action_flag
6056                    .trim_start_matches("--")
6057                    .to_ascii_uppercase()
6058                    .replace('-', "_")
6059            );
6060            assert!(!request.body.contains_key("body"));
6061        }
6062
6063        let duplicate = [
6064            OsString::from("pr"),
6065            OsString::from("review"),
6066            OsString::from("7"),
6067            OsString::from("--approve"),
6068            OsString::from("--comment"),
6069            OsString::from("--body"),
6070            OsString::from("review"),
6071        ];
6072        assert_eq!(
6073            canonicalize_governed(&duplicate, "pr review", &canonical, 1).unwrap_err(),
6074            "pr review accepts only one of --approve, --comment, or --request-changes"
6075        );
6076    }
6077
6078    #[test]
6079    fn upstream_api_errors_fail_without_changing_success_status() {
6080        let error_response = json!({
6081            "outcome": "result",
6082            "gh_route_schema": 1,
6083            "result": {
6084                "status": 404,
6085                "error": {"message": "Not Found", "documentation_url": "https://docs.github.com"}
6086            }
6087        });
6088        let error_outcome =
6089            parse_governed_response(&serde_json::to_vec(&error_response).unwrap()).unwrap();
6090        let error_body = match error_outcome {
6091            RouteOutcome::UpstreamError(body) => body,
6092            other => panic!("expected upstream error, got {other:?}"),
6093        };
6094        assert!(error_body.contains("Not Found"));
6095        let directory = tempfile::tempdir().unwrap();
6096        let paths = StatePaths::from_root(directory.path().to_path_buf());
6097        let binding = AgentBinding {
6098            repo: "owner/repo".to_string(),
6099            agent_id: "agent-7".to_string(),
6100        };
6101        assert_eq!(
6102            governed_outcome_status(
6103                &paths,
6104                &binding,
6105                123,
6106                RouteOutcome::UpstreamError(error_body)
6107            ),
6108            UPSTREAM_FAILURE_EXIT_STATUS
6109        );
6110
6111        let success_response = json!({
6112            "outcome": "result",
6113            "gh_route_schema": 1,
6114            "result": {"status": 201, "url": "https://github.com/example"},
6115            "field_order": ["status", "url"]
6116        });
6117        let success_outcome =
6118            parse_governed_response(&serde_json::to_vec(&success_response).unwrap()).unwrap();
6119        assert!(matches!(&success_outcome, RouteOutcome::Result(_)));
6120        assert_eq!(
6121            governed_outcome_status(&paths, &binding, 123, success_outcome),
6122            0
6123        );
6124    }
6125
6126    #[test]
6127    fn governed_renderer_is_deterministic_for_scalars_arrays_and_escapes() {
6128        let result = json!({"message":"snowman ☃\n", "items":["a", 2], "ok":true});
6129        let order = vec![json!("ok"), json!("message"), json!("items")];
6130        assert_eq!(
6131            render_governed_response(&result, &order).unwrap(),
6132            "ok: true\nmessage: \"snowman ☃\\n\"\nitems:\n  \"a\"\n  2\n"
6133        );
6134        assert!(matches!(
6135            render_governed_response(&json!("scalar"), &order),
6136            Err(RouteOutcome::SchemaMismatch(_))
6137        ));
6138    }
6139
6140    #[test]
6141    fn lower_rungs_are_cached_durably_but_r1_is_not_written() {
6142        let directory = tempfile::tempdir().unwrap();
6143        let paths = StatePaths::from_root(directory.path().to_path_buf());
6144        let determination = RungDetermination::r2(
6145            123,
6146            R2Reason::DaemonUnreachable,
6147            None,
6148            &test_rung_provenance(),
6149        );
6150        write_rung_record_silently(&paths, &determination.record);
6151        assert_eq!(load_rung_record(&paths).unwrap().rung, Rung::R2);
6152        assert!(!paths.root.join("r1-cache.json").exists());
6153    }
6154
6155    #[test]
6156    fn governed_bound_disposition_is_reason_independent_except_operator_hard_off() {
6157        const EXPECTED_RUNG_SHAPE_COUNT: usize = 11;
6158
6159        let directory = tempfile::tempdir().unwrap();
6160        let paths = StatePaths::from_root(directory.path().to_path_buf());
6161        let connection_file = directory.path().join("connection.json");
6162        fs::write(&connection_file, "present").unwrap();
6163        let missing_connection = directory.path().join("missing-connection.json");
6164        let disabled_doc = serde_json::json!({
6165            "gh_shim": { "enabled": false },
6166            "subc": { "connection_file": missing_connection }
6167        })
6168        .to_string();
6169        let unreachable_doc = serde_json::json!({
6170            "subc": { "connection_file": directory.path().join("still-missing.json") }
6171        })
6172        .to_string();
6173        let budget_doc = serde_json::json!({
6174            "subc": { "connection_file": connection_file }
6175        })
6176        .to_string();
6177        let future_deadline = || std::time::Instant::now() + DISCOVERY_BUDGET;
6178        let r1_cases = [
6179            (
6180                R1Reason::DisabledByConfig,
6181                determine_rung_from_doc(
6182                    &paths,
6183                    directory.path(),
6184                    1,
6185                    future_deadline(),
6186                    Some(&disabled_doc),
6187                ),
6188            ),
6189            (
6190                R1Reason::AbsentOrUnparseable,
6191                determine_rung_from_doc(&paths, directory.path(), 1, future_deadline(), Some("{}")),
6192            ),
6193            (
6194                R1Reason::Unreachable,
6195                determine_rung_from_doc(
6196                    &paths,
6197                    directory.path(),
6198                    1,
6199                    future_deadline(),
6200                    Some(&unreachable_doc),
6201                ),
6202            ),
6203            (
6204                R1Reason::DiscoveryBudgetExhausted,
6205                determine_rung_from_doc(
6206                    &paths,
6207                    directory.path(),
6208                    1,
6209                    std::time::Instant::now() - Duration::from_millis(1),
6210                    Some(&budget_doc),
6211                ),
6212            ),
6213        ];
6214        assert_eq!(r1_cases.len(), R1Reason::ALL.len());
6215        for (reason, determination) in &r1_cases {
6216            assert_eq!(determination.record.rung, Rung::R1);
6217            assert_eq!(
6218                determination
6219                    .record
6220                    .inputs
6221                    .get("connection_file")
6222                    .map(String::as_str),
6223                Some(reason.diagnostic())
6224            );
6225        }
6226
6227        let mut determinations = r1_cases
6228            .into_iter()
6229            .map(|(_, determination)| determination)
6230            .collect::<Vec<_>>();
6231        determinations.extend(
6232            R2Reason::ALL
6233                .into_iter()
6234                .map(|reason| RungDetermination::r2(1, reason, Some(1), &test_rung_provenance())),
6235        );
6236        determinations.push(RungDetermination::r3(1, 1, &test_rung_provenance()));
6237        assert_eq!(
6238            R1Reason::ALL.len() + R2Reason::ALL.len() + 1,
6239            EXPECTED_RUNG_SHAPE_COUNT,
6240            "update the explicit disposition matrix when a rung shape is added"
6241        );
6242        assert_eq!(determinations.len(), EXPECTED_RUNG_SHAPE_COUNT);
6243
6244        let manifest = fixture_manifest();
6245        let governed_args = [
6246            OsString::from("issue"),
6247            OsString::from("comment"),
6248            OsString::from("42"),
6249            OsString::from("--body"),
6250            OsString::from("hello"),
6251        ];
6252        let admin_args = [
6253            OsString::from("pr"),
6254            OsString::from("merge"),
6255            OsString::from("42"),
6256        ];
6257        let mechanical_args = [
6258            OsString::from("issue"),
6259            OsString::from("view"),
6260            OsString::from("42"),
6261        ];
6262        let governed = classify(&governed_args, &manifest, "macos");
6263        let admin = classify(&admin_args, &manifest, "macos");
6264        let mechanical = classify(&mechanical_args, &manifest, "macos");
6265        let binding = || AgentBinding {
6266            repo: "cortexkit/aft".to_string(),
6267            agent_id: "alfonso-aft".to_string(),
6268        };
6269
6270        for determination in &determinations {
6271            let bound_governed = structural_governance_disposition(
6272                determination,
6273                &governed,
6274                Some(binding()),
6275                manifest.manifest_version,
6276            );
6277            if determination.operator_disabled {
6278                assert!(matches!(bound_governed, GovernanceDisposition::Delegate));
6279            } else if determination.record.rung == Rung::R3 {
6280                assert!(matches!(bound_governed, GovernanceDisposition::Ready));
6281            } else {
6282                assert!(matches!(
6283                    bound_governed,
6284                    GovernanceDisposition::Unavailable(_)
6285                ));
6286            }
6287
6288            assert!(matches!(
6289                structural_governance_disposition(
6290                    determination,
6291                    &governed,
6292                    None,
6293                    manifest.manifest_version,
6294                ),
6295                GovernanceDisposition::Delegate
6296            ));
6297            assert!(matches!(
6298                structural_governance_disposition(
6299                    determination,
6300                    &mechanical,
6301                    Some(binding()),
6302                    manifest.manifest_version,
6303                ),
6304                GovernanceDisposition::Delegate
6305            ));
6306
6307            if determination.record.rung != Rung::R3 && !determination.operator_disabled {
6308                assert!(matches!(
6309                    structural_governance_disposition(
6310                        determination,
6311                        &admin,
6312                        Some(binding()),
6313                        manifest.manifest_version,
6314                    ),
6315                    GovernanceDisposition::Unavailable(_)
6316                ));
6317            }
6318        }
6319    }
6320
6321    #[test]
6322    fn ambient_credentials_on_a_bound_governed_invocation_refuse_identity_ambiguity() {
6323        let manifest = fixture_manifest();
6324        let governed = classify(
6325            &[
6326                OsString::from("issue"),
6327                OsString::from("comment"),
6328                OsString::from("42"),
6329                OsString::from("--body"),
6330                OsString::from("hello"),
6331            ],
6332            &manifest,
6333            "macos",
6334        );
6335        let determination = RungDetermination::r2(
6336            1,
6337            R2Reason::AgentCredentialsPresent,
6338            Some(manifest.manifest_version),
6339            &test_rung_provenance(),
6340        );
6341        let binding = AgentBinding {
6342            repo: "cortexkit/aft".to_string(),
6343            agent_id: "alfonso-aft".to_string(),
6344        };
6345
6346        assert!(matches!(
6347            structural_governance_disposition(
6348                &determination,
6349                &governed,
6350                Some(binding),
6351                manifest.manifest_version,
6352            ),
6353            GovernanceDisposition::Unavailable(_)
6354        ));
6355    }
6356
6357    #[cfg(unix)]
6358    #[test]
6359    fn resolved_image_identity_skips_a_shim_reached_through_a_symlinked_parent() {
6360        use std::os::unix::fs::symlink;
6361
6362        let directory = tempfile::tempdir().unwrap();
6363        let image = directory.path().join("aft");
6364        fs::write(&image, b"shim image").unwrap();
6365        let bin = directory.path().join("bin");
6366        fs::create_dir(&bin).unwrap();
6367        symlink(&image, bin.join("gh")).unwrap();
6368        let linked_parent = directory.path().join("linked-bin");
6369        symlink(&bin, &linked_parent).unwrap();
6370
6371        assert!(same_image(&linked_parent.join("gh"), &image));
6372    }
6373
6374    #[test]
6375    fn bypass_audit_is_visible_to_a_later_self_report_reader() {
6376        let directory = tempfile::tempdir().unwrap();
6377        let paths = StatePaths::from_root(directory.path().to_path_buf());
6378        append_bypass_audit(&paths, "issue close", Some("owner/repo"), 99).unwrap();
6379        let (records, error) = read_bypass_audit(&paths);
6380        assert!(error.is_none());
6381        let records = records.unwrap();
6382        assert_eq!(records.len(), 1);
6383        assert_eq!(records[0]["tuple"], "issue close");
6384    }
6385
6386    #[test]
6387    fn refusal_and_self_report_codes_are_separate_closed_sets() {
6388        assert_eq!(RefusalCode::ALL.len(), 13);
6389        assert!(RefusalCode::ALL
6390            .iter()
6391            .all(|code| code.as_str().starts_with("gh_shim_")));
6392        assert_eq!(
6393            RefusalCode::GovernanceUnavailable.as_str(),
6394            "gh_shim_governance_unavailable"
6395        );
6396        assert_eq!(
6397            GOVERNANCE_UNAVAILABLE_TEXT,
6398            "the governance daemon is unreachable and this repository's actions are identity-governed; retry after the daemon returns"
6399        );
6400        assert_eq!(SelfReportDiagnostic::ALL.len(), 6);
6401        assert!(SelfReportDiagnostic::ALL
6402            .iter()
6403            .all(|code| code.as_str().starts_with("gh_shim_status_")));
6404        assert!(SelfReportDiagnostic::ALL
6405            .iter()
6406            .all(|code| !code.as_str().contains("stale")));
6407        assert_eq!(REFUSAL_EXIT_STATUS, 86);
6408    }
6409
6410    #[test]
6411    fn v1_write_classification_accepts_only_the_reviewed_tuple_sets() {
6412        let manifest = fixture_manifest();
6413        for tuple in V1_GOVERNED_TUPLES {
6414            let args = tuple
6415                .split_whitespace()
6416                .map(OsString::from)
6417                .collect::<Vec<_>>();
6418            assert!(matches!(
6419                classify(&args, &manifest, "macos"),
6420                Classification::Governed { .. }
6421            ));
6422        }
6423        for tuple in V1_ADMIN_TUPLES {
6424            let args = tuple
6425                .split_whitespace()
6426                .map(OsString::from)
6427                .collect::<Vec<_>>();
6428            assert!(matches!(
6429                classify(&args, &manifest, "macos"),
6430                Classification::Admin { .. }
6431            ));
6432        }
6433        for args in [
6434            ["release", "publish"].as_slice(),
6435            ["issue", "create"].as_slice(),
6436            ["pr", "reopen"].as_slice(),
6437        ] {
6438            let args = args.iter().map(OsString::from).collect::<Vec<_>>();
6439            assert!(matches!(
6440                classify(&args, &manifest, "macos"),
6441                Classification::Unclassified
6442            ));
6443        }
6444    }
6445
6446    #[test]
6447    fn v13_release_maintenance_rows_allow_reviewed_flags_and_refuse_destructive_forms() {
6448        // The v13 shape is built here rather than read from a ceremony file:
6449        // the signed payload lives in the operator's state directory and the
6450        // assembled draft under the gitignored `.alfonso/`, so neither exists
6451        // on a clean checkout. This is the classifier's view of v13 - the
6452        // admin release rows plus the branch-protection API rules.
6453        let mut manifest = branch_protection_manifest("PUT", Tier::Admin);
6454        manifest.api_rules.push(ApiRule {
6455            method: "DELETE".to_string(),
6456            path_glob: BRANCH_PROTECTION_PATH_GLOB.to_string(),
6457            tier: Tier::Admin,
6458            platform: vec!["macos".to_string(), "linux".to_string()],
6459            rationale: Some(
6460                "branch protection is a repository setting; operator identity, audited bypass"
6461                    .to_string(),
6462            ),
6463        });
6464        let admin = manifest
6465            .tiers
6466            .get_mut(&Tier::Admin)
6467            .expect("v13 admin tier");
6468        for tuple in ["release edit", "release upload"] {
6469            admin.push(TupleDecl::Details {
6470                tuple: tuple.to_string(),
6471                platform: vec!["macos".to_string(), "linux".to_string()],
6472                api_match: None,
6473                rationale: None,
6474            });
6475        }
6476        manifest.validate().expect("valid v13 admin extensions");
6477        for method in ["PUT", "DELETE"] {
6478            let rule = manifest
6479                .api_rules
6480                .iter()
6481                .find(|rule| rule.method == method && rule.path_glob == BRANCH_PROTECTION_PATH_GLOB)
6482                .expect("v13 branch protection API rule");
6483            assert_eq!(rule.tier, Tier::Admin);
6484            assert_eq!(rule.platform, ["macos", "linux"]);
6485            assert_eq!(
6486                rule.rationale.as_deref(),
6487                Some(
6488                    "branch protection is a repository setting; operator identity, audited bypass"
6489                )
6490            );
6491        }
6492
6493        for args in [
6494            vec![
6495                "release",
6496                "edit",
6497                "v1.2.3",
6498                "--notes",
6499                "notes",
6500                "--notes-file",
6501                "notes.md",
6502                "--title",
6503                "Dashboard",
6504                "--draft=false",
6505                "--latest",
6506                "--prerelease",
6507            ],
6508            vec!["release", "upload", "v1.2.3", "dashboard.json", "--clobber"],
6509        ] {
6510            let args = os_args(&args);
6511            assert!(matches!(
6512                classify(&args, &manifest, "macos"),
6513                Classification::Admin { ref tuple }
6514                    if tuple == if args[1] == "edit" { "release edit" } else { "release upload" }
6515            ));
6516        }
6517        assert!(is_reviewed_admin_tuple(13, "release edit"));
6518        assert!(is_reviewed_admin_tuple(13, "release upload"));
6519        assert!(!is_reviewed_admin_tuple(12, "release edit"));
6520        assert!(!is_reviewed_admin_tuple(12, "release upload"));
6521
6522        for args in [
6523            os_args(&["release", "delete", "v1.2.3"]),
6524            os_args(&["release", "delete-asset", "v1.2.3", "dashboard.json"]),
6525            os_args(&["release", "edit", "v1.2.3", "--delete-tag"]),
6526            os_args(&["release", "upload", "v1.2.3", "--delete-asset"]),
6527        ] {
6528            assert!(matches!(
6529                classify(&args, &manifest, "macos"),
6530                Classification::Destructive
6531            ));
6532            assert_eq!(
6533                RefusalCode::DestructiveFlag.as_str(),
6534                "gh_shim_destructive_flag"
6535            );
6536            assert_eq!(
6537                refuse(
6538                    RefusalCode::DestructiveFlag,
6539                    "destructive GitHub operations are not available through the shim"
6540                ),
6541                REFUSAL_EXIT_STATUS
6542            );
6543        }
6544    }
6545
6546    #[test]
6547    fn admin_api_rule_classifies_field_bearing_branch_protection_puts() {
6548        let input_args = os_args(&[
6549            "api",
6550            "-X",
6551            "PUT",
6552            "/repos/o/r/branches/main/protection",
6553            "--input",
6554            "body.json",
6555        ]);
6556        let admin_manifest = branch_protection_manifest("PUT", Tier::Admin);
6557        assert!(matches!(
6558            classify(&input_args, &admin_manifest, "macos"),
6559            Classification::Admin { ref tuple } if tuple == BRANCH_PROTECTION_API_TUPLE
6560        ));
6561        assert!(matches!(
6562            classify(&input_args, &v12_fixture_manifest(), "macos"),
6563            Classification::Unclassified
6564        ));
6565        assert!(matches!(
6566            classify(
6567                &input_args,
6568                &branch_protection_manifest("PUT", Tier::Governed),
6569                "macos"
6570            ),
6571            Classification::Unclassified
6572        ));
6573
6574        let field_args = os_args(&[
6575            "api",
6576            "-X",
6577            "PUT",
6578            "/repos/o/r/branches/main/protection",
6579            "-f",
6580            "enforce_admins=true",
6581        ]);
6582        assert!(matches!(
6583            classify(&field_args, &admin_manifest, "macos"),
6584            Classification::Admin { ref tuple } if tuple == BRANCH_PROTECTION_API_TUPLE
6585        ));
6586    }
6587
6588    #[test]
6589    fn delete_branch_protection_is_admin_only_when_declared_and_not_destructive() {
6590        let args = os_args(&["api", "-X", "DELETE", "/repos/o/r/branches/main/protection"]);
6591        assert!(matches!(
6592            classify(
6593                &args,
6594                &branch_protection_manifest("DELETE", Tier::Admin),
6595                "macos"
6596            ),
6597            Classification::Admin { ref tuple }
6598                if tuple == "api:DELETE:/repos/*/*/branches/*/protection"
6599        ));
6600        assert!(matches!(
6601            classify(&args, &v12_fixture_manifest(), "macos"),
6602            Classification::Unclassified
6603        ));
6604    }
6605
6606    /// `gh api repos/o/r/...` (no leading slash) is the everyday spelling and
6607    /// the same request as `/repos/o/r/...`; a declared endpoint must classify
6608    /// identically under both, or the common form refuses as undeclared.
6609    #[test]
6610    fn slashless_api_endpoint_classifies_like_the_declared_glob() {
6611        let manifest = branch_protection_manifest("PUT", Tier::Admin);
6612        for spelling in [
6613            "/repos/o/r/branches/main/protection",
6614            "repos/o/r/branches/main/protection",
6615        ] {
6616            let args = os_args(&["api", "-X", "PUT", spelling, "--input", "-"]);
6617            assert!(
6618                matches!(
6619                    classify(&args, &manifest, "macos"),
6620                    Classification::Admin { ref tuple } if tuple == BRANCH_PROTECTION_API_TUPLE
6621                ),
6622                "{spelling} must classify as the declared admin endpoint"
6623            );
6624        }
6625        // An undeclared endpoint stays undeclared under either spelling.
6626        let args = os_args(&["api", "-X", "PUT", "repos/o/r/topics", "--input", "-"]);
6627        assert!(matches!(
6628            classify(&args, &manifest, "macos"),
6629            Classification::Unclassified
6630        ));
6631    }
6632
6633    #[cfg(debug_assertions)] // verifies under the dev test key, which release trust sets exclude
6634    #[test]
6635    fn dev_signed_admin_api_dispatch_requires_bypass_and_audits_delegation() {
6636        use std::cell::Cell;
6637
6638        let _env_lock = crate::test_env::process_env_lock();
6639        let directory = tempfile::tempdir().expect("create admin dispatch state directory");
6640        let paths = StatePaths::from_root(directory.path().to_path_buf());
6641        // The rule is declared for the platforms the fleet runs on; this test
6642        // exercises dispatch given an Admin classification, so it classifies
6643        // against a declared platform rather than the host (Windows would
6644        // classify nothing and assert the refusal arm twice).
6645        let manifest = branch_protection_manifest("PUT", Tier::Admin);
6646        manifest.validate().expect("valid admin API manifest");
6647        write_signed_manifest(&paths, manifest, TEST_NOW);
6648        let manifest = load_manifest(&paths, TEST_NOW).expect("dev-signed manifest verifies");
6649        let args = os_args(&[
6650            "api",
6651            "-X",
6652            "PUT",
6653            "/repos/o/r/branches/main/protection",
6654            "--input",
6655            "body.json",
6656        ]);
6657        let rung =
6658            RungDetermination::r3(TEST_NOW, manifest.manifest_version, &test_rung_provenance())
6659                .record;
6660        let binding = AgentBinding {
6661            repo: "cortexkit/aft".to_string(),
6662            agent_id: "alfonso-aft".to_string(),
6663        };
6664
6665        {
6666            let _bypass = ScopedTestEnvVar::set("GH_SHIM_BYPASS", None);
6667            let status = dispatch_r3(
6668                &args,
6669                classify(&args, &manifest, "macos"),
6670                &manifest,
6671                &paths,
6672                &rung,
6673                &binding,
6674                TEST_NOW,
6675                |_| panic!("ADMIN request delegated without operator bypass"),
6676            );
6677            assert_eq!(status, REFUSAL_EXIT_STATUS);
6678            assert_eq!(RefusalCode::AdminTier.as_str(), "gh_shim_admin_tier");
6679        }
6680
6681        let delegated = Cell::new(0);
6682        {
6683            let _bypass = ScopedTestEnvVar::set("GH_SHIM_BYPASS", Some("operator"));
6684            let status = dispatch_r3(
6685                &args,
6686                classify(&args, &manifest, "macos"),
6687                &manifest,
6688                &paths,
6689                &rung,
6690                &binding,
6691                TEST_NOW,
6692                |delegated_args| {
6693                    assert_eq!(delegated_args, args);
6694                    delegated.set(delegated.get() + 1);
6695                    73
6696                },
6697            );
6698            assert_eq!(status, 73);
6699        }
6700        assert_eq!(delegated.get(), 1);
6701        let (records, error) = read_bypass_audit(&paths);
6702        assert!(error.is_none());
6703        let records = records.expect("operator bypass audit records");
6704        assert_eq!(records.len(), 1);
6705        assert_eq!(records[0]["tuple"], BRANCH_PROTECTION_API_TUPLE);
6706    }
6707
6708    #[test]
6709    fn release_api_mutations_remain_unclassified_because_api_rules_are_get_only() {
6710        let manifest = v12_fixture_manifest();
6711        // The v1 audit keeps api_rules GET-only; do not widen them for REST writes.
6712        for args in [
6713            os_args(&["api", "-X", "PATCH", "repos/owner/repo/releases/42"]),
6714            os_args(&["api", "-X", "POST", "repos/owner/repo/releases/42/assets"]),
6715        ] {
6716            assert!(matches!(
6717                classify(&args, &manifest, "macos"),
6718                Classification::Unclassified
6719            ));
6720        }
6721    }
6722
6723    #[test]
6724    fn field_bearing_get_remains_unclassified_without_widening_the_mechanical_fallback() {
6725        let manifest = fixture_manifest();
6726        for field_flag in [
6727            "--field=name=value",
6728            "--raw-field=name=value",
6729            "--input=body.json",
6730            "-fname=value",
6731            "-Fname=value",
6732        ] {
6733            let args = vec![
6734                OsString::from("api"),
6735                OsString::from("/repos/owner/repo"),
6736                OsString::from(field_flag),
6737            ];
6738            assert!(matches!(
6739                classify(&args, &manifest, "macos"),
6740                Classification::Unclassified
6741            ));
6742        }
6743        assert!(matches!(
6744            classify(
6745                &os_args(&[
6746                    "api",
6747                    "/repos/o/r/branches/main/protection",
6748                    "--input",
6749                    "body.json"
6750                ]),
6751                &manifest,
6752                "macos"
6753            ),
6754            Classification::Unclassified
6755        ));
6756        assert!(matches!(
6757            classify(
6758                &os_args(&["api", "/repos/o/r/branches/main/protection"]),
6759                &manifest,
6760                "macos"
6761            ),
6762            Classification::Mechanical
6763        ));
6764    }
6765
6766    #[test]
6767    fn holder_refusals_preserve_any_string_code_and_reject_non_strings() {
6768        for code in FIXTURE_ACCEPTED_SEAM_REFUSAL_CODES {
6769            let response = json!({"outcome": "refusal", "refusal_code": code});
6770            let outcome = parse_governed_response(&serde_json::to_vec(&response).unwrap()).unwrap();
6771            assert!(matches!(outcome, RouteOutcome::Refusal(ref actual) if actual == code));
6772            assert_eq!(RefusalCode::SeamRefusal.as_str(), "gh_shim_seam_refusal");
6773            assert_eq!(
6774                seam_refusal_text(code),
6775                format!("governance seam refused the action: {code}")
6776            );
6777            assert_eq!(REFUSAL_EXIT_STATUS, 86);
6778        }
6779        let unknown = "quota_exhausted_v2";
6780        let response = json!({"outcome": "refusal", "refusal_code": unknown});
6781        let outcome = parse_governed_response(&serde_json::to_vec(&response).unwrap()).unwrap();
6782        assert!(matches!(outcome, RouteOutcome::Refusal(ref actual) if actual == unknown));
6783        assert_eq!(RefusalCode::SeamRefusal.as_str(), "gh_shim_seam_refusal");
6784        assert_eq!(
6785            seam_refusal_text(unknown),
6786            "governance seam refused the action: quota_exhausted_v2"
6787        );
6788        assert_eq!(REFUSAL_EXIT_STATUS, 86);
6789
6790        for response in [
6791            json!({"outcome": "refusal", "refusal_code": 7}),
6792            json!({"outcome": "refusal", "refusal_code": null}),
6793            json!({"outcome": "refusal"}),
6794        ] {
6795            assert!(matches!(
6796                parse_governed_response(&serde_json::to_vec(&response).unwrap()),
6797                Err(RouteOutcome::SchemaMismatch(_))
6798            ));
6799        }
6800    }
6801
6802    #[test]
6803    fn governed_self_report_transitions_are_durable_and_mechanical_classification_preserves_them() {
6804        let directory = tempfile::tempdir().unwrap();
6805        let paths = StatePaths::from_root(directory.path().to_path_buf());
6806        let binding = AgentBinding {
6807            repo: "owner/repo".to_string(),
6808            agent_id: "agent-7".to_string(),
6809        };
6810        write_seam_state(
6811            &paths,
6812            SeamState {
6813                bound_holder: None,
6814                agent_binding: Some(binding.clone()),
6815                last_seam_refusal: None,
6816            },
6817        )
6818        .unwrap();
6819        let report = build_self_report(&paths);
6820        assert_eq!(report.bound_holder, None);
6821        assert_eq!(report.agent_binding, Some(binding.clone()));
6822        assert_eq!(report.last_seam_refusal, None);
6823
6824        write_seam_state(
6825            &paths,
6826            SeamState {
6827                bound_holder: Some(ROUTING_HOLDER_MODULE_ID.to_string()),
6828                agent_binding: Some(binding.clone()),
6829                last_seam_refusal: Some(LastSeamRefusal {
6830                    code: "rate_limited".to_string(),
6831                    at_unix_secs: 77,
6832                }),
6833            },
6834        )
6835        .unwrap();
6836        let report = build_self_report(&paths);
6837        assert_eq!(
6838            report.bound_holder.as_deref(),
6839            Some(ROUTING_HOLDER_MODULE_ID)
6840        );
6841        assert_eq!(report.agent_binding, Some(binding.clone()));
6842        assert_eq!(
6843            report
6844                .last_seam_refusal
6845                .as_ref()
6846                .map(|refusal| refusal.code.as_str()),
6847            Some("rate_limited")
6848        );
6849
6850        write_seam_state(
6851            &paths,
6852            governed_seam_state(&paths, Some(ROUTING_HOLDER_MODULE_ID.to_string()), &binding),
6853        )
6854        .unwrap();
6855        assert_eq!(
6856            seam_state(&paths)
6857                .last_seam_refusal
6858                .as_ref()
6859                .map(|refusal| refusal.code.as_str()),
6860            Some("rate_limited")
6861        );
6862
6863        let mechanical = [OsString::from("issue"), OsString::from("view")];
6864        assert!(matches!(
6865            classify(&mechanical, &fixture_manifest(), "macos"),
6866            Classification::Mechanical
6867        ));
6868        assert_eq!(
6869            seam_state(&paths)
6870                .last_seam_refusal
6871                .as_ref()
6872                .map(|refusal| refusal.at_unix_secs),
6873            Some(77)
6874        );
6875    }
6876
6877    #[test]
6878    fn governed_self_report_persistence_failure_is_loud() {
6879        let directory = tempfile::tempdir().unwrap();
6880        let state_root = directory.path().join("not-a-directory");
6881        fs::write(&state_root, b"file").unwrap();
6882        let paths = StatePaths::from_root(state_root);
6883        assert!(write_seam_state(&paths, SeamState::default()).is_err());
6884    }
6885
6886    #[cfg(debug_assertions)] // verifies under the dev test key, which release trust sets exclude
6887    #[test]
6888    fn raw_bytes_round_trip_verifies_then_parses_from_the_fixture_envelope() {
6889        let envelope: SignedManifest = serde_json::from_str(include_str!(
6890            "../tests/fixtures/gh_shim/signed-envelope-v2.json"
6891        ))
6892        .expect("signed envelope fixture");
6893        // The embedded bytes are exactly the published manifest file.
6894        assert_eq!(
6895            envelope.manifest_bytes,
6896            include_str!("../tests/fixtures/gh_shim/initial-manifest-v1.json")
6897        );
6898        // Verify the received bytes first, parse second.
6899        let manifest = verify_manifest_signature(&envelope).expect("fixture signature verifies");
6900        assert_eq!(manifest.manifest_version, 1);
6901        assert_eq!(manifest.issued_at_unix_secs, FIXTURE_ISSUED_AT);
6902        manifest.validate().expect("fixture manifest validates");
6903    }
6904
6905    #[test]
6906    fn tampered_single_byte_fixture_fails_signature_verification() {
6907        let canonical: SignedManifest = serde_json::from_str(include_str!(
6908            "../tests/fixtures/gh_shim/signed-envelope-v2.json"
6909        ))
6910        .expect("canonical envelope fixture");
6911        let tampered: SignedManifest = serde_json::from_str(include_str!(
6912            "../tests/fixtures/gh_shim/signed-envelope-v2-tampered.json"
6913        ))
6914        .expect("tampered envelope fixture");
6915        // The tampering is exactly one substituted byte inside the signed
6916        // bytes; the signature is untouched.
6917        assert_eq!(
6918            canonical.manifest_bytes.len(),
6919            tampered.manifest_bytes.len()
6920        );
6921        assert_eq!(
6922            canonical
6923                .manifest_bytes
6924                .bytes()
6925                .zip(tampered.manifest_bytes.bytes())
6926                .filter(|(left, right)| left != right)
6927                .count(),
6928            1
6929        );
6930        assert_eq!(canonical.signature, tampered.signature);
6931        assert!(matches!(
6932            verify_manifest_signature(&tampered),
6933            Err(ManifestProblem::Invalid(_))
6934        ));
6935    }
6936
6937    #[cfg(debug_assertions)] // verifies under the dev test key, which release trust sets exclude
6938    #[test]
6939    fn future_issued_at_fixture_is_refused_and_aged_fixture_serves_governed_classification() {
6940        let directory = tempfile::tempdir().unwrap();
6941        let paths = StatePaths::from_root(directory.path().to_path_buf());
6942
6943        write_envelope_fixture(
6944            &paths,
6945            include_str!("../tests/fixtures/gh_shim/signed-envelope-v2-future-issued-at.json"),
6946        );
6947        match load_manifest(&paths, TEST_NOW) {
6948            Err(ManifestProblem::Invalid(error)) => {
6949                assert!(error.contains("future"), "unexpected error: {error}")
6950            }
6951            other => panic!("expected future issued_at refusal, got {other:?}"),
6952        }
6953
6954        // This signature is valid, but its provenance timestamp is 2,000,000
6955        // seconds old. A ceremony-once manifest remains active, so it still
6956        // classifies governed commands instead of scheduling an outage.
6957        write_envelope_fixture(
6958            &paths,
6959            include_str!("../tests/fixtures/gh_shim/signed-envelope-v2-stale-issued-at.json"),
6960        );
6961        let ManifestResolution::Active(manifest) = resolve_manifest(&paths, TEST_NOW) else {
6962            panic!("expected the aged signed manifest to remain active");
6963        };
6964        assert_eq!(manifest.issued_at_unix_secs, FIXTURE_ISSUED_AT - 2_000_000);
6965        assert!(matches!(
6966            classify(
6967                &[
6968                    OsString::from("issue"),
6969                    OsString::from("comment"),
6970                    OsString::from("42"),
6971                    OsString::from("--body"),
6972                    OsString::from("hello"),
6973                ],
6974                &manifest,
6975                "macos"
6976            ),
6977            Classification::Governed { tuple, .. } if tuple == "issue comment"
6978        ));
6979
6980        let report: Value =
6981            serde_json::from_str(&render_self_report(&paths).expect("self report serialization"))
6982                .expect("self report JSON");
6983        assert_eq!(
6984            report["cached_manifest"]["issued_at_unix_secs"],
6985            FIXTURE_ISSUED_AT - 2_000_000
6986        );
6987    }
6988
6989    #[test]
6990    fn standby_key_fixture_verifies_under_a_two_slot_trust_set_and_unknown_key_ids_are_refused() {
6991        let envelope: SignedManifest = serde_json::from_str(include_str!(
6992            "../tests/fixtures/gh_shim/signed-envelope-v2-standby-key.json"
6993        ))
6994        .expect("standby envelope fixture");
6995
6996        let standby = Ed25519KeyPair::from_seed_unchecked(&STANDBY_TEST_SEED).expect("standby key");
6997        assert_ne!(standby.public_key().as_ref(), DEV_MANIFEST_PUBLIC_KEY);
6998        let standby_public: &'static [u8] =
6999            Box::leak(standby.public_key().as_ref().to_vec().into_boxed_slice());
7000        let trust_set = [
7001            Some(ManifestTrustKey {
7002                key_id: DEV_MANIFEST_KEY_ID,
7003                public_key: &DEV_MANIFEST_PUBLIC_KEY,
7004            }),
7005            Some(ManifestTrustKey {
7006                key_id: DEV_STANDBY_MANIFEST_KEY_ID,
7007                public_key: standby_public,
7008            }),
7009        ];
7010
7011        // A standby-signed manifest is accepted under the two-slot set.
7012        let manifest =
7013            verify_manifest_signature_with(&envelope, &trust_set).expect("standby slot verifies");
7014        assert_eq!(
7015            manifest.manifest_version,
7016            fixture_manifest().manifest_version
7017        );
7018
7019        // A third, unknown key id is refused by the same set.
7020        let mut unknown = envelope.clone();
7021        unknown.key_id = "gh-routing-unknown-key".to_string();
7022        assert!(matches!(
7023            verify_manifest_signature_with(&unknown, &trust_set),
7024            Err(ManifestProblem::Invalid(_))
7025        ));
7026    }
7027
7028    #[test]
7029    fn compiled_trust_set_shape_matches_the_two_slot_design() {
7030        let slots = compiled_manifest_trust_set();
7031        // Every profile trusts the production root minted in the 2026-08-27
7032        // CKCRED ceremony (`signing:gh-manifest-root:1`); the bytes here are
7033        // the published public half, re-asserted so a trust-slot edit cannot
7034        // silently swap the live key.
7035        let live = slots[0].expect("live slot carries the production root");
7036        assert_eq!(live.key_id, PROD_MANIFEST_KEY_ID);
7037        assert_eq!(live.public_key, &PROD_MANIFEST_PUBLIC_KEY);
7038        #[cfg(debug_assertions)]
7039        {
7040            // Debug images verify both eras: prod live + the dev test key so
7041            // fixtures exercise R3 without a custody round-trip.
7042            assert_eq!(slots.len(), 2);
7043            assert_eq!(slots[1].unwrap().key_id, DEV_MANIFEST_KEY_ID);
7044        }
7045        #[cfg(not(debug_assertions))]
7046        {
7047            // The release set keeps two slots: prod live + a cold standby that
7048            // stays empty until a future custody release fills it.
7049            assert_eq!(slots.len(), 2);
7050            assert!(slots[1].is_none());
7051        }
7052    }
7053
7054    #[test]
7055    fn envelope_v1_shapes_are_refused_by_the_v2_verifier() {
7056        let directory = tempfile::tempdir().unwrap();
7057        let paths = StatePaths::from_root(directory.path().to_path_buf());
7058        let manifest = fixture_manifest();
7059        let bytes = serde_json::to_vec(&manifest).unwrap();
7060        let key = Ed25519KeyPair::from_seed_unchecked(&TEST_SEED).unwrap();
7061        let signature = base64::engine::general_purpose::STANDARD.encode(key.sign(&bytes).as_ref());
7062
7063        // The pre-v2 shape carried the parsed manifest object in the envelope.
7064        let v1_object = json!({
7065            "artifact_id": MANIFEST_ARTIFACT_ID,
7066            "key_id": DEV_MANIFEST_KEY_ID,
7067            "fetched_at_unix_secs": TEST_NOW,
7068            "signature": signature,
7069            "manifest": serde_json::to_value(&manifest).unwrap(),
7070        });
7071        fs::write(&paths.manifest, serde_json::to_vec(&v1_object).unwrap()).unwrap();
7072        assert!(matches!(
7073            load_manifest(&paths, TEST_NOW),
7074            Err(ManifestProblem::Invalid(_))
7075        ));
7076
7077        // An envelope naming an older version is refused even with raw bytes.
7078        let mut old_version = signed(&manifest, TEST_NOW);
7079        old_version.envelope_version = 1;
7080        fs::write(&paths.manifest, serde_json::to_vec(&old_version).unwrap()).unwrap();
7081        match load_manifest(&paths, TEST_NOW) {
7082            Err(ManifestProblem::Invalid(error)) => {
7083                assert!(
7084                    error.contains("envelope version"),
7085                    "unexpected error: {error}"
7086                )
7087            }
7088            other => panic!("expected envelope version refusal, got {other:?}"),
7089        }
7090    }
7091
7092    #[test]
7093    fn dormant_resolution_is_presence_based() {
7094        let directory = tempfile::tempdir().unwrap();
7095        let paths = StatePaths::from_root(directory.path().to_path_buf());
7096        // No artifact on disk: dormant.
7097        assert!(matches!(
7098            resolve_manifest(&paths, TEST_NOW),
7099            ManifestResolution::Dormant
7100        ));
7101
7102        // A failing artifact with no last-valid cache falls back without a
7103        // regressed classification, but remains distinguishable from a missing
7104        // public-install manifest so the invocation can announce the fallback.
7105        let untrusted = signed_with(
7106            &fixture_manifest(),
7107            TEST_NOW,
7108            &STANDBY_TEST_SEED,
7109            "gh-routing-unknown-key",
7110        );
7111        fs::write(&paths.manifest, serde_json::to_vec(&untrusted).unwrap()).unwrap();
7112        assert!(matches!(
7113            resolve_manifest(&paths, TEST_NOW),
7114            ManifestResolution::Invalid(ManifestProblem::Invalid(_))
7115        ));
7116    }
7117
7118    #[cfg(debug_assertions)] // verifies under the dev test key, which release trust sets exclude
7119    #[test]
7120    fn regressed_invalid_artifact_refuses_governed_and_admin_and_passes_mechanical() {
7121        let directory = tempfile::tempdir().unwrap();
7122        let paths = StatePaths::from_root(directory.path().to_path_buf());
7123        let now = TEST_NOW;
7124
7125        // Accept the canonical manifest; this writes the last-valid cache.
7126        write_signed_manifest(&paths, fixture_manifest(), now);
7127        load_manifest(&paths, now).expect("canonical manifest verifies");
7128
7129        // Break the installed artifact: signed bytes tampered after signing.
7130        write_envelope_fixture(
7131            &paths,
7132            include_str!("../tests/fixtures/gh_shim/signed-envelope-v2-tampered.json"),
7133        );
7134
7135        // A failed validation immediately enters the regressed arm; time passing
7136        // does not participate in manifest validity.
7137        let ManifestResolution::Regressed { manifest, problem } = resolve_manifest(&paths, now)
7138        else {
7139            panic!("expected the regressed arm");
7140        };
7141        let governed = [
7142            OsString::from("issue"),
7143            OsString::from("comment"),
7144            OsString::from("42"),
7145            OsString::from("--body"),
7146            OsString::from("hello"),
7147        ];
7148        assert!(matches!(
7149            regressed_disposition(&governed, &manifest, "macos", &problem),
7150            RegressedDisposition::Refuse {
7151                code: RefusalCode::ManifestRegressed,
7152                ..
7153            }
7154        ));
7155        let admin = [
7156            OsString::from("pr"),
7157            OsString::from("merge"),
7158            OsString::from("1"),
7159        ];
7160        assert!(matches!(
7161            regressed_disposition(&admin, &manifest, "macos", &problem),
7162            RegressedDisposition::Refuse {
7163                code: RefusalCode::ManifestRegressed,
7164                ..
7165            }
7166        ));
7167        let mechanical = [OsString::from("issue"), OsString::from("view")];
7168        assert!(matches!(
7169            regressed_disposition(&mechanical, &manifest, "macos", &problem),
7170            RegressedDisposition::Passthrough
7171        ));
7172        let undeclared = [OsString::from("alias"), OsString::from("set")];
7173        assert!(matches!(
7174            regressed_disposition(&undeclared, &manifest, "macos", &problem),
7175            RegressedDisposition::Refuse {
7176                code: RefusalCode::Unclassified,
7177                ..
7178            }
7179        ));
7180
7181        // The self report is loud about the regressed validation failure.
7182        let report = cached_manifest_report_at(&paths, now);
7183        assert_eq!(report.state, Some("regressed"));
7184        assert_eq!(report.version, Some(1));
7185        assert_eq!(report.issued_at_unix_secs, Some(FIXTURE_ISSUED_AT));
7186        assert_eq!(
7187            report.diagnostics,
7188            vec![
7189                SelfReportDiagnostic::ManifestRegressed.as_str(),
7190                SelfReportDiagnostic::ManifestInvalid.as_str(),
7191            ]
7192        );
7193    }
7194
7195    #[cfg(debug_assertions)] // verifies under the dev test key, which release trust sets exclude
7196    #[test]
7197    fn self_report_exposes_manifest_and_rung_record_provenance() {
7198        let directory = tempfile::tempdir().unwrap();
7199        let paths = StatePaths::from_root(directory.path().to_path_buf());
7200        write_signed_manifest(&paths, fixture_manifest(), TEST_NOW);
7201
7202        let manifest_report = cached_manifest_report_at(&paths, TEST_NOW);
7203        assert_eq!(manifest_report.state, Some("valid"));
7204        assert_eq!(
7205            manifest_report.verified_by_key_id.as_deref(),
7206            Some(DEV_MANIFEST_KEY_ID)
7207        );
7208        assert_eq!(
7209            manifest_report.compiled_trust_set_key_ids,
7210            trust_set_key_ids(compiled_manifest_trust_set())
7211        );
7212
7213        let provenance = RungRecordProvenance {
7214            image_path: "/opt/cortexkit/aft-gh-shim".to_string(),
7215            version: "0.53.0-test".to_string(),
7216            repo_key: "cortexkit/aft".to_string(),
7217        };
7218        let determination =
7219            RungDetermination::r2(TEST_NOW, R2Reason::DaemonUnreachable, Some(1), &provenance);
7220        write_rung_record_silently(&paths, &determination.record);
7221        let fresh_rung = last_rung_report(&paths);
7222        assert_eq!(
7223            fresh_rung.recorded_by_image_path.as_deref(),
7224            Some("/opt/cortexkit/aft-gh-shim")
7225        );
7226        assert_eq!(
7227            fresh_rung.recorded_by_version.as_deref(),
7228            Some("0.53.0-test")
7229        );
7230        assert_eq!(
7231            fresh_rung.recorded_by_repo_key.as_deref(),
7232            Some("cortexkit/aft")
7233        );
7234
7235        fs::write(
7236            &paths.rung,
7237            serde_json::to_vec(&json!({
7238                "rung": "R2",
7239                "as_of_unix_secs": TEST_NOW,
7240                "inputs": { "daemon_unreachable": "failed" },
7241                "manifest_version": 1
7242            }))
7243            .unwrap(),
7244        )
7245        .unwrap();
7246        let legacy_rung = last_rung_report(&paths);
7247        assert_eq!(
7248            legacy_rung.recorded_by_image_path.as_deref(),
7249            Some(PRE_PROVENANCE_RECORD)
7250        );
7251        assert_eq!(
7252            legacy_rung.recorded_by_version.as_deref(),
7253            Some(PRE_PROVENANCE_RECORD)
7254        );
7255        assert_eq!(
7256            legacy_rung.recorded_by_repo_key.as_deref(),
7257            Some(PRE_PROVENANCE_RECORD)
7258        );
7259    }
7260
7261    #[test]
7262    fn trust_set_provenance_explains_image_level_untrusted_key_regression() {
7263        let directory = tempfile::tempdir().unwrap();
7264        let paths = StatePaths::from_root(directory.path().to_path_buf());
7265        write_signed_manifest(&paths, fixture_manifest(), TEST_NOW);
7266        let verifier_a = [Some(ManifestTrustKey {
7267            key_id: DEV_MANIFEST_KEY_ID,
7268            public_key: &DEV_MANIFEST_PUBLIC_KEY,
7269        })];
7270        let verifier_b = [Some(PROD_MANIFEST_TRUST_KEY)];
7271
7272        let report_a = cached_manifest_report_at_with(&paths, TEST_NOW, &verifier_a);
7273        assert_eq!(report_a.state, Some("valid"));
7274        assert_eq!(
7275            report_a.verified_by_key_id.as_deref(),
7276            Some(DEV_MANIFEST_KEY_ID)
7277        );
7278        assert_eq!(
7279            report_a.compiled_trust_set_key_ids,
7280            vec![DEV_MANIFEST_KEY_ID]
7281        );
7282
7283        let report_b = cached_manifest_report_at_with(&paths, TEST_NOW, &verifier_b);
7284        assert_eq!(report_b.state, Some("regressed"));
7285        assert_eq!(report_b.verified_by_key_id, None);
7286        assert_eq!(
7287            report_b.compiled_trust_set_key_ids,
7288            vec![PROD_MANIFEST_KEY_ID]
7289        );
7290        assert_eq!(
7291            report_b.diagnostic_guidance,
7292            Some(UNTRUSTED_MANIFEST_KEY_STEERING)
7293        );
7294
7295        let cached = read_last_valid_manifest(&paths).expect("verifier A wrote last-valid cache");
7296        let governed = [
7297            OsString::from("issue"),
7298            OsString::from("comment"),
7299            OsString::from("42"),
7300            OsString::from("--body"),
7301            OsString::from("hello"),
7302        ];
7303        let untrusted =
7304            ManifestProblem::Invalid(format!("untrusted manifest key id {DEV_MANIFEST_KEY_ID}"));
7305        let RegressedDisposition::Refuse { text, .. } =
7306            regressed_disposition(&governed, &cached.manifest, "macos", &untrusted)
7307        else {
7308            panic!("a governed command must refuse under verifier B");
7309        };
7310        assert!(text.ends_with(UNTRUSTED_MANIFEST_KEY_STEERING));
7311    }
7312
7313    #[cfg(debug_assertions)] // verifies under the dev test key, which release trust sets exclude
7314    #[test]
7315    fn version_high_water_refuses_rollbacks_and_status_reports_them() {
7316        let directory = tempfile::tempdir().unwrap();
7317        let paths = StatePaths::from_root(directory.path().to_path_buf());
7318
7319        // Accept the newer manifest first.
7320        write_envelope_fixture(
7321            &paths,
7322            include_str!("../tests/fixtures/gh_shim/signed-envelope-v2-version-2.json"),
7323        );
7324        assert_eq!(load_manifest(&paths, TEST_NOW).unwrap().manifest_version, 2);
7325        assert_eq!(version_high_water(&paths), 2);
7326
7327        // A validly-signed OLDER manifest is then refused as a rollback
7328        // incident, never as ordinary out-of-order arrival.
7329        write_envelope_fixture(
7330            &paths,
7331            include_str!("../tests/fixtures/gh_shim/signed-envelope-v2.json"),
7332        );
7333        assert!(matches!(
7334            load_manifest(&paths, TEST_NOW),
7335            Err(ManifestProblem::RolledBack {
7336                manifest_version: 1,
7337                newest_accepted: 2,
7338            })
7339        ));
7340        let report = cached_manifest_report_at(&paths, TEST_NOW);
7341        assert_eq!(
7342            report.diagnostics,
7343            vec![
7344                SelfReportDiagnostic::ManifestRegressed.as_str(),
7345                SelfReportDiagnostic::ManifestRollback.as_str(),
7346            ]
7347        );
7348        // That rollback is also visible through the --status document.
7349        let document = render_self_report(&paths).expect("self report");
7350        assert!(document.contains(SelfReportDiagnostic::ManifestRollback.as_str()));
7351
7352        // Re-presenting the newest accepted version is not a rollback.
7353        write_envelope_fixture(
7354            &paths,
7355            include_str!("../tests/fixtures/gh_shim/signed-envelope-v2-version-2.json"),
7356        );
7357        assert_eq!(load_manifest(&paths, TEST_NOW).unwrap().manifest_version, 2);
7358    }
7359
7360    fn fixture_dir() -> PathBuf {
7361        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/gh_shim")
7362    }
7363
7364    fn canonical_manifest_bytes() -> Vec<u8> {
7365        fs::read(fixture_dir().join("initial-manifest-v1.json"))
7366            .expect("canonical manifest fixture")
7367    }
7368
7369    fn envelope_json(envelope: &SignedManifest) -> Vec<u8> {
7370        let mut bytes = serde_json::to_vec_pretty(envelope).expect("envelope serialization");
7371        bytes.push(b'\n');
7372        bytes
7373    }
7374
7375    /// Deterministic generator for every dev-signed envelope fixture. The
7376    /// canonical fixture's signature covers the exact bytes of the checked-in
7377    /// manifest file; variant fixtures re-sign their serialized variant bytes.
7378    fn generate_envelope_fixtures() -> Vec<(String, Vec<u8>)> {
7379        let sign = |bytes: &[u8], seed: &[u8; 32]| {
7380            let key = Ed25519KeyPair::from_seed_unchecked(seed).expect("fixture key");
7381            base64::engine::general_purpose::STANDARD.encode(key.sign(bytes).as_ref())
7382        };
7383        let envelope = |key_id: &str, seed: &[u8; 32], manifest_bytes: String| {
7384            envelope_json(&SignedManifest {
7385                artifact_id: MANIFEST_ARTIFACT_ID.to_string(),
7386                envelope_version: ENVELOPE_VERSION,
7387                key_id: key_id.to_string(),
7388                fetched_at_unix_secs: FIXTURE_ISSUED_AT,
7389                signature: sign(manifest_bytes.as_bytes(), seed),
7390                manifest_bytes,
7391            })
7392        };
7393
7394        let canonical = canonical_manifest_bytes();
7395        let canonical_text = String::from_utf8(canonical.clone()).expect("UTF-8 manifest");
7396        let canonical_signature = sign(&canonical, &TEST_SEED);
7397
7398        let mut fixtures = Vec::new();
7399        // Raw-bytes round-trip golden: signature over the published file.
7400        fixtures.push((
7401            "signed-envelope-v2.json".to_string(),
7402            envelope_json(&SignedManifest {
7403                artifact_id: MANIFEST_ARTIFACT_ID.to_string(),
7404                envelope_version: ENVELOPE_VERSION,
7405                key_id: DEV_MANIFEST_KEY_ID.to_string(),
7406                fetched_at_unix_secs: FIXTURE_ISSUED_AT,
7407                signature: canonical_signature.clone(),
7408                manifest_bytes: canonical_text.clone(),
7409            }),
7410        ));
7411        // Tampered-single-byte case: one substitution inside the signed bytes,
7412        // keeping the ORIGINAL signature so verification must fail.
7413        let tampered = canonical_text.replacen("issue view", "issue View", 1);
7414        assert_ne!(tampered, canonical_text);
7415        fixtures.push((
7416            "signed-envelope-v2-tampered.json".to_string(),
7417            envelope_json(&SignedManifest {
7418                artifact_id: MANIFEST_ARTIFACT_ID.to_string(),
7419                envelope_version: ENVELOPE_VERSION,
7420                key_id: DEV_MANIFEST_KEY_ID.to_string(),
7421                fetched_at_unix_secs: FIXTURE_ISSUED_AT,
7422                signature: canonical_signature,
7423                manifest_bytes: tampered,
7424            }),
7425        ));
7426
7427        let mut variant = |name: &str, mutate: fn(&mut Manifest), seed: &[u8; 32], key_id: &str| {
7428            let mut manifest = fixture_manifest();
7429            mutate(&mut manifest);
7430            let bytes = serde_json::to_vec(&manifest).expect("variant manifest bytes");
7431            fixtures.push((
7432                name.to_string(),
7433                envelope(
7434                    key_id,
7435                    seed,
7436                    String::from_utf8(bytes).expect("UTF-8 variant bytes"),
7437                ),
7438            ));
7439        };
7440        variant(
7441            "signed-envelope-v2-future-issued-at.json",
7442            |manifest| {
7443                manifest.issued_at_unix_secs =
7444                    FIXTURE_ISSUED_AT + ISSUED_AT_FUTURE_SKEW.as_secs() + 3300;
7445            },
7446            &TEST_SEED,
7447            DEV_MANIFEST_KEY_ID,
7448        );
7449        variant(
7450            "signed-envelope-v2-stale-issued-at.json",
7451            |manifest| {
7452                manifest.issued_at_unix_secs = FIXTURE_ISSUED_AT - 2_000_000;
7453            },
7454            &TEST_SEED,
7455            DEV_MANIFEST_KEY_ID,
7456        );
7457        variant(
7458            "signed-envelope-v2-version-2.json",
7459            |manifest| {
7460                manifest.manifest_version = 2;
7461            },
7462            &TEST_SEED,
7463            DEV_MANIFEST_KEY_ID,
7464        );
7465        variant(
7466            "signed-envelope-v2-standby-key.json",
7467            |_manifest| {},
7468            &STANDBY_TEST_SEED,
7469            DEV_STANDBY_MANIFEST_KEY_ID,
7470        );
7471        fixtures
7472    }
7473
7474    #[test]
7475    fn signed_envelope_fixtures_match_their_generator() {
7476        let regen = std::env::var_os("AFT_GH_SHIM_REGEN").is_some();
7477        for (name, bytes) in generate_envelope_fixtures() {
7478            let path = fixture_dir().join(&name);
7479            if regen {
7480                fs::write(&path, &bytes).expect("write fixture");
7481                continue;
7482            }
7483            let disk = fs::read(&path)
7484                .unwrap_or_else(|error| panic!("fixture {name} is missing: {error}"));
7485            assert_eq!(
7486                disk, bytes,
7487                "fixture {name} drifted from its generator; rerun with AFT_GH_SHIM_REGEN=1"
7488            );
7489        }
7490    }
7491
7492    fn retained_files(paths: &StatePaths) -> Vec<PathBuf> {
7493        fs::read_dir(&paths.manifests_dir)
7494            .map(|entries| {
7495                entries
7496                    .filter_map(Result::ok)
7497                    .map(|entry| entry.path())
7498                    .collect()
7499            })
7500            .unwrap_or_default()
7501    }
7502
7503    #[cfg(debug_assertions)] // verifies under the dev test key, which release trust sets exclude
7504    #[test]
7505    fn activation_retains_every_accepted_manifest_with_exact_bytes() {
7506        let directory = tempfile::tempdir().unwrap();
7507        let paths = StatePaths::from_root(directory.path().to_path_buf());
7508
7509        // Activate v11 then v12 in the same temp state dir.
7510        write_signed_manifest(&paths, v11_fixture_manifest(), TEST_NOW);
7511        assert_eq!(
7512            load_manifest(&paths, TEST_NOW).unwrap().manifest_version,
7513            11
7514        );
7515        write_signed_manifest(&paths, v12_fixture_manifest(), TEST_NOW);
7516        assert_eq!(
7517            load_manifest(&paths, TEST_NOW).unwrap().manifest_version,
7518            12
7519        );
7520
7521        // Two files, one per accepted version.
7522        let files = retained_files(&paths);
7523        assert_eq!(files.len(), 2);
7524
7525        // Reading each back and re-verifying its signature passes, and the
7526        // payload bytes are the exact signed bytes (never a re-serialization).
7527        let mut versions = BTreeSet::new();
7528        for file in &files {
7529            let record: RetainedManifest =
7530                serde_json::from_slice(&fs::read(file).unwrap()).expect("retained record");
7531            let envelope = SignedManifest {
7532                artifact_id: MANIFEST_ARTIFACT_ID.to_string(),
7533                envelope_version: ENVELOPE_VERSION,
7534                key_id: record.key_id.clone(),
7535                fetched_at_unix_secs: TEST_NOW,
7536                signature: record.signature.clone(),
7537                manifest_bytes: record.manifest_bytes.clone(),
7538            };
7539            let verified =
7540                verify_manifest_signature(&envelope).expect("retained signature re-verifies");
7541            versions.insert(verified.manifest_version);
7542        }
7543        assert_eq!(versions, BTreeSet::from([11, 12]));
7544
7545        // The self-report reflects the retained count and directory.
7546        let document = render_self_report(&paths).expect("self report");
7547        let value: Value = serde_json::from_str(&document).expect("self report JSON");
7548        assert_eq!(value["manifests_retained"], json!(2));
7549        assert_eq!(
7550            value["manifests_dir"],
7551            json!(paths.manifests_dir.to_string_lossy())
7552        );
7553    }
7554
7555    #[test]
7556    fn tampered_payload_is_not_retained() {
7557        let directory = tempfile::tempdir().unwrap();
7558        let paths = StatePaths::from_root(directory.path().to_path_buf());
7559
7560        // A tampered envelope fails signature verification, so activation is
7561        // refused and nothing is filed.
7562        write_envelope_fixture(
7563            &paths,
7564            include_str!("../tests/fixtures/gh_shim/signed-envelope-v2-tampered.json"),
7565        );
7566        assert!(load_manifest(&paths, TEST_NOW).is_err());
7567        assert!(retained_files(&paths).is_empty());
7568    }
7569
7570    #[test]
7571    fn version_mismatch_between_payload_and_filing_is_refused() {
7572        let directory = tempfile::tempdir().unwrap();
7573        let paths = StatePaths::from_root(directory.path().to_path_buf());
7574
7575        // A validly signed v11 payload presented under a v12 filing must be
7576        // refused: a signature authenticates bytes, not a label.
7577        let envelope = signed(&v11_fixture_manifest(), TEST_NOW);
7578        retain_manifest(&paths, &envelope, 12);
7579
7580        assert!(retained_files(&paths).is_empty());
7581    }
7582
7583    #[test]
7584    fn same_name_different_bytes_is_refused_and_existing_file_untouched() {
7585        let directory = tempfile::tempdir().unwrap();
7586        let paths = StatePaths::from_root(directory.path().to_path_buf());
7587
7588        // Pre-place a file at the exact name the v11 payload would use, but with
7589        // different bytes. Retention must refuse rather than silently replace
7590        // evidence.
7591        let envelope = signed(&v11_fixture_manifest(), TEST_NOW);
7592        let digest = Sha256::digest(envelope.manifest_bytes.as_bytes());
7593        let digest_hex = format!("{digest:x}");
7594        let destination = paths
7595            .manifests_dir
7596            .join(format!("v11-{}.json", &digest_hex[..16]));
7597        fs::create_dir_all(&paths.manifests_dir).unwrap();
7598        let original = b"{\"different\":\"bytes\"}".to_vec();
7599        fs::write(&destination, &original).unwrap();
7600
7601        retain_manifest(&paths, &envelope, 11);
7602
7603        assert_eq!(
7604            fs::read(&destination).unwrap(),
7605            original,
7606            "existing file must be untouched on collision"
7607        );
7608    }
7609
7610    #[cfg(unix)]
7611    #[cfg(debug_assertions)] // verifies under the dev test key, which release trust sets exclude
7612    #[test]
7613    fn retained_manifest_is_mode_0600() {
7614        use std::os::unix::fs::PermissionsExt;
7615
7616        let directory = tempfile::tempdir().unwrap();
7617        let paths = StatePaths::from_root(directory.path().to_path_buf());
7618
7619        write_signed_manifest(&paths, v11_fixture_manifest(), TEST_NOW);
7620        load_manifest(&paths, TEST_NOW).unwrap();
7621
7622        let files = retained_files(&paths);
7623        assert_eq!(files.len(), 1);
7624        let mode = fs::metadata(&files[0]).unwrap().permissions().mode();
7625        assert_eq!(mode & 0o777, 0o600);
7626    }
7627
7628    use subc_protocol::{Flags, Frame, FrameType, ModuleHelloAckBody, Priority, PROTOCOL_VERSION};
7629    use subc_transport::connection_file::{self, ConnectionInfo, Endpoint, SCHEMA_VERSION};
7630
7631    fn control_flags() -> Flags {
7632        Flags::new(false, Priority::Passive, false)
7633    }
7634
7635    struct SlowDaemonConfig {
7636        handshake_delay: Duration,
7637        catalog_delay: Duration,
7638        open_route_delay: Duration,
7639        /// When true, the configured stage delays apply only to the first
7640        /// accepted connection, so a retried probe (attempt 2) sees a fast
7641        /// daemon. Used to prove the discovery retry succeeds when the first
7642        /// attempt times out under load.
7643        first_connection_only: bool,
7644    }
7645
7646    struct SlowTestDaemon {
7647        port: u16,
7648        key: Vec<u8>,
7649        daemon_id: [u8; subc_transport::DAEMON_ID_LEN],
7650        shutdown_tx: Option<tokio::sync::oneshot::Sender<()>>,
7651        server_task: Option<std::thread::JoinHandle<()>>,
7652    }
7653
7654    impl SlowTestDaemon {
7655        fn spawn(config: SlowDaemonConfig) -> Self {
7656            let std_listener =
7657                std::net::TcpListener::bind("127.0.0.1:0").expect("bind test daemon");
7658            std_listener.set_nonblocking(true).expect("set nonblocking");
7659            let port = std_listener.local_addr().expect("local addr").port();
7660            let key = vec![0x42; subc_transport::KEY_LEN];
7661            let daemon_id = [0x24; subc_transport::DAEMON_ID_LEN];
7662            let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel::<()>();
7663
7664            let key_clone = key.clone();
7665            let daemon_id_clone = daemon_id;
7666
7667            let server_task = std::thread::spawn(move || {
7668                let rt = tokio::runtime::Builder::new_current_thread()
7669                    .enable_io()
7670                    .enable_time()
7671                    .build()
7672                    .expect("build daemon tokio runtime");
7673                rt.block_on(async move {
7674                    let listener =
7675                        tokio::net::TcpListener::from_std(std_listener).expect("tokio listener");
7676                    let mut connection_count = 0usize;
7677                    loop {
7678                        tokio::select! {
7679                            _ = &mut shutdown_rx => break,
7680                            accepted = listener.accept() => {
7681                                let Ok((mut stream, _)) = accepted else { break; };
7682                                let k = key_clone.clone();
7683                                let d = daemon_id_clone;
7684                                let first_connection = connection_count == 0;
7685                                connection_count += 1;
7686                                let hs_delay = if config.first_connection_only && !first_connection {
7687                                    Duration::ZERO
7688                                } else {
7689                                    config.handshake_delay
7690                                };
7691                                let cat_delay = if config.first_connection_only && !first_connection {
7692                                    Duration::ZERO
7693                                } else {
7694                                    config.catalog_delay
7695                                };
7696                                let open_delay = if config.first_connection_only && !first_connection {
7697                                    Duration::ZERO
7698                                } else {
7699                                    config.open_route_delay
7700                                };
7701                                tokio::spawn(async move {
7702                                    if hs_delay > Duration::ZERO {
7703                                        tokio::time::sleep(hs_delay).await;
7704                                    }
7705                                    if subc_transport::authenticate_server(
7706                                        &mut stream,
7707                                        &k,
7708                                        &d,
7709                                        "subc-test",
7710                                        Duration::from_secs(5),
7711                                    )
7712                                    .await
7713                                    .is_err()
7714                                    {
7715                                        return;
7716                                    }
7717
7718                                    loop {
7719                                        let frame = match subc_transport::read_frame(&mut stream).await {
7720                                            Ok(Some(frame)) => frame,
7721                                            _ => break,
7722                                        };
7723
7724                                        match frame.header.ty {
7725                                            FrameType::Hello => {
7726                                                let ack = Frame::build(
7727                                                    FrameType::HelloAck,
7728                                                    control_flags(),
7729                                                    0,
7730                                                    0,
7731                                                    frame.header.corr,
7732                                                    serde_json::to_vec(&ModuleHelloAckBody {
7733                                                        negotiated_ver: PROTOCOL_VERSION,
7734                                                        subc_ops: Vec::new(),
7735                                                        subc_capabilities: Vec::new(),
7736                                                        storage: None,
7737                                                    })
7738                                                    .expect("hello ack body"),
7739                                                )
7740                                                .expect("hello ack frame");
7741                                                if subc_transport::write_frame(&mut stream, &ack).await.is_err() {
7742                                                    break;
7743                                                }
7744                                            }
7745                                            FrameType::Request => {
7746                                                let op: Option<String> = serde_json::from_slice::<Value>(&frame.body)
7747                                                    .ok()
7748                                                    .and_then(|v| v.get("op").and_then(Value::as_str).map(String::from));
7749
7750                                                if op.as_deref() == Some("catalog.list") {
7751                                                    if !cat_delay.is_zero() {
7752                                                        tokio::time::sleep(cat_delay).await;
7753                                                    }
7754                                                    let response_body = json!({
7755                                                        "op": "catalog.list",
7756                                                        "generation": 1,
7757                                                        "modules": [{
7758                                                            "module_id": "prefrontal-core",
7759                                                            "module_version": "0.1.0",
7760                                                            "roles": [{
7761                                                                "role": "management_surface",
7762                                                                "operations": [{ "name": "gh.route", "kind": "query" }],
7763                                                                "config_schema": {},
7764                                                                "observability": [],
7765                                                                "identity_scope": ["project"]
7766                                                            }],
7767                                                            "control_ops": []
7768                                                        }],
7769                                                        "subc_ops": ["catalog.list", "route.open"]
7770                                                    });
7771                                                    let resp = Frame::build_with_version(
7772                                                        frame.header.ver,
7773                                                        FrameType::Response,
7774                                                        frame.header.flags,
7775                                                        frame.header.channel,
7776                                                        frame.header.epoch,
7777                                                        frame.header.corr,
7778                                                        serde_json::to_vec(&response_body).expect("catalog json"),
7779                                                    )
7780                                                    .expect("catalog response frame");
7781                                                    if subc_transport::write_frame(&mut stream, &resp).await.is_err() {
7782                                                        break;
7783                                                    }
7784                                                } else if op.as_deref() == Some("route.open") {
7785                                                    if !open_delay.is_zero() {
7786                                                        tokio::time::sleep(open_delay).await;
7787                                                    }
7788                                                    let response_body = json!({
7789                                                        "op": "route.open",
7790                                                        "route_channel": 42,
7791                                                        "route_epoch": 1
7792                                                    });
7793                                                    let resp = Frame::build_with_version(
7794                                                        frame.header.ver,
7795                                                        FrameType::Response,
7796                                                        frame.header.flags,
7797                                                        frame.header.channel,
7798                                                        frame.header.epoch,
7799                                                        frame.header.corr,
7800                                                        serde_json::to_vec(&response_body).expect("route open json"),
7801                                                    )
7802                                                    .expect("route open frame");
7803                                                    if subc_transport::write_frame(&mut stream, &resp).await.is_err() {
7804                                                        break;
7805                                                    }
7806                                                } else if op.as_deref() == Some("route.close") {
7807                                                    let response_body = json!({ "op": "route.close" });
7808                                                    let resp = Frame::build_with_version(
7809                                                        frame.header.ver,
7810                                                        FrameType::Response,
7811                                                        frame.header.flags,
7812                                                        frame.header.channel,
7813                                                        frame.header.epoch,
7814                                                        frame.header.corr,
7815                                                        serde_json::to_vec(&response_body).expect("route close json"),
7816                                                    )
7817                                                    .expect("route close frame");
7818                                                    if subc_transport::write_frame(&mut stream, &resp).await.is_err() {
7819                                                        break;
7820                                                    }
7821                                                } else if frame.header.channel == 42 {
7822                                                    let response_body = json!({
7823                                                        "outcome": "result",
7824                                                        "gh_route_schema": 1,
7825                                                        "result": { "url": "https://github.com/cortexkit/aft/issues/1#issuecomment-123" },
7826                                                        "field_order": ["url"]
7827                                                    });
7828                                                    let resp = Frame::build_with_version(
7829                                                        frame.header.ver,
7830                                                        FrameType::Response,
7831                                                        frame.header.flags,
7832                                                        frame.header.channel,
7833                                                        frame.header.epoch,
7834                                                        frame.header.corr,
7835                                                        serde_json::to_vec(&response_body).expect("result json"),
7836                                                    )
7837                                                    .expect("result frame");
7838                                                    if subc_transport::write_frame(&mut stream, &resp).await.is_err() {
7839                                                        break;
7840                                                    }
7841                                                }
7842                                            }
7843                                            _ => {}
7844                                        }
7845                                    }
7846                                });
7847                            }
7848                        }
7849                    }
7850                });
7851            });
7852
7853            Self {
7854                port,
7855                key,
7856                daemon_id,
7857                shutdown_tx: Some(shutdown_tx),
7858                server_task: Some(server_task),
7859            }
7860        }
7861
7862        fn write_connection_file(&self, path: &Path) {
7863            let conn = ConnectionInfo {
7864                schema: SCHEMA_VERSION,
7865                wire_version: Some(PROTOCOL_VERSION),
7866                endpoints: vec![Endpoint {
7867                    host: "127.0.0.1".to_string(),
7868                    port: self.port,
7869                }],
7870                key: self.key.clone(),
7871                daemon_id: self.daemon_id,
7872                pid: std::process::id(),
7873                daemon_ver: "gh-shim-test-daemon".to_string(),
7874            };
7875            connection_file::write_atomic(path, &conn).expect("write test daemon connection file");
7876        }
7877    }
7878
7879    impl Drop for SlowTestDaemon {
7880        fn drop(&mut self) {
7881            if let Some(tx) = self.shutdown_tx.take() {
7882                let _ = tx.send(());
7883            }
7884            let _ = std::net::TcpStream::connect(("127.0.0.1", self.port));
7885            if let Some(task) = self.server_task.take() {
7886                let _ = task.join();
7887            }
7888        }
7889    }
7890
7891    fn write_test_project_repo(root: &Path, repository: &str) -> PathBuf {
7892        let project = root.join("test-project");
7893        fs::create_dir_all(&project).expect("create project directory");
7894        Command::new("git")
7895            .args(["init", "--quiet"])
7896            .current_dir(&project)
7897            .status()
7898            .expect("init git repo");
7899        Command::new("git")
7900            .args([
7901                "remote",
7902                "add",
7903                "origin",
7904                &format!("https://github.com/{repository}.git"),
7905            ])
7906            .current_dir(&project)
7907            .status()
7908            .expect("add git origin");
7909        project
7910    }
7911
7912    /// Stage-naming tests: a deadline wide enough that a listening loopback daemon's
7913    /// connect and handshake finish under it even on a loaded Windows runner (train 51:
7914    /// the old 150 ms budget was blown at the connect stage, so the injected
7915    /// catalog_list delay was never reached and the assertion read the connect-stage
7916    /// outcome), with the injected stage delay far beyond it so the named stage is the
7917    /// one that times out. The discovery budget is now 2 s per stage, so the injected
7918    /// delay must exceed 2 s to force a timeout.
7919    const STAGE_TEST_DEADLINE: Duration = Duration::from_secs(2);
7920
7921    #[cfg(debug_assertions)] // verifies under the dev test key, which release trust sets exclude
7922    #[test]
7923    fn probe_exceeded_at_catalog_list_names_stage_and_budget_in_status_and_refusal() {
7924        let daemon = SlowTestDaemon::spawn(SlowDaemonConfig {
7925            handshake_delay: Duration::ZERO,
7926            catalog_delay: Duration::from_secs(6),
7927            open_route_delay: Duration::ZERO,
7928            first_connection_only: false,
7929        });
7930        let temp = tempfile::tempdir().unwrap();
7931        let paths = StatePaths::from_root(temp.path().join("state"));
7932        let conn_file = temp.path().join("subc-connection.json");
7933        daemon.write_connection_file(&conn_file);
7934        let project = write_test_project_repo(temp.path(), "cortexkit/aft");
7935
7936        let manifest = v12_fixture_manifest();
7937        let now = unix_seconds();
7938        write_signed_manifest(&paths, manifest, now);
7939
7940        let doc = json!({
7941            "subc": { "connection_file": conn_file.to_str().unwrap() }
7942        })
7943        .to_string();
7944
7945        let determination = determine_rung_from_doc(
7946            &paths,
7947            &project,
7948            now,
7949            Instant::now() + STAGE_TEST_DEADLINE,
7950            Some(&doc),
7951        );
7952        assert_eq!(determination.record.rung, Rung::R1);
7953        assert_eq!(
7954            determination.refusal_detail.as_deref(),
7955            Some(
7956                "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"
7957            )
7958        );
7959
7960        let last_probe = read_last_probe(&paths).expect("last probe record");
7961        assert_eq!(last_probe.stage, "catalog_list");
7962        assert_eq!(last_probe.outcome, "timed_out");
7963        assert!(last_probe.elapsed_ms >= 2000);
7964
7965        let report = render_self_report(&paths).expect("self report");
7966        let status: Value = serde_json::from_str(&report).expect("status json");
7967        assert_eq!(status["last_probe"]["stage"], "catalog_list");
7968        assert_eq!(status["last_probe"]["outcome"], "timed_out");
7969        assert!(status["last_probe"]["elapsed_ms"].as_u64().unwrap() >= 2000);
7970    }
7971
7972    #[cfg(debug_assertions)] // verifies under the dev test key, which release trust sets exclude
7973    #[test]
7974    fn probe_exceeded_at_open_route_names_stage_and_budget_in_status_and_refusal() {
7975        let daemon = SlowTestDaemon::spawn(SlowDaemonConfig {
7976            handshake_delay: Duration::ZERO,
7977            catalog_delay: Duration::ZERO,
7978            open_route_delay: Duration::from_secs(6),
7979            first_connection_only: false,
7980        });
7981        let temp = tempfile::tempdir().unwrap();
7982        let paths = StatePaths::from_root(temp.path().join("state"));
7983        let conn_file = temp.path().join("subc-connection.json");
7984        daemon.write_connection_file(&conn_file);
7985        let project = write_test_project_repo(temp.path(), "cortexkit/aft");
7986
7987        let manifest = v12_fixture_manifest();
7988        let now = unix_seconds();
7989        write_signed_manifest(&paths, manifest, now);
7990
7991        let doc = json!({
7992            "subc": { "connection_file": conn_file.to_str().unwrap() }
7993        })
7994        .to_string();
7995
7996        let determination = determine_rung_from_doc(
7997            &paths,
7998            &project,
7999            now,
8000            Instant::now() + STAGE_TEST_DEADLINE,
8001            Some(&doc),
8002        );
8003        assert_eq!(determination.record.rung, Rung::R1);
8004        assert_eq!(
8005            determination.refusal_detail.as_deref(),
8006            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")
8007        );
8008
8009        let last_probe = read_last_probe(&paths).expect("last probe record");
8010        assert_eq!(last_probe.stage, "open_route");
8011        assert_eq!(last_probe.outcome, "timed_out");
8012        assert!(last_probe.elapsed_ms >= 2000);
8013
8014        let report = render_self_report(&paths).expect("self report");
8015        let status: Value = serde_json::from_str(&report).expect("status json");
8016        assert_eq!(status["last_probe"]["stage"], "open_route");
8017        assert_eq!(status["last_probe"]["outcome"], "timed_out");
8018        assert!(status["last_probe"]["elapsed_ms"].as_u64().unwrap() >= 2000);
8019    }
8020
8021    #[cfg(debug_assertions)] // verifies under the dev test key, which release trust sets exclude
8022    #[test]
8023    fn discovery_retry_succeeds_when_only_the_first_attempt_times_out() {
8024        // The daemon delays only the first accepted connection, so the first
8025        // probe attempt times out at the catalog_list stage and the single
8026        // retry (after the 250 ms backoff) sees a fast daemon and reaches R3.
8027        let daemon = SlowTestDaemon::spawn(SlowDaemonConfig {
8028            handshake_delay: Duration::ZERO,
8029            catalog_delay: Duration::from_secs(6),
8030            open_route_delay: Duration::ZERO,
8031            first_connection_only: true,
8032        });
8033        let temp = tempfile::tempdir().unwrap();
8034        let paths = StatePaths::from_root(temp.path().join("state"));
8035        let conn_file = temp.path().join("subc-connection.json");
8036        daemon.write_connection_file(&conn_file);
8037        let project = write_test_project_repo(temp.path(), "cortexkit/aft");
8038
8039        let manifest = v12_fixture_manifest();
8040        let now = unix_seconds();
8041        write_signed_manifest(&paths, manifest, now);
8042
8043        let doc = json!({
8044            "subc": { "connection_file": conn_file.to_str().unwrap() }
8045        })
8046        .to_string();
8047
8048        let determination = determine_rung_from_doc(
8049            &paths,
8050            &project,
8051            now,
8052            Instant::now() + STAGE_TEST_DEADLINE,
8053            Some(&doc),
8054        );
8055        assert_eq!(determination.record.rung, Rung::R3);
8056        assert!(determination.refusal_detail.is_none());
8057
8058        // The retry succeeded, so the last probe records the successful attempt.
8059        let last_probe = read_last_probe(&paths).expect("last probe record");
8060        assert_eq!(last_probe.outcome, "ready");
8061    }
8062
8063    #[cfg(debug_assertions)] // verifies under the dev test key, which release trust sets exclude
8064    #[test]
8065    fn probe_connect_refused_keeps_unreachable_outcome() {
8066        let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("reserve port");
8067        let port = listener.local_addr().expect("port").port();
8068        drop(listener);
8069
8070        let temp = tempfile::tempdir().unwrap();
8071        let paths = StatePaths::from_root(temp.path().join("state"));
8072        let conn_file = temp.path().join("subc-connection.json");
8073        let conn = ConnectionInfo {
8074            schema: SCHEMA_VERSION,
8075            wire_version: Some(PROTOCOL_VERSION),
8076            endpoints: vec![Endpoint {
8077                host: "127.0.0.1".to_string(),
8078                port,
8079            }],
8080            key: vec![0x42; subc_transport::KEY_LEN],
8081            daemon_id: [0x24; subc_transport::DAEMON_ID_LEN],
8082            pid: std::process::id(),
8083            daemon_ver: "dead".to_string(),
8084        };
8085        connection_file::write_atomic(&conn_file, &conn).expect("write connection file");
8086        let project = write_test_project_repo(temp.path(), "cortexkit/aft");
8087
8088        let manifest = v12_fixture_manifest();
8089        let now = unix_seconds();
8090        write_signed_manifest(&paths, manifest, now);
8091
8092        let doc = json!({
8093            "subc": { "connection_file": conn_file.to_str().unwrap() }
8094        })
8095        .to_string();
8096
8097        let determination = determine_rung_from_doc(
8098            &paths,
8099            &project,
8100            now,
8101            Instant::now() + DISCOVERY_BUDGET,
8102            Some(&doc),
8103        );
8104        assert_eq!(determination.record.rung, Rung::R2);
8105        assert_eq!(determination.refusal_detail, None);
8106
8107        let last_probe = read_last_probe(&paths).expect("last probe record");
8108        assert_eq!(last_probe.stage, "connect");
8109        #[cfg(windows)]
8110        assert!(
8111            last_probe.outcome == "timed_out" || last_probe.outcome == "unreachable",
8112            "windows connect-refused probe outcome was {}",
8113            last_probe.outcome
8114        );
8115        #[cfg(not(windows))]
8116        assert_eq!(last_probe.outcome, "unreachable");
8117
8118        let report = render_self_report(&paths).expect("self report");
8119        let status: Value = serde_json::from_str(&report).expect("status json");
8120        assert_eq!(status["last_probe"]["stage"], "connect");
8121        #[cfg(windows)]
8122        assert!(
8123            status["last_probe"]["outcome"] == "timed_out"
8124                || status["last_probe"]["outcome"] == "unreachable"
8125        );
8126        #[cfg(not(windows))]
8127        assert_eq!(status["last_probe"]["outcome"], "unreachable");
8128    }
8129
8130    #[cfg(debug_assertions)] // verifies under the dev test key, which release trust sets exclude
8131    #[test]
8132    fn probe_connect_stage_budget_exceeded_determines_r2_and_records_last_probe() {
8133        let daemon = SlowTestDaemon::spawn(SlowDaemonConfig {
8134            handshake_delay: Duration::from_secs(6),
8135            catalog_delay: Duration::ZERO,
8136            open_route_delay: Duration::ZERO,
8137            first_connection_only: false,
8138        });
8139        let temp = tempfile::tempdir().unwrap();
8140        let paths = StatePaths::from_root(temp.path().join("state"));
8141        let conn_file = temp.path().join("subc-connection.json");
8142        daemon.write_connection_file(&conn_file);
8143        let project = write_test_project_repo(temp.path(), "cortexkit/aft");
8144
8145        let manifest = v12_fixture_manifest();
8146        let now = unix_seconds();
8147        write_signed_manifest(&paths, manifest, now);
8148
8149        let doc = json!({
8150            "subc": { "connection_file": conn_file.to_str().unwrap() }
8151        })
8152        .to_string();
8153
8154        let determination = determine_rung_from_doc(
8155            &paths,
8156            &project,
8157            now,
8158            Instant::now() + DISCOVERY_BUDGET,
8159            Some(&doc),
8160        );
8161        assert_eq!(determination.record.rung, Rung::R2);
8162        assert_eq!(determination.refusal_detail, None);
8163
8164        let last_probe = read_last_probe(&paths).expect("last probe record");
8165        assert_eq!(last_probe.stage, "connect");
8166        assert_eq!(last_probe.outcome, "timed_out");
8167        assert!(last_probe.elapsed_ms >= 2000);
8168
8169        let report = render_self_report(&paths).expect("self report");
8170        let status: Value = serde_json::from_str(&report).expect("status json");
8171        assert_eq!(status["last_probe"]["stage"], "connect");
8172        assert_eq!(status["last_probe"]["outcome"], "timed_out");
8173        assert!(status["last_probe"]["elapsed_ms"].as_u64().unwrap() >= 2000);
8174    }
8175
8176    #[cfg(debug_assertions)] // verifies under the dev test key, which release trust sets exclude
8177    #[test]
8178    fn slow_daemon_connect_delay_fallback_active_determines_r3_and_records_last_probe() {
8179        let daemon = SlowTestDaemon::spawn(SlowDaemonConfig {
8180            handshake_delay: Duration::from_secs(6),
8181            catalog_delay: Duration::ZERO,
8182            open_route_delay: Duration::ZERO,
8183            first_connection_only: false,
8184        });
8185        let temp = tempfile::tempdir().unwrap();
8186        let paths = StatePaths::from_root(temp.path().join("state"));
8187        let conn_file = temp.path().join("subc-connection.json");
8188        daemon.write_connection_file(&conn_file);
8189        let project = write_test_project_repo(temp.path(), "cortexkit/aft");
8190
8191        let manifest = v12_fixture_manifest();
8192        let now = unix_seconds();
8193        write_signed_manifest(&paths, manifest, now);
8194
8195        let mut cached_record = RungDetermination::r3(now - 30, 12, &test_rung_provenance()).record;
8196        cached_record.last_reachable_unix_secs = Some(now - 30);
8197        write_rung_record_silently(&paths, &cached_record);
8198
8199        let doc = json!({
8200            "subc": { "connection_file": conn_file.to_str().unwrap() }
8201        })
8202        .to_string();
8203
8204        let determination = determine_rung_from_doc(
8205            &paths,
8206            &project,
8207            now,
8208            Instant::now() + DISCOVERY_BUDGET,
8209            Some(&doc),
8210        );
8211        assert_eq!(determination.record.rung, Rung::R3);
8212        assert!(determination.refusal_detail.is_none());
8213
8214        let last_probe = read_last_probe(&paths).expect("last probe record");
8215        assert_eq!(last_probe.stage, "connect");
8216        assert_eq!(last_probe.outcome, "timed_out");
8217        assert!(last_probe.elapsed_ms >= 2000);
8218
8219        let report = render_self_report(&paths).expect("self report");
8220        let status: Value = serde_json::from_str(&report).expect("status json");
8221        assert_eq!(status["last_probe"]["stage"], "connect");
8222        assert_eq!(status["last_probe"]["outcome"], "timed_out");
8223        assert!(status["last_probe"]["elapsed_ms"].as_u64().unwrap() >= 2000);
8224    }
8225
8226    #[cfg(debug_assertions)] // verifies under the dev test key, which release trust sets exclude
8227    #[test]
8228    fn slow_daemon_catalog_list_delay_fallback_active_determines_r3_and_records_last_probe() {
8229        let daemon = SlowTestDaemon::spawn(SlowDaemonConfig {
8230            handshake_delay: Duration::ZERO,
8231            catalog_delay: Duration::from_secs(6),
8232            open_route_delay: Duration::ZERO,
8233            first_connection_only: false,
8234        });
8235        let temp = tempfile::tempdir().unwrap();
8236        let paths = StatePaths::from_root(temp.path().join("state"));
8237        let conn_file = temp.path().join("subc-connection.json");
8238        daemon.write_connection_file(&conn_file);
8239        let project = write_test_project_repo(temp.path(), "cortexkit/aft");
8240
8241        let manifest = v12_fixture_manifest();
8242        let now = unix_seconds();
8243        write_signed_manifest(&paths, manifest, now);
8244
8245        let mut cached_record = RungDetermination::r3(now - 30, 12, &test_rung_provenance()).record;
8246        cached_record.last_reachable_unix_secs = Some(now - 30);
8247        write_rung_record_silently(&paths, &cached_record);
8248
8249        let doc = json!({
8250            "subc": { "connection_file": conn_file.to_str().unwrap() }
8251        })
8252        .to_string();
8253
8254        let determination = determine_rung_from_doc(
8255            &paths,
8256            &project,
8257            now,
8258            Instant::now() + DISCOVERY_BUDGET,
8259            Some(&doc),
8260        );
8261        assert_eq!(determination.record.rung, Rung::R3);
8262        assert!(determination.refusal_detail.is_none());
8263
8264        let last_probe = read_last_probe(&paths).expect("last probe record");
8265        assert_eq!(last_probe.stage, "catalog_list");
8266        assert_eq!(last_probe.outcome, "timed_out");
8267        assert!(last_probe.elapsed_ms >= 2000);
8268
8269        let report = render_self_report(&paths).expect("self report");
8270        let status: Value = serde_json::from_str(&report).expect("status json");
8271        assert_eq!(status["last_probe"]["stage"], "catalog_list");
8272        assert_eq!(status["last_probe"]["outcome"], "timed_out");
8273        assert!(status["last_probe"]["elapsed_ms"].as_u64().unwrap() >= 2000);
8274    }
8275
8276    #[cfg(debug_assertions)] // verifies under the dev test key, which release trust sets exclude
8277    #[test]
8278    fn slow_daemon_catalog_list_delay_expired_fallback_refuses_naming_stage() {
8279        let daemon = SlowTestDaemon::spawn(SlowDaemonConfig {
8280            handshake_delay: Duration::ZERO,
8281            catalog_delay: Duration::from_secs(6),
8282            open_route_delay: Duration::ZERO,
8283            first_connection_only: false,
8284        });
8285        let temp = tempfile::tempdir().unwrap();
8286        let paths = StatePaths::from_root(temp.path().join("state"));
8287        let conn_file = temp.path().join("subc-connection.json");
8288        daemon.write_connection_file(&conn_file);
8289        let project = write_test_project_repo(temp.path(), "cortexkit/aft");
8290
8291        let manifest = v12_fixture_manifest();
8292        let now = unix_seconds();
8293        write_signed_manifest(&paths, manifest, now);
8294
8295        let mut cached_record =
8296            RungDetermination::r3(now - 301, 12, &test_rung_provenance()).record;
8297        cached_record.last_reachable_unix_secs = Some(now - 301);
8298        write_rung_record_silently(&paths, &cached_record);
8299
8300        let doc = json!({
8301            "subc": { "connection_file": conn_file.to_str().unwrap() }
8302        })
8303        .to_string();
8304
8305        let determination = determine_rung_from_doc(
8306            &paths,
8307            &project,
8308            now,
8309            Instant::now() + DISCOVERY_BUDGET,
8310            Some(&doc),
8311        );
8312        assert_eq!(determination.record.rung, Rung::R1);
8313        assert_eq!(
8314            determination.refusal_detail.as_deref(),
8315            Some(
8316                "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"
8317            )
8318        );
8319
8320        let report = render_self_report(&paths).expect("self report");
8321        let status: Value = serde_json::from_str(&report).expect("status json");
8322        assert_eq!(status["last_probe"]["stage"], "catalog_list");
8323        assert_eq!(status["last_probe"]["outcome"], "timed_out");
8324        assert!(status["last_probe"]["elapsed_ms"].as_u64().unwrap() >= 2000);
8325    }
8326
8327    #[cfg(debug_assertions)] // verifies under the dev test key, which release trust sets exclude
8328    #[test]
8329    fn slow_daemon_open_route_delay_fallback_active_determines_r3_and_records_last_probe() {
8330        let daemon = SlowTestDaemon::spawn(SlowDaemonConfig {
8331            handshake_delay: Duration::ZERO,
8332            catalog_delay: Duration::ZERO,
8333            open_route_delay: Duration::from_secs(6),
8334            first_connection_only: false,
8335        });
8336        let temp = tempfile::tempdir().unwrap();
8337        let paths = StatePaths::from_root(temp.path().join("state"));
8338        let conn_file = temp.path().join("subc-connection.json");
8339        daemon.write_connection_file(&conn_file);
8340        let project = write_test_project_repo(temp.path(), "cortexkit/aft");
8341
8342        let manifest = v12_fixture_manifest();
8343        let now = unix_seconds();
8344        write_signed_manifest(&paths, manifest, now);
8345
8346        let mut cached_record = RungDetermination::r3(now - 30, 12, &test_rung_provenance()).record;
8347        cached_record.last_reachable_unix_secs = Some(now - 30);
8348        write_rung_record_silently(&paths, &cached_record);
8349
8350        let doc = json!({
8351            "subc": { "connection_file": conn_file.to_str().unwrap() }
8352        })
8353        .to_string();
8354
8355        let determination = determine_rung_from_doc(
8356            &paths,
8357            &project,
8358            now,
8359            Instant::now() + DISCOVERY_BUDGET,
8360            Some(&doc),
8361        );
8362        assert_eq!(determination.record.rung, Rung::R3);
8363        assert!(determination.refusal_detail.is_none());
8364
8365        let last_probe = read_last_probe(&paths).expect("last probe record");
8366        assert_eq!(last_probe.stage, "open_route");
8367        assert_eq!(last_probe.outcome, "timed_out");
8368        assert!(last_probe.elapsed_ms >= 2000);
8369
8370        let report = render_self_report(&paths).expect("self report");
8371        let status: Value = serde_json::from_str(&report).expect("status json");
8372        assert_eq!(status["last_probe"]["stage"], "open_route");
8373        assert_eq!(status["last_probe"]["outcome"], "timed_out");
8374        assert!(status["last_probe"]["elapsed_ms"].as_u64().unwrap() >= 2000);
8375    }
8376
8377    #[cfg(debug_assertions)] // verifies under the dev test key, which release trust sets exclude
8378    #[test]
8379    fn slow_daemon_open_route_delay_expired_fallback_refuses_naming_stage() {
8380        let daemon = SlowTestDaemon::spawn(SlowDaemonConfig {
8381            handshake_delay: Duration::ZERO,
8382            catalog_delay: Duration::ZERO,
8383            open_route_delay: Duration::from_secs(6),
8384            first_connection_only: false,
8385        });
8386        let temp = tempfile::tempdir().unwrap();
8387        let paths = StatePaths::from_root(temp.path().join("state"));
8388        let conn_file = temp.path().join("subc-connection.json");
8389        daemon.write_connection_file(&conn_file);
8390        let project = write_test_project_repo(temp.path(), "cortexkit/aft");
8391
8392        let manifest = v12_fixture_manifest();
8393        let now = unix_seconds();
8394        write_signed_manifest(&paths, manifest, now);
8395
8396        let mut cached_record =
8397            RungDetermination::r3(now - 301, 12, &test_rung_provenance()).record;
8398        cached_record.last_reachable_unix_secs = Some(now - 301);
8399        write_rung_record_silently(&paths, &cached_record);
8400
8401        let doc = json!({
8402            "subc": { "connection_file": conn_file.to_str().unwrap() }
8403        })
8404        .to_string();
8405
8406        let determination = determine_rung_from_doc(
8407            &paths,
8408            &project,
8409            now,
8410            Instant::now() + DISCOVERY_BUDGET,
8411            Some(&doc),
8412        );
8413        assert_eq!(determination.record.rung, Rung::R1);
8414        assert_eq!(
8415            determination.refusal_detail.as_deref(),
8416            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")
8417        );
8418
8419        let report = render_self_report(&paths).expect("self report");
8420        let status: Value = serde_json::from_str(&report).expect("status json");
8421        assert_eq!(status["last_probe"]["stage"], "open_route");
8422        assert_eq!(status["last_probe"]["outcome"], "timed_out");
8423        assert!(status["last_probe"]["elapsed_ms"].as_u64().unwrap() >= 2000);
8424    }
8425}
8426
8427#[cfg(test)]
8428mod github_read_mutation_tests {
8429    //! These tests exercise PRIVATE gh_shim internals (GovernedRequest,
8430    //! GithubReadMutation) and can only compile beside them. They originally
8431    //! lived in the integration tree and reached the lib suite through an
8432    //! include! - a shape that breaks the moment anyone registers the file in
8433    //! the integration crate, so they live here as an ordinary module now.
8434    use super::invalidate_successful_github_read_mutation_at;
8435    use super::{GithubReadMutation, GovernedRequest, RouteOutcome};
8436    use crate::db::github_read_cache::GithubReadResourceKind;
8437
8438    use crate::db::github_read_cache::{
8439        lookup_github_read_cache_entry, upsert_github_read_cache_entry, GithubReadCacheKey,
8440    };
8441    use rusqlite::Connection;
8442
8443    fn github_read_mutation_request(
8444        action: &str,
8445        repository: &str,
8446        resource_number: i64,
8447    ) -> GovernedRequest {
8448        let mut target = serde_json::Map::new();
8449        target.insert(
8450            "number".to_string(),
8451            serde_json::Value::String(resource_number.to_string()),
8452        );
8453        GovernedRequest {
8454            action: action.to_string(),
8455            target,
8456            body: serde_json::Map::new(),
8457            repository: Some(repository.to_string()),
8458            manifest_version: 1,
8459            edit_last: false,
8460        }
8461    }
8462
8463    fn cache_key(repository: &str, resource_number: i64, identity: &str) -> GithubReadCacheKey {
8464        GithubReadCacheKey::new(
8465            GithubReadResourceKind::Issue,
8466            repository,
8467            resource_number,
8468            identity,
8469        )
8470    }
8471
8472    fn write_cached_issue(
8473        conn: &Connection,
8474        repository: &str,
8475        resource_number: i64,
8476        identity: &str,
8477    ) {
8478        upsert_github_read_cache_entry(
8479            conn,
8480            &cache_key(repository, resource_number, identity),
8481            "# Cached issue\n",
8482            1_000,
8483        )
8484        .expect("write cached issue");
8485    }
8486
8487    fn cached_issue_exists(
8488        conn: &Connection,
8489        repository: &str,
8490        resource_number: i64,
8491        identity: &str,
8492    ) -> bool {
8493        lookup_github_read_cache_entry(conn, &cache_key(repository, resource_number, identity))
8494            .expect("look up cached issue")
8495            .is_some()
8496    }
8497
8498    #[test]
8499    fn successful_structured_comment_mutation_invalidates_the_touched_issue_for_all_identities() {
8500        let storage = tempfile::tempdir().expect("create storage");
8501        let conn = crate::db::open(&storage.path().join("aft.db")).expect("open cache database");
8502        write_cached_issue(&conn, "cortexkit/aft", 42, "principal:alice");
8503        write_cached_issue(&conn, "cortexkit/aft", 42, "principal:bob");
8504
8505        let request = github_read_mutation_request("issue comment", "CortexKit/AFT", 42);
8506        let mutation = GithubReadMutation::from_governed_request(&request)
8507            .expect("structured issue comment has a cache resource");
8508        assert_eq!(mutation.normalized_repository, "cortexkit/aft");
8509        assert_eq!(mutation.resource_kind, GithubReadResourceKind::Issue);
8510        assert_eq!(mutation.resource_number, 42);
8511
8512        invalidate_successful_github_read_mutation_at(
8513            storage.path(),
8514            Some(&mutation),
8515            &RouteOutcome::Result("comment created".to_string()),
8516        );
8517
8518        assert!(
8519            !cached_issue_exists(&conn, "cortexkit/aft", 42, "principal:alice"),
8520            "a successful comment invalidates Alice's cached issue"
8521        );
8522        assert!(
8523            !cached_issue_exists(&conn, "cortexkit/aft", 42, "principal:bob"),
8524            "a successful comment invalidates every identity's cached issue"
8525        );
8526    }
8527
8528    #[test]
8529    fn failed_structured_comment_mutation_does_not_invalidate_the_touched_issue() {
8530        let storage = tempfile::tempdir().expect("create storage");
8531        let conn = crate::db::open(&storage.path().join("aft.db")).expect("open cache database");
8532        write_cached_issue(&conn, "cortexkit/aft", 42, "principal:alice");
8533
8534        let request = github_read_mutation_request("issue comment", "cortexkit/aft", 42);
8535        let mutation = GithubReadMutation::from_governed_request(&request)
8536            .expect("structured issue comment has a cache resource");
8537        invalidate_successful_github_read_mutation_at(
8538            storage.path(),
8539            Some(&mutation),
8540            &RouteOutcome::UpstreamError("comment rejected".to_string()),
8541        );
8542
8543        assert!(
8544            cached_issue_exists(&conn, "cortexkit/aft", 42, "principal:alice"),
8545            "a failed mutation must preserve the cached issue"
8546        );
8547    }
8548
8549    #[test]
8550    fn successful_mutation_for_a_different_issue_leaves_the_control_entry_intact() {
8551        let storage = tempfile::tempdir().expect("create storage");
8552        let conn = crate::db::open(&storage.path().join("aft.db")).expect("open cache database");
8553        write_cached_issue(&conn, "cortexkit/aft", 42, "principal:alice");
8554        write_cached_issue(&conn, "cortexkit/aft", 43, "principal:alice");
8555
8556        let request = github_read_mutation_request("issue comment", "cortexkit/aft", 43);
8557        let mutation = GithubReadMutation::from_governed_request(&request)
8558            .expect("structured issue comment has a cache resource");
8559        invalidate_successful_github_read_mutation_at(
8560            storage.path(),
8561            Some(&mutation),
8562            &RouteOutcome::Result("comment created".to_string()),
8563        );
8564
8565        assert!(
8566            cached_issue_exists(&conn, "cortexkit/aft", 42, "principal:alice"),
8567            "a mutation for another issue must not evict the control entry"
8568        );
8569        assert!(
8570            !cached_issue_exists(&conn, "cortexkit/aft", 43, "principal:alice"),
8571            "the successful mutation must still evict its own issue"
8572        );
8573    }
8574}