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