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