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 V9_ADMIN_TUPLES: &[&str] = &["repo edit", "run delete"];
47const V10_ADMIN_TUPLES: &[&str] = &["workflow run"];
48const V10_EDIT_LAST_TUPLES: &[&str] = &["issue comment", "pr comment"];
52const READ_ONLY_ACTION_TUPLES: &[&str] = &[
53 "run view",
54 "run list",
55 "run watch",
56 "workflow view",
57 "workflow list",
58];
59const RESERVED_SELF_REPORT: &[&str] = &["--status", "--shim-version"];
60const CO_AUTHOR_LINE_REPORT: &str = "--co-author-line";
61const GOVERNANCE_UNAVAILABLE_TEXT: &str = "the governance daemon is unreachable and this repository's actions are identity-governed; retry after the daemon returns";
62const UNTRUSTED_MANIFEST_KEY_STEERING: &str = "the manifest may be newer than this aft build's trust set - update aft, or install a manifest signed by a trusted key";
63const PRE_PROVENANCE_RECORD: &str = "unrecorded (pre-provenance record)";
64
65#[derive(Clone, Copy, Debug, Eq, PartialEq)]
68pub enum RefusalCode {
69 Unclassified,
70 AdminTier,
71 ManifestBelowFloor,
72 ManifestRegressed,
73 SeamSchemaMismatch,
74 UnboundIdentity,
75 BypassAuditUnavailable,
76 NoRealGh,
77 GovernanceUnavailable,
78 SeamUnavailable,
79 SeamRefusal,
80}
81
82impl RefusalCode {
83 pub const ALL: [Self; 11] = [
84 Self::Unclassified,
85 Self::AdminTier,
86 Self::ManifestBelowFloor,
87 Self::ManifestRegressed,
88 Self::SeamSchemaMismatch,
89 Self::UnboundIdentity,
90 Self::BypassAuditUnavailable,
91 Self::NoRealGh,
92 Self::GovernanceUnavailable,
93 Self::SeamUnavailable,
94 Self::SeamRefusal,
95 ];
96
97 pub const fn as_str(self) -> &'static str {
98 match self {
99 Self::Unclassified => "gh_shim_unclassified",
100 Self::AdminTier => "gh_shim_admin_tier",
101 Self::ManifestBelowFloor => "gh_shim_manifest_below_floor",
102 Self::ManifestRegressed => "gh_shim_manifest_regressed",
103 Self::SeamSchemaMismatch => "gh_shim_seam_schema_mismatch",
104 Self::UnboundIdentity => "gh_shim_unbound_identity",
105 Self::BypassAuditUnavailable => "gh_shim_bypass_audit_unavailable",
106 Self::NoRealGh => "gh_shim_no_real_gh",
107 Self::GovernanceUnavailable => "gh_shim_governance_unavailable",
108 Self::SeamUnavailable => "gh_shim_seam_unavailable",
109 Self::SeamRefusal => "gh_shim_seam_refusal",
110 }
111 }
112}
113
114#[derive(Clone, Copy, Debug, Eq, PartialEq)]
118pub enum SelfReportDiagnostic {
119 ManifestUnavailable,
120 ManifestInvalid,
121 ManifestBelowFloor,
122 ManifestRegressed,
123 ManifestRollback,
124 RungUnavailable,
125}
126
127impl SelfReportDiagnostic {
128 pub const ALL: [Self; 6] = [
129 Self::ManifestUnavailable,
130 Self::ManifestInvalid,
131 Self::ManifestBelowFloor,
132 Self::ManifestRegressed,
133 Self::ManifestRollback,
134 Self::RungUnavailable,
135 ];
136
137 pub const fn as_str(self) -> &'static str {
138 match self {
139 Self::ManifestUnavailable => "gh_shim_status_manifest_unavailable",
140 Self::ManifestInvalid => "gh_shim_status_manifest_invalid",
141 Self::ManifestBelowFloor => "gh_shim_status_manifest_below_floor",
142 Self::ManifestRegressed => "gh_shim_status_manifest_regressed",
143 Self::ManifestRollback => "gh_shim_status_manifest_rollback",
144 Self::RungUnavailable => "gh_shim_status_rung_unavailable",
145 }
146 }
147}
148
149#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
150#[serde(rename_all = "lowercase")]
151pub enum Tier {
152 Mechanical,
153 Governed,
154 Admin,
155}
156
157impl Tier {
158 fn rank(self) -> u8 {
159 match self {
160 Self::Mechanical => 0,
161 Self::Governed => 1,
162 Self::Admin => 2,
163 }
164 }
165}
166
167#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
168#[serde(rename_all = "UPPERCASE")]
169pub enum Rung {
170 R1,
171 R2,
172 R3,
173}
174
175impl Rung {
176 const fn label(self) -> &'static str {
177 match self {
178 Self::R1 => "R1",
179 Self::R2 => "R2",
180 Self::R3 => "R3",
181 }
182 }
183}
184
185pub fn is_shim_invocation(program: &OsStr, args: &[OsString]) -> bool {
189 Path::new(program)
190 .file_name()
191 .is_some_and(|name| name == OsStr::new("gh"))
192 || args.first().is_some_and(|arg| arg == OsStr::new("gh-shim"))
193}
194
195pub fn is_shim_invocation_from_env() -> bool {
196 let mut argv = std::env::args_os();
197 let Some(program) = argv.next() else {
198 return false;
199 };
200 is_shim_invocation(&program, &argv.collect::<Vec<_>>())
201}
202
203pub fn run_from_env() -> i32 {
207 let mut argv = std::env::args_os();
208 let Some(program) = argv.next() else {
209 return refuse(RefusalCode::NoRealGh, "the executing image was unavailable");
210 };
211 let raw_args = argv.collect::<Vec<_>>();
212 let shim_args = if Path::new(&program)
213 .file_name()
214 .is_some_and(|name| name == OsStr::new("gh"))
215 {
216 raw_args
217 } else {
218 raw_args.into_iter().skip(1).collect()
219 };
220 run(&shim_args)
221}
222
223fn run(args: &[OsString]) -> i32 {
224 let paths = StatePaths::from_process();
225 if args.first().and_then(|arg| arg.to_str()) == Some(CO_AUTHOR_LINE_REPORT) {
226 if let Some(line) = co_author_line(&paths) {
227 println!("{line}");
228 }
229 return 0;
230 }
231 if is_reserved_self_report(args) {
232 print_self_report(&paths);
233 return 0;
234 }
235
236 let now = unix_seconds();
237 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
238
239 let initial_manifest = resolve_manifest(&paths, now);
244 let invalid_manifest_problem = initial_manifest.invalid_problem().cloned();
245 if let ManifestResolution::Regressed { manifest, problem } = &initial_manifest {
246 return match regressed_disposition(args, manifest, current_platform(), problem) {
247 RegressedDisposition::Passthrough => {
248 delegate_after_invalid_manifest_notice(args, problem)
249 }
250 RegressedDisposition::Refuse { code, text } => refuse(code, &text),
251 };
252 }
253
254 let determination = determine_rung(&paths, &cwd, now);
255 if determination.record.rung != Rung::R3 {
256 let disposition = match resolve_manifest(&paths, now) {
257 ManifestResolution::Active(manifest) => non_r3_governance_disposition(
258 &cwd,
259 &determination,
260 args,
261 &manifest,
262 current_platform(),
263 ),
264 ManifestResolution::Regressed { .. }
265 | ManifestResolution::Invalid(_)
266 | ManifestResolution::Dormant => GovernanceDisposition::Delegate,
267 };
268 return match disposition {
269 GovernanceDisposition::Unavailable(agent_binding) => {
270 refuse_governance_unavailable(&paths, &agent_binding, now)
271 }
272 GovernanceDisposition::Unclassified { manifest_version } => refuse(
273 RefusalCode::Unclassified,
274 &format!(
275 "no manifest declaration for this invocation (manifest {manifest_version})"
276 ),
277 ),
278 GovernanceDisposition::Delegate | GovernanceDisposition::Ready => {
279 match invalid_manifest_problem.as_ref() {
280 Some(problem) => delegate_after_invalid_manifest_notice(args, problem),
281 None => delegate(args),
282 }
283 }
284 };
285 }
286
287 let manifest = match resolve_manifest(&paths, now) {
292 ManifestResolution::Active(manifest) => manifest,
293 ManifestResolution::Regressed { manifest, problem } => {
294 return match regressed_disposition(args, &manifest, current_platform(), &problem) {
295 RegressedDisposition::Passthrough => {
296 delegate_after_invalid_manifest_notice(args, &problem)
297 }
298 RegressedDisposition::Refuse { code, text } => refuse(code, &text),
299 }
300 }
301 ManifestResolution::Invalid(problem) => {
302 return delegate_after_invalid_manifest_notice(args, &problem)
303 }
304 ManifestResolution::Dormant => return delegate(args),
305 };
306 let Some(agent_binding) = resolved_agent_binding(&manifest, &cwd) else {
307 return delegate(args);
308 };
309
310 match classify(args, &manifest, current_platform()) {
311 Classification::Mechanical => delegate(args),
312 Classification::Admin { tuple } => {
313 if std::env::var_os("GH_SHIM_BYPASS").as_deref() == Some(OsStr::new("operator")) {
314 let repository = explicit_repo(args).or_else(infer_repository_from_git);
315 if let Err(error) = append_bypass_audit(&paths, &tuple, repository.as_deref(), now)
316 {
317 return refuse(
318 RefusalCode::BypassAuditUnavailable,
319 &format!("operator bypass audit could not be appended: {error}"),
320 );
321 }
322 delegate(args)
323 } else {
324 refuse(
325 RefusalCode::AdminTier,
326 "this action requires GH_SHIM_BYPASS=operator",
327 )
328 }
329 }
330 Classification::Governed { tuple, canonical } => {
331 let request =
332 match canonicalize_governed(args, &tuple, &canonical, manifest.manifest_version) {
333 Ok(request) => request,
334 Err(error) => return refuse_governed_canonicalization(&error),
335 };
336 let outcome =
337 route_governed(&paths, &determination.record, &agent_binding, request, now);
338 governed_outcome_status(&paths, &agent_binding, now, outcome)
339 }
340 Classification::Unclassified => refuse(
341 RefusalCode::Unclassified,
342 &format!(
343 "no manifest declaration for this invocation (manifest {})",
344 manifest.manifest_version
345 ),
346 ),
347 }
348}
349
350fn refuse_governed_canonicalization(error: &str) -> i32 {
351 refuse(RefusalCode::Unclassified, error)
352}
353
354fn governed_outcome_status(
355 paths: &StatePaths,
356 agent_binding: &AgentBinding,
357 now: u64,
358 outcome: RouteOutcome,
359) -> i32 {
360 match outcome {
361 RouteOutcome::Result(output) => {
362 print!("{output}");
363 0
364 }
365 RouteOutcome::UpstreamError(body) => {
366 eprintln!("{body}");
367 UPSTREAM_FAILURE_EXIT_STATUS
368 }
369 RouteOutcome::Refusal(code) => refuse(RefusalCode::SeamRefusal, &seam_refusal_text(&code)),
370 RouteOutcome::UnboundIdentity => refuse(
371 RefusalCode::UnboundIdentity,
372 "the project binding was unavailable at route time",
373 ),
374 RouteOutcome::SchemaMismatch(message) => refuse(RefusalCode::SeamSchemaMismatch, &message),
375 RouteOutcome::GovernanceUnavailable => {
376 refuse_governance_unavailable(paths, agent_binding, now)
377 }
378 RouteOutcome::Unavailable(message) => refuse(RefusalCode::SeamUnavailable, &message),
379 }
380}
381
382fn seam_refusal_text(code: &str) -> String {
383 format!("governance seam refused the action: {code}")
384}
385
386fn is_reserved_self_report(args: &[OsString]) -> bool {
387 args.first()
388 .and_then(|arg| arg.to_str())
389 .is_some_and(|arg| RESERVED_SELF_REPORT.contains(&arg))
390}
391
392#[derive(Clone, Debug)]
393struct StatePaths {
394 root: PathBuf,
395 manifest: PathBuf,
396 rung: PathBuf,
397 bypass_audit: PathBuf,
398 unexpected_gh_route_advertisers: PathBuf,
399 seam_state: PathBuf,
400 last_valid_manifest: PathBuf,
401 version_high_water: PathBuf,
402 numeric_ids: PathBuf,
403}
404
405impl StatePaths {
406 fn from_process() -> Self {
407 let root = std::env::var_os("XDG_STATE_HOME")
408 .map(PathBuf::from)
409 .filter(|path| path.is_absolute())
410 .or_else(|| {
411 std::env::var_os("HOME")
412 .or_else(|| std::env::var_os("USERPROFILE"))
413 .map(|home| PathBuf::from(home).join(".local/state"))
414 })
415 .unwrap_or_else(|| std::env::temp_dir())
416 .join("cortexkit")
417 .join("aft")
418 .join("gh-shim");
419 Self::from_root(root)
420 }
421
422 fn from_root(root: PathBuf) -> Self {
423 Self {
424 manifest: root.join("gh-routing-manifest.json"),
425 rung: root.join("rung-cache.json"),
426 bypass_audit: root.join("operator-bypass.jsonl"),
427 unexpected_gh_route_advertisers: root.join("unexpected-gh-route-advertisers.json"),
428 seam_state: root.join("seam-state.json"),
429 last_valid_manifest: root.join("last-valid-manifest.json"),
430 version_high_water: root.join("manifest-version-high-water.json"),
431 numeric_ids: root.join("numeric-ids.json"),
432 root,
433 }
434 }
435}
436
437#[derive(Clone, Debug, Deserialize, Serialize)]
438struct RungRecord {
439 rung: Rung,
440 as_of_unix_secs: u64,
441 #[serde(default)]
442 inputs: BTreeMap<String, String>,
443 #[serde(default)]
444 manifest_version: Option<u64>,
445 #[serde(default)]
446 recorded_by_image_path: Option<String>,
447 #[serde(default)]
448 recorded_by_version: Option<String>,
449 #[serde(default)]
450 recorded_by_repo_key: Option<String>,
451}
452
453#[derive(Clone, Debug)]
454struct RungRecordProvenance {
455 image_path: String,
456 version: String,
457 repo_key: String,
458}
459
460impl RungRecordProvenance {
461 fn for_cwd(cwd: &Path) -> Self {
462 let project_root = project_root_for(cwd);
463 Self {
464 image_path: executing_image().to_string_lossy().into_owned(),
465 version: env!("CARGO_PKG_VERSION").to_string(),
466 repo_key: repository_key_from_origin(&project_root)
467 .unwrap_or_else(|| "unresolved (no GitHub origin)".to_string()),
468 }
469 }
470}
471
472impl RungRecord {
473 fn fresh_at(&self, now: u64) -> bool {
474 now.saturating_sub(self.as_of_unix_secs) < DISCOVERY_CACHE_TTL.as_secs()
475 }
476}
477
478#[derive(Clone, Copy, Debug, Eq, PartialEq)]
479#[repr(usize)]
480enum R1Reason {
481 DisabledByConfig,
482 AbsentOrUnparseable,
483 Unreachable,
484 DiscoveryBudgetExhausted,
485 #[cfg(test)]
486 Count,
487}
488
489impl R1Reason {
490 #[cfg(test)]
491 const ALL: [Self; Self::Count as usize] = [
492 Self::DisabledByConfig,
493 Self::AbsentOrUnparseable,
494 Self::Unreachable,
495 Self::DiscoveryBudgetExhausted,
496 ];
497
498 const fn diagnostic(self) -> &'static str {
499 match self {
500 Self::DisabledByConfig => "disabled_by_config",
501 Self::AbsentOrUnparseable => "absent_or_unparseable",
502 Self::Unreachable => "unreachable",
503 Self::DiscoveryBudgetExhausted => "discovery_budget_exhausted",
504 #[cfg(test)]
505 Self::Count => unreachable!(),
506 }
507 }
508}
509
510#[derive(Clone, Copy, Debug, Eq, PartialEq)]
511#[repr(usize)]
512enum R2Reason {
513 ManifestUnavailable,
514 AgentBindingUnavailable,
515 AgentCredentialsPresent,
516 DaemonUnreachable,
517 CatalogGhRouteAbsent,
518 GhRouteHolderUnbound,
519 #[cfg(test)]
520 Count,
521}
522
523impl R2Reason {
524 #[cfg(test)]
525 const ALL: [Self; Self::Count as usize] = [
526 Self::ManifestUnavailable,
527 Self::AgentBindingUnavailable,
528 Self::AgentCredentialsPresent,
529 Self::DaemonUnreachable,
530 Self::CatalogGhRouteAbsent,
531 Self::GhRouteHolderUnbound,
532 ];
533
534 const fn diagnostic(self) -> &'static str {
535 match self {
536 Self::ManifestUnavailable => "manifest_unavailable",
537 Self::AgentBindingUnavailable => "agent_binding_unavailable",
538 Self::AgentCredentialsPresent => "agent_credentials_present",
539 Self::DaemonUnreachable => "daemon_unreachable",
540 Self::CatalogGhRouteAbsent => "catalog_gh_route_absent",
541 Self::GhRouteHolderUnbound => "gh_route_holder_unbound",
542 #[cfg(test)]
543 Self::Count => unreachable!(),
544 }
545 }
546}
547
548#[derive(Clone, Debug)]
549struct RungDetermination {
550 record: RungRecord,
551 operator_disabled: bool,
552}
553
554impl RungDetermination {
555 fn r1(now: u64, reason: R1Reason) -> Self {
556 Self {
557 record: RungRecord {
558 rung: Rung::R1,
559 as_of_unix_secs: now,
560 inputs: BTreeMap::from([(
561 "connection_file".to_string(),
562 reason.diagnostic().to_string(),
563 )]),
564 manifest_version: None,
565 recorded_by_image_path: None,
566 recorded_by_version: None,
567 recorded_by_repo_key: None,
568 },
569 operator_disabled: reason == R1Reason::DisabledByConfig,
570 }
571 }
572
573 fn r2(
574 now: u64,
575 reason: R2Reason,
576 manifest_version: Option<u64>,
577 provenance: &RungRecordProvenance,
578 ) -> Self {
579 Self {
580 record: RungRecord {
581 rung: Rung::R2,
582 as_of_unix_secs: now,
583 inputs: BTreeMap::from([
584 ("connection_file".to_string(), "ready".to_string()),
585 (reason.diagnostic().to_string(), "failed".to_string()),
586 ]),
587 manifest_version,
588 recorded_by_image_path: Some(provenance.image_path.clone()),
589 recorded_by_version: Some(provenance.version.clone()),
590 recorded_by_repo_key: Some(provenance.repo_key.clone()),
591 },
592 operator_disabled: false,
593 }
594 }
595
596 fn r3(now: u64, manifest_version: u64, provenance: &RungRecordProvenance) -> Self {
597 Self {
598 record: RungRecord {
599 rung: Rung::R3,
600 as_of_unix_secs: now,
601 inputs: BTreeMap::from([
602 ("connection_file".to_string(), "ready".to_string()),
603 ("catalog_gh_route".to_string(), "ready".to_string()),
604 ("agent_binding".to_string(), "ready".to_string()),
605 ("manifest".to_string(), "ready".to_string()),
606 (
607 "agent_credentials_present".to_string(),
608 "absent".to_string(),
609 ),
610 ]),
611 manifest_version: Some(manifest_version),
612 recorded_by_image_path: Some(provenance.image_path.clone()),
613 recorded_by_version: Some(provenance.version.clone()),
614 recorded_by_repo_key: Some(provenance.repo_key.clone()),
615 },
616 operator_disabled: false,
617 }
618 }
619
620 fn cached(record: RungRecord) -> Self {
621 Self {
622 record,
623 operator_disabled: false,
624 }
625 }
626}
627
628#[derive(Debug)]
629enum GovernanceDisposition {
630 Delegate,
631 Ready,
632 Unavailable(AgentBinding),
633 Unclassified { manifest_version: u64 },
634}
635
636fn structural_governance_disposition(
637 determination: &RungDetermination,
638 classification: &Classification,
639 agent_binding: Option<AgentBinding>,
640 manifest_version: u64,
641) -> GovernanceDisposition {
642 if determination.operator_disabled || matches!(classification, Classification::Mechanical) {
643 return GovernanceDisposition::Delegate;
644 }
645 let Some(agent_binding) = agent_binding else {
646 return GovernanceDisposition::Delegate;
647 };
648 if determination.record.rung == Rung::R3 {
649 return GovernanceDisposition::Ready;
650 }
651
652 match classification {
653 Classification::Governed { .. } | Classification::Admin { .. } => {
654 GovernanceDisposition::Unavailable(agent_binding)
655 }
656 Classification::Unclassified => GovernanceDisposition::Unclassified { manifest_version },
657 Classification::Mechanical => GovernanceDisposition::Delegate,
658 }
659}
660
661fn non_r3_governance_disposition(
662 cwd: &Path,
663 determination: &RungDetermination,
664 args: &[OsString],
665 manifest: &Manifest,
666 platform: &str,
667) -> GovernanceDisposition {
668 if determination.operator_disabled {
669 return GovernanceDisposition::Delegate;
670 }
671
672 let classification = classify(args, manifest, platform);
673 if matches!(classification, Classification::Mechanical) {
674 return GovernanceDisposition::Delegate;
675 }
676
677 let agent_binding = resolved_agent_binding(manifest, cwd);
681 structural_governance_disposition(
682 determination,
683 &classification,
684 agent_binding,
685 manifest.manifest_version,
686 )
687}
688
689fn determine_rung(paths: &StatePaths, cwd: &Path, now: u64) -> RungDetermination {
690 let deadline = std::time::Instant::now() + DISCOVERY_BUDGET;
693 let config_doc = read_user_config_doc();
694 determine_rung_from_doc(paths, cwd, now, deadline, config_doc.as_deref())
695}
696
697fn determine_rung_from_doc(
703 paths: &StatePaths,
704 cwd: &Path,
705 now: u64,
706 deadline: std::time::Instant,
707 config_doc: Option<&str>,
708) -> RungDetermination {
709 if gh_shim_enabled_from_config_doc(config_doc.unwrap_or("")) == Some(false) {
715 return RungDetermination::r1(now, R1Reason::DisabledByConfig);
716 }
717
718 let Some(connection_file) = connection_file_from_config_doc(config_doc.unwrap_or("")) else {
719 return RungDetermination::r1(now, R1Reason::AbsentOrUnparseable);
721 };
722 if !connection_file.is_file() {
723 return RungDetermination::r1(now, R1Reason::Unreachable);
724 }
725
726 let cached = load_rung_record(paths);
727 if std::time::Instant::now() >= deadline {
728 return cached
729 .filter(|record| record.fresh_at(now))
730 .map(RungDetermination::cached)
731 .unwrap_or_else(|| RungDetermination::r1(now, R1Reason::DiscoveryBudgetExhausted));
732 }
733 if let Some(record) = cached.as_ref().filter(|record| record.fresh_at(now)) {
734 if record.rung != Rung::R3
735 || resolve_manifest(paths, now)
736 .manifest()
737 .and_then(|manifest| resolved_agent_binding(manifest, cwd))
738 .is_some()
739 {
740 return RungDetermination::cached(record.clone());
741 }
742 }
743
744 let provenance = RungRecordProvenance::for_cwd(cwd);
745 let Some(manifest) = resolve_manifest(paths, now).into_manifest() else {
750 let determination =
751 RungDetermination::r2(now, R2Reason::ManifestUnavailable, None, &provenance);
752 write_rung_record_silently(paths, &determination.record);
753 return determination;
754 };
755 let Some(agent_binding) = resolved_agent_binding(&manifest, cwd) else {
756 let determination = RungDetermination::r2(
757 now,
758 R2Reason::AgentBindingUnavailable,
759 Some(manifest.manifest_version),
760 &provenance,
761 );
762 write_rung_record_silently(paths, &determination.record);
763 return determination;
764 };
765
766 let discovery = probe_governance(
767 paths,
768 &connection_file,
769 cwd,
770 deadline,
771 &agent_binding.agent_id,
772 );
773 let determination = match discovery {
774 ProbeResult::Ready { module_id } => {
775 match find_ambient_agent_credential(&manifest.detectors) {
776 Some(source) => {
777 let mut determination = RungDetermination::r2(
778 now,
779 R2Reason::AgentCredentialsPresent,
780 Some(manifest.manifest_version),
781 &provenance,
782 );
783 determination
784 .record
785 .inputs
786 .insert("agent_credentials_present".to_string(), source);
787 determination
788 .record
789 .inputs
790 .insert("catalog_holder".to_string(), module_id);
791 determination
792 }
793 None => RungDetermination::r3(now, manifest.manifest_version, &provenance),
794 }
795 }
796 ProbeResult::Unreachable => {
797 RungDetermination::r2(now, R2Reason::DaemonUnreachable, None, &provenance)
798 }
799 ProbeResult::NoRoute => {
800 RungDetermination::r2(now, R2Reason::CatalogGhRouteAbsent, None, &provenance)
801 }
802 ProbeResult::Unbound => {
805 RungDetermination::r2(now, R2Reason::GhRouteHolderUnbound, None, &provenance)
806 }
807 ProbeResult::TimedOut => cached
808 .filter(|record| record.fresh_at(now))
809 .map(RungDetermination::cached)
810 .unwrap_or_else(|| RungDetermination::r1(now, R1Reason::DiscoveryBudgetExhausted)),
811 };
812
813 if determination.record.rung != Rung::R1 {
814 write_rung_record_silently(paths, &determination.record);
815 }
816 determination
817}
818
819fn configured_connection_file() -> Option<PathBuf> {
820 let xdg_config_home = std::env::var_os("XDG_CONFIG_HOME");
821 let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"));
822 configured_connection_file_from(xdg_config_home.as_deref(), home.as_deref())
823}
824
825fn configured_connection_file_from(
826 xdg_config_home: Option<&OsStr>,
827 home: Option<&OsStr>,
828) -> Option<PathBuf> {
829 let config_path = crate::subc_config::user_config_path_from(xdg_config_home, home)?;
835 let doc = fs::read_to_string(config_path).ok()?;
836 connection_file_from_config_doc(&doc).filter(|path| path.is_file())
837}
838
839fn read_user_config_doc() -> Option<String> {
843 let xdg_config_home = std::env::var_os("XDG_CONFIG_HOME");
844 let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"));
845 let config_path =
846 crate::subc_config::user_config_path_from(xdg_config_home.as_deref(), home.as_deref())?;
847 fs::read_to_string(config_path).ok()
848}
849
850fn gh_shim_enabled_from_config_doc(doc: &str) -> Option<bool> {
854 let value: Value = serde_json::from_str(&crate::jsonc::strip_jsonc(doc)).ok()?;
855 value.get("gh_shim")?.get("enabled")?.as_bool()
856}
857
858fn connection_file_from_config_doc(doc: &str) -> Option<PathBuf> {
859 let value: Value = serde_json::from_str(&crate::jsonc::strip_jsonc(doc)).ok()?;
860 let raw = value.get("subc")?.get("connection_file")?.as_str()?.trim();
861 let path = PathBuf::from(raw);
862 (!raw.is_empty() && path.is_absolute()).then_some(path)
863}
864
865fn load_rung_record(paths: &StatePaths) -> Option<RungRecord> {
866 serde_json::from_slice(&fs::read(&paths.rung).ok()?).ok()
867}
868
869fn write_rung_record_silently(paths: &StatePaths, record: &RungRecord) {
870 let Ok(bytes) = serde_json::to_vec(record) else {
871 return;
872 };
873 let _ = fs::create_dir_all(&paths.root);
874 let temporary = paths.root.join("rung-cache.json.tmp");
875 if fs::write(&temporary, bytes).is_ok() {
876 let _ = fs::rename(temporary, &paths.rung);
877 }
878}
879
880#[derive(Debug)]
881enum ProbeResult {
882 Ready { module_id: String },
883 Unreachable,
884 NoRoute,
885 Unbound,
886 TimedOut,
887}
888
889fn probe_governance(
890 paths: &StatePaths,
891 connection_file: &Path,
892 cwd: &Path,
893 deadline: std::time::Instant,
894 agent_id: &str,
895) -> ProbeResult {
896 let remaining = deadline.saturating_duration_since(std::time::Instant::now());
897 if remaining.is_zero() {
898 return ProbeResult::TimedOut;
899 }
900 let connection_file = connection_file.to_path_buf();
901 let project_root = project_root_for(cwd);
902 let record_paths = paths.clone();
903 let agent_id = agent_id.to_string();
904 let Ok(runtime) = tokio::runtime::Builder::new_current_thread()
905 .enable_io()
906 .enable_time()
907 .build()
908 else {
909 return ProbeResult::Unreachable;
910 };
911
912 match runtime.block_on(async move {
917 tokio::time::timeout(remaining, async move {
918 let options = ConsumerOptions {
919 call_timeout: remaining,
920 ..ConsumerOptions::default()
921 };
922 let consumer = SubcConsumer::connect(&connection_file, options)
923 .await
924 .map_err(|_| ProbeResult::Unreachable)?;
925 let catalog = consumer
926 .catalog_list()
927 .await
928 .map_err(|_| ProbeResult::Unreachable)?;
929 let holder = route_holder(&catalog.modules);
930 record_unexpected_gh_route_advertisers(&record_paths, &holder.unexpected_advertisers);
931 let Some(module_id) = holder.module_id else {
932 return Err(ProbeResult::NoRoute);
933 };
934 let identity = BindIdentity {
935 project_root: project_root.to_string_lossy().into_owned().into(),
936 harness: "aft-gh-shim".to_string(),
937 session: gh_session_id(&agent_id),
938 };
939 let route = consumer
940 .open_route(
941 RouteTarget::ManagementSurface {
942 module_id: module_id.clone(),
943 },
944 identity,
945 CallOptions::default(),
946 )
947 .await
948 .map_err(|_| ProbeResult::Unbound)?;
949 let _ = consumer
950 .close_handle(&route, CloseRouteOptions::default())
951 .await;
952 Ok(module_id)
953 })
954 .await
955 }) {
956 Ok(Ok(module_id)) => ProbeResult::Ready { module_id },
957 Ok(Err(result)) => result,
958 Err(_) => ProbeResult::TimedOut,
959 }
960}
961
962#[derive(Debug, Default, Eq, PartialEq)]
963struct RouteHolder {
964 module_id: Option<String>,
965 unexpected_advertisers: Vec<String>,
966}
967
968fn route_holder(entries: &[subc_client_rs::CatalogEntry]) -> RouteHolder {
969 select_route_holder(entries.iter().filter_map(|entry| {
970 entry
971 .roles
972 .iter()
973 .any(|role| {
974 matches!(
975 role,
976 ProviderRole::ManagementSurface { operations, .. }
977 if operations.iter().any(|operation| operation.name == ROUTING_OPERATION)
978 )
979 })
980 .then(|| entry.module_id.clone())
981 }))
982}
983
984fn select_route_holder(advertisers: impl IntoIterator<Item = String>) -> RouteHolder {
985 let mut holder = None;
986 let mut unexpected_advertisers = BTreeSet::new();
987 for advertiser in advertisers {
988 if advertiser == ROUTING_HOLDER_MODULE_ID {
993 holder.get_or_insert(advertiser);
994 } else {
995 unexpected_advertisers.insert(advertiser);
996 }
997 }
998 RouteHolder {
999 module_id: holder,
1000 unexpected_advertisers: unexpected_advertisers.into_iter().collect(),
1001 }
1002}
1003
1004fn project_root_for(cwd: &Path) -> PathBuf {
1005 let canonical = cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf());
1006 canonical
1007 .ancestors()
1008 .find(|path| path.join(".git").exists())
1009 .map(Path::to_path_buf)
1010 .unwrap_or(canonical)
1011}
1012
1013#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1014struct AgentBinding {
1015 repo: String,
1016 agent_id: String,
1017}
1018
1019fn resolved_agent_binding(manifest: &Manifest, cwd: &Path) -> Option<AgentBinding> {
1020 let project_root = project_root_for(cwd);
1021 let repo = repository_key_from_origin(&project_root)?;
1022 manifest
1023 .bindings
1024 .get(&repo)
1025 .cloned()
1026 .map(|agent_id| AgentBinding { repo, agent_id })
1027}
1028
1029fn co_author_line(paths: &StatePaths) -> Option<String> {
1030 let cwd = std::env::current_dir().ok()?;
1031 let manifest = load_manifest(paths, unix_seconds()).ok()?;
1032 let binding = resolved_agent_binding(&manifest, &cwd)?;
1033 let login = binding.agent_id;
1034 if !valid_github_login(&login) {
1035 return None;
1036 }
1037 let numeric_id =
1038 cached_numeric_id(paths, &login).or_else(|| resolve_and_cache_numeric_id(paths, &login))?;
1039 Some(format!(
1040 "Co-authored-by: {login} <{numeric_id}+{login}@users.noreply.github.com>"
1041 ))
1042}
1043
1044fn valid_github_login(login: &str) -> bool {
1045 let core = login.strip_suffix("[bot]").unwrap_or(login);
1046 !core.is_empty()
1047 && core.len() <= 100
1048 && !core.starts_with('-')
1049 && !core.ends_with('-')
1050 && core
1051 .bytes()
1052 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
1053}
1054
1055fn cached_numeric_ids(paths: &StatePaths) -> BTreeMap<String, u64> {
1056 fs::read(&paths.numeric_ids)
1057 .ok()
1058 .and_then(|bytes| serde_json::from_slice(&bytes).ok())
1059 .unwrap_or_default()
1060}
1061
1062fn cached_numeric_id(paths: &StatePaths, login: &str) -> Option<u64> {
1063 cached_numeric_ids(paths)
1064 .get(login)
1065 .copied()
1066 .filter(|id| *id > 0)
1067}
1068
1069fn resolve_and_cache_numeric_id(paths: &StatePaths, login: &str) -> Option<u64> {
1070 let image = executing_image();
1071 let real_gh = resolve_real_gh(&image)?;
1072 let encoded_login = url::form_urlencoded::byte_serialize(login.as_bytes()).collect::<String>();
1073 let output = Command::new(real_gh)
1074 .args(["api", &format!("users/{encoded_login}"), "--jq", ".id"])
1075 .output()
1076 .ok()?;
1077 if !output.status.success() {
1078 return None;
1079 }
1080 let numeric_id = String::from_utf8(output.stdout)
1081 .ok()?
1082 .trim()
1083 .parse::<u64>()
1084 .ok()
1085 .filter(|id| *id > 0)?;
1086 let mut ids = cached_numeric_ids(paths);
1087 ids.insert(login.to_string(), numeric_id);
1088 write_numeric_ids_silently(paths, &ids);
1089 Some(numeric_id)
1090}
1091
1092fn write_numeric_ids_silently(paths: &StatePaths, ids: &BTreeMap<String, u64>) {
1093 let Ok(bytes) = serde_json::to_vec(ids) else {
1094 return;
1095 };
1096 if fs::create_dir_all(&paths.root).is_err() {
1097 return;
1098 }
1099 let temporary = paths.root.join("numeric-ids.json.tmp");
1100 if fs::write(&temporary, bytes).is_ok() {
1101 #[cfg(windows)]
1102 let _ = fs::remove_file(&paths.numeric_ids);
1103 let _ = fs::rename(temporary, &paths.numeric_ids);
1104 }
1105}
1106
1107fn repository_key_from_origin(project_root: &Path) -> Option<String> {
1108 let remote = origin_remote(project_root)?;
1111 canonical_repository_key(&remote)
1112}
1113
1114fn origin_remote(cwd: &Path) -> Option<String> {
1115 let output = Command::new("git")
1116 .current_dir(cwd)
1117 .args(["remote", "get-url", "origin"])
1118 .output()
1119 .ok()?;
1120 output
1121 .status
1122 .success()
1123 .then(|| String::from_utf8(output.stdout).ok())
1124 .flatten()
1125 .map(|remote| remote.trim().to_string())
1126 .filter(|remote| !remote.is_empty())
1127}
1128
1129fn canonical_repository_key(value: &str) -> Option<String> {
1130 let remote = value.trim().trim_end_matches('/');
1131 let path = if let Some(path) = [
1132 "https://github.com/",
1133 "http://github.com/",
1134 "ssh://git@github.com/",
1135 "git://github.com/",
1136 "git@github.com:",
1137 "github.com/",
1138 ]
1139 .iter()
1140 .find_map(|prefix| remote.strip_prefix(prefix))
1141 {
1142 path
1143 } else if remote.contains("://") || remote.contains('@') || remote.contains(':') {
1144 return None;
1147 } else {
1148 remote
1149 }
1150 .trim_end_matches(".git")
1151 .trim_matches('/');
1152 let mut parts = path.split('/');
1153 let owner = parts.next()?.trim();
1154 let repository = parts.next()?.trim();
1155 (!owner.is_empty() && !repository.is_empty() && parts.next().is_none()).then(|| {
1156 format!(
1157 "{}/{}",
1158 owner.to_ascii_lowercase(),
1159 repository.to_ascii_lowercase()
1160 )
1161 })
1162}
1163
1164fn gh_session_id(agent_id: &str) -> String {
1165 format!("gh-shim:{agent_id}")
1166}
1167
1168#[derive(Clone, Debug, Default, Deserialize, Serialize)]
1169struct Detectors {
1170 #[serde(default)]
1171 wrapper_config_dirs: Vec<String>,
1172 #[serde(default)]
1173 credential_env_names: Vec<String>,
1174}
1175
1176fn find_ambient_agent_credential(detectors: &Detectors) -> Option<String> {
1177 for name in &detectors.credential_env_names {
1178 if std::env::var_os(name).is_some() {
1179 return Some(format!("env:{name}"));
1180 }
1181 }
1182
1183 let home = std::env::var_os("HOME")
1184 .or_else(|| std::env::var_os("USERPROFILE"))
1185 .map(PathBuf::from);
1186 for raw_pattern in &detectors.wrapper_config_dirs {
1187 let pattern = expand_home_pattern(raw_pattern, home.as_deref());
1188 let paths = if pattern.contains(['*', '?', '[', '{']) {
1189 crate::walk_boundary::expand_glob_same_file_system(&pattern).unwrap_or_default()
1192 } else {
1193 vec![PathBuf::from(pattern)]
1194 };
1195 for path in paths {
1196 if path.is_dir() {
1197 return Some(format!("path:{}", path.display()));
1198 }
1199 }
1200 }
1201
1202 let configured = std::env::var_os("GH_CONFIG_DIR").map(PathBuf::from)?;
1206 if !configured.is_dir() {
1207 return None;
1208 }
1209 let name = configured.file_name()?.to_string_lossy();
1210 detectors
1211 .wrapper_config_dirs
1212 .iter()
1213 .any(|pattern| {
1214 Path::new(pattern).file_name().is_some_and(|glob_name| {
1215 glob::Pattern::new(&glob_name.to_string_lossy()).is_ok_and(|p| p.matches(&name))
1216 })
1217 })
1218 .then(|| format!("path:{}", configured.display()))
1219}
1220
1221fn expand_home_pattern(pattern: &str, home: Option<&Path>) -> String {
1222 pattern
1223 .strip_prefix("~/")
1224 .and_then(|suffix| home.map(|home| home.join(suffix).to_string_lossy().into_owned()))
1225 .unwrap_or_else(|| pattern.to_string())
1226}
1227
1228#[derive(Clone, Debug, Deserialize, Serialize)]
1229#[serde(untagged)]
1230enum TupleDecl {
1231 Name(String),
1232 Details {
1233 tuple: String,
1234 #[serde(default)]
1235 platform: Vec<String>,
1236 #[serde(default)]
1237 api_match: Option<String>,
1238 #[serde(default)]
1239 rationale: Option<String>,
1240 },
1241}
1242
1243impl TupleDecl {
1244 fn tuple(&self) -> &str {
1245 match self {
1246 Self::Name(name) => name,
1247 Self::Details { tuple, .. } => tuple,
1248 }
1249 }
1250
1251 fn platform(&self) -> &[String] {
1252 match self {
1253 Self::Name(_) => &[],
1254 Self::Details { platform, .. } => platform,
1255 }
1256 }
1257
1258 fn empty_api_match_has_rationale(&self) -> bool {
1259 match self {
1260 Self::Details {
1261 api_match: Some(api_match),
1262 rationale,
1263 ..
1264 } if api_match.is_empty() => rationale
1265 .as_deref()
1266 .is_some_and(|text| !text.trim().is_empty()),
1267 _ => true,
1268 }
1269 }
1270}
1271
1272#[derive(Clone, Debug, Deserialize, Serialize)]
1273struct ApiRule {
1274 method: String,
1275 path_glob: String,
1276 tier: Tier,
1277 #[serde(default)]
1278 platform: Vec<String>,
1279 #[serde(default)]
1280 rationale: Option<String>,
1281}
1282
1283#[derive(Clone, Debug, Default, Deserialize, Serialize)]
1284struct Canonicalization {
1285 #[serde(default)]
1286 argv_forms: Vec<String>,
1287 #[serde(default)]
1288 target_fields: Vec<String>,
1289 #[serde(default)]
1290 body_fields: Vec<String>,
1291}
1292
1293#[derive(Clone, Debug, Default, Deserialize, Serialize)]
1294struct RepositorySection {
1295 #[serde(default)]
1296 tiers: BTreeMap<Tier, Vec<TupleDecl>>,
1297 #[serde(default, alias = "remove")]
1298 removed_tuples: Vec<String>,
1299}
1300
1301#[derive(Clone, Debug, Deserialize, Serialize)]
1302struct Manifest {
1303 artifact_id: String,
1304 manifest_version: u64,
1305 schema_floor: u64,
1306 issued_at_unix_secs: u64,
1310 #[serde(default)]
1311 detectors: Detectors,
1312 #[serde(default)]
1313 tiers: BTreeMap<Tier, Vec<TupleDecl>>,
1314 #[serde(default)]
1315 api_rules: Vec<ApiRule>,
1316 #[serde(default)]
1317 canonicalization: BTreeMap<String, Canonicalization>,
1318 #[serde(default)]
1319 repository_sections: BTreeMap<String, RepositorySection>,
1320 #[serde(default)]
1321 bindings: BTreeMap<String, String>,
1322}
1323
1324impl Manifest {
1325 fn validate(&self) -> Result<(), String> {
1326 if self.artifact_id != MANIFEST_ARTIFACT_ID {
1327 return Err(format!("unexpected artifact id {}", self.artifact_id));
1328 }
1329 if self.manifest_version == 0 {
1330 return Err("manifest_version must be positive".to_string());
1331 }
1332
1333 let mut declared = BTreeMap::<String, Tier>::new();
1334 for (tier, entries) in &self.tiers {
1335 for entry in entries {
1336 let tuple = normalized_tuple(entry.tuple())?;
1337 if entry.platform().is_empty() {
1338 return Err(format!("tuple {tuple} is missing its platform declaration"));
1339 }
1340 if !entry.empty_api_match_has_rationale() {
1341 return Err(format!(
1342 "tuple {tuple} has an empty api_match without rationale"
1343 ));
1344 }
1345 if let Some(previous) = declared.insert(tuple.clone(), *tier) {
1346 return Err(format!(
1347 "tuple {tuple} is declared in both {previous:?} and {tier:?}"
1348 ));
1349 }
1350 }
1351 }
1352
1353 let mut api_declared = BTreeSet::new();
1354 for rule in &self.api_rules {
1355 if rule.method.trim().is_empty() || rule.path_glob.trim().is_empty() {
1356 if rule.path_glob.is_empty()
1357 && rule
1358 .rationale
1359 .as_deref()
1360 .is_some_and(|text| !text.trim().is_empty())
1361 {
1362 continue;
1363 }
1364 return Err("api rule requires method and non-empty path_glob".to_string());
1365 }
1366 if rule.platform.is_empty() {
1367 return Err(format!(
1368 "api rule {} {} is missing its platform declaration",
1369 rule.method, rule.path_glob
1370 ));
1371 }
1372 let key = format!("{} {}", rule.method.to_ascii_uppercase(), rule.path_glob);
1373 if !api_declared.insert(key.clone()) {
1374 return Err(format!("api rule {key} is declared more than once"));
1375 }
1376 }
1377
1378 let governed = self.tiers.get(&Tier::Governed).cloned().unwrap_or_default();
1379 for entry in &governed {
1380 let tuple = normalized_tuple(entry.tuple())?;
1381 let Some(canonical) = self.canonicalization.get(&tuple) else {
1382 return Err(format!("governed tuple {tuple} lacks canonicalization"));
1383 };
1384 if canonical.argv_forms.is_empty() || canonical.target_fields.is_empty() {
1385 return Err(format!(
1386 "governed tuple {tuple} has incomplete canonicalization"
1387 ));
1388 }
1389 }
1390 for tuple in self.canonicalization.keys() {
1391 if declared.get(tuple) != Some(&Tier::Governed) {
1392 return Err(format!(
1393 "canonicalization {tuple} does not name a governed tuple"
1394 ));
1395 }
1396 }
1397
1398 for (repository, agent_id) in &self.bindings {
1399 if canonical_repository_key(repository).as_deref() != Some(repository.as_str()) {
1400 return Err(format!(
1401 "binding repository {repository} is not canonical owner/name"
1402 ));
1403 }
1404 if agent_id.trim().is_empty() || agent_id.trim() != agent_id {
1405 return Err(format!(
1406 "binding repository {repository} has an invalid agent id"
1407 ));
1408 }
1409 }
1410
1411 for (repository, section) in &self.repository_sections {
1412 for removed in §ion.removed_tuples {
1413 if !declared.contains_key(&normalized_tuple(removed)?) {
1414 return Err(format!(
1415 "repository section {repository} removes undeclared tuple {removed}"
1416 ));
1417 }
1418 }
1419 for (tier, entries) in §ion.tiers {
1420 for entry in entries {
1421 let tuple = normalized_tuple(entry.tuple())?;
1422 let Some(base) = declared.get(&tuple) else {
1423 return Err(format!(
1424 "repository section {repository} adds tuple {tuple}"
1425 ));
1426 };
1427 if tier.rank() < base.rank() {
1428 return Err(format!(
1429 "repository section {repository} lowers tuple {tuple}"
1430 ));
1431 }
1432 }
1433 }
1434 }
1435 Ok(())
1436 }
1437
1438 fn tier_for_tuple(&self, tuple: &str, platform: &str) -> Option<Tier> {
1439 self.tiers.iter().find_map(|(tier, entries)| {
1440 entries
1441 .iter()
1442 .any(|entry| {
1443 normalized_tuple(entry.tuple()).ok().as_deref() == Some(tuple)
1444 && platform_matches(entry.platform(), platform)
1445 })
1446 .then_some(*tier)
1447 })
1448 }
1449}
1450
1451fn normalized_tuple(value: &str) -> Result<String, String> {
1452 let words = value
1453 .split_whitespace()
1454 .map(|word| word.to_ascii_lowercase())
1455 .collect::<Vec<_>>();
1456 (!words.is_empty())
1457 .then(|| words.join(" "))
1458 .ok_or_else(|| "tuple cannot be empty".to_string())
1459}
1460
1461fn platform_matches(platforms: &[String], current: &str) -> bool {
1462 platforms
1463 .iter()
1464 .any(|platform| platform.eq_ignore_ascii_case(current))
1465}
1466
1467#[derive(Clone, Debug, Deserialize, Serialize)]
1473struct SignedManifest {
1474 artifact_id: String,
1475 envelope_version: u64,
1476 key_id: String,
1477 fetched_at_unix_secs: u64,
1481 signature: String,
1482 manifest_bytes: String,
1483}
1484
1485#[derive(Clone, Debug)]
1486struct VerifiedManifest {
1487 manifest: Manifest,
1488 verified_by_key_id: String,
1489}
1490
1491#[derive(Clone, Debug)]
1492enum ManifestProblem {
1493 Missing,
1494 Invalid(String),
1495 BelowFloor {
1496 manifest_floor: u64,
1497 },
1498 RolledBack {
1502 manifest_version: u64,
1503 newest_accepted: u64,
1504 },
1505}
1506
1507impl ManifestProblem {
1508 fn diagnostic(&self) -> SelfReportDiagnostic {
1509 match self {
1510 Self::Missing => SelfReportDiagnostic::ManifestUnavailable,
1511 Self::Invalid(_) => SelfReportDiagnostic::ManifestInvalid,
1512 Self::BelowFloor { .. } => SelfReportDiagnostic::ManifestBelowFloor,
1513 Self::RolledBack { .. } => SelfReportDiagnostic::ManifestRollback,
1514 }
1515 }
1516
1517 fn status_label(&self) -> String {
1518 match self {
1519 Self::Missing => "unavailable".to_string(),
1520 Self::Invalid(error) => format!("invalid ({error})"),
1521 Self::BelowFloor { manifest_floor } => format!(
1522 "{} (manifest floor {manifest_floor}, shim floor {SCHEMA_FLOOR})",
1523 RefusalCode::ManifestBelowFloor.as_str()
1524 ),
1525 Self::RolledBack {
1526 manifest_version,
1527 newest_accepted,
1528 } => format!(
1529 "{} (manifest version {manifest_version}, newest accepted version {newest_accepted})",
1530 SelfReportDiagnostic::ManifestRollback.as_str()
1531 ),
1532 }
1533 }
1534
1535 fn fallback_notice_reason(&self) -> String {
1536 match self {
1537 Self::Missing => "manifest unavailable".to_string(),
1538 Self::Invalid(reason) => reason.clone(),
1539 Self::BelowFloor { manifest_floor } => {
1540 format!("manifest floor {manifest_floor}, shim floor {SCHEMA_FLOOR}")
1541 }
1542 Self::RolledBack {
1543 manifest_version,
1544 newest_accepted,
1545 } => {
1546 format!("manifest version {manifest_version}, newest accepted version {newest_accepted}")
1547 }
1548 }
1549 }
1550
1551 fn untrusted_manifest_key_steering(&self) -> Option<&'static str> {
1552 match self {
1553 Self::Invalid(reason) if reason.starts_with("untrusted manifest key id ") => {
1554 Some(UNTRUSTED_MANIFEST_KEY_STEERING)
1555 }
1556 Self::Missing
1557 | Self::Invalid(_)
1558 | Self::BelowFloor { .. }
1559 | Self::RolledBack { .. } => None,
1560 }
1561 }
1562}
1563
1564fn load_manifest(paths: &StatePaths, now: u64) -> Result<Manifest, ManifestProblem> {
1617 load_manifest_with_trust_set(paths, now, compiled_manifest_trust_set())
1618 .map(|verified| verified.manifest)
1619}
1620
1621fn load_manifest_with_trust_set(
1622 paths: &StatePaths,
1623 now: u64,
1624 trust_set: &[Option<ManifestTrustKey>],
1625) -> Result<VerifiedManifest, ManifestProblem> {
1626 let bytes = fs::read(&paths.manifest).map_err(|_| ManifestProblem::Missing)?;
1627 let envelope: SignedManifest = serde_json::from_slice(&bytes)
1628 .map_err(|error| ManifestProblem::Invalid(error.to_string()))?;
1629 if envelope.artifact_id != MANIFEST_ARTIFACT_ID {
1630 return Err(ManifestProblem::Invalid("artifact id mismatch".to_string()));
1631 }
1632 if envelope.envelope_version != ENVELOPE_VERSION {
1633 return Err(ManifestProblem::Invalid(format!(
1634 "unsupported envelope version {} (this shim verifies envelope version {ENVELOPE_VERSION})",
1635 envelope.envelope_version
1636 )));
1637 }
1638 let VerifiedManifest {
1640 manifest,
1641 verified_by_key_id,
1642 } = verify_manifest_signature_with_provenance(&envelope, trust_set)?;
1643 manifest.validate().map_err(ManifestProblem::Invalid)?;
1644 if manifest.schema_floor < SCHEMA_FLOOR {
1645 return Err(ManifestProblem::BelowFloor {
1646 manifest_floor: manifest.schema_floor,
1647 });
1648 }
1649 let newest_accepted = version_high_water(paths);
1653 if manifest.manifest_version < newest_accepted {
1654 return Err(ManifestProblem::RolledBack {
1655 manifest_version: manifest.manifest_version,
1656 newest_accepted,
1657 });
1658 }
1659 if manifest.issued_at_unix_secs > now + ISSUED_AT_FUTURE_SKEW.as_secs() {
1660 return Err(ManifestProblem::Invalid(format!(
1661 "issued_at_unix_secs {} is more than {} seconds in the future",
1662 manifest.issued_at_unix_secs,
1663 ISSUED_AT_FUTURE_SKEW.as_secs()
1664 )));
1665 }
1666 if manifest.manifest_version > newest_accepted {
1670 write_version_high_water(paths, manifest.manifest_version);
1671 }
1672 write_last_valid_manifest(paths, &manifest);
1673 Ok(VerifiedManifest {
1674 manifest,
1675 verified_by_key_id,
1676 })
1677}
1678
1679#[cfg(test)]
1682fn verify_manifest_signature(envelope: &SignedManifest) -> Result<Manifest, ManifestProblem> {
1683 verify_manifest_signature_with(envelope, compiled_manifest_trust_set())
1684}
1685
1686#[cfg(test)]
1687fn verify_manifest_signature_with(
1688 envelope: &SignedManifest,
1689 trust_set: &[Option<ManifestTrustKey>],
1690) -> Result<Manifest, ManifestProblem> {
1691 verify_manifest_signature_with_provenance(envelope, trust_set).map(|verified| verified.manifest)
1692}
1693
1694fn verify_manifest_signature_with_provenance(
1695 envelope: &SignedManifest,
1696 trust_set: &[Option<ManifestTrustKey>],
1697) -> Result<VerifiedManifest, ManifestProblem> {
1698 let Some(key) = trust_set
1699 .iter()
1700 .flatten()
1701 .find(|slot| slot.key_id == envelope.key_id)
1702 .copied()
1703 else {
1704 return Err(ManifestProblem::Invalid(format!(
1705 "untrusted manifest key id {}",
1706 envelope.key_id
1707 )));
1708 };
1709 let signature = base64::engine::general_purpose::STANDARD
1710 .decode(&envelope.signature)
1711 .map_err(|_| ManifestProblem::Invalid("invalid detached signature encoding".to_string()))?;
1712 UnparsedPublicKey::new(&ED25519, key.public_key)
1713 .verify(envelope.manifest_bytes.as_bytes(), &signature)
1714 .map_err(|_| {
1715 ManifestProblem::Invalid("detached signature verification failed".to_string())
1716 })?;
1717 let manifest = serde_json::from_str(&envelope.manifest_bytes).map_err(|error| {
1718 ManifestProblem::Invalid(format!("signed manifest bytes failed to parse: {error}"))
1719 })?;
1720 Ok(VerifiedManifest {
1721 manifest,
1722 verified_by_key_id: key.key_id.to_string(),
1723 })
1724}
1725
1726#[derive(Clone, Copy)]
1729struct ManifestTrustKey {
1730 key_id: &'static str,
1731 public_key: &'static [u8],
1732}
1733
1734const PROD_MANIFEST_KEY_ID: &str = "c9ad111282d1da10";
1771const PROD_MANIFEST_PUBLIC_KEY: [u8; 32] = [
1772 0x5f, 0x4c, 0x81, 0x90, 0x18, 0xe2, 0xb6, 0x8d, 0x18, 0xdb, 0xce, 0x6a, 0xc3, 0x6f, 0x9b, 0x84,
1773 0x65, 0x28, 0x84, 0x14, 0x75, 0x55, 0xe8, 0x44, 0x2e, 0xf7, 0x6d, 0x7f, 0xb4, 0x7a, 0x42, 0xf4,
1774];
1775const PROD_MANIFEST_TRUST_KEY: ManifestTrustKey = ManifestTrustKey {
1776 key_id: PROD_MANIFEST_KEY_ID,
1777 public_key: &PROD_MANIFEST_PUBLIC_KEY,
1778};
1779
1780#[cfg(not(debug_assertions))]
1781const RELEASE_MANIFEST_TRUST_SET: &[Option<ManifestTrustKey>; 2] = &[
1782 Some(PROD_MANIFEST_TRUST_KEY), None, ];
1785
1786#[cfg(debug_assertions)]
1787const DEV_MANIFEST_KEY_ID: &str = "gh-routing-dev-test-key-v1";
1788#[cfg(debug_assertions)]
1789const DEV_MANIFEST_PUBLIC_KEY: [u8; 32] = [
1790 0xd7, 0x5a, 0x98, 0x01, 0x82, 0xb1, 0x0a, 0xb7, 0xd5, 0x4b, 0xfe, 0xd3, 0xc9, 0x64, 0x07, 0x3a,
1791 0x0e, 0xe1, 0x72, 0xf3, 0xda, 0xa6, 0x23, 0x25, 0xaf, 0x02, 0x1a, 0x68, 0xf7, 0x07, 0x51, 0x1a,
1792];
1793#[cfg(debug_assertions)]
1797const DEV_MANIFEST_TRUST_SET: &[Option<ManifestTrustKey>; 2] = &[
1798 Some(PROD_MANIFEST_TRUST_KEY),
1799 Some(ManifestTrustKey {
1800 key_id: DEV_MANIFEST_KEY_ID,
1801 public_key: &DEV_MANIFEST_PUBLIC_KEY,
1802 }),
1803];
1804
1805fn compiled_manifest_trust_set() -> &'static [Option<ManifestTrustKey>] {
1806 #[cfg(debug_assertions)]
1807 {
1808 DEV_MANIFEST_TRUST_SET
1809 }
1810 #[cfg(not(debug_assertions))]
1811 {
1812 RELEASE_MANIFEST_TRUST_SET
1813 }
1814}
1815
1816fn trust_set_key_ids(trust_set: &[Option<ManifestTrustKey>]) -> Vec<&'static str> {
1817 trust_set.iter().flatten().map(|key| key.key_id).collect()
1818}
1819
1820#[derive(Debug)]
1826enum ManifestResolution {
1827 Active(Manifest),
1829 Regressed {
1833 manifest: Manifest,
1834 problem: ManifestProblem,
1835 },
1836 Invalid(ManifestProblem),
1839 Dormant,
1841}
1842
1843impl ManifestResolution {
1844 fn manifest(&self) -> Option<&Manifest> {
1845 match self {
1846 Self::Active(manifest) | Self::Regressed { manifest, .. } => Some(manifest),
1847 Self::Invalid(_) | Self::Dormant => None,
1848 }
1849 }
1850
1851 fn into_manifest(self) -> Option<Manifest> {
1852 match self {
1853 Self::Active(manifest) | Self::Regressed { manifest, .. } => Some(manifest),
1854 Self::Invalid(_) | Self::Dormant => None,
1855 }
1856 }
1857
1858 fn invalid_problem(&self) -> Option<&ManifestProblem> {
1859 match self {
1860 Self::Regressed { problem, .. } | Self::Invalid(problem) => Some(problem),
1861 Self::Active(_) | Self::Dormant => None,
1862 }
1863 }
1864}
1865
1866fn resolve_manifest(paths: &StatePaths, now: u64) -> ManifestResolution {
1867 match load_manifest(paths, now) {
1868 Ok(manifest) => ManifestResolution::Active(manifest),
1869 Err(ManifestProblem::Missing) => ManifestResolution::Dormant,
1870 Err(problem) => match read_last_valid_manifest(paths) {
1871 Some(cache) => ManifestResolution::Regressed {
1872 manifest: cache.manifest,
1873 problem,
1874 },
1875 None => ManifestResolution::Invalid(problem),
1876 },
1877 }
1878}
1879
1880fn delegate_after_invalid_manifest_notice(args: &[OsString], problem: &ManifestProblem) -> i32 {
1881 eprintln!(
1885 "gh-shim: manifest invalid ({}); executing with ambient gh credentials",
1886 problem.fallback_notice_reason().replace(['\n', '\r'], " ")
1887 );
1888 delegate(args)
1889}
1890
1891fn regressed_disposition(
1899 args: &[OsString],
1900 manifest: &Manifest,
1901 platform: &str,
1902 problem: &ManifestProblem,
1903) -> RegressedDisposition {
1904 match classify(args, manifest, platform) {
1905 Classification::Mechanical => RegressedDisposition::Passthrough,
1906 Classification::Governed { tuple, .. } | Classification::Admin { tuple } => {
1907 let text = match problem.untrusted_manifest_key_steering() {
1908 Some(steering) => {
1909 format!("the manifest artifact fails validation; {tuple} is refused; {steering}")
1910 }
1911 None => format!(
1912 "the manifest artifact fails validation; {tuple} is refused until the manifest is repaired"
1913 ),
1914 };
1915 RegressedDisposition::Refuse {
1916 code: RefusalCode::ManifestRegressed,
1917 text,
1918 }
1919 }
1920 Classification::Unclassified => RegressedDisposition::Refuse {
1921 code: RefusalCode::Unclassified,
1922 text:
1923 "no manifest declaration for this invocation (manifest artifact fails validation)"
1924 .to_string(),
1925 },
1926 }
1927}
1928
1929#[derive(Debug)]
1930enum RegressedDisposition {
1931 Passthrough,
1932 Refuse { code: RefusalCode, text: String },
1933}
1934
1935#[derive(Clone, Debug, Deserialize, Serialize)]
1940struct LastValidManifest {
1941 manifest: Manifest,
1942}
1943
1944fn read_last_valid_manifest(paths: &StatePaths) -> Option<LastValidManifest> {
1945 serde_json::from_slice(&fs::read(&paths.last_valid_manifest).ok()?).ok()
1946}
1947
1948fn write_last_valid_manifest(paths: &StatePaths, manifest: &Manifest) {
1949 let record = LastValidManifest {
1950 manifest: manifest.clone(),
1951 };
1952 let Ok(bytes) = serde_json::to_vec(&record) else {
1953 return;
1954 };
1955 let _ = fs::create_dir_all(&paths.root);
1956 let temporary = paths.last_valid_manifest.with_extension("tmp");
1957 if fs::write(&temporary, bytes).is_ok() {
1958 let _ = fs::rename(temporary, &paths.last_valid_manifest);
1959 }
1960}
1961
1962#[derive(Clone, Debug, Deserialize, Serialize)]
1967struct VersionHighWater {
1968 newest_accepted_version: u64,
1969}
1970
1971fn version_high_water(paths: &StatePaths) -> u64 {
1972 fs::read(&paths.version_high_water)
1973 .ok()
1974 .and_then(|bytes| serde_json::from_slice::<VersionHighWater>(&bytes).ok())
1975 .map(|record| record.newest_accepted_version)
1976 .unwrap_or(0)
1977}
1978
1979fn write_version_high_water(paths: &StatePaths, newest_accepted_version: u64) {
1980 let Ok(bytes) = serde_json::to_vec(&VersionHighWater {
1981 newest_accepted_version,
1982 }) else {
1983 return;
1984 };
1985 let _ = fs::create_dir_all(&paths.root);
1986 let temporary = paths.version_high_water.with_extension("tmp");
1987 if fs::write(&temporary, bytes).is_ok() {
1988 let _ = fs::rename(temporary, &paths.version_high_water);
1989 }
1990}
1991
1992#[derive(Debug)]
1993enum Classification {
1994 Mechanical,
1995 Governed {
1996 tuple: String,
1997 canonical: Canonicalization,
1998 },
1999 Admin {
2000 tuple: String,
2001 },
2002 Unclassified,
2003}
2004
2005fn is_reviewed_admin_tuple(manifest_version: u64, tuple: &str) -> bool {
2006 V1_ADMIN_TUPLES.contains(&tuple)
2007 || (manifest_version >= 9 && V9_ADMIN_TUPLES.contains(&tuple))
2008 || (manifest_version >= 10 && V10_ADMIN_TUPLES.contains(&tuple))
2009}
2010
2011fn is_reviewed_edit_last_tuple(manifest_version: u64, tuple: &str) -> bool {
2012 manifest_version >= 10 && V10_EDIT_LAST_TUPLES.contains(&tuple)
2013}
2014
2015fn has_exact_flag(args: &[OsString], flag: &str) -> bool {
2016 args.iter().any(|arg| arg.to_str() == Some(flag))
2017}
2018
2019fn classify(args: &[OsString], manifest: &Manifest, platform: &str) -> Classification {
2020 let Some((verb, subcommand, _)) = command_head(args) else {
2021 if args.iter().any(|arg| arg.to_str().is_none()) {
2024 return Classification::Unclassified;
2025 }
2026 return Classification::Mechanical;
2030 };
2031 if verb == "help" {
2032 return Classification::Mechanical;
2034 }
2035 if verb == "api" {
2036 return classify_api(args, manifest, platform);
2037 }
2038 let tuple = match subcommand {
2039 Some(subcommand) => format!("{verb} {subcommand}"),
2040 None => verb,
2041 };
2042 if has_exact_flag(args, "--edit-last")
2048 && !is_reviewed_edit_last_tuple(manifest.manifest_version, &tuple)
2049 {
2050 return Classification::Unclassified;
2051 }
2052 if has_exact_flag(args, "--delete-last") || has_exact_flag(args, "--create-if-none") {
2056 return Classification::Unclassified;
2057 }
2058 if READ_ONLY_ACTION_TUPLES.contains(&tuple.as_str()) {
2059 return Classification::Mechanical;
2060 }
2061 match manifest.tier_for_tuple(&tuple, platform) {
2062 Some(Tier::Mechanical) => Classification::Mechanical,
2063 Some(Tier::Admin) if is_reviewed_admin_tuple(manifest.manifest_version, &tuple) => {
2064 Classification::Admin { tuple }
2065 }
2066 Some(Tier::Governed) if V1_GOVERNED_TUPLES.contains(&tuple.as_str()) => manifest
2067 .canonicalization
2068 .get(&tuple)
2069 .cloned()
2070 .map(|canonical| Classification::Governed { tuple, canonical })
2071 .unwrap_or(Classification::Unclassified),
2072 Some(Tier::Governed | Tier::Admin) | None => Classification::Unclassified,
2077 }
2078}
2079
2080fn command_head(args: &[OsString]) -> Option<(String, Option<String>, usize)> {
2081 let mut positionals = Vec::new();
2082 let mut skip_next = false;
2083 for (index, raw) in args.iter().enumerate() {
2084 let value = raw.to_str()?;
2085 if skip_next {
2086 skip_next = false;
2087 continue;
2088 }
2089 if matches!(value, "--repo" | "-R" | "--hostname" | "--config-dir") {
2090 skip_next = true;
2091 continue;
2092 }
2093 if value.starts_with('-') {
2094 continue;
2095 }
2096 positionals.push((value.to_ascii_lowercase(), index));
2097 if positionals.len() == 2 || positionals[0].0 == "api" {
2098 break;
2099 }
2100 }
2101 let (verb, index) = positionals.first()?.clone();
2102 let subcommand = positionals.get(1).map(|(value, _)| value.clone());
2103 Some((verb, subcommand, index))
2104}
2105
2106fn classify_api(args: &[OsString], manifest: &Manifest, platform: &str) -> Classification {
2107 let Some((method, path)) = api_method_and_path(args) else {
2108 return Classification::Unclassified;
2109 };
2110 let matches = manifest
2111 .api_rules
2112 .iter()
2113 .filter(|rule| {
2114 rule.method.eq_ignore_ascii_case(&method)
2115 && platform_matches(&rule.platform, platform)
2116 && glob::Pattern::new(&rule.path_glob).is_ok_and(|pattern| pattern.matches(&path))
2117 })
2118 .collect::<Vec<_>>();
2119 if matches.is_empty() && method.eq_ignore_ascii_case("GET") {
2120 return Classification::Mechanical;
2123 }
2124 if matches.len() != 1 {
2125 return Classification::Unclassified;
2126 }
2127 match matches[0].tier {
2132 Tier::Mechanical => Classification::Mechanical,
2133 Tier::Governed | Tier::Admin => Classification::Unclassified,
2134 }
2135}
2136
2137fn api_method_and_path(args: &[OsString]) -> Option<(String, String)> {
2138 let mut method = "GET".to_string();
2139 let mut path = None;
2140 let mut index = 1;
2141 while index < args.len() {
2142 let value = args[index].to_str()?;
2143 if matches!(value, "--method" | "-X") {
2144 method = args.get(index + 1)?.to_str()?.to_ascii_uppercase();
2145 index += 2;
2146 continue;
2147 }
2148 if let Some(method_value) = value.strip_prefix("--method=") {
2149 method = method_value.to_ascii_uppercase();
2150 index += 1;
2151 continue;
2152 }
2153 if is_api_field_argument(value) {
2154 return None;
2158 }
2159 if value.starts_with('-') {
2160 index += 1;
2161 continue;
2162 }
2163 if path.is_none() {
2164 path = Some(value.to_string());
2165 }
2166 index += 1;
2167 }
2168 let path = path?;
2169 (path != "-").then_some((method, path))
2170}
2171
2172fn is_api_field_argument(value: &str) -> bool {
2173 ["--input", "--raw-field", "--field"]
2174 .iter()
2175 .any(|flag| value == *flag || value.starts_with(&format!("{flag}=")))
2176 || value == "-F"
2177 || value.starts_with("-F")
2178 || value == "-f"
2179 || value.starts_with("-f")
2180}
2181
2182#[derive(Clone, Debug)]
2183struct GovernedRequest {
2184 action: String,
2185 target: Map<String, Value>,
2186 body: Map<String, Value>,
2187 repository: Option<String>,
2188 manifest_version: u64,
2189 edit_last: bool,
2190}
2191
2192fn canonicalize_governed(
2193 args: &[OsString],
2194 tuple: &str,
2195 canonical: &Canonicalization,
2196 manifest_version: u64,
2197) -> Result<GovernedRequest, String> {
2198 let (_, _, head_index) =
2199 command_head(args).ok_or_else(|| "missing command head".to_string())?;
2200 let subcommand_index = if tuple.starts_with("api ") {
2201 head_index
2202 } else {
2203 head_index + 1
2204 };
2205 let mut positional = Vec::new();
2206 let mut body = Map::new();
2207 let mut review_event = None;
2208 let mut explicit_repository = None;
2209 let mut edit_last = false;
2210 let mut index = subcommand_index + 1;
2211 while index < args.len() {
2212 let value = args[index]
2213 .to_str()
2214 .ok_or_else(|| "non-UTF-8 governed arguments are undeclared".to_string())?;
2215 if tuple == "pr review" {
2216 if let Some(event) = declared_review_event(value) {
2217 if review_event.replace(event.to_string()).is_some() {
2218 return Err(
2219 "pr review accepts only one of --approve, --comment, or --request-changes"
2220 .to_string(),
2221 );
2222 }
2223 index += 1;
2224 continue;
2225 }
2226 }
2227 if value == "--edit-last" {
2228 if !is_reviewed_edit_last_tuple(manifest_version, tuple) {
2229 return Err("undeclared flag --edit-last".to_string());
2230 }
2231 if edit_last {
2232 return Err("--edit-last may be provided only once".to_string());
2233 }
2234 edit_last = true;
2235 } else if value == "--repo" || value == "-R" {
2236 index += 1;
2237 let repository = args
2238 .get(index)
2239 .and_then(|arg| arg.to_str())
2240 .ok_or_else(|| "--repo requires a value".to_string())?;
2241 explicit_repository = Some(repository.to_string());
2242 } else if let Some(repository) = value.strip_prefix("--repo=") {
2243 explicit_repository = Some(repository.to_string());
2244 } else if let Some((field, supplied)) =
2245 declared_body_value(value, canonical, args.get(index + 1))?
2246 {
2247 body.insert(field, Value::String(supplied));
2248 if !value.contains('=') && !value.starts_with('-') {
2249 positional.push(value.to_string());
2251 }
2252 if !value.contains('=') {
2253 index += 1;
2254 }
2255 } else if value.starts_with('-') {
2256 return Err(format!("undeclared flag {value}"));
2257 } else {
2258 positional.push(value.to_string());
2259 }
2260 index += 1;
2261 }
2262
2263 if positional.len() != canonical.target_fields.len() {
2264 return Err("target positional form is undeclared".to_string());
2265 }
2266 if canonical
2267 .body_fields
2268 .iter()
2269 .any(|field| !body.contains_key(field))
2270 {
2271 let body_optional_for_review = tuple == "pr review"
2275 && review_event
2276 .as_deref()
2277 .is_some_and(|event| event != "COMMENT")
2278 && canonical.body_fields.iter().all(|field| field == "body");
2279 if !body_optional_for_review {
2280 return Err("required declared body field is absent".to_string());
2281 }
2282 }
2283 if let Some(event) = review_event {
2284 body.insert("event".to_string(), Value::String(event));
2285 }
2286 let target = canonical
2287 .target_fields
2288 .iter()
2289 .cloned()
2290 .zip(positional)
2291 .map(|(field, value)| (field, Value::String(value)))
2292 .collect::<Map<_, _>>();
2293 let repository = explicit_repo(args)
2296 .or(explicit_repository)
2297 .or_else(infer_repository_from_git)
2298 .map(|repository| {
2299 canonical_repository_key(&repository)
2300 .ok_or_else(|| format!("repository {repository} is not owner/name"))
2301 })
2302 .transpose()?;
2303 Ok(GovernedRequest {
2304 action: tuple.to_string(),
2305 target,
2306 body,
2307 repository,
2308 manifest_version,
2309 edit_last,
2310 })
2311}
2312
2313fn declared_body_value(
2314 value: &str,
2315 canonical: &Canonicalization,
2316 next: Option<&OsString>,
2317) -> Result<Option<(String, String)>, String> {
2318 for field in &canonical.body_fields {
2319 let long = format!("--{field}");
2320 let short = match field.as_str() {
2321 "body" => Some("-b"),
2322 "reaction" => Some("-r"),
2323 _ => None,
2324 };
2325 if value == long || short == Some(value) {
2326 let supplied = next
2327 .and_then(|arg| arg.to_str())
2328 .ok_or_else(|| format!("{value} requires a value"))?;
2329 return Ok(Some((field.clone(), supplied.to_string())));
2330 }
2331 if let Some(supplied) = value.strip_prefix(&(long + "=")) {
2332 return Ok(Some((field.clone(), supplied.to_string())));
2333 }
2334
2335 if field == "body" {
2340 let file = if value == "--body-file" || value == "-F" {
2341 Some(
2342 next.and_then(|arg| arg.to_str())
2343 .ok_or_else(|| format!("{value} requires a value"))?,
2344 )
2345 } else {
2346 value
2347 .strip_prefix("--body-file=")
2348 .or_else(|| value.strip_prefix("-F="))
2349 .or_else(|| value.strip_prefix("-F"))
2350 };
2351 if let Some(file) = file {
2352 let supplied =
2353 read_body_file(Path::new(file)).map_err(|error| format!("{value}: {error}"))?;
2354 return Ok(Some((field.clone(), supplied)));
2355 }
2356 }
2357 }
2358 Ok(None)
2359}
2360
2361fn read_body_file(path: &Path) -> Result<String, String> {
2362 let mut stdin = io::stdin().lock();
2363 read_body_file_from(path, &mut stdin)
2364}
2365
2366fn read_body_file_from<R: Read>(path: &Path, stdin: &mut R) -> Result<String, String> {
2367 let mut body = String::new();
2368 if path == Path::new("-") {
2372 stdin
2373 .read_to_string(&mut body)
2374 .map_err(|error| format!("could not read body from stdin: {error}"))?;
2375 } else {
2376 body = fs::read_to_string(path)
2377 .map_err(|error| format!("could not read body file {}: {error}", path.display()))?;
2378 }
2379 Ok(body)
2380}
2381
2382fn declared_review_event(value: &str) -> Option<&'static str> {
2383 match value {
2384 "--approve" => Some("APPROVE"),
2385 "--comment" => Some("COMMENT"),
2386 "--request-changes" => Some("REQUEST_CHANGES"),
2387 _ => None,
2388 }
2389}
2390
2391fn explicit_repo(args: &[OsString]) -> Option<String> {
2392 let mut args = args.iter();
2393 while let Some(arg) = args.next() {
2394 let value = arg.to_str()?;
2395 if value == "--repo" || value == "-R" {
2396 return args.next()?.to_str().map(str::to_string);
2397 }
2398 if let Some(repository) = value.strip_prefix("--repo=") {
2399 return Some(repository.to_string());
2400 }
2401 }
2402 None
2403}
2404
2405fn infer_repository_from_git() -> Option<String> {
2406 let cwd = std::env::current_dir().ok()?;
2407 canonical_repository_key(&origin_remote(&cwd)?)
2408}
2409
2410#[derive(Debug)]
2411enum RouteOutcome {
2412 Result(String),
2413 UpstreamError(String),
2414 Refusal(String),
2415 UnboundIdentity,
2416 SchemaMismatch(String),
2417 GovernanceUnavailable,
2418 Unavailable(String),
2419}
2420
2421#[derive(Clone, Debug, Default, Deserialize, Serialize)]
2422struct SeamState {
2423 bound_holder: Option<String>,
2424 agent_binding: Option<AgentBinding>,
2425 last_seam_refusal: Option<LastSeamRefusal>,
2426}
2427
2428#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2429struct LastSeamRefusal {
2430 code: String,
2431 at_unix_secs: u64,
2432}
2433
2434fn route_governed(
2435 paths: &StatePaths,
2436 determination: &RungRecord,
2437 agent_binding: &AgentBinding,
2438 request: GovernedRequest,
2439 now: u64,
2440) -> RouteOutcome {
2441 if let Err(error) = write_seam_state(paths, governed_seam_state(paths, None, agent_binding)) {
2442 return RouteOutcome::Unavailable(format!("governed self-report update failed: {error}"));
2443 }
2444
2445 let Some(connection_file) = configured_connection_file() else {
2446 return RouteOutcome::GovernanceUnavailable;
2447 };
2448 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
2449 let project_root = project_root_for(&cwd);
2450 let record_paths = paths.clone();
2451 let agent_binding = agent_binding.clone();
2452 let runtime = match tokio::runtime::Builder::new_current_thread()
2453 .enable_io()
2454 .enable_time()
2455 .build()
2456 {
2457 Ok(runtime) => runtime,
2458 Err(error) => return RouteOutcome::Unavailable(error.to_string()),
2459 };
2460 runtime
2461 .block_on(async move {
2462 let options = ConsumerOptions {
2463 call_timeout: Duration::from_secs(5),
2464 ..ConsumerOptions::default()
2465 };
2466 let consumer = SubcConsumer::connect(&connection_file, options)
2467 .await
2468 .map_err(|_| RouteOutcome::GovernanceUnavailable)?;
2469 let catalog = consumer
2470 .catalog_list()
2471 .await
2472 .map_err(|_| RouteOutcome::GovernanceUnavailable)?;
2473 let holder = route_holder(&catalog.modules);
2474 record_unexpected_gh_route_advertisers(&record_paths, &holder.unexpected_advertisers);
2475 let module_id = holder
2476 .module_id
2477 .ok_or(RouteOutcome::GovernanceUnavailable)?;
2478 let route = consumer
2479 .open_route(
2480 RouteTarget::ManagementSurface {
2481 module_id: module_id.clone(),
2482 },
2483 BindIdentity {
2484 project_root: project_root.to_string_lossy().into_owned().into(),
2485 harness: "aft-gh-shim".to_string(),
2486 session: gh_session_id(&agent_binding.agent_id),
2487 },
2488 CallOptions::default(),
2489 )
2490 .await
2491 .map_err(|_| RouteOutcome::UnboundIdentity)?;
2492 if let Err(error) = write_seam_state(
2493 &record_paths,
2494 governed_seam_state(&record_paths, Some(module_id.clone()), &agent_binding),
2495 ) {
2496 let _ = consumer
2497 .close_handle(&route, CloseRouteOptions::default())
2498 .await;
2499 return Err(RouteOutcome::Unavailable(format!(
2500 "governed self-report update failed: {error}"
2501 )));
2502 }
2503 let wire_request =
2504 governed_wire_request(determination, &agent_binding.agent_id, request);
2505 let body = serde_json::to_vec(&wire_request)
2506 .map_err(|error| RouteOutcome::SchemaMismatch(error.to_string()))?;
2507 let response = consumer
2508 .request(&route, body, CallOptions::default())
2509 .await
2510 .map_err(|error| RouteOutcome::Unavailable(error.to_string()));
2511 let _ = consumer
2512 .close_handle(&route, CloseRouteOptions::default())
2513 .await;
2514 let response = response?;
2515 let outcome = parse_governed_response(&response)?;
2516 if let RouteOutcome::Refusal(code) = &outcome {
2517 write_seam_state(
2518 &record_paths,
2519 SeamState {
2520 bound_holder: Some(module_id),
2521 agent_binding: Some(agent_binding),
2522 last_seam_refusal: Some(LastSeamRefusal {
2523 code: code.clone(),
2524 at_unix_secs: now,
2525 }),
2526 },
2527 )
2528 .map_err(|error| {
2529 RouteOutcome::Unavailable(format!(
2530 "governed self-report update failed: {error}"
2531 ))
2532 })?;
2533 }
2534 Ok(outcome)
2535 })
2536 .unwrap_or_else(|outcome| outcome)
2537}
2538
2539fn refuse_governance_unavailable(
2540 paths: &StatePaths,
2541 agent_binding: &AgentBinding,
2542 now: u64,
2543) -> i32 {
2544 let state = SeamState {
2545 bound_holder: None,
2546 agent_binding: Some(agent_binding.clone()),
2547 last_seam_refusal: Some(LastSeamRefusal {
2548 code: RefusalCode::GovernanceUnavailable.as_str().to_string(),
2549 at_unix_secs: now,
2550 }),
2551 };
2552 if let Err(error) = write_seam_state(paths, state) {
2553 return refuse(
2554 RefusalCode::SeamUnavailable,
2555 &format!("governed self-report update failed: {error}"),
2556 );
2557 }
2558 refuse(
2559 RefusalCode::GovernanceUnavailable,
2560 GOVERNANCE_UNAVAILABLE_TEXT,
2561 )
2562}
2563
2564fn governed_seam_state(
2565 paths: &StatePaths,
2566 bound_holder: Option<String>,
2567 agent_binding: &AgentBinding,
2568) -> SeamState {
2569 SeamState {
2570 bound_holder,
2571 agent_binding: Some(agent_binding.clone()),
2572 last_seam_refusal: seam_state(paths).last_seam_refusal,
2575 }
2576}
2577
2578fn write_seam_state(paths: &StatePaths, state: SeamState) -> io::Result<()> {
2579 fs::create_dir_all(&paths.root)?;
2580 let bytes = serde_json::to_vec(&state).map_err(io::Error::other)?;
2581 let temporary = paths.seam_state.with_extension("tmp");
2582 let mut file = OpenOptions::new()
2583 .create(true)
2584 .truncate(true)
2585 .write(true)
2586 .open(&temporary)?;
2587 file.write_all(&bytes)?;
2588 file.sync_data()?;
2592 fs::rename(temporary, &paths.seam_state)
2593}
2594
2595fn seam_state(paths: &StatePaths) -> SeamState {
2596 fs::read(&paths.seam_state)
2597 .ok()
2598 .and_then(|bytes| serde_json::from_slice(&bytes).ok())
2599 .unwrap_or_default()
2600}
2601
2602fn governed_wire_request(
2603 determination: &RungRecord,
2604 agent_id: &str,
2605 request: GovernedRequest,
2606) -> Value {
2607 let edit_last = request.edit_last;
2608 let mut wire = json!({
2609 "operation": ROUTING_OPERATION,
2610 "gh_route_schema": 1,
2611 "action": request.action,
2612 "target": request.target,
2613 "body": request.body,
2614 "repository": request.repository,
2615 "manifest_version": request.manifest_version,
2616 "rung_as_of_unix_secs": determination.as_of_unix_secs,
2617 "metadata": {
2618 "agent_id": agent_id,
2619 "pid": std::process::id(),
2620 },
2621 });
2622 if edit_last {
2626 wire["edit_last"] = Value::Bool(true);
2627 }
2628 wire
2629}
2630
2631fn parse_governed_response(bytes: &[u8]) -> Result<RouteOutcome, RouteOutcome> {
2632 let value: Value = serde_json::from_slice(bytes).map_err(|_| {
2633 RouteOutcome::SchemaMismatch(
2634 "governance seam returned malformed or non-UTF-8 JSON".to_string(),
2635 )
2636 })?;
2637 let object = value.as_object().ok_or_else(|| {
2638 RouteOutcome::SchemaMismatch("governance seam response must be an object".to_string())
2639 })?;
2640 match object.get("outcome").and_then(Value::as_str) {
2641 Some("result") => {
2642 let schema = object
2643 .get("gh_route_schema")
2644 .and_then(Value::as_u64)
2645 .ok_or_else(|| {
2646 RouteOutcome::SchemaMismatch(
2647 "governance seam omitted gh_route_schema".to_string(),
2648 )
2649 })?;
2650 if schema > 1 {
2651 return Err(RouteOutcome::SchemaMismatch(format!(
2652 "governance seam schema {schema} is newer than supported schema 1"
2653 )));
2654 }
2655 let result = object.get("result").ok_or_else(|| {
2656 RouteOutcome::SchemaMismatch("governance seam omitted result".to_string())
2657 })?;
2658 if let Some(body) = upstream_error_body(object, result) {
2659 return Ok(RouteOutcome::UpstreamError(body));
2660 }
2661 let field_order = object
2662 .get("field_order")
2663 .and_then(Value::as_array)
2664 .ok_or_else(|| {
2665 RouteOutcome::SchemaMismatch("governance seam omitted field_order".to_string())
2666 })?;
2667 render_governed_response(result, field_order).map(RouteOutcome::Result)
2668 }
2669 Some("refusal") => {
2670 let refusal_code = object
2671 .get("refusal_code")
2672 .and_then(Value::as_str)
2673 .ok_or_else(|| {
2674 RouteOutcome::SchemaMismatch(
2675 "governance refusal omitted a string refusal_code".to_string(),
2676 )
2677 })?;
2678 Ok(RouteOutcome::Refusal(refusal_code.to_string()))
2679 }
2680 Some("unbound_identity") => Ok(RouteOutcome::UnboundIdentity),
2681 _ => Err(RouteOutcome::SchemaMismatch(
2682 "governance seam returned an unknown outcome".to_string(),
2683 )),
2684 }
2685}
2686
2687fn upstream_error_body(response: &Map<String, Value>, result: &Value) -> Option<String> {
2688 let result_object = result.as_object();
2689 let status = response
2690 .get("status")
2691 .or_else(|| response.get("status_code"))
2692 .or_else(|| result_object.and_then(|object| object.get("status")))
2693 .or_else(|| result_object.and_then(|object| object.get("status_code")))
2694 .and_then(|value| value.as_u64())?;
2695 if (200..300).contains(&status) {
2696 return None;
2697 }
2698 let body = response
2699 .get("error")
2700 .or_else(|| response.get("body"))
2701 .or_else(|| result_object.and_then(|object| object.get("error")))
2702 .or_else(|| result_object.and_then(|object| object.get("body")))
2703 .unwrap_or(result);
2704 Some(match body {
2705 Value::String(body) => body.clone(),
2706 _ => serde_json::to_string(body).unwrap_or_else(|_| body.to_string()),
2707 })
2708}
2709
2710fn render_governed_response(result: &Value, field_order: &[Value]) -> Result<String, RouteOutcome> {
2711 let object = result.as_object().ok_or_else(|| {
2712 RouteOutcome::SchemaMismatch("governance result must be an object".to_string())
2713 })?;
2714 let mut output = String::new();
2715 let mut rendered = BTreeSet::new();
2716 for field in field_order {
2717 let field = field.as_str().ok_or_else(|| {
2718 RouteOutcome::SchemaMismatch("field_order must contain string fields".to_string())
2719 })?;
2720 let value = object.get(field).ok_or_else(|| {
2721 RouteOutcome::SchemaMismatch(format!(
2722 "field_order references absent result field {field}"
2723 ))
2724 })?;
2725 if !rendered.insert(field) {
2726 return Err(RouteOutcome::SchemaMismatch(format!(
2727 "field_order repeats result field {field}"
2728 )));
2729 }
2730 render_field(&mut output, field, value)?;
2731 }
2732 if rendered.len() != object.len() {
2733 return Err(RouteOutcome::SchemaMismatch(
2734 "field_order does not cover every governed result field".to_string(),
2735 ));
2736 }
2737 Ok(output)
2738}
2739
2740fn render_field(output: &mut String, field: &str, value: &Value) -> Result<(), RouteOutcome> {
2741 match value {
2742 Value::Array(values) => {
2743 output.push_str(field);
2744 output.push_str(":\n");
2745 for value in values {
2746 output.push_str(" ");
2747 output.push_str(&render_scalar(value)?);
2748 output.push('\n');
2749 }
2750 }
2751 _ => {
2752 output.push_str(field);
2753 output.push_str(": ");
2754 output.push_str(&render_scalar(value)?);
2755 output.push('\n');
2756 }
2757 }
2758 Ok(())
2759}
2760
2761fn render_scalar(value: &Value) -> Result<String, RouteOutcome> {
2762 match value {
2763 Value::String(value) => serde_json::to_string(value)
2764 .map_err(|error| RouteOutcome::SchemaMismatch(error.to_string())),
2765 Value::Number(_) | Value::Bool(_) | Value::Null => Ok(value.to_string()),
2766 Value::Object(_) | Value::Array(_) => serde_json::to_string(value)
2767 .map_err(|error| RouteOutcome::SchemaMismatch(error.to_string())),
2768 }
2769}
2770
2771fn append_bypass_audit(
2772 paths: &StatePaths,
2773 tuple: &str,
2774 repository: Option<&str>,
2775 now: u64,
2776) -> io::Result<()> {
2777 fs::create_dir_all(&paths.root)?;
2778 let mut record = serde_json::to_vec(&json!({
2779 "as_of_unix_secs": now,
2780 "tuple": tuple,
2781 "repository": repository,
2782 }))
2783 .map_err(io::Error::other)?;
2784 record.push(b'\n');
2785 let mut file = OpenOptions::new()
2786 .create(true)
2787 .append(true)
2788 .open(&paths.bypass_audit)?;
2789 file.write_all(&record)?;
2790 file.sync_data()
2793}
2794
2795#[derive(Serialize)]
2796struct SelfReport {
2797 shim_version: &'static str,
2798 gh_routing_schema_floor: u64,
2799 unexpected_gh_route_advertiser: Option<Vec<String>>,
2800 bound_holder: Option<String>,
2801 agent_binding: Option<AgentBinding>,
2802 last_seam_refusal: Option<LastSeamRefusal>,
2803 cached_manifest: CachedManifestReport,
2804 last_rung: LastRungReport,
2805 bypass_audit: Option<Vec<Value>>,
2806 bypass_audit_error: Option<String>,
2807 executing_image: Option<String>,
2808 executing_image_error: Option<String>,
2809 real_gh_resolution: Option<RealGhResolution>,
2810 real_gh_resolution_error: Option<String>,
2811}
2812
2813#[derive(Serialize)]
2814struct CachedManifestReport {
2815 version: Option<u64>,
2816 issued_at_unix_secs: Option<u64>,
2819 verified_by_key_id: Option<String>,
2821 compiled_trust_set_key_ids: Vec<&'static str>,
2823 version_error: Option<String>,
2824 state: Option<&'static str>,
2825 state_error: Option<String>,
2826 diagnostics: Vec<&'static str>,
2827 diagnostic_guidance: Option<&'static str>,
2828}
2829
2830#[derive(Serialize)]
2831struct LastRungReport {
2832 rung: Option<&'static str>,
2833 rung_error: Option<String>,
2834 as_of_unix_secs: Option<u64>,
2835 as_of_unix_secs_error: Option<String>,
2836 determination_inputs: Option<BTreeMap<String, String>>,
2837 determination_inputs_error: Option<String>,
2838 recorded_by_image_path: Option<String>,
2839 recorded_by_version: Option<String>,
2840 recorded_by_repo_key: Option<String>,
2841}
2842
2843#[derive(Serialize)]
2844struct RealGhResolution {
2845 path: String,
2846 shim_path_positions: Vec<usize>,
2847}
2848
2849fn print_self_report(paths: &StatePaths) {
2850 if let Ok(document) = render_self_report(paths) {
2853 let mut stdout = io::stdout().lock();
2854 let _ = stdout.write_all(document.as_bytes());
2855 }
2856}
2857
2858fn render_self_report(paths: &StatePaths) -> Result<String, serde_json::Error> {
2859 let report = build_self_report(paths);
2860 let mut document = serde_json::to_string(&report)?;
2861 document.push('\n');
2862 Ok(document)
2863}
2864
2865fn build_self_report(paths: &StatePaths) -> SelfReport {
2866 let image = self_report_executing_image();
2867 let (real_gh_resolution, real_gh_resolution_error) = match image.as_ref() {
2868 Ok(image) => match resolve_real_gh(image) {
2869 Some(path) => (
2870 Some(RealGhResolution {
2871 path: path.to_string_lossy().into_owned(),
2872 shim_path_positions: executing_image_path_positions(image),
2873 }),
2874 None,
2875 ),
2876 None => (
2877 None,
2878 Some(
2879 "PATH contains no upstream gh after skipping the executing shim image"
2880 .to_string(),
2881 ),
2882 ),
2883 },
2884 Err(error) => (None, Some(format!("executing image unavailable: {error}"))),
2885 };
2886 let (bypass_audit, bypass_audit_error) = read_bypass_audit(paths);
2887 let seam_state = seam_state(paths);
2888 let disabled = gh_shim_enabled_from_config_doc(read_user_config_doc().as_deref().unwrap_or(""))
2892 == Some(false);
2893 let (cached_manifest, last_rung) = if disabled {
2894 (disabled_manifest_report(), disabled_last_rung_report())
2895 } else {
2896 (cached_manifest_report(paths), last_rung_report(paths))
2897 };
2898 SelfReport {
2899 shim_version: env!("CARGO_PKG_VERSION"),
2900 gh_routing_schema_floor: SCHEMA_FLOOR,
2901 unexpected_gh_route_advertiser: unexpected_gh_route_advertisers(paths),
2902 bound_holder: seam_state.bound_holder,
2903 agent_binding: seam_state.agent_binding,
2904 last_seam_refusal: seam_state.last_seam_refusal,
2905 cached_manifest,
2906 last_rung,
2907 bypass_audit,
2908 bypass_audit_error,
2909 executing_image: image
2910 .as_ref()
2911 .ok()
2912 .map(|path| path.to_string_lossy().into_owned()),
2913 executing_image_error: image.err(),
2914 real_gh_resolution,
2915 real_gh_resolution_error,
2916 }
2917}
2918
2919fn disabled_manifest_report() -> CachedManifestReport {
2923 CachedManifestReport {
2924 version: None,
2925 issued_at_unix_secs: None,
2926 verified_by_key_id: None,
2927 compiled_trust_set_key_ids: trust_set_key_ids(compiled_manifest_trust_set()),
2928 version_error: None,
2929 state: Some("disabled"),
2930 state_error: None,
2931 diagnostics: Vec::new(),
2932 diagnostic_guidance: None,
2933 }
2934}
2935
2936fn disabled_last_rung_report() -> LastRungReport {
2939 LastRungReport {
2940 rung: Some(Rung::R1.label()),
2941 rung_error: None,
2942 as_of_unix_secs: Some(unix_seconds()),
2943 as_of_unix_secs_error: None,
2944 determination_inputs: Some(BTreeMap::from([(
2945 "connection_file".to_string(),
2946 "disabled_by_config".to_string(),
2947 )])),
2948 determination_inputs_error: None,
2949 recorded_by_image_path: None,
2950 recorded_by_version: None,
2951 recorded_by_repo_key: None,
2952 }
2953}
2954
2955fn cached_manifest_report(paths: &StatePaths) -> CachedManifestReport {
2956 cached_manifest_report_at(paths, unix_seconds())
2957}
2958
2959fn cached_manifest_report_at(paths: &StatePaths, now: u64) -> CachedManifestReport {
2960 cached_manifest_report_at_with(paths, now, compiled_manifest_trust_set())
2961}
2962
2963fn cached_manifest_report_at_with(
2964 paths: &StatePaths,
2965 now: u64,
2966 trust_set: &[Option<ManifestTrustKey>],
2967) -> CachedManifestReport {
2968 let compiled_trust_set_key_ids = trust_set_key_ids(trust_set);
2969 match load_manifest_with_trust_set(paths, now, trust_set) {
2970 Ok(verified) => CachedManifestReport {
2971 version: Some(verified.manifest.manifest_version),
2972 issued_at_unix_secs: Some(verified.manifest.issued_at_unix_secs),
2973 verified_by_key_id: Some(verified.verified_by_key_id),
2974 compiled_trust_set_key_ids,
2975 version_error: None,
2976 state: Some("valid"),
2977 state_error: None,
2978 diagnostics: Vec::new(),
2979 diagnostic_guidance: None,
2980 },
2981 Err(ManifestProblem::Missing) => {
2982 let error = ManifestProblem::Missing.status_label();
2983 CachedManifestReport {
2984 version: None,
2985 issued_at_unix_secs: None,
2986 verified_by_key_id: None,
2987 compiled_trust_set_key_ids,
2988 version_error: Some(error.clone()),
2989 state: None,
2990 state_error: Some(error),
2991 diagnostics: vec![SelfReportDiagnostic::ManifestUnavailable.as_str()],
2992 diagnostic_guidance: None,
2993 }
2994 }
2995 Err(problem) => {
2996 let diagnostic_guidance = problem.untrusted_manifest_key_steering();
3000 match read_last_valid_manifest(paths) {
3001 Some(cache) => CachedManifestReport {
3002 version: Some(cache.manifest.manifest_version),
3003 issued_at_unix_secs: Some(cache.manifest.issued_at_unix_secs),
3004 verified_by_key_id: None,
3005 compiled_trust_set_key_ids,
3006 version_error: None,
3007 state: Some("regressed"),
3008 state_error: None,
3009 diagnostics: vec![
3010 SelfReportDiagnostic::ManifestRegressed.as_str(),
3011 problem.diagnostic().as_str(),
3012 ],
3013 diagnostic_guidance,
3014 },
3015 None => {
3016 let error = problem.status_label();
3017 CachedManifestReport {
3018 version: None,
3019 issued_at_unix_secs: None,
3020 verified_by_key_id: None,
3021 compiled_trust_set_key_ids,
3022 version_error: Some(error.clone()),
3023 state: None,
3024 state_error: Some(error),
3025 diagnostics: vec![problem.diagnostic().as_str()],
3026 diagnostic_guidance,
3027 }
3028 }
3029 }
3030 }
3031 }
3032}
3033
3034fn last_rung_report(paths: &StatePaths) -> LastRungReport {
3035 match fs::read(&paths.rung) {
3036 Ok(bytes) => match serde_json::from_slice::<RungRecord>(&bytes) {
3037 Ok(record) => LastRungReport {
3038 rung: Some(record.rung.label()),
3039 rung_error: None,
3040 as_of_unix_secs: Some(record.as_of_unix_secs),
3041 as_of_unix_secs_error: None,
3042 determination_inputs: Some(record.inputs),
3043 determination_inputs_error: None,
3044 recorded_by_image_path: Some(
3045 record
3046 .recorded_by_image_path
3047 .unwrap_or_else(|| PRE_PROVENANCE_RECORD.to_string()),
3048 ),
3049 recorded_by_version: Some(
3050 record
3051 .recorded_by_version
3052 .unwrap_or_else(|| PRE_PROVENANCE_RECORD.to_string()),
3053 ),
3054 recorded_by_repo_key: Some(
3055 record
3056 .recorded_by_repo_key
3057 .unwrap_or_else(|| PRE_PROVENANCE_RECORD.to_string()),
3058 ),
3059 },
3060 Err(error) => unavailable_last_rung(format!("corrupt rung cache: {error}")),
3061 },
3062 Err(error) if error.kind() == io::ErrorKind::NotFound => {
3063 unavailable_last_rung("rung cache is unavailable".to_string())
3064 }
3065 Err(error) => unavailable_last_rung(format!("rung cache is unavailable: {error}")),
3066 }
3067}
3068
3069fn unavailable_last_rung(error: String) -> LastRungReport {
3070 LastRungReport {
3071 rung: None,
3072 rung_error: Some(error.clone()),
3073 as_of_unix_secs: None,
3074 as_of_unix_secs_error: Some(error.clone()),
3075 determination_inputs: None,
3076 determination_inputs_error: Some(error),
3077 recorded_by_image_path: None,
3078 recorded_by_version: None,
3079 recorded_by_repo_key: None,
3080 }
3081}
3082
3083fn read_bypass_audit(paths: &StatePaths) -> (Option<Vec<Value>>, Option<String>) {
3084 let contents = match fs::read_to_string(&paths.bypass_audit) {
3085 Ok(contents) => contents,
3086 Err(error) if error.kind() == io::ErrorKind::NotFound => return (Some(Vec::new()), None),
3087 Err(error) => return (None, Some(format!("bypass audit is unavailable: {error}"))),
3088 };
3089 let mut records = Vec::new();
3090 for (line_number, line) in contents.lines().enumerate() {
3091 match serde_json::from_str(line) {
3092 Ok(record) => records.push(record),
3093 Err(error) => {
3094 return (
3095 None,
3096 Some(format!(
3097 "bypass audit is corrupt at line {}: {error}",
3098 line_number + 1
3099 )),
3100 )
3101 }
3102 }
3103 }
3104 (Some(records), None)
3105}
3106
3107fn unexpected_gh_route_advertisers(paths: &StatePaths) -> Option<Vec<String>> {
3108 serde_json::from_slice(&fs::read(&paths.unexpected_gh_route_advertisers).ok()?)
3109 .ok()
3110 .filter(|advertisers: &Vec<String>| !advertisers.is_empty())
3111}
3112
3113fn record_unexpected_gh_route_advertisers(paths: &StatePaths, advertisers: &[String]) {
3114 if advertisers.is_empty() {
3115 return;
3116 }
3117 let mut recorded = unexpected_gh_route_advertisers(paths)
3118 .unwrap_or_default()
3119 .into_iter()
3120 .collect::<BTreeSet<_>>();
3121 recorded.extend(advertisers.iter().cloned());
3122 let Ok(bytes) = serde_json::to_vec(&recorded.into_iter().collect::<Vec<_>>()) else {
3123 return;
3124 };
3125 let _ = fs::create_dir_all(&paths.root);
3126 let temporary = paths.unexpected_gh_route_advertisers.with_extension("tmp");
3127 if fs::write(&temporary, bytes).is_ok() {
3128 let _ = fs::rename(temporary, &paths.unexpected_gh_route_advertisers);
3129 }
3130}
3131
3132fn self_report_executing_image() -> Result<PathBuf, String> {
3133 let path = std::env::current_exe().map_err(|error| error.to_string())?;
3134 Ok(path.canonicalize().unwrap_or(path))
3135}
3136
3137fn executing_image() -> PathBuf {
3138 std::env::current_exe()
3139 .ok()
3140 .and_then(|path| path.canonicalize().ok().or(Some(path)))
3141 .unwrap_or_else(|| PathBuf::from("unavailable"))
3142}
3143
3144fn executing_image_path_positions(image: &Path) -> Vec<usize> {
3145 let path = std::env::var_os("PATH").unwrap_or_default();
3146 std::env::split_paths(&path)
3147 .enumerate()
3148 .filter_map(|(index, directory)| same_image(&directory.join("gh"), image).then_some(index))
3149 .collect()
3150}
3151
3152fn delegate(args: &[OsString]) -> i32 {
3153 let image = executing_image();
3154 let Some(real_gh) = resolve_real_gh(&image) else {
3155 return refuse(
3156 RefusalCode::NoRealGh,
3157 "PATH contains no upstream gh after skipping the executing shim image",
3158 );
3159 };
3160 exec_real_gh(real_gh, args)
3161}
3162
3163fn resolve_real_gh(executing_image: &Path) -> Option<PathBuf> {
3164 let path = std::env::var_os("PATH")?;
3165 let shims_dir = std::env::var_os("AFT_GH_SHIMS_DIR").map(PathBuf::from);
3166 resolve_real_gh_in_path(executing_image, &path, shims_dir.as_deref())
3167}
3168
3169fn resolve_real_gh_in_path(
3170 executing_image: &Path,
3171 path: &OsStr,
3172 shims_dir: Option<&Path>,
3173) -> Option<PathBuf> {
3174 std::env::split_paths(path).find_map(|directory| {
3175 if shims_dir.is_some_and(|shims_dir| same_directory(&directory, shims_dir)) {
3176 return None;
3177 }
3178 gh_candidate_names().iter().find_map(|name| {
3179 let candidate = directory.join(name);
3180 (is_executable_file(&candidate) && !same_image(&candidate, executing_image))
3181 .then_some(candidate)
3182 })
3183 })
3184}
3185
3186#[cfg(windows)]
3187fn gh_candidate_names() -> &'static [&'static str] {
3188 &["gh.exe", "gh.cmd", "gh.bat", "gh"]
3189}
3190
3191#[cfg(not(windows))]
3192fn gh_candidate_names() -> &'static [&'static str] {
3193 &["gh"]
3194}
3195
3196fn same_directory(left: &Path, right: &Path) -> bool {
3197 left == right
3198 || left
3199 .canonicalize()
3200 .ok()
3201 .zip(right.canonicalize().ok())
3202 .is_some_and(|(left, right)| left == right)
3203}
3204
3205fn is_executable_file(path: &Path) -> bool {
3206 if !path.is_file() {
3207 return false;
3208 }
3209 #[cfg(unix)]
3210 {
3211 use std::os::unix::fs::PermissionsExt;
3212 return fs::metadata(path).is_ok_and(|metadata| metadata.permissions().mode() & 0o111 != 0);
3213 }
3214 #[cfg(not(unix))]
3215 true
3216}
3217
3218fn same_image(left: &Path, right: &Path) -> bool {
3219 let left_canonical = left.canonicalize().ok();
3220 let right_canonical = right.canonicalize().ok();
3221 if left_canonical.is_some() && left_canonical == right_canonical {
3222 return true;
3223 }
3224 #[cfg(unix)]
3225 {
3226 use std::os::unix::fs::MetadataExt;
3227 if let (Ok(left), Ok(right)) = (fs::metadata(left), fs::metadata(right)) {
3228 return left.dev() == right.dev() && left.ino() == right.ino();
3229 }
3230 }
3231 false
3232}
3233
3234#[cfg(unix)]
3235fn exec_real_gh(real_gh: PathBuf, args: &[OsString]) -> i32 {
3236 use std::os::unix::process::CommandExt;
3237 let error = Command::new(real_gh).args(args).exec();
3238 refuse(
3242 RefusalCode::NoRealGh,
3243 &format!("unable to exec upstream gh: {error}"),
3244 )
3245}
3246
3247#[cfg(not(unix))]
3248fn exec_real_gh(real_gh: PathBuf, args: &[OsString]) -> i32 {
3249 match Command::new(real_gh).args(args).status() {
3250 Ok(status) => status.code().unwrap_or(1),
3251 Err(error) => refuse(
3252 RefusalCode::NoRealGh,
3253 &format!("unable to exec upstream gh: {error}"),
3254 ),
3255 }
3256}
3257
3258fn refuse(code: RefusalCode, text: &str) -> i32 {
3259 let text = text.replace(['\n', '\r'], " ");
3260 eprintln!("gh-shim: {}: {text}", code.as_str());
3261 REFUSAL_EXIT_STATUS
3262}
3263
3264fn current_platform() -> &'static str {
3265 if cfg!(target_os = "macos") {
3266 "macos"
3267 } else if cfg!(target_os = "linux") {
3268 "linux"
3269 } else {
3270 "unsupported"
3271 }
3272}
3273
3274fn unix_seconds() -> u64 {
3275 SystemTime::now()
3276 .duration_since(UNIX_EPOCH)
3277 .unwrap_or_default()
3278 .as_secs()
3279}
3280
3281#[cfg(test)]
3282mod tests {
3283 use super::*;
3284 use ring::signature::{Ed25519KeyPair, KeyPair};
3285 use sha2::{Digest, Sha256};
3286
3287 const TEST_SEED: [u8; 32] = [
3288 0x9d, 0x61, 0xb1, 0x9d, 0xef, 0xfd, 0x5a, 0x60, 0xba, 0x84, 0x4a, 0xf4, 0x92, 0xec, 0x2c,
3289 0xc4, 0x44, 0x49, 0xc5, 0x69, 0x7b, 0x32, 0x69, 0x19, 0x70, 0x3b, 0xac, 0x03, 0x1c, 0xae,
3290 0x7f, 0x60,
3291 ];
3292 const STANDBY_TEST_SEED: [u8; 32] = *b"gh-shim-standby-fixture-seed-001";
3297 const DEV_STANDBY_MANIFEST_KEY_ID: &str = "gh-routing-dev-standby-key-v1";
3298 const FIXTURE_ISSUED_AT: u64 = 1_787_184_000;
3301 const TEST_NOW: u64 = FIXTURE_ISSUED_AT + 60;
3302 const FIXTURE_ACCEPTED_SEAM_REFUSAL_CODES: &[&str] = &[
3303 "identity_mismatch",
3304 "unmapped_operation",
3305 "custody_unavailable",
3306 "schema_unsupported",
3307 "rate_limited",
3308 ];
3309
3310 fn fixture_manifest() -> Manifest {
3311 serde_json::from_str(include_str!(
3312 "../tests/fixtures/gh_shim/initial-manifest-v1.json"
3313 ))
3314 .expect("initial manifest fixture")
3315 }
3316
3317 fn v9_fixture_manifest() -> Manifest {
3318 serde_json::from_str(include_str!("../tests/fixtures/gh_shim/v9-manifest.json"))
3319 .expect("v9 manifest fixture")
3320 }
3321
3322 fn v10_fixture_manifest() -> Manifest {
3323 serde_json::from_str(include_str!("../tests/fixtures/gh_shim/v10-manifest.json"))
3324 .expect("v10 manifest fixture")
3325 }
3326
3327 fn edit_last_vectors_fixture() -> Value {
3328 let fixture = include_str!("../tests/fixtures/gh_shim/edit-last-vectors-v1.json");
3331 let json = fixture
3332 .lines()
3333 .filter(|line| !line.starts_with("//"))
3334 .collect::<Vec<_>>()
3335 .join("\n");
3336 serde_json::from_str(&json).expect("producer edit-last vectors fixture")
3337 }
3338
3339 fn signed_with(
3340 manifest: &Manifest,
3341 fetched_at_unix_secs: u64,
3342 seed: &[u8; 32],
3343 key_id: &str,
3344 ) -> SignedManifest {
3345 let key = Ed25519KeyPair::from_seed_unchecked(seed).expect("test key");
3346 let bytes = serde_json::to_vec(manifest).expect("manifest bytes");
3347 SignedManifest {
3348 artifact_id: MANIFEST_ARTIFACT_ID.to_string(),
3349 envelope_version: ENVELOPE_VERSION,
3350 key_id: key_id.to_string(),
3351 fetched_at_unix_secs,
3352 signature: base64::engine::general_purpose::STANDARD.encode(key.sign(&bytes).as_ref()),
3353 manifest_bytes: String::from_utf8(bytes).expect("manifest bytes are UTF-8"),
3354 }
3355 }
3356
3357 fn signed(manifest: &Manifest, fetched_at_unix_secs: u64) -> SignedManifest {
3358 let key = Ed25519KeyPair::from_seed_unchecked(&TEST_SEED).expect("test key");
3359 assert_eq!(key.public_key().as_ref(), DEV_MANIFEST_PUBLIC_KEY);
3360 signed_with(
3361 manifest,
3362 fetched_at_unix_secs,
3363 &TEST_SEED,
3364 DEV_MANIFEST_KEY_ID,
3365 )
3366 }
3367
3368 fn write_signed_manifest(paths: &StatePaths, manifest: Manifest, now: u64) {
3369 fs::create_dir_all(&paths.root).expect("state root");
3370 fs::write(
3371 &paths.manifest,
3372 serde_json::to_vec(&signed(&manifest, now)).expect("signed manifest"),
3373 )
3374 .expect("manifest cache");
3375 }
3376
3377 fn write_envelope_fixture(paths: &StatePaths, envelope_json: &str) {
3378 fs::create_dir_all(&paths.root).expect("state root");
3379 fs::write(&paths.manifest, envelope_json.as_bytes()).expect("manifest cache");
3380 }
3381
3382 fn test_rung_provenance() -> RungRecordProvenance {
3383 RungRecordProvenance {
3384 image_path: "/opt/cortexkit/aft-gh-shim".to_string(),
3385 version: "0.53.0-test".to_string(),
3386 repo_key: "cortexkit/aft".to_string(),
3387 }
3388 }
3389
3390 #[test]
3391 fn shim_dispatch_precedes_global_argument_scans_for_both_forms() {
3392 assert!(is_shim_invocation(
3393 OsStr::new("gh"),
3394 &[OsString::from("--version")]
3395 ));
3396 assert!(is_shim_invocation(
3397 OsStr::new("aft"),
3398 &[OsString::from("gh-shim"), OsString::from("--version")]
3399 ));
3400 assert!(!is_shim_invocation(
3401 OsStr::new("aft"),
3402 &[OsString::from("--version")]
3403 ));
3404 }
3405
3406 #[test]
3407 fn reserved_self_report_tokens_are_exactly_the_two_first_arguments() {
3408 assert_eq!(RESERVED_SELF_REPORT, ["--status", "--shim-version"]);
3409 assert!(is_reserved_self_report(&[OsString::from("--status")]));
3410 assert!(is_reserved_self_report(&[OsString::from("--shim-version")]));
3411 assert!(!is_reserved_self_report(&[OsString::from("status")]));
3412 assert!(!is_reserved_self_report(&[
3413 OsString::from("issue"),
3414 OsString::from("--status")
3415 ]));
3416 }
3417
3418 #[cfg(unix)]
3419 #[test]
3420 fn real_gh_resolution_skips_the_managed_shims_directory_without_recursing() {
3421 use std::os::unix::fs::{symlink, PermissionsExt};
3422
3423 let directory = tempfile::tempdir().unwrap();
3424 let image = directory.path().join("aft");
3425 fs::write(&image, "image").unwrap();
3426 let shims = directory.path().join("shims");
3427 let upstream = directory.path().join("upstream");
3428 fs::create_dir_all(&shims).unwrap();
3429 fs::create_dir_all(&upstream).unwrap();
3430 symlink(&image, shims.join("gh")).unwrap();
3431 let real = upstream.join("gh");
3432 fs::write(&real, "#!/bin/sh\nexit 0\n").unwrap();
3433 let mut permissions = fs::metadata(&real).unwrap().permissions();
3434 permissions.set_mode(0o755);
3435 fs::set_permissions(&real, permissions).unwrap();
3436 let path = std::env::join_paths([shims.clone(), upstream]).unwrap();
3437
3438 assert_eq!(
3439 resolve_real_gh_in_path(&image, &path, Some(&shims)),
3440 Some(real)
3441 );
3442 }
3443
3444 #[test]
3445 fn status_serializes_one_json_document_with_the_exact_top_level_schema() {
3446 let directory = tempfile::tempdir().unwrap();
3447 let paths = StatePaths::from_root(directory.path().to_path_buf());
3448 let document = render_self_report(&paths).expect("self report serialization");
3449 assert!(document.ends_with('\n'));
3450 let value: Value = serde_json::from_str(&document).expect("self report JSON");
3451 let keys = value
3452 .as_object()
3453 .expect("self report object")
3454 .keys()
3455 .cloned()
3456 .collect::<Vec<_>>();
3457 assert_eq!(
3458 keys,
3459 vec![
3460 "shim_version",
3461 "gh_routing_schema_floor",
3462 "unexpected_gh_route_advertiser",
3463 "bound_holder",
3464 "agent_binding",
3465 "last_seam_refusal",
3466 "cached_manifest",
3467 "last_rung",
3468 "bypass_audit",
3469 "bypass_audit_error",
3470 "executing_image",
3471 "executing_image_error",
3472 "real_gh_resolution",
3473 "real_gh_resolution_error",
3474 ]
3475 );
3476 }
3477
3478 #[test]
3479 fn route_holder_is_pinned_and_records_other_advertisers() {
3480 let holder = select_route_holder([
3481 "other-module".to_string(),
3482 ROUTING_HOLDER_MODULE_ID.to_string(),
3483 "another-module".to_string(),
3484 ]);
3485 assert_eq!(holder.module_id.as_deref(), Some(ROUTING_HOLDER_MODULE_ID));
3486 assert_eq!(
3487 holder.unexpected_advertisers,
3488 vec!["another-module", "other-module"]
3489 );
3490
3491 let holder = select_route_holder(["other-module".to_string()]);
3492 assert_eq!(holder.module_id, None);
3493 assert_eq!(holder.unexpected_advertisers, vec!["other-module"]);
3494 }
3495
3496 #[test]
3497 fn unexpected_route_advertisers_are_persisted_for_self_report() {
3498 let directory = tempfile::tempdir().unwrap();
3499 let paths = StatePaths::from_root(directory.path().to_path_buf());
3500 record_unexpected_gh_route_advertisers(&paths, &["other-module".to_string()]);
3501 record_unexpected_gh_route_advertisers(&paths, &["another-module".to_string()]);
3502
3503 assert_eq!(
3504 unexpected_gh_route_advertisers(&paths),
3505 Some(vec![
3506 "another-module".to_string(),
3507 "other-module".to_string(),
3508 ])
3509 );
3510 assert_eq!(
3511 build_self_report(&paths).unexpected_gh_route_advertiser,
3512 Some(vec![
3513 "another-module".to_string(),
3514 "other-module".to_string(),
3515 ])
3516 );
3517 }
3518
3519 #[test]
3520 fn disabled_by_config_short_circuits_to_r1_without_connection_file_read() {
3521 let directory = tempfile::tempdir().unwrap();
3522 let paths = StatePaths::from_root(directory.path().to_path_buf());
3523 let doc = serde_json::json!({
3526 "gh_shim": { "enabled": false },
3527 "subc": { "connection_file": "/nonexistent/connection.json" }
3528 })
3529 .to_string();
3530 let record = determine_rung_from_doc(
3531 &paths,
3532 Path::new("/cwd"),
3533 123,
3534 std::time::Instant::now() + DISCOVERY_BUDGET,
3535 Some(&doc),
3536 );
3537 assert_eq!(record.record.rung, Rung::R1);
3538 assert_eq!(
3539 record
3540 .record
3541 .inputs
3542 .get("connection_file")
3543 .map(String::as_str),
3544 Some("disabled_by_config")
3545 );
3546 assert!(!paths.root.join("rung-cache.json").exists());
3548 }
3549
3550 #[test]
3551 fn configured_but_unreachable_connection_file_is_distinct_from_absence() {
3552 let directory = tempfile::tempdir().unwrap();
3553 let paths = StatePaths::from_root(directory.path().to_path_buf());
3554 let connection_file = directory.path().join("missing-connection.json");
3555 let doc = serde_json::json!({
3556 "subc": { "connection_file": connection_file }
3557 })
3558 .to_string();
3559 let record = determine_rung_from_doc(
3560 &paths,
3561 Path::new("/cwd"),
3562 1,
3563 std::time::Instant::now() + DISCOVERY_BUDGET,
3564 Some(&doc),
3565 );
3566 assert_eq!(record.record.rung, Rung::R1);
3567 assert_eq!(
3568 record
3569 .record
3570 .inputs
3571 .get("connection_file")
3572 .map(String::as_str),
3573 Some("unreachable")
3574 );
3575 }
3576
3577 #[test]
3578 fn enabled_default_keeps_structural_rungs() {
3579 let directory = tempfile::tempdir().unwrap();
3580 let paths = StatePaths::from_root(directory.path().to_path_buf());
3581 let record = determine_rung_from_doc(
3583 &paths,
3584 Path::new("/cwd"),
3585 1,
3586 std::time::Instant::now() + DISCOVERY_BUDGET,
3587 Some("{}"),
3588 );
3589 assert_eq!(record.record.rung, Rung::R1);
3590 assert_eq!(
3591 record
3592 .record
3593 .inputs
3594 .get("connection_file")
3595 .map(String::as_str),
3596 Some("absent_or_unparseable")
3597 );
3598 }
3599
3600 #[test]
3601 fn xdg_connection_config_precedes_home_config() {
3602 let directory = tempfile::tempdir().unwrap();
3603 let xdg = directory.path().join("xdg");
3604 let home = directory.path().join("home");
3605 let xdg_connection = directory.path().join("xdg-connection.json");
3606 let home_connection = directory.path().join("home-connection.json");
3607 fs::write(&xdg_connection, "{}").unwrap();
3608 fs::write(&home_connection, "{}").unwrap();
3609 let xdg_config = xdg.join("cortexkit/aft.jsonc");
3610 let home_config = home.join(".config/cortexkit/aft.jsonc");
3611 fs::create_dir_all(xdg_config.parent().unwrap()).unwrap();
3612 fs::create_dir_all(home_config.parent().unwrap()).unwrap();
3613 fs::write(
3617 &xdg_config,
3618 serde_json::json!({"subc": {"connection_file": xdg_connection}}).to_string(),
3619 )
3620 .unwrap();
3621 fs::write(
3622 &home_config,
3623 serde_json::json!({"subc": {"connection_file": home_connection}}).to_string(),
3624 )
3625 .unwrap();
3626
3627 assert_eq!(
3628 configured_connection_file_from(Some(xdg.as_os_str()), Some(home.as_os_str())),
3629 Some(xdg_connection)
3630 );
3631 }
3632
3633 #[test]
3634 fn initial_manifest_is_complete_and_valid() {
3635 fixture_manifest()
3636 .validate()
3637 .expect("valid initial manifest");
3638 }
3639
3640 #[test]
3641 fn v9_admin_tuple_fixture_differentiates_native_writes_from_raw_api_delete() {
3642 let manifest = v9_fixture_manifest();
3643 assert_eq!(manifest.manifest_version, 9);
3644 manifest.validate().expect("valid v9 manifest");
3645
3646 for (args, expected_tuple) in [
3647 (
3648 vec![
3649 OsString::from("repo"),
3650 OsString::from("edit"),
3651 OsString::from("cortexkit/insula"),
3652 OsString::from("--visibility"),
3653 OsString::from("public"),
3654 ],
3655 "repo edit",
3656 ),
3657 (
3658 vec![
3659 OsString::from("repo"),
3660 OsString::from("edit"),
3661 OsString::from("cortexkit/insula"),
3662 OsString::from("--visibility"),
3663 OsString::from("private"),
3664 ],
3665 "repo edit",
3666 ),
3667 (
3668 vec![
3669 OsString::from("run"),
3670 OsString::from("delete"),
3671 OsString::from("123"),
3672 OsString::from("--repo"),
3673 OsString::from("cortexkit/insula"),
3674 ],
3675 "run delete",
3676 ),
3677 ] {
3678 assert!(matches!(
3679 classify(&args, &manifest, "macos"),
3680 Classification::Admin { tuple } if tuple == expected_tuple
3681 ));
3682 }
3683
3684 let raw_api_delete = [
3685 OsString::from("api"),
3686 OsString::from("-X"),
3687 OsString::from("DELETE"),
3688 OsString::from("repos/cortexkit/insula/actions/runs/123"),
3689 ];
3690 assert!(matches!(
3691 classify(&raw_api_delete, &manifest, "macos"),
3692 Classification::Unclassified
3693 ));
3694
3695 let get_control = [
3696 OsString::from("api"),
3697 OsString::from("repos/cortexkit/insula"),
3698 OsString::from("--jq"),
3699 OsString::from(".name"),
3700 ];
3701 assert!(matches!(
3702 classify(&get_control, &manifest, "macos"),
3703 Classification::Mechanical
3704 ));
3705 }
3706
3707 #[test]
3708 fn v10_workflow_run_admin_tuple_is_version_gated_and_raw_dispatch_stays_unclassified() {
3709 let manifest = v10_fixture_manifest();
3710 assert_eq!(manifest.manifest_version, 10);
3711 manifest.validate().expect("valid v10 manifest");
3712
3713 let workflow_run = [
3714 OsString::from("workflow"),
3715 OsString::from("run"),
3716 OsString::from("ci.yml"),
3717 OsString::from("--ref"),
3718 OsString::from("main"),
3719 ];
3720 assert!(matches!(
3721 classify(&workflow_run, &manifest, "macos"),
3722 Classification::Admin { tuple } if tuple == "workflow run"
3723 ));
3724
3725 let mut v9_manifest = manifest.clone();
3729 v9_manifest.manifest_version = 9;
3730 assert!(matches!(
3731 classify(&workflow_run, &v9_manifest, "macos"),
3732 Classification::Unclassified
3733 ));
3734
3735 let raw_api_dispatch = [
3736 OsString::from("api"),
3737 OsString::from("-X"),
3738 OsString::from("POST"),
3739 OsString::from("repos/cortexkit/aft/actions/workflows/ci.yml/dispatches"),
3740 ];
3741 for manifest in [&manifest, &v9_manifest] {
3742 assert!(matches!(
3743 classify(&raw_api_dispatch, manifest, "macos"),
3744 Classification::Unclassified
3745 ));
3746 }
3747 }
3748
3749 #[test]
3750 fn v10_edit_last_comment_variants_are_exactly_governed_and_author_scoped() {
3751 let manifest = v10_fixture_manifest();
3752 manifest.validate().expect("valid v10 manifest");
3753
3754 for (verb, number) in [("issue", "42"), ("pr", "7")] {
3755 let args = [
3756 OsString::from(verb),
3757 OsString::from("comment"),
3758 OsString::from(number),
3759 OsString::from("--body"),
3760 OsString::from("replace the draft"),
3761 OsString::from("--edit-last"),
3762 ];
3763 let Classification::Governed { tuple, canonical } = classify(&args, &manifest, "macos")
3764 else {
3765 panic!("native edit-last should use the governed comment tuple: {args:?}");
3766 };
3767 assert_eq!(tuple, format!("{verb} comment"));
3768
3769 let request =
3770 canonicalize_governed(&args, &tuple, &canonical, manifest.manifest_version)
3771 .expect("reviewed edit-last form should canonicalize");
3772 assert!(request.edit_last);
3773 assert_eq!(request.target["number"], number);
3774 assert_eq!(request.body["body"], "replace the draft");
3775
3776 let wire = governed_wire_request(
3777 &(RungDetermination::r3(1, manifest.manifest_version, &test_rung_provenance())
3778 .record),
3779 "alfonso-aft",
3780 request,
3781 );
3782 assert_eq!(wire["edit_last"], true);
3783 }
3784
3785 let bare_create = [
3786 OsString::from("issue"),
3787 OsString::from("comment"),
3788 OsString::from("42"),
3789 OsString::from("--body"),
3790 OsString::from("new comment"),
3791 ];
3792 let Classification::Governed { tuple, canonical } =
3793 classify(&bare_create, &manifest, "macos")
3794 else {
3795 panic!("bare comment creation must remain governed");
3796 };
3797 let request =
3798 canonicalize_governed(&bare_create, &tuple, &canonical, manifest.manifest_version)
3799 .expect("bare comment creation should remain canonicalizable");
3800 assert!(!request.edit_last);
3801 let wire = governed_wire_request(
3802 &(RungDetermination::r3(1, manifest.manifest_version, &test_rung_provenance()).record),
3803 "alfonso-aft",
3804 request,
3805 );
3806 assert!(wire.get("edit_last").is_none());
3807
3808 let mut v9_manifest = manifest.clone();
3812 v9_manifest.manifest_version = 9;
3813 let v9_edit = [
3814 OsString::from("pr"),
3815 OsString::from("comment"),
3816 OsString::from("7"),
3817 OsString::from("--body"),
3818 OsString::from("replace the draft"),
3819 OsString::from("--edit-last"),
3820 ];
3821 assert!(matches!(
3822 classify(&v9_edit, &v9_manifest, "macos"),
3823 Classification::Unclassified
3824 ));
3825
3826 let delete_last = [
3830 OsString::from("pr"),
3831 OsString::from("comment"),
3832 OsString::from("7"),
3833 OsString::from("--body"),
3834 OsString::from("replace the draft"),
3835 OsString::from("--delete-last"),
3836 ];
3837 assert!(matches!(
3838 classify(&delete_last, &manifest, "macos"),
3839 Classification::Unclassified
3840 ));
3841
3842 let reaction_edit = [
3846 OsString::from("issue"),
3847 OsString::from("reaction"),
3848 OsString::from("42"),
3849 OsString::from("--reaction"),
3850 OsString::from("+1"),
3851 OsString::from("--edit-last"),
3852 ];
3853 assert!(matches!(
3854 classify(&reaction_edit, &manifest, "macos"),
3855 Classification::Unclassified
3856 ));
3857 }
3858
3859 #[test]
3860 fn producer_edit_last_vectors_pin_consumer_wire_request_and_refusals() {
3861 const EXPECTED_SHA256: &str =
3862 "cd22bb4de80b5c44b500d75220f03d3b0908f0e67101842de0c29c86b1e9b9e0";
3863 let fixture_bytes = include_bytes!("../tests/fixtures/gh_shim/edit-last-vectors-v1.json");
3864 assert_eq!(
3865 format!("{:x}", Sha256::digest(fixture_bytes)),
3866 EXPECTED_SHA256,
3867 "producer edit-last vectors changed; re-pin by copying the fixture from repo CortexKit/prefrontal at commit 0b1dea6b, then update this consumer fixture and digest"
3868 );
3869
3870 let vectors = edit_last_vectors_fixture();
3871 let vector_case = |name: &str| {
3872 vectors["cases"]
3873 .as_array()
3874 .expect("producer vector cases")
3875 .iter()
3876 .find(|case| case["name"] == name)
3877 .unwrap_or_else(|| panic!("producer vector case {name} is missing"))
3878 };
3879 let happy_request = vector_case("edit_last_happy")["request"].clone();
3880 let happy_body_fields = happy_request["body"]
3881 .as_object()
3882 .expect("producer happy request body")
3883 .keys()
3884 .cloned()
3885 .collect::<Vec<_>>();
3886 assert!(vector_case("absent_edit_last_create")["request"]
3887 .get("edit_last")
3888 .is_none());
3889
3890 let manifest = v10_fixture_manifest();
3891 let args = [
3892 OsString::from("pr"),
3893 OsString::from("comment"),
3894 OsString::from("372"),
3895 OsString::from("--edit-last"),
3896 OsString::from("--body-file"),
3897 OsString::from("-"),
3898 ];
3899 let Classification::Governed { tuple, canonical } = classify(&args, &manifest, "macos")
3900 else {
3901 panic!("the native edit-last command must remain governed");
3902 };
3903 assert_eq!(tuple, "pr comment");
3904 let request = canonicalize_governed(&args, &tuple, &canonical, manifest.manifest_version)
3905 .expect("native edit-last command should canonicalize");
3906 let determination =
3907 RungDetermination::r3(1, manifest.manifest_version, &test_rung_provenance());
3908 let wire = governed_wire_request(&determination.record, "consumer-agent", request);
3909
3910 let mut expected = happy_request;
3913 expected["action"] = json!("pr comment");
3914 expected["target"] = json!({"number": "372"});
3915 expected["body"] = wire["body"].clone();
3916 expected["manifest_version"] = json!(manifest.manifest_version);
3917 expected["rung_as_of_unix_secs"] = json!(determination.record.as_of_unix_secs);
3918 expected["metadata"]["pid"] = json!(std::process::id());
3919 expected["metadata"]
3920 .as_object_mut()
3921 .expect("expected metadata object")
3922 .remove("agent_id");
3923 let mut actual = wire;
3924 actual["metadata"]
3925 .as_object_mut()
3926 .expect("actual metadata object")
3927 .remove("agent_id");
3928 assert_eq!(
3929 actual["body"]
3930 .as_object()
3931 .expect("actual request body")
3932 .keys()
3933 .cloned()
3934 .collect::<Vec<_>>(),
3935 happy_body_fields,
3936 "consumer body fields drifted from producer shape"
3937 );
3938 assert_eq!(
3939 actual, expected,
3940 "consumer request drifted from producer shape"
3941 );
3942 assert_eq!(
3943 actual["edit_last"], true,
3944 "edit_last marker must be present"
3945 );
3946
3947 for case_name in ["edit_last_no_own_comment", "edit_last_unsupported_action"] {
3948 let code = vector_case(case_name)["response"]["refusal_code"]
3949 .as_str()
3950 .expect("producer refusal code");
3951 let response = json!({"outcome": "refusal", "refusal_code": code});
3952 let outcome = parse_governed_response(&serde_json::to_vec(&response).unwrap())
3953 .expect("producer refusal should parse");
3954 assert!(matches!(outcome, RouteOutcome::Refusal(ref actual) if actual == code));
3955 assert_eq!(
3956 RefusalCode::SeamRefusal.as_str(),
3957 "gh_shim_seam_refusal",
3958 "open-world holder refusal codes must use the seam refusal classification"
3959 );
3960 assert_eq!(
3961 seam_refusal_text(code),
3962 format!("governance seam refused the action: {code}"),
3963 "holder refusal code must pass through without remapping"
3964 );
3965 }
3966 }
3967
3968 #[test]
3969 fn manifest_rejects_duplicate_tiers_and_empty_api_rationales() {
3970 let mut duplicate = fixture_manifest();
3971 duplicate
3972 .tiers
3973 .get_mut(&Tier::Admin)
3974 .unwrap()
3975 .push(TupleDecl::Details {
3976 tuple: "issue comment".to_string(),
3977 platform: vec!["macos".to_string()],
3978 api_match: None,
3979 rationale: None,
3980 });
3981 assert!(duplicate.validate().unwrap_err().contains("both"));
3982
3983 let mut empty_api = fixture_manifest();
3984 empty_api
3985 .tiers
3986 .get_mut(&Tier::Admin)
3987 .unwrap()
3988 .push(TupleDecl::Details {
3989 tuple: "api patch close".to_string(),
3990 platform: vec!["macos".to_string()],
3991 api_match: Some(String::new()),
3992 rationale: None,
3993 });
3994 assert!(empty_api.validate().unwrap_err().contains("rationale"));
3995
3996 let mut malformed_binding = fixture_manifest();
3997 malformed_binding.bindings.insert(
3998 "https://github.com/cortexkit/aft.git".to_string(),
3999 "alfonso-aft".to_string(),
4000 );
4001 assert!(malformed_binding
4002 .validate()
4003 .unwrap_err()
4004 .contains("canonical owner/name"));
4005 }
4006
4007 #[test]
4008 fn binding_keys_and_governed_session_identity_are_stable() {
4009 assert_eq!(
4010 canonical_repository_key("https://github.com/CortexKit/aft.git"),
4011 Some("cortexkit/aft".to_string())
4012 );
4013 assert_eq!(
4014 canonical_repository_key("git@github.com:cortexkit/aft.git"),
4015 Some("cortexkit/aft".to_string())
4016 );
4017 assert_eq!(gh_session_id("alfonso-aft"), "gh-shim:alfonso-aft");
4018
4019 let request = GovernedRequest {
4020 action: "issue comment".to_string(),
4021 target: Map::new(),
4022 body: Map::new(),
4023 repository: Some("cortexkit/aft".to_string()),
4024 manifest_version: 1,
4025 edit_last: false,
4026 };
4027 let determination = RungDetermination::r3(7, 1, &test_rung_provenance());
4028 let wire = governed_wire_request(&determination.record, "alfonso-aft", request);
4029 assert_eq!(wire["metadata"]["agent_id"], "alfonso-aft");
4030 assert_eq!(wire["metadata"]["pid"], std::process::id());
4031 }
4032
4033 #[test]
4034 fn manifest_rejects_repo_sections_that_add_or_lower_a_tuple() {
4035 let mut manifest = fixture_manifest();
4036 manifest.repository_sections.insert(
4037 "owner/repo".to_string(),
4038 RepositorySection {
4039 tiers: BTreeMap::from([(
4040 Tier::Mechanical,
4041 vec![TupleDecl::Details {
4042 tuple: "issue comment".to_string(),
4043 platform: vec!["macos".to_string()],
4044 api_match: None,
4045 rationale: None,
4046 }],
4047 )]),
4048 removed_tuples: Vec::new(),
4049 },
4050 );
4051 assert!(manifest.validate().unwrap_err().contains("lowers"));
4052
4053 manifest.repository_sections.insert(
4054 "owner/repo".to_string(),
4055 RepositorySection {
4056 tiers: BTreeMap::from([(
4057 Tier::Admin,
4058 vec![TupleDecl::Details {
4059 tuple: "workflow dispatch".to_string(),
4060 platform: vec!["macos".to_string()],
4061 api_match: None,
4062 rationale: None,
4063 }],
4064 )]),
4065 removed_tuples: Vec::new(),
4066 },
4067 );
4068 assert!(manifest.validate().unwrap_err().contains("adds"));
4069 }
4070
4071 #[test]
4072 fn signed_cache_rejects_tampering_and_old_schema_floor() {
4073 let directory = tempfile::tempdir().unwrap();
4074 let paths = StatePaths::from_root(directory.path().to_path_buf());
4075 let now = TEST_NOW;
4076 write_signed_manifest(&paths, fixture_manifest(), now);
4077 assert_eq!(load_manifest(&paths, now).unwrap().manifest_version, 1);
4078
4079 let mut value: Value = serde_json::from_slice(&fs::read(&paths.manifest).unwrap()).unwrap();
4082 let tampered =
4083 value["manifest_bytes"]
4084 .as_str()
4085 .unwrap()
4086 .replacen("issue view", "issue View", 1);
4087 value["manifest_bytes"] = Value::String(tampered);
4088 fs::write(&paths.manifest, serde_json::to_vec(&value).unwrap()).unwrap();
4089 assert!(matches!(
4090 load_manifest(&paths, now),
4091 Err(ManifestProblem::Invalid(_))
4092 ));
4093 assert_eq!(
4096 cached_manifest_report_at(&paths, now).diagnostics,
4097 vec![
4098 SelfReportDiagnostic::ManifestRegressed.as_str(),
4099 SelfReportDiagnostic::ManifestInvalid.as_str(),
4100 ]
4101 );
4102
4103 let mut below_floor = fixture_manifest();
4104 below_floor.schema_floor = 0;
4105 write_signed_manifest(&paths, below_floor, now);
4106 assert!(matches!(
4107 load_manifest(&paths, now),
4108 Err(ManifestProblem::BelowFloor { manifest_floor: 0 })
4109 ));
4110 }
4111
4112 #[test]
4113 fn no_verb_and_help_invocations_are_mechanical_on_a_governed_manifest() {
4114 let manifest = fixture_manifest();
4115 for args in [
4116 Vec::new(),
4117 vec![OsString::from("--version")],
4118 vec![OsString::from("--help")],
4119 vec![OsString::from("-h")],
4120 vec![OsString::from("help"), OsString::from("pr")],
4121 ] {
4122 assert!(
4123 matches!(
4124 classify(&args, &manifest, "macos"),
4125 Classification::Mechanical
4126 ),
4127 "expected passthrough classification for {args:?}"
4128 );
4129 }
4130 }
4131
4132 #[test]
4133 fn unmapped_get_and_actions_reads_are_mechanical_but_writes_remain_unclassified() {
4134 let mut manifest = fixture_manifest();
4135 manifest.api_rules.clear();
4136
4137 for args in [
4138 vec![
4139 OsString::from("api"),
4140 OsString::from("/repos/cortexkit/aft/actions/runs"),
4141 ],
4142 vec![
4143 OsString::from("api"),
4144 OsString::from("--method"),
4145 OsString::from("GET"),
4146 OsString::from("/repos/cortexkit/aft/actions/runs"),
4147 ],
4148 vec![
4149 OsString::from("api"),
4150 OsString::from("-X"),
4151 OsString::from("GET"),
4152 OsString::from("/repos/cortexkit/aft/actions/runs"),
4153 ],
4154 vec![OsString::from("run"), OsString::from("view")],
4155 vec![OsString::from("run"), OsString::from("list")],
4156 vec![OsString::from("run"), OsString::from("watch")],
4157 vec![OsString::from("workflow"), OsString::from("view")],
4158 vec![OsString::from("workflow"), OsString::from("list")],
4159 ] {
4160 assert!(
4161 matches!(
4162 classify(&args, &manifest, "macos"),
4163 Classification::Mechanical
4164 ),
4165 "expected read passthrough classification for {args:?}"
4166 );
4167 }
4168
4169 for args in [
4170 vec![
4171 OsString::from("api"),
4172 OsString::from("-X"),
4173 OsString::from("POST"),
4174 OsString::from("/repos/cortexkit/aft/actions/runs"),
4175 ],
4176 vec![
4177 OsString::from("api"),
4178 OsString::from("-f"),
4179 OsString::from("key=value"),
4180 OsString::from("/repos/cortexkit/aft/actions/runs"),
4181 ],
4182 ] {
4183 assert!(
4184 matches!(
4185 classify(&args, &manifest, "macos"),
4186 Classification::Unclassified
4187 ),
4188 "expected fail-closed classification for {args:?}"
4189 );
4190 }
4191 }
4192
4193 #[test]
4194 fn classification_is_allowlist_driven_without_a_write_heuristic() {
4195 let manifest = fixture_manifest();
4196 assert!(matches!(
4197 classify(
4198 &[OsString::from("issue"), OsString::from("view")],
4199 &manifest,
4200 "macos"
4201 ),
4202 Classification::Mechanical
4203 ));
4204 assert!(matches!(
4205 classify(
4206 &[OsString::from("api"), OsString::from("/repos/a/b")],
4207 &manifest,
4208 "macos"
4209 ),
4210 Classification::Mechanical
4211 ));
4212 assert!(matches!(
4213 classify(
4214 &[
4215 OsString::from("api"),
4216 OsString::from("--method=POST"),
4217 OsString::from("/repos/a/b")
4218 ],
4219 &manifest,
4220 "macos"
4221 ),
4222 Classification::Unclassified
4223 ));
4224 assert!(matches!(
4225 classify(
4226 &[
4227 OsString::from("api"),
4228 OsString::from("--method"),
4229 OsString::from("POST"),
4230 OsString::from("/repos/a/b")
4231 ],
4232 &manifest,
4233 "macos"
4234 ),
4235 Classification::Unclassified
4236 ));
4237 assert!(matches!(
4238 classify(
4239 &[OsString::from("alias"), OsString::from("set")],
4240 &manifest,
4241 "macos"
4242 ),
4243 Classification::Unclassified
4244 ));
4245 assert!(matches!(
4246 classify(
4247 &[
4248 OsString::from("alias"),
4249 OsString::from("set"),
4250 OsString::from("--write")
4251 ],
4252 &manifest,
4253 "macos"
4254 ),
4255 Classification::Unclassified
4256 ));
4257 }
4258
4259 #[test]
4260 fn canonical_repository_key_parses_github_remotes_and_rejects_foreign_hosts() {
4261 for remote in [
4262 "https://github.com/CortexKit/Aft",
4263 "https://github.com/cortexkit/aft.git",
4264 "https://github.com/cortexkit/aft/",
4265 "https://github.com/cortexkit/aft.git/",
4266 "git@github.com:cortexkit/aft.git",
4267 "ssh://git@github.com/cortexkit/aft",
4268 "cortexkit/aft",
4269 ] {
4270 assert_eq!(
4271 canonical_repository_key(remote).as_deref(),
4272 Some("cortexkit/aft")
4273 );
4274 }
4275 for remote in [
4276 "https://gitlab.com/cortexkit/aft.git",
4277 "ssh://git@gitlab.com/cortexkit/aft",
4278 "git@gitlab.com:cortexkit/aft.git",
4279 ] {
4280 assert_eq!(canonical_repository_key(remote), None);
4281 }
4282 }
4283
4284 #[test]
4285 fn invalid_repository_argument_refuses_before_seam_routing() {
4286 let manifest = fixture_manifest();
4287 let canonical = manifest.canonicalization["issue comment"].clone();
4288 let error = canonicalize_governed(
4289 &[
4290 OsString::from("--repo"),
4291 OsString::from("not/an/owner-name"),
4292 OsString::from("issue"),
4293 OsString::from("comment"),
4294 OsString::from("42"),
4295 OsString::from("--body"),
4296 OsString::from("hello"),
4297 ],
4298 "issue comment",
4299 &canonical,
4300 1,
4301 )
4302 .expect_err("an unparseable repository must abort before seam routing");
4303 assert_eq!(error, "repository not/an/owner-name is not owner/name");
4304 assert_eq!(
4305 refuse_governed_canonicalization(&error),
4306 REFUSAL_EXIT_STATUS,
4307 "a pre-routing governance refusal must have a nonzero exit status"
4308 );
4309 }
4310
4311 #[test]
4312 fn governed_canonicalization_normalizes_flags_and_explicit_repo_wins() {
4313 let manifest = fixture_manifest();
4314 let canonical = manifest.canonicalization["issue comment"].clone();
4315 let request = canonicalize_governed(
4316 &[
4317 OsString::from("--repo=owner/explicit"),
4318 OsString::from("issue"),
4319 OsString::from("comment"),
4320 OsString::from("42"),
4321 OsString::from("--body"),
4322 OsString::from("hello"),
4323 ],
4324 "issue comment",
4325 &canonical,
4326 1,
4327 )
4328 .unwrap();
4329 assert_eq!(request.repository.as_deref(), Some("owner/explicit"));
4330 assert_eq!(request.target["number"], "42");
4331 assert_eq!(request.body["body"], "hello");
4332 }
4333
4334 #[test]
4335 fn speech_body_file_forms_are_allowed_and_forward_fixture_contents() {
4336 let manifest = fixture_manifest();
4337 let body_file = fixture_dir().join("governed-speech.md");
4338 let expected_body = fs::read_to_string(&body_file).expect("speech body fixture");
4339
4340 for (expected_tuple, verb, subcommand, target) in [
4341 ("issue comment", "issue", "comment", "42"),
4342 ("pr comment", "pr", "comment", "7"),
4343 ("pr review", "pr", "review", "7"),
4344 ] {
4345 let canonical = manifest.canonicalization[expected_tuple].clone();
4346 for (flag, suffix) in [("--body-file", ""), ("-F", "")]
4347 .into_iter()
4348 .chain([("--body-file=", "equals"), ("-F=", "equals")])
4349 {
4350 let file_arg = if suffix.is_empty() {
4351 body_file.to_string_lossy().into_owned()
4352 } else {
4353 format!("{flag}{}", body_file.display())
4354 };
4355 let args = if suffix.is_empty() {
4356 vec![
4357 OsString::from(verb),
4358 OsString::from(subcommand),
4359 OsString::from(target),
4360 OsString::from(flag),
4361 OsString::from(file_arg),
4362 ]
4363 } else {
4364 vec![
4365 OsString::from(verb),
4366 OsString::from(subcommand),
4367 OsString::from(target),
4368 OsString::from(file_arg),
4369 ]
4370 };
4371 assert!(matches!(
4372 classify(&args, &manifest, "macos"),
4373 Classification::Governed { ref tuple, .. } if tuple == expected_tuple
4374 ));
4375 let request = canonicalize_governed(&args, expected_tuple, &canonical, 1)
4376 .expect("body-file form should canonicalize");
4377 let determination = RungDetermination::r3(1, 1, &test_rung_provenance());
4378 let wire = governed_wire_request(&determination.record, "agent-7", request);
4379 assert_eq!(wire["body"]["body"], expected_body);
4380 }
4381 }
4382
4383 let reaction = manifest.canonicalization["issue reaction"].clone();
4384 let error = canonicalize_governed(
4385 &[
4386 OsString::from("issue"),
4387 OsString::from("reaction"),
4388 OsString::from("42"),
4389 OsString::from("--body-file"),
4390 OsString::from(body_file),
4391 ],
4392 "issue reaction",
4393 &reaction,
4394 1,
4395 )
4396 .expect_err("body-file is speech-only vocabulary");
4397 assert_eq!(error, "undeclared flag --body-file");
4398 }
4399
4400 #[test]
4401 fn body_file_failures_refuse_instead_of_forwarding_an_empty_body() {
4402 let manifest = fixture_manifest();
4403 let canonical = manifest.canonicalization["pr comment"].clone();
4404 let directory = tempfile::tempdir().unwrap();
4405 let missing = directory.path().join("missing.md");
4406 let invalid = directory.path().join("invalid-utf8.md");
4407 fs::write(&invalid, [0xff, 0xfe]).unwrap();
4408
4409 for path in [missing, invalid] {
4410 let error = canonicalize_governed(
4411 &[
4412 OsString::from("pr"),
4413 OsString::from("comment"),
4414 OsString::from("7"),
4415 OsString::from("--body-file"),
4416 OsString::from(&path),
4417 ],
4418 "pr comment",
4419 &canonical,
4420 1,
4421 )
4422 .expect_err("an unreadable body file must refuse");
4423 assert!(error.starts_with("--body-file: could not read body file "));
4424 assert!(error.contains(&path.display().to_string()));
4425 assert_eq!(
4426 refuse_governed_canonicalization(&error),
4427 REFUSAL_EXIT_STATUS
4428 );
4429 }
4430 }
4431
4432 #[test]
4433 fn body_file_dash_reads_stdin_under_the_caller_permissions() {
4434 let mut stdin = std::io::Cursor::new("body supplied through stdin");
4435 assert_eq!(
4436 read_body_file_from(Path::new("-"), &mut stdin).unwrap(),
4437 "body supplied through stdin"
4438 );
4439 }
4440
4441 #[test]
4442 fn pr_review_action_and_body_matrix_reaches_the_governed_payload() {
4443 let manifest = fixture_manifest();
4444 let canonical = manifest.canonicalization["pr review"].clone();
4445 let body_file = fixture_dir().join("governed-speech.md");
4446 let expected_body = fs::read_to_string(&body_file).expect("speech body fixture");
4447
4448 for (action_flag, event) in [
4449 ("--approve", "APPROVE"),
4450 ("--comment", "COMMENT"),
4451 ("--request-changes", "REQUEST_CHANGES"),
4452 ] {
4453 for (body_flag, body_value) in [("--body", "inline review"), ("-b", "short review")] {
4454 let args = vec![
4455 OsString::from("pr"),
4456 OsString::from("review"),
4457 OsString::from("7"),
4458 OsString::from(action_flag),
4459 OsString::from(body_flag),
4460 OsString::from(body_value),
4461 ];
4462 assert!(matches!(
4463 classify(&args, &manifest, "macos"),
4464 Classification::Governed { ref tuple, .. } if tuple == "pr review"
4465 ));
4466 let request = canonicalize_governed(&args, "pr review", &canonical, 1)
4467 .expect("review action with inline body should canonicalize");
4468 assert_eq!(request.body["event"], event);
4469 assert_eq!(request.body["body"], body_value);
4470 }
4471
4472 let args = vec![
4473 OsString::from("pr"),
4474 OsString::from("review"),
4475 OsString::from("7"),
4476 OsString::from(action_flag),
4477 OsString::from("--body-file"),
4478 OsString::from(&body_file),
4479 ];
4480 let request = canonicalize_governed(&args, "pr review", &canonical, 1)
4481 .expect("review action with body-file should canonicalize");
4482 assert_eq!(request.body["event"], event);
4483 assert_eq!(request.body["body"], expected_body);
4484 }
4485
4486 for action_flag in ["--approve", "--request-changes"] {
4487 let args = vec![
4488 OsString::from("pr"),
4489 OsString::from("review"),
4490 OsString::from("7"),
4491 OsString::from(action_flag),
4492 ];
4493 let request = canonicalize_governed(&args, "pr review", &canonical, 1)
4494 .expect("approve/request-changes may omit review prose");
4495 assert_eq!(
4496 request.body["event"],
4497 action_flag
4498 .trim_start_matches("--")
4499 .to_ascii_uppercase()
4500 .replace('-', "_")
4501 );
4502 assert!(!request.body.contains_key("body"));
4503 }
4504
4505 let duplicate = [
4506 OsString::from("pr"),
4507 OsString::from("review"),
4508 OsString::from("7"),
4509 OsString::from("--approve"),
4510 OsString::from("--comment"),
4511 OsString::from("--body"),
4512 OsString::from("review"),
4513 ];
4514 assert_eq!(
4515 canonicalize_governed(&duplicate, "pr review", &canonical, 1).unwrap_err(),
4516 "pr review accepts only one of --approve, --comment, or --request-changes"
4517 );
4518 }
4519
4520 #[test]
4521 fn upstream_api_errors_fail_without_changing_success_status() {
4522 let error_response = json!({
4523 "outcome": "result",
4524 "gh_route_schema": 1,
4525 "result": {
4526 "status": 404,
4527 "error": {"message": "Not Found", "documentation_url": "https://docs.github.com"}
4528 }
4529 });
4530 let error_outcome =
4531 parse_governed_response(&serde_json::to_vec(&error_response).unwrap()).unwrap();
4532 let error_body = match error_outcome {
4533 RouteOutcome::UpstreamError(body) => body,
4534 other => panic!("expected upstream error, got {other:?}"),
4535 };
4536 assert!(error_body.contains("Not Found"));
4537 let directory = tempfile::tempdir().unwrap();
4538 let paths = StatePaths::from_root(directory.path().to_path_buf());
4539 let binding = AgentBinding {
4540 repo: "owner/repo".to_string(),
4541 agent_id: "agent-7".to_string(),
4542 };
4543 assert_eq!(
4544 governed_outcome_status(
4545 &paths,
4546 &binding,
4547 123,
4548 RouteOutcome::UpstreamError(error_body)
4549 ),
4550 UPSTREAM_FAILURE_EXIT_STATUS
4551 );
4552
4553 let success_response = json!({
4554 "outcome": "result",
4555 "gh_route_schema": 1,
4556 "result": {"status": 201, "url": "https://github.com/example"},
4557 "field_order": ["status", "url"]
4558 });
4559 let success_outcome =
4560 parse_governed_response(&serde_json::to_vec(&success_response).unwrap()).unwrap();
4561 assert!(matches!(&success_outcome, RouteOutcome::Result(_)));
4562 assert_eq!(
4563 governed_outcome_status(&paths, &binding, 123, success_outcome),
4564 0
4565 );
4566 }
4567
4568 #[test]
4569 fn governed_renderer_is_deterministic_for_scalars_arrays_and_escapes() {
4570 let result = json!({"message":"snowman ☃\n", "items":["a", 2], "ok":true});
4571 let order = vec![json!("ok"), json!("message"), json!("items")];
4572 assert_eq!(
4573 render_governed_response(&result, &order).unwrap(),
4574 "ok: true\nmessage: \"snowman ☃\\n\"\nitems:\n \"a\"\n 2\n"
4575 );
4576 assert!(matches!(
4577 render_governed_response(&json!("scalar"), &order),
4578 Err(RouteOutcome::SchemaMismatch(_))
4579 ));
4580 }
4581
4582 #[test]
4583 fn lower_rungs_are_cached_durably_but_r1_is_not_written() {
4584 let directory = tempfile::tempdir().unwrap();
4585 let paths = StatePaths::from_root(directory.path().to_path_buf());
4586 let determination = RungDetermination::r2(
4587 123,
4588 R2Reason::DaemonUnreachable,
4589 None,
4590 &test_rung_provenance(),
4591 );
4592 write_rung_record_silently(&paths, &determination.record);
4593 assert_eq!(load_rung_record(&paths).unwrap().rung, Rung::R2);
4594 assert!(!paths.root.join("r1-cache.json").exists());
4595 }
4596
4597 #[test]
4598 fn governed_bound_disposition_is_reason_independent_except_operator_hard_off() {
4599 const EXPECTED_RUNG_SHAPE_COUNT: usize = 11;
4600
4601 let directory = tempfile::tempdir().unwrap();
4602 let paths = StatePaths::from_root(directory.path().to_path_buf());
4603 let connection_file = directory.path().join("connection.json");
4604 fs::write(&connection_file, "present").unwrap();
4605 let missing_connection = directory.path().join("missing-connection.json");
4606 let disabled_doc = serde_json::json!({
4607 "gh_shim": { "enabled": false },
4608 "subc": { "connection_file": missing_connection }
4609 })
4610 .to_string();
4611 let unreachable_doc = serde_json::json!({
4612 "subc": { "connection_file": directory.path().join("still-missing.json") }
4613 })
4614 .to_string();
4615 let budget_doc = serde_json::json!({
4616 "subc": { "connection_file": connection_file }
4617 })
4618 .to_string();
4619 let future_deadline = || std::time::Instant::now() + DISCOVERY_BUDGET;
4620 let r1_cases = [
4621 (
4622 R1Reason::DisabledByConfig,
4623 determine_rung_from_doc(
4624 &paths,
4625 directory.path(),
4626 1,
4627 future_deadline(),
4628 Some(&disabled_doc),
4629 ),
4630 ),
4631 (
4632 R1Reason::AbsentOrUnparseable,
4633 determine_rung_from_doc(&paths, directory.path(), 1, future_deadline(), Some("{}")),
4634 ),
4635 (
4636 R1Reason::Unreachable,
4637 determine_rung_from_doc(
4638 &paths,
4639 directory.path(),
4640 1,
4641 future_deadline(),
4642 Some(&unreachable_doc),
4643 ),
4644 ),
4645 (
4646 R1Reason::DiscoveryBudgetExhausted,
4647 determine_rung_from_doc(
4648 &paths,
4649 directory.path(),
4650 1,
4651 std::time::Instant::now() - Duration::from_millis(1),
4652 Some(&budget_doc),
4653 ),
4654 ),
4655 ];
4656 assert_eq!(r1_cases.len(), R1Reason::ALL.len());
4657 for (reason, determination) in &r1_cases {
4658 assert_eq!(determination.record.rung, Rung::R1);
4659 assert_eq!(
4660 determination
4661 .record
4662 .inputs
4663 .get("connection_file")
4664 .map(String::as_str),
4665 Some(reason.diagnostic())
4666 );
4667 }
4668
4669 let mut determinations = r1_cases
4670 .into_iter()
4671 .map(|(_, determination)| determination)
4672 .collect::<Vec<_>>();
4673 determinations.extend(
4674 R2Reason::ALL
4675 .into_iter()
4676 .map(|reason| RungDetermination::r2(1, reason, Some(1), &test_rung_provenance())),
4677 );
4678 determinations.push(RungDetermination::r3(1, 1, &test_rung_provenance()));
4679 assert_eq!(
4680 R1Reason::ALL.len() + R2Reason::ALL.len() + 1,
4681 EXPECTED_RUNG_SHAPE_COUNT,
4682 "update the explicit disposition matrix when a rung shape is added"
4683 );
4684 assert_eq!(determinations.len(), EXPECTED_RUNG_SHAPE_COUNT);
4685
4686 let manifest = fixture_manifest();
4687 let governed_args = [
4688 OsString::from("issue"),
4689 OsString::from("comment"),
4690 OsString::from("42"),
4691 OsString::from("--body"),
4692 OsString::from("hello"),
4693 ];
4694 let admin_args = [
4695 OsString::from("pr"),
4696 OsString::from("merge"),
4697 OsString::from("42"),
4698 ];
4699 let mechanical_args = [
4700 OsString::from("issue"),
4701 OsString::from("view"),
4702 OsString::from("42"),
4703 ];
4704 let governed = classify(&governed_args, &manifest, "macos");
4705 let admin = classify(&admin_args, &manifest, "macos");
4706 let mechanical = classify(&mechanical_args, &manifest, "macos");
4707 let binding = || AgentBinding {
4708 repo: "cortexkit/aft".to_string(),
4709 agent_id: "alfonso-aft".to_string(),
4710 };
4711
4712 for determination in &determinations {
4713 let bound_governed = structural_governance_disposition(
4714 determination,
4715 &governed,
4716 Some(binding()),
4717 manifest.manifest_version,
4718 );
4719 if determination.operator_disabled {
4720 assert!(matches!(bound_governed, GovernanceDisposition::Delegate));
4721 } else if determination.record.rung == Rung::R3 {
4722 assert!(matches!(bound_governed, GovernanceDisposition::Ready));
4723 } else {
4724 assert!(matches!(
4725 bound_governed,
4726 GovernanceDisposition::Unavailable(_)
4727 ));
4728 }
4729
4730 assert!(matches!(
4731 structural_governance_disposition(
4732 determination,
4733 &governed,
4734 None,
4735 manifest.manifest_version,
4736 ),
4737 GovernanceDisposition::Delegate
4738 ));
4739 assert!(matches!(
4740 structural_governance_disposition(
4741 determination,
4742 &mechanical,
4743 Some(binding()),
4744 manifest.manifest_version,
4745 ),
4746 GovernanceDisposition::Delegate
4747 ));
4748
4749 if determination.record.rung != Rung::R3 && !determination.operator_disabled {
4750 assert!(matches!(
4751 structural_governance_disposition(
4752 determination,
4753 &admin,
4754 Some(binding()),
4755 manifest.manifest_version,
4756 ),
4757 GovernanceDisposition::Unavailable(_)
4758 ));
4759 }
4760 }
4761 }
4762
4763 #[test]
4764 fn ambient_credentials_on_a_bound_governed_invocation_refuse_identity_ambiguity() {
4765 let manifest = fixture_manifest();
4766 let governed = classify(
4767 &[
4768 OsString::from("issue"),
4769 OsString::from("comment"),
4770 OsString::from("42"),
4771 OsString::from("--body"),
4772 OsString::from("hello"),
4773 ],
4774 &manifest,
4775 "macos",
4776 );
4777 let determination = RungDetermination::r2(
4778 1,
4779 R2Reason::AgentCredentialsPresent,
4780 Some(manifest.manifest_version),
4781 &test_rung_provenance(),
4782 );
4783 let binding = AgentBinding {
4784 repo: "cortexkit/aft".to_string(),
4785 agent_id: "alfonso-aft".to_string(),
4786 };
4787
4788 assert!(matches!(
4789 structural_governance_disposition(
4790 &determination,
4791 &governed,
4792 Some(binding),
4793 manifest.manifest_version,
4794 ),
4795 GovernanceDisposition::Unavailable(_)
4796 ));
4797 }
4798
4799 #[cfg(unix)]
4800 #[test]
4801 fn resolved_image_identity_skips_a_shim_reached_through_a_symlinked_parent() {
4802 use std::os::unix::fs::symlink;
4803
4804 let directory = tempfile::tempdir().unwrap();
4805 let image = directory.path().join("aft");
4806 fs::write(&image, b"shim image").unwrap();
4807 let bin = directory.path().join("bin");
4808 fs::create_dir(&bin).unwrap();
4809 symlink(&image, bin.join("gh")).unwrap();
4810 let linked_parent = directory.path().join("linked-bin");
4811 symlink(&bin, &linked_parent).unwrap();
4812
4813 assert!(same_image(&linked_parent.join("gh"), &image));
4814 }
4815
4816 #[test]
4817 fn bypass_audit_is_visible_to_a_later_self_report_reader() {
4818 let directory = tempfile::tempdir().unwrap();
4819 let paths = StatePaths::from_root(directory.path().to_path_buf());
4820 append_bypass_audit(&paths, "issue close", Some("owner/repo"), 99).unwrap();
4821 let (records, error) = read_bypass_audit(&paths);
4822 assert!(error.is_none());
4823 let records = records.unwrap();
4824 assert_eq!(records.len(), 1);
4825 assert_eq!(records[0]["tuple"], "issue close");
4826 }
4827
4828 #[test]
4829 fn refusal_and_self_report_codes_are_separate_closed_sets() {
4830 assert_eq!(RefusalCode::ALL.len(), 11);
4831 assert!(RefusalCode::ALL
4832 .iter()
4833 .all(|code| code.as_str().starts_with("gh_shim_")));
4834 assert_eq!(
4835 RefusalCode::GovernanceUnavailable.as_str(),
4836 "gh_shim_governance_unavailable"
4837 );
4838 assert_eq!(
4839 GOVERNANCE_UNAVAILABLE_TEXT,
4840 "the governance daemon is unreachable and this repository's actions are identity-governed; retry after the daemon returns"
4841 );
4842 assert_eq!(SelfReportDiagnostic::ALL.len(), 6);
4843 assert!(SelfReportDiagnostic::ALL
4844 .iter()
4845 .all(|code| code.as_str().starts_with("gh_shim_status_")));
4846 assert!(SelfReportDiagnostic::ALL
4847 .iter()
4848 .all(|code| !code.as_str().contains("stale")));
4849 assert_eq!(REFUSAL_EXIT_STATUS, 86);
4850 }
4851
4852 #[test]
4853 fn v1_write_classification_accepts_only_the_reviewed_tuple_sets() {
4854 let manifest = fixture_manifest();
4855 for tuple in V1_GOVERNED_TUPLES {
4856 let args = tuple
4857 .split_whitespace()
4858 .map(OsString::from)
4859 .collect::<Vec<_>>();
4860 assert!(matches!(
4861 classify(&args, &manifest, "macos"),
4862 Classification::Governed { .. }
4863 ));
4864 }
4865 for tuple in V1_ADMIN_TUPLES {
4866 let args = tuple
4867 .split_whitespace()
4868 .map(OsString::from)
4869 .collect::<Vec<_>>();
4870 assert!(matches!(
4871 classify(&args, &manifest, "macos"),
4872 Classification::Admin { .. }
4873 ));
4874 }
4875 for args in [
4876 ["release", "publish"].as_slice(),
4877 ["issue", "create"].as_slice(),
4878 ["pr", "reopen"].as_slice(),
4879 ] {
4880 let args = args.iter().map(OsString::from).collect::<Vec<_>>();
4881 assert!(matches!(
4882 classify(&args, &manifest, "macos"),
4883 Classification::Unclassified
4884 ));
4885 }
4886 }
4887
4888 #[test]
4889 fn field_bearing_api_forms_remain_unclassified_without_an_audited_parser() {
4890 let manifest = fixture_manifest();
4891 for field_flag in [
4892 "--field=name=value",
4893 "--raw-field=name=value",
4894 "--input=body.json",
4895 "-fname=value",
4896 "-Fname=value",
4897 ] {
4898 let args = vec![
4899 OsString::from("api"),
4900 OsString::from("/repos/owner/repo"),
4901 OsString::from(field_flag),
4902 ];
4903 assert!(matches!(
4904 classify(&args, &manifest, "macos"),
4905 Classification::Unclassified
4906 ));
4907 }
4908 }
4909
4910 #[test]
4911 fn holder_refusals_preserve_any_string_code_and_reject_non_strings() {
4912 for code in FIXTURE_ACCEPTED_SEAM_REFUSAL_CODES {
4913 let response = json!({"outcome": "refusal", "refusal_code": code});
4914 let outcome = parse_governed_response(&serde_json::to_vec(&response).unwrap()).unwrap();
4915 assert!(matches!(outcome, RouteOutcome::Refusal(ref actual) if actual == code));
4916 assert_eq!(RefusalCode::SeamRefusal.as_str(), "gh_shim_seam_refusal");
4917 assert_eq!(
4918 seam_refusal_text(code),
4919 format!("governance seam refused the action: {code}")
4920 );
4921 assert_eq!(REFUSAL_EXIT_STATUS, 86);
4922 }
4923 let unknown = "quota_exhausted_v2";
4924 let response = json!({"outcome": "refusal", "refusal_code": unknown});
4925 let outcome = parse_governed_response(&serde_json::to_vec(&response).unwrap()).unwrap();
4926 assert!(matches!(outcome, RouteOutcome::Refusal(ref actual) if actual == unknown));
4927 assert_eq!(RefusalCode::SeamRefusal.as_str(), "gh_shim_seam_refusal");
4928 assert_eq!(
4929 seam_refusal_text(unknown),
4930 "governance seam refused the action: quota_exhausted_v2"
4931 );
4932 assert_eq!(REFUSAL_EXIT_STATUS, 86);
4933
4934 for response in [
4935 json!({"outcome": "refusal", "refusal_code": 7}),
4936 json!({"outcome": "refusal", "refusal_code": null}),
4937 json!({"outcome": "refusal"}),
4938 ] {
4939 assert!(matches!(
4940 parse_governed_response(&serde_json::to_vec(&response).unwrap()),
4941 Err(RouteOutcome::SchemaMismatch(_))
4942 ));
4943 }
4944 }
4945
4946 #[test]
4947 fn governed_self_report_transitions_are_durable_and_mechanical_classification_preserves_them() {
4948 let directory = tempfile::tempdir().unwrap();
4949 let paths = StatePaths::from_root(directory.path().to_path_buf());
4950 let binding = AgentBinding {
4951 repo: "owner/repo".to_string(),
4952 agent_id: "agent-7".to_string(),
4953 };
4954 write_seam_state(
4955 &paths,
4956 SeamState {
4957 bound_holder: None,
4958 agent_binding: Some(binding.clone()),
4959 last_seam_refusal: None,
4960 },
4961 )
4962 .unwrap();
4963 let report = build_self_report(&paths);
4964 assert_eq!(report.bound_holder, None);
4965 assert_eq!(report.agent_binding, Some(binding.clone()));
4966 assert_eq!(report.last_seam_refusal, None);
4967
4968 write_seam_state(
4969 &paths,
4970 SeamState {
4971 bound_holder: Some(ROUTING_HOLDER_MODULE_ID.to_string()),
4972 agent_binding: Some(binding.clone()),
4973 last_seam_refusal: Some(LastSeamRefusal {
4974 code: "rate_limited".to_string(),
4975 at_unix_secs: 77,
4976 }),
4977 },
4978 )
4979 .unwrap();
4980 let report = build_self_report(&paths);
4981 assert_eq!(
4982 report.bound_holder.as_deref(),
4983 Some(ROUTING_HOLDER_MODULE_ID)
4984 );
4985 assert_eq!(report.agent_binding, Some(binding.clone()));
4986 assert_eq!(
4987 report
4988 .last_seam_refusal
4989 .as_ref()
4990 .map(|refusal| refusal.code.as_str()),
4991 Some("rate_limited")
4992 );
4993
4994 write_seam_state(
4995 &paths,
4996 governed_seam_state(&paths, Some(ROUTING_HOLDER_MODULE_ID.to_string()), &binding),
4997 )
4998 .unwrap();
4999 assert_eq!(
5000 seam_state(&paths)
5001 .last_seam_refusal
5002 .as_ref()
5003 .map(|refusal| refusal.code.as_str()),
5004 Some("rate_limited")
5005 );
5006
5007 let mechanical = [OsString::from("issue"), OsString::from("view")];
5008 assert!(matches!(
5009 classify(&mechanical, &fixture_manifest(), "macos"),
5010 Classification::Mechanical
5011 ));
5012 assert_eq!(
5013 seam_state(&paths)
5014 .last_seam_refusal
5015 .as_ref()
5016 .map(|refusal| refusal.at_unix_secs),
5017 Some(77)
5018 );
5019 }
5020
5021 #[test]
5022 fn governed_self_report_persistence_failure_is_loud() {
5023 let directory = tempfile::tempdir().unwrap();
5024 let state_root = directory.path().join("not-a-directory");
5025 fs::write(&state_root, b"file").unwrap();
5026 let paths = StatePaths::from_root(state_root);
5027 assert!(write_seam_state(&paths, SeamState::default()).is_err());
5028 }
5029
5030 #[test]
5031 fn raw_bytes_round_trip_verifies_then_parses_from_the_fixture_envelope() {
5032 let envelope: SignedManifest = serde_json::from_str(include_str!(
5033 "../tests/fixtures/gh_shim/signed-envelope-v2.json"
5034 ))
5035 .expect("signed envelope fixture");
5036 assert_eq!(
5038 envelope.manifest_bytes,
5039 include_str!("../tests/fixtures/gh_shim/initial-manifest-v1.json")
5040 );
5041 let manifest = verify_manifest_signature(&envelope).expect("fixture signature verifies");
5043 assert_eq!(manifest.manifest_version, 1);
5044 assert_eq!(manifest.issued_at_unix_secs, FIXTURE_ISSUED_AT);
5045 manifest.validate().expect("fixture manifest validates");
5046 }
5047
5048 #[test]
5049 fn tampered_single_byte_fixture_fails_signature_verification() {
5050 let canonical: SignedManifest = serde_json::from_str(include_str!(
5051 "../tests/fixtures/gh_shim/signed-envelope-v2.json"
5052 ))
5053 .expect("canonical envelope fixture");
5054 let tampered: SignedManifest = serde_json::from_str(include_str!(
5055 "../tests/fixtures/gh_shim/signed-envelope-v2-tampered.json"
5056 ))
5057 .expect("tampered envelope fixture");
5058 assert_eq!(
5061 canonical.manifest_bytes.len(),
5062 tampered.manifest_bytes.len()
5063 );
5064 assert_eq!(
5065 canonical
5066 .manifest_bytes
5067 .bytes()
5068 .zip(tampered.manifest_bytes.bytes())
5069 .filter(|(left, right)| left != right)
5070 .count(),
5071 1
5072 );
5073 assert_eq!(canonical.signature, tampered.signature);
5074 assert!(matches!(
5075 verify_manifest_signature(&tampered),
5076 Err(ManifestProblem::Invalid(_))
5077 ));
5078 }
5079
5080 #[test]
5081 fn future_issued_at_fixture_is_refused_and_aged_fixture_serves_governed_classification() {
5082 let directory = tempfile::tempdir().unwrap();
5083 let paths = StatePaths::from_root(directory.path().to_path_buf());
5084
5085 write_envelope_fixture(
5086 &paths,
5087 include_str!("../tests/fixtures/gh_shim/signed-envelope-v2-future-issued-at.json"),
5088 );
5089 match load_manifest(&paths, TEST_NOW) {
5090 Err(ManifestProblem::Invalid(error)) => {
5091 assert!(error.contains("future"), "unexpected error: {error}")
5092 }
5093 other => panic!("expected future issued_at refusal, got {other:?}"),
5094 }
5095
5096 write_envelope_fixture(
5100 &paths,
5101 include_str!("../tests/fixtures/gh_shim/signed-envelope-v2-stale-issued-at.json"),
5102 );
5103 let ManifestResolution::Active(manifest) = resolve_manifest(&paths, TEST_NOW) else {
5104 panic!("expected the aged signed manifest to remain active");
5105 };
5106 assert_eq!(manifest.issued_at_unix_secs, FIXTURE_ISSUED_AT - 2_000_000);
5107 assert!(matches!(
5108 classify(
5109 &[
5110 OsString::from("issue"),
5111 OsString::from("comment"),
5112 OsString::from("42"),
5113 OsString::from("--body"),
5114 OsString::from("hello"),
5115 ],
5116 &manifest,
5117 "macos"
5118 ),
5119 Classification::Governed { tuple, .. } if tuple == "issue comment"
5120 ));
5121
5122 let report: Value =
5123 serde_json::from_str(&render_self_report(&paths).expect("self report serialization"))
5124 .expect("self report JSON");
5125 assert_eq!(
5126 report["cached_manifest"]["issued_at_unix_secs"],
5127 FIXTURE_ISSUED_AT - 2_000_000
5128 );
5129 }
5130
5131 #[test]
5132 fn standby_key_fixture_verifies_under_a_two_slot_trust_set_and_unknown_key_ids_are_refused() {
5133 let envelope: SignedManifest = serde_json::from_str(include_str!(
5134 "../tests/fixtures/gh_shim/signed-envelope-v2-standby-key.json"
5135 ))
5136 .expect("standby envelope fixture");
5137
5138 let standby = Ed25519KeyPair::from_seed_unchecked(&STANDBY_TEST_SEED).expect("standby key");
5139 assert_ne!(standby.public_key().as_ref(), DEV_MANIFEST_PUBLIC_KEY);
5140 let standby_public: &'static [u8] =
5141 Box::leak(standby.public_key().as_ref().to_vec().into_boxed_slice());
5142 let trust_set = [
5143 Some(ManifestTrustKey {
5144 key_id: DEV_MANIFEST_KEY_ID,
5145 public_key: &DEV_MANIFEST_PUBLIC_KEY,
5146 }),
5147 Some(ManifestTrustKey {
5148 key_id: DEV_STANDBY_MANIFEST_KEY_ID,
5149 public_key: standby_public,
5150 }),
5151 ];
5152
5153 let manifest =
5155 verify_manifest_signature_with(&envelope, &trust_set).expect("standby slot verifies");
5156 assert_eq!(
5157 manifest.manifest_version,
5158 fixture_manifest().manifest_version
5159 );
5160
5161 let mut unknown = envelope.clone();
5163 unknown.key_id = "gh-routing-unknown-key".to_string();
5164 assert!(matches!(
5165 verify_manifest_signature_with(&unknown, &trust_set),
5166 Err(ManifestProblem::Invalid(_))
5167 ));
5168 }
5169
5170 #[test]
5171 fn compiled_trust_set_shape_matches_the_two_slot_design() {
5172 let slots = compiled_manifest_trust_set();
5173 let live = slots[0].expect("live slot carries the production root");
5178 assert_eq!(live.key_id, PROD_MANIFEST_KEY_ID);
5179 assert_eq!(live.public_key, &PROD_MANIFEST_PUBLIC_KEY);
5180 #[cfg(debug_assertions)]
5181 {
5182 assert_eq!(slots.len(), 2);
5185 assert_eq!(slots[1].unwrap().key_id, DEV_MANIFEST_KEY_ID);
5186 }
5187 #[cfg(not(debug_assertions))]
5188 {
5189 assert_eq!(slots.len(), 2);
5192 assert!(slots[1].is_none());
5193 }
5194 }
5195
5196 #[test]
5197 fn envelope_v1_shapes_are_refused_by_the_v2_verifier() {
5198 let directory = tempfile::tempdir().unwrap();
5199 let paths = StatePaths::from_root(directory.path().to_path_buf());
5200 let manifest = fixture_manifest();
5201 let bytes = serde_json::to_vec(&manifest).unwrap();
5202 let key = Ed25519KeyPair::from_seed_unchecked(&TEST_SEED).unwrap();
5203 let signature = base64::engine::general_purpose::STANDARD.encode(key.sign(&bytes).as_ref());
5204
5205 let v1_object = json!({
5207 "artifact_id": MANIFEST_ARTIFACT_ID,
5208 "key_id": DEV_MANIFEST_KEY_ID,
5209 "fetched_at_unix_secs": TEST_NOW,
5210 "signature": signature,
5211 "manifest": serde_json::to_value(&manifest).unwrap(),
5212 });
5213 fs::write(&paths.manifest, serde_json::to_vec(&v1_object).unwrap()).unwrap();
5214 assert!(matches!(
5215 load_manifest(&paths, TEST_NOW),
5216 Err(ManifestProblem::Invalid(_))
5217 ));
5218
5219 let mut old_version = signed(&manifest, TEST_NOW);
5221 old_version.envelope_version = 1;
5222 fs::write(&paths.manifest, serde_json::to_vec(&old_version).unwrap()).unwrap();
5223 match load_manifest(&paths, TEST_NOW) {
5224 Err(ManifestProblem::Invalid(error)) => {
5225 assert!(
5226 error.contains("envelope version"),
5227 "unexpected error: {error}"
5228 )
5229 }
5230 other => panic!("expected envelope version refusal, got {other:?}"),
5231 }
5232 }
5233
5234 #[test]
5235 fn dormant_resolution_is_presence_based() {
5236 let directory = tempfile::tempdir().unwrap();
5237 let paths = StatePaths::from_root(directory.path().to_path_buf());
5238 assert!(matches!(
5240 resolve_manifest(&paths, TEST_NOW),
5241 ManifestResolution::Dormant
5242 ));
5243
5244 let untrusted = signed_with(
5248 &fixture_manifest(),
5249 TEST_NOW,
5250 &STANDBY_TEST_SEED,
5251 "gh-routing-unknown-key",
5252 );
5253 fs::write(&paths.manifest, serde_json::to_vec(&untrusted).unwrap()).unwrap();
5254 assert!(matches!(
5255 resolve_manifest(&paths, TEST_NOW),
5256 ManifestResolution::Invalid(ManifestProblem::Invalid(_))
5257 ));
5258 }
5259
5260 #[test]
5261 fn regressed_invalid_artifact_refuses_governed_and_admin_and_passes_mechanical() {
5262 let directory = tempfile::tempdir().unwrap();
5263 let paths = StatePaths::from_root(directory.path().to_path_buf());
5264 let now = TEST_NOW;
5265
5266 write_signed_manifest(&paths, fixture_manifest(), now);
5268 load_manifest(&paths, now).expect("canonical manifest verifies");
5269
5270 write_envelope_fixture(
5272 &paths,
5273 include_str!("../tests/fixtures/gh_shim/signed-envelope-v2-tampered.json"),
5274 );
5275
5276 let ManifestResolution::Regressed { manifest, problem } = resolve_manifest(&paths, now)
5279 else {
5280 panic!("expected the regressed arm");
5281 };
5282 let governed = [
5283 OsString::from("issue"),
5284 OsString::from("comment"),
5285 OsString::from("42"),
5286 OsString::from("--body"),
5287 OsString::from("hello"),
5288 ];
5289 assert!(matches!(
5290 regressed_disposition(&governed, &manifest, "macos", &problem),
5291 RegressedDisposition::Refuse {
5292 code: RefusalCode::ManifestRegressed,
5293 ..
5294 }
5295 ));
5296 let admin = [
5297 OsString::from("pr"),
5298 OsString::from("merge"),
5299 OsString::from("1"),
5300 ];
5301 assert!(matches!(
5302 regressed_disposition(&admin, &manifest, "macos", &problem),
5303 RegressedDisposition::Refuse {
5304 code: RefusalCode::ManifestRegressed,
5305 ..
5306 }
5307 ));
5308 let mechanical = [OsString::from("issue"), OsString::from("view")];
5309 assert!(matches!(
5310 regressed_disposition(&mechanical, &manifest, "macos", &problem),
5311 RegressedDisposition::Passthrough
5312 ));
5313 let undeclared = [OsString::from("alias"), OsString::from("set")];
5314 assert!(matches!(
5315 regressed_disposition(&undeclared, &manifest, "macos", &problem),
5316 RegressedDisposition::Refuse {
5317 code: RefusalCode::Unclassified,
5318 ..
5319 }
5320 ));
5321
5322 let report = cached_manifest_report_at(&paths, now);
5324 assert_eq!(report.state, Some("regressed"));
5325 assert_eq!(report.version, Some(1));
5326 assert_eq!(report.issued_at_unix_secs, Some(FIXTURE_ISSUED_AT));
5327 assert_eq!(
5328 report.diagnostics,
5329 vec![
5330 SelfReportDiagnostic::ManifestRegressed.as_str(),
5331 SelfReportDiagnostic::ManifestInvalid.as_str(),
5332 ]
5333 );
5334 }
5335
5336 #[test]
5337 fn self_report_exposes_manifest_and_rung_record_provenance() {
5338 let directory = tempfile::tempdir().unwrap();
5339 let paths = StatePaths::from_root(directory.path().to_path_buf());
5340 write_signed_manifest(&paths, fixture_manifest(), TEST_NOW);
5341
5342 let manifest_report = cached_manifest_report_at(&paths, TEST_NOW);
5343 assert_eq!(manifest_report.state, Some("valid"));
5344 assert_eq!(
5345 manifest_report.verified_by_key_id.as_deref(),
5346 Some(DEV_MANIFEST_KEY_ID)
5347 );
5348 assert_eq!(
5349 manifest_report.compiled_trust_set_key_ids,
5350 trust_set_key_ids(compiled_manifest_trust_set())
5351 );
5352
5353 let provenance = RungRecordProvenance {
5354 image_path: "/opt/cortexkit/aft-gh-shim".to_string(),
5355 version: "0.53.0-test".to_string(),
5356 repo_key: "cortexkit/aft".to_string(),
5357 };
5358 let determination =
5359 RungDetermination::r2(TEST_NOW, R2Reason::DaemonUnreachable, Some(1), &provenance);
5360 write_rung_record_silently(&paths, &determination.record);
5361 let fresh_rung = last_rung_report(&paths);
5362 assert_eq!(
5363 fresh_rung.recorded_by_image_path.as_deref(),
5364 Some("/opt/cortexkit/aft-gh-shim")
5365 );
5366 assert_eq!(
5367 fresh_rung.recorded_by_version.as_deref(),
5368 Some("0.53.0-test")
5369 );
5370 assert_eq!(
5371 fresh_rung.recorded_by_repo_key.as_deref(),
5372 Some("cortexkit/aft")
5373 );
5374
5375 fs::write(
5376 &paths.rung,
5377 serde_json::to_vec(&json!({
5378 "rung": "R2",
5379 "as_of_unix_secs": TEST_NOW,
5380 "inputs": { "daemon_unreachable": "failed" },
5381 "manifest_version": 1
5382 }))
5383 .unwrap(),
5384 )
5385 .unwrap();
5386 let legacy_rung = last_rung_report(&paths);
5387 assert_eq!(
5388 legacy_rung.recorded_by_image_path.as_deref(),
5389 Some(PRE_PROVENANCE_RECORD)
5390 );
5391 assert_eq!(
5392 legacy_rung.recorded_by_version.as_deref(),
5393 Some(PRE_PROVENANCE_RECORD)
5394 );
5395 assert_eq!(
5396 legacy_rung.recorded_by_repo_key.as_deref(),
5397 Some(PRE_PROVENANCE_RECORD)
5398 );
5399 }
5400
5401 #[test]
5402 fn trust_set_provenance_explains_image_level_untrusted_key_regression() {
5403 let directory = tempfile::tempdir().unwrap();
5404 let paths = StatePaths::from_root(directory.path().to_path_buf());
5405 write_signed_manifest(&paths, fixture_manifest(), TEST_NOW);
5406 let verifier_a = [Some(ManifestTrustKey {
5407 key_id: DEV_MANIFEST_KEY_ID,
5408 public_key: &DEV_MANIFEST_PUBLIC_KEY,
5409 })];
5410 let verifier_b = [Some(PROD_MANIFEST_TRUST_KEY)];
5411
5412 let report_a = cached_manifest_report_at_with(&paths, TEST_NOW, &verifier_a);
5413 assert_eq!(report_a.state, Some("valid"));
5414 assert_eq!(
5415 report_a.verified_by_key_id.as_deref(),
5416 Some(DEV_MANIFEST_KEY_ID)
5417 );
5418 assert_eq!(
5419 report_a.compiled_trust_set_key_ids,
5420 vec![DEV_MANIFEST_KEY_ID]
5421 );
5422
5423 let report_b = cached_manifest_report_at_with(&paths, TEST_NOW, &verifier_b);
5424 assert_eq!(report_b.state, Some("regressed"));
5425 assert_eq!(report_b.verified_by_key_id, None);
5426 assert_eq!(
5427 report_b.compiled_trust_set_key_ids,
5428 vec![PROD_MANIFEST_KEY_ID]
5429 );
5430 assert_eq!(
5431 report_b.diagnostic_guidance,
5432 Some(UNTRUSTED_MANIFEST_KEY_STEERING)
5433 );
5434
5435 let cached = read_last_valid_manifest(&paths).expect("verifier A wrote last-valid cache");
5436 let governed = [
5437 OsString::from("issue"),
5438 OsString::from("comment"),
5439 OsString::from("42"),
5440 OsString::from("--body"),
5441 OsString::from("hello"),
5442 ];
5443 let untrusted =
5444 ManifestProblem::Invalid(format!("untrusted manifest key id {DEV_MANIFEST_KEY_ID}"));
5445 let RegressedDisposition::Refuse { text, .. } =
5446 regressed_disposition(&governed, &cached.manifest, "macos", &untrusted)
5447 else {
5448 panic!("a governed command must refuse under verifier B");
5449 };
5450 assert!(text.ends_with(UNTRUSTED_MANIFEST_KEY_STEERING));
5451 }
5452
5453 #[test]
5454 fn version_high_water_refuses_rollbacks_and_status_reports_them() {
5455 let directory = tempfile::tempdir().unwrap();
5456 let paths = StatePaths::from_root(directory.path().to_path_buf());
5457
5458 write_envelope_fixture(
5460 &paths,
5461 include_str!("../tests/fixtures/gh_shim/signed-envelope-v2-version-2.json"),
5462 );
5463 assert_eq!(load_manifest(&paths, TEST_NOW).unwrap().manifest_version, 2);
5464 assert_eq!(version_high_water(&paths), 2);
5465
5466 write_envelope_fixture(
5469 &paths,
5470 include_str!("../tests/fixtures/gh_shim/signed-envelope-v2.json"),
5471 );
5472 assert!(matches!(
5473 load_manifest(&paths, TEST_NOW),
5474 Err(ManifestProblem::RolledBack {
5475 manifest_version: 1,
5476 newest_accepted: 2,
5477 })
5478 ));
5479 let report = cached_manifest_report_at(&paths, TEST_NOW);
5480 assert_eq!(
5481 report.diagnostics,
5482 vec![
5483 SelfReportDiagnostic::ManifestRegressed.as_str(),
5484 SelfReportDiagnostic::ManifestRollback.as_str(),
5485 ]
5486 );
5487 let document = render_self_report(&paths).expect("self report");
5489 assert!(document.contains(SelfReportDiagnostic::ManifestRollback.as_str()));
5490
5491 write_envelope_fixture(
5493 &paths,
5494 include_str!("../tests/fixtures/gh_shim/signed-envelope-v2-version-2.json"),
5495 );
5496 assert_eq!(load_manifest(&paths, TEST_NOW).unwrap().manifest_version, 2);
5497 }
5498
5499 fn fixture_dir() -> PathBuf {
5500 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/gh_shim")
5501 }
5502
5503 fn canonical_manifest_bytes() -> Vec<u8> {
5504 fs::read(fixture_dir().join("initial-manifest-v1.json"))
5505 .expect("canonical manifest fixture")
5506 }
5507
5508 fn envelope_json(envelope: &SignedManifest) -> Vec<u8> {
5509 let mut bytes = serde_json::to_vec_pretty(envelope).expect("envelope serialization");
5510 bytes.push(b'\n');
5511 bytes
5512 }
5513
5514 fn generate_envelope_fixtures() -> Vec<(String, Vec<u8>)> {
5518 let sign = |bytes: &[u8], seed: &[u8; 32]| {
5519 let key = Ed25519KeyPair::from_seed_unchecked(seed).expect("fixture key");
5520 base64::engine::general_purpose::STANDARD.encode(key.sign(bytes).as_ref())
5521 };
5522 let envelope = |key_id: &str, seed: &[u8; 32], manifest_bytes: String| {
5523 envelope_json(&SignedManifest {
5524 artifact_id: MANIFEST_ARTIFACT_ID.to_string(),
5525 envelope_version: ENVELOPE_VERSION,
5526 key_id: key_id.to_string(),
5527 fetched_at_unix_secs: FIXTURE_ISSUED_AT,
5528 signature: sign(manifest_bytes.as_bytes(), seed),
5529 manifest_bytes,
5530 })
5531 };
5532
5533 let canonical = canonical_manifest_bytes();
5534 let canonical_text = String::from_utf8(canonical.clone()).expect("UTF-8 manifest");
5535 let canonical_signature = sign(&canonical, &TEST_SEED);
5536
5537 let mut fixtures = Vec::new();
5538 fixtures.push((
5540 "signed-envelope-v2.json".to_string(),
5541 envelope_json(&SignedManifest {
5542 artifact_id: MANIFEST_ARTIFACT_ID.to_string(),
5543 envelope_version: ENVELOPE_VERSION,
5544 key_id: DEV_MANIFEST_KEY_ID.to_string(),
5545 fetched_at_unix_secs: FIXTURE_ISSUED_AT,
5546 signature: canonical_signature.clone(),
5547 manifest_bytes: canonical_text.clone(),
5548 }),
5549 ));
5550 let tampered = canonical_text.replacen("issue view", "issue View", 1);
5553 assert_ne!(tampered, canonical_text);
5554 fixtures.push((
5555 "signed-envelope-v2-tampered.json".to_string(),
5556 envelope_json(&SignedManifest {
5557 artifact_id: MANIFEST_ARTIFACT_ID.to_string(),
5558 envelope_version: ENVELOPE_VERSION,
5559 key_id: DEV_MANIFEST_KEY_ID.to_string(),
5560 fetched_at_unix_secs: FIXTURE_ISSUED_AT,
5561 signature: canonical_signature,
5562 manifest_bytes: tampered,
5563 }),
5564 ));
5565
5566 let mut variant = |name: &str, mutate: fn(&mut Manifest), seed: &[u8; 32], key_id: &str| {
5567 let mut manifest = fixture_manifest();
5568 mutate(&mut manifest);
5569 let bytes = serde_json::to_vec(&manifest).expect("variant manifest bytes");
5570 fixtures.push((
5571 name.to_string(),
5572 envelope(
5573 key_id,
5574 seed,
5575 String::from_utf8(bytes).expect("UTF-8 variant bytes"),
5576 ),
5577 ));
5578 };
5579 variant(
5580 "signed-envelope-v2-future-issued-at.json",
5581 |manifest| {
5582 manifest.issued_at_unix_secs =
5583 FIXTURE_ISSUED_AT + ISSUED_AT_FUTURE_SKEW.as_secs() + 3300;
5584 },
5585 &TEST_SEED,
5586 DEV_MANIFEST_KEY_ID,
5587 );
5588 variant(
5589 "signed-envelope-v2-stale-issued-at.json",
5590 |manifest| {
5591 manifest.issued_at_unix_secs = FIXTURE_ISSUED_AT - 2_000_000;
5592 },
5593 &TEST_SEED,
5594 DEV_MANIFEST_KEY_ID,
5595 );
5596 variant(
5597 "signed-envelope-v2-version-2.json",
5598 |manifest| {
5599 manifest.manifest_version = 2;
5600 },
5601 &TEST_SEED,
5602 DEV_MANIFEST_KEY_ID,
5603 );
5604 variant(
5605 "signed-envelope-v2-standby-key.json",
5606 |_manifest| {},
5607 &STANDBY_TEST_SEED,
5608 DEV_STANDBY_MANIFEST_KEY_ID,
5609 );
5610 fixtures
5611 }
5612
5613 #[test]
5614 fn signed_envelope_fixtures_match_their_generator() {
5615 let regen = std::env::var_os("AFT_GH_SHIM_REGEN").is_some();
5616 for (name, bytes) in generate_envelope_fixtures() {
5617 let path = fixture_dir().join(&name);
5618 if regen {
5619 fs::write(&path, &bytes).expect("write fixture");
5620 continue;
5621 }
5622 let disk = fs::read(&path)
5623 .unwrap_or_else(|error| panic!("fixture {name} is missing: {error}"));
5624 assert_eq!(
5625 disk, bytes,
5626 "fixture {name} drifted from its generator; rerun with AFT_GH_SHIM_REGEN=1"
5627 );
5628 }
5629 }
5630}