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