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