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
7use std::collections::{BTreeMap, BTreeSet};
8use std::ffi::{OsStr, OsString};
9use std::fs::{self, OpenOptions};
10use std::io::{self, Write};
11use std::path::{Path, PathBuf};
12use std::process::Command;
13use std::time::{Duration, SystemTime, UNIX_EPOCH};
14
15use base64::Engine;
16use ring::signature::{UnparsedPublicKey, ED25519};
17use serde::{Deserialize, Serialize};
18use serde_json::{json, Map, Value};
19use subc_client_rs::{CallOptions, CloseRouteOptions, ConsumerOptions, SubcConsumer};
20use subc_protocol::manifest::ProviderRole;
21use subc_protocol::{BindIdentity, RouteTarget};
22
23pub const SCHEMA_FLOOR: u64 = 1;
24pub const REFUSAL_EXIT_STATUS: i32 = 86;
25const DISCOVERY_BUDGET: Duration = Duration::from_millis(150);
26const DISCOVERY_CACHE_TTL: Duration = Duration::from_secs(15);
27const MANIFEST_TTL: Duration = Duration::from_secs(15 * 60);
28const MANIFEST_STALE_GRACE: Duration = Duration::from_secs(24 * 60 * 60);
29const ROUTING_OPERATION: &str = "gh.route";
30const ROUTING_HOLDER_MODULE_ID: &str = "prefrontal-core";
31const MANIFEST_ARTIFACT_ID: &str = "gh-routing-manifest";
32const V1_GOVERNED_TUPLES: &[&str] = &["issue comment", "pr comment", "pr review", "issue reaction"];
33const V1_ADMIN_TUPLES: &[&str] = &["issue close", "pr close", "pr merge", "release create"];
34const RESERVED_SELF_REPORT: &[&str] = &["--status", "--shim-version"];
35
36/// The only shim-originated refusal identifiers. Keep this enumeration closed:
37/// callers must parse these identifiers rather than human prose.
38#[derive(Clone, Copy, Debug, Eq, PartialEq)]
39pub enum RefusalCode {
40    Unclassified,
41    AdminTier,
42    ManifestStale,
43    ManifestBelowFloor,
44    SeamSchemaMismatch,
45    UnboundIdentity,
46    BypassAuditUnavailable,
47    NoRealGh,
48    SeamUnavailable,
49    SeamRefusal,
50}
51
52impl RefusalCode {
53    pub const ALL: [Self; 10] = [
54        Self::Unclassified,
55        Self::AdminTier,
56        Self::ManifestStale,
57        Self::ManifestBelowFloor,
58        Self::SeamSchemaMismatch,
59        Self::UnboundIdentity,
60        Self::BypassAuditUnavailable,
61        Self::NoRealGh,
62        Self::SeamUnavailable,
63        Self::SeamRefusal,
64    ];
65
66    pub const fn as_str(self) -> &'static str {
67        match self {
68            Self::Unclassified => "gh_shim_unclassified",
69            Self::AdminTier => "gh_shim_admin_tier",
70            Self::ManifestStale => "gh_shim_manifest_stale",
71            Self::ManifestBelowFloor => "gh_shim_manifest_below_floor",
72            Self::SeamSchemaMismatch => "gh_shim_seam_schema_mismatch",
73            Self::UnboundIdentity => "gh_shim_unbound_identity",
74            Self::BypassAuditUnavailable => "gh_shim_bypass_audit_unavailable",
75            Self::NoRealGh => "gh_shim_no_real_gh",
76            Self::SeamUnavailable => "gh_shim_seam_unavailable",
77            Self::SeamRefusal => "gh_shim_seam_refusal",
78        }
79    }
80}
81
82/// Offline self-report uses diagnostic identifiers distinct from invocation
83/// refusals. A report can therefore describe historical local-state trouble
84/// without pretending that an upstream `gh` invocation was refused.
85#[derive(Clone, Copy, Debug, Eq, PartialEq)]
86pub enum SelfReportDiagnostic {
87    ManifestUnavailable,
88    ManifestInvalid,
89    ManifestBelowFloor,
90    ManifestStale,
91    RungUnavailable,
92}
93
94impl SelfReportDiagnostic {
95    pub const ALL: [Self; 5] = [
96        Self::ManifestUnavailable,
97        Self::ManifestInvalid,
98        Self::ManifestBelowFloor,
99        Self::ManifestStale,
100        Self::RungUnavailable,
101    ];
102
103    pub const fn as_str(self) -> &'static str {
104        match self {
105            Self::ManifestUnavailable => "gh_shim_status_manifest_unavailable",
106            Self::ManifestInvalid => "gh_shim_status_manifest_invalid",
107            Self::ManifestBelowFloor => "gh_shim_status_manifest_below_floor",
108            Self::ManifestStale => "gh_shim_status_manifest_stale",
109            Self::RungUnavailable => "gh_shim_status_rung_unavailable",
110        }
111    }
112}
113
114#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
115#[serde(rename_all = "lowercase")]
116pub enum Tier {
117    Mechanical,
118    Governed,
119    Admin,
120}
121
122impl Tier {
123    fn rank(self) -> u8 {
124        match self {
125            Self::Mechanical => 0,
126            Self::Governed => 1,
127            Self::Admin => 2,
128        }
129    }
130}
131
132#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
133#[serde(rename_all = "UPPERCASE")]
134pub enum Rung {
135    R1,
136    R2,
137    R3,
138}
139
140impl Rung {
141    const fn label(self) -> &'static str {
142        match self {
143            Self::R1 => "R1",
144            Self::R2 => "R2",
145            Self::R3 => "R3",
146        }
147    }
148}
149
150/// Return true when the process was invoked through the `gh` symlink or the
151/// explicit `aft gh-shim` development entry point. This is public so the binary
152/// can perform it before its own global `--version` and `--subc` scans.
153pub fn is_shim_invocation(program: &OsStr, args: &[OsString]) -> bool {
154    Path::new(program)
155        .file_name()
156        .is_some_and(|name| name == OsStr::new("gh"))
157        || args.first().is_some_and(|arg| arg == OsStr::new("gh-shim"))
158}
159
160pub fn is_shim_invocation_from_env() -> bool {
161    let mut argv = std::env::args_os();
162    let Some(program) = argv.next() else {
163        return false;
164    };
165    is_shim_invocation(&program, &argv.collect::<Vec<_>>())
166}
167
168/// Execute the shim for either supported entry form. This intentionally runs
169/// before logging initialization so delegating invocations cannot add shim bytes
170/// to upstream stderr.
171pub fn run_from_env() -> i32 {
172    let mut argv = std::env::args_os();
173    let Some(program) = argv.next() else {
174        return refuse(RefusalCode::NoRealGh, "the executing image was unavailable");
175    };
176    let raw_args = argv.collect::<Vec<_>>();
177    let shim_args = if Path::new(&program)
178        .file_name()
179        .is_some_and(|name| name == OsStr::new("gh"))
180    {
181        raw_args
182    } else {
183        raw_args.into_iter().skip(1).collect()
184    };
185    run(&shim_args)
186}
187
188fn run(args: &[OsString]) -> i32 {
189    let paths = StatePaths::from_process();
190    if is_reserved_self_report(args) {
191        print_self_report(&paths);
192        return 0;
193    }
194
195    let now = unix_seconds();
196    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
197    let determination = determine_rung(&paths, &cwd, now);
198    if determination.rung != Rung::R3 {
199        return delegate(args);
200    }
201
202    // A valid manifest gates R3 both during fresh discovery and when a cached
203    // R3 determination is reused. If it disappears or expires between those
204    // two moments, B1 requires whole-invocation R2 passthrough instead of a
205    // classification-shaped refusal.
206    let manifest = match load_manifest(&paths, now) {
207        Ok(manifest) => manifest,
208        Err(_) => return delegate(args),
209    };
210    let Some(agent_binding) = resolved_agent_binding(&manifest, &cwd) else {
211        return delegate(args);
212    };
213
214    match classify(args, &manifest, current_platform()) {
215        Classification::Mechanical => delegate(args),
216        Classification::Admin { tuple } => {
217            if std::env::var_os("GH_SHIM_BYPASS").as_deref() == Some(OsStr::new("operator")) {
218                let repository = explicit_repo(args).or_else(infer_repository_from_git);
219                if let Err(error) = append_bypass_audit(&paths, &tuple, repository.as_deref(), now)
220                {
221                    return refuse(
222                        RefusalCode::BypassAuditUnavailable,
223                        &format!("operator bypass audit could not be appended: {error}"),
224                    );
225                }
226                delegate(args)
227            } else {
228                refuse(
229                    RefusalCode::AdminTier,
230                    "this action requires GH_SHIM_BYPASS=operator",
231                )
232            }
233        }
234        Classification::Governed { tuple, canonical } => {
235            let request =
236                match canonicalize_governed(args, &tuple, &canonical, manifest.manifest_version) {
237                    Ok(request) => request,
238                    Err(_) => {
239                        return refuse(
240                            RefusalCode::Unclassified,
241                            &format!(
242                                "undeclared shape for {tuple} in manifest {}",
243                                manifest.manifest_version
244                            ),
245                        )
246                    }
247                };
248            governed_outcome_status(route_governed(
249                &paths,
250                &determination,
251                &agent_binding,
252                request,
253                now,
254            ))
255        }
256        Classification::Unclassified => refuse(
257            RefusalCode::Unclassified,
258            &format!(
259                "no manifest declaration for this invocation (manifest {})",
260                manifest.manifest_version
261            ),
262        ),
263    }
264}
265
266fn governed_outcome_status(outcome: RouteOutcome) -> i32 {
267    match outcome {
268        RouteOutcome::Result(output) => {
269            print!("{output}");
270            0
271        }
272        RouteOutcome::Refusal(code) => refuse(RefusalCode::SeamRefusal, &seam_refusal_text(&code)),
273        RouteOutcome::UnboundIdentity => refuse(
274            RefusalCode::UnboundIdentity,
275            "the project binding was unavailable at route time",
276        ),
277        RouteOutcome::SchemaMismatch(message) => refuse(RefusalCode::SeamSchemaMismatch, &message),
278        RouteOutcome::Unavailable(message) => refuse(RefusalCode::SeamUnavailable, &message),
279    }
280}
281
282fn seam_refusal_text(code: &str) -> String {
283    format!("governance seam refused the action: {code}")
284}
285
286fn is_reserved_self_report(args: &[OsString]) -> bool {
287    args.first()
288        .and_then(|arg| arg.to_str())
289        .is_some_and(|arg| RESERVED_SELF_REPORT.contains(&arg))
290}
291
292#[derive(Clone, Debug)]
293struct StatePaths {
294    root: PathBuf,
295    manifest: PathBuf,
296    rung: PathBuf,
297    bypass_audit: PathBuf,
298    unexpected_gh_route_advertisers: PathBuf,
299    seam_state: PathBuf,
300}
301
302impl StatePaths {
303    fn from_process() -> Self {
304        let root = std::env::var_os("XDG_STATE_HOME")
305            .map(PathBuf::from)
306            .filter(|path| path.is_absolute())
307            .or_else(|| {
308                std::env::var_os("HOME")
309                    .or_else(|| std::env::var_os("USERPROFILE"))
310                    .map(|home| PathBuf::from(home).join(".local/state"))
311            })
312            .unwrap_or_else(|| std::env::temp_dir())
313            .join("cortexkit")
314            .join("aft")
315            .join("gh-shim");
316        Self::from_root(root)
317    }
318
319    fn from_root(root: PathBuf) -> Self {
320        Self {
321            manifest: root.join("gh-routing-manifest.json"),
322            rung: root.join("rung-cache.json"),
323            bypass_audit: root.join("operator-bypass.jsonl"),
324            unexpected_gh_route_advertisers: root.join("unexpected-gh-route-advertisers.json"),
325            seam_state: root.join("seam-state.json"),
326            root,
327        }
328    }
329}
330
331#[derive(Clone, Debug, Deserialize, Serialize)]
332struct RungRecord {
333    rung: Rung,
334    as_of_unix_secs: u64,
335    #[serde(default)]
336    inputs: BTreeMap<String, String>,
337    #[serde(default)]
338    manifest_version: Option<u64>,
339}
340
341impl RungRecord {
342    fn r1(now: u64, reason: &str) -> Self {
343        Self {
344            rung: Rung::R1,
345            as_of_unix_secs: now,
346            inputs: BTreeMap::from([("connection_file".to_string(), reason.to_string())]),
347            manifest_version: None,
348        }
349    }
350
351    fn r2(now: u64, reason: &str, manifest_version: Option<u64>) -> Self {
352        Self {
353            rung: Rung::R2,
354            as_of_unix_secs: now,
355            inputs: BTreeMap::from([
356                ("connection_file".to_string(), "ready".to_string()),
357                (reason.to_string(), "failed".to_string()),
358            ]),
359            manifest_version,
360        }
361    }
362
363    fn r3(now: u64, manifest_version: u64) -> Self {
364        Self {
365            rung: Rung::R3,
366            as_of_unix_secs: now,
367            inputs: BTreeMap::from([
368                ("connection_file".to_string(), "ready".to_string()),
369                ("catalog_gh_route".to_string(), "ready".to_string()),
370                ("agent_binding".to_string(), "ready".to_string()),
371                ("manifest".to_string(), "ready".to_string()),
372                (
373                    "agent_credentials_present".to_string(),
374                    "absent".to_string(),
375                ),
376            ]),
377            manifest_version: Some(manifest_version),
378        }
379    }
380
381    fn fresh_at(&self, now: u64) -> bool {
382        now.saturating_sub(self.as_of_unix_secs) < DISCOVERY_CACHE_TTL.as_secs()
383    }
384}
385
386fn determine_rung(paths: &StatePaths, cwd: &Path, now: u64) -> RungRecord {
387    // The budget starts before the config read and connection-file stat. This
388    // keeps a slow filesystem from silently extending discovery beyond 150ms.
389    let deadline = std::time::Instant::now() + DISCOVERY_BUDGET;
390    let Some(connection_file) = configured_connection_file() else {
391        // R1 has no daemon dial and no durable determination write.
392        return RungRecord::r1(now, "absent_or_unparseable");
393    };
394
395    let cached = load_rung_record(paths);
396    if std::time::Instant::now() >= deadline {
397        return cached
398            .filter(|record| record.fresh_at(now))
399            .unwrap_or_else(|| RungRecord::r1(now, "discovery_budget_exhausted"));
400    }
401    if let Some(record) = cached.as_ref().filter(|record| record.fresh_at(now)) {
402        if record.rung != Rung::R3
403            || load_manifest(paths, now)
404                .ok()
405                .and_then(|manifest| resolved_agent_binding(&manifest, cwd))
406                .is_some()
407        {
408            return record.clone();
409        }
410    }
411
412    // The signed manifest supplies the binding before the probe opens a route, so
413    // rate accounting and audit records use the same agent session on every run.
414    let manifest = match load_manifest(paths, now) {
415        Ok(manifest) => manifest,
416        Err(_) => {
417            let record = RungRecord::r2(now, "manifest_unavailable", None);
418            write_rung_record_silently(paths, &record);
419            return record;
420        }
421    };
422    let Some(agent_binding) = resolved_agent_binding(&manifest, cwd) else {
423        let record = RungRecord::r2(
424            now,
425            "agent_binding_unavailable",
426            Some(manifest.manifest_version),
427        );
428        write_rung_record_silently(paths, &record);
429        return record;
430    };
431
432    let discovery = probe_governance(
433        paths,
434        &connection_file,
435        cwd,
436        deadline,
437        &agent_binding.agent_id,
438    );
439    let record = match discovery {
440        ProbeResult::Ready { module_id } => {
441            match find_ambient_agent_credential(&manifest.detectors) {
442                Some(source) => {
443                    let mut record = RungRecord::r2(
444                        now,
445                        "agent_credentials_present",
446                        Some(manifest.manifest_version),
447                    );
448                    record
449                        .inputs
450                        .insert("agent_credentials_present".to_string(), source);
451                    record
452                        .inputs
453                        .insert("catalog_holder".to_string(), module_id);
454                    record
455                }
456                None => RungRecord::r3(now, manifest.manifest_version),
457            }
458        }
459        ProbeResult::Unreachable => RungRecord::r2(now, "daemon_unreachable", None),
460        ProbeResult::NoRoute => RungRecord::r2(now, "catalog_gh_route_absent", None),
461        ProbeResult::Unbound => RungRecord::r2(now, "agent_binding_unavailable", None),
462        ProbeResult::TimedOut => cached
463            .filter(|record| record.fresh_at(now))
464            .unwrap_or_else(|| RungRecord::r1(now, "discovery_budget_exhausted")),
465    };
466
467    if record.rung != Rung::R1 {
468        write_rung_record_silently(paths, &record);
469    }
470    record
471}
472
473fn configured_connection_file() -> Option<PathBuf> {
474    let xdg_config_home = std::env::var_os("XDG_CONFIG_HOME");
475    let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"));
476    configured_connection_file_from(xdg_config_home.as_deref(), home.as_deref())
477}
478
479fn configured_connection_file_from(
480    xdg_config_home: Option<&OsStr>,
481    home: Option<&OsStr>,
482) -> Option<PathBuf> {
483    // The shim uses the same user-tier resolver as subc: `$XDG_CONFIG_HOME/cortexkit/aft.jsonc`,
484    // then `~/.config/cortexkit/aft.jsonc`. XDG selects only the trusted user's
485    // config location; it cannot select a project file or alter the configured
486    // connection. If that path is invalid, resolution falls back to the lower-priority
487    // R1 route instead of allowing a routing bypass.
488    let config_path = crate::subc_config::user_config_path_from(xdg_config_home, home)?;
489    let doc = fs::read_to_string(config_path).ok()?;
490    connection_file_from_config_doc(&doc).filter(|path| path.is_file())
491}
492
493fn connection_file_from_config_doc(doc: &str) -> Option<PathBuf> {
494    let value: Value = serde_json::from_str(&crate::jsonc::strip_jsonc(doc)).ok()?;
495    let raw = value.get("subc")?.get("connection_file")?.as_str()?.trim();
496    let path = PathBuf::from(raw);
497    (!raw.is_empty() && path.is_absolute()).then_some(path)
498}
499
500fn load_rung_record(paths: &StatePaths) -> Option<RungRecord> {
501    serde_json::from_slice(&fs::read(&paths.rung).ok()?).ok()
502}
503
504fn write_rung_record_silently(paths: &StatePaths, record: &RungRecord) {
505    let Ok(bytes) = serde_json::to_vec(record) else {
506        return;
507    };
508    let _ = fs::create_dir_all(&paths.root);
509    let temporary = paths.root.join("rung-cache.json.tmp");
510    if fs::write(&temporary, bytes).is_ok() {
511        let _ = fs::rename(temporary, &paths.rung);
512    }
513}
514
515#[derive(Debug)]
516enum ProbeResult {
517    Ready { module_id: String },
518    Unreachable,
519    NoRoute,
520    Unbound,
521    TimedOut,
522}
523
524fn probe_governance(
525    paths: &StatePaths,
526    connection_file: &Path,
527    cwd: &Path,
528    deadline: std::time::Instant,
529    agent_id: &str,
530) -> ProbeResult {
531    let remaining = deadline.saturating_duration_since(std::time::Instant::now());
532    if remaining.is_zero() {
533        return ProbeResult::TimedOut;
534    }
535    let connection_file = connection_file.to_path_buf();
536    let project_root = project_root_for(cwd);
537    let record_paths = paths.clone();
538    let agent_id = agent_id.to_string();
539    let Ok(runtime) = tokio::runtime::Builder::new_current_thread()
540        .enable_io()
541        .enable_time()
542        .build()
543    else {
544        return ProbeResult::Unreachable;
545    };
546
547    match runtime.block_on(tokio::time::timeout(remaining, async move {
548        let options = ConsumerOptions {
549            call_timeout: remaining,
550            ..ConsumerOptions::default()
551        };
552        let consumer = SubcConsumer::connect(&connection_file, options)
553            .await
554            .map_err(|_| ProbeResult::Unreachable)?;
555        let catalog = consumer
556            .catalog_list()
557            .await
558            .map_err(|_| ProbeResult::Unreachable)?;
559        let holder = route_holder(&catalog.modules);
560        record_unexpected_gh_route_advertisers(&record_paths, &holder.unexpected_advertisers);
561        let Some(module_id) = holder.module_id else {
562            return Err(ProbeResult::NoRoute);
563        };
564        let identity = BindIdentity {
565            project_root: project_root.to_string_lossy().into_owned().into(),
566            harness: "aft-gh-shim".to_string(),
567            session: gh_session_id(&agent_id),
568        };
569        let route = consumer
570            .open_route(
571                RouteTarget::ManagementSurface {
572                    module_id: module_id.clone(),
573                },
574                identity,
575                CallOptions::default(),
576            )
577            .await
578            .map_err(|_| ProbeResult::Unbound)?;
579        let _ = consumer
580            .close_handle(&route, CloseRouteOptions::default())
581            .await;
582        Ok(module_id)
583    })) {
584        Ok(Ok(module_id)) => ProbeResult::Ready { module_id },
585        Ok(Err(result)) => result,
586        Err(_) => ProbeResult::TimedOut,
587    }
588}
589
590#[derive(Debug, Default, Eq, PartialEq)]
591struct RouteHolder {
592    module_id: Option<String>,
593    unexpected_advertisers: Vec<String>,
594}
595
596fn route_holder(entries: &[subc_client_rs::CatalogEntry]) -> RouteHolder {
597    select_route_holder(entries.iter().filter_map(|entry| {
598        entry
599            .roles
600            .iter()
601            .any(|role| {
602                matches!(
603                    role,
604                    ProviderRole::ManagementSurface { operations, .. }
605                        if operations.iter().any(|operation| operation.name == ROUTING_OPERATION)
606                )
607            })
608            .then(|| entry.module_id.clone())
609    }))
610}
611
612fn select_route_holder(advertisers: impl IntoIterator<Item = String>) -> RouteHolder {
613    let mut holder = None;
614    let mut unexpected_advertisers = BTreeSet::new();
615    for advertiser in advertisers {
616        // Governed routes carry identity-bearing writes, so only prefrontal-core may
617        // hold `gh.route`; another module advertising it must not capture the route.
618        // The holder module identifies the routing server, not the bound agent. Using
619        // its module ID would merge all agents into one audit and rate-accounting session.
620        if advertiser == ROUTING_HOLDER_MODULE_ID {
621            holder.get_or_insert(advertiser);
622        } else {
623            unexpected_advertisers.insert(advertiser);
624        }
625    }
626    RouteHolder {
627        module_id: holder,
628        unexpected_advertisers: unexpected_advertisers.into_iter().collect(),
629    }
630}
631
632fn project_root_for(cwd: &Path) -> PathBuf {
633    let canonical = cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf());
634    canonical
635        .ancestors()
636        .find(|path| path.join(".git").exists())
637        .map(Path::to_path_buf)
638        .unwrap_or(canonical)
639}
640
641#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
642struct AgentBinding {
643    repo: String,
644    agent_id: String,
645}
646
647fn resolved_agent_binding(manifest: &Manifest, cwd: &Path) -> Option<AgentBinding> {
648    let project_root = project_root_for(cwd);
649    let repo = repository_key_from_origin(&project_root)?;
650    manifest
651        .bindings
652        .get(&repo)
653        .cloned()
654        .map(|agent_id| AgentBinding { repo, agent_id })
655}
656
657fn repository_key_from_origin(project_root: &Path) -> Option<String> {
658    // The binding key comes from parsing the local origin remote, not a network
659    // lookup, so a signed manifest selects the same agent when offline.
660    let remote = origin_remote(project_root)?;
661    canonical_repository_key(&remote)
662}
663
664fn origin_remote(cwd: &Path) -> Option<String> {
665    let output = Command::new("git")
666        .current_dir(cwd)
667        .args(["remote", "get-url", "origin"])
668        .output()
669        .ok()?;
670    output
671        .status
672        .success()
673        .then(|| String::from_utf8(output.stdout).ok())
674        .flatten()
675        .map(|remote| remote.trim().to_string())
676        .filter(|remote| !remote.is_empty())
677}
678
679fn canonical_repository_key(value: &str) -> Option<String> {
680    let remote = value.trim().trim_end_matches('/');
681    let path = [
682        "https://github.com/",
683        "http://github.com/",
684        "ssh://git@github.com/",
685        "git://github.com/",
686        "git@github.com:",
687        "github.com/",
688    ]
689    .iter()
690    .find_map(|prefix| remote.strip_prefix(prefix))
691    .unwrap_or(remote)
692    .trim_end_matches(".git")
693    .trim_matches('/');
694    let mut parts = path.split('/');
695    let owner = parts.next()?.trim();
696    let repository = parts.next()?.trim();
697    (!owner.is_empty() && !repository.is_empty() && parts.next().is_none()).then(|| {
698        format!(
699            "{}/{}",
700            owner.to_ascii_lowercase(),
701            repository.to_ascii_lowercase()
702        )
703    })
704}
705
706fn gh_session_id(agent_id: &str) -> String {
707    format!("gh-shim:{agent_id}")
708}
709
710#[derive(Clone, Debug, Default, Deserialize, Serialize)]
711struct Detectors {
712    #[serde(default)]
713    wrapper_config_dirs: Vec<String>,
714    #[serde(default)]
715    credential_env_names: Vec<String>,
716}
717
718fn find_ambient_agent_credential(detectors: &Detectors) -> Option<String> {
719    for name in &detectors.credential_env_names {
720        if std::env::var_os(name).is_some() {
721            return Some(format!("env:{name}"));
722        }
723    }
724
725    let home = std::env::var_os("HOME")
726        .or_else(|| std::env::var_os("USERPROFILE"))
727        .map(PathBuf::from);
728    for raw_pattern in &detectors.wrapper_config_dirs {
729        let pattern = expand_home_pattern(raw_pattern, home.as_deref());
730        if let Ok(paths) = glob::glob(&pattern) {
731            for path in paths.flatten() {
732                if path.is_dir() {
733                    return Some(format!("path:{}", path.display()));
734                }
735            }
736        }
737    }
738
739    // `GH_CONFIG_DIR` is only inspected as a metadata path. The basename is
740    // compared to the manifest's declared wrapper-dir glob, so the operator's
741    // normal gh configuration remains outside this detector inventory.
742    let configured = std::env::var_os("GH_CONFIG_DIR").map(PathBuf::from)?;
743    if !configured.is_dir() {
744        return None;
745    }
746    let name = configured.file_name()?.to_string_lossy();
747    detectors
748        .wrapper_config_dirs
749        .iter()
750        .any(|pattern| {
751            Path::new(pattern).file_name().is_some_and(|glob_name| {
752                glob::Pattern::new(&glob_name.to_string_lossy()).is_ok_and(|p| p.matches(&name))
753            })
754        })
755        .then(|| format!("path:{}", configured.display()))
756}
757
758fn expand_home_pattern(pattern: &str, home: Option<&Path>) -> String {
759    pattern
760        .strip_prefix("~/")
761        .and_then(|suffix| home.map(|home| home.join(suffix).to_string_lossy().into_owned()))
762        .unwrap_or_else(|| pattern.to_string())
763}
764
765#[derive(Clone, Debug, Deserialize, Serialize)]
766#[serde(untagged)]
767enum TupleDecl {
768    Name(String),
769    Details {
770        tuple: String,
771        #[serde(default)]
772        platform: Vec<String>,
773        #[serde(default)]
774        api_match: Option<String>,
775        #[serde(default)]
776        rationale: Option<String>,
777    },
778}
779
780impl TupleDecl {
781    fn tuple(&self) -> &str {
782        match self {
783            Self::Name(name) => name,
784            Self::Details { tuple, .. } => tuple,
785        }
786    }
787
788    fn platform(&self) -> &[String] {
789        match self {
790            Self::Name(_) => &[],
791            Self::Details { platform, .. } => platform,
792        }
793    }
794
795    fn empty_api_match_has_rationale(&self) -> bool {
796        match self {
797            Self::Details {
798                api_match: Some(api_match),
799                rationale,
800                ..
801            } if api_match.is_empty() => rationale
802                .as_deref()
803                .is_some_and(|text| !text.trim().is_empty()),
804            _ => true,
805        }
806    }
807}
808
809#[derive(Clone, Debug, Deserialize, Serialize)]
810struct ApiRule {
811    method: String,
812    path_glob: String,
813    tier: Tier,
814    #[serde(default)]
815    platform: Vec<String>,
816    #[serde(default)]
817    rationale: Option<String>,
818}
819
820#[derive(Clone, Debug, Default, Deserialize, Serialize)]
821struct Canonicalization {
822    #[serde(default)]
823    argv_forms: Vec<String>,
824    #[serde(default)]
825    target_fields: Vec<String>,
826    #[serde(default)]
827    body_fields: Vec<String>,
828}
829
830#[derive(Clone, Debug, Default, Deserialize, Serialize)]
831struct RepositorySection {
832    #[serde(default)]
833    tiers: BTreeMap<Tier, Vec<TupleDecl>>,
834    #[serde(default, alias = "remove")]
835    removed_tuples: Vec<String>,
836}
837
838#[derive(Clone, Debug, Deserialize, Serialize)]
839struct Manifest {
840    artifact_id: String,
841    manifest_version: u64,
842    schema_floor: u64,
843    #[serde(default)]
844    detectors: Detectors,
845    #[serde(default)]
846    tiers: BTreeMap<Tier, Vec<TupleDecl>>,
847    #[serde(default)]
848    api_rules: Vec<ApiRule>,
849    #[serde(default)]
850    canonicalization: BTreeMap<String, Canonicalization>,
851    #[serde(default)]
852    repository_sections: BTreeMap<String, RepositorySection>,
853    #[serde(default)]
854    bindings: BTreeMap<String, String>,
855}
856
857impl Manifest {
858    fn validate(&self) -> Result<(), String> {
859        if self.artifact_id != MANIFEST_ARTIFACT_ID {
860            return Err(format!("unexpected artifact id {}", self.artifact_id));
861        }
862        if self.manifest_version == 0 {
863            return Err("manifest_version must be positive".to_string());
864        }
865
866        let mut declared = BTreeMap::<String, Tier>::new();
867        for (tier, entries) in &self.tiers {
868            for entry in entries {
869                let tuple = normalized_tuple(entry.tuple())?;
870                if entry.platform().is_empty() {
871                    return Err(format!("tuple {tuple} is missing its platform declaration"));
872                }
873                if !entry.empty_api_match_has_rationale() {
874                    return Err(format!(
875                        "tuple {tuple} has an empty api_match without rationale"
876                    ));
877                }
878                if let Some(previous) = declared.insert(tuple.clone(), *tier) {
879                    return Err(format!(
880                        "tuple {tuple} is declared in both {previous:?} and {tier:?}"
881                    ));
882                }
883            }
884        }
885
886        let mut api_declared = BTreeSet::new();
887        for rule in &self.api_rules {
888            if rule.method.trim().is_empty() || rule.path_glob.trim().is_empty() {
889                if rule.path_glob.is_empty()
890                    && rule
891                        .rationale
892                        .as_deref()
893                        .is_some_and(|text| !text.trim().is_empty())
894                {
895                    continue;
896                }
897                return Err("api rule requires method and non-empty path_glob".to_string());
898            }
899            if rule.platform.is_empty() {
900                return Err(format!(
901                    "api rule {} {} is missing its platform declaration",
902                    rule.method, rule.path_glob
903                ));
904            }
905            let key = format!("{} {}", rule.method.to_ascii_uppercase(), rule.path_glob);
906            if !api_declared.insert(key.clone()) {
907                return Err(format!("api rule {key} is declared more than once"));
908            }
909        }
910
911        let governed = self.tiers.get(&Tier::Governed).cloned().unwrap_or_default();
912        for entry in &governed {
913            let tuple = normalized_tuple(entry.tuple())?;
914            let Some(canonical) = self.canonicalization.get(&tuple) else {
915                return Err(format!("governed tuple {tuple} lacks canonicalization"));
916            };
917            if canonical.argv_forms.is_empty() || canonical.target_fields.is_empty() {
918                return Err(format!(
919                    "governed tuple {tuple} has incomplete canonicalization"
920                ));
921            }
922        }
923        for tuple in self.canonicalization.keys() {
924            if declared.get(tuple) != Some(&Tier::Governed) {
925                return Err(format!(
926                    "canonicalization {tuple} does not name a governed tuple"
927                ));
928            }
929        }
930
931        for (repository, agent_id) in &self.bindings {
932            if canonical_repository_key(repository).as_deref() != Some(repository.as_str()) {
933                return Err(format!(
934                    "binding repository {repository} is not canonical owner/name"
935                ));
936            }
937            if agent_id.trim().is_empty() || agent_id.trim() != agent_id {
938                return Err(format!(
939                    "binding repository {repository} has an invalid agent id"
940                ));
941            }
942        }
943
944        for (repository, section) in &self.repository_sections {
945            for removed in &section.removed_tuples {
946                if !declared.contains_key(&normalized_tuple(removed)?) {
947                    return Err(format!(
948                        "repository section {repository} removes undeclared tuple {removed}"
949                    ));
950                }
951            }
952            for (tier, entries) in &section.tiers {
953                for entry in entries {
954                    let tuple = normalized_tuple(entry.tuple())?;
955                    let Some(base) = declared.get(&tuple) else {
956                        return Err(format!(
957                            "repository section {repository} adds tuple {tuple}"
958                        ));
959                    };
960                    if tier.rank() < base.rank() {
961                        return Err(format!(
962                            "repository section {repository} lowers tuple {tuple}"
963                        ));
964                    }
965                }
966            }
967        }
968        Ok(())
969    }
970
971    fn tier_for_tuple(&self, tuple: &str, platform: &str) -> Option<Tier> {
972        self.tiers.iter().find_map(|(tier, entries)| {
973            entries
974                .iter()
975                .any(|entry| {
976                    normalized_tuple(entry.tuple()).ok().as_deref() == Some(tuple)
977                        && platform_matches(entry.platform(), platform)
978                })
979                .then_some(*tier)
980        })
981    }
982}
983
984fn normalized_tuple(value: &str) -> Result<String, String> {
985    let words = value
986        .split_whitespace()
987        .map(|word| word.to_ascii_lowercase())
988        .collect::<Vec<_>>();
989    (!words.is_empty())
990        .then(|| words.join(" "))
991        .ok_or_else(|| "tuple cannot be empty".to_string())
992}
993
994fn platform_matches(platforms: &[String], current: &str) -> bool {
995    platforms
996        .iter()
997        .any(|platform| platform.eq_ignore_ascii_case(current))
998}
999
1000#[derive(Clone, Debug, Deserialize, Serialize)]
1001struct SignedManifest {
1002    artifact_id: String,
1003    key_id: String,
1004    fetched_at_unix_secs: u64,
1005    signature: String,
1006    manifest: Manifest,
1007}
1008
1009#[derive(Clone, Debug)]
1010enum ManifestProblem {
1011    Missing,
1012    Invalid(String),
1013    BelowFloor { manifest_floor: u64 },
1014    Stale { manifest_version: u64 },
1015}
1016
1017impl ManifestProblem {
1018    fn diagnostic(&self) -> SelfReportDiagnostic {
1019        match self {
1020            Self::Missing => SelfReportDiagnostic::ManifestUnavailable,
1021            Self::Invalid(_) => SelfReportDiagnostic::ManifestInvalid,
1022            Self::BelowFloor { .. } => SelfReportDiagnostic::ManifestBelowFloor,
1023            Self::Stale { .. } => SelfReportDiagnostic::ManifestStale,
1024        }
1025    }
1026
1027    fn status_label(&self) -> String {
1028        match self {
1029            Self::Missing => "unavailable".to_string(),
1030            Self::Invalid(error) => format!("invalid ({error})"),
1031            Self::BelowFloor { manifest_floor } => format!(
1032                "{} (manifest floor {manifest_floor}, shim floor {SCHEMA_FLOOR})",
1033                RefusalCode::ManifestBelowFloor.as_str()
1034            ),
1035            Self::Stale { manifest_version } => format!(
1036                "{} (manifest version {manifest_version})",
1037                RefusalCode::ManifestStale.as_str()
1038            ),
1039        }
1040    }
1041}
1042
1043fn load_manifest(paths: &StatePaths, now: u64) -> Result<Manifest, ManifestProblem> {
1044    let bytes = fs::read(&paths.manifest).map_err(|_| ManifestProblem::Missing)?;
1045    let envelope: SignedManifest = serde_json::from_slice(&bytes)
1046        .map_err(|error| ManifestProblem::Invalid(error.to_string()))?;
1047    if envelope.artifact_id != MANIFEST_ARTIFACT_ID
1048        || envelope.manifest.artifact_id != MANIFEST_ARTIFACT_ID
1049    {
1050        return Err(ManifestProblem::Invalid("artifact id mismatch".to_string()));
1051    }
1052    verify_manifest_signature(&envelope)?;
1053    envelope
1054        .manifest
1055        .validate()
1056        .map_err(ManifestProblem::Invalid)?;
1057    if envelope.manifest.schema_floor < SCHEMA_FLOOR {
1058        return Err(ManifestProblem::BelowFloor {
1059            manifest_floor: envelope.manifest.schema_floor,
1060        });
1061    }
1062    if now.saturating_sub(envelope.fetched_at_unix_secs)
1063        > MANIFEST_TTL.as_secs() + MANIFEST_STALE_GRACE.as_secs()
1064    {
1065        return Err(ManifestProblem::Stale {
1066            manifest_version: envelope.manifest.manifest_version,
1067        });
1068    }
1069    Ok(envelope.manifest)
1070}
1071
1072fn verify_manifest_signature(envelope: &SignedManifest) -> Result<(), ManifestProblem> {
1073    let Some(key) = trusted_manifest_key(&envelope.key_id) else {
1074        return Err(ManifestProblem::Invalid(format!(
1075            "untrusted manifest key id {}",
1076            envelope.key_id
1077        )));
1078    };
1079    let signature = base64::engine::general_purpose::STANDARD
1080        .decode(&envelope.signature)
1081        .map_err(|_| ManifestProblem::Invalid("invalid detached signature encoding".to_string()))?;
1082    let bytes = serde_json::to_vec(&envelope.manifest)
1083        .map_err(|error| ManifestProblem::Invalid(error.to_string()))?;
1084    UnparsedPublicKey::new(&ED25519, key)
1085        .verify(&bytes, &signature)
1086        .map_err(|_| ManifestProblem::Invalid("detached signature verification failed".to_string()))
1087}
1088
1089// A manifest signature is the barrier preventing an agent from editing its own
1090// cache to turn a governed verb into a mechanical one. The development key is
1091// deliberately compiled only in debug builds so fixtures can exercise R3. A
1092// release build has no trust root until the separately reviewed CKCRED custody
1093// release supplies one, which keeps release binaries at R2 rather than making a
1094// governance claim with a test key.
1095#[cfg(not(debug_assertions))]
1096const RELEASE_MANIFEST_KEYS: &[(&str, &[u8])] = &[];
1097
1098#[cfg(debug_assertions)]
1099const DEV_MANIFEST_KEY_ID: &str = "gh-routing-dev-test-key-v1";
1100#[cfg(debug_assertions)]
1101const DEV_MANIFEST_PUBLIC_KEY: [u8; 32] = [
1102    0xd7, 0x5a, 0x98, 0x01, 0x82, 0xb1, 0x0a, 0xb7, 0xd5, 0x4b, 0xfe, 0xd3, 0xc9, 0x64, 0x07, 0x3a,
1103    0x0e, 0xe1, 0x72, 0xf3, 0xda, 0xa6, 0x23, 0x25, 0xaf, 0x02, 0x1a, 0x68, 0xf7, 0x07, 0x51, 0x1a,
1104];
1105
1106fn trusted_manifest_key(key_id: &str) -> Option<&'static [u8]> {
1107    #[cfg(debug_assertions)]
1108    if key_id == DEV_MANIFEST_KEY_ID {
1109        return Some(&DEV_MANIFEST_PUBLIC_KEY);
1110    }
1111    #[cfg(not(debug_assertions))]
1112    if let Some((_, key)) = RELEASE_MANIFEST_KEYS.iter().find(|(id, _)| *id == key_id) {
1113        return Some(*key);
1114    }
1115    let _ = key_id;
1116    None
1117}
1118
1119#[derive(Debug)]
1120enum Classification {
1121    Mechanical,
1122    Governed {
1123        tuple: String,
1124        canonical: Canonicalization,
1125    },
1126    Admin {
1127        tuple: String,
1128    },
1129    Unclassified,
1130}
1131
1132fn classify(args: &[OsString], manifest: &Manifest, platform: &str) -> Classification {
1133    let Some((verb, subcommand, _)) = command_head(args) else {
1134        return Classification::Unclassified;
1135    };
1136    if verb == "api" {
1137        return classify_api(args, manifest, platform);
1138    }
1139    let tuple = match subcommand {
1140        Some(subcommand) => format!("{verb} {subcommand}"),
1141        None => verb,
1142    };
1143    match manifest.tier_for_tuple(&tuple, platform) {
1144        Some(Tier::Mechanical) => Classification::Mechanical,
1145        Some(Tier::Admin) if V1_ADMIN_TUPLES.contains(&tuple.as_str()) => {
1146            Classification::Admin { tuple }
1147        }
1148        Some(Tier::Governed) if V1_GOVERNED_TUPLES.contains(&tuple.as_str()) => manifest
1149            .canonicalization
1150            .get(&tuple)
1151            .cloned()
1152            .map(|canonical| Classification::Governed { tuple, canonical })
1153            .unwrap_or(Classification::Unclassified),
1154        // Manifest entries outside the v1 tuple set do not acquire a policy from
1155        // nearby command names. A reviewed manifest and implementation change are
1156        // both required before another write shape can be classified.
1157        Some(Tier::Governed | Tier::Admin) | None => Classification::Unclassified,
1158    }
1159}
1160
1161fn command_head(args: &[OsString]) -> Option<(String, Option<String>, usize)> {
1162    let mut positionals = Vec::new();
1163    let mut skip_next = false;
1164    for (index, raw) in args.iter().enumerate() {
1165        let value = raw.to_str()?;
1166        if skip_next {
1167            skip_next = false;
1168            continue;
1169        }
1170        if matches!(value, "--repo" | "-R" | "--hostname" | "--config-dir") {
1171            skip_next = true;
1172            continue;
1173        }
1174        if value.starts_with('-') {
1175            continue;
1176        }
1177        positionals.push((value.to_ascii_lowercase(), index));
1178        if positionals.len() == 2 || positionals[0].0 == "api" {
1179            break;
1180        }
1181    }
1182    let (verb, index) = positionals.first()?.clone();
1183    let subcommand = positionals.get(1).map(|(value, _)| value.clone());
1184    Some((verb, subcommand, index))
1185}
1186
1187fn classify_api(args: &[OsString], manifest: &Manifest, platform: &str) -> Classification {
1188    let Some((method, path)) = api_method_and_path(args) else {
1189        return Classification::Unclassified;
1190    };
1191    let matches = manifest
1192        .api_rules
1193        .iter()
1194        .filter(|rule| {
1195            rule.method.eq_ignore_ascii_case(&method)
1196                && platform_matches(&rule.platform, platform)
1197                && glob::Pattern::new(&rule.path_glob).is_ok_and(|pattern| pattern.matches(&path))
1198        })
1199        .collect::<Vec<_>>();
1200    if matches.len() != 1 {
1201        return Classification::Unclassified;
1202    }
1203    // The v1 manifest declares only mechanical API passthrough. API writes are
1204    // not normalized into governed or ADMIN equivalents before their exact argv
1205    // forms have an audited, reviewed parser contract.
1206    match matches[0].tier {
1207        Tier::Mechanical => Classification::Mechanical,
1208        Tier::Governed | Tier::Admin => Classification::Unclassified,
1209    }
1210}
1211
1212fn api_method_and_path(args: &[OsString]) -> Option<(String, String)> {
1213    let mut method = "GET".to_string();
1214    let mut path = None;
1215    let mut index = 1;
1216    while index < args.len() {
1217        let value = args[index].to_str()?;
1218        if matches!(value, "--method" | "-X") {
1219            method = args.get(index + 1)?.to_str()?.to_ascii_uppercase();
1220            index += 2;
1221            continue;
1222        }
1223        if let Some(method_value) = value.strip_prefix("--method=") {
1224            method = method_value.to_ascii_uppercase();
1225            index += 1;
1226            continue;
1227        }
1228        if is_api_field_argument(value) {
1229            // Field-bearing API forms can change request semantics independently
1230            // of the endpoint. Until an audited form has a reviewed parser, they
1231            // remain unclassified rather than inheriting a read-like API rule.
1232            return None;
1233        }
1234        if value.starts_with('-') {
1235            index += 1;
1236            continue;
1237        }
1238        if path.is_none() {
1239            path = Some(value.to_string());
1240        }
1241        index += 1;
1242    }
1243    let path = path?;
1244    (path != "-").then_some((method, path))
1245}
1246
1247fn is_api_field_argument(value: &str) -> bool {
1248    ["--input", "--raw-field", "--field"]
1249        .iter()
1250        .any(|flag| value == *flag || value.starts_with(&format!("{flag}=")))
1251        || value == "-F"
1252        || value.starts_with("-F")
1253        || value == "-f"
1254        || value.starts_with("-f")
1255}
1256
1257#[derive(Clone, Debug)]
1258struct GovernedRequest {
1259    action: String,
1260    target: Map<String, Value>,
1261    body: Map<String, Value>,
1262    repository: Option<String>,
1263    manifest_version: u64,
1264}
1265
1266fn canonicalize_governed(
1267    args: &[OsString],
1268    tuple: &str,
1269    canonical: &Canonicalization,
1270    manifest_version: u64,
1271) -> Result<GovernedRequest, String> {
1272    let (_, _, head_index) =
1273        command_head(args).ok_or_else(|| "missing command head".to_string())?;
1274    let subcommand_index = if tuple.starts_with("api ") {
1275        head_index
1276    } else {
1277        head_index + 1
1278    };
1279    let mut positional = Vec::new();
1280    let mut body = Map::new();
1281    let mut explicit_repository = None;
1282    let mut index = subcommand_index + 1;
1283    while index < args.len() {
1284        let value = args[index]
1285            .to_str()
1286            .ok_or_else(|| "non-UTF-8 governed arguments are undeclared".to_string())?;
1287        if value == "--repo" || value == "-R" {
1288            index += 1;
1289            let repository = args
1290                .get(index)
1291                .and_then(|arg| arg.to_str())
1292                .ok_or_else(|| "--repo requires a value".to_string())?;
1293            explicit_repository = Some(repository.to_string());
1294        } else if let Some(repository) = value.strip_prefix("--repo=") {
1295            explicit_repository = Some(repository.to_string());
1296        } else if let Some((field, supplied)) =
1297            declared_body_value(value, canonical, args.get(index + 1))?
1298        {
1299            body.insert(field, Value::String(supplied));
1300            if !value.contains('=') && !value.starts_with('-') {
1301                // Kept for completeness; declared_body_value only returns flags.
1302                positional.push(value.to_string());
1303            }
1304            if !value.contains('=') {
1305                index += 1;
1306            }
1307        } else if value.starts_with('-') {
1308            return Err(format!("undeclared flag {value}"));
1309        } else {
1310            positional.push(value.to_string());
1311        }
1312        index += 1;
1313    }
1314
1315    if positional.len() != canonical.target_fields.len() {
1316        return Err("target positional form is undeclared".to_string());
1317    }
1318    if canonical
1319        .body_fields
1320        .iter()
1321        .any(|field| !body.contains_key(field))
1322    {
1323        return Err("required declared body field is absent".to_string());
1324    }
1325    let target = canonical
1326        .target_fields
1327        .iter()
1328        .cloned()
1329        .zip(positional)
1330        .map(|(field, value)| (field, Value::String(value)))
1331        .collect::<Map<_, _>>();
1332    // A global `--repo` may precede the command head, so inspect the original
1333    // argv before falling back to a command-local flag or remote inference.
1334    let repository = explicit_repo(args)
1335        .or(explicit_repository)
1336        .or_else(infer_repository_from_git);
1337    Ok(GovernedRequest {
1338        action: tuple.to_string(),
1339        target,
1340        body,
1341        repository,
1342        manifest_version,
1343    })
1344}
1345
1346fn declared_body_value(
1347    value: &str,
1348    canonical: &Canonicalization,
1349    next: Option<&OsString>,
1350) -> Result<Option<(String, String)>, String> {
1351    for field in &canonical.body_fields {
1352        let long = format!("--{field}");
1353        let short = match field.as_str() {
1354            "body" => Some("-b"),
1355            "reaction" => Some("-r"),
1356            _ => None,
1357        };
1358        if value == long || short == Some(value) {
1359            let supplied = next
1360                .and_then(|arg| arg.to_str())
1361                .ok_or_else(|| format!("{value} requires a value"))?;
1362            return Ok(Some((field.clone(), supplied.to_string())));
1363        }
1364        if let Some(supplied) = value.strip_prefix(&(long + "=")) {
1365            return Ok(Some((field.clone(), supplied.to_string())));
1366        }
1367    }
1368    Ok(None)
1369}
1370
1371fn explicit_repo(args: &[OsString]) -> Option<String> {
1372    let mut args = args.iter();
1373    while let Some(arg) = args.next() {
1374        let value = arg.to_str()?;
1375        if value == "--repo" || value == "-R" {
1376            return args.next()?.to_str().map(str::to_string);
1377        }
1378        if let Some(repository) = value.strip_prefix("--repo=") {
1379            return Some(repository.to_string());
1380        }
1381    }
1382    None
1383}
1384
1385fn infer_repository_from_git() -> Option<String> {
1386    let cwd = std::env::current_dir().ok()?;
1387    origin_remote(&cwd)
1388}
1389
1390#[derive(Debug)]
1391enum RouteOutcome {
1392    Result(String),
1393    Refusal(String),
1394    UnboundIdentity,
1395    SchemaMismatch(String),
1396    Unavailable(String),
1397}
1398
1399#[derive(Clone, Debug, Default, Deserialize, Serialize)]
1400struct SeamState {
1401    bound_holder: Option<String>,
1402    agent_binding: Option<AgentBinding>,
1403    last_seam_refusal: Option<LastSeamRefusal>,
1404}
1405
1406#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1407struct LastSeamRefusal {
1408    code: String,
1409    at_unix_secs: u64,
1410}
1411
1412fn route_governed(
1413    paths: &StatePaths,
1414    determination: &RungRecord,
1415    agent_binding: &AgentBinding,
1416    request: GovernedRequest,
1417    now: u64,
1418) -> RouteOutcome {
1419    if let Err(error) = write_seam_state(paths, governed_seam_state(paths, None, agent_binding)) {
1420        return RouteOutcome::Unavailable(format!("governed self-report update failed: {error}"));
1421    }
1422
1423    let Some(connection_file) = configured_connection_file() else {
1424        return RouteOutcome::Unavailable(
1425            "the governance connection file is no longer available".to_string(),
1426        );
1427    };
1428    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1429    let project_root = project_root_for(&cwd);
1430    let record_paths = paths.clone();
1431    let agent_binding = agent_binding.clone();
1432    let runtime = match tokio::runtime::Builder::new_current_thread()
1433        .enable_io()
1434        .enable_time()
1435        .build()
1436    {
1437        Ok(runtime) => runtime,
1438        Err(error) => return RouteOutcome::Unavailable(error.to_string()),
1439    };
1440    runtime
1441        .block_on(async move {
1442            let options = ConsumerOptions {
1443                call_timeout: Duration::from_secs(5),
1444                ..ConsumerOptions::default()
1445            };
1446            let consumer = SubcConsumer::connect(&connection_file, options)
1447                .await
1448                .map_err(|error| RouteOutcome::Unavailable(error.to_string()))?;
1449            let catalog = consumer
1450                .catalog_list()
1451                .await
1452                .map_err(|error| RouteOutcome::Unavailable(error.to_string()))?;
1453            let holder = route_holder(&catalog.modules);
1454            record_unexpected_gh_route_advertisers(&record_paths, &holder.unexpected_advertisers);
1455            let module_id = holder.module_id.ok_or_else(|| {
1456                RouteOutcome::Unavailable("no holder advertises gh.route".to_string())
1457            })?;
1458            let route = consumer
1459                .open_route(
1460                    RouteTarget::ManagementSurface {
1461                        module_id: module_id.clone(),
1462                    },
1463                    BindIdentity {
1464                        project_root: project_root.to_string_lossy().into_owned().into(),
1465                        harness: "aft-gh-shim".to_string(),
1466                        session: gh_session_id(&agent_binding.agent_id),
1467                    },
1468                    CallOptions::default(),
1469                )
1470                .await
1471                .map_err(|_| RouteOutcome::UnboundIdentity)?;
1472            if let Err(error) = write_seam_state(
1473                &record_paths,
1474                governed_seam_state(&record_paths, Some(module_id.clone()), &agent_binding),
1475            ) {
1476                let _ = consumer
1477                    .close_handle(&route, CloseRouteOptions::default())
1478                    .await;
1479                return Err(RouteOutcome::Unavailable(format!(
1480                    "governed self-report update failed: {error}"
1481                )));
1482            }
1483            let wire_request =
1484                governed_wire_request(determination, &agent_binding.agent_id, request);
1485            let body = serde_json::to_vec(&wire_request)
1486                .map_err(|error| RouteOutcome::SchemaMismatch(error.to_string()))?;
1487            let response = consumer
1488                .request(&route, body, CallOptions::default())
1489                .await
1490                .map_err(|error| RouteOutcome::Unavailable(error.to_string()));
1491            let _ = consumer
1492                .close_handle(&route, CloseRouteOptions::default())
1493                .await;
1494            let response = response?;
1495            let outcome = parse_governed_response(&response)?;
1496            if let RouteOutcome::Refusal(code) = &outcome {
1497                write_seam_state(
1498                    &record_paths,
1499                    SeamState {
1500                        bound_holder: Some(module_id),
1501                        agent_binding: Some(agent_binding),
1502                        last_seam_refusal: Some(LastSeamRefusal {
1503                            code: code.clone(),
1504                            at_unix_secs: now,
1505                        }),
1506                    },
1507                )
1508                .map_err(|error| {
1509                    RouteOutcome::Unavailable(format!(
1510                        "governed self-report update failed: {error}"
1511                    ))
1512                })?;
1513            }
1514            Ok(outcome)
1515        })
1516        .unwrap_or_else(|outcome| outcome)
1517}
1518
1519fn governed_seam_state(
1520    paths: &StatePaths,
1521    bound_holder: Option<String>,
1522    agent_binding: &AgentBinding,
1523) -> SeamState {
1524    SeamState {
1525        bound_holder,
1526        agent_binding: Some(agent_binding.clone()),
1527        // A successful route is not a refusal event, so it must retain the last
1528        // holder refusal for operators to inspect its timestamp and code.
1529        last_seam_refusal: seam_state(paths).last_seam_refusal,
1530    }
1531}
1532
1533fn write_seam_state(paths: &StatePaths, state: SeamState) -> io::Result<()> {
1534    fs::create_dir_all(&paths.root)?;
1535    let bytes = serde_json::to_vec(&state).map_err(io::Error::other)?;
1536    let temporary = paths.seam_state.with_extension("tmp");
1537    let mut file = OpenOptions::new()
1538        .create(true)
1539        .truncate(true)
1540        .write(true)
1541        .open(&temporary)?;
1542    file.write_all(&bytes)?;
1543    // A governed result is visible only after its self-report transition is
1544    // durable enough to survive a process exit. Failure stays on the seam path
1545    // and is surfaced as a refusal instead of falling through to real `gh`.
1546    file.sync_data()?;
1547    fs::rename(temporary, &paths.seam_state)
1548}
1549
1550fn seam_state(paths: &StatePaths) -> SeamState {
1551    fs::read(&paths.seam_state)
1552        .ok()
1553        .and_then(|bytes| serde_json::from_slice(&bytes).ok())
1554        .unwrap_or_default()
1555}
1556
1557fn governed_wire_request(
1558    determination: &RungRecord,
1559    agent_id: &str,
1560    request: GovernedRequest,
1561) -> Value {
1562    json!({
1563        "operation": ROUTING_OPERATION,
1564        "gh_route_schema": 1,
1565        "action": request.action,
1566        "target": request.target,
1567        "body": request.body,
1568        "repository": request.repository,
1569        "manifest_version": request.manifest_version,
1570        "rung_as_of_unix_secs": determination.as_of_unix_secs,
1571        "metadata": {
1572            "agent_id": agent_id,
1573            "pid": std::process::id(),
1574        },
1575    })
1576}
1577
1578fn parse_governed_response(bytes: &[u8]) -> Result<RouteOutcome, RouteOutcome> {
1579    let value: Value = serde_json::from_slice(bytes).map_err(|_| {
1580        RouteOutcome::SchemaMismatch(
1581            "governance seam returned malformed or non-UTF-8 JSON".to_string(),
1582        )
1583    })?;
1584    let object = value.as_object().ok_or_else(|| {
1585        RouteOutcome::SchemaMismatch("governance seam response must be an object".to_string())
1586    })?;
1587    match object.get("outcome").and_then(Value::as_str) {
1588        Some("result") => {
1589            let schema = object
1590                .get("gh_route_schema")
1591                .and_then(Value::as_u64)
1592                .ok_or_else(|| {
1593                    RouteOutcome::SchemaMismatch(
1594                        "governance seam omitted gh_route_schema".to_string(),
1595                    )
1596                })?;
1597            if schema > 1 {
1598                return Err(RouteOutcome::SchemaMismatch(format!(
1599                    "governance seam schema {schema} is newer than supported schema 1"
1600                )));
1601            }
1602            let result = object.get("result").ok_or_else(|| {
1603                RouteOutcome::SchemaMismatch("governance seam omitted result".to_string())
1604            })?;
1605            let field_order = object
1606                .get("field_order")
1607                .and_then(Value::as_array)
1608                .ok_or_else(|| {
1609                    RouteOutcome::SchemaMismatch("governance seam omitted field_order".to_string())
1610                })?;
1611            render_governed_response(result, field_order).map(RouteOutcome::Result)
1612        }
1613        Some("refusal") => {
1614            let refusal_code = object
1615                .get("refusal_code")
1616                .and_then(Value::as_str)
1617                .ok_or_else(|| {
1618                    RouteOutcome::SchemaMismatch(
1619                        "governance refusal omitted a string refusal_code".to_string(),
1620                    )
1621                })?;
1622            Ok(RouteOutcome::Refusal(refusal_code.to_string()))
1623        }
1624        Some("unbound_identity") => Ok(RouteOutcome::UnboundIdentity),
1625        _ => Err(RouteOutcome::SchemaMismatch(
1626            "governance seam returned an unknown outcome".to_string(),
1627        )),
1628    }
1629}
1630
1631fn render_governed_response(result: &Value, field_order: &[Value]) -> Result<String, RouteOutcome> {
1632    let object = result.as_object().ok_or_else(|| {
1633        RouteOutcome::SchemaMismatch("governance result must be an object".to_string())
1634    })?;
1635    let mut output = String::new();
1636    let mut rendered = BTreeSet::new();
1637    for field in field_order {
1638        let field = field.as_str().ok_or_else(|| {
1639            RouteOutcome::SchemaMismatch("field_order must contain string fields".to_string())
1640        })?;
1641        let value = object.get(field).ok_or_else(|| {
1642            RouteOutcome::SchemaMismatch(format!(
1643                "field_order references absent result field {field}"
1644            ))
1645        })?;
1646        if !rendered.insert(field) {
1647            return Err(RouteOutcome::SchemaMismatch(format!(
1648                "field_order repeats result field {field}"
1649            )));
1650        }
1651        render_field(&mut output, field, value)?;
1652    }
1653    if rendered.len() != object.len() {
1654        return Err(RouteOutcome::SchemaMismatch(
1655            "field_order does not cover every governed result field".to_string(),
1656        ));
1657    }
1658    Ok(output)
1659}
1660
1661fn render_field(output: &mut String, field: &str, value: &Value) -> Result<(), RouteOutcome> {
1662    match value {
1663        Value::Array(values) => {
1664            output.push_str(field);
1665            output.push_str(":\n");
1666            for value in values {
1667                output.push_str("  ");
1668                output.push_str(&render_scalar(value)?);
1669                output.push('\n');
1670            }
1671        }
1672        _ => {
1673            output.push_str(field);
1674            output.push_str(": ");
1675            output.push_str(&render_scalar(value)?);
1676            output.push('\n');
1677        }
1678    }
1679    Ok(())
1680}
1681
1682fn render_scalar(value: &Value) -> Result<String, RouteOutcome> {
1683    match value {
1684        Value::String(value) => serde_json::to_string(value)
1685            .map_err(|error| RouteOutcome::SchemaMismatch(error.to_string())),
1686        Value::Number(_) | Value::Bool(_) | Value::Null => Ok(value.to_string()),
1687        Value::Object(_) | Value::Array(_) => serde_json::to_string(value)
1688            .map_err(|error| RouteOutcome::SchemaMismatch(error.to_string())),
1689    }
1690}
1691
1692fn append_bypass_audit(
1693    paths: &StatePaths,
1694    tuple: &str,
1695    repository: Option<&str>,
1696    now: u64,
1697) -> io::Result<()> {
1698    fs::create_dir_all(&paths.root)?;
1699    let mut record = serde_json::to_vec(&json!({
1700        "as_of_unix_secs": now,
1701        "tuple": tuple,
1702        "repository": repository,
1703    }))
1704    .map_err(io::Error::other)?;
1705    record.push(b'\n');
1706    let mut file = OpenOptions::new()
1707        .create(true)
1708        .append(true)
1709        .open(&paths.bypass_audit)?;
1710    file.write_all(&record)?;
1711    // An operator bypass is allowed only after the audit record is durable enough
1712    // to survive a process replacement. If this returns an error we do not exec.
1713    file.sync_data()
1714}
1715
1716#[derive(Serialize)]
1717struct SelfReport {
1718    shim_version: &'static str,
1719    gh_routing_schema_floor: u64,
1720    unexpected_gh_route_advertiser: Option<Vec<String>>,
1721    bound_holder: Option<String>,
1722    agent_binding: Option<AgentBinding>,
1723    last_seam_refusal: Option<LastSeamRefusal>,
1724    cached_manifest: CachedManifestReport,
1725    last_rung: LastRungReport,
1726    bypass_audit: Option<Vec<Value>>,
1727    bypass_audit_error: Option<String>,
1728    executing_image: Option<String>,
1729    executing_image_error: Option<String>,
1730    real_gh_resolution: Option<RealGhResolution>,
1731    real_gh_resolution_error: Option<String>,
1732}
1733
1734#[derive(Serialize)]
1735struct CachedManifestReport {
1736    version: Option<u64>,
1737    version_error: Option<String>,
1738    state: Option<&'static str>,
1739    state_error: Option<String>,
1740    diagnostics: Vec<&'static str>,
1741}
1742
1743#[derive(Serialize)]
1744struct LastRungReport {
1745    rung: Option<&'static str>,
1746    rung_error: Option<String>,
1747    as_of_unix_secs: Option<u64>,
1748    as_of_unix_secs_error: Option<String>,
1749    determination_inputs: Option<BTreeMap<String, String>>,
1750    determination_inputs_error: Option<String>,
1751}
1752
1753#[derive(Serialize)]
1754struct RealGhResolution {
1755    path: String,
1756    shim_path_positions: Vec<usize>,
1757}
1758
1759fn print_self_report(paths: &StatePaths) {
1760    // This is deliberately one JSON document, rather than status lines, so a
1761    // later forensic process can consume it with jq while every dependency is down.
1762    if let Ok(document) = render_self_report(paths) {
1763        let mut stdout = io::stdout().lock();
1764        let _ = stdout.write_all(document.as_bytes());
1765    }
1766}
1767
1768fn render_self_report(paths: &StatePaths) -> Result<String, serde_json::Error> {
1769    let report = build_self_report(paths);
1770    let mut document = serde_json::to_string(&report)?;
1771    document.push('\n');
1772    Ok(document)
1773}
1774
1775fn build_self_report(paths: &StatePaths) -> SelfReport {
1776    let image = self_report_executing_image();
1777    let (real_gh_resolution, real_gh_resolution_error) = match image.as_ref() {
1778        Ok(image) => match resolve_real_gh(image) {
1779            Some(path) => (
1780                Some(RealGhResolution {
1781                    path: path.to_string_lossy().into_owned(),
1782                    shim_path_positions: executing_image_path_positions(image),
1783                }),
1784                None,
1785            ),
1786            None => (
1787                None,
1788                Some(
1789                    "PATH contains no upstream gh after skipping the executing shim image"
1790                        .to_string(),
1791                ),
1792            ),
1793        },
1794        Err(error) => (None, Some(format!("executing image unavailable: {error}"))),
1795    };
1796    let (bypass_audit, bypass_audit_error) = read_bypass_audit(paths);
1797    let seam_state = seam_state(paths);
1798    SelfReport {
1799        shim_version: env!("CARGO_PKG_VERSION"),
1800        gh_routing_schema_floor: SCHEMA_FLOOR,
1801        unexpected_gh_route_advertiser: unexpected_gh_route_advertisers(paths),
1802        bound_holder: seam_state.bound_holder,
1803        agent_binding: seam_state.agent_binding,
1804        last_seam_refusal: seam_state.last_seam_refusal,
1805        cached_manifest: cached_manifest_report(paths),
1806        last_rung: last_rung_report(paths),
1807        bypass_audit,
1808        bypass_audit_error,
1809        executing_image: image
1810            .as_ref()
1811            .ok()
1812            .map(|path| path.to_string_lossy().into_owned()),
1813        executing_image_error: image.err(),
1814        real_gh_resolution,
1815        real_gh_resolution_error,
1816    }
1817}
1818
1819fn cached_manifest_report(paths: &StatePaths) -> CachedManifestReport {
1820    match load_manifest(paths, unix_seconds()) {
1821        Ok(manifest) => CachedManifestReport {
1822            version: Some(manifest.manifest_version),
1823            version_error: None,
1824            state: Some("valid"),
1825            state_error: None,
1826            diagnostics: Vec::new(),
1827        },
1828        Err(problem) => {
1829            let error = problem.status_label();
1830            CachedManifestReport {
1831                version: None,
1832                version_error: Some(error.clone()),
1833                state: None,
1834                state_error: Some(error),
1835                diagnostics: vec![problem.diagnostic().as_str()],
1836            }
1837        }
1838    }
1839}
1840
1841fn last_rung_report(paths: &StatePaths) -> LastRungReport {
1842    match fs::read(&paths.rung) {
1843        Ok(bytes) => match serde_json::from_slice::<RungRecord>(&bytes) {
1844            Ok(record) => LastRungReport {
1845                rung: Some(record.rung.label()),
1846                rung_error: None,
1847                as_of_unix_secs: Some(record.as_of_unix_secs),
1848                as_of_unix_secs_error: None,
1849                determination_inputs: Some(record.inputs),
1850                determination_inputs_error: None,
1851            },
1852            Err(error) => unavailable_last_rung(format!("corrupt rung cache: {error}")),
1853        },
1854        Err(error) if error.kind() == io::ErrorKind::NotFound => {
1855            unavailable_last_rung("rung cache is unavailable".to_string())
1856        }
1857        Err(error) => unavailable_last_rung(format!("rung cache is unavailable: {error}")),
1858    }
1859}
1860
1861fn unavailable_last_rung(error: String) -> LastRungReport {
1862    LastRungReport {
1863        rung: None,
1864        rung_error: Some(error.clone()),
1865        as_of_unix_secs: None,
1866        as_of_unix_secs_error: Some(error.clone()),
1867        determination_inputs: None,
1868        determination_inputs_error: Some(error),
1869    }
1870}
1871
1872fn read_bypass_audit(paths: &StatePaths) -> (Option<Vec<Value>>, Option<String>) {
1873    let contents = match fs::read_to_string(&paths.bypass_audit) {
1874        Ok(contents) => contents,
1875        Err(error) if error.kind() == io::ErrorKind::NotFound => return (Some(Vec::new()), None),
1876        Err(error) => return (None, Some(format!("bypass audit is unavailable: {error}"))),
1877    };
1878    let mut records = Vec::new();
1879    for (line_number, line) in contents.lines().enumerate() {
1880        match serde_json::from_str(line) {
1881            Ok(record) => records.push(record),
1882            Err(error) => {
1883                return (
1884                    None,
1885                    Some(format!(
1886                        "bypass audit is corrupt at line {}: {error}",
1887                        line_number + 1
1888                    )),
1889                )
1890            }
1891        }
1892    }
1893    (Some(records), None)
1894}
1895
1896fn unexpected_gh_route_advertisers(paths: &StatePaths) -> Option<Vec<String>> {
1897    serde_json::from_slice(&fs::read(&paths.unexpected_gh_route_advertisers).ok()?)
1898        .ok()
1899        .filter(|advertisers: &Vec<String>| !advertisers.is_empty())
1900}
1901
1902fn record_unexpected_gh_route_advertisers(paths: &StatePaths, advertisers: &[String]) {
1903    if advertisers.is_empty() {
1904        return;
1905    }
1906    let mut recorded = unexpected_gh_route_advertisers(paths)
1907        .unwrap_or_default()
1908        .into_iter()
1909        .collect::<BTreeSet<_>>();
1910    recorded.extend(advertisers.iter().cloned());
1911    let Ok(bytes) = serde_json::to_vec(&recorded.into_iter().collect::<Vec<_>>()) else {
1912        return;
1913    };
1914    let _ = fs::create_dir_all(&paths.root);
1915    let temporary = paths.unexpected_gh_route_advertisers.with_extension("tmp");
1916    if fs::write(&temporary, bytes).is_ok() {
1917        let _ = fs::rename(temporary, &paths.unexpected_gh_route_advertisers);
1918    }
1919}
1920
1921fn self_report_executing_image() -> Result<PathBuf, String> {
1922    let path = std::env::current_exe().map_err(|error| error.to_string())?;
1923    Ok(path.canonicalize().unwrap_or(path))
1924}
1925
1926fn executing_image() -> PathBuf {
1927    std::env::current_exe()
1928        .ok()
1929        .and_then(|path| path.canonicalize().ok().or(Some(path)))
1930        .unwrap_or_else(|| PathBuf::from("unavailable"))
1931}
1932
1933fn executing_image_path_positions(image: &Path) -> Vec<usize> {
1934    let path = std::env::var_os("PATH").unwrap_or_default();
1935    std::env::split_paths(&path)
1936        .enumerate()
1937        .filter_map(|(index, directory)| same_image(&directory.join("gh"), image).then_some(index))
1938        .collect()
1939}
1940
1941fn delegate(args: &[OsString]) -> i32 {
1942    let image = executing_image();
1943    let Some(real_gh) = resolve_real_gh(&image) else {
1944        return refuse(
1945            RefusalCode::NoRealGh,
1946            "PATH contains no upstream gh after skipping the executing shim image",
1947        );
1948    };
1949    exec_real_gh(real_gh, args)
1950}
1951
1952fn resolve_real_gh(executing_image: &Path) -> Option<PathBuf> {
1953    let path = std::env::var_os("PATH")?;
1954    std::env::split_paths(&path).find_map(|directory| {
1955        let candidate = directory.join("gh");
1956        (is_executable_file(&candidate) && !same_image(&candidate, executing_image))
1957            .then_some(candidate)
1958    })
1959}
1960
1961fn is_executable_file(path: &Path) -> bool {
1962    if !path.is_file() {
1963        return false;
1964    }
1965    #[cfg(unix)]
1966    {
1967        use std::os::unix::fs::PermissionsExt;
1968        return fs::metadata(path).is_ok_and(|metadata| metadata.permissions().mode() & 0o111 != 0);
1969    }
1970    #[cfg(not(unix))]
1971    true
1972}
1973
1974fn same_image(left: &Path, right: &Path) -> bool {
1975    let left_canonical = left.canonicalize().ok();
1976    let right_canonical = right.canonicalize().ok();
1977    if left_canonical.is_some() && left_canonical == right_canonical {
1978        return true;
1979    }
1980    #[cfg(unix)]
1981    {
1982        use std::os::unix::fs::MetadataExt;
1983        if let (Ok(left), Ok(right)) = (fs::metadata(left), fs::metadata(right)) {
1984            return left.dev() == right.dev() && left.ino() == right.ino();
1985        }
1986    }
1987    false
1988}
1989
1990#[cfg(unix)]
1991fn exec_real_gh(real_gh: PathBuf, args: &[OsString]) -> i32 {
1992    use std::os::unix::process::CommandExt;
1993    let error = Command::new(real_gh).args(args).exec();
1994    // `exec` returns only if a candidate disappeared after the PATH scan. This
1995    // remains a shim refusal, rather than silently treating a failed exec as a
1996    // successful no-op.
1997    refuse(
1998        RefusalCode::NoRealGh,
1999        &format!("unable to exec upstream gh: {error}"),
2000    )
2001}
2002
2003#[cfg(not(unix))]
2004fn exec_real_gh(real_gh: PathBuf, args: &[OsString]) -> i32 {
2005    match Command::new(real_gh).args(args).status() {
2006        Ok(status) => status.code().unwrap_or(1),
2007        Err(error) => refuse(
2008            RefusalCode::NoRealGh,
2009            &format!("unable to exec upstream gh: {error}"),
2010        ),
2011    }
2012}
2013
2014fn refuse(code: RefusalCode, text: &str) -> i32 {
2015    let text = text.replace(['\n', '\r'], " ");
2016    eprintln!("gh-shim: {}: {text}", code.as_str());
2017    REFUSAL_EXIT_STATUS
2018}
2019
2020fn current_platform() -> &'static str {
2021    if cfg!(target_os = "macos") {
2022        "macos"
2023    } else if cfg!(target_os = "linux") {
2024        "linux"
2025    } else {
2026        "unsupported"
2027    }
2028}
2029
2030fn unix_seconds() -> u64 {
2031    SystemTime::now()
2032        .duration_since(UNIX_EPOCH)
2033        .unwrap_or_default()
2034        .as_secs()
2035}
2036
2037#[cfg(test)]
2038mod tests {
2039    use super::*;
2040    use ring::signature::{Ed25519KeyPair, KeyPair};
2041
2042    const TEST_SEED: [u8; 32] = [
2043        0x9d, 0x61, 0xb1, 0x9d, 0xef, 0xfd, 0x5a, 0x60, 0xba, 0x84, 0x4a, 0xf4, 0x92, 0xec, 0x2c,
2044        0xc4, 0x44, 0x49, 0xc5, 0x69, 0x7b, 0x32, 0x69, 0x19, 0x70, 0x3b, 0xac, 0x03, 0x1c, 0xae,
2045        0x7f, 0x60,
2046    ];
2047    const FIXTURE_ACCEPTED_SEAM_REFUSAL_CODES: &[&str] = &[
2048        "identity_mismatch",
2049        "unmapped_operation",
2050        "custody_unavailable",
2051        "schema_unsupported",
2052        "rate_limited",
2053    ];
2054
2055    fn fixture_manifest() -> Manifest {
2056        serde_json::from_str(include_str!(
2057            "../tests/fixtures/gh_shim/initial-manifest-v1.json"
2058        ))
2059        .expect("initial manifest fixture")
2060    }
2061
2062    fn signed(manifest: Manifest, fetched_at_unix_secs: u64) -> SignedManifest {
2063        let key = Ed25519KeyPair::from_seed_unchecked(&TEST_SEED).expect("test key");
2064        assert_eq!(key.public_key().as_ref(), DEV_MANIFEST_PUBLIC_KEY);
2065        let bytes = serde_json::to_vec(&manifest).expect("manifest bytes");
2066        SignedManifest {
2067            artifact_id: MANIFEST_ARTIFACT_ID.to_string(),
2068            key_id: DEV_MANIFEST_KEY_ID.to_string(),
2069            fetched_at_unix_secs,
2070            signature: base64::engine::general_purpose::STANDARD.encode(key.sign(&bytes).as_ref()),
2071            manifest,
2072        }
2073    }
2074
2075    fn write_signed_manifest(paths: &StatePaths, manifest: Manifest, now: u64) {
2076        fs::create_dir_all(&paths.root).expect("state root");
2077        fs::write(
2078            &paths.manifest,
2079            serde_json::to_vec(&signed(manifest, now)).expect("signed manifest"),
2080        )
2081        .expect("manifest cache");
2082    }
2083
2084    #[test]
2085    fn shim_dispatch_precedes_global_argument_scans_for_both_forms() {
2086        assert!(is_shim_invocation(
2087            OsStr::new("gh"),
2088            &[OsString::from("--version")]
2089        ));
2090        assert!(is_shim_invocation(
2091            OsStr::new("aft"),
2092            &[OsString::from("gh-shim"), OsString::from("--version")]
2093        ));
2094        assert!(!is_shim_invocation(
2095            OsStr::new("aft"),
2096            &[OsString::from("--version")]
2097        ));
2098    }
2099
2100    #[test]
2101    fn reserved_self_report_tokens_are_exactly_the_two_first_arguments() {
2102        assert_eq!(RESERVED_SELF_REPORT, ["--status", "--shim-version"]);
2103        assert!(is_reserved_self_report(&[OsString::from("--status")]));
2104        assert!(is_reserved_self_report(&[OsString::from("--shim-version")]));
2105        assert!(!is_reserved_self_report(&[OsString::from("status")]));
2106        assert!(!is_reserved_self_report(&[
2107            OsString::from("issue"),
2108            OsString::from("--status")
2109        ]));
2110    }
2111
2112    #[test]
2113    fn status_serializes_one_json_document_with_the_exact_top_level_schema() {
2114        let directory = tempfile::tempdir().unwrap();
2115        let paths = StatePaths::from_root(directory.path().to_path_buf());
2116        let document = render_self_report(&paths).expect("self report serialization");
2117        assert!(document.ends_with('\n'));
2118        let value: Value = serde_json::from_str(&document).expect("self report JSON");
2119        let keys = value
2120            .as_object()
2121            .expect("self report object")
2122            .keys()
2123            .cloned()
2124            .collect::<Vec<_>>();
2125        assert_eq!(
2126            keys,
2127            vec![
2128                "shim_version",
2129                "gh_routing_schema_floor",
2130                "unexpected_gh_route_advertiser",
2131                "bound_holder",
2132                "agent_binding",
2133                "last_seam_refusal",
2134                "cached_manifest",
2135                "last_rung",
2136                "bypass_audit",
2137                "bypass_audit_error",
2138                "executing_image",
2139                "executing_image_error",
2140                "real_gh_resolution",
2141                "real_gh_resolution_error",
2142            ]
2143        );
2144    }
2145
2146    #[test]
2147    fn route_holder_is_pinned_and_records_other_advertisers() {
2148        let holder = select_route_holder([
2149            "other-module".to_string(),
2150            ROUTING_HOLDER_MODULE_ID.to_string(),
2151            "another-module".to_string(),
2152        ]);
2153        assert_eq!(holder.module_id.as_deref(), Some(ROUTING_HOLDER_MODULE_ID));
2154        assert_eq!(
2155            holder.unexpected_advertisers,
2156            vec!["another-module", "other-module"]
2157        );
2158
2159        let holder = select_route_holder(["other-module".to_string()]);
2160        assert_eq!(holder.module_id, None);
2161        assert_eq!(holder.unexpected_advertisers, vec!["other-module"]);
2162    }
2163
2164    #[test]
2165    fn unexpected_route_advertisers_are_persisted_for_self_report() {
2166        let directory = tempfile::tempdir().unwrap();
2167        let paths = StatePaths::from_root(directory.path().to_path_buf());
2168        record_unexpected_gh_route_advertisers(&paths, &["other-module".to_string()]);
2169        record_unexpected_gh_route_advertisers(&paths, &["another-module".to_string()]);
2170
2171        assert_eq!(
2172            unexpected_gh_route_advertisers(&paths),
2173            Some(vec![
2174                "another-module".to_string(),
2175                "other-module".to_string(),
2176            ])
2177        );
2178        assert_eq!(
2179            build_self_report(&paths).unexpected_gh_route_advertiser,
2180            Some(vec![
2181                "another-module".to_string(),
2182                "other-module".to_string(),
2183            ])
2184        );
2185    }
2186
2187    #[test]
2188    fn xdg_connection_config_precedes_home_config() {
2189        let directory = tempfile::tempdir().unwrap();
2190        let xdg = directory.path().join("xdg");
2191        let home = directory.path().join("home");
2192        let xdg_connection = directory.path().join("xdg-connection.json");
2193        let home_connection = directory.path().join("home-connection.json");
2194        fs::write(&xdg_connection, "{}").unwrap();
2195        fs::write(&home_connection, "{}").unwrap();
2196        let xdg_config = xdg.join("cortexkit/aft.jsonc");
2197        let home_config = home.join(".config/cortexkit/aft.jsonc");
2198        fs::create_dir_all(xdg_config.parent().unwrap()).unwrap();
2199        fs::create_dir_all(home_config.parent().unwrap()).unwrap();
2200        // Serialize through serde_json so Windows backslash paths are
2201        // JSON-escaped; a raw format! of Path::display() writes `C:\Users\...`
2202        // into the string, which is invalid JSON and parses to None.
2203        fs::write(
2204            &xdg_config,
2205            serde_json::json!({"subc": {"connection_file": xdg_connection}}).to_string(),
2206        )
2207        .unwrap();
2208        fs::write(
2209            &home_config,
2210            serde_json::json!({"subc": {"connection_file": home_connection}}).to_string(),
2211        )
2212        .unwrap();
2213
2214        assert_eq!(
2215            configured_connection_file_from(Some(xdg.as_os_str()), Some(home.as_os_str())),
2216            Some(xdg_connection)
2217        );
2218    }
2219
2220    #[test]
2221    fn initial_manifest_is_complete_and_valid() {
2222        fixture_manifest()
2223            .validate()
2224            .expect("valid initial manifest");
2225    }
2226
2227    #[test]
2228    fn manifest_rejects_duplicate_tiers_and_empty_api_rationales() {
2229        let mut duplicate = fixture_manifest();
2230        duplicate
2231            .tiers
2232            .get_mut(&Tier::Admin)
2233            .unwrap()
2234            .push(TupleDecl::Details {
2235                tuple: "issue comment".to_string(),
2236                platform: vec!["macos".to_string()],
2237                api_match: None,
2238                rationale: None,
2239            });
2240        assert!(duplicate.validate().unwrap_err().contains("both"));
2241
2242        let mut empty_api = fixture_manifest();
2243        empty_api
2244            .tiers
2245            .get_mut(&Tier::Admin)
2246            .unwrap()
2247            .push(TupleDecl::Details {
2248                tuple: "api patch close".to_string(),
2249                platform: vec!["macos".to_string()],
2250                api_match: Some(String::new()),
2251                rationale: None,
2252            });
2253        assert!(empty_api.validate().unwrap_err().contains("rationale"));
2254
2255        let mut malformed_binding = fixture_manifest();
2256        malformed_binding.bindings.insert(
2257            "https://github.com/cortexkit/aft.git".to_string(),
2258            "alfonso-aft".to_string(),
2259        );
2260        assert!(malformed_binding
2261            .validate()
2262            .unwrap_err()
2263            .contains("canonical owner/name"));
2264    }
2265
2266    #[test]
2267    fn binding_keys_and_governed_session_identity_are_stable() {
2268        assert_eq!(
2269            canonical_repository_key("https://github.com/CortexKit/aft.git"),
2270            Some("cortexkit/aft".to_string())
2271        );
2272        assert_eq!(
2273            canonical_repository_key("git@github.com:cortexkit/aft.git"),
2274            Some("cortexkit/aft".to_string())
2275        );
2276        assert_eq!(gh_session_id("alfonso-aft"), "gh-shim:alfonso-aft");
2277
2278        let request = GovernedRequest {
2279            action: "issue comment".to_string(),
2280            target: Map::new(),
2281            body: Map::new(),
2282            repository: Some("cortexkit/aft".to_string()),
2283            manifest_version: 1,
2284        };
2285        let wire = governed_wire_request(&RungRecord::r3(7, 1), "alfonso-aft", request);
2286        assert_eq!(wire["metadata"]["agent_id"], "alfonso-aft");
2287        assert_eq!(wire["metadata"]["pid"], std::process::id());
2288    }
2289
2290    #[test]
2291    fn manifest_rejects_repo_sections_that_add_or_lower_a_tuple() {
2292        let mut manifest = fixture_manifest();
2293        manifest.repository_sections.insert(
2294            "owner/repo".to_string(),
2295            RepositorySection {
2296                tiers: BTreeMap::from([(
2297                    Tier::Mechanical,
2298                    vec![TupleDecl::Details {
2299                        tuple: "issue comment".to_string(),
2300                        platform: vec!["macos".to_string()],
2301                        api_match: None,
2302                        rationale: None,
2303                    }],
2304                )]),
2305                removed_tuples: Vec::new(),
2306            },
2307        );
2308        assert!(manifest.validate().unwrap_err().contains("lowers"));
2309
2310        manifest.repository_sections.insert(
2311            "owner/repo".to_string(),
2312            RepositorySection {
2313                tiers: BTreeMap::from([(
2314                    Tier::Admin,
2315                    vec![TupleDecl::Details {
2316                        tuple: "workflow dispatch".to_string(),
2317                        platform: vec!["macos".to_string()],
2318                        api_match: None,
2319                        rationale: None,
2320                    }],
2321                )]),
2322                removed_tuples: Vec::new(),
2323            },
2324        );
2325        assert!(manifest.validate().unwrap_err().contains("adds"));
2326    }
2327
2328    #[test]
2329    fn signed_cache_rejects_tampering_staleness_and_old_schema_floor() {
2330        let directory = tempfile::tempdir().unwrap();
2331        let paths = StatePaths::from_root(directory.path().to_path_buf());
2332        let now = 1_000_000;
2333        write_signed_manifest(&paths, fixture_manifest(), now);
2334        assert_eq!(load_manifest(&paths, now).unwrap().manifest_version, 1);
2335
2336        let mut value: Value = serde_json::from_slice(&fs::read(&paths.manifest).unwrap()).unwrap();
2337        value["manifest"]["tiers"]["mechanical"][0]["tuple"] =
2338            Value::String("issue comment".to_string());
2339        fs::write(&paths.manifest, serde_json::to_vec(&value).unwrap()).unwrap();
2340        assert!(matches!(
2341            load_manifest(&paths, now),
2342            Err(ManifestProblem::Invalid(_))
2343        ));
2344        assert_eq!(
2345            cached_manifest_report(&paths).diagnostics,
2346            vec![SelfReportDiagnostic::ManifestInvalid.as_str()]
2347        );
2348
2349        let mut below_floor = fixture_manifest();
2350        below_floor.schema_floor = 0;
2351        write_signed_manifest(&paths, below_floor, now);
2352        assert!(matches!(
2353            load_manifest(&paths, now),
2354            Err(ManifestProblem::BelowFloor { manifest_floor: 0 })
2355        ));
2356
2357        write_signed_manifest(
2358            &paths,
2359            fixture_manifest(),
2360            now - MANIFEST_TTL.as_secs() - MANIFEST_STALE_GRACE.as_secs() - 1,
2361        );
2362        assert!(matches!(
2363            load_manifest(&paths, now),
2364            Err(ManifestProblem::Stale { .. })
2365        ));
2366    }
2367
2368    #[test]
2369    fn classification_is_allowlist_driven_without_a_write_heuristic() {
2370        let manifest = fixture_manifest();
2371        assert!(matches!(
2372            classify(
2373                &[OsString::from("issue"), OsString::from("view")],
2374                &manifest,
2375                "macos"
2376            ),
2377            Classification::Mechanical
2378        ));
2379        assert!(matches!(
2380            classify(
2381                &[OsString::from("api"), OsString::from("/repos/a/b")],
2382                &manifest,
2383                "macos"
2384            ),
2385            Classification::Mechanical
2386        ));
2387        assert!(matches!(
2388            classify(
2389                &[
2390                    OsString::from("api"),
2391                    OsString::from("--method=POST"),
2392                    OsString::from("/repos/a/b")
2393                ],
2394                &manifest,
2395                "macos"
2396            ),
2397            Classification::Unclassified
2398        ));
2399        assert!(matches!(
2400            classify(
2401                &[
2402                    OsString::from("api"),
2403                    OsString::from("--method"),
2404                    OsString::from("POST"),
2405                    OsString::from("/repos/a/b")
2406                ],
2407                &manifest,
2408                "macos"
2409            ),
2410            Classification::Unclassified
2411        ));
2412        assert!(matches!(
2413            classify(
2414                &[OsString::from("alias"), OsString::from("set")],
2415                &manifest,
2416                "macos"
2417            ),
2418            Classification::Unclassified
2419        ));
2420        assert!(matches!(
2421            classify(
2422                &[
2423                    OsString::from("alias"),
2424                    OsString::from("set"),
2425                    OsString::from("--write")
2426                ],
2427                &manifest,
2428                "macos"
2429            ),
2430            Classification::Unclassified
2431        ));
2432    }
2433
2434    #[test]
2435    fn governed_canonicalization_normalizes_flags_and_explicit_repo_wins() {
2436        let manifest = fixture_manifest();
2437        let canonical = manifest.canonicalization["issue comment"].clone();
2438        let request = canonicalize_governed(
2439            &[
2440                OsString::from("--repo=owner/explicit"),
2441                OsString::from("issue"),
2442                OsString::from("comment"),
2443                OsString::from("42"),
2444                OsString::from("--body"),
2445                OsString::from("hello"),
2446            ],
2447            "issue comment",
2448            &canonical,
2449            1,
2450        )
2451        .unwrap();
2452        assert_eq!(request.repository.as_deref(), Some("owner/explicit"));
2453        assert_eq!(request.target["number"], "42");
2454        assert_eq!(request.body["body"], "hello");
2455    }
2456
2457    #[test]
2458    fn governed_renderer_is_deterministic_for_scalars_arrays_and_escapes() {
2459        let result = json!({"message":"snowman ☃\n", "items":["a", 2], "ok":true});
2460        let order = vec![json!("ok"), json!("message"), json!("items")];
2461        assert_eq!(
2462            render_governed_response(&result, &order).unwrap(),
2463            "ok: true\nmessage: \"snowman ☃\\n\"\nitems:\n  \"a\"\n  2\n"
2464        );
2465        assert!(matches!(
2466            render_governed_response(&json!("scalar"), &order),
2467            Err(RouteOutcome::SchemaMismatch(_))
2468        ));
2469    }
2470
2471    #[test]
2472    fn lower_rungs_are_cached_durably_but_r1_is_not_written() {
2473        let directory = tempfile::tempdir().unwrap();
2474        let paths = StatePaths::from_root(directory.path().to_path_buf());
2475        let record = RungRecord::r2(123, "daemon_unreachable", None);
2476        write_rung_record_silently(&paths, &record);
2477        assert_eq!(load_rung_record(&paths).unwrap().rung, Rung::R2);
2478        assert!(!paths.root.join("r1-cache.json").exists());
2479    }
2480
2481    #[cfg(unix)]
2482    #[test]
2483    fn resolved_image_identity_skips_a_shim_reached_through_a_symlinked_parent() {
2484        use std::os::unix::fs::symlink;
2485
2486        let directory = tempfile::tempdir().unwrap();
2487        let image = directory.path().join("aft");
2488        fs::write(&image, b"shim image").unwrap();
2489        let bin = directory.path().join("bin");
2490        fs::create_dir(&bin).unwrap();
2491        symlink(&image, bin.join("gh")).unwrap();
2492        let linked_parent = directory.path().join("linked-bin");
2493        symlink(&bin, &linked_parent).unwrap();
2494
2495        assert!(same_image(&linked_parent.join("gh"), &image));
2496    }
2497
2498    #[test]
2499    fn bypass_audit_is_visible_to_a_later_self_report_reader() {
2500        let directory = tempfile::tempdir().unwrap();
2501        let paths = StatePaths::from_root(directory.path().to_path_buf());
2502        append_bypass_audit(&paths, "issue close", Some("owner/repo"), 99).unwrap();
2503        let (records, error) = read_bypass_audit(&paths);
2504        assert!(error.is_none());
2505        let records = records.unwrap();
2506        assert_eq!(records.len(), 1);
2507        assert_eq!(records[0]["tuple"], "issue close");
2508    }
2509
2510    #[test]
2511    fn refusal_and_self_report_codes_are_separate_closed_sets() {
2512        assert_eq!(RefusalCode::ALL.len(), 10);
2513        assert!(RefusalCode::ALL
2514            .iter()
2515            .all(|code| code.as_str().starts_with("gh_shim_")));
2516        assert_eq!(SelfReportDiagnostic::ALL.len(), 5);
2517        assert!(SelfReportDiagnostic::ALL
2518            .iter()
2519            .all(|code| code.as_str().starts_with("gh_shim_status_")));
2520        assert_eq!(REFUSAL_EXIT_STATUS, 86);
2521    }
2522
2523    #[test]
2524    fn v1_write_classification_accepts_only_the_reviewed_tuple_sets() {
2525        let manifest = fixture_manifest();
2526        for tuple in V1_GOVERNED_TUPLES {
2527            let args = tuple
2528                .split_whitespace()
2529                .map(OsString::from)
2530                .collect::<Vec<_>>();
2531            assert!(matches!(
2532                classify(&args, &manifest, "macos"),
2533                Classification::Governed { .. }
2534            ));
2535        }
2536        for tuple in V1_ADMIN_TUPLES {
2537            let args = tuple
2538                .split_whitespace()
2539                .map(OsString::from)
2540                .collect::<Vec<_>>();
2541            assert!(matches!(
2542                classify(&args, &manifest, "macos"),
2543                Classification::Admin { .. }
2544            ));
2545        }
2546        for args in [
2547            ["release", "publish"].as_slice(),
2548            ["issue", "create"].as_slice(),
2549            ["pr", "reopen"].as_slice(),
2550        ] {
2551            let args = args.iter().map(OsString::from).collect::<Vec<_>>();
2552            assert!(matches!(
2553                classify(&args, &manifest, "macos"),
2554                Classification::Unclassified
2555            ));
2556        }
2557    }
2558
2559    #[test]
2560    fn field_bearing_api_forms_remain_unclassified_without_an_audited_parser() {
2561        let manifest = fixture_manifest();
2562        for field_flag in [
2563            "--field=name=value",
2564            "--raw-field=name=value",
2565            "--input=body.json",
2566            "-fname=value",
2567            "-Fname=value",
2568        ] {
2569            let args = vec![
2570                OsString::from("api"),
2571                OsString::from("/repos/owner/repo"),
2572                OsString::from(field_flag),
2573            ];
2574            assert!(matches!(
2575                classify(&args, &manifest, "macos"),
2576                Classification::Unclassified
2577            ));
2578        }
2579    }
2580
2581    #[test]
2582    fn holder_refusals_preserve_any_string_code_and_reject_non_strings() {
2583        for code in FIXTURE_ACCEPTED_SEAM_REFUSAL_CODES {
2584            let response = json!({"outcome": "refusal", "refusal_code": code});
2585            let outcome = parse_governed_response(&serde_json::to_vec(&response).unwrap()).unwrap();
2586            assert!(matches!(outcome, RouteOutcome::Refusal(ref actual) if actual == code));
2587            assert_eq!(RefusalCode::SeamRefusal.as_str(), "gh_shim_seam_refusal");
2588            assert_eq!(
2589                seam_refusal_text(code),
2590                format!("governance seam refused the action: {code}")
2591            );
2592            assert_eq!(REFUSAL_EXIT_STATUS, 86);
2593        }
2594        let unknown = "quota_exhausted_v2";
2595        let response = json!({"outcome": "refusal", "refusal_code": unknown});
2596        let outcome = parse_governed_response(&serde_json::to_vec(&response).unwrap()).unwrap();
2597        assert!(matches!(outcome, RouteOutcome::Refusal(ref actual) if actual == unknown));
2598        assert_eq!(RefusalCode::SeamRefusal.as_str(), "gh_shim_seam_refusal");
2599        assert_eq!(
2600            seam_refusal_text(unknown),
2601            "governance seam refused the action: quota_exhausted_v2"
2602        );
2603        assert_eq!(REFUSAL_EXIT_STATUS, 86);
2604
2605        for response in [
2606            json!({"outcome": "refusal", "refusal_code": 7}),
2607            json!({"outcome": "refusal", "refusal_code": null}),
2608            json!({"outcome": "refusal"}),
2609        ] {
2610            assert!(matches!(
2611                parse_governed_response(&serde_json::to_vec(&response).unwrap()),
2612                Err(RouteOutcome::SchemaMismatch(_))
2613            ));
2614        }
2615    }
2616
2617    #[test]
2618    fn governed_self_report_transitions_are_durable_and_mechanical_classification_preserves_them() {
2619        let directory = tempfile::tempdir().unwrap();
2620        let paths = StatePaths::from_root(directory.path().to_path_buf());
2621        let binding = AgentBinding {
2622            repo: "owner/repo".to_string(),
2623            agent_id: "agent-7".to_string(),
2624        };
2625        write_seam_state(
2626            &paths,
2627            SeamState {
2628                bound_holder: None,
2629                agent_binding: Some(binding.clone()),
2630                last_seam_refusal: None,
2631            },
2632        )
2633        .unwrap();
2634        let report = build_self_report(&paths);
2635        assert_eq!(report.bound_holder, None);
2636        assert_eq!(report.agent_binding, Some(binding.clone()));
2637        assert_eq!(report.last_seam_refusal, None);
2638
2639        write_seam_state(
2640            &paths,
2641            SeamState {
2642                bound_holder: Some(ROUTING_HOLDER_MODULE_ID.to_string()),
2643                agent_binding: Some(binding.clone()),
2644                last_seam_refusal: Some(LastSeamRefusal {
2645                    code: "rate_limited".to_string(),
2646                    at_unix_secs: 77,
2647                }),
2648            },
2649        )
2650        .unwrap();
2651        let report = build_self_report(&paths);
2652        assert_eq!(
2653            report.bound_holder.as_deref(),
2654            Some(ROUTING_HOLDER_MODULE_ID)
2655        );
2656        assert_eq!(report.agent_binding, Some(binding.clone()));
2657        assert_eq!(
2658            report
2659                .last_seam_refusal
2660                .as_ref()
2661                .map(|refusal| refusal.code.as_str()),
2662            Some("rate_limited")
2663        );
2664
2665        write_seam_state(
2666            &paths,
2667            governed_seam_state(&paths, Some(ROUTING_HOLDER_MODULE_ID.to_string()), &binding),
2668        )
2669        .unwrap();
2670        assert_eq!(
2671            seam_state(&paths)
2672                .last_seam_refusal
2673                .as_ref()
2674                .map(|refusal| refusal.code.as_str()),
2675            Some("rate_limited")
2676        );
2677
2678        let mechanical = [OsString::from("issue"), OsString::from("view")];
2679        assert!(matches!(
2680            classify(&mechanical, &fixture_manifest(), "macos"),
2681            Classification::Mechanical
2682        ));
2683        assert_eq!(
2684            seam_state(&paths)
2685                .last_seam_refusal
2686                .as_ref()
2687                .map(|refusal| refusal.at_unix_secs),
2688            Some(77)
2689        );
2690    }
2691
2692    #[test]
2693    fn governed_self_report_persistence_failure_is_loud() {
2694        let directory = tempfile::tempdir().unwrap();
2695        let state_root = directory.path().join("not-a-directory");
2696        fs::write(&state_root, b"file").unwrap();
2697        let paths = StatePaths::from_root(state_root);
2698        assert!(write_seam_state(&paths, SeamState::default()).is_err());
2699    }
2700}