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