1use std::collections::{BTreeMap, BTreeSet};
14use std::ffi::{OsStr, OsString};
15use std::fs::{self, OpenOptions};
16use std::io::{self, Write};
17use std::path::{Path, PathBuf};
18use std::process::Command;
19use std::time::{Duration, SystemTime, UNIX_EPOCH};
20
21use base64::Engine;
22use ring::signature::{UnparsedPublicKey, ED25519};
23use serde::{Deserialize, Serialize};
24use serde_json::{json, Map, Value};
25use subc_client_rs::{CallOptions, CloseRouteOptions, ConsumerOptions, SubcConsumer};
26use subc_protocol::manifest::ProviderRole;
27use subc_protocol::{BindIdentity, RouteTarget};
28
29pub const SCHEMA_FLOOR: u64 = 1;
30pub const ENVELOPE_VERSION: u64 = 2;
34pub const REFUSAL_EXIT_STATUS: i32 = 86;
35const DISCOVERY_BUDGET: Duration = Duration::from_millis(150);
36const DISCOVERY_CACHE_TTL: Duration = Duration::from_secs(15);
37const MANIFEST_TTL: Duration = Duration::from_secs(15 * 60);
38const MANIFEST_STALE_GRACE: Duration = Duration::from_secs(24 * 60 * 60);
39const ISSUED_AT_FUTURE_SKEW: Duration = Duration::from_secs(300);
42const ROUTING_OPERATION: &str = "gh.route";
43const ROUTING_HOLDER_MODULE_ID: &str = "prefrontal-core";
44const MANIFEST_ARTIFACT_ID: &str = "gh-routing-manifest";
45const V1_GOVERNED_TUPLES: &[&str] = &["issue comment", "pr comment", "pr review", "issue reaction"];
46const V1_ADMIN_TUPLES: &[&str] = &["issue close", "pr close", "pr merge", "release create"];
47const RESERVED_SELF_REPORT: &[&str] = &["--status", "--shim-version"];
48const GOVERNANCE_UNAVAILABLE_TEXT: &str = "the governance daemon is unreachable and this repository's actions are identity-governed; retry after the daemon returns";
49
50#[derive(Clone, Copy, Debug, Eq, PartialEq)]
53pub enum RefusalCode {
54 Unclassified,
55 AdminTier,
56 ManifestStale,
57 ManifestBelowFloor,
58 ManifestRegressed,
59 SeamSchemaMismatch,
60 UnboundIdentity,
61 BypassAuditUnavailable,
62 NoRealGh,
63 GovernanceUnavailable,
64 SeamUnavailable,
65 SeamRefusal,
66}
67
68impl RefusalCode {
69 pub const ALL: [Self; 12] = [
70 Self::Unclassified,
71 Self::AdminTier,
72 Self::ManifestStale,
73 Self::ManifestBelowFloor,
74 Self::ManifestRegressed,
75 Self::SeamSchemaMismatch,
76 Self::UnboundIdentity,
77 Self::BypassAuditUnavailable,
78 Self::NoRealGh,
79 Self::GovernanceUnavailable,
80 Self::SeamUnavailable,
81 Self::SeamRefusal,
82 ];
83
84 pub const fn as_str(self) -> &'static str {
85 match self {
86 Self::Unclassified => "gh_shim_unclassified",
87 Self::AdminTier => "gh_shim_admin_tier",
88 Self::ManifestStale => "gh_shim_manifest_stale",
89 Self::ManifestBelowFloor => "gh_shim_manifest_below_floor",
90 Self::ManifestRegressed => "gh_shim_manifest_regressed",
91 Self::SeamSchemaMismatch => "gh_shim_seam_schema_mismatch",
92 Self::UnboundIdentity => "gh_shim_unbound_identity",
93 Self::BypassAuditUnavailable => "gh_shim_bypass_audit_unavailable",
94 Self::NoRealGh => "gh_shim_no_real_gh",
95 Self::GovernanceUnavailable => "gh_shim_governance_unavailable",
96 Self::SeamUnavailable => "gh_shim_seam_unavailable",
97 Self::SeamRefusal => "gh_shim_seam_refusal",
98 }
99 }
100}
101
102#[derive(Clone, Copy, Debug, Eq, PartialEq)]
106pub enum SelfReportDiagnostic {
107 ManifestUnavailable,
108 ManifestInvalid,
109 ManifestBelowFloor,
110 ManifestStale,
111 ManifestRegressed,
112 ManifestRollback,
113 RungUnavailable,
114}
115
116impl SelfReportDiagnostic {
117 pub const ALL: [Self; 7] = [
118 Self::ManifestUnavailable,
119 Self::ManifestInvalid,
120 Self::ManifestBelowFloor,
121 Self::ManifestStale,
122 Self::ManifestRegressed,
123 Self::ManifestRollback,
124 Self::RungUnavailable,
125 ];
126
127 pub const fn as_str(self) -> &'static str {
128 match self {
129 Self::ManifestUnavailable => "gh_shim_status_manifest_unavailable",
130 Self::ManifestInvalid => "gh_shim_status_manifest_invalid",
131 Self::ManifestBelowFloor => "gh_shim_status_manifest_below_floor",
132 Self::ManifestStale => "gh_shim_status_manifest_stale",
133 Self::ManifestRegressed => "gh_shim_status_manifest_regressed",
134 Self::ManifestRollback => "gh_shim_status_manifest_rollback",
135 Self::RungUnavailable => "gh_shim_status_rung_unavailable",
136 }
137 }
138}
139
140#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
141#[serde(rename_all = "lowercase")]
142pub enum Tier {
143 Mechanical,
144 Governed,
145 Admin,
146}
147
148impl Tier {
149 fn rank(self) -> u8 {
150 match self {
151 Self::Mechanical => 0,
152 Self::Governed => 1,
153 Self::Admin => 2,
154 }
155 }
156}
157
158#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
159#[serde(rename_all = "UPPERCASE")]
160pub enum Rung {
161 R1,
162 R2,
163 R3,
164}
165
166impl Rung {
167 const fn label(self) -> &'static str {
168 match self {
169 Self::R1 => "R1",
170 Self::R2 => "R2",
171 Self::R3 => "R3",
172 }
173 }
174}
175
176pub fn is_shim_invocation(program: &OsStr, args: &[OsString]) -> bool {
180 Path::new(program)
181 .file_name()
182 .is_some_and(|name| name == OsStr::new("gh"))
183 || args.first().is_some_and(|arg| arg == OsStr::new("gh-shim"))
184}
185
186pub fn is_shim_invocation_from_env() -> bool {
187 let mut argv = std::env::args_os();
188 let Some(program) = argv.next() else {
189 return false;
190 };
191 is_shim_invocation(&program, &argv.collect::<Vec<_>>())
192}
193
194pub fn run_from_env() -> i32 {
198 let mut argv = std::env::args_os();
199 let Some(program) = argv.next() else {
200 return refuse(RefusalCode::NoRealGh, "the executing image was unavailable");
201 };
202 let raw_args = argv.collect::<Vec<_>>();
203 let shim_args = if Path::new(&program)
204 .file_name()
205 .is_some_and(|name| name == OsStr::new("gh"))
206 {
207 raw_args
208 } else {
209 raw_args.into_iter().skip(1).collect()
210 };
211 run(&shim_args)
212}
213
214fn run(args: &[OsString]) -> i32 {
215 let paths = StatePaths::from_process();
216 if is_reserved_self_report(args) {
217 print_self_report(&paths);
218 return 0;
219 }
220
221 let now = unix_seconds();
222 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
223
224 if let ManifestResolution::Regressed(manifest) = resolve_manifest(&paths, now) {
230 return match regressed_disposition(args, &manifest, current_platform()) {
231 RegressedDisposition::Passthrough => delegate(args),
232 RegressedDisposition::Refuse { code, text } => refuse(code, &text),
233 };
234 }
235
236 let determination = determine_rung(&paths, &cwd, now);
237 if determination.rung != Rung::R3 {
238 return match sticky_governance_disposition(
239 &paths,
240 &cwd,
241 now,
242 &determination,
243 args,
244 current_platform(),
245 ) {
246 Some(StickyGovernanceDisposition::Unavailable(agent_binding)) => {
247 refuse_governance_unavailable(&paths, &agent_binding, now)
248 }
249 Some(StickyGovernanceDisposition::Unclassified { manifest_version }) => refuse(
250 RefusalCode::Unclassified,
251 &format!(
252 "no manifest declaration for this invocation (manifest {manifest_version})"
253 ),
254 ),
255 None => delegate(args),
256 };
257 }
258
259 let manifest = match resolve_manifest(&paths, now) {
264 ManifestResolution::Active(manifest) | ManifestResolution::GraceCache(manifest) => manifest,
265 ManifestResolution::Regressed(manifest) => {
266 return match regressed_disposition(args, &manifest, current_platform()) {
267 RegressedDisposition::Passthrough => delegate(args),
268 RegressedDisposition::Refuse { code, text } => refuse(code, &text),
269 }
270 }
271 ManifestResolution::Dormant => return delegate(args),
272 };
273 let Some(agent_binding) = resolved_agent_binding(&manifest, &cwd) else {
274 return delegate(args);
275 };
276
277 match classify(args, &manifest, current_platform()) {
278 Classification::Mechanical => delegate(args),
279 Classification::Admin { tuple } => {
280 if std::env::var_os("GH_SHIM_BYPASS").as_deref() == Some(OsStr::new("operator")) {
281 let repository = explicit_repo(args).or_else(infer_repository_from_git);
282 if let Err(error) = append_bypass_audit(&paths, &tuple, repository.as_deref(), now)
283 {
284 return refuse(
285 RefusalCode::BypassAuditUnavailable,
286 &format!("operator bypass audit could not be appended: {error}"),
287 );
288 }
289 delegate(args)
290 } else {
291 refuse(
292 RefusalCode::AdminTier,
293 "this action requires GH_SHIM_BYPASS=operator",
294 )
295 }
296 }
297 Classification::Governed { tuple, canonical } => {
298 let request =
299 match canonicalize_governed(args, &tuple, &canonical, manifest.manifest_version) {
300 Ok(request) => request,
301 Err(_) => {
302 return refuse(
303 RefusalCode::Unclassified,
304 &format!(
305 "undeclared shape for {tuple} in manifest {}",
306 manifest.manifest_version
307 ),
308 )
309 }
310 };
311 let outcome = route_governed(&paths, &determination, &agent_binding, request, now);
312 governed_outcome_status(&paths, &agent_binding, now, outcome)
313 }
314 Classification::Unclassified => refuse(
315 RefusalCode::Unclassified,
316 &format!(
317 "no manifest declaration for this invocation (manifest {})",
318 manifest.manifest_version
319 ),
320 ),
321 }
322}
323
324fn governed_outcome_status(
325 paths: &StatePaths,
326 agent_binding: &AgentBinding,
327 now: u64,
328 outcome: RouteOutcome,
329) -> i32 {
330 match outcome {
331 RouteOutcome::Result(output) => {
332 print!("{output}");
333 0
334 }
335 RouteOutcome::Refusal(code) => refuse(RefusalCode::SeamRefusal, &seam_refusal_text(&code)),
336 RouteOutcome::UnboundIdentity => refuse(
337 RefusalCode::UnboundIdentity,
338 "the project binding was unavailable at route time",
339 ),
340 RouteOutcome::SchemaMismatch(message) => refuse(RefusalCode::SeamSchemaMismatch, &message),
341 RouteOutcome::GovernanceUnavailable => {
342 refuse_governance_unavailable(paths, agent_binding, now)
343 }
344 RouteOutcome::Unavailable(message) => refuse(RefusalCode::SeamUnavailable, &message),
345 }
346}
347
348fn seam_refusal_text(code: &str) -> String {
349 format!("governance seam refused the action: {code}")
350}
351
352fn is_reserved_self_report(args: &[OsString]) -> bool {
353 args.first()
354 .and_then(|arg| arg.to_str())
355 .is_some_and(|arg| RESERVED_SELF_REPORT.contains(&arg))
356}
357
358#[derive(Clone, Debug)]
359struct StatePaths {
360 root: PathBuf,
361 manifest: PathBuf,
362 rung: PathBuf,
363 bypass_audit: PathBuf,
364 unexpected_gh_route_advertisers: PathBuf,
365 seam_state: PathBuf,
366 last_valid_manifest: PathBuf,
367 version_high_water: PathBuf,
368}
369
370impl StatePaths {
371 fn from_process() -> Self {
372 let root = std::env::var_os("XDG_STATE_HOME")
373 .map(PathBuf::from)
374 .filter(|path| path.is_absolute())
375 .or_else(|| {
376 std::env::var_os("HOME")
377 .or_else(|| std::env::var_os("USERPROFILE"))
378 .map(|home| PathBuf::from(home).join(".local/state"))
379 })
380 .unwrap_or_else(|| std::env::temp_dir())
381 .join("cortexkit")
382 .join("aft")
383 .join("gh-shim");
384 Self::from_root(root)
385 }
386
387 fn from_root(root: PathBuf) -> Self {
388 Self {
389 manifest: root.join("gh-routing-manifest.json"),
390 rung: root.join("rung-cache.json"),
391 bypass_audit: root.join("operator-bypass.jsonl"),
392 unexpected_gh_route_advertisers: root.join("unexpected-gh-route-advertisers.json"),
393 seam_state: root.join("seam-state.json"),
394 last_valid_manifest: root.join("last-valid-manifest.json"),
395 version_high_water: root.join("manifest-version-high-water.json"),
396 root,
397 }
398 }
399}
400
401#[derive(Clone, Debug, Deserialize, Serialize)]
402struct RungRecord {
403 rung: Rung,
404 as_of_unix_secs: u64,
405 #[serde(default)]
406 inputs: BTreeMap<String, String>,
407 #[serde(default)]
408 manifest_version: Option<u64>,
409}
410
411impl RungRecord {
412 fn r1(now: u64, reason: &str) -> Self {
413 Self {
414 rung: Rung::R1,
415 as_of_unix_secs: now,
416 inputs: BTreeMap::from([("connection_file".to_string(), reason.to_string())]),
417 manifest_version: None,
418 }
419 }
420
421 fn r2(now: u64, reason: &str, manifest_version: Option<u64>) -> Self {
422 Self {
423 rung: Rung::R2,
424 as_of_unix_secs: now,
425 inputs: BTreeMap::from([
426 ("connection_file".to_string(), "ready".to_string()),
427 (reason.to_string(), "failed".to_string()),
428 ]),
429 manifest_version,
430 }
431 }
432
433 fn r3(now: u64, manifest_version: u64) -> Self {
434 Self {
435 rung: Rung::R3,
436 as_of_unix_secs: now,
437 inputs: BTreeMap::from([
438 ("connection_file".to_string(), "ready".to_string()),
439 ("catalog_gh_route".to_string(), "ready".to_string()),
440 ("agent_binding".to_string(), "ready".to_string()),
441 ("manifest".to_string(), "ready".to_string()),
442 (
443 "agent_credentials_present".to_string(),
444 "absent".to_string(),
445 ),
446 ]),
447 manifest_version: Some(manifest_version),
448 }
449 }
450
451 fn fresh_at(&self, now: u64) -> bool {
452 now.saturating_sub(self.as_of_unix_secs) < DISCOVERY_CACHE_TTL.as_secs()
453 }
454
455 fn governance_infrastructure_unavailable(&self) -> bool {
456 matches!(
457 self.inputs.get("connection_file").map(String::as_str),
458 Some("unreachable" | "discovery_budget_exhausted")
459 ) || self.inputs.get("daemon_unreachable").map(String::as_str) == Some("failed")
460 || self
461 .inputs
462 .get("catalog_gh_route_absent")
463 .map(String::as_str)
464 == Some("failed")
465 }
466}
467
468enum StickyGovernanceDisposition {
469 Unavailable(AgentBinding),
470 Unclassified { manifest_version: u64 },
471}
472
473fn sticky_governance_disposition(
474 paths: &StatePaths,
475 cwd: &Path,
476 now: u64,
477 determination: &RungRecord,
478 args: &[OsString],
479 platform: &str,
480) -> Option<StickyGovernanceDisposition> {
481 if !determination.governance_infrastructure_unavailable() {
482 return None;
483 }
484 let manifest = match resolve_manifest(paths, now) {
485 ManifestResolution::Active(manifest) | ManifestResolution::GraceCache(manifest) => manifest,
486 ManifestResolution::Regressed(_) | ManifestResolution::Dormant => return None,
487 };
488 let agent_binding = resolved_agent_binding(&manifest, cwd)?;
489
490 match classify(args, &manifest, platform) {
496 Classification::Governed { .. } | Classification::Admin { .. } => {
497 Some(StickyGovernanceDisposition::Unavailable(agent_binding))
498 }
499 Classification::Unclassified => Some(StickyGovernanceDisposition::Unclassified {
500 manifest_version: manifest.manifest_version,
501 }),
502 Classification::Mechanical => None,
503 }
504}
505
506fn determine_rung(paths: &StatePaths, cwd: &Path, now: u64) -> RungRecord {
507 let deadline = std::time::Instant::now() + DISCOVERY_BUDGET;
510 let config_doc = read_user_config_doc();
511 determine_rung_from_doc(paths, cwd, now, deadline, config_doc.as_deref())
512}
513
514fn determine_rung_from_doc(
520 paths: &StatePaths,
521 cwd: &Path,
522 now: u64,
523 deadline: std::time::Instant,
524 config_doc: Option<&str>,
525) -> RungRecord {
526 if gh_shim_enabled_from_config_doc(config_doc.unwrap_or("")) == Some(false) {
531 return RungRecord::r1(now, "disabled_by_config");
532 }
533
534 let Some(connection_file) = connection_file_from_config_doc(config_doc.unwrap_or("")) else {
535 return RungRecord::r1(now, "absent_or_unparseable");
537 };
538 if !connection_file.is_file() {
539 return RungRecord::r1(now, "unreachable");
540 }
541
542 let cached = load_rung_record(paths);
543 if std::time::Instant::now() >= deadline {
544 return cached
545 .filter(|record| record.fresh_at(now))
546 .unwrap_or_else(|| RungRecord::r1(now, "discovery_budget_exhausted"));
547 }
548 if let Some(record) = cached.as_ref().filter(|record| record.fresh_at(now)) {
549 if record.rung != Rung::R3
550 || resolve_manifest(paths, now)
551 .manifest()
552 .and_then(|manifest| resolved_agent_binding(manifest, cwd))
553 .is_some()
554 {
555 return record.clone();
556 }
557 }
558
559 let Some(manifest) = resolve_manifest(paths, now).into_manifest() else {
565 let record = RungRecord::r2(now, "manifest_unavailable", None);
566 write_rung_record_silently(paths, &record);
567 return record;
568 };
569 let Some(agent_binding) = resolved_agent_binding(&manifest, cwd) else {
570 let record = RungRecord::r2(
571 now,
572 "agent_binding_unavailable",
573 Some(manifest.manifest_version),
574 );
575 write_rung_record_silently(paths, &record);
576 return record;
577 };
578
579 let discovery = probe_governance(
580 paths,
581 &connection_file,
582 cwd,
583 deadline,
584 &agent_binding.agent_id,
585 );
586 let record = match discovery {
587 ProbeResult::Ready { module_id } => {
588 match find_ambient_agent_credential(&manifest.detectors) {
589 Some(source) => {
590 let mut record = RungRecord::r2(
591 now,
592 "agent_credentials_present",
593 Some(manifest.manifest_version),
594 );
595 record
596 .inputs
597 .insert("agent_credentials_present".to_string(), source);
598 record
599 .inputs
600 .insert("catalog_holder".to_string(), module_id);
601 record
602 }
603 None => RungRecord::r3(now, manifest.manifest_version),
604 }
605 }
606 ProbeResult::Unreachable => RungRecord::r2(now, "daemon_unreachable", None),
607 ProbeResult::NoRoute => RungRecord::r2(now, "catalog_gh_route_absent", None),
608 ProbeResult::Unbound => RungRecord::r2(now, "agent_binding_unavailable", None),
609 ProbeResult::TimedOut => cached
610 .filter(|record| record.fresh_at(now))
611 .unwrap_or_else(|| RungRecord::r1(now, "discovery_budget_exhausted")),
612 };
613
614 if record.rung != Rung::R1 {
615 write_rung_record_silently(paths, &record);
616 }
617 record
618}
619
620fn configured_connection_file() -> Option<PathBuf> {
621 let xdg_config_home = std::env::var_os("XDG_CONFIG_HOME");
622 let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"));
623 configured_connection_file_from(xdg_config_home.as_deref(), home.as_deref())
624}
625
626fn configured_connection_file_from(
627 xdg_config_home: Option<&OsStr>,
628 home: Option<&OsStr>,
629) -> Option<PathBuf> {
630 let config_path = crate::subc_config::user_config_path_from(xdg_config_home, home)?;
636 let doc = fs::read_to_string(config_path).ok()?;
637 connection_file_from_config_doc(&doc).filter(|path| path.is_file())
638}
639
640fn read_user_config_doc() -> Option<String> {
644 let xdg_config_home = std::env::var_os("XDG_CONFIG_HOME");
645 let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"));
646 let config_path =
647 crate::subc_config::user_config_path_from(xdg_config_home.as_deref(), home.as_deref())?;
648 fs::read_to_string(config_path).ok()
649}
650
651fn gh_shim_enabled_from_config_doc(doc: &str) -> Option<bool> {
655 let value: Value = serde_json::from_str(&crate::jsonc::strip_jsonc(doc)).ok()?;
656 value.get("gh_shim")?.get("enabled")?.as_bool()
657}
658
659fn connection_file_from_config_doc(doc: &str) -> Option<PathBuf> {
660 let value: Value = serde_json::from_str(&crate::jsonc::strip_jsonc(doc)).ok()?;
661 let raw = value.get("subc")?.get("connection_file")?.as_str()?.trim();
662 let path = PathBuf::from(raw);
663 (!raw.is_empty() && path.is_absolute()).then_some(path)
664}
665
666fn load_rung_record(paths: &StatePaths) -> Option<RungRecord> {
667 serde_json::from_slice(&fs::read(&paths.rung).ok()?).ok()
668}
669
670fn write_rung_record_silently(paths: &StatePaths, record: &RungRecord) {
671 let Ok(bytes) = serde_json::to_vec(record) else {
672 return;
673 };
674 let _ = fs::create_dir_all(&paths.root);
675 let temporary = paths.root.join("rung-cache.json.tmp");
676 if fs::write(&temporary, bytes).is_ok() {
677 let _ = fs::rename(temporary, &paths.rung);
678 }
679}
680
681#[derive(Debug)]
682enum ProbeResult {
683 Ready { module_id: String },
684 Unreachable,
685 NoRoute,
686 Unbound,
687 TimedOut,
688}
689
690fn probe_governance(
691 paths: &StatePaths,
692 connection_file: &Path,
693 cwd: &Path,
694 deadline: std::time::Instant,
695 agent_id: &str,
696) -> ProbeResult {
697 let remaining = deadline.saturating_duration_since(std::time::Instant::now());
698 if remaining.is_zero() {
699 return ProbeResult::TimedOut;
700 }
701 let connection_file = connection_file.to_path_buf();
702 let project_root = project_root_for(cwd);
703 let record_paths = paths.clone();
704 let agent_id = agent_id.to_string();
705 let Ok(runtime) = tokio::runtime::Builder::new_current_thread()
706 .enable_io()
707 .enable_time()
708 .build()
709 else {
710 return ProbeResult::Unreachable;
711 };
712
713 match runtime.block_on(async move {
718 tokio::time::timeout(remaining, async move {
719 let options = ConsumerOptions {
720 call_timeout: remaining,
721 ..ConsumerOptions::default()
722 };
723 let consumer = SubcConsumer::connect(&connection_file, options)
724 .await
725 .map_err(|_| ProbeResult::Unreachable)?;
726 let catalog = consumer
727 .catalog_list()
728 .await
729 .map_err(|_| ProbeResult::Unreachable)?;
730 let holder = route_holder(&catalog.modules);
731 record_unexpected_gh_route_advertisers(&record_paths, &holder.unexpected_advertisers);
732 let Some(module_id) = holder.module_id else {
733 return Err(ProbeResult::NoRoute);
734 };
735 let identity = BindIdentity {
736 project_root: project_root.to_string_lossy().into_owned().into(),
737 harness: "aft-gh-shim".to_string(),
738 session: gh_session_id(&agent_id),
739 };
740 let route = consumer
741 .open_route(
742 RouteTarget::ManagementSurface {
743 module_id: module_id.clone(),
744 },
745 identity,
746 CallOptions::default(),
747 )
748 .await
749 .map_err(|_| ProbeResult::Unbound)?;
750 let _ = consumer
751 .close_handle(&route, CloseRouteOptions::default())
752 .await;
753 Ok(module_id)
754 })
755 .await
756 }) {
757 Ok(Ok(module_id)) => ProbeResult::Ready { module_id },
758 Ok(Err(result)) => result,
759 Err(_) => ProbeResult::TimedOut,
760 }
761}
762
763#[derive(Debug, Default, Eq, PartialEq)]
764struct RouteHolder {
765 module_id: Option<String>,
766 unexpected_advertisers: Vec<String>,
767}
768
769fn route_holder(entries: &[subc_client_rs::CatalogEntry]) -> RouteHolder {
770 select_route_holder(entries.iter().filter_map(|entry| {
771 entry
772 .roles
773 .iter()
774 .any(|role| {
775 matches!(
776 role,
777 ProviderRole::ManagementSurface { operations, .. }
778 if operations.iter().any(|operation| operation.name == ROUTING_OPERATION)
779 )
780 })
781 .then(|| entry.module_id.clone())
782 }))
783}
784
785fn select_route_holder(advertisers: impl IntoIterator<Item = String>) -> RouteHolder {
786 let mut holder = None;
787 let mut unexpected_advertisers = BTreeSet::new();
788 for advertiser in advertisers {
789 if advertiser == ROUTING_HOLDER_MODULE_ID {
794 holder.get_or_insert(advertiser);
795 } else {
796 unexpected_advertisers.insert(advertiser);
797 }
798 }
799 RouteHolder {
800 module_id: holder,
801 unexpected_advertisers: unexpected_advertisers.into_iter().collect(),
802 }
803}
804
805fn project_root_for(cwd: &Path) -> PathBuf {
806 let canonical = cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf());
807 canonical
808 .ancestors()
809 .find(|path| path.join(".git").exists())
810 .map(Path::to_path_buf)
811 .unwrap_or(canonical)
812}
813
814#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
815struct AgentBinding {
816 repo: String,
817 agent_id: String,
818}
819
820fn resolved_agent_binding(manifest: &Manifest, cwd: &Path) -> Option<AgentBinding> {
821 let project_root = project_root_for(cwd);
822 let repo = repository_key_from_origin(&project_root)?;
823 manifest
824 .bindings
825 .get(&repo)
826 .cloned()
827 .map(|agent_id| AgentBinding { repo, agent_id })
828}
829
830fn repository_key_from_origin(project_root: &Path) -> Option<String> {
831 let remote = origin_remote(project_root)?;
834 canonical_repository_key(&remote)
835}
836
837fn origin_remote(cwd: &Path) -> Option<String> {
838 let output = Command::new("git")
839 .current_dir(cwd)
840 .args(["remote", "get-url", "origin"])
841 .output()
842 .ok()?;
843 output
844 .status
845 .success()
846 .then(|| String::from_utf8(output.stdout).ok())
847 .flatten()
848 .map(|remote| remote.trim().to_string())
849 .filter(|remote| !remote.is_empty())
850}
851
852fn canonical_repository_key(value: &str) -> Option<String> {
853 let remote = value.trim().trim_end_matches('/');
854 let path = [
855 "https://github.com/",
856 "http://github.com/",
857 "ssh://git@github.com/",
858 "git://github.com/",
859 "git@github.com:",
860 "github.com/",
861 ]
862 .iter()
863 .find_map(|prefix| remote.strip_prefix(prefix))
864 .unwrap_or(remote)
865 .trim_end_matches(".git")
866 .trim_matches('/');
867 let mut parts = path.split('/');
868 let owner = parts.next()?.trim();
869 let repository = parts.next()?.trim();
870 (!owner.is_empty() && !repository.is_empty() && parts.next().is_none()).then(|| {
871 format!(
872 "{}/{}",
873 owner.to_ascii_lowercase(),
874 repository.to_ascii_lowercase()
875 )
876 })
877}
878
879fn gh_session_id(agent_id: &str) -> String {
880 format!("gh-shim:{agent_id}")
881}
882
883#[derive(Clone, Debug, Default, Deserialize, Serialize)]
884struct Detectors {
885 #[serde(default)]
886 wrapper_config_dirs: Vec<String>,
887 #[serde(default)]
888 credential_env_names: Vec<String>,
889}
890
891fn find_ambient_agent_credential(detectors: &Detectors) -> Option<String> {
892 for name in &detectors.credential_env_names {
893 if std::env::var_os(name).is_some() {
894 return Some(format!("env:{name}"));
895 }
896 }
897
898 let home = std::env::var_os("HOME")
899 .or_else(|| std::env::var_os("USERPROFILE"))
900 .map(PathBuf::from);
901 for raw_pattern in &detectors.wrapper_config_dirs {
902 let pattern = expand_home_pattern(raw_pattern, home.as_deref());
903 if let Ok(paths) = glob::glob(&pattern) {
904 for path in paths.flatten() {
905 if path.is_dir() {
906 return Some(format!("path:{}", path.display()));
907 }
908 }
909 }
910 }
911
912 let configured = std::env::var_os("GH_CONFIG_DIR").map(PathBuf::from)?;
916 if !configured.is_dir() {
917 return None;
918 }
919 let name = configured.file_name()?.to_string_lossy();
920 detectors
921 .wrapper_config_dirs
922 .iter()
923 .any(|pattern| {
924 Path::new(pattern).file_name().is_some_and(|glob_name| {
925 glob::Pattern::new(&glob_name.to_string_lossy()).is_ok_and(|p| p.matches(&name))
926 })
927 })
928 .then(|| format!("path:{}", configured.display()))
929}
930
931fn expand_home_pattern(pattern: &str, home: Option<&Path>) -> String {
932 pattern
933 .strip_prefix("~/")
934 .and_then(|suffix| home.map(|home| home.join(suffix).to_string_lossy().into_owned()))
935 .unwrap_or_else(|| pattern.to_string())
936}
937
938#[derive(Clone, Debug, Deserialize, Serialize)]
939#[serde(untagged)]
940enum TupleDecl {
941 Name(String),
942 Details {
943 tuple: String,
944 #[serde(default)]
945 platform: Vec<String>,
946 #[serde(default)]
947 api_match: Option<String>,
948 #[serde(default)]
949 rationale: Option<String>,
950 },
951}
952
953impl TupleDecl {
954 fn tuple(&self) -> &str {
955 match self {
956 Self::Name(name) => name,
957 Self::Details { tuple, .. } => tuple,
958 }
959 }
960
961 fn platform(&self) -> &[String] {
962 match self {
963 Self::Name(_) => &[],
964 Self::Details { platform, .. } => platform,
965 }
966 }
967
968 fn empty_api_match_has_rationale(&self) -> bool {
969 match self {
970 Self::Details {
971 api_match: Some(api_match),
972 rationale,
973 ..
974 } if api_match.is_empty() => rationale
975 .as_deref()
976 .is_some_and(|text| !text.trim().is_empty()),
977 _ => true,
978 }
979 }
980}
981
982#[derive(Clone, Debug, Deserialize, Serialize)]
983struct ApiRule {
984 method: String,
985 path_glob: String,
986 tier: Tier,
987 #[serde(default)]
988 platform: Vec<String>,
989 #[serde(default)]
990 rationale: Option<String>,
991}
992
993#[derive(Clone, Debug, Default, Deserialize, Serialize)]
994struct Canonicalization {
995 #[serde(default)]
996 argv_forms: Vec<String>,
997 #[serde(default)]
998 target_fields: Vec<String>,
999 #[serde(default)]
1000 body_fields: Vec<String>,
1001}
1002
1003#[derive(Clone, Debug, Default, Deserialize, Serialize)]
1004struct RepositorySection {
1005 #[serde(default)]
1006 tiers: BTreeMap<Tier, Vec<TupleDecl>>,
1007 #[serde(default, alias = "remove")]
1008 removed_tuples: Vec<String>,
1009}
1010
1011#[derive(Clone, Debug, Deserialize, Serialize)]
1012struct Manifest {
1013 artifact_id: String,
1014 manifest_version: u64,
1015 schema_floor: u64,
1016 issued_at_unix_secs: u64,
1020 #[serde(default)]
1021 detectors: Detectors,
1022 #[serde(default)]
1023 tiers: BTreeMap<Tier, Vec<TupleDecl>>,
1024 #[serde(default)]
1025 api_rules: Vec<ApiRule>,
1026 #[serde(default)]
1027 canonicalization: BTreeMap<String, Canonicalization>,
1028 #[serde(default)]
1029 repository_sections: BTreeMap<String, RepositorySection>,
1030 #[serde(default)]
1031 bindings: BTreeMap<String, String>,
1032}
1033
1034impl Manifest {
1035 fn validate(&self) -> Result<(), String> {
1036 if self.artifact_id != MANIFEST_ARTIFACT_ID {
1037 return Err(format!("unexpected artifact id {}", self.artifact_id));
1038 }
1039 if self.manifest_version == 0 {
1040 return Err("manifest_version must be positive".to_string());
1041 }
1042
1043 let mut declared = BTreeMap::<String, Tier>::new();
1044 for (tier, entries) in &self.tiers {
1045 for entry in entries {
1046 let tuple = normalized_tuple(entry.tuple())?;
1047 if entry.platform().is_empty() {
1048 return Err(format!("tuple {tuple} is missing its platform declaration"));
1049 }
1050 if !entry.empty_api_match_has_rationale() {
1051 return Err(format!(
1052 "tuple {tuple} has an empty api_match without rationale"
1053 ));
1054 }
1055 if let Some(previous) = declared.insert(tuple.clone(), *tier) {
1056 return Err(format!(
1057 "tuple {tuple} is declared in both {previous:?} and {tier:?}"
1058 ));
1059 }
1060 }
1061 }
1062
1063 let mut api_declared = BTreeSet::new();
1064 for rule in &self.api_rules {
1065 if rule.method.trim().is_empty() || rule.path_glob.trim().is_empty() {
1066 if rule.path_glob.is_empty()
1067 && rule
1068 .rationale
1069 .as_deref()
1070 .is_some_and(|text| !text.trim().is_empty())
1071 {
1072 continue;
1073 }
1074 return Err("api rule requires method and non-empty path_glob".to_string());
1075 }
1076 if rule.platform.is_empty() {
1077 return Err(format!(
1078 "api rule {} {} is missing its platform declaration",
1079 rule.method, rule.path_glob
1080 ));
1081 }
1082 let key = format!("{} {}", rule.method.to_ascii_uppercase(), rule.path_glob);
1083 if !api_declared.insert(key.clone()) {
1084 return Err(format!("api rule {key} is declared more than once"));
1085 }
1086 }
1087
1088 let governed = self.tiers.get(&Tier::Governed).cloned().unwrap_or_default();
1089 for entry in &governed {
1090 let tuple = normalized_tuple(entry.tuple())?;
1091 let Some(canonical) = self.canonicalization.get(&tuple) else {
1092 return Err(format!("governed tuple {tuple} lacks canonicalization"));
1093 };
1094 if canonical.argv_forms.is_empty() || canonical.target_fields.is_empty() {
1095 return Err(format!(
1096 "governed tuple {tuple} has incomplete canonicalization"
1097 ));
1098 }
1099 }
1100 for tuple in self.canonicalization.keys() {
1101 if declared.get(tuple) != Some(&Tier::Governed) {
1102 return Err(format!(
1103 "canonicalization {tuple} does not name a governed tuple"
1104 ));
1105 }
1106 }
1107
1108 for (repository, agent_id) in &self.bindings {
1109 if canonical_repository_key(repository).as_deref() != Some(repository.as_str()) {
1110 return Err(format!(
1111 "binding repository {repository} is not canonical owner/name"
1112 ));
1113 }
1114 if agent_id.trim().is_empty() || agent_id.trim() != agent_id {
1115 return Err(format!(
1116 "binding repository {repository} has an invalid agent id"
1117 ));
1118 }
1119 }
1120
1121 for (repository, section) in &self.repository_sections {
1122 for removed in §ion.removed_tuples {
1123 if !declared.contains_key(&normalized_tuple(removed)?) {
1124 return Err(format!(
1125 "repository section {repository} removes undeclared tuple {removed}"
1126 ));
1127 }
1128 }
1129 for (tier, entries) in §ion.tiers {
1130 for entry in entries {
1131 let tuple = normalized_tuple(entry.tuple())?;
1132 let Some(base) = declared.get(&tuple) else {
1133 return Err(format!(
1134 "repository section {repository} adds tuple {tuple}"
1135 ));
1136 };
1137 if tier.rank() < base.rank() {
1138 return Err(format!(
1139 "repository section {repository} lowers tuple {tuple}"
1140 ));
1141 }
1142 }
1143 }
1144 }
1145 Ok(())
1146 }
1147
1148 fn tier_for_tuple(&self, tuple: &str, platform: &str) -> Option<Tier> {
1149 self.tiers.iter().find_map(|(tier, entries)| {
1150 entries
1151 .iter()
1152 .any(|entry| {
1153 normalized_tuple(entry.tuple()).ok().as_deref() == Some(tuple)
1154 && platform_matches(entry.platform(), platform)
1155 })
1156 .then_some(*tier)
1157 })
1158 }
1159}
1160
1161fn normalized_tuple(value: &str) -> Result<String, String> {
1162 let words = value
1163 .split_whitespace()
1164 .map(|word| word.to_ascii_lowercase())
1165 .collect::<Vec<_>>();
1166 (!words.is_empty())
1167 .then(|| words.join(" "))
1168 .ok_or_else(|| "tuple cannot be empty".to_string())
1169}
1170
1171fn platform_matches(platforms: &[String], current: &str) -> bool {
1172 platforms
1173 .iter()
1174 .any(|platform| platform.eq_ignore_ascii_case(current))
1175}
1176
1177#[derive(Clone, Debug, Deserialize, Serialize)]
1183struct SignedManifest {
1184 artifact_id: String,
1185 envelope_version: u64,
1186 key_id: String,
1187 fetched_at_unix_secs: u64,
1192 signature: String,
1193 manifest_bytes: String,
1194}
1195
1196#[derive(Clone, Debug)]
1197enum ManifestProblem {
1198 Missing,
1199 Invalid(String),
1200 BelowFloor {
1201 manifest_floor: u64,
1202 },
1203 Stale {
1204 manifest_version: u64,
1205 },
1206 RolledBack {
1210 manifest_version: u64,
1211 newest_accepted: u64,
1212 },
1213}
1214
1215impl ManifestProblem {
1216 fn diagnostic(&self) -> SelfReportDiagnostic {
1217 match self {
1218 Self::Missing => SelfReportDiagnostic::ManifestUnavailable,
1219 Self::Invalid(_) => SelfReportDiagnostic::ManifestInvalid,
1220 Self::BelowFloor { .. } => SelfReportDiagnostic::ManifestBelowFloor,
1221 Self::Stale { .. } => SelfReportDiagnostic::ManifestStale,
1222 Self::RolledBack { .. } => SelfReportDiagnostic::ManifestRollback,
1223 }
1224 }
1225
1226 fn status_label(&self) -> String {
1227 match self {
1228 Self::Missing => "unavailable".to_string(),
1229 Self::Invalid(error) => format!("invalid ({error})"),
1230 Self::BelowFloor { manifest_floor } => format!(
1231 "{} (manifest floor {manifest_floor}, shim floor {SCHEMA_FLOOR})",
1232 RefusalCode::ManifestBelowFloor.as_str()
1233 ),
1234 Self::Stale { manifest_version } => format!(
1235 "{} (manifest version {manifest_version})",
1236 RefusalCode::ManifestStale.as_str()
1237 ),
1238 Self::RolledBack {
1239 manifest_version,
1240 newest_accepted,
1241 } => format!(
1242 "{} (manifest version {manifest_version}, newest accepted version {newest_accepted})",
1243 SelfReportDiagnostic::ManifestRollback.as_str()
1244 ),
1245 }
1246 }
1247}
1248
1249fn load_manifest(paths: &StatePaths, now: u64) -> Result<Manifest, ManifestProblem> {
1300 let bytes = fs::read(&paths.manifest).map_err(|_| ManifestProblem::Missing)?;
1301 let envelope: SignedManifest = serde_json::from_slice(&bytes)
1302 .map_err(|error| ManifestProblem::Invalid(error.to_string()))?;
1303 if envelope.artifact_id != MANIFEST_ARTIFACT_ID {
1304 return Err(ManifestProblem::Invalid("artifact id mismatch".to_string()));
1305 }
1306 if envelope.envelope_version != ENVELOPE_VERSION {
1307 return Err(ManifestProblem::Invalid(format!(
1308 "unsupported envelope version {} (this shim verifies envelope version {ENVELOPE_VERSION})",
1309 envelope.envelope_version
1310 )));
1311 }
1312 let manifest = verify_manifest_signature(&envelope)?;
1314 manifest.validate().map_err(ManifestProblem::Invalid)?;
1315 if manifest.schema_floor < SCHEMA_FLOOR {
1316 return Err(ManifestProblem::BelowFloor {
1317 manifest_floor: manifest.schema_floor,
1318 });
1319 }
1320 let newest_accepted = version_high_water(paths);
1326 if manifest.manifest_version < newest_accepted {
1327 return Err(ManifestProblem::RolledBack {
1328 manifest_version: manifest.manifest_version,
1329 newest_accepted,
1330 });
1331 }
1332 if manifest.issued_at_unix_secs > now + ISSUED_AT_FUTURE_SKEW.as_secs() {
1333 return Err(ManifestProblem::Invalid(format!(
1334 "issued_at_unix_secs {} is more than {} seconds in the future",
1335 manifest.issued_at_unix_secs,
1336 ISSUED_AT_FUTURE_SKEW.as_secs()
1337 )));
1338 }
1339 if now.saturating_sub(manifest.issued_at_unix_secs)
1340 > MANIFEST_TTL.as_secs() + MANIFEST_STALE_GRACE.as_secs()
1341 {
1342 return Err(ManifestProblem::Stale {
1343 manifest_version: manifest.manifest_version,
1344 });
1345 }
1346 if manifest.manifest_version > newest_accepted {
1350 write_version_high_water(paths, manifest.manifest_version);
1351 }
1352 write_last_valid_manifest(paths, now, &manifest);
1353 Ok(manifest)
1354}
1355
1356fn verify_manifest_signature(envelope: &SignedManifest) -> Result<Manifest, ManifestProblem> {
1359 verify_manifest_signature_with(envelope, compiled_manifest_trust_set())
1360}
1361
1362fn verify_manifest_signature_with(
1363 envelope: &SignedManifest,
1364 trust_set: &[Option<ManifestTrustKey>],
1365) -> Result<Manifest, ManifestProblem> {
1366 let Some(key) = trust_set
1367 .iter()
1368 .flatten()
1369 .find(|slot| slot.key_id == envelope.key_id)
1370 .map(|slot| slot.public_key)
1371 else {
1372 return Err(ManifestProblem::Invalid(format!(
1373 "untrusted manifest key id {}",
1374 envelope.key_id
1375 )));
1376 };
1377 let signature = base64::engine::general_purpose::STANDARD
1378 .decode(&envelope.signature)
1379 .map_err(|_| ManifestProblem::Invalid("invalid detached signature encoding".to_string()))?;
1380 UnparsedPublicKey::new(&ED25519, key)
1381 .verify(envelope.manifest_bytes.as_bytes(), &signature)
1382 .map_err(|_| {
1383 ManifestProblem::Invalid("detached signature verification failed".to_string())
1384 })?;
1385 serde_json::from_str(&envelope.manifest_bytes).map_err(|error| {
1386 ManifestProblem::Invalid(format!("signed manifest bytes failed to parse: {error}"))
1387 })
1388}
1389
1390#[derive(Clone, Copy)]
1393struct ManifestTrustKey {
1394 key_id: &'static str,
1395 public_key: &'static [u8],
1396}
1397
1398#[cfg(not(debug_assertions))]
1429const RELEASE_MANIFEST_TRUST_SET: &[Option<ManifestTrustKey>; 2] = &[
1430 None, None, ];
1433
1434#[cfg(debug_assertions)]
1435const DEV_MANIFEST_KEY_ID: &str = "gh-routing-dev-test-key-v1";
1436#[cfg(debug_assertions)]
1437const DEV_MANIFEST_PUBLIC_KEY: [u8; 32] = [
1438 0xd7, 0x5a, 0x98, 0x01, 0x82, 0xb1, 0x0a, 0xb7, 0xd5, 0x4b, 0xfe, 0xd3, 0xc9, 0x64, 0x07, 0x3a,
1439 0x0e, 0xe1, 0x72, 0xf3, 0xda, 0xa6, 0x23, 0x25, 0xaf, 0x02, 0x1a, 0x68, 0xf7, 0x07, 0x51, 0x1a,
1440];
1441#[cfg(debug_assertions)]
1442const DEV_MANIFEST_TRUST_SET: &[Option<ManifestTrustKey>; 1] = &[Some(ManifestTrustKey {
1443 key_id: DEV_MANIFEST_KEY_ID,
1444 public_key: &DEV_MANIFEST_PUBLIC_KEY,
1445})];
1446
1447fn compiled_manifest_trust_set() -> &'static [Option<ManifestTrustKey>] {
1448 #[cfg(debug_assertions)]
1449 {
1450 DEV_MANIFEST_TRUST_SET
1451 }
1452 #[cfg(not(debug_assertions))]
1453 {
1454 RELEASE_MANIFEST_TRUST_SET
1455 }
1456}
1457
1458#[derive(Debug)]
1464enum ManifestResolution {
1465 Active(Manifest),
1467 GraceCache(Manifest),
1470 Regressed(Manifest),
1474 Dormant,
1477}
1478
1479impl ManifestResolution {
1480 fn manifest(&self) -> Option<&Manifest> {
1481 match self {
1482 Self::Active(manifest) | Self::GraceCache(manifest) | Self::Regressed(manifest) => {
1483 Some(manifest)
1484 }
1485 Self::Dormant => None,
1486 }
1487 }
1488
1489 fn into_manifest(self) -> Option<Manifest> {
1490 match self {
1491 Self::Active(manifest) | Self::GraceCache(manifest) | Self::Regressed(manifest) => {
1492 Some(manifest)
1493 }
1494 Self::Dormant => None,
1495 }
1496 }
1497}
1498
1499fn resolve_manifest(paths: &StatePaths, now: u64) -> ManifestResolution {
1500 match load_manifest(paths, now) {
1501 Ok(manifest) => return ManifestResolution::Active(manifest),
1502 Err(ManifestProblem::Missing) => return ManifestResolution::Dormant,
1503 Err(_) => {}
1504 }
1505 match read_last_valid_manifest(paths) {
1510 Some(cache) if cache_within_grace(&cache, now) => {
1511 ManifestResolution::GraceCache(cache.manifest)
1512 }
1513 Some(cache) => ManifestResolution::Regressed(cache.manifest),
1514 None => ManifestResolution::Dormant,
1515 }
1516}
1517
1518fn regressed_disposition(
1526 args: &[OsString],
1527 manifest: &Manifest,
1528 platform: &str,
1529) -> RegressedDisposition {
1530 match classify(args, manifest, platform) {
1531 Classification::Mechanical => RegressedDisposition::Passthrough,
1532 Classification::Governed { tuple, .. } | Classification::Admin { tuple } => {
1533 RegressedDisposition::Refuse {
1534 code: RefusalCode::ManifestRegressed,
1535 text: format!(
1536 "the manifest artifact fails validation past the last-valid grace window; {tuple} is refused until the manifest is repaired"
1537 ),
1538 }
1539 }
1540 Classification::Unclassified => RegressedDisposition::Refuse {
1541 code: RefusalCode::Unclassified,
1542 text: "no manifest declaration for this invocation (manifest artifact fails validation)"
1543 .to_string(),
1544 },
1545 }
1546}
1547
1548#[derive(Debug)]
1549enum RegressedDisposition {
1550 Passthrough,
1551 Refuse { code: RefusalCode, text: String },
1552}
1553
1554#[derive(Clone, Debug, Deserialize, Serialize)]
1559struct LastValidManifest {
1560 accepted_at_unix_secs: u64,
1561 manifest: Manifest,
1562}
1563
1564fn cache_within_grace(cache: &LastValidManifest, now: u64) -> bool {
1565 now.saturating_sub(cache.accepted_at_unix_secs) <= MANIFEST_STALE_GRACE.as_secs()
1566}
1567
1568fn read_last_valid_manifest(paths: &StatePaths) -> Option<LastValidManifest> {
1569 serde_json::from_slice(&fs::read(&paths.last_valid_manifest).ok()?).ok()
1570}
1571
1572fn write_last_valid_manifest(paths: &StatePaths, accepted_at_unix_secs: u64, manifest: &Manifest) {
1573 let record = LastValidManifest {
1574 accepted_at_unix_secs,
1575 manifest: manifest.clone(),
1576 };
1577 let Ok(bytes) = serde_json::to_vec(&record) else {
1578 return;
1579 };
1580 let _ = fs::create_dir_all(&paths.root);
1581 let temporary = paths.last_valid_manifest.with_extension("tmp");
1582 if fs::write(&temporary, bytes).is_ok() {
1583 let _ = fs::rename(temporary, &paths.last_valid_manifest);
1584 }
1585}
1586
1587#[derive(Clone, Debug, Deserialize, Serialize)]
1592struct VersionHighWater {
1593 newest_accepted_version: u64,
1594}
1595
1596fn version_high_water(paths: &StatePaths) -> u64 {
1597 fs::read(&paths.version_high_water)
1598 .ok()
1599 .and_then(|bytes| serde_json::from_slice::<VersionHighWater>(&bytes).ok())
1600 .map(|record| record.newest_accepted_version)
1601 .unwrap_or(0)
1602}
1603
1604fn write_version_high_water(paths: &StatePaths, newest_accepted_version: u64) {
1605 let Ok(bytes) = serde_json::to_vec(&VersionHighWater {
1606 newest_accepted_version,
1607 }) else {
1608 return;
1609 };
1610 let _ = fs::create_dir_all(&paths.root);
1611 let temporary = paths.version_high_water.with_extension("tmp");
1612 if fs::write(&temporary, bytes).is_ok() {
1613 let _ = fs::rename(temporary, &paths.version_high_water);
1614 }
1615}
1616
1617#[derive(Debug)]
1618enum Classification {
1619 Mechanical,
1620 Governed {
1621 tuple: String,
1622 canonical: Canonicalization,
1623 },
1624 Admin {
1625 tuple: String,
1626 },
1627 Unclassified,
1628}
1629
1630fn classify(args: &[OsString], manifest: &Manifest, platform: &str) -> Classification {
1631 let Some((verb, subcommand, _)) = command_head(args) else {
1632 return Classification::Unclassified;
1633 };
1634 if verb == "api" {
1635 return classify_api(args, manifest, platform);
1636 }
1637 let tuple = match subcommand {
1638 Some(subcommand) => format!("{verb} {subcommand}"),
1639 None => verb,
1640 };
1641 match manifest.tier_for_tuple(&tuple, platform) {
1642 Some(Tier::Mechanical) => Classification::Mechanical,
1643 Some(Tier::Admin) if V1_ADMIN_TUPLES.contains(&tuple.as_str()) => {
1644 Classification::Admin { tuple }
1645 }
1646 Some(Tier::Governed) if V1_GOVERNED_TUPLES.contains(&tuple.as_str()) => manifest
1647 .canonicalization
1648 .get(&tuple)
1649 .cloned()
1650 .map(|canonical| Classification::Governed { tuple, canonical })
1651 .unwrap_or(Classification::Unclassified),
1652 Some(Tier::Governed | Tier::Admin) | None => Classification::Unclassified,
1656 }
1657}
1658
1659fn command_head(args: &[OsString]) -> Option<(String, Option<String>, usize)> {
1660 let mut positionals = Vec::new();
1661 let mut skip_next = false;
1662 for (index, raw) in args.iter().enumerate() {
1663 let value = raw.to_str()?;
1664 if skip_next {
1665 skip_next = false;
1666 continue;
1667 }
1668 if matches!(value, "--repo" | "-R" | "--hostname" | "--config-dir") {
1669 skip_next = true;
1670 continue;
1671 }
1672 if value.starts_with('-') {
1673 continue;
1674 }
1675 positionals.push((value.to_ascii_lowercase(), index));
1676 if positionals.len() == 2 || positionals[0].0 == "api" {
1677 break;
1678 }
1679 }
1680 let (verb, index) = positionals.first()?.clone();
1681 let subcommand = positionals.get(1).map(|(value, _)| value.clone());
1682 Some((verb, subcommand, index))
1683}
1684
1685fn classify_api(args: &[OsString], manifest: &Manifest, platform: &str) -> Classification {
1686 let Some((method, path)) = api_method_and_path(args) else {
1687 return Classification::Unclassified;
1688 };
1689 let matches = manifest
1690 .api_rules
1691 .iter()
1692 .filter(|rule| {
1693 rule.method.eq_ignore_ascii_case(&method)
1694 && platform_matches(&rule.platform, platform)
1695 && glob::Pattern::new(&rule.path_glob).is_ok_and(|pattern| pattern.matches(&path))
1696 })
1697 .collect::<Vec<_>>();
1698 if matches.len() != 1 {
1699 return Classification::Unclassified;
1700 }
1701 match matches[0].tier {
1705 Tier::Mechanical => Classification::Mechanical,
1706 Tier::Governed | Tier::Admin => Classification::Unclassified,
1707 }
1708}
1709
1710fn api_method_and_path(args: &[OsString]) -> Option<(String, String)> {
1711 let mut method = "GET".to_string();
1712 let mut path = None;
1713 let mut index = 1;
1714 while index < args.len() {
1715 let value = args[index].to_str()?;
1716 if matches!(value, "--method" | "-X") {
1717 method = args.get(index + 1)?.to_str()?.to_ascii_uppercase();
1718 index += 2;
1719 continue;
1720 }
1721 if let Some(method_value) = value.strip_prefix("--method=") {
1722 method = method_value.to_ascii_uppercase();
1723 index += 1;
1724 continue;
1725 }
1726 if is_api_field_argument(value) {
1727 return None;
1731 }
1732 if value.starts_with('-') {
1733 index += 1;
1734 continue;
1735 }
1736 if path.is_none() {
1737 path = Some(value.to_string());
1738 }
1739 index += 1;
1740 }
1741 let path = path?;
1742 (path != "-").then_some((method, path))
1743}
1744
1745fn is_api_field_argument(value: &str) -> bool {
1746 ["--input", "--raw-field", "--field"]
1747 .iter()
1748 .any(|flag| value == *flag || value.starts_with(&format!("{flag}=")))
1749 || value == "-F"
1750 || value.starts_with("-F")
1751 || value == "-f"
1752 || value.starts_with("-f")
1753}
1754
1755#[derive(Clone, Debug)]
1756struct GovernedRequest {
1757 action: String,
1758 target: Map<String, Value>,
1759 body: Map<String, Value>,
1760 repository: Option<String>,
1761 manifest_version: u64,
1762}
1763
1764fn canonicalize_governed(
1765 args: &[OsString],
1766 tuple: &str,
1767 canonical: &Canonicalization,
1768 manifest_version: u64,
1769) -> Result<GovernedRequest, String> {
1770 let (_, _, head_index) =
1771 command_head(args).ok_or_else(|| "missing command head".to_string())?;
1772 let subcommand_index = if tuple.starts_with("api ") {
1773 head_index
1774 } else {
1775 head_index + 1
1776 };
1777 let mut positional = Vec::new();
1778 let mut body = Map::new();
1779 let mut explicit_repository = None;
1780 let mut index = subcommand_index + 1;
1781 while index < args.len() {
1782 let value = args[index]
1783 .to_str()
1784 .ok_or_else(|| "non-UTF-8 governed arguments are undeclared".to_string())?;
1785 if value == "--repo" || value == "-R" {
1786 index += 1;
1787 let repository = args
1788 .get(index)
1789 .and_then(|arg| arg.to_str())
1790 .ok_or_else(|| "--repo requires a value".to_string())?;
1791 explicit_repository = Some(repository.to_string());
1792 } else if let Some(repository) = value.strip_prefix("--repo=") {
1793 explicit_repository = Some(repository.to_string());
1794 } else if let Some((field, supplied)) =
1795 declared_body_value(value, canonical, args.get(index + 1))?
1796 {
1797 body.insert(field, Value::String(supplied));
1798 if !value.contains('=') && !value.starts_with('-') {
1799 positional.push(value.to_string());
1801 }
1802 if !value.contains('=') {
1803 index += 1;
1804 }
1805 } else if value.starts_with('-') {
1806 return Err(format!("undeclared flag {value}"));
1807 } else {
1808 positional.push(value.to_string());
1809 }
1810 index += 1;
1811 }
1812
1813 if positional.len() != canonical.target_fields.len() {
1814 return Err("target positional form is undeclared".to_string());
1815 }
1816 if canonical
1817 .body_fields
1818 .iter()
1819 .any(|field| !body.contains_key(field))
1820 {
1821 return Err("required declared body field is absent".to_string());
1822 }
1823 let target = canonical
1824 .target_fields
1825 .iter()
1826 .cloned()
1827 .zip(positional)
1828 .map(|(field, value)| (field, Value::String(value)))
1829 .collect::<Map<_, _>>();
1830 let repository = explicit_repo(args)
1833 .or(explicit_repository)
1834 .or_else(infer_repository_from_git);
1835 Ok(GovernedRequest {
1836 action: tuple.to_string(),
1837 target,
1838 body,
1839 repository,
1840 manifest_version,
1841 })
1842}
1843
1844fn declared_body_value(
1845 value: &str,
1846 canonical: &Canonicalization,
1847 next: Option<&OsString>,
1848) -> Result<Option<(String, String)>, String> {
1849 for field in &canonical.body_fields {
1850 let long = format!("--{field}");
1851 let short = match field.as_str() {
1852 "body" => Some("-b"),
1853 "reaction" => Some("-r"),
1854 _ => None,
1855 };
1856 if value == long || short == Some(value) {
1857 let supplied = next
1858 .and_then(|arg| arg.to_str())
1859 .ok_or_else(|| format!("{value} requires a value"))?;
1860 return Ok(Some((field.clone(), supplied.to_string())));
1861 }
1862 if let Some(supplied) = value.strip_prefix(&(long + "=")) {
1863 return Ok(Some((field.clone(), supplied.to_string())));
1864 }
1865 }
1866 Ok(None)
1867}
1868
1869fn explicit_repo(args: &[OsString]) -> Option<String> {
1870 let mut args = args.iter();
1871 while let Some(arg) = args.next() {
1872 let value = arg.to_str()?;
1873 if value == "--repo" || value == "-R" {
1874 return args.next()?.to_str().map(str::to_string);
1875 }
1876 if let Some(repository) = value.strip_prefix("--repo=") {
1877 return Some(repository.to_string());
1878 }
1879 }
1880 None
1881}
1882
1883fn infer_repository_from_git() -> Option<String> {
1884 let cwd = std::env::current_dir().ok()?;
1885 origin_remote(&cwd)
1886}
1887
1888#[derive(Debug)]
1889enum RouteOutcome {
1890 Result(String),
1891 Refusal(String),
1892 UnboundIdentity,
1893 SchemaMismatch(String),
1894 GovernanceUnavailable,
1895 Unavailable(String),
1896}
1897
1898#[derive(Clone, Debug, Default, Deserialize, Serialize)]
1899struct SeamState {
1900 bound_holder: Option<String>,
1901 agent_binding: Option<AgentBinding>,
1902 last_seam_refusal: Option<LastSeamRefusal>,
1903}
1904
1905#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1906struct LastSeamRefusal {
1907 code: String,
1908 at_unix_secs: u64,
1909}
1910
1911fn route_governed(
1912 paths: &StatePaths,
1913 determination: &RungRecord,
1914 agent_binding: &AgentBinding,
1915 request: GovernedRequest,
1916 now: u64,
1917) -> RouteOutcome {
1918 if let Err(error) = write_seam_state(paths, governed_seam_state(paths, None, agent_binding)) {
1919 return RouteOutcome::Unavailable(format!("governed self-report update failed: {error}"));
1920 }
1921
1922 let Some(connection_file) = configured_connection_file() else {
1923 return RouteOutcome::GovernanceUnavailable;
1924 };
1925 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1926 let project_root = project_root_for(&cwd);
1927 let record_paths = paths.clone();
1928 let agent_binding = agent_binding.clone();
1929 let runtime = match tokio::runtime::Builder::new_current_thread()
1930 .enable_io()
1931 .enable_time()
1932 .build()
1933 {
1934 Ok(runtime) => runtime,
1935 Err(error) => return RouteOutcome::Unavailable(error.to_string()),
1936 };
1937 runtime
1938 .block_on(async move {
1939 let options = ConsumerOptions {
1940 call_timeout: Duration::from_secs(5),
1941 ..ConsumerOptions::default()
1942 };
1943 let consumer = SubcConsumer::connect(&connection_file, options)
1944 .await
1945 .map_err(|_| RouteOutcome::GovernanceUnavailable)?;
1946 let catalog = consumer
1947 .catalog_list()
1948 .await
1949 .map_err(|_| RouteOutcome::GovernanceUnavailable)?;
1950 let holder = route_holder(&catalog.modules);
1951 record_unexpected_gh_route_advertisers(&record_paths, &holder.unexpected_advertisers);
1952 let module_id = holder
1953 .module_id
1954 .ok_or(RouteOutcome::GovernanceUnavailable)?;
1955 let route = consumer
1956 .open_route(
1957 RouteTarget::ManagementSurface {
1958 module_id: module_id.clone(),
1959 },
1960 BindIdentity {
1961 project_root: project_root.to_string_lossy().into_owned().into(),
1962 harness: "aft-gh-shim".to_string(),
1963 session: gh_session_id(&agent_binding.agent_id),
1964 },
1965 CallOptions::default(),
1966 )
1967 .await
1968 .map_err(|_| RouteOutcome::UnboundIdentity)?;
1969 if let Err(error) = write_seam_state(
1970 &record_paths,
1971 governed_seam_state(&record_paths, Some(module_id.clone()), &agent_binding),
1972 ) {
1973 let _ = consumer
1974 .close_handle(&route, CloseRouteOptions::default())
1975 .await;
1976 return Err(RouteOutcome::Unavailable(format!(
1977 "governed self-report update failed: {error}"
1978 )));
1979 }
1980 let wire_request =
1981 governed_wire_request(determination, &agent_binding.agent_id, request);
1982 let body = serde_json::to_vec(&wire_request)
1983 .map_err(|error| RouteOutcome::SchemaMismatch(error.to_string()))?;
1984 let response = consumer
1985 .request(&route, body, CallOptions::default())
1986 .await
1987 .map_err(|error| RouteOutcome::Unavailable(error.to_string()));
1988 let _ = consumer
1989 .close_handle(&route, CloseRouteOptions::default())
1990 .await;
1991 let response = response?;
1992 let outcome = parse_governed_response(&response)?;
1993 if let RouteOutcome::Refusal(code) = &outcome {
1994 write_seam_state(
1995 &record_paths,
1996 SeamState {
1997 bound_holder: Some(module_id),
1998 agent_binding: Some(agent_binding),
1999 last_seam_refusal: Some(LastSeamRefusal {
2000 code: code.clone(),
2001 at_unix_secs: now,
2002 }),
2003 },
2004 )
2005 .map_err(|error| {
2006 RouteOutcome::Unavailable(format!(
2007 "governed self-report update failed: {error}"
2008 ))
2009 })?;
2010 }
2011 Ok(outcome)
2012 })
2013 .unwrap_or_else(|outcome| outcome)
2014}
2015
2016fn refuse_governance_unavailable(
2017 paths: &StatePaths,
2018 agent_binding: &AgentBinding,
2019 now: u64,
2020) -> i32 {
2021 let state = SeamState {
2022 bound_holder: None,
2023 agent_binding: Some(agent_binding.clone()),
2024 last_seam_refusal: Some(LastSeamRefusal {
2025 code: RefusalCode::GovernanceUnavailable.as_str().to_string(),
2026 at_unix_secs: now,
2027 }),
2028 };
2029 if let Err(error) = write_seam_state(paths, state) {
2030 return refuse(
2031 RefusalCode::SeamUnavailable,
2032 &format!("governed self-report update failed: {error}"),
2033 );
2034 }
2035 refuse(
2036 RefusalCode::GovernanceUnavailable,
2037 GOVERNANCE_UNAVAILABLE_TEXT,
2038 )
2039}
2040
2041fn governed_seam_state(
2042 paths: &StatePaths,
2043 bound_holder: Option<String>,
2044 agent_binding: &AgentBinding,
2045) -> SeamState {
2046 SeamState {
2047 bound_holder,
2048 agent_binding: Some(agent_binding.clone()),
2049 last_seam_refusal: seam_state(paths).last_seam_refusal,
2052 }
2053}
2054
2055fn write_seam_state(paths: &StatePaths, state: SeamState) -> io::Result<()> {
2056 fs::create_dir_all(&paths.root)?;
2057 let bytes = serde_json::to_vec(&state).map_err(io::Error::other)?;
2058 let temporary = paths.seam_state.with_extension("tmp");
2059 let mut file = OpenOptions::new()
2060 .create(true)
2061 .truncate(true)
2062 .write(true)
2063 .open(&temporary)?;
2064 file.write_all(&bytes)?;
2065 file.sync_data()?;
2069 fs::rename(temporary, &paths.seam_state)
2070}
2071
2072fn seam_state(paths: &StatePaths) -> SeamState {
2073 fs::read(&paths.seam_state)
2074 .ok()
2075 .and_then(|bytes| serde_json::from_slice(&bytes).ok())
2076 .unwrap_or_default()
2077}
2078
2079fn governed_wire_request(
2080 determination: &RungRecord,
2081 agent_id: &str,
2082 request: GovernedRequest,
2083) -> Value {
2084 json!({
2085 "operation": ROUTING_OPERATION,
2086 "gh_route_schema": 1,
2087 "action": request.action,
2088 "target": request.target,
2089 "body": request.body,
2090 "repository": request.repository,
2091 "manifest_version": request.manifest_version,
2092 "rung_as_of_unix_secs": determination.as_of_unix_secs,
2093 "metadata": {
2094 "agent_id": agent_id,
2095 "pid": std::process::id(),
2096 },
2097 })
2098}
2099
2100fn parse_governed_response(bytes: &[u8]) -> Result<RouteOutcome, RouteOutcome> {
2101 let value: Value = serde_json::from_slice(bytes).map_err(|_| {
2102 RouteOutcome::SchemaMismatch(
2103 "governance seam returned malformed or non-UTF-8 JSON".to_string(),
2104 )
2105 })?;
2106 let object = value.as_object().ok_or_else(|| {
2107 RouteOutcome::SchemaMismatch("governance seam response must be an object".to_string())
2108 })?;
2109 match object.get("outcome").and_then(Value::as_str) {
2110 Some("result") => {
2111 let schema = object
2112 .get("gh_route_schema")
2113 .and_then(Value::as_u64)
2114 .ok_or_else(|| {
2115 RouteOutcome::SchemaMismatch(
2116 "governance seam omitted gh_route_schema".to_string(),
2117 )
2118 })?;
2119 if schema > 1 {
2120 return Err(RouteOutcome::SchemaMismatch(format!(
2121 "governance seam schema {schema} is newer than supported schema 1"
2122 )));
2123 }
2124 let result = object.get("result").ok_or_else(|| {
2125 RouteOutcome::SchemaMismatch("governance seam omitted result".to_string())
2126 })?;
2127 let field_order = object
2128 .get("field_order")
2129 .and_then(Value::as_array)
2130 .ok_or_else(|| {
2131 RouteOutcome::SchemaMismatch("governance seam omitted field_order".to_string())
2132 })?;
2133 render_governed_response(result, field_order).map(RouteOutcome::Result)
2134 }
2135 Some("refusal") => {
2136 let refusal_code = object
2137 .get("refusal_code")
2138 .and_then(Value::as_str)
2139 .ok_or_else(|| {
2140 RouteOutcome::SchemaMismatch(
2141 "governance refusal omitted a string refusal_code".to_string(),
2142 )
2143 })?;
2144 Ok(RouteOutcome::Refusal(refusal_code.to_string()))
2145 }
2146 Some("unbound_identity") => Ok(RouteOutcome::UnboundIdentity),
2147 _ => Err(RouteOutcome::SchemaMismatch(
2148 "governance seam returned an unknown outcome".to_string(),
2149 )),
2150 }
2151}
2152
2153fn render_governed_response(result: &Value, field_order: &[Value]) -> Result<String, RouteOutcome> {
2154 let object = result.as_object().ok_or_else(|| {
2155 RouteOutcome::SchemaMismatch("governance result must be an object".to_string())
2156 })?;
2157 let mut output = String::new();
2158 let mut rendered = BTreeSet::new();
2159 for field in field_order {
2160 let field = field.as_str().ok_or_else(|| {
2161 RouteOutcome::SchemaMismatch("field_order must contain string fields".to_string())
2162 })?;
2163 let value = object.get(field).ok_or_else(|| {
2164 RouteOutcome::SchemaMismatch(format!(
2165 "field_order references absent result field {field}"
2166 ))
2167 })?;
2168 if !rendered.insert(field) {
2169 return Err(RouteOutcome::SchemaMismatch(format!(
2170 "field_order repeats result field {field}"
2171 )));
2172 }
2173 render_field(&mut output, field, value)?;
2174 }
2175 if rendered.len() != object.len() {
2176 return Err(RouteOutcome::SchemaMismatch(
2177 "field_order does not cover every governed result field".to_string(),
2178 ));
2179 }
2180 Ok(output)
2181}
2182
2183fn render_field(output: &mut String, field: &str, value: &Value) -> Result<(), RouteOutcome> {
2184 match value {
2185 Value::Array(values) => {
2186 output.push_str(field);
2187 output.push_str(":\n");
2188 for value in values {
2189 output.push_str(" ");
2190 output.push_str(&render_scalar(value)?);
2191 output.push('\n');
2192 }
2193 }
2194 _ => {
2195 output.push_str(field);
2196 output.push_str(": ");
2197 output.push_str(&render_scalar(value)?);
2198 output.push('\n');
2199 }
2200 }
2201 Ok(())
2202}
2203
2204fn render_scalar(value: &Value) -> Result<String, RouteOutcome> {
2205 match value {
2206 Value::String(value) => serde_json::to_string(value)
2207 .map_err(|error| RouteOutcome::SchemaMismatch(error.to_string())),
2208 Value::Number(_) | Value::Bool(_) | Value::Null => Ok(value.to_string()),
2209 Value::Object(_) | Value::Array(_) => serde_json::to_string(value)
2210 .map_err(|error| RouteOutcome::SchemaMismatch(error.to_string())),
2211 }
2212}
2213
2214fn append_bypass_audit(
2215 paths: &StatePaths,
2216 tuple: &str,
2217 repository: Option<&str>,
2218 now: u64,
2219) -> io::Result<()> {
2220 fs::create_dir_all(&paths.root)?;
2221 let mut record = serde_json::to_vec(&json!({
2222 "as_of_unix_secs": now,
2223 "tuple": tuple,
2224 "repository": repository,
2225 }))
2226 .map_err(io::Error::other)?;
2227 record.push(b'\n');
2228 let mut file = OpenOptions::new()
2229 .create(true)
2230 .append(true)
2231 .open(&paths.bypass_audit)?;
2232 file.write_all(&record)?;
2233 file.sync_data()
2236}
2237
2238#[derive(Serialize)]
2239struct SelfReport {
2240 shim_version: &'static str,
2241 gh_routing_schema_floor: u64,
2242 unexpected_gh_route_advertiser: Option<Vec<String>>,
2243 bound_holder: Option<String>,
2244 agent_binding: Option<AgentBinding>,
2245 last_seam_refusal: Option<LastSeamRefusal>,
2246 cached_manifest: CachedManifestReport,
2247 last_rung: LastRungReport,
2248 bypass_audit: Option<Vec<Value>>,
2249 bypass_audit_error: Option<String>,
2250 executing_image: Option<String>,
2251 executing_image_error: Option<String>,
2252 real_gh_resolution: Option<RealGhResolution>,
2253 real_gh_resolution_error: Option<String>,
2254}
2255
2256#[derive(Serialize)]
2257struct CachedManifestReport {
2258 version: Option<u64>,
2259 version_error: Option<String>,
2260 state: Option<&'static str>,
2261 state_error: Option<String>,
2262 diagnostics: Vec<&'static str>,
2263}
2264
2265#[derive(Serialize)]
2266struct LastRungReport {
2267 rung: Option<&'static str>,
2268 rung_error: Option<String>,
2269 as_of_unix_secs: Option<u64>,
2270 as_of_unix_secs_error: Option<String>,
2271 determination_inputs: Option<BTreeMap<String, String>>,
2272 determination_inputs_error: Option<String>,
2273}
2274
2275#[derive(Serialize)]
2276struct RealGhResolution {
2277 path: String,
2278 shim_path_positions: Vec<usize>,
2279}
2280
2281fn print_self_report(paths: &StatePaths) {
2282 if let Ok(document) = render_self_report(paths) {
2285 let mut stdout = io::stdout().lock();
2286 let _ = stdout.write_all(document.as_bytes());
2287 }
2288}
2289
2290fn render_self_report(paths: &StatePaths) -> Result<String, serde_json::Error> {
2291 let report = build_self_report(paths);
2292 let mut document = serde_json::to_string(&report)?;
2293 document.push('\n');
2294 Ok(document)
2295}
2296
2297fn build_self_report(paths: &StatePaths) -> SelfReport {
2298 let image = self_report_executing_image();
2299 let (real_gh_resolution, real_gh_resolution_error) = match image.as_ref() {
2300 Ok(image) => match resolve_real_gh(image) {
2301 Some(path) => (
2302 Some(RealGhResolution {
2303 path: path.to_string_lossy().into_owned(),
2304 shim_path_positions: executing_image_path_positions(image),
2305 }),
2306 None,
2307 ),
2308 None => (
2309 None,
2310 Some(
2311 "PATH contains no upstream gh after skipping the executing shim image"
2312 .to_string(),
2313 ),
2314 ),
2315 },
2316 Err(error) => (None, Some(format!("executing image unavailable: {error}"))),
2317 };
2318 let (bypass_audit, bypass_audit_error) = read_bypass_audit(paths);
2319 let seam_state = seam_state(paths);
2320 let disabled = gh_shim_enabled_from_config_doc(read_user_config_doc().as_deref().unwrap_or(""))
2324 == Some(false);
2325 let (cached_manifest, last_rung) = if disabled {
2326 (disabled_manifest_report(), disabled_last_rung_report())
2327 } else {
2328 (cached_manifest_report(paths), last_rung_report(paths))
2329 };
2330 SelfReport {
2331 shim_version: env!("CARGO_PKG_VERSION"),
2332 gh_routing_schema_floor: SCHEMA_FLOOR,
2333 unexpected_gh_route_advertiser: unexpected_gh_route_advertisers(paths),
2334 bound_holder: seam_state.bound_holder,
2335 agent_binding: seam_state.agent_binding,
2336 last_seam_refusal: seam_state.last_seam_refusal,
2337 cached_manifest,
2338 last_rung,
2339 bypass_audit,
2340 bypass_audit_error,
2341 executing_image: image
2342 .as_ref()
2343 .ok()
2344 .map(|path| path.to_string_lossy().into_owned()),
2345 executing_image_error: image.err(),
2346 real_gh_resolution,
2347 real_gh_resolution_error,
2348 }
2349}
2350
2351fn disabled_manifest_report() -> CachedManifestReport {
2355 CachedManifestReport {
2356 version: None,
2357 version_error: None,
2358 state: Some("disabled"),
2359 state_error: None,
2360 diagnostics: Vec::new(),
2361 }
2362}
2363
2364fn disabled_last_rung_report() -> LastRungReport {
2367 LastRungReport {
2368 rung: Some(Rung::R1.label()),
2369 rung_error: None,
2370 as_of_unix_secs: Some(unix_seconds()),
2371 as_of_unix_secs_error: None,
2372 determination_inputs: Some(BTreeMap::from([(
2373 "connection_file".to_string(),
2374 "disabled_by_config".to_string(),
2375 )])),
2376 determination_inputs_error: None,
2377 }
2378}
2379
2380fn cached_manifest_report(paths: &StatePaths) -> CachedManifestReport {
2381 cached_manifest_report_at(paths, unix_seconds())
2382}
2383
2384fn cached_manifest_report_at(paths: &StatePaths, now: u64) -> CachedManifestReport {
2385 match load_manifest(paths, now) {
2386 Ok(manifest) => CachedManifestReport {
2387 version: Some(manifest.manifest_version),
2388 version_error: None,
2389 state: Some("valid"),
2390 state_error: None,
2391 diagnostics: Vec::new(),
2392 },
2393 Err(ManifestProblem::Missing) => {
2394 let error = ManifestProblem::Missing.status_label();
2395 CachedManifestReport {
2396 version: None,
2397 version_error: Some(error.clone()),
2398 state: None,
2399 state_error: Some(error),
2400 diagnostics: vec![SelfReportDiagnostic::ManifestUnavailable.as_str()],
2401 }
2402 }
2403 Err(problem) => {
2404 match read_last_valid_manifest(paths) {
2408 Some(cache) if cache_within_grace(&cache, now) => CachedManifestReport {
2409 version: Some(cache.manifest.manifest_version),
2410 version_error: None,
2411 state: Some("regressed_grace"),
2412 state_error: None,
2413 diagnostics: vec![
2414 SelfReportDiagnostic::ManifestRegressed.as_str(),
2415 problem.diagnostic().as_str(),
2416 ],
2417 },
2418 Some(cache) => CachedManifestReport {
2419 version: Some(cache.manifest.manifest_version),
2420 version_error: None,
2421 state: Some("regressed"),
2422 state_error: None,
2423 diagnostics: vec![
2424 SelfReportDiagnostic::ManifestRegressed.as_str(),
2425 problem.diagnostic().as_str(),
2426 ],
2427 },
2428 None => {
2429 let error = problem.status_label();
2430 CachedManifestReport {
2431 version: None,
2432 version_error: Some(error.clone()),
2433 state: None,
2434 state_error: Some(error),
2435 diagnostics: vec![problem.diagnostic().as_str()],
2436 }
2437 }
2438 }
2439 }
2440 }
2441}
2442
2443fn last_rung_report(paths: &StatePaths) -> LastRungReport {
2444 match fs::read(&paths.rung) {
2445 Ok(bytes) => match serde_json::from_slice::<RungRecord>(&bytes) {
2446 Ok(record) => LastRungReport {
2447 rung: Some(record.rung.label()),
2448 rung_error: None,
2449 as_of_unix_secs: Some(record.as_of_unix_secs),
2450 as_of_unix_secs_error: None,
2451 determination_inputs: Some(record.inputs),
2452 determination_inputs_error: None,
2453 },
2454 Err(error) => unavailable_last_rung(format!("corrupt rung cache: {error}")),
2455 },
2456 Err(error) if error.kind() == io::ErrorKind::NotFound => {
2457 unavailable_last_rung("rung cache is unavailable".to_string())
2458 }
2459 Err(error) => unavailable_last_rung(format!("rung cache is unavailable: {error}")),
2460 }
2461}
2462
2463fn unavailable_last_rung(error: String) -> LastRungReport {
2464 LastRungReport {
2465 rung: None,
2466 rung_error: Some(error.clone()),
2467 as_of_unix_secs: None,
2468 as_of_unix_secs_error: Some(error.clone()),
2469 determination_inputs: None,
2470 determination_inputs_error: Some(error),
2471 }
2472}
2473
2474fn read_bypass_audit(paths: &StatePaths) -> (Option<Vec<Value>>, Option<String>) {
2475 let contents = match fs::read_to_string(&paths.bypass_audit) {
2476 Ok(contents) => contents,
2477 Err(error) if error.kind() == io::ErrorKind::NotFound => return (Some(Vec::new()), None),
2478 Err(error) => return (None, Some(format!("bypass audit is unavailable: {error}"))),
2479 };
2480 let mut records = Vec::new();
2481 for (line_number, line) in contents.lines().enumerate() {
2482 match serde_json::from_str(line) {
2483 Ok(record) => records.push(record),
2484 Err(error) => {
2485 return (
2486 None,
2487 Some(format!(
2488 "bypass audit is corrupt at line {}: {error}",
2489 line_number + 1
2490 )),
2491 )
2492 }
2493 }
2494 }
2495 (Some(records), None)
2496}
2497
2498fn unexpected_gh_route_advertisers(paths: &StatePaths) -> Option<Vec<String>> {
2499 serde_json::from_slice(&fs::read(&paths.unexpected_gh_route_advertisers).ok()?)
2500 .ok()
2501 .filter(|advertisers: &Vec<String>| !advertisers.is_empty())
2502}
2503
2504fn record_unexpected_gh_route_advertisers(paths: &StatePaths, advertisers: &[String]) {
2505 if advertisers.is_empty() {
2506 return;
2507 }
2508 let mut recorded = unexpected_gh_route_advertisers(paths)
2509 .unwrap_or_default()
2510 .into_iter()
2511 .collect::<BTreeSet<_>>();
2512 recorded.extend(advertisers.iter().cloned());
2513 let Ok(bytes) = serde_json::to_vec(&recorded.into_iter().collect::<Vec<_>>()) else {
2514 return;
2515 };
2516 let _ = fs::create_dir_all(&paths.root);
2517 let temporary = paths.unexpected_gh_route_advertisers.with_extension("tmp");
2518 if fs::write(&temporary, bytes).is_ok() {
2519 let _ = fs::rename(temporary, &paths.unexpected_gh_route_advertisers);
2520 }
2521}
2522
2523fn self_report_executing_image() -> Result<PathBuf, String> {
2524 let path = std::env::current_exe().map_err(|error| error.to_string())?;
2525 Ok(path.canonicalize().unwrap_or(path))
2526}
2527
2528fn executing_image() -> PathBuf {
2529 std::env::current_exe()
2530 .ok()
2531 .and_then(|path| path.canonicalize().ok().or(Some(path)))
2532 .unwrap_or_else(|| PathBuf::from("unavailable"))
2533}
2534
2535fn executing_image_path_positions(image: &Path) -> Vec<usize> {
2536 let path = std::env::var_os("PATH").unwrap_or_default();
2537 std::env::split_paths(&path)
2538 .enumerate()
2539 .filter_map(|(index, directory)| same_image(&directory.join("gh"), image).then_some(index))
2540 .collect()
2541}
2542
2543fn delegate(args: &[OsString]) -> i32 {
2544 let image = executing_image();
2545 let Some(real_gh) = resolve_real_gh(&image) else {
2546 return refuse(
2547 RefusalCode::NoRealGh,
2548 "PATH contains no upstream gh after skipping the executing shim image",
2549 );
2550 };
2551 exec_real_gh(real_gh, args)
2552}
2553
2554fn resolve_real_gh(executing_image: &Path) -> Option<PathBuf> {
2555 let path = std::env::var_os("PATH")?;
2556 std::env::split_paths(&path).find_map(|directory| {
2557 let candidate = directory.join("gh");
2558 (is_executable_file(&candidate) && !same_image(&candidate, executing_image))
2559 .then_some(candidate)
2560 })
2561}
2562
2563fn is_executable_file(path: &Path) -> bool {
2564 if !path.is_file() {
2565 return false;
2566 }
2567 #[cfg(unix)]
2568 {
2569 use std::os::unix::fs::PermissionsExt;
2570 return fs::metadata(path).is_ok_and(|metadata| metadata.permissions().mode() & 0o111 != 0);
2571 }
2572 #[cfg(not(unix))]
2573 true
2574}
2575
2576fn same_image(left: &Path, right: &Path) -> bool {
2577 let left_canonical = left.canonicalize().ok();
2578 let right_canonical = right.canonicalize().ok();
2579 if left_canonical.is_some() && left_canonical == right_canonical {
2580 return true;
2581 }
2582 #[cfg(unix)]
2583 {
2584 use std::os::unix::fs::MetadataExt;
2585 if let (Ok(left), Ok(right)) = (fs::metadata(left), fs::metadata(right)) {
2586 return left.dev() == right.dev() && left.ino() == right.ino();
2587 }
2588 }
2589 false
2590}
2591
2592#[cfg(unix)]
2593fn exec_real_gh(real_gh: PathBuf, args: &[OsString]) -> i32 {
2594 use std::os::unix::process::CommandExt;
2595 let error = Command::new(real_gh).args(args).exec();
2596 refuse(
2600 RefusalCode::NoRealGh,
2601 &format!("unable to exec upstream gh: {error}"),
2602 )
2603}
2604
2605#[cfg(not(unix))]
2606fn exec_real_gh(real_gh: PathBuf, args: &[OsString]) -> i32 {
2607 match Command::new(real_gh).args(args).status() {
2608 Ok(status) => status.code().unwrap_or(1),
2609 Err(error) => refuse(
2610 RefusalCode::NoRealGh,
2611 &format!("unable to exec upstream gh: {error}"),
2612 ),
2613 }
2614}
2615
2616fn refuse(code: RefusalCode, text: &str) -> i32 {
2617 let text = text.replace(['\n', '\r'], " ");
2618 eprintln!("gh-shim: {}: {text}", code.as_str());
2619 REFUSAL_EXIT_STATUS
2620}
2621
2622fn current_platform() -> &'static str {
2623 if cfg!(target_os = "macos") {
2624 "macos"
2625 } else if cfg!(target_os = "linux") {
2626 "linux"
2627 } else {
2628 "unsupported"
2629 }
2630}
2631
2632fn unix_seconds() -> u64 {
2633 SystemTime::now()
2634 .duration_since(UNIX_EPOCH)
2635 .unwrap_or_default()
2636 .as_secs()
2637}
2638
2639#[cfg(test)]
2640mod tests {
2641 use super::*;
2642 use ring::signature::{Ed25519KeyPair, KeyPair};
2643
2644 const TEST_SEED: [u8; 32] = [
2645 0x9d, 0x61, 0xb1, 0x9d, 0xef, 0xfd, 0x5a, 0x60, 0xba, 0x84, 0x4a, 0xf4, 0x92, 0xec, 0x2c,
2646 0xc4, 0x44, 0x49, 0xc5, 0x69, 0x7b, 0x32, 0x69, 0x19, 0x70, 0x3b, 0xac, 0x03, 0x1c, 0xae,
2647 0x7f, 0x60,
2648 ];
2649 const STANDBY_TEST_SEED: [u8; 32] = *b"gh-shim-standby-fixture-seed-001";
2654 const DEV_STANDBY_MANIFEST_KEY_ID: &str = "gh-routing-dev-standby-key-v1";
2655 const FIXTURE_ISSUED_AT: u64 = 1_787_184_000;
2659 const TEST_NOW: u64 = FIXTURE_ISSUED_AT + 60;
2660 const FIXTURE_ACCEPTED_SEAM_REFUSAL_CODES: &[&str] = &[
2661 "identity_mismatch",
2662 "unmapped_operation",
2663 "custody_unavailable",
2664 "schema_unsupported",
2665 "rate_limited",
2666 ];
2667
2668 fn fixture_manifest() -> Manifest {
2669 serde_json::from_str(include_str!(
2670 "../tests/fixtures/gh_shim/initial-manifest-v1.json"
2671 ))
2672 .expect("initial manifest fixture")
2673 }
2674
2675 fn signed_with(
2676 manifest: &Manifest,
2677 fetched_at_unix_secs: u64,
2678 seed: &[u8; 32],
2679 key_id: &str,
2680 ) -> SignedManifest {
2681 let key = Ed25519KeyPair::from_seed_unchecked(seed).expect("test key");
2682 let bytes = serde_json::to_vec(manifest).expect("manifest bytes");
2683 SignedManifest {
2684 artifact_id: MANIFEST_ARTIFACT_ID.to_string(),
2685 envelope_version: ENVELOPE_VERSION,
2686 key_id: key_id.to_string(),
2687 fetched_at_unix_secs,
2688 signature: base64::engine::general_purpose::STANDARD.encode(key.sign(&bytes).as_ref()),
2689 manifest_bytes: String::from_utf8(bytes).expect("manifest bytes are UTF-8"),
2690 }
2691 }
2692
2693 fn signed(manifest: &Manifest, fetched_at_unix_secs: u64) -> SignedManifest {
2694 let key = Ed25519KeyPair::from_seed_unchecked(&TEST_SEED).expect("test key");
2695 assert_eq!(key.public_key().as_ref(), DEV_MANIFEST_PUBLIC_KEY);
2696 signed_with(
2697 manifest,
2698 fetched_at_unix_secs,
2699 &TEST_SEED,
2700 DEV_MANIFEST_KEY_ID,
2701 )
2702 }
2703
2704 fn write_signed_manifest(paths: &StatePaths, manifest: Manifest, now: u64) {
2705 fs::create_dir_all(&paths.root).expect("state root");
2706 fs::write(
2707 &paths.manifest,
2708 serde_json::to_vec(&signed(&manifest, now)).expect("signed manifest"),
2709 )
2710 .expect("manifest cache");
2711 }
2712
2713 fn write_envelope_fixture(paths: &StatePaths, envelope_json: &str) {
2714 fs::create_dir_all(&paths.root).expect("state root");
2715 fs::write(&paths.manifest, envelope_json.as_bytes()).expect("manifest cache");
2716 }
2717
2718 #[test]
2719 fn shim_dispatch_precedes_global_argument_scans_for_both_forms() {
2720 assert!(is_shim_invocation(
2721 OsStr::new("gh"),
2722 &[OsString::from("--version")]
2723 ));
2724 assert!(is_shim_invocation(
2725 OsStr::new("aft"),
2726 &[OsString::from("gh-shim"), OsString::from("--version")]
2727 ));
2728 assert!(!is_shim_invocation(
2729 OsStr::new("aft"),
2730 &[OsString::from("--version")]
2731 ));
2732 }
2733
2734 #[test]
2735 fn reserved_self_report_tokens_are_exactly_the_two_first_arguments() {
2736 assert_eq!(RESERVED_SELF_REPORT, ["--status", "--shim-version"]);
2737 assert!(is_reserved_self_report(&[OsString::from("--status")]));
2738 assert!(is_reserved_self_report(&[OsString::from("--shim-version")]));
2739 assert!(!is_reserved_self_report(&[OsString::from("status")]));
2740 assert!(!is_reserved_self_report(&[
2741 OsString::from("issue"),
2742 OsString::from("--status")
2743 ]));
2744 }
2745
2746 #[test]
2747 fn status_serializes_one_json_document_with_the_exact_top_level_schema() {
2748 let directory = tempfile::tempdir().unwrap();
2749 let paths = StatePaths::from_root(directory.path().to_path_buf());
2750 let document = render_self_report(&paths).expect("self report serialization");
2751 assert!(document.ends_with('\n'));
2752 let value: Value = serde_json::from_str(&document).expect("self report JSON");
2753 let keys = value
2754 .as_object()
2755 .expect("self report object")
2756 .keys()
2757 .cloned()
2758 .collect::<Vec<_>>();
2759 assert_eq!(
2760 keys,
2761 vec![
2762 "shim_version",
2763 "gh_routing_schema_floor",
2764 "unexpected_gh_route_advertiser",
2765 "bound_holder",
2766 "agent_binding",
2767 "last_seam_refusal",
2768 "cached_manifest",
2769 "last_rung",
2770 "bypass_audit",
2771 "bypass_audit_error",
2772 "executing_image",
2773 "executing_image_error",
2774 "real_gh_resolution",
2775 "real_gh_resolution_error",
2776 ]
2777 );
2778 }
2779
2780 #[test]
2781 fn route_holder_is_pinned_and_records_other_advertisers() {
2782 let holder = select_route_holder([
2783 "other-module".to_string(),
2784 ROUTING_HOLDER_MODULE_ID.to_string(),
2785 "another-module".to_string(),
2786 ]);
2787 assert_eq!(holder.module_id.as_deref(), Some(ROUTING_HOLDER_MODULE_ID));
2788 assert_eq!(
2789 holder.unexpected_advertisers,
2790 vec!["another-module", "other-module"]
2791 );
2792
2793 let holder = select_route_holder(["other-module".to_string()]);
2794 assert_eq!(holder.module_id, None);
2795 assert_eq!(holder.unexpected_advertisers, vec!["other-module"]);
2796 }
2797
2798 #[test]
2799 fn unexpected_route_advertisers_are_persisted_for_self_report() {
2800 let directory = tempfile::tempdir().unwrap();
2801 let paths = StatePaths::from_root(directory.path().to_path_buf());
2802 record_unexpected_gh_route_advertisers(&paths, &["other-module".to_string()]);
2803 record_unexpected_gh_route_advertisers(&paths, &["another-module".to_string()]);
2804
2805 assert_eq!(
2806 unexpected_gh_route_advertisers(&paths),
2807 Some(vec![
2808 "another-module".to_string(),
2809 "other-module".to_string(),
2810 ])
2811 );
2812 assert_eq!(
2813 build_self_report(&paths).unexpected_gh_route_advertiser,
2814 Some(vec![
2815 "another-module".to_string(),
2816 "other-module".to_string(),
2817 ])
2818 );
2819 }
2820
2821 #[test]
2822 fn disabled_by_config_short_circuits_to_r1_without_connection_file_read() {
2823 let directory = tempfile::tempdir().unwrap();
2824 let paths = StatePaths::from_root(directory.path().to_path_buf());
2825 let doc = serde_json::json!({
2828 "gh_shim": { "enabled": false },
2829 "subc": { "connection_file": "/nonexistent/connection.json" }
2830 })
2831 .to_string();
2832 let record = determine_rung_from_doc(
2833 &paths,
2834 Path::new("/cwd"),
2835 123,
2836 std::time::Instant::now() + DISCOVERY_BUDGET,
2837 Some(&doc),
2838 );
2839 assert_eq!(record.rung, Rung::R1);
2840 assert_eq!(
2841 record.inputs.get("connection_file").map(String::as_str),
2842 Some("disabled_by_config")
2843 );
2844 assert!(!paths.root.join("rung-cache.json").exists());
2846 }
2847
2848 #[test]
2849 fn configured_but_unreachable_connection_file_is_distinct_from_absence() {
2850 let directory = tempfile::tempdir().unwrap();
2851 let paths = StatePaths::from_root(directory.path().to_path_buf());
2852 let connection_file = directory.path().join("missing-connection.json");
2853 let doc = serde_json::json!({
2854 "subc": { "connection_file": connection_file }
2855 })
2856 .to_string();
2857 let record = determine_rung_from_doc(
2858 &paths,
2859 Path::new("/cwd"),
2860 1,
2861 std::time::Instant::now() + DISCOVERY_BUDGET,
2862 Some(&doc),
2863 );
2864 assert_eq!(record.rung, Rung::R1);
2865 assert_eq!(
2866 record.inputs.get("connection_file").map(String::as_str),
2867 Some("unreachable")
2868 );
2869 }
2870
2871 #[test]
2872 fn enabled_default_keeps_structural_rungs() {
2873 let directory = tempfile::tempdir().unwrap();
2874 let paths = StatePaths::from_root(directory.path().to_path_buf());
2875 let record = determine_rung_from_doc(
2877 &paths,
2878 Path::new("/cwd"),
2879 1,
2880 std::time::Instant::now() + DISCOVERY_BUDGET,
2881 Some("{}"),
2882 );
2883 assert_eq!(record.rung, Rung::R1);
2884 assert_eq!(
2885 record.inputs.get("connection_file").map(String::as_str),
2886 Some("absent_or_unparseable")
2887 );
2888 }
2889
2890 #[test]
2891 fn xdg_connection_config_precedes_home_config() {
2892 let directory = tempfile::tempdir().unwrap();
2893 let xdg = directory.path().join("xdg");
2894 let home = directory.path().join("home");
2895 let xdg_connection = directory.path().join("xdg-connection.json");
2896 let home_connection = directory.path().join("home-connection.json");
2897 fs::write(&xdg_connection, "{}").unwrap();
2898 fs::write(&home_connection, "{}").unwrap();
2899 let xdg_config = xdg.join("cortexkit/aft.jsonc");
2900 let home_config = home.join(".config/cortexkit/aft.jsonc");
2901 fs::create_dir_all(xdg_config.parent().unwrap()).unwrap();
2902 fs::create_dir_all(home_config.parent().unwrap()).unwrap();
2903 fs::write(
2907 &xdg_config,
2908 serde_json::json!({"subc": {"connection_file": xdg_connection}}).to_string(),
2909 )
2910 .unwrap();
2911 fs::write(
2912 &home_config,
2913 serde_json::json!({"subc": {"connection_file": home_connection}}).to_string(),
2914 )
2915 .unwrap();
2916
2917 assert_eq!(
2918 configured_connection_file_from(Some(xdg.as_os_str()), Some(home.as_os_str())),
2919 Some(xdg_connection)
2920 );
2921 }
2922
2923 #[test]
2924 fn initial_manifest_is_complete_and_valid() {
2925 fixture_manifest()
2926 .validate()
2927 .expect("valid initial manifest");
2928 }
2929
2930 #[test]
2931 fn manifest_rejects_duplicate_tiers_and_empty_api_rationales() {
2932 let mut duplicate = fixture_manifest();
2933 duplicate
2934 .tiers
2935 .get_mut(&Tier::Admin)
2936 .unwrap()
2937 .push(TupleDecl::Details {
2938 tuple: "issue comment".to_string(),
2939 platform: vec!["macos".to_string()],
2940 api_match: None,
2941 rationale: None,
2942 });
2943 assert!(duplicate.validate().unwrap_err().contains("both"));
2944
2945 let mut empty_api = fixture_manifest();
2946 empty_api
2947 .tiers
2948 .get_mut(&Tier::Admin)
2949 .unwrap()
2950 .push(TupleDecl::Details {
2951 tuple: "api patch close".to_string(),
2952 platform: vec!["macos".to_string()],
2953 api_match: Some(String::new()),
2954 rationale: None,
2955 });
2956 assert!(empty_api.validate().unwrap_err().contains("rationale"));
2957
2958 let mut malformed_binding = fixture_manifest();
2959 malformed_binding.bindings.insert(
2960 "https://github.com/cortexkit/aft.git".to_string(),
2961 "alfonso-aft".to_string(),
2962 );
2963 assert!(malformed_binding
2964 .validate()
2965 .unwrap_err()
2966 .contains("canonical owner/name"));
2967 }
2968
2969 #[test]
2970 fn binding_keys_and_governed_session_identity_are_stable() {
2971 assert_eq!(
2972 canonical_repository_key("https://github.com/CortexKit/aft.git"),
2973 Some("cortexkit/aft".to_string())
2974 );
2975 assert_eq!(
2976 canonical_repository_key("git@github.com:cortexkit/aft.git"),
2977 Some("cortexkit/aft".to_string())
2978 );
2979 assert_eq!(gh_session_id("alfonso-aft"), "gh-shim:alfonso-aft");
2980
2981 let request = GovernedRequest {
2982 action: "issue comment".to_string(),
2983 target: Map::new(),
2984 body: Map::new(),
2985 repository: Some("cortexkit/aft".to_string()),
2986 manifest_version: 1,
2987 };
2988 let wire = governed_wire_request(&RungRecord::r3(7, 1), "alfonso-aft", request);
2989 assert_eq!(wire["metadata"]["agent_id"], "alfonso-aft");
2990 assert_eq!(wire["metadata"]["pid"], std::process::id());
2991 }
2992
2993 #[test]
2994 fn manifest_rejects_repo_sections_that_add_or_lower_a_tuple() {
2995 let mut manifest = fixture_manifest();
2996 manifest.repository_sections.insert(
2997 "owner/repo".to_string(),
2998 RepositorySection {
2999 tiers: BTreeMap::from([(
3000 Tier::Mechanical,
3001 vec![TupleDecl::Details {
3002 tuple: "issue comment".to_string(),
3003 platform: vec!["macos".to_string()],
3004 api_match: None,
3005 rationale: None,
3006 }],
3007 )]),
3008 removed_tuples: Vec::new(),
3009 },
3010 );
3011 assert!(manifest.validate().unwrap_err().contains("lowers"));
3012
3013 manifest.repository_sections.insert(
3014 "owner/repo".to_string(),
3015 RepositorySection {
3016 tiers: BTreeMap::from([(
3017 Tier::Admin,
3018 vec![TupleDecl::Details {
3019 tuple: "workflow dispatch".to_string(),
3020 platform: vec!["macos".to_string()],
3021 api_match: None,
3022 rationale: None,
3023 }],
3024 )]),
3025 removed_tuples: Vec::new(),
3026 },
3027 );
3028 assert!(manifest.validate().unwrap_err().contains("adds"));
3029 }
3030
3031 #[test]
3032 fn signed_cache_rejects_tampering_staleness_and_old_schema_floor() {
3033 let directory = tempfile::tempdir().unwrap();
3034 let paths = StatePaths::from_root(directory.path().to_path_buf());
3035 let now = TEST_NOW;
3036 write_signed_manifest(&paths, fixture_manifest(), now);
3037 assert_eq!(load_manifest(&paths, now).unwrap().manifest_version, 1);
3038
3039 let mut value: Value = serde_json::from_slice(&fs::read(&paths.manifest).unwrap()).unwrap();
3042 let tampered =
3043 value["manifest_bytes"]
3044 .as_str()
3045 .unwrap()
3046 .replacen("issue view", "issue View", 1);
3047 value["manifest_bytes"] = Value::String(tampered);
3048 fs::write(&paths.manifest, serde_json::to_vec(&value).unwrap()).unwrap();
3049 assert!(matches!(
3050 load_manifest(&paths, now),
3051 Err(ManifestProblem::Invalid(_))
3052 ));
3053 assert_eq!(
3056 cached_manifest_report(&paths).diagnostics,
3057 vec![
3058 SelfReportDiagnostic::ManifestRegressed.as_str(),
3059 SelfReportDiagnostic::ManifestInvalid.as_str(),
3060 ]
3061 );
3062
3063 let mut below_floor = fixture_manifest();
3064 below_floor.schema_floor = 0;
3065 write_signed_manifest(&paths, below_floor, now);
3066 assert!(matches!(
3067 load_manifest(&paths, now),
3068 Err(ManifestProblem::BelowFloor { manifest_floor: 0 })
3069 ));
3070
3071 let mut stale = fixture_manifest();
3073 stale.issued_at_unix_secs =
3074 now - MANIFEST_TTL.as_secs() - MANIFEST_STALE_GRACE.as_secs() - 1;
3075 write_signed_manifest(&paths, stale, now);
3076 assert!(matches!(
3077 load_manifest(&paths, now),
3078 Err(ManifestProblem::Stale { .. })
3079 ));
3080
3081 let mut skewed = fixture_manifest();
3084 skewed.issued_at_unix_secs = now + ISSUED_AT_FUTURE_SKEW.as_secs();
3085 write_signed_manifest(&paths, skewed, now);
3086 assert!(load_manifest(&paths, now).is_ok());
3087
3088 let mut future = fixture_manifest();
3089 future.issued_at_unix_secs = now + ISSUED_AT_FUTURE_SKEW.as_secs() + 1;
3090 write_signed_manifest(&paths, future, now);
3091 assert!(matches!(
3092 load_manifest(&paths, now),
3093 Err(ManifestProblem::Invalid(_))
3094 ));
3095 }
3096
3097 #[test]
3098 fn classification_is_allowlist_driven_without_a_write_heuristic() {
3099 let manifest = fixture_manifest();
3100 assert!(matches!(
3101 classify(
3102 &[OsString::from("issue"), OsString::from("view")],
3103 &manifest,
3104 "macos"
3105 ),
3106 Classification::Mechanical
3107 ));
3108 assert!(matches!(
3109 classify(
3110 &[OsString::from("api"), OsString::from("/repos/a/b")],
3111 &manifest,
3112 "macos"
3113 ),
3114 Classification::Mechanical
3115 ));
3116 assert!(matches!(
3117 classify(
3118 &[
3119 OsString::from("api"),
3120 OsString::from("--method=POST"),
3121 OsString::from("/repos/a/b")
3122 ],
3123 &manifest,
3124 "macos"
3125 ),
3126 Classification::Unclassified
3127 ));
3128 assert!(matches!(
3129 classify(
3130 &[
3131 OsString::from("api"),
3132 OsString::from("--method"),
3133 OsString::from("POST"),
3134 OsString::from("/repos/a/b")
3135 ],
3136 &manifest,
3137 "macos"
3138 ),
3139 Classification::Unclassified
3140 ));
3141 assert!(matches!(
3142 classify(
3143 &[OsString::from("alias"), OsString::from("set")],
3144 &manifest,
3145 "macos"
3146 ),
3147 Classification::Unclassified
3148 ));
3149 assert!(matches!(
3150 classify(
3151 &[
3152 OsString::from("alias"),
3153 OsString::from("set"),
3154 OsString::from("--write")
3155 ],
3156 &manifest,
3157 "macos"
3158 ),
3159 Classification::Unclassified
3160 ));
3161 }
3162
3163 #[test]
3164 fn governed_canonicalization_normalizes_flags_and_explicit_repo_wins() {
3165 let manifest = fixture_manifest();
3166 let canonical = manifest.canonicalization["issue comment"].clone();
3167 let request = canonicalize_governed(
3168 &[
3169 OsString::from("--repo=owner/explicit"),
3170 OsString::from("issue"),
3171 OsString::from("comment"),
3172 OsString::from("42"),
3173 OsString::from("--body"),
3174 OsString::from("hello"),
3175 ],
3176 "issue comment",
3177 &canonical,
3178 1,
3179 )
3180 .unwrap();
3181 assert_eq!(request.repository.as_deref(), Some("owner/explicit"));
3182 assert_eq!(request.target["number"], "42");
3183 assert_eq!(request.body["body"], "hello");
3184 }
3185
3186 #[test]
3187 fn governed_renderer_is_deterministic_for_scalars_arrays_and_escapes() {
3188 let result = json!({"message":"snowman ☃\n", "items":["a", 2], "ok":true});
3189 let order = vec![json!("ok"), json!("message"), json!("items")];
3190 assert_eq!(
3191 render_governed_response(&result, &order).unwrap(),
3192 "ok: true\nmessage: \"snowman ☃\\n\"\nitems:\n \"a\"\n 2\n"
3193 );
3194 assert!(matches!(
3195 render_governed_response(&json!("scalar"), &order),
3196 Err(RouteOutcome::SchemaMismatch(_))
3197 ));
3198 }
3199
3200 #[test]
3201 fn lower_rungs_are_cached_durably_but_r1_is_not_written() {
3202 let directory = tempfile::tempdir().unwrap();
3203 let paths = StatePaths::from_root(directory.path().to_path_buf());
3204 let record = RungRecord::r2(123, "daemon_unreachable", None);
3205 write_rung_record_silently(&paths, &record);
3206 assert_eq!(load_rung_record(&paths).unwrap().rung, Rung::R2);
3207 assert!(!paths.root.join("r1-cache.json").exists());
3208 }
3209
3210 #[test]
3211 fn governance_unavailable_is_per_determination_and_does_not_latch_r3() {
3212 assert!(RungRecord::r1(1, "unreachable").governance_infrastructure_unavailable());
3213 assert!(
3214 RungRecord::r1(1, "discovery_budget_exhausted").governance_infrastructure_unavailable()
3215 );
3216 assert!(
3217 RungRecord::r2(1, "daemon_unreachable", None).governance_infrastructure_unavailable()
3218 );
3219 assert!(RungRecord::r2(1, "catalog_gh_route_absent", None)
3220 .governance_infrastructure_unavailable());
3221 assert!(!RungRecord::r2(1, "agent_credentials_present", Some(1))
3222 .governance_infrastructure_unavailable());
3223 assert!(!RungRecord::r3(2, 1).governance_infrastructure_unavailable());
3224 }
3225
3226 #[cfg(unix)]
3227 #[test]
3228 fn resolved_image_identity_skips_a_shim_reached_through_a_symlinked_parent() {
3229 use std::os::unix::fs::symlink;
3230
3231 let directory = tempfile::tempdir().unwrap();
3232 let image = directory.path().join("aft");
3233 fs::write(&image, b"shim image").unwrap();
3234 let bin = directory.path().join("bin");
3235 fs::create_dir(&bin).unwrap();
3236 symlink(&image, bin.join("gh")).unwrap();
3237 let linked_parent = directory.path().join("linked-bin");
3238 symlink(&bin, &linked_parent).unwrap();
3239
3240 assert!(same_image(&linked_parent.join("gh"), &image));
3241 }
3242
3243 #[test]
3244 fn bypass_audit_is_visible_to_a_later_self_report_reader() {
3245 let directory = tempfile::tempdir().unwrap();
3246 let paths = StatePaths::from_root(directory.path().to_path_buf());
3247 append_bypass_audit(&paths, "issue close", Some("owner/repo"), 99).unwrap();
3248 let (records, error) = read_bypass_audit(&paths);
3249 assert!(error.is_none());
3250 let records = records.unwrap();
3251 assert_eq!(records.len(), 1);
3252 assert_eq!(records[0]["tuple"], "issue close");
3253 }
3254
3255 #[test]
3256 fn refusal_and_self_report_codes_are_separate_closed_sets() {
3257 assert_eq!(RefusalCode::ALL.len(), 12);
3258 assert!(RefusalCode::ALL
3259 .iter()
3260 .all(|code| code.as_str().starts_with("gh_shim_")));
3261 assert_eq!(
3262 RefusalCode::GovernanceUnavailable.as_str(),
3263 "gh_shim_governance_unavailable"
3264 );
3265 assert_eq!(
3266 GOVERNANCE_UNAVAILABLE_TEXT,
3267 "the governance daemon is unreachable and this repository's actions are identity-governed; retry after the daemon returns"
3268 );
3269 assert_eq!(SelfReportDiagnostic::ALL.len(), 7);
3270 assert!(SelfReportDiagnostic::ALL
3271 .iter()
3272 .all(|code| code.as_str().starts_with("gh_shim_status_")));
3273 assert_eq!(REFUSAL_EXIT_STATUS, 86);
3274 }
3275
3276 #[test]
3277 fn v1_write_classification_accepts_only_the_reviewed_tuple_sets() {
3278 let manifest = fixture_manifest();
3279 for tuple in V1_GOVERNED_TUPLES {
3280 let args = tuple
3281 .split_whitespace()
3282 .map(OsString::from)
3283 .collect::<Vec<_>>();
3284 assert!(matches!(
3285 classify(&args, &manifest, "macos"),
3286 Classification::Governed { .. }
3287 ));
3288 }
3289 for tuple in V1_ADMIN_TUPLES {
3290 let args = tuple
3291 .split_whitespace()
3292 .map(OsString::from)
3293 .collect::<Vec<_>>();
3294 assert!(matches!(
3295 classify(&args, &manifest, "macos"),
3296 Classification::Admin { .. }
3297 ));
3298 }
3299 for args in [
3300 ["release", "publish"].as_slice(),
3301 ["issue", "create"].as_slice(),
3302 ["pr", "reopen"].as_slice(),
3303 ] {
3304 let args = args.iter().map(OsString::from).collect::<Vec<_>>();
3305 assert!(matches!(
3306 classify(&args, &manifest, "macos"),
3307 Classification::Unclassified
3308 ));
3309 }
3310 }
3311
3312 #[test]
3313 fn field_bearing_api_forms_remain_unclassified_without_an_audited_parser() {
3314 let manifest = fixture_manifest();
3315 for field_flag in [
3316 "--field=name=value",
3317 "--raw-field=name=value",
3318 "--input=body.json",
3319 "-fname=value",
3320 "-Fname=value",
3321 ] {
3322 let args = vec![
3323 OsString::from("api"),
3324 OsString::from("/repos/owner/repo"),
3325 OsString::from(field_flag),
3326 ];
3327 assert!(matches!(
3328 classify(&args, &manifest, "macos"),
3329 Classification::Unclassified
3330 ));
3331 }
3332 }
3333
3334 #[test]
3335 fn holder_refusals_preserve_any_string_code_and_reject_non_strings() {
3336 for code in FIXTURE_ACCEPTED_SEAM_REFUSAL_CODES {
3337 let response = json!({"outcome": "refusal", "refusal_code": code});
3338 let outcome = parse_governed_response(&serde_json::to_vec(&response).unwrap()).unwrap();
3339 assert!(matches!(outcome, RouteOutcome::Refusal(ref actual) if actual == code));
3340 assert_eq!(RefusalCode::SeamRefusal.as_str(), "gh_shim_seam_refusal");
3341 assert_eq!(
3342 seam_refusal_text(code),
3343 format!("governance seam refused the action: {code}")
3344 );
3345 assert_eq!(REFUSAL_EXIT_STATUS, 86);
3346 }
3347 let unknown = "quota_exhausted_v2";
3348 let response = json!({"outcome": "refusal", "refusal_code": unknown});
3349 let outcome = parse_governed_response(&serde_json::to_vec(&response).unwrap()).unwrap();
3350 assert!(matches!(outcome, RouteOutcome::Refusal(ref actual) if actual == unknown));
3351 assert_eq!(RefusalCode::SeamRefusal.as_str(), "gh_shim_seam_refusal");
3352 assert_eq!(
3353 seam_refusal_text(unknown),
3354 "governance seam refused the action: quota_exhausted_v2"
3355 );
3356 assert_eq!(REFUSAL_EXIT_STATUS, 86);
3357
3358 for response in [
3359 json!({"outcome": "refusal", "refusal_code": 7}),
3360 json!({"outcome": "refusal", "refusal_code": null}),
3361 json!({"outcome": "refusal"}),
3362 ] {
3363 assert!(matches!(
3364 parse_governed_response(&serde_json::to_vec(&response).unwrap()),
3365 Err(RouteOutcome::SchemaMismatch(_))
3366 ));
3367 }
3368 }
3369
3370 #[test]
3371 fn governed_self_report_transitions_are_durable_and_mechanical_classification_preserves_them() {
3372 let directory = tempfile::tempdir().unwrap();
3373 let paths = StatePaths::from_root(directory.path().to_path_buf());
3374 let binding = AgentBinding {
3375 repo: "owner/repo".to_string(),
3376 agent_id: "agent-7".to_string(),
3377 };
3378 write_seam_state(
3379 &paths,
3380 SeamState {
3381 bound_holder: None,
3382 agent_binding: Some(binding.clone()),
3383 last_seam_refusal: None,
3384 },
3385 )
3386 .unwrap();
3387 let report = build_self_report(&paths);
3388 assert_eq!(report.bound_holder, None);
3389 assert_eq!(report.agent_binding, Some(binding.clone()));
3390 assert_eq!(report.last_seam_refusal, None);
3391
3392 write_seam_state(
3393 &paths,
3394 SeamState {
3395 bound_holder: Some(ROUTING_HOLDER_MODULE_ID.to_string()),
3396 agent_binding: Some(binding.clone()),
3397 last_seam_refusal: Some(LastSeamRefusal {
3398 code: "rate_limited".to_string(),
3399 at_unix_secs: 77,
3400 }),
3401 },
3402 )
3403 .unwrap();
3404 let report = build_self_report(&paths);
3405 assert_eq!(
3406 report.bound_holder.as_deref(),
3407 Some(ROUTING_HOLDER_MODULE_ID)
3408 );
3409 assert_eq!(report.agent_binding, Some(binding.clone()));
3410 assert_eq!(
3411 report
3412 .last_seam_refusal
3413 .as_ref()
3414 .map(|refusal| refusal.code.as_str()),
3415 Some("rate_limited")
3416 );
3417
3418 write_seam_state(
3419 &paths,
3420 governed_seam_state(&paths, Some(ROUTING_HOLDER_MODULE_ID.to_string()), &binding),
3421 )
3422 .unwrap();
3423 assert_eq!(
3424 seam_state(&paths)
3425 .last_seam_refusal
3426 .as_ref()
3427 .map(|refusal| refusal.code.as_str()),
3428 Some("rate_limited")
3429 );
3430
3431 let mechanical = [OsString::from("issue"), OsString::from("view")];
3432 assert!(matches!(
3433 classify(&mechanical, &fixture_manifest(), "macos"),
3434 Classification::Mechanical
3435 ));
3436 assert_eq!(
3437 seam_state(&paths)
3438 .last_seam_refusal
3439 .as_ref()
3440 .map(|refusal| refusal.at_unix_secs),
3441 Some(77)
3442 );
3443 }
3444
3445 #[test]
3446 fn governed_self_report_persistence_failure_is_loud() {
3447 let directory = tempfile::tempdir().unwrap();
3448 let state_root = directory.path().join("not-a-directory");
3449 fs::write(&state_root, b"file").unwrap();
3450 let paths = StatePaths::from_root(state_root);
3451 assert!(write_seam_state(&paths, SeamState::default()).is_err());
3452 }
3453
3454 #[test]
3455 fn raw_bytes_round_trip_verifies_then_parses_from_the_fixture_envelope() {
3456 let envelope: SignedManifest = serde_json::from_str(include_str!(
3457 "../tests/fixtures/gh_shim/signed-envelope-v2.json"
3458 ))
3459 .expect("signed envelope fixture");
3460 assert_eq!(
3462 envelope.manifest_bytes,
3463 include_str!("../tests/fixtures/gh_shim/initial-manifest-v1.json")
3464 );
3465 let manifest = verify_manifest_signature(&envelope).expect("fixture signature verifies");
3467 assert_eq!(manifest.manifest_version, 1);
3468 assert_eq!(manifest.issued_at_unix_secs, FIXTURE_ISSUED_AT);
3469 manifest.validate().expect("fixture manifest validates");
3470 }
3471
3472 #[test]
3473 fn tampered_single_byte_fixture_fails_signature_verification() {
3474 let canonical: SignedManifest = serde_json::from_str(include_str!(
3475 "../tests/fixtures/gh_shim/signed-envelope-v2.json"
3476 ))
3477 .expect("canonical envelope fixture");
3478 let tampered: SignedManifest = serde_json::from_str(include_str!(
3479 "../tests/fixtures/gh_shim/signed-envelope-v2-tampered.json"
3480 ))
3481 .expect("tampered envelope fixture");
3482 assert_eq!(
3485 canonical.manifest_bytes.len(),
3486 tampered.manifest_bytes.len()
3487 );
3488 assert_eq!(
3489 canonical
3490 .manifest_bytes
3491 .bytes()
3492 .zip(tampered.manifest_bytes.bytes())
3493 .filter(|(left, right)| left != right)
3494 .count(),
3495 1
3496 );
3497 assert_eq!(canonical.signature, tampered.signature);
3498 assert!(matches!(
3499 verify_manifest_signature(&tampered),
3500 Err(ManifestProblem::Invalid(_))
3501 ));
3502 }
3503
3504 #[test]
3505 fn future_and_stale_issued_at_fixtures_are_refused() {
3506 let directory = tempfile::tempdir().unwrap();
3507 let paths = StatePaths::from_root(directory.path().to_path_buf());
3508
3509 write_envelope_fixture(
3510 &paths,
3511 include_str!("../tests/fixtures/gh_shim/signed-envelope-v2-future-issued-at.json"),
3512 );
3513 match load_manifest(&paths, TEST_NOW) {
3514 Err(ManifestProblem::Invalid(error)) => {
3515 assert!(error.contains("future"), "unexpected error: {error}")
3516 }
3517 other => panic!("expected future issued_at refusal, got {other:?}"),
3518 }
3519
3520 write_envelope_fixture(
3521 &paths,
3522 include_str!("../tests/fixtures/gh_shim/signed-envelope-v2-stale-issued-at.json"),
3523 );
3524 assert!(matches!(
3525 load_manifest(&paths, TEST_NOW),
3526 Err(ManifestProblem::Stale {
3527 manifest_version: 1
3528 })
3529 ));
3530 }
3531
3532 #[test]
3533 fn standby_key_fixture_verifies_under_a_two_slot_trust_set_and_unknown_key_ids_are_refused() {
3534 let envelope: SignedManifest = serde_json::from_str(include_str!(
3535 "../tests/fixtures/gh_shim/signed-envelope-v2-standby-key.json"
3536 ))
3537 .expect("standby envelope fixture");
3538
3539 let standby = Ed25519KeyPair::from_seed_unchecked(&STANDBY_TEST_SEED).expect("standby key");
3540 assert_ne!(standby.public_key().as_ref(), DEV_MANIFEST_PUBLIC_KEY);
3541 let standby_public: &'static [u8] =
3542 Box::leak(standby.public_key().as_ref().to_vec().into_boxed_slice());
3543 let trust_set = [
3544 Some(ManifestTrustKey {
3545 key_id: DEV_MANIFEST_KEY_ID,
3546 public_key: &DEV_MANIFEST_PUBLIC_KEY,
3547 }),
3548 Some(ManifestTrustKey {
3549 key_id: DEV_STANDBY_MANIFEST_KEY_ID,
3550 public_key: standby_public,
3551 }),
3552 ];
3553
3554 let manifest =
3556 verify_manifest_signature_with(&envelope, &trust_set).expect("standby slot verifies");
3557 assert_eq!(
3558 manifest.manifest_version,
3559 fixture_manifest().manifest_version
3560 );
3561
3562 let mut unknown = envelope.clone();
3564 unknown.key_id = "gh-routing-unknown-key".to_string();
3565 assert!(matches!(
3566 verify_manifest_signature_with(&unknown, &trust_set),
3567 Err(ManifestProblem::Invalid(_))
3568 ));
3569 }
3570
3571 #[test]
3572 fn compiled_trust_set_shape_matches_the_two_slot_design() {
3573 let slots = compiled_manifest_trust_set();
3574 #[cfg(debug_assertions)]
3575 {
3576 assert_eq!(slots.len(), 1);
3578 assert_eq!(slots[0].unwrap().key_id, DEV_MANIFEST_KEY_ID);
3579 }
3580 #[cfg(not(debug_assertions))]
3581 {
3582 assert_eq!(slots.len(), 2);
3585 assert!(slots.iter().all(Option::is_none));
3586 }
3587 }
3588
3589 #[test]
3590 fn envelope_v1_shapes_are_refused_by_the_v2_verifier() {
3591 let directory = tempfile::tempdir().unwrap();
3592 let paths = StatePaths::from_root(directory.path().to_path_buf());
3593 let manifest = fixture_manifest();
3594 let bytes = serde_json::to_vec(&manifest).unwrap();
3595 let key = Ed25519KeyPair::from_seed_unchecked(&TEST_SEED).unwrap();
3596 let signature = base64::engine::general_purpose::STANDARD.encode(key.sign(&bytes).as_ref());
3597
3598 let v1_object = json!({
3600 "artifact_id": MANIFEST_ARTIFACT_ID,
3601 "key_id": DEV_MANIFEST_KEY_ID,
3602 "fetched_at_unix_secs": TEST_NOW,
3603 "signature": signature,
3604 "manifest": serde_json::to_value(&manifest).unwrap(),
3605 });
3606 fs::write(&paths.manifest, serde_json::to_vec(&v1_object).unwrap()).unwrap();
3607 assert!(matches!(
3608 load_manifest(&paths, TEST_NOW),
3609 Err(ManifestProblem::Invalid(_))
3610 ));
3611
3612 let mut old_version = signed(&manifest, TEST_NOW);
3614 old_version.envelope_version = 1;
3615 fs::write(&paths.manifest, serde_json::to_vec(&old_version).unwrap()).unwrap();
3616 match load_manifest(&paths, TEST_NOW) {
3617 Err(ManifestProblem::Invalid(error)) => {
3618 assert!(
3619 error.contains("envelope version"),
3620 "unexpected error: {error}"
3621 )
3622 }
3623 other => panic!("expected envelope version refusal, got {other:?}"),
3624 }
3625 }
3626
3627 #[test]
3628 fn dormant_resolution_is_presence_based() {
3629 let directory = tempfile::tempdir().unwrap();
3630 let paths = StatePaths::from_root(directory.path().to_path_buf());
3631 assert!(matches!(
3633 resolve_manifest(&paths, TEST_NOW),
3634 ManifestResolution::Dormant
3635 ));
3636
3637 let untrusted = signed_with(
3641 &fixture_manifest(),
3642 TEST_NOW,
3643 &STANDBY_TEST_SEED,
3644 "gh-routing-unknown-key",
3645 );
3646 fs::write(&paths.manifest, serde_json::to_vec(&untrusted).unwrap()).unwrap();
3647 assert!(matches!(
3648 resolve_manifest(&paths, TEST_NOW),
3649 ManifestResolution::Dormant
3650 ));
3651 }
3652
3653 #[test]
3654 fn regressed_past_grace_refuses_governed_and_admin_and_passes_mechanical() {
3655 let directory = tempfile::tempdir().unwrap();
3656 let paths = StatePaths::from_root(directory.path().to_path_buf());
3657 let accepted_at = TEST_NOW;
3658
3659 write_signed_manifest(&paths, fixture_manifest(), accepted_at);
3661 load_manifest(&paths, accepted_at).expect("canonical manifest verifies");
3662
3663 write_envelope_fixture(
3665 &paths,
3666 include_str!("../tests/fixtures/gh_shim/signed-envelope-v2-tampered.json"),
3667 );
3668
3669 assert!(matches!(
3671 resolve_manifest(&paths, accepted_at + MANIFEST_STALE_GRACE.as_secs()),
3672 ManifestResolution::GraceCache(_)
3673 ));
3674
3675 let now = accepted_at + MANIFEST_STALE_GRACE.as_secs() + 1;
3677 let ManifestResolution::Regressed(manifest) = resolve_manifest(&paths, now) else {
3678 panic!("expected the regressed arm");
3679 };
3680 let governed = [
3681 OsString::from("issue"),
3682 OsString::from("comment"),
3683 OsString::from("42"),
3684 OsString::from("--body"),
3685 OsString::from("hello"),
3686 ];
3687 assert!(matches!(
3688 regressed_disposition(&governed, &manifest, "macos"),
3689 RegressedDisposition::Refuse {
3690 code: RefusalCode::ManifestRegressed,
3691 ..
3692 }
3693 ));
3694 let admin = [
3695 OsString::from("pr"),
3696 OsString::from("merge"),
3697 OsString::from("1"),
3698 ];
3699 assert!(matches!(
3700 regressed_disposition(&admin, &manifest, "macos"),
3701 RegressedDisposition::Refuse {
3702 code: RefusalCode::ManifestRegressed,
3703 ..
3704 }
3705 ));
3706 let mechanical = [OsString::from("issue"), OsString::from("view")];
3707 assert!(matches!(
3708 regressed_disposition(&mechanical, &manifest, "macos"),
3709 RegressedDisposition::Passthrough
3710 ));
3711 let undeclared = [OsString::from("alias"), OsString::from("set")];
3712 assert!(matches!(
3713 regressed_disposition(&undeclared, &manifest, "macos"),
3714 RegressedDisposition::Refuse {
3715 code: RefusalCode::Unclassified,
3716 ..
3717 }
3718 ));
3719
3720 let report = cached_manifest_report_at(&paths, now);
3722 assert_eq!(report.state, Some("regressed"));
3723 assert_eq!(report.version, Some(1));
3724 assert_eq!(
3725 report.diagnostics,
3726 vec![
3727 SelfReportDiagnostic::ManifestRegressed.as_str(),
3728 SelfReportDiagnostic::ManifestInvalid.as_str(),
3729 ]
3730 );
3731 }
3732
3733 #[test]
3734 fn version_high_water_refuses_rollbacks_and_the_incident_is_visible_in_self_report() {
3735 let directory = tempfile::tempdir().unwrap();
3736 let paths = StatePaths::from_root(directory.path().to_path_buf());
3737
3738 write_envelope_fixture(
3740 &paths,
3741 include_str!("../tests/fixtures/gh_shim/signed-envelope-v2-version-2.json"),
3742 );
3743 assert_eq!(load_manifest(&paths, TEST_NOW).unwrap().manifest_version, 2);
3744 assert_eq!(version_high_water(&paths), 2);
3745
3746 write_envelope_fixture(
3749 &paths,
3750 include_str!("../tests/fixtures/gh_shim/signed-envelope-v2.json"),
3751 );
3752 assert!(matches!(
3753 load_manifest(&paths, TEST_NOW),
3754 Err(ManifestProblem::RolledBack {
3755 manifest_version: 1,
3756 newest_accepted: 2,
3757 })
3758 ));
3759 let report = cached_manifest_report_at(&paths, TEST_NOW);
3760 assert_eq!(
3761 report.diagnostics,
3762 vec![
3763 SelfReportDiagnostic::ManifestRegressed.as_str(),
3764 SelfReportDiagnostic::ManifestRollback.as_str(),
3765 ]
3766 );
3767 let document = render_self_report(&paths).expect("self report");
3769 assert!(document.contains(SelfReportDiagnostic::ManifestRollback.as_str()));
3770
3771 write_envelope_fixture(
3773 &paths,
3774 include_str!("../tests/fixtures/gh_shim/signed-envelope-v2-version-2.json"),
3775 );
3776 assert_eq!(load_manifest(&paths, TEST_NOW).unwrap().manifest_version, 2);
3777 }
3778
3779 fn fixture_dir() -> PathBuf {
3780 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/gh_shim")
3781 }
3782
3783 fn canonical_manifest_bytes() -> Vec<u8> {
3784 fs::read(fixture_dir().join("initial-manifest-v1.json"))
3785 .expect("canonical manifest fixture")
3786 }
3787
3788 fn envelope_json(envelope: &SignedManifest) -> Vec<u8> {
3789 let mut bytes = serde_json::to_vec_pretty(envelope).expect("envelope serialization");
3790 bytes.push(b'\n');
3791 bytes
3792 }
3793
3794 fn generate_envelope_fixtures() -> Vec<(String, Vec<u8>)> {
3798 let sign = |bytes: &[u8], seed: &[u8; 32]| {
3799 let key = Ed25519KeyPair::from_seed_unchecked(seed).expect("fixture key");
3800 base64::engine::general_purpose::STANDARD.encode(key.sign(bytes).as_ref())
3801 };
3802 let envelope = |key_id: &str, seed: &[u8; 32], manifest_bytes: String| {
3803 envelope_json(&SignedManifest {
3804 artifact_id: MANIFEST_ARTIFACT_ID.to_string(),
3805 envelope_version: ENVELOPE_VERSION,
3806 key_id: key_id.to_string(),
3807 fetched_at_unix_secs: FIXTURE_ISSUED_AT,
3808 signature: sign(manifest_bytes.as_bytes(), seed),
3809 manifest_bytes,
3810 })
3811 };
3812
3813 let canonical = canonical_manifest_bytes();
3814 let canonical_text = String::from_utf8(canonical.clone()).expect("UTF-8 manifest");
3815 let canonical_signature = sign(&canonical, &TEST_SEED);
3816
3817 let mut fixtures = Vec::new();
3818 fixtures.push((
3820 "signed-envelope-v2.json".to_string(),
3821 envelope_json(&SignedManifest {
3822 artifact_id: MANIFEST_ARTIFACT_ID.to_string(),
3823 envelope_version: ENVELOPE_VERSION,
3824 key_id: DEV_MANIFEST_KEY_ID.to_string(),
3825 fetched_at_unix_secs: FIXTURE_ISSUED_AT,
3826 signature: canonical_signature.clone(),
3827 manifest_bytes: canonical_text.clone(),
3828 }),
3829 ));
3830 let tampered = canonical_text.replacen("issue view", "issue View", 1);
3833 assert_ne!(tampered, canonical_text);
3834 fixtures.push((
3835 "signed-envelope-v2-tampered.json".to_string(),
3836 envelope_json(&SignedManifest {
3837 artifact_id: MANIFEST_ARTIFACT_ID.to_string(),
3838 envelope_version: ENVELOPE_VERSION,
3839 key_id: DEV_MANIFEST_KEY_ID.to_string(),
3840 fetched_at_unix_secs: FIXTURE_ISSUED_AT,
3841 signature: canonical_signature,
3842 manifest_bytes: tampered,
3843 }),
3844 ));
3845
3846 let mut variant = |name: &str, mutate: fn(&mut Manifest), seed: &[u8; 32], key_id: &str| {
3847 let mut manifest = fixture_manifest();
3848 mutate(&mut manifest);
3849 let bytes = serde_json::to_vec(&manifest).expect("variant manifest bytes");
3850 fixtures.push((
3851 name.to_string(),
3852 envelope(
3853 key_id,
3854 seed,
3855 String::from_utf8(bytes).expect("UTF-8 variant bytes"),
3856 ),
3857 ));
3858 };
3859 variant(
3860 "signed-envelope-v2-future-issued-at.json",
3861 |manifest| {
3862 manifest.issued_at_unix_secs =
3863 FIXTURE_ISSUED_AT + ISSUED_AT_FUTURE_SKEW.as_secs() + 3300;
3864 },
3865 &TEST_SEED,
3866 DEV_MANIFEST_KEY_ID,
3867 );
3868 variant(
3869 "signed-envelope-v2-stale-issued-at.json",
3870 |manifest| {
3871 manifest.issued_at_unix_secs = FIXTURE_ISSUED_AT - 2_000_000;
3872 },
3873 &TEST_SEED,
3874 DEV_MANIFEST_KEY_ID,
3875 );
3876 variant(
3877 "signed-envelope-v2-version-2.json",
3878 |manifest| {
3879 manifest.manifest_version = 2;
3880 },
3881 &TEST_SEED,
3882 DEV_MANIFEST_KEY_ID,
3883 );
3884 variant(
3885 "signed-envelope-v2-standby-key.json",
3886 |_manifest| {},
3887 &STANDBY_TEST_SEED,
3888 DEV_STANDBY_MANIFEST_KEY_ID,
3889 );
3890 fixtures
3891 }
3892
3893 #[test]
3894 fn signed_envelope_fixtures_match_their_generator() {
3895 let regen = std::env::var_os("AFT_GH_SHIM_REGEN").is_some();
3896 for (name, bytes) in generate_envelope_fixtures() {
3897 let path = fixture_dir().join(&name);
3898 if regen {
3899 fs::write(&path, &bytes).expect("write fixture");
3900 continue;
3901 }
3902 let disk = fs::read(&path)
3903 .unwrap_or_else(|error| panic!("fixture {name} is missing: {error}"));
3904 assert_eq!(
3905 disk, bytes,
3906 "fixture {name} drifted from its generator; rerun with AFT_GH_SHIM_REGEN=1"
3907 );
3908 }
3909 }
3910}