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::sync::{Arc, Mutex};
20use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
21
22use base64::Engine;
23use ring::signature::{UnparsedPublicKey, ED25519};
24use serde::{Deserialize, Serialize};
25use serde_json::{json, Map, Value};
26use sha2::{Digest, Sha256};
27use subc_client_rs::{CallOptions, CloseRouteOptions, ConsumerOptions, SubcConsumer};
28use subc_protocol::manifest::ProviderRole;
29
30use crate::db::github_read_cache::{invalidate_github_read_cache_resource, GithubReadResourceKind};
31use subc_protocol::{BindIdentity, RouteTarget};
32
33pub const SCHEMA_FLOOR: u64 = 1;
34pub const ENVELOPE_VERSION: u64 = 2;
38pub const REFUSAL_EXIT_STATUS: i32 = 86;
39const UPSTREAM_FAILURE_EXIT_STATUS: i32 = 1;
40const DISCOVERY_BUDGET: Duration = Duration::from_secs(2);
41const DISCOVERY_RETRY_BACKOFF: Duration = Duration::from_millis(250);
46const DISCOVERY_CACHE_TTL: Duration = Duration::from_secs(15);
47const RECENTLY_REACHABLE_WINDOW: Duration = Duration::from_secs(300);
48const ISSUED_AT_FUTURE_SKEW: Duration = Duration::from_secs(300);
51const ROUTING_OPERATION: &str = "gh.route";
52const ROUTING_HOLDER_MODULE_ID: &str = "prefrontal-core";
53const MANIFEST_ARTIFACT_ID: &str = "gh-routing-manifest";
54const V1_GOVERNED_TUPLES: &[&str] = &["issue comment", "pr comment", "pr review", "issue reaction"];
55const V1_ADMIN_TUPLES: &[&str] = &["issue close", "pr close", "pr merge", "release create"];
56const V9_ADMIN_TUPLES: &[&str] = &["repo edit", "run delete"];
57const V11_ADMIN_TUPLES: &[&str] = &["issue reopen", "pr reopen"];
61const V12_GOVERNED_TUPLES: &[&str] = &["issue close", "issue reopen", "pr close", "pr reopen"];
62const TARGET_AND_STATE_FORM: &str = "target-and-state";
63const ISSUE_CLOSE_REASONS: &[&str] = &["completed", "not_planned"];
64const V10_ADMIN_TUPLES: &[&str] = &["workflow run", "run rerun"];
71const V13_ADMIN_TUPLES: &[&str] = &["release edit", "release upload"];
74const DESTRUCTIVE_TUPLES: &[&str] = &["release delete", "release delete-asset"];
75const V10_EDIT_LAST_TUPLES: &[&str] = &["issue comment", "pr comment"];
79const READ_ONLY_ACTION_TUPLES: &[&str] = &[
80 "run view",
81 "run list",
82 "run watch",
83 "workflow view",
84 "workflow list",
85];
86const RESERVED_SELF_REPORT: &[&str] = &["--status", "--shim-version"];
87const CO_AUTHOR_LINE_REPORT: &str = "--co-author-line";
88const GOVERNANCE_UNAVAILABLE_TEXT: &str = "the governance daemon is unreachable and this repository's actions are identity-governed; retry after the daemon returns";
89fn governance_probe_timeout_text(elapsed_ms: u64, stage: ProbeStage) -> String {
95 format!(
96 "governance probe timed out after {elapsed_ms} ms at {stage} (daemon may be busy; host load?) - this repository's actions are identity-governed, so the command was not run; retry"
97 )
98}
99const 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";
100const PRE_PROVENANCE_RECORD: &str = "unrecorded (pre-provenance record)";
101const GH_SHIM_STATE_DIR_ENV: &str = "AFT_GH_SHIM_STATE_DIR";
102
103#[derive(Clone, Copy, Debug, Eq, PartialEq)]
106pub enum RefusalCode {
107 Unclassified,
108 AdminTier,
109 ManifestBelowFloor,
110 ManifestRegressed,
111 SeamSchemaMismatch,
112 UnboundIdentity,
113 BypassAuditUnavailable,
114 NoRealGh,
115 GovernanceUnavailable,
116 SeamUnavailable,
117 SeamRefusal,
118 MissingReason,
119 DestructiveFlag,
120}
121
122impl RefusalCode {
123 pub const ALL: [Self; 13] = [
124 Self::Unclassified,
125 Self::AdminTier,
126 Self::ManifestBelowFloor,
127 Self::ManifestRegressed,
128 Self::SeamSchemaMismatch,
129 Self::UnboundIdentity,
130 Self::BypassAuditUnavailable,
131 Self::NoRealGh,
132 Self::GovernanceUnavailable,
133 Self::SeamUnavailable,
134 Self::SeamRefusal,
135 Self::MissingReason,
136 Self::DestructiveFlag,
137 ];
138
139 pub const fn as_str(self) -> &'static str {
140 match self {
141 Self::Unclassified => "gh_shim_unclassified",
142 Self::AdminTier => "gh_shim_admin_tier",
143 Self::ManifestBelowFloor => "gh_shim_manifest_below_floor",
144 Self::ManifestRegressed => "gh_shim_manifest_regressed",
145 Self::SeamSchemaMismatch => "gh_shim_seam_schema_mismatch",
146 Self::UnboundIdentity => "gh_shim_unbound_identity",
147 Self::BypassAuditUnavailable => "gh_shim_bypass_audit_unavailable",
148 Self::NoRealGh => "gh_shim_no_real_gh",
149 Self::GovernanceUnavailable => "gh_shim_governance_unavailable",
150 Self::SeamUnavailable => "gh_shim_seam_unavailable",
151 Self::SeamRefusal => "gh_shim_seam_refusal",
152 Self::MissingReason => "gh_shim_missing_reason",
153 Self::DestructiveFlag => "gh_shim_destructive_flag",
154 }
155 }
156}
157
158#[derive(Clone, Copy, Debug, Eq, PartialEq)]
162pub enum SelfReportDiagnostic {
163 ManifestUnavailable,
164 ManifestInvalid,
165 ManifestBelowFloor,
166 ManifestRegressed,
167 ManifestRollback,
168 RungUnavailable,
169}
170
171impl SelfReportDiagnostic {
172 pub const ALL: [Self; 6] = [
173 Self::ManifestUnavailable,
174 Self::ManifestInvalid,
175 Self::ManifestBelowFloor,
176 Self::ManifestRegressed,
177 Self::ManifestRollback,
178 Self::RungUnavailable,
179 ];
180
181 pub const fn as_str(self) -> &'static str {
182 match self {
183 Self::ManifestUnavailable => "gh_shim_status_manifest_unavailable",
184 Self::ManifestInvalid => "gh_shim_status_manifest_invalid",
185 Self::ManifestBelowFloor => "gh_shim_status_manifest_below_floor",
186 Self::ManifestRegressed => "gh_shim_status_manifest_regressed",
187 Self::ManifestRollback => "gh_shim_status_manifest_rollback",
188 Self::RungUnavailable => "gh_shim_status_rung_unavailable",
189 }
190 }
191}
192
193#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
194#[serde(rename_all = "lowercase")]
195pub enum Tier {
196 Mechanical,
197 Governed,
198 Admin,
199}
200
201impl Tier {
202 fn rank(self) -> u8 {
203 match self {
204 Self::Mechanical => 0,
205 Self::Governed => 1,
206 Self::Admin => 2,
207 }
208 }
209}
210
211#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
212#[serde(rename_all = "UPPERCASE")]
213pub enum Rung {
214 R1,
215 R2,
216 R3,
217}
218
219impl Rung {
220 const fn label(self) -> &'static str {
221 match self {
222 Self::R1 => "R1",
223 Self::R2 => "R2",
224 Self::R3 => "R3",
225 }
226 }
227}
228
229pub fn is_shim_invocation(program: &OsStr, args: &[OsString]) -> bool {
233 Path::new(program)
234 .file_name()
235 .is_some_and(|name| name == OsStr::new("gh"))
236 || args.first().is_some_and(|arg| arg == OsStr::new("gh-shim"))
237}
238
239pub fn is_shim_invocation_from_env() -> bool {
240 let mut argv = std::env::args_os();
241 let Some(program) = argv.next() else {
242 return false;
243 };
244 is_shim_invocation(&program, &argv.collect::<Vec<_>>())
245}
246
247pub fn run_from_env() -> i32 {
251 let mut argv = std::env::args_os();
252 let Some(program) = argv.next() else {
253 return refuse(RefusalCode::NoRealGh, "the executing image was unavailable");
254 };
255 let raw_args = argv.collect::<Vec<_>>();
256 let shim_args = if Path::new(&program)
257 .file_name()
258 .is_some_and(|name| name == OsStr::new("gh"))
259 {
260 raw_args
261 } else {
262 raw_args.into_iter().skip(1).collect()
263 };
264 run(&shim_args)
265}
266
267fn run(args: &[OsString]) -> i32 {
268 let paths = StatePaths::from_process();
269 if args.first().and_then(|arg| arg.to_str()) == Some(CO_AUTHOR_LINE_REPORT) {
270 if let Some(line) = co_author_line(&paths) {
271 println!("{line}");
272 }
273 return 0;
274 }
275 if is_reserved_self_report(args) {
276 print_self_report(&paths);
277 return 0;
278 }
279
280 let now = unix_seconds();
281 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
282
283 let initial_manifest = resolve_manifest(&paths, now);
288 let invalid_manifest_problem = initial_manifest.invalid_problem().cloned();
289 if let ManifestResolution::Regressed { manifest, problem } = &initial_manifest {
290 return match regressed_disposition(args, manifest, current_platform(), problem) {
291 RegressedDisposition::Passthrough => {
292 delegate_after_invalid_manifest_notice(args, problem)
293 }
294 RegressedDisposition::Refuse { code, text } => refuse(code, &text),
295 };
296 }
297
298 let determination = determine_rung(&paths, &cwd, now);
299 if determination.record.rung != Rung::R3 {
300 let disposition = match resolve_manifest(&paths, now) {
301 ManifestResolution::Active(manifest) => non_r3_governance_disposition(
302 &cwd,
303 &determination,
304 args,
305 &manifest,
306 current_platform(),
307 ),
308 ManifestResolution::Regressed { .. }
309 | ManifestResolution::Invalid(_)
310 | ManifestResolution::Dormant => GovernanceDisposition::Delegate,
311 };
312 return match disposition {
313 GovernanceDisposition::Unavailable(agent_binding) => {
314 let refusal_text = determination
315 .refusal_detail
316 .as_deref()
317 .unwrap_or(GOVERNANCE_UNAVAILABLE_TEXT);
318 refuse_governance_unavailable(&paths, &agent_binding, now, refusal_text)
319 }
320 GovernanceDisposition::Unclassified { manifest_version } => refuse(
321 RefusalCode::Unclassified,
322 &unclassified_refusal_text(manifest_version),
323 ),
324 GovernanceDisposition::Destructive => refuse(
325 RefusalCode::DestructiveFlag,
326 "destructive GitHub operations are not available through the shim",
327 ),
328 GovernanceDisposition::Delegate | GovernanceDisposition::Ready => {
329 match invalid_manifest_problem.as_ref() {
330 Some(problem) => delegate_after_invalid_manifest_notice(args, problem),
331 None => delegate(args),
332 }
333 }
334 };
335 }
336
337 let manifest = match resolve_manifest(&paths, now) {
342 ManifestResolution::Active(manifest) => manifest,
343 ManifestResolution::Regressed { manifest, problem } => {
344 return match regressed_disposition(args, &manifest, current_platform(), &problem) {
345 RegressedDisposition::Passthrough => {
346 delegate_after_invalid_manifest_notice(args, &problem)
347 }
348 RegressedDisposition::Refuse { code, text } => refuse(code, &text),
349 }
350 }
351 ManifestResolution::Invalid(problem) => {
352 return delegate_after_invalid_manifest_notice(args, &problem)
353 }
354 ManifestResolution::Dormant => return delegate(args),
355 };
356 let Some(agent_binding) = resolved_agent_binding(&manifest, &cwd) else {
357 return delegate(args);
358 };
359
360 let classification = classify(args, &manifest, current_platform());
361 dispatch_r3(
362 args,
363 classification,
364 &manifest,
365 &paths,
366 &determination.record,
367 &agent_binding,
368 now,
369 delegate,
370 )
371}
372
373#[allow(clippy::too_many_arguments)]
374fn dispatch_r3<F>(
375 args: &[OsString],
376 classification: Classification,
377 manifest: &Manifest,
378 paths: &StatePaths,
379 rung: &RungRecord,
380 agent_binding: &AgentBinding,
381 now: u64,
382 delegate_to_upstream: F,
383) -> i32
384where
385 F: FnOnce(&[OsString]) -> i32,
386{
387 match classification {
388 Classification::Mechanical => delegate_to_upstream(args),
389 Classification::Admin { tuple } => {
390 if std::env::var_os("GH_SHIM_BYPASS").as_deref() == Some(OsStr::new("operator")) {
391 let repository = explicit_repo(args).or_else(infer_repository_from_git);
392 if let Err(error) = append_bypass_audit(paths, &tuple, repository.as_deref(), now) {
393 return refuse(
394 RefusalCode::BypassAuditUnavailable,
395 &format!("operator bypass audit could not be appended: {error}"),
396 );
397 }
398 delegate_to_upstream(args)
399 } else {
400 refuse(
401 RefusalCode::AdminTier,
402 "this action requires GH_SHIM_BYPASS=operator",
403 )
404 }
405 }
406 Classification::Governed { tuple, canonical } => {
407 let request =
408 match canonicalize_governed(args, &tuple, &canonical, manifest.manifest_version) {
409 Ok(request) => request,
410 Err(error) => return refuse_governed_canonicalization(&error),
411 };
412 let mutation = GithubReadMutation::from_governed_request(&request);
413 let outcome = route_governed(paths, rung, agent_binding, request, now);
414 invalidate_successful_github_read_mutation(mutation.as_ref(), &outcome);
415 governed_outcome_status(paths, agent_binding, now, outcome)
416 }
417 Classification::Unclassified => refuse(
418 RefusalCode::Unclassified,
419 &unclassified_refusal_text(manifest.manifest_version),
420 ),
421 Classification::Destructive => refuse(
422 RefusalCode::DestructiveFlag,
423 "destructive GitHub operations are not available through the shim",
424 ),
425 }
426}
427
428fn unclassified_refusal_text(manifest_version: u64) -> String {
429 format!(
430 "no manifest declaration for this invocation (manifest {manifest_version}); GH_SHIM_BYPASS does not apply to undeclared invocations - this verb needs a manifest declaration"
431 )
432}
433
434fn refuse_governed_canonicalization(error: &CanonicalizeError) -> i32 {
435 refuse(error.code, &error.text)
436}
437
438fn governed_outcome_status(
439 paths: &StatePaths,
440 agent_binding: &AgentBinding,
441 now: u64,
442 outcome: RouteOutcome,
443) -> i32 {
444 match outcome {
445 RouteOutcome::Result(output) => {
446 print!("{output}");
447 0
448 }
449 RouteOutcome::StateAppliedCommentFailed(output) => {
450 print!("{output}");
451 UPSTREAM_FAILURE_EXIT_STATUS
452 }
453 RouteOutcome::UpstreamError(body) => {
454 eprintln!("{body}");
455 UPSTREAM_FAILURE_EXIT_STATUS
456 }
457 RouteOutcome::Refusal(code) => refuse(RefusalCode::SeamRefusal, &seam_refusal_text(&code)),
458 RouteOutcome::UnboundIdentity => refuse(
459 RefusalCode::UnboundIdentity,
460 "the project binding was unavailable at route time",
461 ),
462 RouteOutcome::SchemaMismatch(message) => refuse(RefusalCode::SeamSchemaMismatch, &message),
463 RouteOutcome::GovernanceUnavailable => {
464 refuse_governance_unavailable(paths, agent_binding, now, GOVERNANCE_UNAVAILABLE_TEXT)
465 }
466 RouteOutcome::GovernanceUnavailableTimedOut { stage, elapsed_ms } => {
467 let text = if stage == ProbeStage::Connect {
468 GOVERNANCE_UNAVAILABLE_TEXT.to_string()
469 } else {
470 governance_probe_timeout_text(elapsed_ms, stage)
471 };
472 refuse_governance_unavailable(paths, agent_binding, now, &text)
473 }
474 RouteOutcome::Unavailable(message) => refuse(RefusalCode::SeamUnavailable, &message),
475 }
476}
477
478fn seam_refusal_text(code: &str) -> String {
479 format!("governance seam refused the action: {code}")
480}
481
482fn is_reserved_self_report(args: &[OsString]) -> bool {
483 args.first()
484 .and_then(|arg| arg.to_str())
485 .is_some_and(|arg| RESERVED_SELF_REPORT.contains(&arg))
486}
487
488#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
489#[serde(rename_all = "snake_case")]
490enum ProbeStage {
491 Connect,
492 CatalogList,
493 OpenRoute,
494}
495
496impl ProbeStage {
497 fn as_str(self) -> &'static str {
498 match self {
499 Self::Connect => "connect",
500 Self::CatalogList => "catalog_list",
501 Self::OpenRoute => "open_route",
502 }
503 }
504}
505
506impl std::fmt::Display for ProbeStage {
507 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
508 write!(f, "{}", self.as_str())
509 }
510}
511
512#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
513struct LastProbeReport {
514 stage: String,
515 elapsed_ms: u64,
516 outcome: String,
517}
518
519#[derive(Clone, Debug)]
520struct StatePaths {
521 root: PathBuf,
522 manifest: PathBuf,
523 rung: PathBuf,
524 bypass_audit: PathBuf,
525 unexpected_gh_route_advertisers: PathBuf,
526 seam_state: PathBuf,
527 last_valid_manifest: PathBuf,
528 version_high_water: PathBuf,
529 numeric_ids: PathBuf,
530 manifests_dir: PathBuf,
531 last_probe: PathBuf,
532}
533
534impl StatePaths {
535 fn from_process() -> Self {
536 Self::from_root(gh_shim_state_dir_from(
537 crate::environment::non_empty_os_var(GH_SHIM_STATE_DIR_ENV).as_deref(),
538 crate::environment::non_empty_os_var("XDG_STATE_HOME").as_deref(),
539 crate::environment::non_empty_os_var("HOME").as_deref(),
540 ))
541 }
542
543 fn from_root(root: PathBuf) -> Self {
544 Self {
545 manifest: root.join("gh-routing-manifest.json"),
546 rung: root.join("rung-cache.json"),
547 bypass_audit: root.join("operator-bypass.jsonl"),
548 unexpected_gh_route_advertisers: root.join("unexpected-gh-route-advertisers.json"),
549 seam_state: root.join("seam-state.json"),
550 last_valid_manifest: root.join("last-valid-manifest.json"),
551 version_high_water: root.join("manifest-version-high-water.json"),
552 numeric_ids: root.join("numeric-ids.json"),
553 manifests_dir: root.join("manifests"),
554 last_probe: root.join("last-probe.json"),
555 root,
556 }
557 }
558}
559
560fn write_last_probe_silently(paths: &StatePaths, probe: &LastProbeReport) {
561 let Ok(bytes) = serde_json::to_vec(probe) else {
562 return;
563 };
564 let _ = fs::create_dir_all(&paths.root);
565 let temporary = paths.last_probe.with_extension("tmp");
566 if fs::write(&temporary, bytes).is_ok() {
567 let _ = fs::rename(temporary, &paths.last_probe);
568 }
569}
570
571fn read_last_probe(paths: &StatePaths) -> Option<LastProbeReport> {
572 serde_json::from_slice(&fs::read(&paths.last_probe).ok()?).ok()
573}
574
575fn gh_shim_state_dir_from(
590 dedicated_override: Option<&OsStr>,
591 xdg_state_home: Option<&OsStr>,
592 home: Option<&OsStr>,
593) -> PathBuf {
594 if let Some(path) = dedicated_override
595 .filter(|value| !value.is_empty())
596 .map(PathBuf::from)
597 .filter(|path| path.is_absolute())
598 {
599 return path;
600 }
601 xdg_state_home
602 .filter(|value| !value.is_empty())
603 .map(PathBuf::from)
604 .filter(|path| path.is_absolute())
605 .or_else(|| {
606 home.filter(|value| !value.is_empty())
607 .map(|home| PathBuf::from(home).join(".local/state"))
608 })
609 .unwrap_or_else(std::env::temp_dir)
610 .join("cortexkit")
611 .join("aft")
612 .join("gh-shim")
613}
614
615#[derive(Clone, Debug, Deserialize, Serialize)]
616struct RungRecord {
617 rung: Rung,
618 as_of_unix_secs: u64,
619 #[serde(default)]
620 inputs: BTreeMap<String, String>,
621 #[serde(default)]
622 manifest_version: Option<u64>,
623 #[serde(default)]
624 recorded_by_image_path: Option<String>,
625 #[serde(default)]
626 recorded_by_version: Option<String>,
627 #[serde(default)]
628 recorded_by_repo_key: Option<String>,
629 #[serde(default)]
630 last_reachable_unix_secs: Option<u64>,
631}
632
633#[derive(Clone, Debug)]
634struct RungRecordProvenance {
635 image_path: String,
636 version: String,
637 repo_key: String,
638}
639
640impl RungRecordProvenance {
641 fn for_cwd(cwd: &Path) -> Self {
642 let project_root = project_root_for(cwd);
643 Self {
644 image_path: executing_image().to_string_lossy().into_owned(),
645 version: env!("CARGO_PKG_VERSION").to_string(),
646 repo_key: repository_key_from_origin(&project_root)
647 .unwrap_or_else(|| "unresolved (no GitHub origin)".to_string()),
648 }
649 }
650}
651
652impl RungRecord {
653 fn fresh_at(&self, now: u64) -> bool {
654 now.saturating_sub(self.as_of_unix_secs) < DISCOVERY_CACHE_TTL.as_secs()
655 }
656
657 fn recently_reachable(&self, now: u64) -> bool {
658 let reachable_at = self
659 .last_reachable_unix_secs
660 .unwrap_or(self.as_of_unix_secs);
661 now.saturating_sub(reachable_at) < RECENTLY_REACHABLE_WINDOW.as_secs()
662 }
663}
664
665#[derive(Clone, Copy, Debug, Eq, PartialEq)]
666#[repr(usize)]
667enum R1Reason {
668 DisabledByConfig,
669 AbsentOrUnparseable,
670 Unreachable,
671 DiscoveryBudgetExhausted,
672 #[cfg(test)]
673 Count,
674}
675
676impl R1Reason {
677 #[cfg(test)]
678 const ALL: [Self; Self::Count as usize] = [
679 Self::DisabledByConfig,
680 Self::AbsentOrUnparseable,
681 Self::Unreachable,
682 Self::DiscoveryBudgetExhausted,
683 ];
684
685 const fn diagnostic(self) -> &'static str {
686 match self {
687 Self::DisabledByConfig => "disabled_by_config",
688 Self::AbsentOrUnparseable => "absent_or_unparseable",
689 Self::Unreachable => "unreachable",
690 Self::DiscoveryBudgetExhausted => "discovery_budget_exhausted",
691 #[cfg(test)]
692 Self::Count => unreachable!(),
693 }
694 }
695}
696
697#[derive(Clone, Copy, Debug, Eq, PartialEq)]
698#[repr(usize)]
699enum R2Reason {
700 ManifestUnavailable,
701 AgentBindingUnavailable,
702 AgentCredentialsPresent,
703 DaemonUnreachable,
704 CatalogGhRouteAbsent,
705 GhRouteHolderUnbound,
706 #[cfg(test)]
707 Count,
708}
709
710impl R2Reason {
711 #[cfg(test)]
712 const ALL: [Self; Self::Count as usize] = [
713 Self::ManifestUnavailable,
714 Self::AgentBindingUnavailable,
715 Self::AgentCredentialsPresent,
716 Self::DaemonUnreachable,
717 Self::CatalogGhRouteAbsent,
718 Self::GhRouteHolderUnbound,
719 ];
720
721 const fn diagnostic(self) -> &'static str {
722 match self {
723 Self::ManifestUnavailable => "manifest_unavailable",
724 Self::AgentBindingUnavailable => "agent_binding_unavailable",
725 Self::AgentCredentialsPresent => "agent_credentials_present",
726 Self::DaemonUnreachable => "daemon_unreachable",
727 Self::CatalogGhRouteAbsent => "catalog_gh_route_absent",
728 Self::GhRouteHolderUnbound => "gh_route_holder_unbound",
729 #[cfg(test)]
730 Self::Count => unreachable!(),
731 }
732 }
733}
734
735#[derive(Clone, Debug)]
736struct RungDetermination {
737 record: RungRecord,
738 operator_disabled: bool,
739 refusal_detail: Option<String>,
740}
741
742impl RungDetermination {
743 fn r1(now: u64, reason: R1Reason) -> Self {
744 Self {
745 record: RungRecord {
746 rung: Rung::R1,
747 as_of_unix_secs: now,
748 inputs: BTreeMap::from([(
749 "connection_file".to_string(),
750 reason.diagnostic().to_string(),
751 )]),
752 manifest_version: None,
753 recorded_by_image_path: None,
754 recorded_by_version: None,
755 recorded_by_repo_key: None,
756 last_reachable_unix_secs: None,
757 },
758 operator_disabled: reason == R1Reason::DisabledByConfig,
759 refusal_detail: None,
760 }
761 }
762
763 fn r2(
764 now: u64,
765 reason: R2Reason,
766 manifest_version: Option<u64>,
767 provenance: &RungRecordProvenance,
768 ) -> Self {
769 Self {
770 record: RungRecord {
771 rung: Rung::R2,
772 as_of_unix_secs: now,
773 inputs: BTreeMap::from([
774 ("connection_file".to_string(), "ready".to_string()),
775 (reason.diagnostic().to_string(), "failed".to_string()),
776 ]),
777 manifest_version,
778 recorded_by_image_path: Some(provenance.image_path.clone()),
779 recorded_by_version: Some(provenance.version.clone()),
780 recorded_by_repo_key: Some(provenance.repo_key.clone()),
781 last_reachable_unix_secs: None,
782 },
783 operator_disabled: false,
784 refusal_detail: None,
785 }
786 }
787
788 fn r3(now: u64, manifest_version: u64, provenance: &RungRecordProvenance) -> Self {
789 Self {
790 record: RungRecord {
791 rung: Rung::R3,
792 as_of_unix_secs: now,
793 inputs: BTreeMap::from([
794 ("connection_file".to_string(), "ready".to_string()),
795 ("catalog_gh_route".to_string(), "ready".to_string()),
796 ("agent_binding".to_string(), "ready".to_string()),
797 ("manifest".to_string(), "ready".to_string()),
798 (
799 "agent_credentials_present".to_string(),
800 "absent".to_string(),
801 ),
802 ]),
803 manifest_version: Some(manifest_version),
804 recorded_by_image_path: Some(provenance.image_path.clone()),
805 recorded_by_version: Some(provenance.version.clone()),
806 recorded_by_repo_key: Some(provenance.repo_key.clone()),
807 last_reachable_unix_secs: Some(now),
808 },
809 operator_disabled: false,
810 refusal_detail: None,
811 }
812 }
813
814 fn cached(record: RungRecord) -> Self {
815 Self {
816 record,
817 operator_disabled: false,
818 refusal_detail: None,
819 }
820 }
821}
822
823#[derive(Debug)]
824enum GovernanceDisposition {
825 Delegate,
826 Ready,
827 Unavailable(AgentBinding),
828 Unclassified { manifest_version: u64 },
829 Destructive,
830}
831
832fn structural_governance_disposition(
833 determination: &RungDetermination,
834 classification: &Classification,
835 agent_binding: Option<AgentBinding>,
836 manifest_version: u64,
837) -> GovernanceDisposition {
838 if determination.operator_disabled || matches!(classification, Classification::Mechanical) {
839 return GovernanceDisposition::Delegate;
840 }
841 let Some(agent_binding) = agent_binding else {
842 return GovernanceDisposition::Delegate;
843 };
844 if determination.record.rung == Rung::R3 {
845 return GovernanceDisposition::Ready;
846 }
847
848 match classification {
849 Classification::Governed { .. } | Classification::Admin { .. } => {
850 GovernanceDisposition::Unavailable(agent_binding)
851 }
852 Classification::Unclassified => GovernanceDisposition::Unclassified { manifest_version },
853 Classification::Destructive => GovernanceDisposition::Destructive,
854 Classification::Mechanical => GovernanceDisposition::Delegate,
855 }
856}
857
858fn non_r3_governance_disposition(
859 cwd: &Path,
860 determination: &RungDetermination,
861 args: &[OsString],
862 manifest: &Manifest,
863 platform: &str,
864) -> GovernanceDisposition {
865 if determination.operator_disabled {
866 return GovernanceDisposition::Delegate;
867 }
868
869 let classification = classify(args, manifest, platform);
870 if matches!(classification, Classification::Mechanical) {
871 return GovernanceDisposition::Delegate;
872 }
873 if matches!(classification, Classification::Destructive) {
874 return GovernanceDisposition::Destructive;
875 }
876
877 let agent_binding = resolved_agent_binding(manifest, cwd);
881 structural_governance_disposition(
882 determination,
883 &classification,
884 agent_binding,
885 manifest.manifest_version,
886 )
887}
888
889fn determine_rung(paths: &StatePaths, cwd: &Path, now: u64) -> RungDetermination {
890 let deadline = std::time::Instant::now() + DISCOVERY_BUDGET;
894 let config_doc = read_user_config_doc();
895 determine_rung_from_doc(paths, cwd, now, deadline, config_doc.as_deref())
896}
897
898fn determine_rung_from_doc(
904 paths: &StatePaths,
905 cwd: &Path,
906 now: u64,
907 deadline: std::time::Instant,
908 config_doc: Option<&str>,
909) -> RungDetermination {
910 if gh_shim_enabled_from_config_doc(config_doc.unwrap_or("")) == Some(false) {
916 return RungDetermination::r1(now, R1Reason::DisabledByConfig);
917 }
918
919 let Some(connection_file) = connection_file_from_config_doc(config_doc.unwrap_or("")) else {
920 return RungDetermination::r1(now, R1Reason::AbsentOrUnparseable);
922 };
923 if !connection_file.is_file() {
924 return RungDetermination::r1(now, R1Reason::Unreachable);
925 }
926
927 let cached = load_rung_record(paths);
928 if std::time::Instant::now() >= deadline {
929 let budget_ms = DISCOVERY_BUDGET.as_millis();
930 let stage = ProbeStage::Connect;
931 let probe = LastProbeReport {
932 stage: stage.as_str().to_string(),
933 elapsed_ms: budget_ms as u64,
934 outcome: "timed_out".to_string(),
935 };
936 write_last_probe_silently(paths, &probe);
937 if let Some(record) = cached.as_ref().filter(|record| {
938 record.rung == Rung::R3 && (record.fresh_at(now) || record.recently_reachable(now))
939 }) {
940 return RungDetermination::cached(record.clone());
941 }
942 let mut determination = cached
943 .filter(|record| record.fresh_at(now))
944 .map(RungDetermination::cached)
945 .unwrap_or_else(|| RungDetermination::r1(now, R1Reason::DiscoveryBudgetExhausted));
946 if determination.record.rung == Rung::R1 {
947 determination.refusal_detail =
948 Some(governance_probe_timeout_text(budget_ms as u64, stage));
949 }
950 return determination;
951 }
952 if let Some(record) = cached.as_ref().filter(|record| record.fresh_at(now)) {
953 if record.rung != Rung::R3
954 || resolve_manifest(paths, now)
955 .manifest()
956 .and_then(|manifest| resolved_agent_binding(manifest, cwd))
957 .is_some()
958 {
959 return RungDetermination::cached(record.clone());
960 }
961 }
962
963 let provenance = RungRecordProvenance::for_cwd(cwd);
964 let Some(manifest) = resolve_manifest(paths, now).into_manifest() else {
969 let determination =
970 RungDetermination::r2(now, R2Reason::ManifestUnavailable, None, &provenance);
971 write_rung_record_silently(paths, &determination.record);
972 return determination;
973 };
974 let Some(agent_binding) = resolved_agent_binding(&manifest, cwd) else {
975 let determination = RungDetermination::r2(
976 now,
977 R2Reason::AgentBindingUnavailable,
978 Some(manifest.manifest_version),
979 &provenance,
980 );
981 write_rung_record_silently(paths, &determination.record);
982 return determination;
983 };
984
985 let discovery = probe_governance_with_retry(
986 paths,
987 &connection_file,
988 cwd,
989 deadline,
990 &agent_binding.agent_id,
991 );
992 let determination = match discovery {
993 ProbeResult::Ready { module_id } => {
994 match find_ambient_agent_credential(&manifest.detectors) {
995 Some(source) => {
996 let mut determination = RungDetermination::r2(
997 now,
998 R2Reason::AgentCredentialsPresent,
999 Some(manifest.manifest_version),
1000 &provenance,
1001 );
1002 determination.record.last_reachable_unix_secs = Some(now);
1003 determination
1004 .record
1005 .inputs
1006 .insert("agent_credentials_present".to_string(), source);
1007 determination
1008 .record
1009 .inputs
1010 .insert("catalog_holder".to_string(), module_id);
1011 determination
1012 }
1013 None => RungDetermination::r3(now, manifest.manifest_version, &provenance),
1014 }
1015 }
1016 ProbeResult::Unreachable => {
1017 RungDetermination::r2(now, R2Reason::DaemonUnreachable, None, &provenance)
1018 }
1019 ProbeResult::NoRoute => {
1020 RungDetermination::r2(now, R2Reason::CatalogGhRouteAbsent, None, &provenance)
1021 }
1022 ProbeResult::Unbound => {
1025 RungDetermination::r2(now, R2Reason::GhRouteHolderUnbound, None, &provenance)
1026 }
1027 ProbeResult::TimedOut { stage, .. } => {
1028 if let Some(record) = cached.as_ref().filter(|record| {
1029 record.rung == Rung::R3 && (record.fresh_at(now) || record.recently_reachable(now))
1030 }) {
1031 RungDetermination::cached(record.clone())
1032 } else if stage == ProbeStage::Connect {
1033 RungDetermination::r2(now, R2Reason::DaemonUnreachable, None, &provenance)
1034 } else {
1035 let budget_ms = DISCOVERY_BUDGET.as_millis();
1036 let refusal_text = governance_probe_timeout_text(budget_ms as u64, stage);
1037 let mut determination = cached
1038 .filter(|record| record.fresh_at(now))
1039 .map(RungDetermination::cached)
1040 .unwrap_or_else(|| {
1041 RungDetermination::r1(now, R1Reason::DiscoveryBudgetExhausted)
1042 });
1043 if determination.record.rung == Rung::R1 {
1044 determination.refusal_detail = Some(refusal_text);
1045 }
1046 determination
1047 }
1048 }
1049 };
1050
1051 if determination.record.rung != Rung::R1 {
1052 write_rung_record_silently(paths, &determination.record);
1053 }
1054 determination
1055}
1056
1057pub fn configured_connection_file() -> Option<PathBuf> {
1058 let xdg_config_home = std::env::var_os("XDG_CONFIG_HOME");
1059 let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"));
1060 configured_connection_file_from(xdg_config_home.as_deref(), home.as_deref())
1061}
1062
1063fn configured_connection_file_from(
1064 xdg_config_home: Option<&OsStr>,
1065 home: Option<&OsStr>,
1066) -> Option<PathBuf> {
1067 let config_path = crate::subc_config::user_config_path_from(xdg_config_home, home)?;
1079 let doc = fs::read_to_string(config_path).ok()?;
1080 connection_file_from_config_doc(&doc).filter(|path| path.is_file())
1081}
1082
1083fn read_user_config_doc() -> Option<String> {
1087 let xdg_config_home = std::env::var_os("XDG_CONFIG_HOME");
1088 let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"));
1089 let config_path =
1090 crate::subc_config::user_config_path_from(xdg_config_home.as_deref(), home.as_deref())?;
1091 fs::read_to_string(config_path).ok()
1092}
1093
1094fn gh_shim_enabled_from_config_doc(doc: &str) -> Option<bool> {
1097 let value: Value = serde_json::from_str(&crate::jsonc::strip_jsonc(doc)).ok()?;
1098 let github = value.get("github");
1099 let master = github
1100 .and_then(|config| config.get("enabled"))
1101 .and_then(Value::as_bool)
1102 .unwrap_or(true);
1103 let shim = github
1104 .and_then(|config| config.get("shim"))
1105 .and_then(Value::as_bool)
1106 .or_else(|| {
1107 value
1108 .get("gh_shim")
1109 .and_then(|config| config.get("enabled"))
1110 .and_then(Value::as_bool)
1111 })
1112 .unwrap_or(true);
1113 Some(master && shim)
1114}
1115
1116fn connection_file_from_config_doc(doc: &str) -> Option<PathBuf> {
1117 let value: Value = serde_json::from_str(&crate::jsonc::strip_jsonc(doc)).ok()?;
1118 let raw = value.get("subc")?.get("connection_file")?.as_str()?.trim();
1119 let path = PathBuf::from(raw);
1120 (!raw.is_empty() && path.is_absolute()).then_some(path)
1121}
1122
1123fn load_rung_record(paths: &StatePaths) -> Option<RungRecord> {
1124 serde_json::from_slice(&fs::read(&paths.rung).ok()?).ok()
1125}
1126
1127fn write_rung_record_silently(paths: &StatePaths, record: &RungRecord) {
1128 let Ok(bytes) = serde_json::to_vec(record) else {
1129 return;
1130 };
1131 let _ = fs::create_dir_all(&paths.root);
1132 let temporary = paths.root.join("rung-cache.json.tmp");
1133 if fs::write(&temporary, bytes).is_ok() {
1134 let _ = fs::rename(temporary, &paths.rung);
1135 }
1136}
1137
1138#[derive(Debug)]
1139enum ProbeResult {
1140 Ready { module_id: String },
1141 Unreachable,
1142 NoRoute,
1143 Unbound,
1144 TimedOut { stage: ProbeStage },
1145}
1146
1147fn probe_governance_with_retry(
1154 paths: &StatePaths,
1155 connection_file: &Path,
1156 cwd: &Path,
1157 deadline: std::time::Instant,
1158 agent_id: &str,
1159) -> ProbeResult {
1160 let first = probe_governance(paths, connection_file, cwd, deadline, agent_id);
1161 if !matches!(first, ProbeResult::TimedOut { .. }) {
1162 return first;
1163 }
1164 std::thread::sleep(DISCOVERY_RETRY_BACKOFF);
1165 let retry_deadline = std::time::Instant::now() + DISCOVERY_BUDGET;
1169 probe_governance(paths, connection_file, cwd, retry_deadline, agent_id)
1170}
1171
1172fn probe_governance(
1173 paths: &StatePaths,
1174 connection_file: &Path,
1175 cwd: &Path,
1176 deadline: std::time::Instant,
1177 agent_id: &str,
1178) -> ProbeResult {
1179 let start = std::time::Instant::now();
1180 let remaining = deadline.saturating_duration_since(start);
1181 if remaining.is_zero() {
1182 let probe = LastProbeReport {
1183 stage: ProbeStage::Connect.as_str().to_string(),
1184 elapsed_ms: DISCOVERY_BUDGET.as_millis() as u64,
1185 outcome: "timed_out".to_string(),
1186 };
1187 write_last_probe_silently(paths, &probe);
1188 return ProbeResult::TimedOut {
1189 stage: ProbeStage::Connect,
1190 };
1191 }
1192 let connection_file = connection_file.to_path_buf();
1193 let project_root = project_root_for(cwd);
1194 let record_paths = paths.clone();
1195 let agent_id = agent_id.to_string();
1196 let Ok(runtime) = tokio::runtime::Builder::new_current_thread()
1197 .enable_io()
1198 .enable_time()
1199 .build()
1200 else {
1201 let probe = LastProbeReport {
1202 stage: ProbeStage::Connect.as_str().to_string(),
1203 elapsed_ms: start.elapsed().as_millis() as u64,
1204 outcome: "unreachable".to_string(),
1205 };
1206 write_last_probe_silently(paths, &probe);
1207 return ProbeResult::Unreachable;
1208 };
1209
1210 let current_stage = std::sync::Arc::new(std::sync::Mutex::new(ProbeStage::Connect));
1211 let stage_handle = std::sync::Arc::clone(¤t_stage);
1212
1213 let result = runtime.block_on(async move {
1218 tokio::time::timeout(remaining, async move {
1219 let options = ConsumerOptions {
1220 call_timeout: remaining,
1221 ..ConsumerOptions::default()
1222 };
1223 let consumer = SubcConsumer::connect(&connection_file, options)
1224 .await
1225 .map_err(|_| ProbeResult::Unreachable)?;
1226
1227 *stage_handle.lock().unwrap() = ProbeStage::CatalogList;
1228 let catalog = consumer
1229 .catalog_list()
1230 .await
1231 .map_err(|_| ProbeResult::Unreachable)?;
1232 let holder = route_holder(&catalog.modules);
1233 record_unexpected_gh_route_advertisers(&record_paths, &holder.unexpected_advertisers);
1234 let Some(module_id) = holder.module_id else {
1235 return Err(ProbeResult::NoRoute);
1236 };
1237
1238 *stage_handle.lock().unwrap() = ProbeStage::OpenRoute;
1239 let identity = BindIdentity {
1240 project_root: project_root.to_string_lossy().into_owned().into(),
1241 harness: "aft-gh-shim".to_string(),
1242 session: gh_session_id(&agent_id),
1243 };
1244 let route = consumer
1245 .open_route(
1246 RouteTarget::ManagementSurface {
1247 module_id: module_id.clone(),
1248 },
1249 identity,
1250 CallOptions::default(),
1251 )
1252 .await
1253 .map_err(|_| ProbeResult::Unbound)?;
1254 let _ = consumer
1255 .close_handle(&route, CloseRouteOptions::default())
1256 .await;
1257 Ok(module_id)
1258 })
1259 .await
1260 });
1261
1262 let final_stage = *current_stage.lock().unwrap();
1263 let elapsed_ms = start.elapsed().as_millis() as u64;
1264
1265 match result {
1266 Ok(Ok(module_id)) => {
1267 let probe = LastProbeReport {
1268 stage: final_stage.as_str().to_string(),
1269 elapsed_ms,
1270 outcome: "ready".to_string(),
1271 };
1272 write_last_probe_silently(paths, &probe);
1273 ProbeResult::Ready { module_id }
1274 }
1275 Ok(Err(ProbeResult::Unreachable)) => {
1276 if std::time::Instant::now() >= deadline {
1277 let probe = LastProbeReport {
1278 stage: final_stage.as_str().to_string(),
1279 elapsed_ms: elapsed_ms.max(DISCOVERY_BUDGET.as_millis() as u64),
1280 outcome: "timed_out".to_string(),
1281 };
1282 write_last_probe_silently(paths, &probe);
1283 ProbeResult::TimedOut { stage: final_stage }
1284 } else {
1285 let probe = LastProbeReport {
1286 stage: final_stage.as_str().to_string(),
1287 elapsed_ms,
1288 outcome: "unreachable".to_string(),
1289 };
1290 write_last_probe_silently(paths, &probe);
1291 ProbeResult::Unreachable
1292 }
1293 }
1294 Ok(Err(ProbeResult::NoRoute)) => {
1295 let probe = LastProbeReport {
1296 stage: final_stage.as_str().to_string(),
1297 elapsed_ms,
1298 outcome: "no_route".to_string(),
1299 };
1300 write_last_probe_silently(paths, &probe);
1301 ProbeResult::NoRoute
1302 }
1303 Ok(Err(ProbeResult::Unbound)) => {
1304 if std::time::Instant::now() >= deadline {
1305 let probe = LastProbeReport {
1306 stage: final_stage.as_str().to_string(),
1307 elapsed_ms: elapsed_ms.max(DISCOVERY_BUDGET.as_millis() as u64),
1308 outcome: "timed_out".to_string(),
1309 };
1310 write_last_probe_silently(paths, &probe);
1311 ProbeResult::TimedOut { stage: final_stage }
1312 } else {
1313 let probe = LastProbeReport {
1314 stage: final_stage.as_str().to_string(),
1315 elapsed_ms,
1316 outcome: "unbound".to_string(),
1317 };
1318 write_last_probe_silently(paths, &probe);
1319 ProbeResult::Unbound
1320 }
1321 }
1322 Ok(Err(other)) => other,
1323 Err(_) => {
1324 let probe = LastProbeReport {
1325 stage: final_stage.as_str().to_string(),
1326 elapsed_ms: elapsed_ms.max(DISCOVERY_BUDGET.as_millis() as u64),
1327 outcome: "timed_out".to_string(),
1328 };
1329 write_last_probe_silently(paths, &probe);
1330 ProbeResult::TimedOut { stage: final_stage }
1331 }
1332 }
1333}
1334
1335#[derive(Debug, Default, Eq, PartialEq)]
1336struct RouteHolder {
1337 module_id: Option<String>,
1338 unexpected_advertisers: Vec<String>,
1339}
1340
1341fn route_holder(entries: &[subc_client_rs::CatalogEntry]) -> RouteHolder {
1342 select_route_holder(entries.iter().filter_map(|entry| {
1343 entry
1344 .roles
1345 .iter()
1346 .any(|role| {
1347 matches!(
1348 role,
1349 ProviderRole::ManagementSurface { operations, .. }
1350 if operations.iter().any(|operation| operation.name == ROUTING_OPERATION)
1351 )
1352 })
1353 .then(|| entry.module_id.clone())
1354 }))
1355}
1356
1357fn select_route_holder(advertisers: impl IntoIterator<Item = String>) -> RouteHolder {
1358 let mut holder = None;
1359 let mut unexpected_advertisers = BTreeSet::new();
1360 for advertiser in advertisers {
1361 if advertiser == ROUTING_HOLDER_MODULE_ID {
1366 holder.get_or_insert(advertiser);
1367 } else {
1368 unexpected_advertisers.insert(advertiser);
1369 }
1370 }
1371 RouteHolder {
1372 module_id: holder,
1373 unexpected_advertisers: unexpected_advertisers.into_iter().collect(),
1374 }
1375}
1376
1377fn project_root_for(cwd: &Path) -> PathBuf {
1378 let canonical = cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf());
1379 canonical
1380 .ancestors()
1381 .find(|path| path.join(".git").exists())
1382 .map(Path::to_path_buf)
1383 .unwrap_or(canonical)
1384}
1385
1386#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1387struct AgentBinding {
1388 repo: String,
1389 agent_id: String,
1390}
1391
1392fn resolved_agent_binding(manifest: &Manifest, cwd: &Path) -> Option<AgentBinding> {
1393 let project_root = project_root_for(cwd);
1394 let repo = repository_key_from_origin(&project_root)?;
1395 manifest
1396 .bindings
1397 .get(&repo)
1398 .cloned()
1399 .map(|agent_id| AgentBinding { repo, agent_id })
1400}
1401
1402fn co_author_line(paths: &StatePaths) -> Option<String> {
1403 let cwd = std::env::current_dir().ok()?;
1404 let manifest = load_manifest(paths, unix_seconds()).ok()?;
1405 let binding = resolved_agent_binding(&manifest, &cwd)?;
1406 let login = binding.agent_id;
1407 if !valid_github_login(&login) {
1408 return None;
1409 }
1410 let numeric_id =
1411 cached_numeric_id(paths, &login).or_else(|| resolve_and_cache_numeric_id(paths, &login))?;
1412 Some(format!(
1413 "Co-authored-by: {login} <{numeric_id}+{login}@users.noreply.github.com>"
1414 ))
1415}
1416
1417fn valid_github_login(login: &str) -> bool {
1418 let core = login.strip_suffix("[bot]").unwrap_or(login);
1419 !core.is_empty()
1420 && core.len() <= 100
1421 && !core.starts_with('-')
1422 && !core.ends_with('-')
1423 && core
1424 .bytes()
1425 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
1426}
1427
1428fn cached_numeric_ids(paths: &StatePaths) -> BTreeMap<String, u64> {
1429 fs::read(&paths.numeric_ids)
1430 .ok()
1431 .and_then(|bytes| serde_json::from_slice(&bytes).ok())
1432 .unwrap_or_default()
1433}
1434
1435fn cached_numeric_id(paths: &StatePaths, login: &str) -> Option<u64> {
1436 cached_numeric_ids(paths)
1437 .get(login)
1438 .copied()
1439 .filter(|id| *id > 0)
1440}
1441
1442fn resolve_and_cache_numeric_id(paths: &StatePaths, login: &str) -> Option<u64> {
1443 let image = executing_image();
1444 let real_gh = resolve_real_gh(&image)?;
1445 let encoded_login = url::form_urlencoded::byte_serialize(login.as_bytes()).collect::<String>();
1446 let output = Command::new(real_gh)
1447 .args(["api", &format!("users/{encoded_login}"), "--jq", ".id"])
1448 .output()
1449 .ok()?;
1450 if !output.status.success() {
1451 return None;
1452 }
1453 let numeric_id = String::from_utf8(output.stdout)
1454 .ok()?
1455 .trim()
1456 .parse::<u64>()
1457 .ok()
1458 .filter(|id| *id > 0)?;
1459 let mut ids = cached_numeric_ids(paths);
1460 ids.insert(login.to_string(), numeric_id);
1461 write_numeric_ids_silently(paths, &ids);
1462 Some(numeric_id)
1463}
1464
1465fn write_numeric_ids_silently(paths: &StatePaths, ids: &BTreeMap<String, u64>) {
1466 let Ok(bytes) = serde_json::to_vec(ids) else {
1467 return;
1468 };
1469 if fs::create_dir_all(&paths.root).is_err() {
1470 return;
1471 }
1472 let temporary = paths.root.join("numeric-ids.json.tmp");
1473 if fs::write(&temporary, bytes).is_ok() {
1474 #[cfg(windows)]
1475 let _ = fs::remove_file(&paths.numeric_ids);
1476 let _ = fs::rename(temporary, &paths.numeric_ids);
1477 }
1478}
1479
1480fn repository_key_from_origin(project_root: &Path) -> Option<String> {
1481 let remote = origin_remote(project_root)?;
1484 canonical_repository_key(&remote)
1485}
1486
1487fn origin_remote(cwd: &Path) -> Option<String> {
1488 let output = Command::new("git")
1489 .current_dir(cwd)
1490 .args(["remote", "get-url", "origin"])
1491 .output()
1492 .ok()?;
1493 output
1494 .status
1495 .success()
1496 .then(|| String::from_utf8(output.stdout).ok())
1497 .flatten()
1498 .map(|remote| remote.trim().to_string())
1499 .filter(|remote| !remote.is_empty())
1500}
1501
1502fn canonical_repository_key(value: &str) -> Option<String> {
1503 let remote = value.trim().trim_end_matches('/');
1504 let path = if let Some(path) = [
1505 "https://github.com/",
1506 "http://github.com/",
1507 "ssh://git@github.com/",
1508 "git://github.com/",
1509 "git@github.com:",
1510 "github.com/",
1511 ]
1512 .iter()
1513 .find_map(|prefix| remote.strip_prefix(prefix))
1514 {
1515 path
1516 } else if remote.contains("://") || remote.contains('@') || remote.contains(':') {
1517 return None;
1520 } else {
1521 remote
1522 }
1523 .trim_end_matches(".git")
1524 .trim_matches('/');
1525 let mut parts = path.split('/');
1526 let owner = parts.next()?.trim();
1527 let repository = parts.next()?.trim();
1528 (!owner.is_empty() && !repository.is_empty() && parts.next().is_none()).then(|| {
1529 format!(
1530 "{}/{}",
1531 owner.to_ascii_lowercase(),
1532 repository.to_ascii_lowercase()
1533 )
1534 })
1535}
1536
1537fn gh_session_id(agent_id: &str) -> String {
1538 format!("gh-shim:{agent_id}")
1539}
1540
1541#[derive(Clone, Debug, Default, Deserialize, Serialize)]
1542struct Detectors {
1543 #[serde(default)]
1544 wrapper_config_dirs: Vec<String>,
1545 #[serde(default)]
1546 credential_env_names: Vec<String>,
1547}
1548
1549fn find_ambient_agent_credential(detectors: &Detectors) -> Option<String> {
1550 for name in &detectors.credential_env_names {
1551 if std::env::var_os(name).is_some() {
1552 return Some(format!("env:{name}"));
1553 }
1554 }
1555
1556 let home = crate::environment::non_empty_os_var("HOME")
1557 .or_else(|| crate::environment::non_empty_os_var("USERPROFILE"))
1558 .map(PathBuf::from);
1559 for raw_pattern in &detectors.wrapper_config_dirs {
1560 let pattern = expand_home_pattern(raw_pattern, home.as_deref());
1561 let paths = if pattern.contains(['*', '?', '[', '{']) {
1562 crate::walk_boundary::expand_glob_same_file_system(&pattern).unwrap_or_default()
1565 } else {
1566 vec![PathBuf::from(pattern)]
1567 };
1568 for path in paths {
1569 if path.is_dir() {
1570 return Some(format!("path:{}", path.display()));
1571 }
1572 }
1573 }
1574
1575 let configured = crate::environment::non_empty_os_var("GH_CONFIG_DIR").map(PathBuf::from)?;
1579 if !configured.is_dir() {
1580 return None;
1581 }
1582 let name = configured.file_name()?.to_string_lossy();
1583 detectors
1584 .wrapper_config_dirs
1585 .iter()
1586 .any(|pattern| {
1587 Path::new(pattern).file_name().is_some_and(|glob_name| {
1588 glob::Pattern::new(&glob_name.to_string_lossy()).is_ok_and(|p| p.matches(&name))
1589 })
1590 })
1591 .then(|| format!("path:{}", configured.display()))
1592}
1593
1594fn expand_home_pattern(pattern: &str, home: Option<&Path>) -> String {
1595 pattern
1596 .strip_prefix("~/")
1597 .and_then(|suffix| home.map(|home| home.join(suffix).to_string_lossy().into_owned()))
1598 .unwrap_or_else(|| pattern.to_string())
1599}
1600
1601#[derive(Clone, Debug, Deserialize, Serialize)]
1602#[serde(untagged)]
1603enum TupleDecl {
1604 Name(String),
1605 Details {
1606 tuple: String,
1607 #[serde(default)]
1608 platform: Vec<String>,
1609 #[serde(default)]
1610 api_match: Option<String>,
1611 #[serde(default, alias = "reasoning")]
1618 rationale: Option<String>,
1619 },
1620}
1621
1622impl TupleDecl {
1623 fn tuple(&self) -> &str {
1624 match self {
1625 Self::Name(name) => name,
1626 Self::Details { tuple, .. } => tuple,
1627 }
1628 }
1629
1630 fn platform(&self) -> &[String] {
1631 match self {
1632 Self::Name(_) => &[],
1633 Self::Details { platform, .. } => platform,
1634 }
1635 }
1636
1637 fn empty_api_match_has_rationale(&self) -> bool {
1638 match self {
1639 Self::Details {
1640 api_match: Some(api_match),
1641 rationale,
1642 ..
1643 } if api_match.is_empty() => rationale
1644 .as_deref()
1645 .is_some_and(|text| !text.trim().is_empty()),
1646 _ => true,
1647 }
1648 }
1649}
1650
1651#[derive(Clone, Debug, Deserialize, Serialize)]
1652struct ApiRule {
1653 method: String,
1654 path_glob: String,
1655 tier: Tier,
1656 #[serde(default)]
1657 platform: Vec<String>,
1658 #[serde(default)]
1659 rationale: Option<String>,
1660}
1661
1662#[derive(Clone, Debug, Default, Deserialize, Serialize)]
1663struct Canonicalization {
1664 #[serde(default)]
1665 argv_forms: Vec<String>,
1666 #[serde(default)]
1667 target_fields: Vec<String>,
1668 #[serde(default)]
1669 body_fields: Vec<String>,
1670}
1671
1672#[derive(Clone, Debug, Default, Deserialize, Serialize)]
1673struct RepositorySection {
1674 #[serde(default)]
1675 tiers: BTreeMap<Tier, Vec<TupleDecl>>,
1676 #[serde(default, alias = "remove")]
1677 removed_tuples: Vec<String>,
1678}
1679
1680#[derive(Clone, Debug, Deserialize, Serialize)]
1681struct Manifest {
1682 artifact_id: String,
1683 manifest_version: u64,
1684 schema_floor: u64,
1685 issued_at_unix_secs: u64,
1689 #[serde(default)]
1690 detectors: Detectors,
1691 #[serde(default)]
1692 tiers: BTreeMap<Tier, Vec<TupleDecl>>,
1693 #[serde(default)]
1694 api_rules: Vec<ApiRule>,
1695 #[serde(default)]
1696 canonicalization: BTreeMap<String, Canonicalization>,
1697 #[serde(default)]
1698 repository_sections: BTreeMap<String, RepositorySection>,
1699 #[serde(default)]
1700 bindings: BTreeMap<String, String>,
1701}
1702
1703impl Manifest {
1704 fn validate(&self) -> Result<(), String> {
1705 if self.artifact_id != MANIFEST_ARTIFACT_ID {
1706 return Err(format!("unexpected artifact id {}", self.artifact_id));
1707 }
1708 if self.manifest_version == 0 {
1709 return Err("manifest_version must be positive".to_string());
1710 }
1711
1712 let mut declared = BTreeMap::<String, Tier>::new();
1713 for (tier, entries) in &self.tiers {
1714 for entry in entries {
1715 let tuple = normalized_tuple(entry.tuple())?;
1716 if entry.platform().is_empty() {
1717 return Err(format!("tuple {tuple} is missing its platform declaration"));
1718 }
1719 if !entry.empty_api_match_has_rationale() {
1720 return Err(format!(
1721 "tuple {tuple} has an empty api_match without rationale"
1722 ));
1723 }
1724 if let Some(previous) = declared.insert(tuple.clone(), *tier) {
1725 return Err(format!(
1726 "tuple {tuple} is declared in both {previous:?} and {tier:?}"
1727 ));
1728 }
1729 }
1730 }
1731
1732 let mut api_declared = BTreeSet::new();
1733 for rule in &self.api_rules {
1734 if rule.method.trim().is_empty() || rule.path_glob.trim().is_empty() {
1735 if rule.path_glob.is_empty()
1736 && rule
1737 .rationale
1738 .as_deref()
1739 .is_some_and(|text| !text.trim().is_empty())
1740 {
1741 continue;
1742 }
1743 return Err("api rule requires method and non-empty path_glob".to_string());
1744 }
1745 if rule.platform.is_empty() {
1746 return Err(format!(
1747 "api rule {} {} is missing its platform declaration",
1748 rule.method, rule.path_glob
1749 ));
1750 }
1751 if let Some(platform) = rule
1752 .platform
1753 .iter()
1754 .find(|platform| !matches!(platform.as_str(), "macos" | "linux"))
1755 {
1756 return Err(format!(
1757 "api rule {} {} names unknown host platform {platform}",
1758 rule.method, rule.path_glob
1759 ));
1760 }
1761 let key = format!("{} {}", rule.method.to_ascii_uppercase(), rule.path_glob);
1762 if !api_declared.insert(key.clone()) {
1763 return Err(format!("api rule {key} is declared more than once"));
1764 }
1765 }
1766
1767 let governed = self.tiers.get(&Tier::Governed).cloned().unwrap_or_default();
1768 for entry in &governed {
1769 let tuple = normalized_tuple(entry.tuple())?;
1770 let Some(canonical) = self.canonicalization.get(&tuple) else {
1771 return Err(format!("governed tuple {tuple} lacks canonicalization"));
1772 };
1773 if canonical.argv_forms.is_empty() || canonical.target_fields.is_empty() {
1774 return Err(format!(
1775 "governed tuple {tuple} has incomplete canonicalization"
1776 ));
1777 }
1778 }
1779 for tuple in self.canonicalization.keys() {
1780 if declared.get(tuple) != Some(&Tier::Governed) {
1781 return Err(format!(
1782 "canonicalization {tuple} does not name a governed tuple"
1783 ));
1784 }
1785 }
1786
1787 for (repository, agent_id) in &self.bindings {
1788 if canonical_repository_key(repository).as_deref() != Some(repository.as_str()) {
1789 return Err(format!(
1790 "binding repository {repository} is not canonical owner/name"
1791 ));
1792 }
1793 if agent_id.trim().is_empty() || agent_id.trim() != agent_id {
1794 return Err(format!(
1795 "binding repository {repository} has an invalid agent id"
1796 ));
1797 }
1798 }
1799
1800 for (repository, section) in &self.repository_sections {
1801 for removed in §ion.removed_tuples {
1802 if !declared.contains_key(&normalized_tuple(removed)?) {
1803 return Err(format!(
1804 "repository section {repository} removes undeclared tuple {removed}"
1805 ));
1806 }
1807 }
1808 for (tier, entries) in §ion.tiers {
1809 for entry in entries {
1810 let tuple = normalized_tuple(entry.tuple())?;
1811 let Some(base) = declared.get(&tuple) else {
1812 return Err(format!(
1813 "repository section {repository} adds tuple {tuple}"
1814 ));
1815 };
1816 if tier.rank() < base.rank() {
1817 return Err(format!(
1818 "repository section {repository} lowers tuple {tuple}"
1819 ));
1820 }
1821 }
1822 }
1823 }
1824 Ok(())
1825 }
1826
1827 fn tier_for_tuple(&self, tuple: &str, platform: &str) -> Option<Tier> {
1828 self.tiers.iter().find_map(|(tier, entries)| {
1829 entries
1830 .iter()
1831 .any(|entry| {
1832 normalized_tuple(entry.tuple()).ok().as_deref() == Some(tuple)
1833 && platform_matches(entry.platform(), platform)
1834 })
1835 .then_some(*tier)
1836 })
1837 }
1838}
1839
1840fn normalized_tuple(value: &str) -> Result<String, String> {
1841 let words = value
1842 .split_whitespace()
1843 .map(|word| word.to_ascii_lowercase())
1844 .collect::<Vec<_>>();
1845 (!words.is_empty())
1846 .then(|| words.join(" "))
1847 .ok_or_else(|| "tuple cannot be empty".to_string())
1848}
1849
1850fn platform_matches(platforms: &[String], current: &str) -> bool {
1851 platforms
1852 .iter()
1853 .any(|platform| platform.eq_ignore_ascii_case(current))
1854}
1855
1856#[derive(Clone, Debug, Deserialize, Serialize)]
1862struct SignedManifest {
1863 artifact_id: String,
1864 envelope_version: u64,
1865 key_id: String,
1866 fetched_at_unix_secs: u64,
1870 signature: String,
1871 manifest_bytes: String,
1872}
1873
1874#[derive(Clone, Debug)]
1875struct VerifiedManifest {
1876 manifest: Manifest,
1877 verified_by_key_id: String,
1878}
1879
1880#[derive(Clone, Debug)]
1881enum ManifestProblem {
1882 Missing,
1883 Invalid(String),
1884 BelowFloor {
1885 manifest_floor: u64,
1886 },
1887 RolledBack {
1891 manifest_version: u64,
1892 newest_accepted: u64,
1893 },
1894}
1895
1896impl ManifestProblem {
1897 fn diagnostic(&self) -> SelfReportDiagnostic {
1898 match self {
1899 Self::Missing => SelfReportDiagnostic::ManifestUnavailable,
1900 Self::Invalid(_) => SelfReportDiagnostic::ManifestInvalid,
1901 Self::BelowFloor { .. } => SelfReportDiagnostic::ManifestBelowFloor,
1902 Self::RolledBack { .. } => SelfReportDiagnostic::ManifestRollback,
1903 }
1904 }
1905
1906 fn status_label(&self) -> String {
1907 match self {
1908 Self::Missing => "unavailable".to_string(),
1909 Self::Invalid(error) => format!("invalid ({error})"),
1910 Self::BelowFloor { manifest_floor } => format!(
1911 "{} (manifest floor {manifest_floor}, shim floor {SCHEMA_FLOOR})",
1912 RefusalCode::ManifestBelowFloor.as_str()
1913 ),
1914 Self::RolledBack {
1915 manifest_version,
1916 newest_accepted,
1917 } => format!(
1918 "{} (manifest version {manifest_version}, newest accepted version {newest_accepted})",
1919 SelfReportDiagnostic::ManifestRollback.as_str()
1920 ),
1921 }
1922 }
1923
1924 fn fallback_notice_reason(&self) -> String {
1925 match self {
1926 Self::Missing => "manifest unavailable".to_string(),
1927 Self::Invalid(reason) => reason.clone(),
1928 Self::BelowFloor { manifest_floor } => {
1929 format!("manifest floor {manifest_floor}, shim floor {SCHEMA_FLOOR}")
1930 }
1931 Self::RolledBack {
1932 manifest_version,
1933 newest_accepted,
1934 } => {
1935 format!("manifest version {manifest_version}, newest accepted version {newest_accepted}")
1936 }
1937 }
1938 }
1939
1940 fn untrusted_manifest_key_steering(&self) -> Option<&'static str> {
1941 match self {
1942 Self::Invalid(reason) if reason.starts_with("untrusted manifest key id ") => {
1943 Some(UNTRUSTED_MANIFEST_KEY_STEERING)
1944 }
1945 Self::Missing
1946 | Self::Invalid(_)
1947 | Self::BelowFloor { .. }
1948 | Self::RolledBack { .. } => None,
1949 }
1950 }
1951}
1952
1953fn load_manifest(paths: &StatePaths, now: u64) -> Result<Manifest, ManifestProblem> {
2006 load_manifest_with_trust_set(paths, now, compiled_manifest_trust_set())
2007 .map(|verified| verified.manifest)
2008}
2009
2010fn load_manifest_with_trust_set(
2011 paths: &StatePaths,
2012 now: u64,
2013 trust_set: &[Option<ManifestTrustKey>],
2014) -> Result<VerifiedManifest, ManifestProblem> {
2015 let bytes = fs::read(&paths.manifest).map_err(|_| ManifestProblem::Missing)?;
2016 let envelope: SignedManifest = serde_json::from_slice(&bytes)
2017 .map_err(|error| ManifestProblem::Invalid(error.to_string()))?;
2018 if envelope.artifact_id != MANIFEST_ARTIFACT_ID {
2019 return Err(ManifestProblem::Invalid("artifact id mismatch".to_string()));
2020 }
2021 if envelope.envelope_version != ENVELOPE_VERSION {
2022 return Err(ManifestProblem::Invalid(format!(
2023 "unsupported envelope version {} (this shim verifies envelope version {ENVELOPE_VERSION})",
2024 envelope.envelope_version
2025 )));
2026 }
2027 let VerifiedManifest {
2029 manifest,
2030 verified_by_key_id,
2031 } = verify_manifest_signature_with_provenance(&envelope, trust_set)?;
2032 manifest.validate().map_err(ManifestProblem::Invalid)?;
2033 if manifest.schema_floor < SCHEMA_FLOOR {
2034 return Err(ManifestProblem::BelowFloor {
2035 manifest_floor: manifest.schema_floor,
2036 });
2037 }
2038 let newest_accepted = version_high_water(paths);
2042 if manifest.manifest_version < newest_accepted {
2043 return Err(ManifestProblem::RolledBack {
2044 manifest_version: manifest.manifest_version,
2045 newest_accepted,
2046 });
2047 }
2048 if manifest.issued_at_unix_secs > now + ISSUED_AT_FUTURE_SKEW.as_secs() {
2049 return Err(ManifestProblem::Invalid(format!(
2050 "issued_at_unix_secs {} is more than {} seconds in the future",
2051 manifest.issued_at_unix_secs,
2052 ISSUED_AT_FUTURE_SKEW.as_secs()
2053 )));
2054 }
2055 if manifest.manifest_version > newest_accepted {
2059 write_version_high_water(paths, manifest.manifest_version);
2060 }
2061 write_last_valid_manifest(paths, &manifest);
2062 retain_manifest(paths, &envelope, manifest.manifest_version);
2067 Ok(VerifiedManifest {
2068 manifest,
2069 verified_by_key_id,
2070 })
2071}
2072
2073#[cfg(test)]
2076fn verify_manifest_signature(envelope: &SignedManifest) -> Result<Manifest, ManifestProblem> {
2077 verify_manifest_signature_with(envelope, compiled_manifest_trust_set())
2078}
2079
2080#[cfg(test)]
2081fn verify_manifest_signature_with(
2082 envelope: &SignedManifest,
2083 trust_set: &[Option<ManifestTrustKey>],
2084) -> Result<Manifest, ManifestProblem> {
2085 verify_manifest_signature_with_provenance(envelope, trust_set).map(|verified| verified.manifest)
2086}
2087
2088fn verify_manifest_signature_with_provenance(
2089 envelope: &SignedManifest,
2090 trust_set: &[Option<ManifestTrustKey>],
2091) -> Result<VerifiedManifest, ManifestProblem> {
2092 let Some(key) = trust_set
2093 .iter()
2094 .flatten()
2095 .find(|slot| slot.key_id == envelope.key_id)
2096 .copied()
2097 else {
2098 return Err(ManifestProblem::Invalid(format!(
2099 "untrusted manifest key id {}",
2100 envelope.key_id
2101 )));
2102 };
2103 let signature = base64::engine::general_purpose::STANDARD
2104 .decode(&envelope.signature)
2105 .map_err(|_| ManifestProblem::Invalid("invalid detached signature encoding".to_string()))?;
2106 UnparsedPublicKey::new(&ED25519, key.public_key)
2107 .verify(envelope.manifest_bytes.as_bytes(), &signature)
2108 .map_err(|_| {
2109 ManifestProblem::Invalid("detached signature verification failed".to_string())
2110 })?;
2111 let manifest = serde_json::from_str(&envelope.manifest_bytes).map_err(|error| {
2112 ManifestProblem::Invalid(format!("signed manifest bytes failed to parse: {error}"))
2113 })?;
2114 Ok(VerifiedManifest {
2115 manifest,
2116 verified_by_key_id: key.key_id.to_string(),
2117 })
2118}
2119
2120#[derive(Clone, Copy)]
2123struct ManifestTrustKey {
2124 key_id: &'static str,
2125 public_key: &'static [u8],
2126}
2127
2128const PROD_MANIFEST_KEY_ID: &str = "c9ad111282d1da10";
2165const PROD_MANIFEST_PUBLIC_KEY: [u8; 32] = [
2166 0x5f, 0x4c, 0x81, 0x90, 0x18, 0xe2, 0xb6, 0x8d, 0x18, 0xdb, 0xce, 0x6a, 0xc3, 0x6f, 0x9b, 0x84,
2167 0x65, 0x28, 0x84, 0x14, 0x75, 0x55, 0xe8, 0x44, 0x2e, 0xf7, 0x6d, 0x7f, 0xb4, 0x7a, 0x42, 0xf4,
2168];
2169const PROD_MANIFEST_TRUST_KEY: ManifestTrustKey = ManifestTrustKey {
2170 key_id: PROD_MANIFEST_KEY_ID,
2171 public_key: &PROD_MANIFEST_PUBLIC_KEY,
2172};
2173
2174#[cfg(not(debug_assertions))]
2175const RELEASE_MANIFEST_TRUST_SET: &[Option<ManifestTrustKey>; 2] = &[
2176 Some(PROD_MANIFEST_TRUST_KEY), None, ];
2179
2180#[cfg(any(debug_assertions, test))]
2184const DEV_MANIFEST_KEY_ID: &str = "gh-routing-dev-test-key-v1";
2185#[cfg(any(debug_assertions, test))]
2186const DEV_MANIFEST_PUBLIC_KEY: [u8; 32] = [
2187 0xd7, 0x5a, 0x98, 0x01, 0x82, 0xb1, 0x0a, 0xb7, 0xd5, 0x4b, 0xfe, 0xd3, 0xc9, 0x64, 0x07, 0x3a,
2188 0x0e, 0xe1, 0x72, 0xf3, 0xda, 0xa6, 0x23, 0x25, 0xaf, 0x02, 0x1a, 0x68, 0xf7, 0x07, 0x51, 0x1a,
2189];
2190#[cfg(debug_assertions)]
2194const DEV_MANIFEST_TRUST_SET: &[Option<ManifestTrustKey>; 2] = &[
2195 Some(PROD_MANIFEST_TRUST_KEY),
2196 Some(ManifestTrustKey {
2197 key_id: DEV_MANIFEST_KEY_ID,
2198 public_key: &DEV_MANIFEST_PUBLIC_KEY,
2199 }),
2200];
2201
2202fn compiled_manifest_trust_set() -> &'static [Option<ManifestTrustKey>] {
2203 #[cfg(debug_assertions)]
2204 {
2205 DEV_MANIFEST_TRUST_SET
2206 }
2207 #[cfg(not(debug_assertions))]
2208 {
2209 RELEASE_MANIFEST_TRUST_SET
2210 }
2211}
2212
2213fn trust_set_key_ids(trust_set: &[Option<ManifestTrustKey>]) -> Vec<&'static str> {
2214 trust_set.iter().flatten().map(|key| key.key_id).collect()
2215}
2216
2217#[derive(Debug)]
2223enum ManifestResolution {
2224 Active(Manifest),
2226 Regressed {
2230 manifest: Manifest,
2231 problem: ManifestProblem,
2232 },
2233 Invalid(ManifestProblem),
2236 Dormant,
2238}
2239
2240impl ManifestResolution {
2241 fn manifest(&self) -> Option<&Manifest> {
2242 match self {
2243 Self::Active(manifest) | Self::Regressed { manifest, .. } => Some(manifest),
2244 Self::Invalid(_) | Self::Dormant => None,
2245 }
2246 }
2247
2248 fn into_manifest(self) -> Option<Manifest> {
2249 match self {
2250 Self::Active(manifest) | Self::Regressed { manifest, .. } => Some(manifest),
2251 Self::Invalid(_) | Self::Dormant => None,
2252 }
2253 }
2254
2255 fn invalid_problem(&self) -> Option<&ManifestProblem> {
2256 match self {
2257 Self::Regressed { problem, .. } | Self::Invalid(problem) => Some(problem),
2258 Self::Active(_) | Self::Dormant => None,
2259 }
2260 }
2261}
2262
2263fn resolve_manifest(paths: &StatePaths, now: u64) -> ManifestResolution {
2264 match load_manifest(paths, now) {
2265 Ok(manifest) => ManifestResolution::Active(manifest),
2266 Err(ManifestProblem::Missing) => ManifestResolution::Dormant,
2267 Err(problem) => match read_last_valid_manifest(paths) {
2268 Some(cache) => ManifestResolution::Regressed {
2269 manifest: cache.manifest,
2270 problem,
2271 },
2272 None => ManifestResolution::Invalid(problem),
2273 },
2274 }
2275}
2276
2277fn delegate_after_invalid_manifest_notice(args: &[OsString], problem: &ManifestProblem) -> i32 {
2278 eprintln!(
2282 "gh-shim: manifest invalid ({}); executing with ambient gh credentials",
2283 problem.fallback_notice_reason().replace(['\n', '\r'], " ")
2284 );
2285 delegate(args)
2286}
2287
2288fn regressed_disposition(
2296 args: &[OsString],
2297 manifest: &Manifest,
2298 platform: &str,
2299 problem: &ManifestProblem,
2300) -> RegressedDisposition {
2301 match classify(args, manifest, platform) {
2302 Classification::Mechanical => RegressedDisposition::Passthrough,
2303 Classification::Governed { tuple, .. } | Classification::Admin { tuple } => {
2304 let text = match problem.untrusted_manifest_key_steering() {
2305 Some(steering) => {
2306 format!("the manifest artifact fails validation; {tuple} is refused; {steering}")
2307 }
2308 None => format!(
2309 "the manifest artifact fails validation; {tuple} is refused until the manifest is repaired"
2310 ),
2311 };
2312 RegressedDisposition::Refuse {
2313 code: RefusalCode::ManifestRegressed,
2314 text,
2315 }
2316 }
2317 Classification::Destructive => RegressedDisposition::Refuse {
2318 code: RefusalCode::DestructiveFlag,
2319 text: "destructive GitHub operations are not available through the shim".to_string(),
2320 },
2321 Classification::Unclassified => RegressedDisposition::Refuse {
2322 code: RefusalCode::Unclassified,
2323 text:
2324 "no manifest declaration for this invocation (manifest artifact fails validation)"
2325 .to_string(),
2326 },
2327 }
2328}
2329
2330#[derive(Debug)]
2331enum RegressedDisposition {
2332 Passthrough,
2333 Refuse { code: RefusalCode, text: String },
2334}
2335
2336#[derive(Clone, Debug, Deserialize, Serialize)]
2341struct LastValidManifest {
2342 manifest: Manifest,
2343}
2344
2345fn read_last_valid_manifest(paths: &StatePaths) -> Option<LastValidManifest> {
2346 serde_json::from_slice(&fs::read(&paths.last_valid_manifest).ok()?).ok()
2347}
2348
2349fn write_last_valid_manifest(paths: &StatePaths, manifest: &Manifest) {
2350 let record = LastValidManifest {
2351 manifest: manifest.clone(),
2352 };
2353 let Ok(bytes) = serde_json::to_vec(&record) else {
2354 return;
2355 };
2356 let _ = fs::create_dir_all(&paths.root);
2357 let temporary = paths.last_valid_manifest.with_extension("tmp");
2358 if fs::write(&temporary, bytes).is_ok() {
2359 let _ = fs::rename(temporary, &paths.last_valid_manifest);
2360 }
2361}
2362
2363#[derive(Clone, Debug, Deserialize, Serialize)]
2368struct VersionHighWater {
2369 newest_accepted_version: u64,
2370}
2371
2372fn version_high_water(paths: &StatePaths) -> u64 {
2373 fs::read(&paths.version_high_water)
2374 .ok()
2375 .and_then(|bytes| serde_json::from_slice::<VersionHighWater>(&bytes).ok())
2376 .map(|record| record.newest_accepted_version)
2377 .unwrap_or(0)
2378}
2379
2380fn write_version_high_water(paths: &StatePaths, newest_accepted_version: u64) {
2381 let Ok(bytes) = serde_json::to_vec(&VersionHighWater {
2382 newest_accepted_version,
2383 }) else {
2384 return;
2385 };
2386 let _ = fs::create_dir_all(&paths.root);
2387 let temporary = paths.version_high_water.with_extension("tmp");
2388 if fs::write(&temporary, bytes).is_ok() {
2389 let _ = fs::rename(temporary, &paths.version_high_water);
2390 }
2391}
2392
2393#[derive(Clone, Debug, Deserialize, Serialize)]
2399struct RetainedManifest {
2400 manifest_bytes: String,
2401 signature: String,
2402 key_id: String,
2403}
2404
2405fn retain_manifest(paths: &StatePaths, envelope: &SignedManifest, filing_version: u64) {
2424 let inside_version = match serde_json::from_str::<Manifest>(&envelope.manifest_bytes) {
2428 Ok(manifest) => manifest.manifest_version,
2429 Err(_) => {
2430 eprintln!(
2431 "gh-shim: refusing to retain manifest: verified payload bytes failed to parse"
2432 );
2433 return;
2434 }
2435 };
2436 if inside_version != filing_version {
2437 eprintln!(
2438 "gh-shim: refusing to retain manifest: payload version {inside_version} does not match filing version {filing_version}"
2439 );
2440 return;
2441 }
2442
2443 let digest = Sha256::digest(envelope.manifest_bytes.as_bytes());
2444 let digest_hex = format!("{digest:x}");
2445 let file_name = format!("v{inside_version}-{}.json", &digest_hex[..16]);
2446 let destination = paths.manifests_dir.join(&file_name);
2447
2448 if destination.exists() {
2449 let existing_bytes = fs::read(&destination)
2453 .ok()
2454 .and_then(|bytes| serde_json::from_slice::<RetainedManifest>(&bytes).ok())
2455 .map(|record| record.manifest_bytes);
2456 if existing_bytes.as_deref() == Some(envelope.manifest_bytes.as_str()) {
2457 return;
2458 }
2459 eprintln!(
2460 "gh-shim: refusing to retain manifest: collision at {} (different bytes at the same name)",
2461 destination.display()
2462 );
2463 return;
2464 }
2465
2466 if fs::create_dir_all(&paths.manifests_dir).is_err() {
2467 eprintln!("gh-shim: refusing to retain manifest: could not create manifests dir");
2468 return;
2469 }
2470
2471 let record = RetainedManifest {
2472 manifest_bytes: envelope.manifest_bytes.clone(),
2473 signature: envelope.signature.clone(),
2474 key_id: envelope.key_id.clone(),
2475 };
2476 let Ok(bytes) = serde_json::to_vec(&record) else {
2477 eprintln!("gh-shim: refusing to retain manifest: serialization failed");
2478 return;
2479 };
2480
2481 let temporary = paths.manifests_dir.join(format!(".{file_name}.tmp"));
2482 if fs::write(&temporary, &bytes).is_err() {
2483 eprintln!("gh-shim: refusing to retain manifest: could not write temporary file");
2484 return;
2485 }
2486 #[cfg(unix)]
2487 {
2488 use std::os::unix::fs::PermissionsExt;
2489 let _ = fs::set_permissions(&temporary, fs::Permissions::from_mode(0o600));
2490 }
2491 if fs::rename(&temporary, &destination).is_err() {
2492 eprintln!("gh-shim: refusing to retain manifest: could not move into place");
2493 let _ = fs::remove_file(&temporary);
2494 }
2495}
2496
2497#[derive(Debug)]
2498enum Classification {
2499 Mechanical,
2500 Governed {
2501 tuple: String,
2502 canonical: Canonicalization,
2503 },
2504 Admin {
2505 tuple: String,
2506 },
2507 Unclassified,
2508 Destructive,
2509}
2510
2511fn is_reviewed_admin_tuple(manifest_version: u64, tuple: &str) -> bool {
2512 V1_ADMIN_TUPLES.contains(&tuple)
2513 || (manifest_version >= 9 && V9_ADMIN_TUPLES.contains(&tuple))
2514 || (manifest_version >= 10 && V10_ADMIN_TUPLES.contains(&tuple))
2515 || (manifest_version >= 11 && V11_ADMIN_TUPLES.contains(&tuple))
2516 || (manifest_version >= 13 && V13_ADMIN_TUPLES.contains(&tuple))
2517}
2518
2519fn is_reviewed_governed_tuple(manifest_version: u64, tuple: &str) -> bool {
2520 V1_GOVERNED_TUPLES.contains(&tuple)
2521 || (manifest_version >= 12 && V12_GOVERNED_TUPLES.contains(&tuple))
2522}
2523
2524fn is_target_and_state(canonical: &Canonicalization) -> bool {
2525 canonical
2526 .argv_forms
2527 .iter()
2528 .any(|form| form == TARGET_AND_STATE_FORM)
2529}
2530
2531fn is_reviewed_edit_last_tuple(manifest_version: u64, tuple: &str) -> bool {
2532 manifest_version >= 10 && V10_EDIT_LAST_TUPLES.contains(&tuple)
2533}
2534
2535fn has_exact_flag(args: &[OsString], flag: &str) -> bool {
2536 args.iter().any(|arg| arg.to_str() == Some(flag))
2537}
2538
2539fn classify(args: &[OsString], manifest: &Manifest, platform: &str) -> Classification {
2540 let Some((verb, subcommand, _)) = command_head(args) else {
2541 if args.iter().any(|arg| arg.to_str().is_none()) {
2544 return Classification::Unclassified;
2545 }
2546 return Classification::Mechanical;
2550 };
2551 if verb == "help" {
2552 return Classification::Mechanical;
2554 }
2555 if verb == "api" {
2556 return classify_api(args, manifest, platform);
2557 }
2558 let tuple = match subcommand {
2559 Some(subcommand) => format!("{verb} {subcommand}"),
2560 None => verb,
2561 };
2562 if DESTRUCTIVE_TUPLES.contains(&tuple.as_str())
2563 || (tuple.starts_with("release ")
2564 && args.iter().any(|arg| {
2565 arg.to_str()
2566 .is_some_and(|value| value.starts_with("--delete-"))
2567 }))
2568 {
2569 return Classification::Destructive;
2570 }
2571 if has_exact_flag(args, "--edit-last")
2577 && !is_reviewed_edit_last_tuple(manifest.manifest_version, &tuple)
2578 {
2579 return Classification::Unclassified;
2580 }
2581 if has_exact_flag(args, "--delete-last") || has_exact_flag(args, "--create-if-none") {
2585 return Classification::Unclassified;
2586 }
2587 if READ_ONLY_ACTION_TUPLES.contains(&tuple.as_str()) {
2588 return Classification::Mechanical;
2589 }
2590 match manifest.tier_for_tuple(&tuple, platform) {
2591 Some(Tier::Mechanical) => Classification::Mechanical,
2592 Some(Tier::Admin) if is_reviewed_admin_tuple(manifest.manifest_version, &tuple) => {
2593 Classification::Admin { tuple }
2594 }
2595 Some(Tier::Governed) if is_reviewed_governed_tuple(manifest.manifest_version, &tuple) => {
2596 manifest
2597 .canonicalization
2598 .get(&tuple)
2599 .cloned()
2600 .map(|canonical| Classification::Governed { tuple, canonical })
2601 .unwrap_or(Classification::Unclassified)
2602 }
2603 Some(Tier::Governed | Tier::Admin) | None => Classification::Unclassified,
2608 }
2609}
2610
2611fn command_head(args: &[OsString]) -> Option<(String, Option<String>, usize)> {
2612 let mut positionals = Vec::new();
2613 let mut skip_next = false;
2614 for (index, raw) in args.iter().enumerate() {
2615 let value = raw.to_str()?;
2616 if skip_next {
2617 skip_next = false;
2618 continue;
2619 }
2620 if matches!(value, "--repo" | "-R" | "--hostname" | "--config-dir") {
2621 skip_next = true;
2622 continue;
2623 }
2624 if value.starts_with('-') {
2625 continue;
2626 }
2627 positionals.push((value.to_ascii_lowercase(), index));
2628 if positionals.len() == 2 || positionals[0].0 == "api" {
2629 break;
2630 }
2631 }
2632 let (verb, index) = positionals.first()?.clone();
2633 let subcommand = positionals.get(1).map(|(value, _)| value.clone());
2634 Some((verb, subcommand, index))
2635}
2636
2637fn classify_api(args: &[OsString], manifest: &Manifest, platform: &str) -> Classification {
2638 let Some((method, path, has_fields)) = api_method_and_path(args) else {
2639 return Classification::Unclassified;
2640 };
2641 let matches = manifest
2642 .api_rules
2643 .iter()
2644 .filter(|rule| {
2645 rule.method.eq_ignore_ascii_case(&method)
2646 && platform_matches(&rule.platform, platform)
2647 && glob::Pattern::new(&rule.path_glob).is_ok_and(|pattern| pattern.matches(&path))
2648 })
2649 .collect::<Vec<_>>();
2650 if matches.is_empty() && method.eq_ignore_ascii_case("GET") && !has_fields {
2651 return Classification::Mechanical;
2654 }
2655 if matches.len() != 1 {
2656 return Classification::Unclassified;
2657 }
2658 let rule = matches[0];
2659 if rule.tier == Tier::Admin {
2660 return Classification::Admin {
2661 tuple: format!(
2662 "api:{}:{}",
2663 rule.method.to_ascii_uppercase(),
2664 rule.path_glob
2665 ),
2666 };
2667 }
2668 if has_fields {
2673 return Classification::Unclassified;
2674 }
2675 match rule.tier {
2676 Tier::Mechanical => Classification::Mechanical,
2677 Tier::Governed => Classification::Unclassified,
2682 Tier::Admin => unreachable!("admin API rules return before field protection"),
2683 }
2684}
2685
2686fn api_method_and_path(args: &[OsString]) -> Option<(String, String, bool)> {
2687 let mut method = "GET".to_string();
2688 let mut path = None;
2689 let mut has_fields = false;
2690 let mut index = 1;
2691 while index < args.len() {
2692 let value = args[index].to_str()?;
2693 if matches!(value, "--method" | "-X") {
2694 method = args.get(index + 1)?.to_str()?.to_ascii_uppercase();
2695 index += 2;
2696 continue;
2697 }
2698 if let Some(method_value) = value.strip_prefix("--method=") {
2699 method = method_value.to_ascii_uppercase();
2700 index += 1;
2701 continue;
2702 }
2703 if is_api_field_argument(value) {
2704 has_fields = true;
2705 if matches!(value, "--input" | "--raw-field" | "--field" | "-F" | "-f") {
2706 args.get(index + 1)?.to_str()?;
2707 index += 2;
2708 } else {
2709 index += 1;
2710 }
2711 continue;
2712 }
2713 if value.starts_with('-') {
2714 index += 1;
2715 continue;
2716 }
2717 if path.is_none() {
2718 path = Some(value.to_string());
2719 }
2720 index += 1;
2721 }
2722 let path = path?;
2723 if path == "-" {
2724 return None;
2725 }
2726 let path = if path.starts_with('/') || path.starts_with("http") {
2733 path
2734 } else {
2735 format!("/{path}")
2736 };
2737 Some((method, path, has_fields))
2738}
2739
2740fn is_api_field_argument(value: &str) -> bool {
2741 ["--input", "--raw-field", "--field"]
2742 .iter()
2743 .any(|flag| value == *flag || value.starts_with(&format!("{flag}=")))
2744 || value == "-F"
2745 || value.starts_with("-F")
2746 || value == "-f"
2747 || value.starts_with("-f")
2748}
2749
2750#[derive(Debug)]
2754struct CanonicalizeError {
2755 code: RefusalCode,
2756 text: String,
2757}
2758
2759impl CanonicalizeError {
2760 fn unclassified(text: impl Into<String>) -> Self {
2761 Self {
2762 code: RefusalCode::Unclassified,
2763 text: text.into(),
2764 }
2765 }
2766
2767 fn typed(code: RefusalCode, text: impl Into<String>) -> Self {
2768 Self {
2769 code,
2770 text: text.into(),
2771 }
2772 }
2773}
2774
2775impl From<String> for CanonicalizeError {
2776 fn from(text: String) -> Self {
2777 Self::unclassified(text)
2778 }
2779}
2780
2781impl PartialEq<&str> for CanonicalizeError {
2782 fn eq(&self, other: &&str) -> bool {
2783 self.text == *other
2784 }
2785}
2786
2787impl std::fmt::Display for CanonicalizeError {
2788 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2789 f.write_str(&self.text)
2790 }
2791}
2792
2793impl std::ops::Deref for CanonicalizeError {
2794 type Target = str;
2795 fn deref(&self) -> &str {
2796 &self.text
2797 }
2798}
2799
2800#[derive(Clone, Debug)]
2801struct GovernedRequest {
2802 action: String,
2803 target: Map<String, Value>,
2804 body: Map<String, Value>,
2805 repository: Option<String>,
2806 manifest_version: u64,
2807 edit_last: bool,
2808}
2809
2810#[derive(Debug, Eq, PartialEq)]
2812struct GithubReadMutation {
2813 resource_kind: GithubReadResourceKind,
2814 normalized_repository: String,
2815 resource_number: i64,
2816}
2817
2818impl GithubReadMutation {
2819 fn from_governed_request(request: &GovernedRequest) -> Option<Self> {
2820 let resource_kind = match request.action.as_str() {
2821 "issue comment" | "issue reaction" | "issue close" | "issue reopen" => {
2822 GithubReadResourceKind::Issue
2823 }
2824 "pr comment" | "pr review" | "pr close" | "pr reopen" => {
2825 GithubReadResourceKind::PullRequest
2826 }
2827 _ => return None,
2828 };
2829 let normalized_repository = canonical_repository_key(request.repository.as_deref()?)?;
2830 let resource_number = request.target.get("number")?.as_str()?.parse().ok()?;
2831 (resource_number > 0).then_some(Self {
2832 resource_kind,
2833 normalized_repository,
2834 resource_number,
2835 })
2836 }
2837}
2838
2839fn invalidate_successful_github_read_mutation(
2844 mutation: Option<&GithubReadMutation>,
2845 outcome: &RouteOutcome,
2846) {
2847 invalidate_successful_github_read_mutation_at(
2848 &crate::bash_background::storage_dir(None),
2849 mutation,
2850 outcome,
2851 );
2852}
2853
2854fn invalidate_successful_github_read_mutation_at(
2855 storage_root: &Path,
2856 mutation: Option<&GithubReadMutation>,
2857 outcome: &RouteOutcome,
2858) {
2859 if !matches!(
2860 outcome,
2861 RouteOutcome::Result(_) | RouteOutcome::StateAppliedCommentFailed(_)
2862 ) {
2863 return;
2864 }
2865 let Some(mutation) = mutation else {
2866 return;
2867 };
2868 let Ok(conn) = crate::db::open(&storage_root.join("aft.db")) else {
2869 return;
2870 };
2871 let _ = invalidate_github_read_cache_resource(
2874 &conn,
2875 mutation.resource_kind,
2876 &mutation.normalized_repository,
2877 mutation.resource_number,
2878 None,
2879 );
2880}
2881
2882fn canonicalize_governed(
2883 args: &[OsString],
2884 tuple: &str,
2885 canonical: &Canonicalization,
2886 manifest_version: u64,
2887) -> Result<GovernedRequest, CanonicalizeError> {
2888 let (_, _, head_index) = command_head(args)
2889 .ok_or_else(|| CanonicalizeError::unclassified("missing command head"))?;
2890 let subcommand_index = if tuple.starts_with("api ") {
2891 head_index
2892 } else {
2893 head_index + 1
2894 };
2895 let target_and_state = is_target_and_state(canonical);
2896 let mut positional = Vec::new();
2897 let mut body = Map::new();
2898 let mut review_event = None;
2899 let mut explicit_repository = None;
2900 let mut close_reason = None;
2901 let mut edit_last = false;
2902 let mut index = subcommand_index + 1;
2903 while index < args.len() {
2904 let value = args[index].to_str().ok_or_else(|| {
2905 CanonicalizeError::unclassified("non-UTF-8 governed arguments are undeclared")
2906 })?;
2907 if tuple == "pr review" {
2908 if let Some(event) = declared_review_event(value) {
2909 if review_event.replace(event.to_string()).is_some() {
2910 return Err(CanonicalizeError::unclassified(
2911 "pr review accepts only one of --approve, --comment, or --request-changes",
2912 ));
2913 }
2914 index += 1;
2915 continue;
2916 }
2917 }
2918 if target_and_state && (value == "--delete-branch" || value == "-d") {
2919 return Err(CanonicalizeError::typed(
2922 RefusalCode::DestructiveFlag,
2923 format!("{value}: branch deletion stays undeclared"),
2924 ));
2925 }
2926 if value == "--edit-last" {
2927 if !is_reviewed_edit_last_tuple(manifest_version, tuple) {
2928 return Err(CanonicalizeError::unclassified(
2929 "undeclared flag --edit-last",
2930 ));
2931 }
2932 if edit_last {
2933 return Err(CanonicalizeError::unclassified(
2934 "--edit-last may be provided only once",
2935 ));
2936 }
2937 edit_last = true;
2938 } else if value == "--repo" || value == "-R" {
2939 index += 1;
2940 let repository = args
2941 .get(index)
2942 .and_then(|arg| arg.to_str())
2943 .ok_or_else(|| CanonicalizeError::unclassified("--repo requires a value"))?;
2944 explicit_repository = Some(repository.to_string());
2945 } else if let Some(repository) = value.strip_prefix("--repo=") {
2946 explicit_repository = Some(repository.to_string());
2947 } else if target_and_state {
2948 if let Some(supplied) = declared_reason_value(value, args.get(index + 1), tuple)? {
2949 if close_reason.replace(supplied).is_some() {
2950 return Err(CanonicalizeError::unclassified(
2951 "--reason may be provided only once",
2952 ));
2953 }
2954 if !value.contains('=') {
2955 index += 1;
2956 }
2957 } else if let Some((field, supplied)) =
2958 declared_body_value(value, canonical, args.get(index + 1))?
2959 {
2960 body.insert(field, Value::String(supplied));
2961 if !value.contains('=') {
2962 index += 1;
2963 }
2964 } else if value.starts_with('-') {
2965 return Err(CanonicalizeError::unclassified(format!(
2966 "undeclared flag {value}"
2967 )));
2968 } else {
2969 positional.push(value.to_string());
2970 }
2971 } else if let Some((field, supplied)) =
2972 declared_body_value(value, canonical, args.get(index + 1))?
2973 {
2974 body.insert(field, Value::String(supplied));
2975 if !value.contains('=') && !value.starts_with('-') {
2976 positional.push(value.to_string());
2978 }
2979 if !value.contains('=') {
2980 index += 1;
2981 }
2982 } else if value.starts_with('-') {
2983 return Err(CanonicalizeError::unclassified(format!(
2984 "undeclared flag {value}"
2985 )));
2986 } else {
2987 positional.push(value.to_string());
2988 }
2989 index += 1;
2990 }
2991
2992 if positional.len() != canonical.target_fields.len() {
2993 return Err(CanonicalizeError::unclassified(
2994 "target positional form is undeclared",
2995 ));
2996 }
2997 if canonical
2998 .body_fields
2999 .iter()
3000 .any(|field| !body.contains_key(field))
3001 {
3002 let body_optional_for_review = tuple == "pr review"
3006 && review_event
3007 .as_deref()
3008 .is_some_and(|event| event != "COMMENT")
3009 && canonical.body_fields.iter().all(|field| field == "body");
3010 let body_optional_for_state =
3013 target_and_state && canonical.body_fields.iter().all(|field| field == "comment");
3014 if !body_optional_for_review && !body_optional_for_state {
3015 return Err(CanonicalizeError::unclassified(
3016 "required declared body field is absent",
3017 ));
3018 }
3019 }
3020 if tuple == "issue close" && target_and_state {
3021 let Some(reason) = close_reason else {
3022 return Err(CanonicalizeError::typed(
3023 RefusalCode::MissingReason,
3024 "issue close requires --reason completed|not_planned because those are distinct public statements",
3025 ));
3026 };
3027 body.insert("reason".to_string(), Value::String(reason));
3028 } else if close_reason.is_some() {
3029 return Err(CanonicalizeError::unclassified(
3030 "--reason is only valid for issue close",
3031 ));
3032 }
3033 if let Some(event) = review_event {
3034 body.insert("event".to_string(), Value::String(event));
3035 }
3036 let target = canonical
3037 .target_fields
3038 .iter()
3039 .cloned()
3040 .zip(positional)
3041 .map(|(field, value)| (field, Value::String(value)))
3042 .collect::<Map<_, _>>();
3043 let repository = explicit_repo(args)
3046 .or(explicit_repository)
3047 .or_else(infer_repository_from_git)
3048 .map(|repository| {
3049 canonical_repository_key(&repository)
3050 .ok_or_else(|| format!("repository {repository} is not owner/name"))
3051 })
3052 .transpose()?;
3053 Ok(GovernedRequest {
3054 action: tuple.to_string(),
3055 target,
3056 body,
3057 repository,
3058 manifest_version,
3059 edit_last,
3060 })
3061}
3062
3063fn declared_body_value(
3064 value: &str,
3065 canonical: &Canonicalization,
3066 next: Option<&OsString>,
3067) -> Result<Option<(String, String)>, String> {
3068 for field in &canonical.body_fields {
3069 let long = format!("--{field}");
3070 let short = match field.as_str() {
3071 "body" => Some("-b"),
3072 "reaction" => Some("-r"),
3073 "comment" => Some("-c"),
3074 _ => None,
3075 };
3076 if value == long || short == Some(value) {
3077 let supplied = next
3078 .and_then(|arg| arg.to_str())
3079 .ok_or_else(|| format!("{value} requires a value"))?;
3080 return Ok(Some((field.clone(), supplied.to_string())));
3081 }
3082 if let Some(supplied) = value.strip_prefix(&(long + "=")) {
3083 return Ok(Some((field.clone(), supplied.to_string())));
3084 }
3085
3086 if field == "body" {
3091 let file = if value == "--body-file" || value == "-F" {
3092 Some(
3093 next.and_then(|arg| arg.to_str())
3094 .ok_or_else(|| format!("{value} requires a value"))?,
3095 )
3096 } else {
3097 value
3098 .strip_prefix("--body-file=")
3099 .or_else(|| value.strip_prefix("-F="))
3100 .or_else(|| value.strip_prefix("-F"))
3101 };
3102 if let Some(file) = file {
3103 let supplied =
3104 read_body_file(Path::new(file)).map_err(|error| format!("{value}: {error}"))?;
3105 return Ok(Some((field.clone(), supplied)));
3106 }
3107 }
3108
3109 if field == "comment" {
3112 let file = if value == "--comment-file" {
3113 Some(
3114 next.and_then(|arg| arg.to_str())
3115 .ok_or_else(|| format!("{value} requires a value"))?,
3116 )
3117 } else {
3118 value.strip_prefix("--comment-file=")
3119 };
3120 if let Some(file) = file {
3121 let supplied =
3122 read_body_file(Path::new(file)).map_err(|error| format!("{value}: {error}"))?;
3123 return Ok(Some((field.clone(), supplied)));
3124 }
3125 }
3126 }
3127 Ok(None)
3128}
3129
3130fn declared_reason_value(
3131 value: &str,
3132 next: Option<&OsString>,
3133 tuple: &str,
3134) -> Result<Option<String>, CanonicalizeError> {
3135 let supplied = if value == "--reason" {
3136 Some(
3137 next.and_then(|arg| arg.to_str())
3138 .ok_or_else(|| CanonicalizeError::unclassified("--reason requires a value"))?
3139 .to_string(),
3140 )
3141 } else {
3142 value.strip_prefix("--reason=").map(str::to_string)
3143 };
3144 let Some(supplied) = supplied else {
3145 return Ok(None);
3146 };
3147 if tuple != "issue close" {
3148 return Err(CanonicalizeError::unclassified(
3149 "--reason is only valid for issue close",
3150 ));
3151 }
3152 if !ISSUE_CLOSE_REASONS.contains(&supplied.as_str()) {
3153 return Err(CanonicalizeError::unclassified(
3154 "--reason must be completed or not_planned",
3155 ));
3156 }
3157 Ok(Some(supplied))
3158}
3159
3160fn read_body_file(path: &Path) -> Result<String, String> {
3161 let mut stdin = io::stdin().lock();
3162 read_body_file_from(path, &mut stdin)
3163}
3164
3165fn read_body_file_from<R: Read>(path: &Path, stdin: &mut R) -> Result<String, String> {
3166 let mut body = String::new();
3167 if path == Path::new("-") {
3171 stdin
3172 .read_to_string(&mut body)
3173 .map_err(|error| format!("could not read body from stdin: {error}"))?;
3174 } else {
3175 body = fs::read_to_string(path)
3176 .map_err(|error| format!("could not read body file {}: {error}", path.display()))?;
3177 }
3178 Ok(body)
3179}
3180
3181fn declared_review_event(value: &str) -> Option<&'static str> {
3182 match value {
3183 "--approve" => Some("APPROVE"),
3184 "--comment" => Some("COMMENT"),
3185 "--request-changes" => Some("REQUEST_CHANGES"),
3186 _ => None,
3187 }
3188}
3189
3190fn explicit_repo(args: &[OsString]) -> Option<String> {
3191 let mut args = args.iter();
3192 while let Some(arg) = args.next() {
3193 let value = arg.to_str()?;
3194 if value == "--repo" || value == "-R" {
3195 return args.next()?.to_str().map(str::to_string);
3196 }
3197 if let Some(repository) = value.strip_prefix("--repo=") {
3198 return Some(repository.to_string());
3199 }
3200 }
3201 None
3202}
3203
3204fn infer_repository_from_git() -> Option<String> {
3205 let cwd = std::env::current_dir().ok()?;
3206 canonical_repository_key(&origin_remote(&cwd)?)
3207}
3208
3209#[derive(Debug)]
3210enum RouteOutcome {
3211 Result(String),
3212 StateAppliedCommentFailed(String),
3213 UpstreamError(String),
3214 Refusal(String),
3215 UnboundIdentity,
3216 SchemaMismatch(String),
3217 GovernanceUnavailable,
3218 GovernanceUnavailableTimedOut { stage: ProbeStage, elapsed_ms: u64 },
3219 Unavailable(String),
3220}
3221
3222#[derive(Clone, Debug, Default, Deserialize, Serialize)]
3223struct SeamState {
3224 bound_holder: Option<String>,
3225 agent_binding: Option<AgentBinding>,
3226 last_seam_refusal: Option<LastSeamRefusal>,
3227}
3228
3229#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
3230struct LastSeamRefusal {
3231 code: String,
3232 at_unix_secs: u64,
3233}
3234
3235fn route_governed(
3236 paths: &StatePaths,
3237 determination: &RungRecord,
3238 agent_binding: &AgentBinding,
3239 request: GovernedRequest,
3240 now: u64,
3241) -> RouteOutcome {
3242 if let Err(error) = write_seam_state(paths, governed_seam_state(paths, None, agent_binding)) {
3243 return RouteOutcome::Unavailable(format!("governed self-report update failed: {error}"));
3244 }
3245
3246 let Some(connection_file) = configured_connection_file() else {
3247 return RouteOutcome::GovernanceUnavailable;
3248 };
3249 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
3250 let project_root = project_root_for(&cwd);
3251 let record_paths = paths.clone();
3252 let agent_binding = agent_binding.clone();
3253 let runtime = match tokio::runtime::Builder::new_current_thread()
3254 .enable_io()
3255 .enable_time()
3256 .build()
3257 {
3258 Ok(runtime) => runtime,
3259 Err(error) => return RouteOutcome::Unavailable(error.to_string()),
3260 };
3261 let current_stage = Arc::new(Mutex::new(ProbeStage::Connect));
3262 let stage_handle = Arc::clone(¤t_stage);
3263 let call_timeout = Duration::from_secs(5);
3264 let deadline = Instant::now() + call_timeout;
3265 let result = runtime.block_on(async move {
3266 tokio::time::timeout(call_timeout, async move {
3267 let options = ConsumerOptions {
3268 call_timeout,
3269 handshake_timeout: call_timeout,
3274 ..ConsumerOptions::default()
3275 };
3276 let consumer = SubcConsumer::connect(&connection_file, options)
3277 .await
3278 .map_err(|_| RouteOutcome::GovernanceUnavailable)?;
3279 *stage_handle.lock().unwrap() = ProbeStage::CatalogList;
3280 let catalog = consumer
3281 .catalog_list()
3282 .await
3283 .map_err(|_| RouteOutcome::GovernanceUnavailable)?;
3284 let holder = route_holder(&catalog.modules);
3285 record_unexpected_gh_route_advertisers(&record_paths, &holder.unexpected_advertisers);
3286 let module_id = holder
3287 .module_id
3288 .ok_or(RouteOutcome::GovernanceUnavailable)?;
3289 *stage_handle.lock().unwrap() = ProbeStage::OpenRoute;
3290 let route = consumer
3291 .open_route(
3292 RouteTarget::ManagementSurface {
3293 module_id: module_id.clone(),
3294 },
3295 BindIdentity {
3296 project_root: project_root.to_string_lossy().into_owned().into(),
3297 harness: "aft-gh-shim".to_string(),
3298 session: gh_session_id(&agent_binding.agent_id),
3299 },
3300 CallOptions::default(),
3301 )
3302 .await
3303 .map_err(|_| RouteOutcome::UnboundIdentity)?;
3304 if let Err(error) = write_seam_state(
3305 &record_paths,
3306 governed_seam_state(&record_paths, Some(module_id.clone()), &agent_binding),
3307 ) {
3308 let _ = consumer
3309 .close_handle(&route, CloseRouteOptions::default())
3310 .await;
3311 return Err(RouteOutcome::Unavailable(format!(
3312 "governed self-report update failed: {error}"
3313 )));
3314 }
3315 let wire_request =
3316 governed_wire_request(determination, &agent_binding.agent_id, request);
3317 let body = serde_json::to_vec(&wire_request)
3318 .map_err(|error| RouteOutcome::SchemaMismatch(error.to_string()))?;
3319 let response = consumer
3320 .request(&route, body, CallOptions::default())
3321 .await
3322 .map_err(|error| RouteOutcome::Unavailable(error.to_string()));
3323 let _ = consumer
3324 .close_handle(&route, CloseRouteOptions::default())
3325 .await;
3326 let response = response?;
3327 let outcome = parse_governed_response(&response)?;
3328 if let RouteOutcome::Refusal(code) = &outcome {
3329 write_seam_state(
3330 &record_paths,
3331 SeamState {
3332 bound_holder: Some(module_id),
3333 agent_binding: Some(agent_binding),
3334 last_seam_refusal: Some(LastSeamRefusal {
3335 code: code.clone(),
3336 at_unix_secs: now,
3337 }),
3338 },
3339 )
3340 .map_err(|error| {
3341 RouteOutcome::Unavailable(format!(
3342 "governed self-report update failed: {error}"
3343 ))
3344 })?;
3345 }
3346 Ok(outcome)
3347 })
3348 .await
3349 });
3350
3351 let final_stage = *current_stage.lock().unwrap();
3352 let outcome = match result {
3353 Ok(Ok(outcome)) => outcome,
3354 Ok(Err(RouteOutcome::GovernanceUnavailable)) => {
3355 if Instant::now() >= deadline {
3356 let probe = LastProbeReport {
3357 stage: final_stage.as_str().to_string(),
3358 elapsed_ms: call_timeout.as_millis() as u64,
3359 outcome: "timed_out".to_string(),
3360 };
3361 write_last_probe_silently(paths, &probe);
3362 RouteOutcome::GovernanceUnavailableTimedOut {
3363 stage: final_stage,
3364 elapsed_ms: call_timeout.as_millis() as u64,
3365 }
3366 } else {
3367 RouteOutcome::GovernanceUnavailable
3368 }
3369 }
3370 Ok(Err(outcome)) => outcome,
3371 Err(_) => {
3372 let probe = LastProbeReport {
3373 stage: final_stage.as_str().to_string(),
3374 elapsed_ms: call_timeout.as_millis() as u64,
3375 outcome: "timed_out".to_string(),
3376 };
3377 write_last_probe_silently(paths, &probe);
3378 RouteOutcome::GovernanceUnavailableTimedOut {
3379 stage: final_stage,
3380 elapsed_ms: call_timeout.as_millis() as u64,
3381 }
3382 }
3383 };
3384 if matches!(
3385 &outcome,
3386 RouteOutcome::Result(_) | RouteOutcome::StateAppliedCommentFailed(_)
3387 ) && determination.rung == Rung::R3
3388 {
3389 let mut updated = determination.clone();
3390 updated.as_of_unix_secs = now;
3391 updated.last_reachable_unix_secs = Some(now);
3392 write_rung_record_silently(paths, &updated);
3393 }
3394 outcome
3395}
3396
3397fn refuse_governance_unavailable(
3398 paths: &StatePaths,
3399 agent_binding: &AgentBinding,
3400 now: u64,
3401 text: &str,
3402) -> i32 {
3403 let state = SeamState {
3404 bound_holder: None,
3405 agent_binding: Some(agent_binding.clone()),
3406 last_seam_refusal: Some(LastSeamRefusal {
3407 code: RefusalCode::GovernanceUnavailable.as_str().to_string(),
3408 at_unix_secs: now,
3409 }),
3410 };
3411 if let Err(error) = write_seam_state(paths, state) {
3412 return refuse(
3413 RefusalCode::SeamUnavailable,
3414 &format!("governed self-report update failed: {error}"),
3415 );
3416 }
3417 refuse(RefusalCode::GovernanceUnavailable, text)
3418}
3419
3420fn governed_seam_state(
3421 paths: &StatePaths,
3422 bound_holder: Option<String>,
3423 agent_binding: &AgentBinding,
3424) -> SeamState {
3425 SeamState {
3426 bound_holder,
3427 agent_binding: Some(agent_binding.clone()),
3428 last_seam_refusal: seam_state(paths).last_seam_refusal,
3431 }
3432}
3433
3434fn write_seam_state(paths: &StatePaths, state: SeamState) -> io::Result<()> {
3435 fs::create_dir_all(&paths.root)?;
3436 let bytes = serde_json::to_vec(&state).map_err(io::Error::other)?;
3437 let temporary = paths.seam_state.with_extension("tmp");
3438 let mut file = OpenOptions::new()
3439 .create(true)
3440 .truncate(true)
3441 .write(true)
3442 .open(&temporary)?;
3443 file.write_all(&bytes)?;
3444 file.sync_data()?;
3448 fs::rename(temporary, &paths.seam_state)
3449}
3450
3451fn seam_state(paths: &StatePaths) -> SeamState {
3452 fs::read(&paths.seam_state)
3453 .ok()
3454 .and_then(|bytes| serde_json::from_slice(&bytes).ok())
3455 .unwrap_or_default()
3456}
3457
3458fn governed_wire_request(
3459 determination: &RungRecord,
3460 agent_id: &str,
3461 request: GovernedRequest,
3462) -> Value {
3463 let metadata = json!({
3464 "agent_id": agent_id,
3465 "pid": std::process::id(),
3466 });
3467 if V12_GOVERNED_TUPLES.contains(&request.action.as_str()) {
3468 let mut wire = json!({
3471 "operation": ROUTING_OPERATION,
3472 "gh_route_schema": 1,
3473 "verb": request.action,
3474 "repository": request.repository,
3475 "number": request.target.get("number").cloned().unwrap_or(Value::Null),
3476 "manifest_version": request.manifest_version,
3477 "rung_as_of_unix_secs": determination.as_of_unix_secs,
3478 "metadata": metadata,
3479 });
3480 if request.action == "issue close" {
3481 if let Some(reason) = request.body.get("reason").cloned() {
3482 wire["reason"] = reason;
3483 }
3484 }
3485 if let Some(comment) = request.body.get("comment").cloned() {
3486 wire["comment"] = comment;
3487 }
3488 return wire;
3489 }
3490 let edit_last = request.edit_last;
3491 let mut wire = json!({
3492 "operation": ROUTING_OPERATION,
3493 "gh_route_schema": 1,
3494 "action": request.action,
3495 "target": request.target,
3496 "body": request.body,
3497 "repository": request.repository,
3498 "manifest_version": request.manifest_version,
3499 "rung_as_of_unix_secs": determination.as_of_unix_secs,
3500 "metadata": metadata,
3501 });
3502 if edit_last {
3506 wire["edit_last"] = Value::Bool(true);
3507 }
3508 wire
3509}
3510
3511fn parse_governed_response(bytes: &[u8]) -> Result<RouteOutcome, RouteOutcome> {
3512 let value: Value = serde_json::from_slice(bytes).map_err(|_| {
3513 RouteOutcome::SchemaMismatch(
3514 "governance seam returned malformed or non-UTF-8 JSON".to_string(),
3515 )
3516 })?;
3517 let object = value.as_object().ok_or_else(|| {
3518 RouteOutcome::SchemaMismatch("governance seam response must be an object".to_string())
3519 })?;
3520 match object.get("outcome").and_then(Value::as_str) {
3521 Some("result") => {
3522 let schema = object
3523 .get("gh_route_schema")
3524 .and_then(Value::as_u64)
3525 .ok_or_else(|| {
3526 RouteOutcome::SchemaMismatch(
3527 "governance seam omitted gh_route_schema".to_string(),
3528 )
3529 })?;
3530 if schema > 1 {
3531 return Err(RouteOutcome::SchemaMismatch(format!(
3532 "governance seam schema {schema} is newer than supported schema 1"
3533 )));
3534 }
3535 let result = object.get("result").ok_or_else(|| {
3536 RouteOutcome::SchemaMismatch("governance seam omitted result".to_string())
3537 })?;
3538 if let Some(body) = upstream_error_body(object, result) {
3539 return Ok(RouteOutcome::UpstreamError(body));
3540 }
3541 let field_order = object
3542 .get("field_order")
3543 .and_then(Value::as_array)
3544 .ok_or_else(|| {
3545 RouteOutcome::SchemaMismatch("governance seam omitted field_order".to_string())
3546 })?;
3547 render_governed_response(result, field_order).map(RouteOutcome::Result)
3548 }
3549 Some("refusal") => {
3550 let refusal_code = object
3551 .get("refusal_code")
3552 .and_then(Value::as_str)
3553 .ok_or_else(|| {
3554 RouteOutcome::SchemaMismatch(
3555 "governance refusal omitted a string refusal_code".to_string(),
3556 )
3557 })?;
3558 Ok(RouteOutcome::Refusal(refusal_code.to_string()))
3559 }
3560 Some("unbound_identity") => Ok(RouteOutcome::UnboundIdentity),
3561 Some("applied") => Ok(RouteOutcome::Result(render_applied_state(object)?)),
3562 Some("state_applied_comment_failed") => Ok(RouteOutcome::StateAppliedCommentFailed(
3563 render_state_applied_comment_failed(object)?,
3564 )),
3565 _ => Err(RouteOutcome::SchemaMismatch(
3566 "governance seam returned an unknown outcome".to_string(),
3567 )),
3568 }
3569}
3570
3571fn upstream_error_body(response: &Map<String, Value>, result: &Value) -> Option<String> {
3572 let result_object = result.as_object();
3573 let status = response
3574 .get("status")
3575 .or_else(|| response.get("status_code"))
3576 .or_else(|| result_object.and_then(|object| object.get("status")))
3577 .or_else(|| result_object.and_then(|object| object.get("status_code")))
3578 .and_then(|value| value.as_u64())?;
3579 if (200..300).contains(&status) {
3580 return None;
3581 }
3582 let body = response
3583 .get("error")
3584 .or_else(|| response.get("body"))
3585 .or_else(|| result_object.and_then(|object| object.get("error")))
3586 .or_else(|| result_object.and_then(|object| object.get("body")))
3587 .unwrap_or(result);
3588 Some(match body {
3589 Value::String(body) => body.clone(),
3590 _ => serde_json::to_string(body).unwrap_or_else(|_| body.to_string()),
3591 })
3592}
3593
3594fn returned_state_fields(
3595 object: &Map<String, Value>,
3596) -> Result<(String, Option<String>), RouteOutcome> {
3597 let state = object
3598 .get("state")
3599 .and_then(Value::as_str)
3600 .ok_or_else(|| {
3601 RouteOutcome::SchemaMismatch("governance seam omitted returned state".to_string())
3602 })?
3603 .to_string();
3604 let state_reason = object
3605 .get("state_reason")
3606 .and_then(Value::as_str)
3607 .map(str::to_string);
3608 Ok((state, state_reason))
3609}
3610
3611fn render_applied_state(object: &Map<String, Value>) -> Result<String, RouteOutcome> {
3612 let (state, state_reason) = returned_state_fields(object)?;
3613 let mut output = state;
3614 output.push('\n');
3615 if let Some(reason) = state_reason {
3616 output.push_str(&reason);
3617 output.push('\n');
3618 }
3619 Ok(output)
3620}
3621
3622fn render_state_applied_comment_failed(
3623 object: &Map<String, Value>,
3624) -> Result<String, RouteOutcome> {
3625 let (state, state_reason) = returned_state_fields(object)?;
3626 let comment_error = object
3627 .get("comment_error")
3628 .and_then(Value::as_object)
3629 .ok_or_else(|| {
3630 RouteOutcome::SchemaMismatch(
3631 "governance seam omitted comment_error on partial state apply".to_string(),
3632 )
3633 })?;
3634 let code = comment_error_code(comment_error.get("code"))
3635 .ok_or_else(|| RouteOutcome::SchemaMismatch("comment_error omitted code".to_string()))?;
3636 let detail = comment_error
3637 .get("detail")
3638 .and_then(Value::as_str)
3639 .ok_or_else(|| RouteOutcome::SchemaMismatch("comment_error omitted detail".to_string()))?;
3640 let mut output = format!("APPLIED {state}\n");
3641 if let Some(reason) = state_reason {
3642 output.push_str(&reason);
3643 output.push('\n');
3644 }
3645 output.push_str("comment_error: ");
3646 output.push_str(&code);
3647 output.push_str(": ");
3648 output.push_str(detail);
3649 output.push('\n');
3650 Ok(output)
3651}
3652
3653fn comment_error_code(value: Option<&Value>) -> Option<String> {
3654 match value? {
3655 Value::String(code) => Some(code.clone()),
3656 Value::Number(code) => Some(code.to_string()),
3657 _ => None,
3658 }
3659}
3660
3661fn render_governed_response(result: &Value, field_order: &[Value]) -> Result<String, RouteOutcome> {
3662 let object = result.as_object().ok_or_else(|| {
3663 RouteOutcome::SchemaMismatch("governance result must be an object".to_string())
3664 })?;
3665 let mut output = String::new();
3666 let mut rendered = BTreeSet::new();
3667 for field in field_order {
3668 let field = field.as_str().ok_or_else(|| {
3669 RouteOutcome::SchemaMismatch("field_order must contain string fields".to_string())
3670 })?;
3671 let value = object.get(field).ok_or_else(|| {
3672 RouteOutcome::SchemaMismatch(format!(
3673 "field_order references absent result field {field}"
3674 ))
3675 })?;
3676 if !rendered.insert(field) {
3677 return Err(RouteOutcome::SchemaMismatch(format!(
3678 "field_order repeats result field {field}"
3679 )));
3680 }
3681 render_field(&mut output, field, value)?;
3682 }
3683 if rendered.len() != object.len() {
3684 return Err(RouteOutcome::SchemaMismatch(
3685 "field_order does not cover every governed result field".to_string(),
3686 ));
3687 }
3688 Ok(output)
3689}
3690
3691fn render_field(output: &mut String, field: &str, value: &Value) -> Result<(), RouteOutcome> {
3692 match value {
3693 Value::Array(values) => {
3694 output.push_str(field);
3695 output.push_str(":\n");
3696 for value in values {
3697 output.push_str(" ");
3698 output.push_str(&render_scalar(value)?);
3699 output.push('\n');
3700 }
3701 }
3702 _ => {
3703 output.push_str(field);
3704 output.push_str(": ");
3705 output.push_str(&render_scalar(value)?);
3706 output.push('\n');
3707 }
3708 }
3709 Ok(())
3710}
3711
3712fn render_scalar(value: &Value) -> Result<String, RouteOutcome> {
3713 match value {
3714 Value::String(value) => serde_json::to_string(value)
3715 .map_err(|error| RouteOutcome::SchemaMismatch(error.to_string())),
3716 Value::Number(_) | Value::Bool(_) | Value::Null => Ok(value.to_string()),
3717 Value::Object(_) | Value::Array(_) => serde_json::to_string(value)
3718 .map_err(|error| RouteOutcome::SchemaMismatch(error.to_string())),
3719 }
3720}
3721
3722fn append_bypass_audit(
3723 paths: &StatePaths,
3724 tuple: &str,
3725 repository: Option<&str>,
3726 now: u64,
3727) -> io::Result<()> {
3728 fs::create_dir_all(&paths.root)?;
3729 let mut record = serde_json::to_vec(&json!({
3730 "as_of_unix_secs": now,
3731 "tuple": tuple,
3732 "repository": repository,
3733 }))
3734 .map_err(io::Error::other)?;
3735 record.push(b'\n');
3736 let mut file = OpenOptions::new()
3737 .create(true)
3738 .append(true)
3739 .open(&paths.bypass_audit)?;
3740 file.write_all(&record)?;
3741 file.sync_data()
3744}
3745
3746#[derive(Serialize)]
3747struct SelfReport {
3748 shim_version: &'static str,
3749 gh_routing_schema_floor: u64,
3750 unexpected_gh_route_advertiser: Option<Vec<String>>,
3751 bound_holder: Option<String>,
3752 agent_binding: Option<AgentBinding>,
3753 last_seam_refusal: Option<LastSeamRefusal>,
3754 cached_manifest: CachedManifestReport,
3755 last_rung: LastRungReport,
3756 last_probe: Option<LastProbeReport>,
3757 bypass_audit: Option<Vec<Value>>,
3758 bypass_audit_error: Option<String>,
3759 executing_image: Option<String>,
3760 executing_image_error: Option<String>,
3761 real_gh_resolution: Option<RealGhResolution>,
3762 real_gh_resolution_error: Option<String>,
3763 manifests_retained: usize,
3764 manifests_dir: String,
3765}
3766
3767#[derive(Serialize)]
3768struct CachedManifestReport {
3769 version: Option<u64>,
3770 issued_at_unix_secs: Option<u64>,
3773 verified_by_key_id: Option<String>,
3775 compiled_trust_set_key_ids: Vec<&'static str>,
3777 version_error: Option<String>,
3778 state: Option<&'static str>,
3779 state_error: Option<String>,
3780 diagnostics: Vec<&'static str>,
3781 diagnostic_guidance: Option<&'static str>,
3782}
3783
3784#[derive(Serialize)]
3785struct LastRungReport {
3786 rung: Option<&'static str>,
3787 rung_error: Option<String>,
3788 as_of_unix_secs: Option<u64>,
3789 as_of_unix_secs_error: Option<String>,
3790 determination_inputs: Option<BTreeMap<String, String>>,
3791 determination_inputs_error: Option<String>,
3792 recorded_by_image_path: Option<String>,
3793 recorded_by_version: Option<String>,
3794 recorded_by_repo_key: Option<String>,
3795}
3796
3797#[derive(Serialize)]
3798struct RealGhResolution {
3799 path: String,
3800 shim_path_positions: Vec<usize>,
3801}
3802
3803fn print_self_report(paths: &StatePaths) {
3804 if let Ok(document) = render_self_report(paths) {
3807 let mut stdout = io::stdout().lock();
3808 let _ = stdout.write_all(document.as_bytes());
3809 }
3810}
3811
3812fn render_self_report(paths: &StatePaths) -> Result<String, serde_json::Error> {
3813 let report = build_self_report(paths);
3814 let mut document = serde_json::to_string(&report)?;
3815 document.push('\n');
3816 Ok(document)
3817}
3818
3819fn build_self_report(paths: &StatePaths) -> SelfReport {
3820 let image = self_report_executing_image();
3821 let (real_gh_resolution, real_gh_resolution_error) = match image.as_ref() {
3822 Ok(image) => match resolve_real_gh(image) {
3823 Some(path) => (
3824 Some(RealGhResolution {
3825 path: path.to_string_lossy().into_owned(),
3826 shim_path_positions: executing_image_path_positions(image),
3827 }),
3828 None,
3829 ),
3830 None => (
3831 None,
3832 Some(
3833 "PATH contains no upstream gh after skipping the executing shim image"
3834 .to_string(),
3835 ),
3836 ),
3837 },
3838 Err(error) => (None, Some(format!("executing image unavailable: {error}"))),
3839 };
3840 let (bypass_audit, bypass_audit_error) = read_bypass_audit(paths);
3841 let seam_state = seam_state(paths);
3842 let disabled = gh_shim_enabled_from_config_doc(read_user_config_doc().as_deref().unwrap_or(""))
3846 == Some(false);
3847 let (cached_manifest, last_rung) = if disabled {
3848 (disabled_manifest_report(), disabled_last_rung_report())
3849 } else {
3850 (cached_manifest_report(paths), last_rung_report(paths))
3851 };
3852 let last_probe = if disabled {
3853 None
3854 } else {
3855 read_last_probe(paths)
3856 };
3857 SelfReport {
3858 shim_version: env!("CARGO_PKG_VERSION"),
3859 gh_routing_schema_floor: SCHEMA_FLOOR,
3860 unexpected_gh_route_advertiser: unexpected_gh_route_advertisers(paths),
3861 bound_holder: seam_state.bound_holder,
3862 agent_binding: seam_state.agent_binding,
3863 last_seam_refusal: seam_state.last_seam_refusal,
3864 cached_manifest,
3865 last_rung,
3866 last_probe,
3867 bypass_audit,
3868 bypass_audit_error,
3869 executing_image: image
3870 .as_ref()
3871 .ok()
3872 .map(|path| path.to_string_lossy().into_owned()),
3873 executing_image_error: image.err(),
3874 real_gh_resolution,
3875 real_gh_resolution_error,
3876 manifests_retained: retained_manifest_count(paths),
3877 manifests_dir: paths.manifests_dir.to_string_lossy().into_owned(),
3878 }
3879}
3880
3881fn retained_manifest_count(paths: &StatePaths) -> usize {
3885 fs::read_dir(&paths.manifests_dir)
3886 .map(|entries| {
3887 entries
3888 .filter_map(Result::ok)
3889 .filter(|entry| entry.file_type().map(|t| t.is_file()).unwrap_or(false))
3890 .count()
3891 })
3892 .unwrap_or(0)
3893}
3894
3895fn disabled_manifest_report() -> CachedManifestReport {
3899 CachedManifestReport {
3900 version: None,
3901 issued_at_unix_secs: None,
3902 verified_by_key_id: None,
3903 compiled_trust_set_key_ids: trust_set_key_ids(compiled_manifest_trust_set()),
3904 version_error: None,
3905 state: Some("disabled"),
3906 state_error: None,
3907 diagnostics: Vec::new(),
3908 diagnostic_guidance: None,
3909 }
3910}
3911
3912fn disabled_last_rung_report() -> LastRungReport {
3915 LastRungReport {
3916 rung: Some(Rung::R1.label()),
3917 rung_error: None,
3918 as_of_unix_secs: Some(unix_seconds()),
3919 as_of_unix_secs_error: None,
3920 determination_inputs: Some(BTreeMap::from([(
3921 "connection_file".to_string(),
3922 "disabled_by_config".to_string(),
3923 )])),
3924 determination_inputs_error: None,
3925 recorded_by_image_path: None,
3926 recorded_by_version: None,
3927 recorded_by_repo_key: None,
3928 }
3929}
3930
3931fn cached_manifest_report(paths: &StatePaths) -> CachedManifestReport {
3932 cached_manifest_report_at(paths, unix_seconds())
3933}
3934
3935fn cached_manifest_report_at(paths: &StatePaths, now: u64) -> CachedManifestReport {
3936 cached_manifest_report_at_with(paths, now, compiled_manifest_trust_set())
3937}
3938
3939fn cached_manifest_report_at_with(
3940 paths: &StatePaths,
3941 now: u64,
3942 trust_set: &[Option<ManifestTrustKey>],
3943) -> CachedManifestReport {
3944 let compiled_trust_set_key_ids = trust_set_key_ids(trust_set);
3945 match load_manifest_with_trust_set(paths, now, trust_set) {
3946 Ok(verified) => CachedManifestReport {
3947 version: Some(verified.manifest.manifest_version),
3948 issued_at_unix_secs: Some(verified.manifest.issued_at_unix_secs),
3949 verified_by_key_id: Some(verified.verified_by_key_id),
3950 compiled_trust_set_key_ids,
3951 version_error: None,
3952 state: Some("valid"),
3953 state_error: None,
3954 diagnostics: Vec::new(),
3955 diagnostic_guidance: None,
3956 },
3957 Err(ManifestProblem::Missing) => {
3958 let error = ManifestProblem::Missing.status_label();
3959 CachedManifestReport {
3960 version: None,
3961 issued_at_unix_secs: None,
3962 verified_by_key_id: None,
3963 compiled_trust_set_key_ids,
3964 version_error: Some(error.clone()),
3965 state: None,
3966 state_error: Some(error),
3967 diagnostics: vec![SelfReportDiagnostic::ManifestUnavailable.as_str()],
3968 diagnostic_guidance: None,
3969 }
3970 }
3971 Err(problem) => {
3972 let diagnostic_guidance = problem.untrusted_manifest_key_steering();
3976 match read_last_valid_manifest(paths) {
3977 Some(cache) => CachedManifestReport {
3978 version: Some(cache.manifest.manifest_version),
3979 issued_at_unix_secs: Some(cache.manifest.issued_at_unix_secs),
3980 verified_by_key_id: None,
3981 compiled_trust_set_key_ids,
3982 version_error: None,
3983 state: Some("regressed"),
3984 state_error: None,
3985 diagnostics: vec![
3986 SelfReportDiagnostic::ManifestRegressed.as_str(),
3987 problem.diagnostic().as_str(),
3988 ],
3989 diagnostic_guidance,
3990 },
3991 None => {
3992 let error = problem.status_label();
3993 CachedManifestReport {
3994 version: None,
3995 issued_at_unix_secs: None,
3996 verified_by_key_id: None,
3997 compiled_trust_set_key_ids,
3998 version_error: Some(error.clone()),
3999 state: None,
4000 state_error: Some(error),
4001 diagnostics: vec![problem.diagnostic().as_str()],
4002 diagnostic_guidance,
4003 }
4004 }
4005 }
4006 }
4007 }
4008}
4009
4010fn last_rung_report(paths: &StatePaths) -> LastRungReport {
4011 match fs::read(&paths.rung) {
4012 Ok(bytes) => match serde_json::from_slice::<RungRecord>(&bytes) {
4013 Ok(record) => LastRungReport {
4014 rung: Some(record.rung.label()),
4015 rung_error: None,
4016 as_of_unix_secs: Some(record.as_of_unix_secs),
4017 as_of_unix_secs_error: None,
4018 determination_inputs: Some(record.inputs),
4019 determination_inputs_error: None,
4020 recorded_by_image_path: Some(
4021 record
4022 .recorded_by_image_path
4023 .unwrap_or_else(|| PRE_PROVENANCE_RECORD.to_string()),
4024 ),
4025 recorded_by_version: Some(
4026 record
4027 .recorded_by_version
4028 .unwrap_or_else(|| PRE_PROVENANCE_RECORD.to_string()),
4029 ),
4030 recorded_by_repo_key: Some(
4031 record
4032 .recorded_by_repo_key
4033 .unwrap_or_else(|| PRE_PROVENANCE_RECORD.to_string()),
4034 ),
4035 },
4036 Err(error) => unavailable_last_rung(format!("corrupt rung cache: {error}")),
4037 },
4038 Err(error) if error.kind() == io::ErrorKind::NotFound => {
4039 unavailable_last_rung("rung cache is unavailable".to_string())
4040 }
4041 Err(error) => unavailable_last_rung(format!("rung cache is unavailable: {error}")),
4042 }
4043}
4044
4045fn unavailable_last_rung(error: String) -> LastRungReport {
4046 LastRungReport {
4047 rung: None,
4048 rung_error: Some(error.clone()),
4049 as_of_unix_secs: None,
4050 as_of_unix_secs_error: Some(error.clone()),
4051 determination_inputs: None,
4052 determination_inputs_error: Some(error),
4053 recorded_by_image_path: None,
4054 recorded_by_version: None,
4055 recorded_by_repo_key: None,
4056 }
4057}
4058
4059fn read_bypass_audit(paths: &StatePaths) -> (Option<Vec<Value>>, Option<String>) {
4060 let contents = match fs::read_to_string(&paths.bypass_audit) {
4061 Ok(contents) => contents,
4062 Err(error) if error.kind() == io::ErrorKind::NotFound => return (Some(Vec::new()), None),
4063 Err(error) => return (None, Some(format!("bypass audit is unavailable: {error}"))),
4064 };
4065 let mut records = Vec::new();
4066 for (line_number, line) in contents.lines().enumerate() {
4067 match serde_json::from_str(line) {
4068 Ok(record) => records.push(record),
4069 Err(error) => {
4070 return (
4071 None,
4072 Some(format!(
4073 "bypass audit is corrupt at line {}: {error}",
4074 line_number + 1
4075 )),
4076 )
4077 }
4078 }
4079 }
4080 (Some(records), None)
4081}
4082
4083fn unexpected_gh_route_advertisers(paths: &StatePaths) -> Option<Vec<String>> {
4084 serde_json::from_slice(&fs::read(&paths.unexpected_gh_route_advertisers).ok()?)
4085 .ok()
4086 .filter(|advertisers: &Vec<String>| !advertisers.is_empty())
4087}
4088
4089fn record_unexpected_gh_route_advertisers(paths: &StatePaths, advertisers: &[String]) {
4090 if advertisers.is_empty() {
4091 return;
4092 }
4093 let mut recorded = unexpected_gh_route_advertisers(paths)
4094 .unwrap_or_default()
4095 .into_iter()
4096 .collect::<BTreeSet<_>>();
4097 recorded.extend(advertisers.iter().cloned());
4098 let Ok(bytes) = serde_json::to_vec(&recorded.into_iter().collect::<Vec<_>>()) else {
4099 return;
4100 };
4101 let _ = fs::create_dir_all(&paths.root);
4102 let temporary = paths.unexpected_gh_route_advertisers.with_extension("tmp");
4103 if fs::write(&temporary, bytes).is_ok() {
4104 let _ = fs::rename(temporary, &paths.unexpected_gh_route_advertisers);
4105 }
4106}
4107
4108fn self_report_executing_image() -> Result<PathBuf, String> {
4109 let path = std::env::current_exe().map_err(|error| error.to_string())?;
4110 Ok(path.canonicalize().unwrap_or(path))
4111}
4112
4113fn executing_image() -> PathBuf {
4114 std::env::current_exe()
4115 .ok()
4116 .and_then(|path| path.canonicalize().ok().or(Some(path)))
4117 .unwrap_or_else(|| PathBuf::from("unavailable"))
4118}
4119
4120fn executing_image_path_positions(image: &Path) -> Vec<usize> {
4121 let path = std::env::var_os("PATH").unwrap_or_default();
4122 std::env::split_paths(&path)
4123 .enumerate()
4124 .filter_map(|(index, directory)| same_image(&directory.join("gh"), image).then_some(index))
4125 .collect()
4126}
4127
4128fn delegate(args: &[OsString]) -> i32 {
4129 let image = executing_image();
4130 let Some(real_gh) = resolve_real_gh(&image) else {
4131 return refuse(
4132 RefusalCode::NoRealGh,
4133 "PATH contains no upstream gh after skipping the executing shim image",
4134 );
4135 };
4136 exec_real_gh(real_gh, args)
4137}
4138
4139fn resolve_real_gh(executing_image: &Path) -> Option<PathBuf> {
4140 let path = std::env::var_os("PATH")?;
4141 let shims_dir = crate::environment::non_empty_os_var("AFT_GH_SHIMS_DIR").map(PathBuf::from);
4142 resolve_real_gh_in_path(executing_image, &path, shims_dir.as_deref())
4143}
4144
4145fn resolve_real_gh_in_path(
4146 executing_image: &Path,
4147 path: &OsStr,
4148 shims_dir: Option<&Path>,
4149) -> Option<PathBuf> {
4150 std::env::split_paths(path).find_map(|directory| {
4151 if shims_dir.is_some_and(|shims_dir| same_directory(&directory, shims_dir)) {
4152 return None;
4153 }
4154 gh_candidate_names().iter().find_map(|name| {
4155 let candidate = directory.join(name);
4156 (is_executable_file(&candidate) && !same_image(&candidate, executing_image))
4157 .then_some(candidate)
4158 })
4159 })
4160}
4161
4162#[cfg(windows)]
4163fn gh_candidate_names() -> &'static [&'static str] {
4164 &["gh.exe", "gh.cmd", "gh.bat", "gh"]
4165}
4166
4167#[cfg(not(windows))]
4168fn gh_candidate_names() -> &'static [&'static str] {
4169 &["gh"]
4170}
4171
4172fn same_directory(left: &Path, right: &Path) -> bool {
4173 left == right
4174 || left
4175 .canonicalize()
4176 .ok()
4177 .zip(right.canonicalize().ok())
4178 .is_some_and(|(left, right)| left == right)
4179}
4180
4181fn is_executable_file(path: &Path) -> bool {
4182 if !path.is_file() {
4183 return false;
4184 }
4185 #[cfg(unix)]
4186 {
4187 use std::os::unix::fs::PermissionsExt;
4188 return fs::metadata(path).is_ok_and(|metadata| metadata.permissions().mode() & 0o111 != 0);
4189 }
4190 #[cfg(not(unix))]
4191 true
4192}
4193
4194fn same_image(left: &Path, right: &Path) -> bool {
4195 let left_canonical = left.canonicalize().ok();
4196 let right_canonical = right.canonicalize().ok();
4197 if left_canonical.is_some() && left_canonical == right_canonical {
4198 return true;
4199 }
4200 #[cfg(unix)]
4201 {
4202 use std::os::unix::fs::MetadataExt;
4203 if let (Ok(left), Ok(right)) = (fs::metadata(left), fs::metadata(right)) {
4204 return left.dev() == right.dev() && left.ino() == right.ino();
4205 }
4206 }
4207 false
4208}
4209
4210#[cfg(unix)]
4211fn exec_real_gh(real_gh: PathBuf, args: &[OsString]) -> i32 {
4212 use std::os::unix::process::CommandExt;
4213 let error = Command::new(real_gh).args(args).exec();
4214 refuse(
4218 RefusalCode::NoRealGh,
4219 &format!("unable to exec upstream gh: {error}"),
4220 )
4221}
4222
4223#[cfg(not(unix))]
4224fn exec_real_gh(real_gh: PathBuf, args: &[OsString]) -> i32 {
4225 match Command::new(real_gh).args(args).status() {
4226 Ok(status) => status.code().unwrap_or(1),
4227 Err(error) => refuse(
4228 RefusalCode::NoRealGh,
4229 &format!("unable to exec upstream gh: {error}"),
4230 ),
4231 }
4232}
4233
4234fn refuse(code: RefusalCode, text: &str) -> i32 {
4235 let text = text.replace(['\n', '\r'], " ");
4236 eprintln!("gh-shim: {}: {text}", code.as_str());
4237 REFUSAL_EXIT_STATUS
4238}
4239
4240fn current_platform() -> &'static str {
4241 if cfg!(target_os = "macos") {
4242 "macos"
4243 } else if cfg!(target_os = "linux") {
4244 "linux"
4245 } else {
4246 "unsupported"
4247 }
4248}
4249
4250fn unix_seconds() -> u64 {
4251 SystemTime::now()
4252 .duration_since(UNIX_EPOCH)
4253 .unwrap_or_default()
4254 .as_secs()
4255}
4256
4257#[cfg(test)]
4258#[cfg_attr(not(debug_assertions), allow(dead_code))]
4262mod tests {
4263 use super::*;
4264 use ring::signature::{Ed25519KeyPair, KeyPair};
4265 use sha2::{Digest, Sha256};
4266
4267 const TEST_SEED: [u8; 32] = [
4268 0x9d, 0x61, 0xb1, 0x9d, 0xef, 0xfd, 0x5a, 0x60, 0xba, 0x84, 0x4a, 0xf4, 0x92, 0xec, 0x2c,
4269 0xc4, 0x44, 0x49, 0xc5, 0x69, 0x7b, 0x32, 0x69, 0x19, 0x70, 0x3b, 0xac, 0x03, 0x1c, 0xae,
4270 0x7f, 0x60,
4271 ];
4272 const STANDBY_TEST_SEED: [u8; 32] = *b"gh-shim-standby-fixture-seed-001";
4277 const DEV_STANDBY_MANIFEST_KEY_ID: &str = "gh-routing-dev-standby-key-v1";
4278 const FIXTURE_ISSUED_AT: u64 = 1_787_184_000;
4281 const TEST_NOW: u64 = FIXTURE_ISSUED_AT + 60;
4282 const FIXTURE_ACCEPTED_SEAM_REFUSAL_CODES: &[&str] = &[
4283 "identity_mismatch",
4284 "unmapped_operation",
4285 "custody_unavailable",
4286 "schema_unsupported",
4287 "rate_limited",
4288 ];
4289 const BRANCH_PROTECTION_PATH_GLOB: &str = "/repos/*/*/branches/*/protection";
4290 const BRANCH_PROTECTION_API_TUPLE: &str = "api:PUT:/repos/*/*/branches/*/protection";
4291
4292 struct ScopedTestEnvVar {
4293 key: &'static str,
4294 previous: Option<OsString>,
4295 }
4296
4297 impl ScopedTestEnvVar {
4298 fn set(key: &'static str, value: Option<&str>) -> Self {
4299 let previous = std::env::var_os(key);
4300 match value {
4301 Some(value) => unsafe { std::env::set_var(key, value) },
4302 None => unsafe { std::env::remove_var(key) },
4303 }
4304 Self { key, previous }
4305 }
4306 }
4307
4308 impl Drop for ScopedTestEnvVar {
4309 fn drop(&mut self) {
4310 match self.previous.take() {
4311 Some(previous) => unsafe { std::env::set_var(self.key, previous) },
4312 None => unsafe { std::env::remove_var(self.key) },
4313 }
4314 }
4315 }
4316
4317 fn fixture_manifest() -> Manifest {
4318 serde_json::from_str(include_str!(
4319 "../tests/fixtures/gh_shim/initial-manifest-v1.json"
4320 ))
4321 .expect("initial manifest fixture")
4322 }
4323
4324 fn v9_fixture_manifest() -> Manifest {
4325 serde_json::from_str(include_str!("../tests/fixtures/gh_shim/v9-manifest.json"))
4326 .expect("v9 manifest fixture")
4327 }
4328
4329 fn v10_fixture_manifest() -> Manifest {
4330 serde_json::from_str(include_str!("../tests/fixtures/gh_shim/v10-manifest.json"))
4331 .expect("v10 manifest fixture")
4332 }
4333
4334 fn v11_fixture_manifest() -> Manifest {
4335 serde_json::from_str(include_str!("../tests/fixtures/gh_shim/v11-manifest.json"))
4336 .expect("v11 manifest fixture")
4337 }
4338
4339 fn v12_fixture_manifest() -> Manifest {
4340 serde_json::from_str(include_str!("../tests/fixtures/gh_shim/v12-manifest.json"))
4341 .expect("v12 manifest fixture")
4342 }
4343
4344 fn branch_protection_manifest(method: &str, tier: Tier) -> Manifest {
4345 let mut manifest = v12_fixture_manifest();
4346 manifest.manifest_version = 13;
4347 manifest.api_rules.push(ApiRule {
4348 method: method.to_string(),
4349 path_glob: BRANCH_PROTECTION_PATH_GLOB.to_string(),
4350 tier,
4351 platform: vec!["macos".to_string(), "linux".to_string()],
4352 rationale: Some(
4353 "branch protection is a repository setting; operator identity, audited bypass"
4354 .to_string(),
4355 ),
4356 });
4357 manifest
4358 }
4359
4360 fn os_args(args: &[&str]) -> Vec<OsString> {
4361 args.iter().map(OsString::from).collect()
4362 }
4363
4364 fn edit_last_vectors_fixture() -> Value {
4365 let fixture = include_str!("../tests/fixtures/gh_shim/edit-last-vectors-v1.json");
4368 let json = fixture
4369 .lines()
4370 .filter(|line| !line.starts_with("//"))
4371 .collect::<Vec<_>>()
4372 .join("\n");
4373 serde_json::from_str(&json).expect("producer edit-last vectors fixture")
4374 }
4375
4376 fn signed_with(
4377 manifest: &Manifest,
4378 fetched_at_unix_secs: u64,
4379 seed: &[u8; 32],
4380 key_id: &str,
4381 ) -> SignedManifest {
4382 let key = Ed25519KeyPair::from_seed_unchecked(seed).expect("test key");
4383 let bytes = serde_json::to_vec(manifest).expect("manifest bytes");
4384 SignedManifest {
4385 artifact_id: MANIFEST_ARTIFACT_ID.to_string(),
4386 envelope_version: ENVELOPE_VERSION,
4387 key_id: key_id.to_string(),
4388 fetched_at_unix_secs,
4389 signature: base64::engine::general_purpose::STANDARD.encode(key.sign(&bytes).as_ref()),
4390 manifest_bytes: String::from_utf8(bytes).expect("manifest bytes are UTF-8"),
4391 }
4392 }
4393
4394 fn signed(manifest: &Manifest, fetched_at_unix_secs: u64) -> SignedManifest {
4395 let key = Ed25519KeyPair::from_seed_unchecked(&TEST_SEED).expect("test key");
4396 assert_eq!(key.public_key().as_ref(), DEV_MANIFEST_PUBLIC_KEY);
4397 signed_with(
4398 manifest,
4399 fetched_at_unix_secs,
4400 &TEST_SEED,
4401 DEV_MANIFEST_KEY_ID,
4402 )
4403 }
4404
4405 fn write_signed_manifest(paths: &StatePaths, manifest: Manifest, now: u64) {
4406 fs::create_dir_all(&paths.root).expect("state root");
4407 fs::write(
4408 &paths.manifest,
4409 serde_json::to_vec(&signed(&manifest, now)).expect("signed manifest"),
4410 )
4411 .expect("manifest cache");
4412 }
4413
4414 fn write_envelope_fixture(paths: &StatePaths, envelope_json: &str) {
4415 fs::create_dir_all(&paths.root).expect("state root");
4416 fs::write(&paths.manifest, envelope_json.as_bytes()).expect("manifest cache");
4417 }
4418
4419 fn test_rung_provenance() -> RungRecordProvenance {
4420 RungRecordProvenance {
4421 image_path: "/opt/cortexkit/aft-gh-shim".to_string(),
4422 version: "0.53.0-test".to_string(),
4423 repo_key: "cortexkit/aft".to_string(),
4424 }
4425 }
4426
4427 #[test]
4428 fn shim_dispatch_precedes_global_argument_scans_for_both_forms() {
4429 assert!(is_shim_invocation(
4430 OsStr::new("gh"),
4431 &[OsString::from("--version")]
4432 ));
4433 assert!(is_shim_invocation(
4434 OsStr::new("aft"),
4435 &[OsString::from("gh-shim"), OsString::from("--version")]
4436 ));
4437 assert!(!is_shim_invocation(
4438 OsStr::new("aft"),
4439 &[OsString::from("--version")]
4440 ));
4441 }
4442
4443 #[test]
4444 fn reserved_self_report_tokens_are_exactly_the_two_first_arguments() {
4445 assert_eq!(RESERVED_SELF_REPORT, ["--status", "--shim-version"]);
4446 assert!(is_reserved_self_report(&[OsString::from("--status")]));
4447 assert!(is_reserved_self_report(&[OsString::from("--shim-version")]));
4448 assert!(!is_reserved_self_report(&[OsString::from("status")]));
4449 assert!(!is_reserved_self_report(&[
4450 OsString::from("issue"),
4451 OsString::from("--status")
4452 ]));
4453 }
4454
4455 #[cfg(unix)]
4456 #[test]
4457 fn real_gh_resolution_skips_the_managed_shims_directory_without_recursing() {
4458 use std::os::unix::fs::{symlink, PermissionsExt};
4459
4460 let directory = tempfile::tempdir().unwrap();
4461 let image = directory.path().join("aft");
4462 fs::write(&image, "image").unwrap();
4463 let shims = directory.path().join("shims");
4464 let upstream = directory.path().join("upstream");
4465 fs::create_dir_all(&shims).unwrap();
4466 fs::create_dir_all(&upstream).unwrap();
4467 symlink(&image, shims.join("gh")).unwrap();
4468 let real = upstream.join("gh");
4469 fs::write(&real, "#!/bin/sh\nexit 0\n").unwrap();
4470 let mut permissions = fs::metadata(&real).unwrap().permissions();
4471 permissions.set_mode(0o755);
4472 fs::set_permissions(&real, permissions).unwrap();
4473 let path = std::env::join_paths([shims.clone(), upstream]).unwrap();
4474
4475 assert_eq!(
4476 resolve_real_gh_in_path(&image, &path, Some(&shims)),
4477 Some(real)
4478 );
4479 }
4480
4481 #[test]
4482 fn state_paths_from_process_obey_the_test_state_guard() {
4483 let _guard = crate::test_env::gh_shim_state_guard();
4484 let selected = std::env::var_os(GH_SHIM_STATE_DIR_ENV).expect("test state override");
4485 assert_eq!(StatePaths::from_process().root, PathBuf::from(selected));
4486 }
4487
4488 #[test]
4492 fn state_dir_uses_dedicated_override_then_xdg_state_home_then_home_and_ignores_empty_values() {
4493 let xdg_state = tempfile::tempdir().unwrap();
4494 let home = tempfile::tempdir().unwrap();
4495 let dedicated = tempfile::tempdir().unwrap();
4496 let before = fs::metadata(dedicated.path()).unwrap().modified().unwrap();
4497 let tail = Path::new("cortexkit").join("aft").join("gh-shim");
4498
4499 assert_eq!(
4500 gh_shim_state_dir_from(
4501 Some(dedicated.path().as_os_str()),
4502 Some(xdg_state.path().as_os_str()),
4503 Some(home.path().as_os_str()),
4504 ),
4505 dedicated.path()
4506 );
4507 assert_eq!(
4508 fs::metadata(dedicated.path()).unwrap().modified().unwrap(),
4509 before,
4510 "resolving the dedicated override must not create or rewrite state"
4511 );
4512 assert_eq!(
4513 gh_shim_state_dir_from(
4514 None,
4515 Some(xdg_state.path().as_os_str()),
4516 Some(home.path().as_os_str())
4517 ),
4518 xdg_state.path().join(&tail),
4519 "the operator's XDG state home is the rung the ceremony writes to"
4520 );
4521 assert_eq!(
4522 gh_shim_state_dir_from(
4523 Some(OsStr::new("")),
4524 Some(OsStr::new("")),
4525 Some(home.path().as_os_str())
4526 ),
4527 home.path().join(".local/state").join(&tail),
4528 "empty override and empty XDG_STATE_HOME fall through to HOME"
4529 );
4530 assert_eq!(
4531 gh_shim_state_dir_from(None, Some(OsStr::new("relative/state")), None),
4532 std::env::temp_dir().join(&tail),
4533 "a relative XDG_STATE_HOME is not a rung"
4534 );
4535 }
4536
4537 #[test]
4538 fn status_serializes_one_json_document_with_the_exact_top_level_schema() {
4539 let directory = tempfile::tempdir().unwrap();
4540 let paths = StatePaths::from_root(directory.path().to_path_buf());
4541 let document = render_self_report(&paths).expect("self report serialization");
4542 assert!(document.ends_with('\n'));
4543 let value: Value = serde_json::from_str(&document).expect("self report JSON");
4544 let keys = value
4545 .as_object()
4546 .expect("self report object")
4547 .keys()
4548 .cloned()
4549 .collect::<Vec<_>>();
4550 assert_eq!(
4551 keys,
4552 vec![
4553 "shim_version",
4554 "gh_routing_schema_floor",
4555 "unexpected_gh_route_advertiser",
4556 "bound_holder",
4557 "agent_binding",
4558 "last_seam_refusal",
4559 "cached_manifest",
4560 "last_rung",
4561 "last_probe",
4562 "bypass_audit",
4563 "bypass_audit_error",
4564 "executing_image",
4565 "executing_image_error",
4566 "real_gh_resolution",
4567 "real_gh_resolution_error",
4568 "manifests_retained",
4569 "manifests_dir",
4570 ]
4571 );
4572 }
4573
4574 #[test]
4575 fn route_holder_is_pinned_and_records_other_advertisers() {
4576 let holder = select_route_holder([
4577 "other-module".to_string(),
4578 ROUTING_HOLDER_MODULE_ID.to_string(),
4579 "another-module".to_string(),
4580 ]);
4581 assert_eq!(holder.module_id.as_deref(), Some(ROUTING_HOLDER_MODULE_ID));
4582 assert_eq!(
4583 holder.unexpected_advertisers,
4584 vec!["another-module", "other-module"]
4585 );
4586
4587 let holder = select_route_holder(["other-module".to_string()]);
4588 assert_eq!(holder.module_id, None);
4589 assert_eq!(holder.unexpected_advertisers, vec!["other-module"]);
4590 }
4591
4592 #[test]
4593 fn unexpected_route_advertisers_are_persisted_for_self_report() {
4594 let directory = tempfile::tempdir().unwrap();
4595 let paths = StatePaths::from_root(directory.path().to_path_buf());
4596 record_unexpected_gh_route_advertisers(&paths, &["other-module".to_string()]);
4597 record_unexpected_gh_route_advertisers(&paths, &["another-module".to_string()]);
4598
4599 assert_eq!(
4600 unexpected_gh_route_advertisers(&paths),
4601 Some(vec![
4602 "another-module".to_string(),
4603 "other-module".to_string(),
4604 ])
4605 );
4606 assert_eq!(
4607 build_self_report(&paths).unexpected_gh_route_advertiser,
4608 Some(vec![
4609 "another-module".to_string(),
4610 "other-module".to_string(),
4611 ])
4612 );
4613 }
4614
4615 #[test]
4616 fn disabled_by_config_short_circuits_to_r1_without_connection_file_read() {
4617 let directory = tempfile::tempdir().unwrap();
4618 let paths = StatePaths::from_root(directory.path().to_path_buf());
4619 let doc = serde_json::json!({
4622 "gh_shim": { "enabled": false },
4623 "subc": { "connection_file": "/nonexistent/connection.json" }
4624 })
4625 .to_string();
4626 let record = determine_rung_from_doc(
4627 &paths,
4628 Path::new("/cwd"),
4629 123,
4630 std::time::Instant::now() + DISCOVERY_BUDGET,
4631 Some(&doc),
4632 );
4633 assert_eq!(record.record.rung, Rung::R1);
4634 assert_eq!(
4635 record
4636 .record
4637 .inputs
4638 .get("connection_file")
4639 .map(String::as_str),
4640 Some("disabled_by_config")
4641 );
4642 assert!(!paths.root.join("rung-cache.json").exists());
4644 }
4645
4646 #[test]
4647 fn configured_but_unreachable_connection_file_is_distinct_from_absence() {
4648 let directory = tempfile::tempdir().unwrap();
4649 let paths = StatePaths::from_root(directory.path().to_path_buf());
4650 let connection_file = directory.path().join("missing-connection.json");
4651 let doc = serde_json::json!({
4652 "subc": { "connection_file": connection_file }
4653 })
4654 .to_string();
4655 let record = determine_rung_from_doc(
4656 &paths,
4657 Path::new("/cwd"),
4658 1,
4659 std::time::Instant::now() + DISCOVERY_BUDGET,
4660 Some(&doc),
4661 );
4662 assert_eq!(record.record.rung, Rung::R1);
4663 assert_eq!(
4664 record
4665 .record
4666 .inputs
4667 .get("connection_file")
4668 .map(String::as_str),
4669 Some("unreachable")
4670 );
4671 }
4672
4673 #[test]
4674 fn enabled_default_keeps_structural_rungs() {
4675 let directory = tempfile::tempdir().unwrap();
4676 let paths = StatePaths::from_root(directory.path().to_path_buf());
4677 let record = determine_rung_from_doc(
4679 &paths,
4680 Path::new("/cwd"),
4681 1,
4682 std::time::Instant::now() + DISCOVERY_BUDGET,
4683 Some("{}"),
4684 );
4685 assert_eq!(record.record.rung, Rung::R1);
4686 assert_eq!(
4687 record
4688 .record
4689 .inputs
4690 .get("connection_file")
4691 .map(String::as_str),
4692 Some("absent_or_unparseable")
4693 );
4694 }
4695
4696 #[test]
4697 fn xdg_connection_config_precedes_home_config() {
4698 let directory = tempfile::tempdir().unwrap();
4699 let xdg = directory.path().join("xdg");
4700 let home = directory.path().join("home");
4701 let xdg_connection = directory.path().join("xdg-connection.json");
4702 let home_connection = directory.path().join("home-connection.json");
4703 fs::write(&xdg_connection, "{}").unwrap();
4704 fs::write(&home_connection, "{}").unwrap();
4705 let xdg_config = xdg.join("cortexkit/aft.jsonc");
4706 let home_config = home.join(".config/cortexkit/aft.jsonc");
4707 fs::create_dir_all(xdg_config.parent().unwrap()).unwrap();
4708 fs::create_dir_all(home_config.parent().unwrap()).unwrap();
4709 fs::write(
4713 &xdg_config,
4714 serde_json::json!({"subc": {"connection_file": xdg_connection}}).to_string(),
4715 )
4716 .unwrap();
4717 fs::write(
4718 &home_config,
4719 serde_json::json!({"subc": {"connection_file": home_connection}}).to_string(),
4720 )
4721 .unwrap();
4722
4723 assert_eq!(
4724 configured_connection_file_from(Some(xdg.as_os_str()), Some(home.as_os_str())),
4725 Some(xdg_connection)
4726 );
4727 }
4728
4729 #[test]
4730 fn initial_manifest_is_complete_and_valid() {
4731 fixture_manifest()
4732 .validate()
4733 .expect("valid initial manifest");
4734 }
4735
4736 #[test]
4737 fn v11_and_v12_thread_state_manifests_validate() {
4738 v11_fixture_manifest()
4739 .validate()
4740 .expect("v11 keeps the four thread-state verbs at admin without canonicalization");
4741 v12_fixture_manifest()
4742 .validate()
4743 .expect("v12 governed target-and-state tuples must satisfy the canonicalization check");
4744 }
4745
4746 #[test]
4747 fn v9_admin_tuple_fixture_differentiates_native_writes_from_raw_api_delete() {
4748 let manifest = v9_fixture_manifest();
4749 assert_eq!(manifest.manifest_version, 9);
4750 manifest.validate().expect("valid v9 manifest");
4751
4752 for (args, expected_tuple) in [
4753 (
4754 vec![
4755 OsString::from("repo"),
4756 OsString::from("edit"),
4757 OsString::from("cortexkit/insula"),
4758 OsString::from("--visibility"),
4759 OsString::from("public"),
4760 ],
4761 "repo edit",
4762 ),
4763 (
4764 vec![
4765 OsString::from("repo"),
4766 OsString::from("edit"),
4767 OsString::from("cortexkit/insula"),
4768 OsString::from("--visibility"),
4769 OsString::from("private"),
4770 ],
4771 "repo edit",
4772 ),
4773 (
4774 vec![
4775 OsString::from("run"),
4776 OsString::from("delete"),
4777 OsString::from("123"),
4778 OsString::from("--repo"),
4779 OsString::from("cortexkit/insula"),
4780 ],
4781 "run delete",
4782 ),
4783 ] {
4784 assert!(matches!(
4785 classify(&args, &manifest, "macos"),
4786 Classification::Admin { tuple } if tuple == expected_tuple
4787 ));
4788 }
4789
4790 let raw_api_delete = [
4791 OsString::from("api"),
4792 OsString::from("-X"),
4793 OsString::from("DELETE"),
4794 OsString::from("repos/cortexkit/insula/actions/runs/123"),
4795 ];
4796 assert!(matches!(
4797 classify(&raw_api_delete, &manifest, "macos"),
4798 Classification::Unclassified
4799 ));
4800
4801 let get_control = [
4802 OsString::from("api"),
4803 OsString::from("repos/cortexkit/insula"),
4804 OsString::from("--jq"),
4805 OsString::from(".name"),
4806 ];
4807 assert!(matches!(
4808 classify(&get_control, &manifest, "macos"),
4809 Classification::Mechanical
4810 ));
4811 }
4812
4813 #[test]
4814 fn v10_workflow_run_admin_tuple_is_version_gated_and_raw_dispatch_stays_unclassified() {
4815 let manifest = v10_fixture_manifest();
4816 assert_eq!(manifest.manifest_version, 10);
4817 manifest.validate().expect("valid v10 manifest");
4818
4819 let workflow_run = [
4820 OsString::from("workflow"),
4821 OsString::from("run"),
4822 OsString::from("ci.yml"),
4823 OsString::from("--ref"),
4824 OsString::from("main"),
4825 ];
4826 assert!(matches!(
4827 classify(&workflow_run, &manifest, "macos"),
4828 Classification::Admin { tuple } if tuple == "workflow run"
4829 ));
4830
4831 let mut v9_manifest = manifest.clone();
4835 v9_manifest.manifest_version = 9;
4836 assert!(matches!(
4837 classify(&workflow_run, &v9_manifest, "macos"),
4838 Classification::Unclassified
4839 ));
4840
4841 let raw_api_dispatch = [
4842 OsString::from("api"),
4843 OsString::from("-X"),
4844 OsString::from("POST"),
4845 OsString::from("repos/cortexkit/aft/actions/workflows/ci.yml/dispatches"),
4846 ];
4847 for manifest in [&manifest, &v9_manifest] {
4848 assert!(matches!(
4849 classify(&raw_api_dispatch, manifest, "macos"),
4850 Classification::Unclassified
4851 ));
4852 }
4853 }
4854
4855 #[test]
4856 fn v10_run_rerun_is_flag_tolerant_and_run_cancel_stays_out_of_bypass_set() {
4857 let mut manifest = v10_fixture_manifest();
4858 let admin = manifest
4859 .tiers
4860 .get_mut(&Tier::Admin)
4861 .expect("v10 admin tier");
4862 for tuple in ["run rerun", "run cancel"] {
4863 admin.push(TupleDecl::Details {
4864 tuple: tuple.to_string(),
4865 platform: vec!["macos".to_string(), "linux".to_string()],
4866 api_match: None,
4867 rationale: None,
4868 });
4869 }
4870 manifest.validate().expect("valid v10 admin extensions");
4871
4872 for args in [
4873 vec![
4874 OsString::from("run"),
4875 OsString::from("rerun"),
4876 OsString::from("123"),
4877 OsString::from("--failed"),
4878 ],
4879 vec![
4880 OsString::from("run"),
4881 OsString::from("rerun"),
4882 OsString::from("123"),
4883 OsString::from("--job"),
4884 OsString::from("17"),
4885 ],
4886 ] {
4887 assert!(matches!(
4888 classify(&args, &manifest, "macos"),
4889 Classification::Admin { tuple } if tuple == "run rerun"
4890 ));
4891 }
4892 assert!(is_reviewed_admin_tuple(10, "run rerun"));
4893 assert!(!is_reviewed_admin_tuple(9, "run rerun"));
4894 let mut v9_manifest = manifest.clone();
4895 v9_manifest.manifest_version = 9;
4896 let v9_rerun = [
4897 OsString::from("run"),
4898 OsString::from("rerun"),
4899 OsString::from("123"),
4900 OsString::from("--failed"),
4901 ];
4902 assert!(matches!(
4903 classify(&v9_rerun, &v9_manifest, "macos"),
4904 Classification::Unclassified
4905 ));
4906
4907 let run_cancel = [
4908 OsString::from("run"),
4909 OsString::from("cancel"),
4910 OsString::from("123"),
4911 ];
4912 assert!(!is_reviewed_admin_tuple(10, "run cancel"));
4913 assert!(matches!(
4914 classify(&run_cancel, &manifest, "macos"),
4915 Classification::Unclassified
4916 ));
4917 }
4918
4919 #[test]
4920 fn v12_thread_state_verbs_route_target_and_state_and_v11_stays_admin() {
4921 let v12 = v12_fixture_manifest();
4922 let v11 = v11_fixture_manifest();
4923 v12.validate().expect("valid v12 manifest");
4924 v11.validate().expect("valid v11 manifest");
4925 assert_eq!(v12.manifest_version, 12);
4926 assert_eq!(v11.manifest_version, 11);
4927
4928 let determination =
4929 RungDetermination::r3(1_700_000_000, v12.manifest_version, &test_rung_provenance());
4930 let body_file = fixture_dir().join("governed-speech.md");
4931 let expected_comment = fs::read_to_string(&body_file).expect("speech body fixture");
4932
4933 let close_args = os_args(&[
4934 "issue",
4935 "close",
4936 "42",
4937 "--reason",
4938 "completed",
4939 "--repo",
4940 "cortexkit/aft",
4941 ]);
4942 let Classification::Governed {
4943 tuple: close_tuple,
4944 canonical: close_canonical,
4945 } = classify(&close_args, &v12, "macos")
4946 else {
4947 panic!("v12 issue close must be governed");
4948 };
4949 assert_eq!(close_tuple, "issue close");
4950 assert!(close_canonical
4951 .argv_forms
4952 .iter()
4953 .any(|form| form == TARGET_AND_STATE_FORM));
4954 let close_request = canonicalize_governed(
4955 &close_args,
4956 &close_tuple,
4957 &close_canonical,
4958 v12.manifest_version,
4959 )
4960 .expect("issue close with --reason should canonicalize");
4961 let close_wire = governed_wire_request(&determination.record, "alfonso-aft", close_request);
4962 assert_eq!(close_wire["verb"], "issue close");
4963 assert_eq!(close_wire["repository"], "cortexkit/aft");
4964 assert_eq!(close_wire["number"], "42");
4965 assert_eq!(close_wire["reason"], "completed");
4966 assert!(close_wire.get("comment").is_none());
4967 assert!(close_wire.get("action").is_none());
4968 assert!(close_wire.get("target").is_none());
4969 assert!(close_wire.get("body").is_none());
4970 assert!(close_wire.get("delete-branch").is_none());
4971 assert!(close_wire.get("delete_branch").is_none());
4972
4973 let reopen_args = os_args(&["pr", "reopen", "7", "--repo", "cortexkit/aft"]);
4974 let Classification::Governed {
4975 tuple: reopen_tuple,
4976 canonical: reopen_canonical,
4977 } = classify(&reopen_args, &v12, "macos")
4978 else {
4979 panic!("v12 pr reopen must be governed");
4980 };
4981 assert_eq!(reopen_tuple, "pr reopen");
4982 let reopen_request = canonicalize_governed(
4983 &reopen_args,
4984 &reopen_tuple,
4985 &reopen_canonical,
4986 v12.manifest_version,
4987 )
4988 .expect("pr reopen should canonicalize without --reason");
4989 let reopen_wire =
4990 governed_wire_request(&determination.record, "alfonso-aft", reopen_request);
4991 assert_eq!(reopen_wire["verb"], "pr reopen");
4992 assert_eq!(reopen_wire["repository"], "cortexkit/aft");
4993 assert_eq!(reopen_wire["number"], "7");
4994 assert!(reopen_wire.get("reason").is_none());
4995 assert!(reopen_wire.get("comment").is_none());
4996 assert!(reopen_wire.get("delete-branch").is_none());
4997
4998 for (args, expected_tuple) in [
4999 (
5000 os_args(&["issue", "close", "42", "--reason", "not_planned"]),
5001 "issue close",
5002 ),
5003 (os_args(&["issue", "reopen", "42"]), "issue reopen"),
5004 (os_args(&["pr", "close", "7"]), "pr close"),
5005 (os_args(&["pr", "reopen", "7"]), "pr reopen"),
5006 ] {
5007 assert!(
5008 matches!(
5009 classify(&args, &v12, "macos"),
5010 Classification::Governed { ref tuple, .. } if tuple == expected_tuple
5011 ),
5012 "v12 must govern {expected_tuple}"
5013 );
5014 assert!(
5015 matches!(
5016 classify(&args, &v11, "macos"),
5017 Classification::Admin { ref tuple } if tuple == expected_tuple
5018 ),
5019 "v11 must keep {expected_tuple} on the admin tier"
5020 );
5021 }
5022
5023 let missing_reason = os_args(&["issue", "close", "42", "--repo", "cortexkit/aft"]);
5024 let Classification::Governed { tuple, canonical } =
5025 classify(&missing_reason, &v12, "macos")
5026 else {
5027 panic!("missing --reason is still the governed issue close tuple");
5028 };
5029 let missing =
5030 canonicalize_governed(&missing_reason, &tuple, &canonical, v12.manifest_version)
5031 .expect_err("issue close without --reason must refuse");
5032 assert_eq!(missing.code, RefusalCode::MissingReason);
5033 assert_eq!(missing.code.as_str(), "gh_shim_missing_reason");
5034 assert!(
5035 missing.contains("--reason"),
5036 "missing-reason refusal must name --reason: {missing}"
5037 );
5038 assert_eq!(
5039 refuse_governed_canonicalization(&missing),
5040 REFUSAL_EXIT_STATUS
5041 );
5042
5043 for flag in ["--delete-branch", "-d"] {
5044 let args = os_args(&["pr", "close", "7", flag, "--repo", "cortexkit/aft"]);
5045 let Classification::Governed { tuple, canonical } = classify(&args, &v12, "macos")
5046 else {
5047 panic!("pr close with {flag} must still classify as governed");
5048 };
5049 let error = canonicalize_governed(&args, &tuple, &canonical, v12.manifest_version)
5050 .expect_err("pr close {flag} must refuse before routing");
5051 assert_eq!(error.code, RefusalCode::DestructiveFlag);
5052 assert_eq!(error.code.as_str(), "gh_shim_destructive_flag");
5053 assert!(
5054 error.contains(flag),
5055 "destructive refusal must name {flag}: {error}"
5056 );
5057 assert!(
5058 error.contains("branch deletion stays undeclared"),
5059 "destructive refusal must say branch deletion stays undeclared: {error}"
5060 );
5061 assert_eq!(
5062 refuse_governed_canonicalization(&error),
5063 REFUSAL_EXIT_STATUS
5064 );
5065 }
5066
5067 let reason_on_reopen = os_args(&[
5068 "issue",
5069 "reopen",
5070 "42",
5071 "--reason",
5072 "completed",
5073 "--repo",
5074 "cortexkit/aft",
5075 ]);
5076 let Classification::Governed { tuple, canonical } =
5077 classify(&reason_on_reopen, &v12, "macos")
5078 else {
5079 panic!("issue reopen remains governed");
5080 };
5081 let rejected_reason =
5082 canonicalize_governed(&reason_on_reopen, &tuple, &canonical, v12.manifest_version)
5083 .expect_err("--reason is issue close only");
5084 assert_eq!(rejected_reason.code, RefusalCode::Unclassified);
5085 assert!(rejected_reason.contains("--reason"));
5086
5087 let inline_comment = os_args(&[
5088 "issue",
5089 "close",
5090 "42",
5091 "--reason",
5092 "completed",
5093 "--comment",
5094 "Closing as done.",
5095 "--repo",
5096 "cortexkit/aft",
5097 ]);
5098 let Classification::Governed { tuple, canonical } =
5099 classify(&inline_comment, &v12, "macos")
5100 else {
5101 panic!("issue close with --comment must be governed");
5102 };
5103 let commented =
5104 canonicalize_governed(&inline_comment, &tuple, &canonical, v12.manifest_version)
5105 .expect("--comment should canonicalize");
5106 assert_eq!(commented.body["comment"], "Closing as done.");
5107 let commented_wire = governed_wire_request(&determination.record, "alfonso-aft", commented);
5108 assert_eq!(commented_wire["comment"], "Closing as done.");
5109
5110 let short_comment = os_args(&[
5111 "pr",
5112 "close",
5113 "7",
5114 "-c",
5115 "Closing the pull request.",
5116 "--repo",
5117 "cortexkit/aft",
5118 ]);
5119 let Classification::Governed { tuple, canonical } = classify(&short_comment, &v12, "macos")
5120 else {
5121 panic!("pr close with -c must be governed");
5122 };
5123 let short = canonicalize_governed(&short_comment, &tuple, &canonical, v12.manifest_version)
5124 .expect("-c should canonicalize");
5125 assert_eq!(short.body["comment"], "Closing the pull request.");
5126
5127 let file_comment = os_args(&[
5128 "issue",
5129 "reopen",
5130 "42",
5131 "--comment-file",
5132 body_file.to_str().expect("utf-8 body file path"),
5133 "--repo",
5134 "cortexkit/aft",
5135 ]);
5136 let Classification::Governed { tuple, canonical } = classify(&file_comment, &v12, "macos")
5137 else {
5138 panic!("issue reopen with --comment-file must be governed");
5139 };
5140 let from_file =
5141 canonicalize_governed(&file_comment, &tuple, &canonical, v12.manifest_version)
5142 .expect("--comment-file should reuse body-file plumbing");
5143 assert_eq!(from_file.body["comment"], expected_comment);
5144
5145 let mut stdin = std::io::Cursor::new("comment supplied through stdin");
5146 assert_eq!(
5147 read_body_file_from(Path::new("-"), &mut stdin).unwrap(),
5148 "comment supplied through stdin"
5149 );
5150 }
5151
5152 #[test]
5153 fn thread_state_holder_applied_and_partial_comment_outcomes_render_returned_state() {
5154 let applied_close = json!({
5155 "outcome": "applied",
5156 "state": "closed",
5157 "state_reason": "not_planned"
5158 });
5159 let applied = parse_governed_response(&serde_json::to_vec(&applied_close).unwrap())
5160 .expect("applied close should parse");
5161 let RouteOutcome::Result(text) = applied else {
5162 panic!("applied close must be a successful result, got {applied:?}");
5163 };
5164 assert!(
5165 text.contains("not_planned"),
5166 "returned state_reason must be printed: {text:?}"
5167 );
5168 assert!(
5169 !text.contains("completed"),
5170 "request reason must not be echoed: {text:?}"
5171 );
5172 assert!(
5173 text.contains("closed"),
5174 "returned state must be printed: {text:?}"
5175 );
5176
5177 let partial = json!({
5178 "outcome": "state_applied_comment_failed",
5179 "state": "closed",
5180 "state_reason": "completed",
5181 "comment_error": {
5182 "code": "rate_limited",
5183 "detail": "secondary rate limit on issue comments"
5184 }
5185 });
5186 let partial_outcome = parse_governed_response(&serde_json::to_vec(&partial).unwrap())
5187 .expect("partial comment failure should parse");
5188 let RouteOutcome::StateAppliedCommentFailed(partial_text) = &partial_outcome else {
5189 panic!("partial must not be a seam refusal or full success, got {partial_outcome:?}");
5190 };
5191 assert!(
5192 partial_text.contains("APPLIED"),
5193 "partial must print an APPLIED line: {partial_text:?}"
5194 );
5195 assert!(
5196 partial_text.contains("rate_limited"),
5197 "partial must print comment_error code: {partial_text:?}"
5198 );
5199 assert!(
5200 partial_text.contains("secondary rate limit on issue comments"),
5201 "partial must print comment_error detail: {partial_text:?}"
5202 );
5203 let directory = tempfile::tempdir().unwrap();
5204 let paths = StatePaths::from_root(directory.path().to_path_buf());
5205 let binding = AgentBinding {
5206 repo: "owner/repo".to_string(),
5207 agent_id: "agent-7".to_string(),
5208 };
5209 assert_eq!(
5210 governed_outcome_status(&paths, &binding, 123, partial_outcome),
5211 UPSTREAM_FAILURE_EXIT_STATUS
5212 );
5213
5214 let applied_reopen = json!({
5215 "outcome": "applied",
5216 "state": "open"
5217 });
5218 let reopen = parse_governed_response(&serde_json::to_vec(&applied_reopen).unwrap())
5219 .expect("applied reopen should parse");
5220 let RouteOutcome::Result(reopen_text) = reopen else {
5221 panic!("applied reopen must be a successful result, got {reopen:?}");
5222 };
5223 assert!(
5224 reopen_text.contains("open"),
5225 "reopen must print returned state: {reopen_text:?}"
5226 );
5227 assert_eq!(
5228 governed_outcome_status(&paths, &binding, 123, RouteOutcome::Result(reopen_text)),
5229 0
5230 );
5231 }
5232
5233 #[test]
5234 fn v10_edit_last_comment_variants_are_exactly_governed_and_author_scoped() {
5235 let manifest = v10_fixture_manifest();
5236 manifest.validate().expect("valid v10 manifest");
5237
5238 for (verb, number) in [("issue", "42"), ("pr", "7")] {
5239 let args = [
5240 OsString::from(verb),
5241 OsString::from("comment"),
5242 OsString::from(number),
5243 OsString::from("--body"),
5244 OsString::from("replace the draft"),
5245 OsString::from("--edit-last"),
5246 ];
5247 let Classification::Governed { tuple, canonical } = classify(&args, &manifest, "macos")
5248 else {
5249 panic!("native edit-last should use the governed comment tuple: {args:?}");
5250 };
5251 assert_eq!(tuple, format!("{verb} comment"));
5252
5253 let request =
5254 canonicalize_governed(&args, &tuple, &canonical, manifest.manifest_version)
5255 .expect("reviewed edit-last form should canonicalize");
5256 assert!(request.edit_last);
5257 assert_eq!(request.target["number"], number);
5258 assert_eq!(request.body["body"], "replace the draft");
5259
5260 let wire = governed_wire_request(
5261 &(RungDetermination::r3(1, manifest.manifest_version, &test_rung_provenance())
5262 .record),
5263 "alfonso-aft",
5264 request,
5265 );
5266 assert_eq!(wire["edit_last"], true);
5267 }
5268
5269 let bare_create = [
5270 OsString::from("issue"),
5271 OsString::from("comment"),
5272 OsString::from("42"),
5273 OsString::from("--body"),
5274 OsString::from("new comment"),
5275 ];
5276 let Classification::Governed { tuple, canonical } =
5277 classify(&bare_create, &manifest, "macos")
5278 else {
5279 panic!("bare comment creation must remain governed");
5280 };
5281 let request =
5282 canonicalize_governed(&bare_create, &tuple, &canonical, manifest.manifest_version)
5283 .expect("bare comment creation should remain canonicalizable");
5284 assert!(!request.edit_last);
5285 let wire = governed_wire_request(
5286 &(RungDetermination::r3(1, manifest.manifest_version, &test_rung_provenance()).record),
5287 "alfonso-aft",
5288 request,
5289 );
5290 assert!(wire.get("edit_last").is_none());
5291
5292 let mut v9_manifest = manifest.clone();
5296 v9_manifest.manifest_version = 9;
5297 let v9_edit = [
5298 OsString::from("pr"),
5299 OsString::from("comment"),
5300 OsString::from("7"),
5301 OsString::from("--body"),
5302 OsString::from("replace the draft"),
5303 OsString::from("--edit-last"),
5304 ];
5305 assert!(matches!(
5306 classify(&v9_edit, &v9_manifest, "macos"),
5307 Classification::Unclassified
5308 ));
5309
5310 let delete_last = [
5314 OsString::from("pr"),
5315 OsString::from("comment"),
5316 OsString::from("7"),
5317 OsString::from("--body"),
5318 OsString::from("replace the draft"),
5319 OsString::from("--delete-last"),
5320 ];
5321 assert!(matches!(
5322 classify(&delete_last, &manifest, "macos"),
5323 Classification::Unclassified
5324 ));
5325
5326 let reaction_edit = [
5330 OsString::from("issue"),
5331 OsString::from("reaction"),
5332 OsString::from("42"),
5333 OsString::from("--reaction"),
5334 OsString::from("+1"),
5335 OsString::from("--edit-last"),
5336 ];
5337 assert!(matches!(
5338 classify(&reaction_edit, &manifest, "macos"),
5339 Classification::Unclassified
5340 ));
5341 }
5342
5343 #[test]
5344 fn producer_edit_last_vectors_pin_consumer_wire_request_and_refusals() {
5345 const EXPECTED_SHA256: &str =
5346 "cd22bb4de80b5c44b500d75220f03d3b0908f0e67101842de0c29c86b1e9b9e0";
5347 let fixture_bytes = include_bytes!("../tests/fixtures/gh_shim/edit-last-vectors-v1.json");
5348 assert_eq!(
5349 format!("{:x}", Sha256::digest(fixture_bytes)),
5350 EXPECTED_SHA256,
5351 "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"
5352 );
5353
5354 let vectors = edit_last_vectors_fixture();
5355 let vector_case = |name: &str| {
5356 vectors["cases"]
5357 .as_array()
5358 .expect("producer vector cases")
5359 .iter()
5360 .find(|case| case["name"] == name)
5361 .unwrap_or_else(|| panic!("producer vector case {name} is missing"))
5362 };
5363 let happy_request = vector_case("edit_last_happy")["request"].clone();
5364 let happy_body_fields = happy_request["body"]
5365 .as_object()
5366 .expect("producer happy request body")
5367 .keys()
5368 .cloned()
5369 .collect::<Vec<_>>();
5370 assert!(vector_case("absent_edit_last_create")["request"]
5371 .get("edit_last")
5372 .is_none());
5373
5374 let manifest = v10_fixture_manifest();
5375 let args = [
5376 OsString::from("pr"),
5377 OsString::from("comment"),
5378 OsString::from("372"),
5379 OsString::from("--edit-last"),
5380 OsString::from("--body-file"),
5381 OsString::from("-"),
5382 ];
5383 let Classification::Governed { tuple, canonical } = classify(&args, &manifest, "macos")
5384 else {
5385 panic!("the native edit-last command must remain governed");
5386 };
5387 assert_eq!(tuple, "pr comment");
5388 let request = canonicalize_governed(&args, &tuple, &canonical, manifest.manifest_version)
5389 .expect("native edit-last command should canonicalize");
5390 let determination =
5391 RungDetermination::r3(1, manifest.manifest_version, &test_rung_provenance());
5392 let wire = governed_wire_request(&determination.record, "consumer-agent", request);
5393
5394 let mut expected = happy_request;
5397 expected["action"] = json!("pr comment");
5398 expected["target"] = json!({"number": "372"});
5399 expected["body"] = wire["body"].clone();
5400 expected["manifest_version"] = json!(manifest.manifest_version);
5401 expected["rung_as_of_unix_secs"] = json!(determination.record.as_of_unix_secs);
5402 expected["metadata"]["pid"] = json!(std::process::id());
5403 expected["metadata"]
5404 .as_object_mut()
5405 .expect("expected metadata object")
5406 .remove("agent_id");
5407 let mut actual = wire;
5408 let actual_repository = actual["repository"]
5414 .as_str()
5415 .expect("wire request must carry a repository string");
5416 let (owner, name) = actual_repository
5417 .split_once('/')
5418 .expect("repository must be owner/name");
5419 assert!(
5420 !owner.is_empty() && !name.is_empty() && !name.contains('/'),
5421 "repository must be a single owner/name pair: {actual_repository}"
5422 );
5423 expected["repository"] = actual["repository"].clone();
5424 actual["metadata"]
5425 .as_object_mut()
5426 .expect("actual metadata object")
5427 .remove("agent_id");
5428 assert_eq!(
5429 actual["body"]
5430 .as_object()
5431 .expect("actual request body")
5432 .keys()
5433 .cloned()
5434 .collect::<Vec<_>>(),
5435 happy_body_fields,
5436 "consumer body fields drifted from producer shape"
5437 );
5438 assert_eq!(
5439 actual, expected,
5440 "consumer request drifted from producer shape"
5441 );
5442 assert_eq!(
5443 actual["edit_last"], true,
5444 "edit_last marker must be present"
5445 );
5446
5447 for case_name in ["edit_last_no_own_comment", "edit_last_unsupported_action"] {
5448 let code = vector_case(case_name)["response"]["refusal_code"]
5449 .as_str()
5450 .expect("producer refusal code");
5451 let response = json!({"outcome": "refusal", "refusal_code": code});
5452 let outcome = parse_governed_response(&serde_json::to_vec(&response).unwrap())
5453 .expect("producer refusal should parse");
5454 assert!(matches!(outcome, RouteOutcome::Refusal(ref actual) if actual == code));
5455 assert_eq!(
5456 RefusalCode::SeamRefusal.as_str(),
5457 "gh_shim_seam_refusal",
5458 "open-world holder refusal codes must use the seam refusal classification"
5459 );
5460 assert_eq!(
5461 seam_refusal_text(code),
5462 format!("governance seam refused the action: {code}"),
5463 "holder refusal code must pass through without remapping"
5464 );
5465 }
5466 }
5467
5468 #[test]
5474 fn signed_v10_reasoning_prose_survives_parse_and_cache_round_trip() {
5475 let signed_row = r#"{
5477 "tuple": "workflow run",
5478 "platform": ["macos", "linux"],
5479 "reasoning": "Administration: dispatching a workflow runs code but carries no public attribution surface; operator identity under explicit bypass."
5480 }"#;
5481 let parsed: TupleDecl = serde_json::from_str(signed_row).expect("signed row parses");
5482 let TupleDecl::Details { rationale, .. } = &parsed else {
5483 panic!("signed row must parse as a detailed declaration");
5484 };
5485 let prose = rationale
5486 .as_deref()
5487 .expect("signed `reasoning` prose must survive the parse, not default to None");
5488 assert!(
5489 prose.starts_with("Administration:"),
5490 "prose intact: {prose}"
5491 );
5492
5493 let cache_bytes = serde_json::to_string(&parsed).expect("cache serialization");
5497 let reparsed: TupleDecl = serde_json::from_str(&cache_bytes).expect("cache view reparses");
5498 let TupleDecl::Details {
5499 rationale: cached, ..
5500 } = &reparsed
5501 else {
5502 panic!("cache view must stay a detailed declaration");
5503 };
5504 assert_eq!(
5505 cached.as_deref(),
5506 Some(prose),
5507 "prose must survive the parse->serialize->parse cache round trip verbatim"
5508 );
5509 }
5510
5511 #[test]
5512 fn manifest_rejects_duplicate_tiers_and_empty_api_rationales() {
5513 let mut duplicate = fixture_manifest();
5514 duplicate
5515 .tiers
5516 .get_mut(&Tier::Admin)
5517 .unwrap()
5518 .push(TupleDecl::Details {
5519 tuple: "issue comment".to_string(),
5520 platform: vec!["macos".to_string()],
5521 api_match: None,
5522 rationale: None,
5523 });
5524 assert!(duplicate.validate().unwrap_err().contains("both"));
5525
5526 let mut empty_api = fixture_manifest();
5527 empty_api
5528 .tiers
5529 .get_mut(&Tier::Admin)
5530 .unwrap()
5531 .push(TupleDecl::Details {
5532 tuple: "api patch close".to_string(),
5533 platform: vec!["macos".to_string()],
5534 api_match: Some(String::new()),
5535 rationale: None,
5536 });
5537 assert!(empty_api.validate().unwrap_err().contains("rationale"));
5538
5539 let mut malformed_binding = fixture_manifest();
5540 malformed_binding.bindings.insert(
5541 "https://github.com/cortexkit/aft.git".to_string(),
5542 "alfonso-aft".to_string(),
5543 );
5544 assert!(malformed_binding
5545 .validate()
5546 .unwrap_err()
5547 .contains("canonical owner/name"));
5548 }
5549
5550 #[test]
5551 fn manifest_rejects_api_rules_for_unknown_host_platforms() {
5552 let mut manifest = branch_protection_manifest("PUT", Tier::Admin);
5553 manifest
5554 .api_rules
5555 .last_mut()
5556 .expect("branch protection API rule")
5557 .platform = vec!["github".to_string()];
5558 assert_eq!(
5559 manifest.validate().unwrap_err(),
5560 "api rule PUT /repos/*/*/branches/*/protection names unknown host platform github"
5561 );
5562 }
5563
5564 #[test]
5565 fn binding_keys_and_governed_session_identity_are_stable() {
5566 assert_eq!(
5567 canonical_repository_key("https://github.com/CortexKit/aft.git"),
5568 Some("cortexkit/aft".to_string())
5569 );
5570 assert_eq!(
5571 canonical_repository_key("git@github.com:cortexkit/aft.git"),
5572 Some("cortexkit/aft".to_string())
5573 );
5574 assert_eq!(gh_session_id("alfonso-aft"), "gh-shim:alfonso-aft");
5575
5576 let request = GovernedRequest {
5577 action: "issue comment".to_string(),
5578 target: Map::new(),
5579 body: Map::new(),
5580 repository: Some("cortexkit/aft".to_string()),
5581 manifest_version: 1,
5582 edit_last: false,
5583 };
5584 let determination = RungDetermination::r3(7, 1, &test_rung_provenance());
5585 let wire = governed_wire_request(&determination.record, "alfonso-aft", request);
5586 assert_eq!(wire["metadata"]["agent_id"], "alfonso-aft");
5587 assert_eq!(wire["metadata"]["pid"], std::process::id());
5588 }
5589
5590 #[test]
5591 fn manifest_rejects_repo_sections_that_add_or_lower_a_tuple() {
5592 let mut manifest = fixture_manifest();
5593 manifest.repository_sections.insert(
5594 "owner/repo".to_string(),
5595 RepositorySection {
5596 tiers: BTreeMap::from([(
5597 Tier::Mechanical,
5598 vec![TupleDecl::Details {
5599 tuple: "issue comment".to_string(),
5600 platform: vec!["macos".to_string()],
5601 api_match: None,
5602 rationale: None,
5603 }],
5604 )]),
5605 removed_tuples: Vec::new(),
5606 },
5607 );
5608 assert!(manifest.validate().unwrap_err().contains("lowers"));
5609
5610 manifest.repository_sections.insert(
5611 "owner/repo".to_string(),
5612 RepositorySection {
5613 tiers: BTreeMap::from([(
5614 Tier::Admin,
5615 vec![TupleDecl::Details {
5616 tuple: "workflow dispatch".to_string(),
5617 platform: vec!["macos".to_string()],
5618 api_match: None,
5619 rationale: None,
5620 }],
5621 )]),
5622 removed_tuples: Vec::new(),
5623 },
5624 );
5625 assert!(manifest.validate().unwrap_err().contains("adds"));
5626 }
5627
5628 #[cfg(debug_assertions)] #[test]
5630 fn signed_cache_rejects_tampering_and_old_schema_floor() {
5631 let directory = tempfile::tempdir().unwrap();
5632 let paths = StatePaths::from_root(directory.path().to_path_buf());
5633 let now = TEST_NOW;
5634 write_signed_manifest(&paths, fixture_manifest(), now);
5635 assert_eq!(load_manifest(&paths, now).unwrap().manifest_version, 1);
5636
5637 let mut value: Value = serde_json::from_slice(&fs::read(&paths.manifest).unwrap()).unwrap();
5640 let tampered =
5641 value["manifest_bytes"]
5642 .as_str()
5643 .unwrap()
5644 .replacen("issue view", "issue View", 1);
5645 value["manifest_bytes"] = Value::String(tampered);
5646 fs::write(&paths.manifest, serde_json::to_vec(&value).unwrap()).unwrap();
5647 assert!(matches!(
5648 load_manifest(&paths, now),
5649 Err(ManifestProblem::Invalid(_))
5650 ));
5651 assert_eq!(
5654 cached_manifest_report_at(&paths, now).diagnostics,
5655 vec![
5656 SelfReportDiagnostic::ManifestRegressed.as_str(),
5657 SelfReportDiagnostic::ManifestInvalid.as_str(),
5658 ]
5659 );
5660
5661 let mut below_floor = fixture_manifest();
5662 below_floor.schema_floor = 0;
5663 write_signed_manifest(&paths, below_floor, now);
5664 assert!(matches!(
5665 load_manifest(&paths, now),
5666 Err(ManifestProblem::BelowFloor { manifest_floor: 0 })
5667 ));
5668 }
5669
5670 #[test]
5671 fn no_verb_and_help_invocations_are_mechanical_on_a_governed_manifest() {
5672 let manifest = fixture_manifest();
5673 for args in [
5674 Vec::new(),
5675 vec![OsString::from("--version")],
5676 vec![OsString::from("--help")],
5677 vec![OsString::from("-h")],
5678 vec![OsString::from("help"), OsString::from("pr")],
5679 ] {
5680 assert!(
5681 matches!(
5682 classify(&args, &manifest, "macos"),
5683 Classification::Mechanical
5684 ),
5685 "expected passthrough classification for {args:?}"
5686 );
5687 }
5688 }
5689
5690 #[test]
5691 fn unmapped_get_and_actions_reads_are_mechanical_but_writes_remain_unclassified() {
5692 let mut manifest = fixture_manifest();
5693 manifest.api_rules.clear();
5694
5695 for args in [
5696 vec![
5697 OsString::from("api"),
5698 OsString::from("/repos/cortexkit/aft/actions/runs"),
5699 ],
5700 vec![
5701 OsString::from("api"),
5702 OsString::from("--method"),
5703 OsString::from("GET"),
5704 OsString::from("/repos/cortexkit/aft/actions/runs"),
5705 ],
5706 vec![
5707 OsString::from("api"),
5708 OsString::from("-X"),
5709 OsString::from("GET"),
5710 OsString::from("/repos/cortexkit/aft/actions/runs"),
5711 ],
5712 vec![OsString::from("run"), OsString::from("view")],
5713 vec![OsString::from("run"), OsString::from("list")],
5714 vec![OsString::from("run"), OsString::from("watch")],
5715 vec![OsString::from("workflow"), OsString::from("view")],
5716 vec![OsString::from("workflow"), OsString::from("list")],
5717 ] {
5718 assert!(
5719 matches!(
5720 classify(&args, &manifest, "macos"),
5721 Classification::Mechanical
5722 ),
5723 "expected read passthrough classification for {args:?}"
5724 );
5725 }
5726
5727 for args in [
5728 vec![
5729 OsString::from("api"),
5730 OsString::from("-X"),
5731 OsString::from("POST"),
5732 OsString::from("/repos/cortexkit/aft/actions/runs"),
5733 ],
5734 vec![
5735 OsString::from("api"),
5736 OsString::from("-f"),
5737 OsString::from("key=value"),
5738 OsString::from("/repos/cortexkit/aft/actions/runs"),
5739 ],
5740 ] {
5741 assert!(
5742 matches!(
5743 classify(&args, &manifest, "macos"),
5744 Classification::Unclassified
5745 ),
5746 "expected fail-closed classification for {args:?}"
5747 );
5748 }
5749 }
5750
5751 #[test]
5752 fn classification_is_allowlist_driven_without_a_write_heuristic() {
5753 let manifest = fixture_manifest();
5754 assert!(matches!(
5755 classify(
5756 &[OsString::from("issue"), OsString::from("view")],
5757 &manifest,
5758 "macos"
5759 ),
5760 Classification::Mechanical
5761 ));
5762 assert!(matches!(
5763 classify(
5764 &[OsString::from("api"), OsString::from("/repos/a/b")],
5765 &manifest,
5766 "macos"
5767 ),
5768 Classification::Mechanical
5769 ));
5770 assert!(matches!(
5771 classify(
5772 &[
5773 OsString::from("api"),
5774 OsString::from("--method=POST"),
5775 OsString::from("/repos/a/b")
5776 ],
5777 &manifest,
5778 "macos"
5779 ),
5780 Classification::Unclassified
5781 ));
5782 assert!(matches!(
5783 classify(
5784 &[
5785 OsString::from("api"),
5786 OsString::from("--method"),
5787 OsString::from("POST"),
5788 OsString::from("/repos/a/b")
5789 ],
5790 &manifest,
5791 "macos"
5792 ),
5793 Classification::Unclassified
5794 ));
5795 assert!(matches!(
5796 classify(
5797 &[OsString::from("alias"), OsString::from("set")],
5798 &manifest,
5799 "macos"
5800 ),
5801 Classification::Unclassified
5802 ));
5803 assert!(matches!(
5804 classify(
5805 &[
5806 OsString::from("alias"),
5807 OsString::from("set"),
5808 OsString::from("--write")
5809 ],
5810 &manifest,
5811 "macos"
5812 ),
5813 Classification::Unclassified
5814 ));
5815 }
5816
5817 #[test]
5818 fn canonical_repository_key_parses_github_remotes_and_rejects_foreign_hosts() {
5819 for remote in [
5820 "https://github.com/CortexKit/Aft",
5821 "https://github.com/cortexkit/aft.git",
5822 "https://github.com/cortexkit/aft/",
5823 "https://github.com/cortexkit/aft.git/",
5824 "git@github.com:cortexkit/aft.git",
5825 "ssh://git@github.com/cortexkit/aft",
5826 "cortexkit/aft",
5827 ] {
5828 assert_eq!(
5829 canonical_repository_key(remote).as_deref(),
5830 Some("cortexkit/aft")
5831 );
5832 }
5833 for remote in [
5834 "https://gitlab.com/cortexkit/aft.git",
5835 "ssh://git@gitlab.com/cortexkit/aft",
5836 "git@gitlab.com:cortexkit/aft.git",
5837 ] {
5838 assert_eq!(canonical_repository_key(remote), None);
5839 }
5840 }
5841
5842 #[test]
5843 fn invalid_repository_argument_refuses_before_seam_routing() {
5844 let manifest = fixture_manifest();
5845 let canonical = manifest.canonicalization["issue comment"].clone();
5846 let error = canonicalize_governed(
5847 &[
5848 OsString::from("--repo"),
5849 OsString::from("not/an/owner-name"),
5850 OsString::from("issue"),
5851 OsString::from("comment"),
5852 OsString::from("42"),
5853 OsString::from("--body"),
5854 OsString::from("hello"),
5855 ],
5856 "issue comment",
5857 &canonical,
5858 1,
5859 )
5860 .expect_err("an unparseable repository must abort before seam routing");
5861 assert_eq!(error, "repository not/an/owner-name is not owner/name");
5862 assert_eq!(
5863 refuse_governed_canonicalization(&error),
5864 REFUSAL_EXIT_STATUS,
5865 "a pre-routing governance refusal must have a nonzero exit status"
5866 );
5867 }
5868
5869 #[test]
5870 fn governed_canonicalization_normalizes_flags_and_explicit_repo_wins() {
5871 let manifest = fixture_manifest();
5872 let canonical = manifest.canonicalization["issue comment"].clone();
5873 let request = canonicalize_governed(
5874 &[
5875 OsString::from("--repo=owner/explicit"),
5876 OsString::from("issue"),
5877 OsString::from("comment"),
5878 OsString::from("42"),
5879 OsString::from("--body"),
5880 OsString::from("hello"),
5881 ],
5882 "issue comment",
5883 &canonical,
5884 1,
5885 )
5886 .unwrap();
5887 assert_eq!(request.repository.as_deref(), Some("owner/explicit"));
5888 assert_eq!(request.target["number"], "42");
5889 assert_eq!(request.body["body"], "hello");
5890 }
5891
5892 #[test]
5893 fn speech_body_file_forms_are_allowed_and_forward_fixture_contents() {
5894 let manifest = fixture_manifest();
5895 let body_file = fixture_dir().join("governed-speech.md");
5896 let expected_body = fs::read_to_string(&body_file).expect("speech body fixture");
5897
5898 for (expected_tuple, verb, subcommand, target) in [
5899 ("issue comment", "issue", "comment", "42"),
5900 ("pr comment", "pr", "comment", "7"),
5901 ("pr review", "pr", "review", "7"),
5902 ] {
5903 let canonical = manifest.canonicalization[expected_tuple].clone();
5904 for (flag, suffix) in [("--body-file", ""), ("-F", "")]
5905 .into_iter()
5906 .chain([("--body-file=", "equals"), ("-F=", "equals")])
5907 {
5908 let file_arg = if suffix.is_empty() {
5909 body_file.to_string_lossy().into_owned()
5910 } else {
5911 format!("{flag}{}", body_file.display())
5912 };
5913 let args = if suffix.is_empty() {
5914 vec![
5915 OsString::from(verb),
5916 OsString::from(subcommand),
5917 OsString::from(target),
5918 OsString::from(flag),
5919 OsString::from(file_arg),
5920 ]
5921 } else {
5922 vec![
5923 OsString::from(verb),
5924 OsString::from(subcommand),
5925 OsString::from(target),
5926 OsString::from(file_arg),
5927 ]
5928 };
5929 assert!(matches!(
5930 classify(&args, &manifest, "macos"),
5931 Classification::Governed { ref tuple, .. } if tuple == expected_tuple
5932 ));
5933 let request = canonicalize_governed(&args, expected_tuple, &canonical, 1)
5934 .expect("body-file form should canonicalize");
5935 let determination = RungDetermination::r3(1, 1, &test_rung_provenance());
5936 let wire = governed_wire_request(&determination.record, "agent-7", request);
5937 assert_eq!(wire["body"]["body"], expected_body);
5938 }
5939 }
5940
5941 let reaction = manifest.canonicalization["issue reaction"].clone();
5942 let error = canonicalize_governed(
5943 &[
5944 OsString::from("issue"),
5945 OsString::from("reaction"),
5946 OsString::from("42"),
5947 OsString::from("--body-file"),
5948 OsString::from(body_file),
5949 ],
5950 "issue reaction",
5951 &reaction,
5952 1,
5953 )
5954 .expect_err("body-file is speech-only vocabulary");
5955 assert_eq!(error, "undeclared flag --body-file");
5956 }
5957
5958 #[test]
5959 fn body_file_failures_refuse_instead_of_forwarding_an_empty_body() {
5960 let manifest = fixture_manifest();
5961 let canonical = manifest.canonicalization["pr comment"].clone();
5962 let directory = tempfile::tempdir().unwrap();
5963 let missing = directory.path().join("missing.md");
5964 let invalid = directory.path().join("invalid-utf8.md");
5965 fs::write(&invalid, [0xff, 0xfe]).unwrap();
5966
5967 for path in [missing, invalid] {
5968 let error = canonicalize_governed(
5969 &[
5970 OsString::from("pr"),
5971 OsString::from("comment"),
5972 OsString::from("7"),
5973 OsString::from("--body-file"),
5974 OsString::from(&path),
5975 ],
5976 "pr comment",
5977 &canonical,
5978 1,
5979 )
5980 .expect_err("an unreadable body file must refuse");
5981 assert!(error.starts_with("--body-file: could not read body file "));
5982 assert!(error.contains(&path.display().to_string()));
5983 assert_eq!(
5984 refuse_governed_canonicalization(&error),
5985 REFUSAL_EXIT_STATUS
5986 );
5987 }
5988 }
5989
5990 #[test]
5991 fn body_file_dash_reads_stdin_under_the_caller_permissions() {
5992 let mut stdin = std::io::Cursor::new("body supplied through stdin");
5993 assert_eq!(
5994 read_body_file_from(Path::new("-"), &mut stdin).unwrap(),
5995 "body supplied through stdin"
5996 );
5997 }
5998
5999 #[test]
6000 fn pr_review_action_and_body_matrix_reaches_the_governed_payload() {
6001 let manifest = fixture_manifest();
6002 let canonical = manifest.canonicalization["pr review"].clone();
6003 let body_file = fixture_dir().join("governed-speech.md");
6004 let expected_body = fs::read_to_string(&body_file).expect("speech body fixture");
6005
6006 for (action_flag, event) in [
6007 ("--approve", "APPROVE"),
6008 ("--comment", "COMMENT"),
6009 ("--request-changes", "REQUEST_CHANGES"),
6010 ] {
6011 for (body_flag, body_value) in [("--body", "inline review"), ("-b", "short review")] {
6012 let args = vec![
6013 OsString::from("pr"),
6014 OsString::from("review"),
6015 OsString::from("7"),
6016 OsString::from(action_flag),
6017 OsString::from(body_flag),
6018 OsString::from(body_value),
6019 ];
6020 assert!(matches!(
6021 classify(&args, &manifest, "macos"),
6022 Classification::Governed { ref tuple, .. } if tuple == "pr review"
6023 ));
6024 let request = canonicalize_governed(&args, "pr review", &canonical, 1)
6025 .expect("review action with inline body should canonicalize");
6026 assert_eq!(request.body["event"], event);
6027 assert_eq!(request.body["body"], body_value);
6028 }
6029
6030 let args = vec![
6031 OsString::from("pr"),
6032 OsString::from("review"),
6033 OsString::from("7"),
6034 OsString::from(action_flag),
6035 OsString::from("--body-file"),
6036 OsString::from(&body_file),
6037 ];
6038 let request = canonicalize_governed(&args, "pr review", &canonical, 1)
6039 .expect("review action with body-file should canonicalize");
6040 assert_eq!(request.body["event"], event);
6041 assert_eq!(request.body["body"], expected_body);
6042 }
6043
6044 for action_flag in ["--approve", "--request-changes"] {
6045 let args = vec![
6046 OsString::from("pr"),
6047 OsString::from("review"),
6048 OsString::from("7"),
6049 OsString::from(action_flag),
6050 ];
6051 let request = canonicalize_governed(&args, "pr review", &canonical, 1)
6052 .expect("approve/request-changes may omit review prose");
6053 assert_eq!(
6054 request.body["event"],
6055 action_flag
6056 .trim_start_matches("--")
6057 .to_ascii_uppercase()
6058 .replace('-', "_")
6059 );
6060 assert!(!request.body.contains_key("body"));
6061 }
6062
6063 let duplicate = [
6064 OsString::from("pr"),
6065 OsString::from("review"),
6066 OsString::from("7"),
6067 OsString::from("--approve"),
6068 OsString::from("--comment"),
6069 OsString::from("--body"),
6070 OsString::from("review"),
6071 ];
6072 assert_eq!(
6073 canonicalize_governed(&duplicate, "pr review", &canonical, 1).unwrap_err(),
6074 "pr review accepts only one of --approve, --comment, or --request-changes"
6075 );
6076 }
6077
6078 #[test]
6079 fn upstream_api_errors_fail_without_changing_success_status() {
6080 let error_response = json!({
6081 "outcome": "result",
6082 "gh_route_schema": 1,
6083 "result": {
6084 "status": 404,
6085 "error": {"message": "Not Found", "documentation_url": "https://docs.github.com"}
6086 }
6087 });
6088 let error_outcome =
6089 parse_governed_response(&serde_json::to_vec(&error_response).unwrap()).unwrap();
6090 let error_body = match error_outcome {
6091 RouteOutcome::UpstreamError(body) => body,
6092 other => panic!("expected upstream error, got {other:?}"),
6093 };
6094 assert!(error_body.contains("Not Found"));
6095 let directory = tempfile::tempdir().unwrap();
6096 let paths = StatePaths::from_root(directory.path().to_path_buf());
6097 let binding = AgentBinding {
6098 repo: "owner/repo".to_string(),
6099 agent_id: "agent-7".to_string(),
6100 };
6101 assert_eq!(
6102 governed_outcome_status(
6103 &paths,
6104 &binding,
6105 123,
6106 RouteOutcome::UpstreamError(error_body)
6107 ),
6108 UPSTREAM_FAILURE_EXIT_STATUS
6109 );
6110
6111 let success_response = json!({
6112 "outcome": "result",
6113 "gh_route_schema": 1,
6114 "result": {"status": 201, "url": "https://github.com/example"},
6115 "field_order": ["status", "url"]
6116 });
6117 let success_outcome =
6118 parse_governed_response(&serde_json::to_vec(&success_response).unwrap()).unwrap();
6119 assert!(matches!(&success_outcome, RouteOutcome::Result(_)));
6120 assert_eq!(
6121 governed_outcome_status(&paths, &binding, 123, success_outcome),
6122 0
6123 );
6124 }
6125
6126 #[test]
6127 fn governed_renderer_is_deterministic_for_scalars_arrays_and_escapes() {
6128 let result = json!({"message":"snowman ☃\n", "items":["a", 2], "ok":true});
6129 let order = vec![json!("ok"), json!("message"), json!("items")];
6130 assert_eq!(
6131 render_governed_response(&result, &order).unwrap(),
6132 "ok: true\nmessage: \"snowman ☃\\n\"\nitems:\n \"a\"\n 2\n"
6133 );
6134 assert!(matches!(
6135 render_governed_response(&json!("scalar"), &order),
6136 Err(RouteOutcome::SchemaMismatch(_))
6137 ));
6138 }
6139
6140 #[test]
6141 fn lower_rungs_are_cached_durably_but_r1_is_not_written() {
6142 let directory = tempfile::tempdir().unwrap();
6143 let paths = StatePaths::from_root(directory.path().to_path_buf());
6144 let determination = RungDetermination::r2(
6145 123,
6146 R2Reason::DaemonUnreachable,
6147 None,
6148 &test_rung_provenance(),
6149 );
6150 write_rung_record_silently(&paths, &determination.record);
6151 assert_eq!(load_rung_record(&paths).unwrap().rung, Rung::R2);
6152 assert!(!paths.root.join("r1-cache.json").exists());
6153 }
6154
6155 #[test]
6156 fn governed_bound_disposition_is_reason_independent_except_operator_hard_off() {
6157 const EXPECTED_RUNG_SHAPE_COUNT: usize = 11;
6158
6159 let directory = tempfile::tempdir().unwrap();
6160 let paths = StatePaths::from_root(directory.path().to_path_buf());
6161 let connection_file = directory.path().join("connection.json");
6162 fs::write(&connection_file, "present").unwrap();
6163 let missing_connection = directory.path().join("missing-connection.json");
6164 let disabled_doc = serde_json::json!({
6165 "gh_shim": { "enabled": false },
6166 "subc": { "connection_file": missing_connection }
6167 })
6168 .to_string();
6169 let unreachable_doc = serde_json::json!({
6170 "subc": { "connection_file": directory.path().join("still-missing.json") }
6171 })
6172 .to_string();
6173 let budget_doc = serde_json::json!({
6174 "subc": { "connection_file": connection_file }
6175 })
6176 .to_string();
6177 let future_deadline = || std::time::Instant::now() + DISCOVERY_BUDGET;
6178 let r1_cases = [
6179 (
6180 R1Reason::DisabledByConfig,
6181 determine_rung_from_doc(
6182 &paths,
6183 directory.path(),
6184 1,
6185 future_deadline(),
6186 Some(&disabled_doc),
6187 ),
6188 ),
6189 (
6190 R1Reason::AbsentOrUnparseable,
6191 determine_rung_from_doc(&paths, directory.path(), 1, future_deadline(), Some("{}")),
6192 ),
6193 (
6194 R1Reason::Unreachable,
6195 determine_rung_from_doc(
6196 &paths,
6197 directory.path(),
6198 1,
6199 future_deadline(),
6200 Some(&unreachable_doc),
6201 ),
6202 ),
6203 (
6204 R1Reason::DiscoveryBudgetExhausted,
6205 determine_rung_from_doc(
6206 &paths,
6207 directory.path(),
6208 1,
6209 std::time::Instant::now() - Duration::from_millis(1),
6210 Some(&budget_doc),
6211 ),
6212 ),
6213 ];
6214 assert_eq!(r1_cases.len(), R1Reason::ALL.len());
6215 for (reason, determination) in &r1_cases {
6216 assert_eq!(determination.record.rung, Rung::R1);
6217 assert_eq!(
6218 determination
6219 .record
6220 .inputs
6221 .get("connection_file")
6222 .map(String::as_str),
6223 Some(reason.diagnostic())
6224 );
6225 }
6226
6227 let mut determinations = r1_cases
6228 .into_iter()
6229 .map(|(_, determination)| determination)
6230 .collect::<Vec<_>>();
6231 determinations.extend(
6232 R2Reason::ALL
6233 .into_iter()
6234 .map(|reason| RungDetermination::r2(1, reason, Some(1), &test_rung_provenance())),
6235 );
6236 determinations.push(RungDetermination::r3(1, 1, &test_rung_provenance()));
6237 assert_eq!(
6238 R1Reason::ALL.len() + R2Reason::ALL.len() + 1,
6239 EXPECTED_RUNG_SHAPE_COUNT,
6240 "update the explicit disposition matrix when a rung shape is added"
6241 );
6242 assert_eq!(determinations.len(), EXPECTED_RUNG_SHAPE_COUNT);
6243
6244 let manifest = fixture_manifest();
6245 let governed_args = [
6246 OsString::from("issue"),
6247 OsString::from("comment"),
6248 OsString::from("42"),
6249 OsString::from("--body"),
6250 OsString::from("hello"),
6251 ];
6252 let admin_args = [
6253 OsString::from("pr"),
6254 OsString::from("merge"),
6255 OsString::from("42"),
6256 ];
6257 let mechanical_args = [
6258 OsString::from("issue"),
6259 OsString::from("view"),
6260 OsString::from("42"),
6261 ];
6262 let governed = classify(&governed_args, &manifest, "macos");
6263 let admin = classify(&admin_args, &manifest, "macos");
6264 let mechanical = classify(&mechanical_args, &manifest, "macos");
6265 let binding = || AgentBinding {
6266 repo: "cortexkit/aft".to_string(),
6267 agent_id: "alfonso-aft".to_string(),
6268 };
6269
6270 for determination in &determinations {
6271 let bound_governed = structural_governance_disposition(
6272 determination,
6273 &governed,
6274 Some(binding()),
6275 manifest.manifest_version,
6276 );
6277 if determination.operator_disabled {
6278 assert!(matches!(bound_governed, GovernanceDisposition::Delegate));
6279 } else if determination.record.rung == Rung::R3 {
6280 assert!(matches!(bound_governed, GovernanceDisposition::Ready));
6281 } else {
6282 assert!(matches!(
6283 bound_governed,
6284 GovernanceDisposition::Unavailable(_)
6285 ));
6286 }
6287
6288 assert!(matches!(
6289 structural_governance_disposition(
6290 determination,
6291 &governed,
6292 None,
6293 manifest.manifest_version,
6294 ),
6295 GovernanceDisposition::Delegate
6296 ));
6297 assert!(matches!(
6298 structural_governance_disposition(
6299 determination,
6300 &mechanical,
6301 Some(binding()),
6302 manifest.manifest_version,
6303 ),
6304 GovernanceDisposition::Delegate
6305 ));
6306
6307 if determination.record.rung != Rung::R3 && !determination.operator_disabled {
6308 assert!(matches!(
6309 structural_governance_disposition(
6310 determination,
6311 &admin,
6312 Some(binding()),
6313 manifest.manifest_version,
6314 ),
6315 GovernanceDisposition::Unavailable(_)
6316 ));
6317 }
6318 }
6319 }
6320
6321 #[test]
6322 fn ambient_credentials_on_a_bound_governed_invocation_refuse_identity_ambiguity() {
6323 let manifest = fixture_manifest();
6324 let governed = classify(
6325 &[
6326 OsString::from("issue"),
6327 OsString::from("comment"),
6328 OsString::from("42"),
6329 OsString::from("--body"),
6330 OsString::from("hello"),
6331 ],
6332 &manifest,
6333 "macos",
6334 );
6335 let determination = RungDetermination::r2(
6336 1,
6337 R2Reason::AgentCredentialsPresent,
6338 Some(manifest.manifest_version),
6339 &test_rung_provenance(),
6340 );
6341 let binding = AgentBinding {
6342 repo: "cortexkit/aft".to_string(),
6343 agent_id: "alfonso-aft".to_string(),
6344 };
6345
6346 assert!(matches!(
6347 structural_governance_disposition(
6348 &determination,
6349 &governed,
6350 Some(binding),
6351 manifest.manifest_version,
6352 ),
6353 GovernanceDisposition::Unavailable(_)
6354 ));
6355 }
6356
6357 #[cfg(unix)]
6358 #[test]
6359 fn resolved_image_identity_skips_a_shim_reached_through_a_symlinked_parent() {
6360 use std::os::unix::fs::symlink;
6361
6362 let directory = tempfile::tempdir().unwrap();
6363 let image = directory.path().join("aft");
6364 fs::write(&image, b"shim image").unwrap();
6365 let bin = directory.path().join("bin");
6366 fs::create_dir(&bin).unwrap();
6367 symlink(&image, bin.join("gh")).unwrap();
6368 let linked_parent = directory.path().join("linked-bin");
6369 symlink(&bin, &linked_parent).unwrap();
6370
6371 assert!(same_image(&linked_parent.join("gh"), &image));
6372 }
6373
6374 #[test]
6375 fn bypass_audit_is_visible_to_a_later_self_report_reader() {
6376 let directory = tempfile::tempdir().unwrap();
6377 let paths = StatePaths::from_root(directory.path().to_path_buf());
6378 append_bypass_audit(&paths, "issue close", Some("owner/repo"), 99).unwrap();
6379 let (records, error) = read_bypass_audit(&paths);
6380 assert!(error.is_none());
6381 let records = records.unwrap();
6382 assert_eq!(records.len(), 1);
6383 assert_eq!(records[0]["tuple"], "issue close");
6384 }
6385
6386 #[test]
6387 fn refusal_and_self_report_codes_are_separate_closed_sets() {
6388 assert_eq!(RefusalCode::ALL.len(), 13);
6389 assert!(RefusalCode::ALL
6390 .iter()
6391 .all(|code| code.as_str().starts_with("gh_shim_")));
6392 assert_eq!(
6393 RefusalCode::GovernanceUnavailable.as_str(),
6394 "gh_shim_governance_unavailable"
6395 );
6396 assert_eq!(
6397 GOVERNANCE_UNAVAILABLE_TEXT,
6398 "the governance daemon is unreachable and this repository's actions are identity-governed; retry after the daemon returns"
6399 );
6400 assert_eq!(SelfReportDiagnostic::ALL.len(), 6);
6401 assert!(SelfReportDiagnostic::ALL
6402 .iter()
6403 .all(|code| code.as_str().starts_with("gh_shim_status_")));
6404 assert!(SelfReportDiagnostic::ALL
6405 .iter()
6406 .all(|code| !code.as_str().contains("stale")));
6407 assert_eq!(REFUSAL_EXIT_STATUS, 86);
6408 }
6409
6410 #[test]
6411 fn v1_write_classification_accepts_only_the_reviewed_tuple_sets() {
6412 let manifest = fixture_manifest();
6413 for tuple in V1_GOVERNED_TUPLES {
6414 let args = tuple
6415 .split_whitespace()
6416 .map(OsString::from)
6417 .collect::<Vec<_>>();
6418 assert!(matches!(
6419 classify(&args, &manifest, "macos"),
6420 Classification::Governed { .. }
6421 ));
6422 }
6423 for tuple in V1_ADMIN_TUPLES {
6424 let args = tuple
6425 .split_whitespace()
6426 .map(OsString::from)
6427 .collect::<Vec<_>>();
6428 assert!(matches!(
6429 classify(&args, &manifest, "macos"),
6430 Classification::Admin { .. }
6431 ));
6432 }
6433 for args in [
6434 ["release", "publish"].as_slice(),
6435 ["issue", "create"].as_slice(),
6436 ["pr", "reopen"].as_slice(),
6437 ] {
6438 let args = args.iter().map(OsString::from).collect::<Vec<_>>();
6439 assert!(matches!(
6440 classify(&args, &manifest, "macos"),
6441 Classification::Unclassified
6442 ));
6443 }
6444 }
6445
6446 #[test]
6447 fn v13_release_maintenance_rows_allow_reviewed_flags_and_refuse_destructive_forms() {
6448 let mut manifest = branch_protection_manifest("PUT", Tier::Admin);
6454 manifest.api_rules.push(ApiRule {
6455 method: "DELETE".to_string(),
6456 path_glob: BRANCH_PROTECTION_PATH_GLOB.to_string(),
6457 tier: Tier::Admin,
6458 platform: vec!["macos".to_string(), "linux".to_string()],
6459 rationale: Some(
6460 "branch protection is a repository setting; operator identity, audited bypass"
6461 .to_string(),
6462 ),
6463 });
6464 let admin = manifest
6465 .tiers
6466 .get_mut(&Tier::Admin)
6467 .expect("v13 admin tier");
6468 for tuple in ["release edit", "release upload"] {
6469 admin.push(TupleDecl::Details {
6470 tuple: tuple.to_string(),
6471 platform: vec!["macos".to_string(), "linux".to_string()],
6472 api_match: None,
6473 rationale: None,
6474 });
6475 }
6476 manifest.validate().expect("valid v13 admin extensions");
6477 for method in ["PUT", "DELETE"] {
6478 let rule = manifest
6479 .api_rules
6480 .iter()
6481 .find(|rule| rule.method == method && rule.path_glob == BRANCH_PROTECTION_PATH_GLOB)
6482 .expect("v13 branch protection API rule");
6483 assert_eq!(rule.tier, Tier::Admin);
6484 assert_eq!(rule.platform, ["macos", "linux"]);
6485 assert_eq!(
6486 rule.rationale.as_deref(),
6487 Some(
6488 "branch protection is a repository setting; operator identity, audited bypass"
6489 )
6490 );
6491 }
6492
6493 for args in [
6494 vec![
6495 "release",
6496 "edit",
6497 "v1.2.3",
6498 "--notes",
6499 "notes",
6500 "--notes-file",
6501 "notes.md",
6502 "--title",
6503 "Dashboard",
6504 "--draft=false",
6505 "--latest",
6506 "--prerelease",
6507 ],
6508 vec!["release", "upload", "v1.2.3", "dashboard.json", "--clobber"],
6509 ] {
6510 let args = os_args(&args);
6511 assert!(matches!(
6512 classify(&args, &manifest, "macos"),
6513 Classification::Admin { ref tuple }
6514 if tuple == if args[1] == "edit" { "release edit" } else { "release upload" }
6515 ));
6516 }
6517 assert!(is_reviewed_admin_tuple(13, "release edit"));
6518 assert!(is_reviewed_admin_tuple(13, "release upload"));
6519 assert!(!is_reviewed_admin_tuple(12, "release edit"));
6520 assert!(!is_reviewed_admin_tuple(12, "release upload"));
6521
6522 for args in [
6523 os_args(&["release", "delete", "v1.2.3"]),
6524 os_args(&["release", "delete-asset", "v1.2.3", "dashboard.json"]),
6525 os_args(&["release", "edit", "v1.2.3", "--delete-tag"]),
6526 os_args(&["release", "upload", "v1.2.3", "--delete-asset"]),
6527 ] {
6528 assert!(matches!(
6529 classify(&args, &manifest, "macos"),
6530 Classification::Destructive
6531 ));
6532 assert_eq!(
6533 RefusalCode::DestructiveFlag.as_str(),
6534 "gh_shim_destructive_flag"
6535 );
6536 assert_eq!(
6537 refuse(
6538 RefusalCode::DestructiveFlag,
6539 "destructive GitHub operations are not available through the shim"
6540 ),
6541 REFUSAL_EXIT_STATUS
6542 );
6543 }
6544 }
6545
6546 #[test]
6547 fn admin_api_rule_classifies_field_bearing_branch_protection_puts() {
6548 let input_args = os_args(&[
6549 "api",
6550 "-X",
6551 "PUT",
6552 "/repos/o/r/branches/main/protection",
6553 "--input",
6554 "body.json",
6555 ]);
6556 let admin_manifest = branch_protection_manifest("PUT", Tier::Admin);
6557 assert!(matches!(
6558 classify(&input_args, &admin_manifest, "macos"),
6559 Classification::Admin { ref tuple } if tuple == BRANCH_PROTECTION_API_TUPLE
6560 ));
6561 assert!(matches!(
6562 classify(&input_args, &v12_fixture_manifest(), "macos"),
6563 Classification::Unclassified
6564 ));
6565 assert!(matches!(
6566 classify(
6567 &input_args,
6568 &branch_protection_manifest("PUT", Tier::Governed),
6569 "macos"
6570 ),
6571 Classification::Unclassified
6572 ));
6573
6574 let field_args = os_args(&[
6575 "api",
6576 "-X",
6577 "PUT",
6578 "/repos/o/r/branches/main/protection",
6579 "-f",
6580 "enforce_admins=true",
6581 ]);
6582 assert!(matches!(
6583 classify(&field_args, &admin_manifest, "macos"),
6584 Classification::Admin { ref tuple } if tuple == BRANCH_PROTECTION_API_TUPLE
6585 ));
6586 }
6587
6588 #[test]
6589 fn delete_branch_protection_is_admin_only_when_declared_and_not_destructive() {
6590 let args = os_args(&["api", "-X", "DELETE", "/repos/o/r/branches/main/protection"]);
6591 assert!(matches!(
6592 classify(
6593 &args,
6594 &branch_protection_manifest("DELETE", Tier::Admin),
6595 "macos"
6596 ),
6597 Classification::Admin { ref tuple }
6598 if tuple == "api:DELETE:/repos/*/*/branches/*/protection"
6599 ));
6600 assert!(matches!(
6601 classify(&args, &v12_fixture_manifest(), "macos"),
6602 Classification::Unclassified
6603 ));
6604 }
6605
6606 #[test]
6610 fn slashless_api_endpoint_classifies_like_the_declared_glob() {
6611 let manifest = branch_protection_manifest("PUT", Tier::Admin);
6612 for spelling in [
6613 "/repos/o/r/branches/main/protection",
6614 "repos/o/r/branches/main/protection",
6615 ] {
6616 let args = os_args(&["api", "-X", "PUT", spelling, "--input", "-"]);
6617 assert!(
6618 matches!(
6619 classify(&args, &manifest, "macos"),
6620 Classification::Admin { ref tuple } if tuple == BRANCH_PROTECTION_API_TUPLE
6621 ),
6622 "{spelling} must classify as the declared admin endpoint"
6623 );
6624 }
6625 let args = os_args(&["api", "-X", "PUT", "repos/o/r/topics", "--input", "-"]);
6627 assert!(matches!(
6628 classify(&args, &manifest, "macos"),
6629 Classification::Unclassified
6630 ));
6631 }
6632
6633 #[cfg(debug_assertions)] #[test]
6635 fn dev_signed_admin_api_dispatch_requires_bypass_and_audits_delegation() {
6636 use std::cell::Cell;
6637
6638 let _env_lock = crate::test_env::process_env_lock();
6639 let directory = tempfile::tempdir().expect("create admin dispatch state directory");
6640 let paths = StatePaths::from_root(directory.path().to_path_buf());
6641 let manifest = branch_protection_manifest("PUT", Tier::Admin);
6646 manifest.validate().expect("valid admin API manifest");
6647 write_signed_manifest(&paths, manifest, TEST_NOW);
6648 let manifest = load_manifest(&paths, TEST_NOW).expect("dev-signed manifest verifies");
6649 let args = os_args(&[
6650 "api",
6651 "-X",
6652 "PUT",
6653 "/repos/o/r/branches/main/protection",
6654 "--input",
6655 "body.json",
6656 ]);
6657 let rung =
6658 RungDetermination::r3(TEST_NOW, manifest.manifest_version, &test_rung_provenance())
6659 .record;
6660 let binding = AgentBinding {
6661 repo: "cortexkit/aft".to_string(),
6662 agent_id: "alfonso-aft".to_string(),
6663 };
6664
6665 {
6666 let _bypass = ScopedTestEnvVar::set("GH_SHIM_BYPASS", None);
6667 let status = dispatch_r3(
6668 &args,
6669 classify(&args, &manifest, "macos"),
6670 &manifest,
6671 &paths,
6672 &rung,
6673 &binding,
6674 TEST_NOW,
6675 |_| panic!("ADMIN request delegated without operator bypass"),
6676 );
6677 assert_eq!(status, REFUSAL_EXIT_STATUS);
6678 assert_eq!(RefusalCode::AdminTier.as_str(), "gh_shim_admin_tier");
6679 }
6680
6681 let delegated = Cell::new(0);
6682 {
6683 let _bypass = ScopedTestEnvVar::set("GH_SHIM_BYPASS", Some("operator"));
6684 let status = dispatch_r3(
6685 &args,
6686 classify(&args, &manifest, "macos"),
6687 &manifest,
6688 &paths,
6689 &rung,
6690 &binding,
6691 TEST_NOW,
6692 |delegated_args| {
6693 assert_eq!(delegated_args, args);
6694 delegated.set(delegated.get() + 1);
6695 73
6696 },
6697 );
6698 assert_eq!(status, 73);
6699 }
6700 assert_eq!(delegated.get(), 1);
6701 let (records, error) = read_bypass_audit(&paths);
6702 assert!(error.is_none());
6703 let records = records.expect("operator bypass audit records");
6704 assert_eq!(records.len(), 1);
6705 assert_eq!(records[0]["tuple"], BRANCH_PROTECTION_API_TUPLE);
6706 }
6707
6708 #[test]
6709 fn release_api_mutations_remain_unclassified_because_api_rules_are_get_only() {
6710 let manifest = v12_fixture_manifest();
6711 for args in [
6713 os_args(&["api", "-X", "PATCH", "repos/owner/repo/releases/42"]),
6714 os_args(&["api", "-X", "POST", "repos/owner/repo/releases/42/assets"]),
6715 ] {
6716 assert!(matches!(
6717 classify(&args, &manifest, "macos"),
6718 Classification::Unclassified
6719 ));
6720 }
6721 }
6722
6723 #[test]
6724 fn field_bearing_get_remains_unclassified_without_widening_the_mechanical_fallback() {
6725 let manifest = fixture_manifest();
6726 for field_flag in [
6727 "--field=name=value",
6728 "--raw-field=name=value",
6729 "--input=body.json",
6730 "-fname=value",
6731 "-Fname=value",
6732 ] {
6733 let args = vec![
6734 OsString::from("api"),
6735 OsString::from("/repos/owner/repo"),
6736 OsString::from(field_flag),
6737 ];
6738 assert!(matches!(
6739 classify(&args, &manifest, "macos"),
6740 Classification::Unclassified
6741 ));
6742 }
6743 assert!(matches!(
6744 classify(
6745 &os_args(&[
6746 "api",
6747 "/repos/o/r/branches/main/protection",
6748 "--input",
6749 "body.json"
6750 ]),
6751 &manifest,
6752 "macos"
6753 ),
6754 Classification::Unclassified
6755 ));
6756 assert!(matches!(
6757 classify(
6758 &os_args(&["api", "/repos/o/r/branches/main/protection"]),
6759 &manifest,
6760 "macos"
6761 ),
6762 Classification::Mechanical
6763 ));
6764 }
6765
6766 #[test]
6767 fn holder_refusals_preserve_any_string_code_and_reject_non_strings() {
6768 for code in FIXTURE_ACCEPTED_SEAM_REFUSAL_CODES {
6769 let response = json!({"outcome": "refusal", "refusal_code": code});
6770 let outcome = parse_governed_response(&serde_json::to_vec(&response).unwrap()).unwrap();
6771 assert!(matches!(outcome, RouteOutcome::Refusal(ref actual) if actual == code));
6772 assert_eq!(RefusalCode::SeamRefusal.as_str(), "gh_shim_seam_refusal");
6773 assert_eq!(
6774 seam_refusal_text(code),
6775 format!("governance seam refused the action: {code}")
6776 );
6777 assert_eq!(REFUSAL_EXIT_STATUS, 86);
6778 }
6779 let unknown = "quota_exhausted_v2";
6780 let response = json!({"outcome": "refusal", "refusal_code": unknown});
6781 let outcome = parse_governed_response(&serde_json::to_vec(&response).unwrap()).unwrap();
6782 assert!(matches!(outcome, RouteOutcome::Refusal(ref actual) if actual == unknown));
6783 assert_eq!(RefusalCode::SeamRefusal.as_str(), "gh_shim_seam_refusal");
6784 assert_eq!(
6785 seam_refusal_text(unknown),
6786 "governance seam refused the action: quota_exhausted_v2"
6787 );
6788 assert_eq!(REFUSAL_EXIT_STATUS, 86);
6789
6790 for response in [
6791 json!({"outcome": "refusal", "refusal_code": 7}),
6792 json!({"outcome": "refusal", "refusal_code": null}),
6793 json!({"outcome": "refusal"}),
6794 ] {
6795 assert!(matches!(
6796 parse_governed_response(&serde_json::to_vec(&response).unwrap()),
6797 Err(RouteOutcome::SchemaMismatch(_))
6798 ));
6799 }
6800 }
6801
6802 #[test]
6803 fn governed_self_report_transitions_are_durable_and_mechanical_classification_preserves_them() {
6804 let directory = tempfile::tempdir().unwrap();
6805 let paths = StatePaths::from_root(directory.path().to_path_buf());
6806 let binding = AgentBinding {
6807 repo: "owner/repo".to_string(),
6808 agent_id: "agent-7".to_string(),
6809 };
6810 write_seam_state(
6811 &paths,
6812 SeamState {
6813 bound_holder: None,
6814 agent_binding: Some(binding.clone()),
6815 last_seam_refusal: None,
6816 },
6817 )
6818 .unwrap();
6819 let report = build_self_report(&paths);
6820 assert_eq!(report.bound_holder, None);
6821 assert_eq!(report.agent_binding, Some(binding.clone()));
6822 assert_eq!(report.last_seam_refusal, None);
6823
6824 write_seam_state(
6825 &paths,
6826 SeamState {
6827 bound_holder: Some(ROUTING_HOLDER_MODULE_ID.to_string()),
6828 agent_binding: Some(binding.clone()),
6829 last_seam_refusal: Some(LastSeamRefusal {
6830 code: "rate_limited".to_string(),
6831 at_unix_secs: 77,
6832 }),
6833 },
6834 )
6835 .unwrap();
6836 let report = build_self_report(&paths);
6837 assert_eq!(
6838 report.bound_holder.as_deref(),
6839 Some(ROUTING_HOLDER_MODULE_ID)
6840 );
6841 assert_eq!(report.agent_binding, Some(binding.clone()));
6842 assert_eq!(
6843 report
6844 .last_seam_refusal
6845 .as_ref()
6846 .map(|refusal| refusal.code.as_str()),
6847 Some("rate_limited")
6848 );
6849
6850 write_seam_state(
6851 &paths,
6852 governed_seam_state(&paths, Some(ROUTING_HOLDER_MODULE_ID.to_string()), &binding),
6853 )
6854 .unwrap();
6855 assert_eq!(
6856 seam_state(&paths)
6857 .last_seam_refusal
6858 .as_ref()
6859 .map(|refusal| refusal.code.as_str()),
6860 Some("rate_limited")
6861 );
6862
6863 let mechanical = [OsString::from("issue"), OsString::from("view")];
6864 assert!(matches!(
6865 classify(&mechanical, &fixture_manifest(), "macos"),
6866 Classification::Mechanical
6867 ));
6868 assert_eq!(
6869 seam_state(&paths)
6870 .last_seam_refusal
6871 .as_ref()
6872 .map(|refusal| refusal.at_unix_secs),
6873 Some(77)
6874 );
6875 }
6876
6877 #[test]
6878 fn governed_self_report_persistence_failure_is_loud() {
6879 let directory = tempfile::tempdir().unwrap();
6880 let state_root = directory.path().join("not-a-directory");
6881 fs::write(&state_root, b"file").unwrap();
6882 let paths = StatePaths::from_root(state_root);
6883 assert!(write_seam_state(&paths, SeamState::default()).is_err());
6884 }
6885
6886 #[cfg(debug_assertions)] #[test]
6888 fn raw_bytes_round_trip_verifies_then_parses_from_the_fixture_envelope() {
6889 let envelope: SignedManifest = serde_json::from_str(include_str!(
6890 "../tests/fixtures/gh_shim/signed-envelope-v2.json"
6891 ))
6892 .expect("signed envelope fixture");
6893 assert_eq!(
6895 envelope.manifest_bytes,
6896 include_str!("../tests/fixtures/gh_shim/initial-manifest-v1.json")
6897 );
6898 let manifest = verify_manifest_signature(&envelope).expect("fixture signature verifies");
6900 assert_eq!(manifest.manifest_version, 1);
6901 assert_eq!(manifest.issued_at_unix_secs, FIXTURE_ISSUED_AT);
6902 manifest.validate().expect("fixture manifest validates");
6903 }
6904
6905 #[test]
6906 fn tampered_single_byte_fixture_fails_signature_verification() {
6907 let canonical: SignedManifest = serde_json::from_str(include_str!(
6908 "../tests/fixtures/gh_shim/signed-envelope-v2.json"
6909 ))
6910 .expect("canonical envelope fixture");
6911 let tampered: SignedManifest = serde_json::from_str(include_str!(
6912 "../tests/fixtures/gh_shim/signed-envelope-v2-tampered.json"
6913 ))
6914 .expect("tampered envelope fixture");
6915 assert_eq!(
6918 canonical.manifest_bytes.len(),
6919 tampered.manifest_bytes.len()
6920 );
6921 assert_eq!(
6922 canonical
6923 .manifest_bytes
6924 .bytes()
6925 .zip(tampered.manifest_bytes.bytes())
6926 .filter(|(left, right)| left != right)
6927 .count(),
6928 1
6929 );
6930 assert_eq!(canonical.signature, tampered.signature);
6931 assert!(matches!(
6932 verify_manifest_signature(&tampered),
6933 Err(ManifestProblem::Invalid(_))
6934 ));
6935 }
6936
6937 #[cfg(debug_assertions)] #[test]
6939 fn future_issued_at_fixture_is_refused_and_aged_fixture_serves_governed_classification() {
6940 let directory = tempfile::tempdir().unwrap();
6941 let paths = StatePaths::from_root(directory.path().to_path_buf());
6942
6943 write_envelope_fixture(
6944 &paths,
6945 include_str!("../tests/fixtures/gh_shim/signed-envelope-v2-future-issued-at.json"),
6946 );
6947 match load_manifest(&paths, TEST_NOW) {
6948 Err(ManifestProblem::Invalid(error)) => {
6949 assert!(error.contains("future"), "unexpected error: {error}")
6950 }
6951 other => panic!("expected future issued_at refusal, got {other:?}"),
6952 }
6953
6954 write_envelope_fixture(
6958 &paths,
6959 include_str!("../tests/fixtures/gh_shim/signed-envelope-v2-stale-issued-at.json"),
6960 );
6961 let ManifestResolution::Active(manifest) = resolve_manifest(&paths, TEST_NOW) else {
6962 panic!("expected the aged signed manifest to remain active");
6963 };
6964 assert_eq!(manifest.issued_at_unix_secs, FIXTURE_ISSUED_AT - 2_000_000);
6965 assert!(matches!(
6966 classify(
6967 &[
6968 OsString::from("issue"),
6969 OsString::from("comment"),
6970 OsString::from("42"),
6971 OsString::from("--body"),
6972 OsString::from("hello"),
6973 ],
6974 &manifest,
6975 "macos"
6976 ),
6977 Classification::Governed { tuple, .. } if tuple == "issue comment"
6978 ));
6979
6980 let report: Value =
6981 serde_json::from_str(&render_self_report(&paths).expect("self report serialization"))
6982 .expect("self report JSON");
6983 assert_eq!(
6984 report["cached_manifest"]["issued_at_unix_secs"],
6985 FIXTURE_ISSUED_AT - 2_000_000
6986 );
6987 }
6988
6989 #[test]
6990 fn standby_key_fixture_verifies_under_a_two_slot_trust_set_and_unknown_key_ids_are_refused() {
6991 let envelope: SignedManifest = serde_json::from_str(include_str!(
6992 "../tests/fixtures/gh_shim/signed-envelope-v2-standby-key.json"
6993 ))
6994 .expect("standby envelope fixture");
6995
6996 let standby = Ed25519KeyPair::from_seed_unchecked(&STANDBY_TEST_SEED).expect("standby key");
6997 assert_ne!(standby.public_key().as_ref(), DEV_MANIFEST_PUBLIC_KEY);
6998 let standby_public: &'static [u8] =
6999 Box::leak(standby.public_key().as_ref().to_vec().into_boxed_slice());
7000 let trust_set = [
7001 Some(ManifestTrustKey {
7002 key_id: DEV_MANIFEST_KEY_ID,
7003 public_key: &DEV_MANIFEST_PUBLIC_KEY,
7004 }),
7005 Some(ManifestTrustKey {
7006 key_id: DEV_STANDBY_MANIFEST_KEY_ID,
7007 public_key: standby_public,
7008 }),
7009 ];
7010
7011 let manifest =
7013 verify_manifest_signature_with(&envelope, &trust_set).expect("standby slot verifies");
7014 assert_eq!(
7015 manifest.manifest_version,
7016 fixture_manifest().manifest_version
7017 );
7018
7019 let mut unknown = envelope.clone();
7021 unknown.key_id = "gh-routing-unknown-key".to_string();
7022 assert!(matches!(
7023 verify_manifest_signature_with(&unknown, &trust_set),
7024 Err(ManifestProblem::Invalid(_))
7025 ));
7026 }
7027
7028 #[test]
7029 fn compiled_trust_set_shape_matches_the_two_slot_design() {
7030 let slots = compiled_manifest_trust_set();
7031 let live = slots[0].expect("live slot carries the production root");
7036 assert_eq!(live.key_id, PROD_MANIFEST_KEY_ID);
7037 assert_eq!(live.public_key, &PROD_MANIFEST_PUBLIC_KEY);
7038 #[cfg(debug_assertions)]
7039 {
7040 assert_eq!(slots.len(), 2);
7043 assert_eq!(slots[1].unwrap().key_id, DEV_MANIFEST_KEY_ID);
7044 }
7045 #[cfg(not(debug_assertions))]
7046 {
7047 assert_eq!(slots.len(), 2);
7050 assert!(slots[1].is_none());
7051 }
7052 }
7053
7054 #[test]
7055 fn envelope_v1_shapes_are_refused_by_the_v2_verifier() {
7056 let directory = tempfile::tempdir().unwrap();
7057 let paths = StatePaths::from_root(directory.path().to_path_buf());
7058 let manifest = fixture_manifest();
7059 let bytes = serde_json::to_vec(&manifest).unwrap();
7060 let key = Ed25519KeyPair::from_seed_unchecked(&TEST_SEED).unwrap();
7061 let signature = base64::engine::general_purpose::STANDARD.encode(key.sign(&bytes).as_ref());
7062
7063 let v1_object = json!({
7065 "artifact_id": MANIFEST_ARTIFACT_ID,
7066 "key_id": DEV_MANIFEST_KEY_ID,
7067 "fetched_at_unix_secs": TEST_NOW,
7068 "signature": signature,
7069 "manifest": serde_json::to_value(&manifest).unwrap(),
7070 });
7071 fs::write(&paths.manifest, serde_json::to_vec(&v1_object).unwrap()).unwrap();
7072 assert!(matches!(
7073 load_manifest(&paths, TEST_NOW),
7074 Err(ManifestProblem::Invalid(_))
7075 ));
7076
7077 let mut old_version = signed(&manifest, TEST_NOW);
7079 old_version.envelope_version = 1;
7080 fs::write(&paths.manifest, serde_json::to_vec(&old_version).unwrap()).unwrap();
7081 match load_manifest(&paths, TEST_NOW) {
7082 Err(ManifestProblem::Invalid(error)) => {
7083 assert!(
7084 error.contains("envelope version"),
7085 "unexpected error: {error}"
7086 )
7087 }
7088 other => panic!("expected envelope version refusal, got {other:?}"),
7089 }
7090 }
7091
7092 #[test]
7093 fn dormant_resolution_is_presence_based() {
7094 let directory = tempfile::tempdir().unwrap();
7095 let paths = StatePaths::from_root(directory.path().to_path_buf());
7096 assert!(matches!(
7098 resolve_manifest(&paths, TEST_NOW),
7099 ManifestResolution::Dormant
7100 ));
7101
7102 let untrusted = signed_with(
7106 &fixture_manifest(),
7107 TEST_NOW,
7108 &STANDBY_TEST_SEED,
7109 "gh-routing-unknown-key",
7110 );
7111 fs::write(&paths.manifest, serde_json::to_vec(&untrusted).unwrap()).unwrap();
7112 assert!(matches!(
7113 resolve_manifest(&paths, TEST_NOW),
7114 ManifestResolution::Invalid(ManifestProblem::Invalid(_))
7115 ));
7116 }
7117
7118 #[cfg(debug_assertions)] #[test]
7120 fn regressed_invalid_artifact_refuses_governed_and_admin_and_passes_mechanical() {
7121 let directory = tempfile::tempdir().unwrap();
7122 let paths = StatePaths::from_root(directory.path().to_path_buf());
7123 let now = TEST_NOW;
7124
7125 write_signed_manifest(&paths, fixture_manifest(), now);
7127 load_manifest(&paths, now).expect("canonical manifest verifies");
7128
7129 write_envelope_fixture(
7131 &paths,
7132 include_str!("../tests/fixtures/gh_shim/signed-envelope-v2-tampered.json"),
7133 );
7134
7135 let ManifestResolution::Regressed { manifest, problem } = resolve_manifest(&paths, now)
7138 else {
7139 panic!("expected the regressed arm");
7140 };
7141 let governed = [
7142 OsString::from("issue"),
7143 OsString::from("comment"),
7144 OsString::from("42"),
7145 OsString::from("--body"),
7146 OsString::from("hello"),
7147 ];
7148 assert!(matches!(
7149 regressed_disposition(&governed, &manifest, "macos", &problem),
7150 RegressedDisposition::Refuse {
7151 code: RefusalCode::ManifestRegressed,
7152 ..
7153 }
7154 ));
7155 let admin = [
7156 OsString::from("pr"),
7157 OsString::from("merge"),
7158 OsString::from("1"),
7159 ];
7160 assert!(matches!(
7161 regressed_disposition(&admin, &manifest, "macos", &problem),
7162 RegressedDisposition::Refuse {
7163 code: RefusalCode::ManifestRegressed,
7164 ..
7165 }
7166 ));
7167 let mechanical = [OsString::from("issue"), OsString::from("view")];
7168 assert!(matches!(
7169 regressed_disposition(&mechanical, &manifest, "macos", &problem),
7170 RegressedDisposition::Passthrough
7171 ));
7172 let undeclared = [OsString::from("alias"), OsString::from("set")];
7173 assert!(matches!(
7174 regressed_disposition(&undeclared, &manifest, "macos", &problem),
7175 RegressedDisposition::Refuse {
7176 code: RefusalCode::Unclassified,
7177 ..
7178 }
7179 ));
7180
7181 let report = cached_manifest_report_at(&paths, now);
7183 assert_eq!(report.state, Some("regressed"));
7184 assert_eq!(report.version, Some(1));
7185 assert_eq!(report.issued_at_unix_secs, Some(FIXTURE_ISSUED_AT));
7186 assert_eq!(
7187 report.diagnostics,
7188 vec![
7189 SelfReportDiagnostic::ManifestRegressed.as_str(),
7190 SelfReportDiagnostic::ManifestInvalid.as_str(),
7191 ]
7192 );
7193 }
7194
7195 #[cfg(debug_assertions)] #[test]
7197 fn self_report_exposes_manifest_and_rung_record_provenance() {
7198 let directory = tempfile::tempdir().unwrap();
7199 let paths = StatePaths::from_root(directory.path().to_path_buf());
7200 write_signed_manifest(&paths, fixture_manifest(), TEST_NOW);
7201
7202 let manifest_report = cached_manifest_report_at(&paths, TEST_NOW);
7203 assert_eq!(manifest_report.state, Some("valid"));
7204 assert_eq!(
7205 manifest_report.verified_by_key_id.as_deref(),
7206 Some(DEV_MANIFEST_KEY_ID)
7207 );
7208 assert_eq!(
7209 manifest_report.compiled_trust_set_key_ids,
7210 trust_set_key_ids(compiled_manifest_trust_set())
7211 );
7212
7213 let provenance = RungRecordProvenance {
7214 image_path: "/opt/cortexkit/aft-gh-shim".to_string(),
7215 version: "0.53.0-test".to_string(),
7216 repo_key: "cortexkit/aft".to_string(),
7217 };
7218 let determination =
7219 RungDetermination::r2(TEST_NOW, R2Reason::DaemonUnreachable, Some(1), &provenance);
7220 write_rung_record_silently(&paths, &determination.record);
7221 let fresh_rung = last_rung_report(&paths);
7222 assert_eq!(
7223 fresh_rung.recorded_by_image_path.as_deref(),
7224 Some("/opt/cortexkit/aft-gh-shim")
7225 );
7226 assert_eq!(
7227 fresh_rung.recorded_by_version.as_deref(),
7228 Some("0.53.0-test")
7229 );
7230 assert_eq!(
7231 fresh_rung.recorded_by_repo_key.as_deref(),
7232 Some("cortexkit/aft")
7233 );
7234
7235 fs::write(
7236 &paths.rung,
7237 serde_json::to_vec(&json!({
7238 "rung": "R2",
7239 "as_of_unix_secs": TEST_NOW,
7240 "inputs": { "daemon_unreachable": "failed" },
7241 "manifest_version": 1
7242 }))
7243 .unwrap(),
7244 )
7245 .unwrap();
7246 let legacy_rung = last_rung_report(&paths);
7247 assert_eq!(
7248 legacy_rung.recorded_by_image_path.as_deref(),
7249 Some(PRE_PROVENANCE_RECORD)
7250 );
7251 assert_eq!(
7252 legacy_rung.recorded_by_version.as_deref(),
7253 Some(PRE_PROVENANCE_RECORD)
7254 );
7255 assert_eq!(
7256 legacy_rung.recorded_by_repo_key.as_deref(),
7257 Some(PRE_PROVENANCE_RECORD)
7258 );
7259 }
7260
7261 #[test]
7262 fn trust_set_provenance_explains_image_level_untrusted_key_regression() {
7263 let directory = tempfile::tempdir().unwrap();
7264 let paths = StatePaths::from_root(directory.path().to_path_buf());
7265 write_signed_manifest(&paths, fixture_manifest(), TEST_NOW);
7266 let verifier_a = [Some(ManifestTrustKey {
7267 key_id: DEV_MANIFEST_KEY_ID,
7268 public_key: &DEV_MANIFEST_PUBLIC_KEY,
7269 })];
7270 let verifier_b = [Some(PROD_MANIFEST_TRUST_KEY)];
7271
7272 let report_a = cached_manifest_report_at_with(&paths, TEST_NOW, &verifier_a);
7273 assert_eq!(report_a.state, Some("valid"));
7274 assert_eq!(
7275 report_a.verified_by_key_id.as_deref(),
7276 Some(DEV_MANIFEST_KEY_ID)
7277 );
7278 assert_eq!(
7279 report_a.compiled_trust_set_key_ids,
7280 vec![DEV_MANIFEST_KEY_ID]
7281 );
7282
7283 let report_b = cached_manifest_report_at_with(&paths, TEST_NOW, &verifier_b);
7284 assert_eq!(report_b.state, Some("regressed"));
7285 assert_eq!(report_b.verified_by_key_id, None);
7286 assert_eq!(
7287 report_b.compiled_trust_set_key_ids,
7288 vec![PROD_MANIFEST_KEY_ID]
7289 );
7290 assert_eq!(
7291 report_b.diagnostic_guidance,
7292 Some(UNTRUSTED_MANIFEST_KEY_STEERING)
7293 );
7294
7295 let cached = read_last_valid_manifest(&paths).expect("verifier A wrote last-valid cache");
7296 let governed = [
7297 OsString::from("issue"),
7298 OsString::from("comment"),
7299 OsString::from("42"),
7300 OsString::from("--body"),
7301 OsString::from("hello"),
7302 ];
7303 let untrusted =
7304 ManifestProblem::Invalid(format!("untrusted manifest key id {DEV_MANIFEST_KEY_ID}"));
7305 let RegressedDisposition::Refuse { text, .. } =
7306 regressed_disposition(&governed, &cached.manifest, "macos", &untrusted)
7307 else {
7308 panic!("a governed command must refuse under verifier B");
7309 };
7310 assert!(text.ends_with(UNTRUSTED_MANIFEST_KEY_STEERING));
7311 }
7312
7313 #[cfg(debug_assertions)] #[test]
7315 fn version_high_water_refuses_rollbacks_and_status_reports_them() {
7316 let directory = tempfile::tempdir().unwrap();
7317 let paths = StatePaths::from_root(directory.path().to_path_buf());
7318
7319 write_envelope_fixture(
7321 &paths,
7322 include_str!("../tests/fixtures/gh_shim/signed-envelope-v2-version-2.json"),
7323 );
7324 assert_eq!(load_manifest(&paths, TEST_NOW).unwrap().manifest_version, 2);
7325 assert_eq!(version_high_water(&paths), 2);
7326
7327 write_envelope_fixture(
7330 &paths,
7331 include_str!("../tests/fixtures/gh_shim/signed-envelope-v2.json"),
7332 );
7333 assert!(matches!(
7334 load_manifest(&paths, TEST_NOW),
7335 Err(ManifestProblem::RolledBack {
7336 manifest_version: 1,
7337 newest_accepted: 2,
7338 })
7339 ));
7340 let report = cached_manifest_report_at(&paths, TEST_NOW);
7341 assert_eq!(
7342 report.diagnostics,
7343 vec![
7344 SelfReportDiagnostic::ManifestRegressed.as_str(),
7345 SelfReportDiagnostic::ManifestRollback.as_str(),
7346 ]
7347 );
7348 let document = render_self_report(&paths).expect("self report");
7350 assert!(document.contains(SelfReportDiagnostic::ManifestRollback.as_str()));
7351
7352 write_envelope_fixture(
7354 &paths,
7355 include_str!("../tests/fixtures/gh_shim/signed-envelope-v2-version-2.json"),
7356 );
7357 assert_eq!(load_manifest(&paths, TEST_NOW).unwrap().manifest_version, 2);
7358 }
7359
7360 fn fixture_dir() -> PathBuf {
7361 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/gh_shim")
7362 }
7363
7364 fn canonical_manifest_bytes() -> Vec<u8> {
7365 fs::read(fixture_dir().join("initial-manifest-v1.json"))
7366 .expect("canonical manifest fixture")
7367 }
7368
7369 fn envelope_json(envelope: &SignedManifest) -> Vec<u8> {
7370 let mut bytes = serde_json::to_vec_pretty(envelope).expect("envelope serialization");
7371 bytes.push(b'\n');
7372 bytes
7373 }
7374
7375 fn generate_envelope_fixtures() -> Vec<(String, Vec<u8>)> {
7379 let sign = |bytes: &[u8], seed: &[u8; 32]| {
7380 let key = Ed25519KeyPair::from_seed_unchecked(seed).expect("fixture key");
7381 base64::engine::general_purpose::STANDARD.encode(key.sign(bytes).as_ref())
7382 };
7383 let envelope = |key_id: &str, seed: &[u8; 32], manifest_bytes: String| {
7384 envelope_json(&SignedManifest {
7385 artifact_id: MANIFEST_ARTIFACT_ID.to_string(),
7386 envelope_version: ENVELOPE_VERSION,
7387 key_id: key_id.to_string(),
7388 fetched_at_unix_secs: FIXTURE_ISSUED_AT,
7389 signature: sign(manifest_bytes.as_bytes(), seed),
7390 manifest_bytes,
7391 })
7392 };
7393
7394 let canonical = canonical_manifest_bytes();
7395 let canonical_text = String::from_utf8(canonical.clone()).expect("UTF-8 manifest");
7396 let canonical_signature = sign(&canonical, &TEST_SEED);
7397
7398 let mut fixtures = Vec::new();
7399 fixtures.push((
7401 "signed-envelope-v2.json".to_string(),
7402 envelope_json(&SignedManifest {
7403 artifact_id: MANIFEST_ARTIFACT_ID.to_string(),
7404 envelope_version: ENVELOPE_VERSION,
7405 key_id: DEV_MANIFEST_KEY_ID.to_string(),
7406 fetched_at_unix_secs: FIXTURE_ISSUED_AT,
7407 signature: canonical_signature.clone(),
7408 manifest_bytes: canonical_text.clone(),
7409 }),
7410 ));
7411 let tampered = canonical_text.replacen("issue view", "issue View", 1);
7414 assert_ne!(tampered, canonical_text);
7415 fixtures.push((
7416 "signed-envelope-v2-tampered.json".to_string(),
7417 envelope_json(&SignedManifest {
7418 artifact_id: MANIFEST_ARTIFACT_ID.to_string(),
7419 envelope_version: ENVELOPE_VERSION,
7420 key_id: DEV_MANIFEST_KEY_ID.to_string(),
7421 fetched_at_unix_secs: FIXTURE_ISSUED_AT,
7422 signature: canonical_signature,
7423 manifest_bytes: tampered,
7424 }),
7425 ));
7426
7427 let mut variant = |name: &str, mutate: fn(&mut Manifest), seed: &[u8; 32], key_id: &str| {
7428 let mut manifest = fixture_manifest();
7429 mutate(&mut manifest);
7430 let bytes = serde_json::to_vec(&manifest).expect("variant manifest bytes");
7431 fixtures.push((
7432 name.to_string(),
7433 envelope(
7434 key_id,
7435 seed,
7436 String::from_utf8(bytes).expect("UTF-8 variant bytes"),
7437 ),
7438 ));
7439 };
7440 variant(
7441 "signed-envelope-v2-future-issued-at.json",
7442 |manifest| {
7443 manifest.issued_at_unix_secs =
7444 FIXTURE_ISSUED_AT + ISSUED_AT_FUTURE_SKEW.as_secs() + 3300;
7445 },
7446 &TEST_SEED,
7447 DEV_MANIFEST_KEY_ID,
7448 );
7449 variant(
7450 "signed-envelope-v2-stale-issued-at.json",
7451 |manifest| {
7452 manifest.issued_at_unix_secs = FIXTURE_ISSUED_AT - 2_000_000;
7453 },
7454 &TEST_SEED,
7455 DEV_MANIFEST_KEY_ID,
7456 );
7457 variant(
7458 "signed-envelope-v2-version-2.json",
7459 |manifest| {
7460 manifest.manifest_version = 2;
7461 },
7462 &TEST_SEED,
7463 DEV_MANIFEST_KEY_ID,
7464 );
7465 variant(
7466 "signed-envelope-v2-standby-key.json",
7467 |_manifest| {},
7468 &STANDBY_TEST_SEED,
7469 DEV_STANDBY_MANIFEST_KEY_ID,
7470 );
7471 fixtures
7472 }
7473
7474 #[test]
7475 fn signed_envelope_fixtures_match_their_generator() {
7476 let regen = std::env::var_os("AFT_GH_SHIM_REGEN").is_some();
7477 for (name, bytes) in generate_envelope_fixtures() {
7478 let path = fixture_dir().join(&name);
7479 if regen {
7480 fs::write(&path, &bytes).expect("write fixture");
7481 continue;
7482 }
7483 let disk = fs::read(&path)
7484 .unwrap_or_else(|error| panic!("fixture {name} is missing: {error}"));
7485 assert_eq!(
7486 disk, bytes,
7487 "fixture {name} drifted from its generator; rerun with AFT_GH_SHIM_REGEN=1"
7488 );
7489 }
7490 }
7491
7492 fn retained_files(paths: &StatePaths) -> Vec<PathBuf> {
7493 fs::read_dir(&paths.manifests_dir)
7494 .map(|entries| {
7495 entries
7496 .filter_map(Result::ok)
7497 .map(|entry| entry.path())
7498 .collect()
7499 })
7500 .unwrap_or_default()
7501 }
7502
7503 #[cfg(debug_assertions)] #[test]
7505 fn activation_retains_every_accepted_manifest_with_exact_bytes() {
7506 let directory = tempfile::tempdir().unwrap();
7507 let paths = StatePaths::from_root(directory.path().to_path_buf());
7508
7509 write_signed_manifest(&paths, v11_fixture_manifest(), TEST_NOW);
7511 assert_eq!(
7512 load_manifest(&paths, TEST_NOW).unwrap().manifest_version,
7513 11
7514 );
7515 write_signed_manifest(&paths, v12_fixture_manifest(), TEST_NOW);
7516 assert_eq!(
7517 load_manifest(&paths, TEST_NOW).unwrap().manifest_version,
7518 12
7519 );
7520
7521 let files = retained_files(&paths);
7523 assert_eq!(files.len(), 2);
7524
7525 let mut versions = BTreeSet::new();
7528 for file in &files {
7529 let record: RetainedManifest =
7530 serde_json::from_slice(&fs::read(file).unwrap()).expect("retained record");
7531 let envelope = SignedManifest {
7532 artifact_id: MANIFEST_ARTIFACT_ID.to_string(),
7533 envelope_version: ENVELOPE_VERSION,
7534 key_id: record.key_id.clone(),
7535 fetched_at_unix_secs: TEST_NOW,
7536 signature: record.signature.clone(),
7537 manifest_bytes: record.manifest_bytes.clone(),
7538 };
7539 let verified =
7540 verify_manifest_signature(&envelope).expect("retained signature re-verifies");
7541 versions.insert(verified.manifest_version);
7542 }
7543 assert_eq!(versions, BTreeSet::from([11, 12]));
7544
7545 let document = render_self_report(&paths).expect("self report");
7547 let value: Value = serde_json::from_str(&document).expect("self report JSON");
7548 assert_eq!(value["manifests_retained"], json!(2));
7549 assert_eq!(
7550 value["manifests_dir"],
7551 json!(paths.manifests_dir.to_string_lossy())
7552 );
7553 }
7554
7555 #[test]
7556 fn tampered_payload_is_not_retained() {
7557 let directory = tempfile::tempdir().unwrap();
7558 let paths = StatePaths::from_root(directory.path().to_path_buf());
7559
7560 write_envelope_fixture(
7563 &paths,
7564 include_str!("../tests/fixtures/gh_shim/signed-envelope-v2-tampered.json"),
7565 );
7566 assert!(load_manifest(&paths, TEST_NOW).is_err());
7567 assert!(retained_files(&paths).is_empty());
7568 }
7569
7570 #[test]
7571 fn version_mismatch_between_payload_and_filing_is_refused() {
7572 let directory = tempfile::tempdir().unwrap();
7573 let paths = StatePaths::from_root(directory.path().to_path_buf());
7574
7575 let envelope = signed(&v11_fixture_manifest(), TEST_NOW);
7578 retain_manifest(&paths, &envelope, 12);
7579
7580 assert!(retained_files(&paths).is_empty());
7581 }
7582
7583 #[test]
7584 fn same_name_different_bytes_is_refused_and_existing_file_untouched() {
7585 let directory = tempfile::tempdir().unwrap();
7586 let paths = StatePaths::from_root(directory.path().to_path_buf());
7587
7588 let envelope = signed(&v11_fixture_manifest(), TEST_NOW);
7592 let digest = Sha256::digest(envelope.manifest_bytes.as_bytes());
7593 let digest_hex = format!("{digest:x}");
7594 let destination = paths
7595 .manifests_dir
7596 .join(format!("v11-{}.json", &digest_hex[..16]));
7597 fs::create_dir_all(&paths.manifests_dir).unwrap();
7598 let original = b"{\"different\":\"bytes\"}".to_vec();
7599 fs::write(&destination, &original).unwrap();
7600
7601 retain_manifest(&paths, &envelope, 11);
7602
7603 assert_eq!(
7604 fs::read(&destination).unwrap(),
7605 original,
7606 "existing file must be untouched on collision"
7607 );
7608 }
7609
7610 #[cfg(unix)]
7611 #[cfg(debug_assertions)] #[test]
7613 fn retained_manifest_is_mode_0600() {
7614 use std::os::unix::fs::PermissionsExt;
7615
7616 let directory = tempfile::tempdir().unwrap();
7617 let paths = StatePaths::from_root(directory.path().to_path_buf());
7618
7619 write_signed_manifest(&paths, v11_fixture_manifest(), TEST_NOW);
7620 load_manifest(&paths, TEST_NOW).unwrap();
7621
7622 let files = retained_files(&paths);
7623 assert_eq!(files.len(), 1);
7624 let mode = fs::metadata(&files[0]).unwrap().permissions().mode();
7625 assert_eq!(mode & 0o777, 0o600);
7626 }
7627
7628 use subc_protocol::{Flags, Frame, FrameType, ModuleHelloAckBody, Priority, PROTOCOL_VERSION};
7629 use subc_transport::connection_file::{self, ConnectionInfo, Endpoint, SCHEMA_VERSION};
7630
7631 fn control_flags() -> Flags {
7632 Flags::new(false, Priority::Passive, false)
7633 }
7634
7635 struct SlowDaemonConfig {
7636 handshake_delay: Duration,
7637 catalog_delay: Duration,
7638 open_route_delay: Duration,
7639 first_connection_only: bool,
7644 }
7645
7646 struct SlowTestDaemon {
7647 port: u16,
7648 key: Vec<u8>,
7649 daemon_id: [u8; subc_transport::DAEMON_ID_LEN],
7650 shutdown_tx: Option<tokio::sync::oneshot::Sender<()>>,
7651 server_task: Option<std::thread::JoinHandle<()>>,
7652 }
7653
7654 impl SlowTestDaemon {
7655 fn spawn(config: SlowDaemonConfig) -> Self {
7656 let std_listener =
7657 std::net::TcpListener::bind("127.0.0.1:0").expect("bind test daemon");
7658 std_listener.set_nonblocking(true).expect("set nonblocking");
7659 let port = std_listener.local_addr().expect("local addr").port();
7660 let key = vec![0x42; subc_transport::KEY_LEN];
7661 let daemon_id = [0x24; subc_transport::DAEMON_ID_LEN];
7662 let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel::<()>();
7663
7664 let key_clone = key.clone();
7665 let daemon_id_clone = daemon_id;
7666
7667 let server_task = std::thread::spawn(move || {
7668 let rt = tokio::runtime::Builder::new_current_thread()
7669 .enable_io()
7670 .enable_time()
7671 .build()
7672 .expect("build daemon tokio runtime");
7673 rt.block_on(async move {
7674 let listener =
7675 tokio::net::TcpListener::from_std(std_listener).expect("tokio listener");
7676 let mut connection_count = 0usize;
7677 loop {
7678 tokio::select! {
7679 _ = &mut shutdown_rx => break,
7680 accepted = listener.accept() => {
7681 let Ok((mut stream, _)) = accepted else { break; };
7682 let k = key_clone.clone();
7683 let d = daemon_id_clone;
7684 let first_connection = connection_count == 0;
7685 connection_count += 1;
7686 let hs_delay = if config.first_connection_only && !first_connection {
7687 Duration::ZERO
7688 } else {
7689 config.handshake_delay
7690 };
7691 let cat_delay = if config.first_connection_only && !first_connection {
7692 Duration::ZERO
7693 } else {
7694 config.catalog_delay
7695 };
7696 let open_delay = if config.first_connection_only && !first_connection {
7697 Duration::ZERO
7698 } else {
7699 config.open_route_delay
7700 };
7701 tokio::spawn(async move {
7702 if hs_delay > Duration::ZERO {
7703 tokio::time::sleep(hs_delay).await;
7704 }
7705 if subc_transport::authenticate_server(
7706 &mut stream,
7707 &k,
7708 &d,
7709 "subc-test",
7710 Duration::from_secs(5),
7711 )
7712 .await
7713 .is_err()
7714 {
7715 return;
7716 }
7717
7718 loop {
7719 let frame = match subc_transport::read_frame(&mut stream).await {
7720 Ok(Some(frame)) => frame,
7721 _ => break,
7722 };
7723
7724 match frame.header.ty {
7725 FrameType::Hello => {
7726 let ack = Frame::build(
7727 FrameType::HelloAck,
7728 control_flags(),
7729 0,
7730 0,
7731 frame.header.corr,
7732 serde_json::to_vec(&ModuleHelloAckBody {
7733 negotiated_ver: PROTOCOL_VERSION,
7734 subc_ops: Vec::new(),
7735 subc_capabilities: Vec::new(),
7736 storage: None,
7737 })
7738 .expect("hello ack body"),
7739 )
7740 .expect("hello ack frame");
7741 if subc_transport::write_frame(&mut stream, &ack).await.is_err() {
7742 break;
7743 }
7744 }
7745 FrameType::Request => {
7746 let op: Option<String> = serde_json::from_slice::<Value>(&frame.body)
7747 .ok()
7748 .and_then(|v| v.get("op").and_then(Value::as_str).map(String::from));
7749
7750 if op.as_deref() == Some("catalog.list") {
7751 if !cat_delay.is_zero() {
7752 tokio::time::sleep(cat_delay).await;
7753 }
7754 let response_body = json!({
7755 "op": "catalog.list",
7756 "generation": 1,
7757 "modules": [{
7758 "module_id": "prefrontal-core",
7759 "module_version": "0.1.0",
7760 "roles": [{
7761 "role": "management_surface",
7762 "operations": [{ "name": "gh.route", "kind": "query" }],
7763 "config_schema": {},
7764 "observability": [],
7765 "identity_scope": ["project"]
7766 }],
7767 "control_ops": []
7768 }],
7769 "subc_ops": ["catalog.list", "route.open"]
7770 });
7771 let resp = Frame::build_with_version(
7772 frame.header.ver,
7773 FrameType::Response,
7774 frame.header.flags,
7775 frame.header.channel,
7776 frame.header.epoch,
7777 frame.header.corr,
7778 serde_json::to_vec(&response_body).expect("catalog json"),
7779 )
7780 .expect("catalog response frame");
7781 if subc_transport::write_frame(&mut stream, &resp).await.is_err() {
7782 break;
7783 }
7784 } else if op.as_deref() == Some("route.open") {
7785 if !open_delay.is_zero() {
7786 tokio::time::sleep(open_delay).await;
7787 }
7788 let response_body = json!({
7789 "op": "route.open",
7790 "route_channel": 42,
7791 "route_epoch": 1
7792 });
7793 let resp = Frame::build_with_version(
7794 frame.header.ver,
7795 FrameType::Response,
7796 frame.header.flags,
7797 frame.header.channel,
7798 frame.header.epoch,
7799 frame.header.corr,
7800 serde_json::to_vec(&response_body).expect("route open json"),
7801 )
7802 .expect("route open frame");
7803 if subc_transport::write_frame(&mut stream, &resp).await.is_err() {
7804 break;
7805 }
7806 } else if op.as_deref() == Some("route.close") {
7807 let response_body = json!({ "op": "route.close" });
7808 let resp = Frame::build_with_version(
7809 frame.header.ver,
7810 FrameType::Response,
7811 frame.header.flags,
7812 frame.header.channel,
7813 frame.header.epoch,
7814 frame.header.corr,
7815 serde_json::to_vec(&response_body).expect("route close json"),
7816 )
7817 .expect("route close frame");
7818 if subc_transport::write_frame(&mut stream, &resp).await.is_err() {
7819 break;
7820 }
7821 } else if frame.header.channel == 42 {
7822 let response_body = json!({
7823 "outcome": "result",
7824 "gh_route_schema": 1,
7825 "result": { "url": "https://github.com/cortexkit/aft/issues/1#issuecomment-123" },
7826 "field_order": ["url"]
7827 });
7828 let resp = Frame::build_with_version(
7829 frame.header.ver,
7830 FrameType::Response,
7831 frame.header.flags,
7832 frame.header.channel,
7833 frame.header.epoch,
7834 frame.header.corr,
7835 serde_json::to_vec(&response_body).expect("result json"),
7836 )
7837 .expect("result frame");
7838 if subc_transport::write_frame(&mut stream, &resp).await.is_err() {
7839 break;
7840 }
7841 }
7842 }
7843 _ => {}
7844 }
7845 }
7846 });
7847 }
7848 }
7849 }
7850 });
7851 });
7852
7853 Self {
7854 port,
7855 key,
7856 daemon_id,
7857 shutdown_tx: Some(shutdown_tx),
7858 server_task: Some(server_task),
7859 }
7860 }
7861
7862 fn write_connection_file(&self, path: &Path) {
7863 let conn = ConnectionInfo {
7864 schema: SCHEMA_VERSION,
7865 wire_version: Some(PROTOCOL_VERSION),
7866 endpoints: vec![Endpoint {
7867 host: "127.0.0.1".to_string(),
7868 port: self.port,
7869 }],
7870 key: self.key.clone(),
7871 daemon_id: self.daemon_id,
7872 pid: std::process::id(),
7873 daemon_ver: "gh-shim-test-daemon".to_string(),
7874 };
7875 connection_file::write_atomic(path, &conn).expect("write test daemon connection file");
7876 }
7877 }
7878
7879 impl Drop for SlowTestDaemon {
7880 fn drop(&mut self) {
7881 if let Some(tx) = self.shutdown_tx.take() {
7882 let _ = tx.send(());
7883 }
7884 let _ = std::net::TcpStream::connect(("127.0.0.1", self.port));
7885 if let Some(task) = self.server_task.take() {
7886 let _ = task.join();
7887 }
7888 }
7889 }
7890
7891 fn write_test_project_repo(root: &Path, repository: &str) -> PathBuf {
7892 let project = root.join("test-project");
7893 fs::create_dir_all(&project).expect("create project directory");
7894 Command::new("git")
7895 .args(["init", "--quiet"])
7896 .current_dir(&project)
7897 .status()
7898 .expect("init git repo");
7899 Command::new("git")
7900 .args([
7901 "remote",
7902 "add",
7903 "origin",
7904 &format!("https://github.com/{repository}.git"),
7905 ])
7906 .current_dir(&project)
7907 .status()
7908 .expect("add git origin");
7909 project
7910 }
7911
7912 const STAGE_TEST_DEADLINE: Duration = Duration::from_secs(2);
7920
7921 #[cfg(debug_assertions)] #[test]
7923 fn probe_exceeded_at_catalog_list_names_stage_and_budget_in_status_and_refusal() {
7924 let daemon = SlowTestDaemon::spawn(SlowDaemonConfig {
7925 handshake_delay: Duration::ZERO,
7926 catalog_delay: Duration::from_secs(6),
7927 open_route_delay: Duration::ZERO,
7928 first_connection_only: false,
7929 });
7930 let temp = tempfile::tempdir().unwrap();
7931 let paths = StatePaths::from_root(temp.path().join("state"));
7932 let conn_file = temp.path().join("subc-connection.json");
7933 daemon.write_connection_file(&conn_file);
7934 let project = write_test_project_repo(temp.path(), "cortexkit/aft");
7935
7936 let manifest = v12_fixture_manifest();
7937 let now = unix_seconds();
7938 write_signed_manifest(&paths, manifest, now);
7939
7940 let doc = json!({
7941 "subc": { "connection_file": conn_file.to_str().unwrap() }
7942 })
7943 .to_string();
7944
7945 let determination = determine_rung_from_doc(
7946 &paths,
7947 &project,
7948 now,
7949 Instant::now() + STAGE_TEST_DEADLINE,
7950 Some(&doc),
7951 );
7952 assert_eq!(determination.record.rung, Rung::R1);
7953 assert_eq!(
7954 determination.refusal_detail.as_deref(),
7955 Some(
7956 "governance probe timed out after 2000 ms at catalog_list (daemon may be busy; host load?) - this repository's actions are identity-governed, so the command was not run; retry"
7957 )
7958 );
7959
7960 let last_probe = read_last_probe(&paths).expect("last probe record");
7961 assert_eq!(last_probe.stage, "catalog_list");
7962 assert_eq!(last_probe.outcome, "timed_out");
7963 assert!(last_probe.elapsed_ms >= 2000);
7964
7965 let report = render_self_report(&paths).expect("self report");
7966 let status: Value = serde_json::from_str(&report).expect("status json");
7967 assert_eq!(status["last_probe"]["stage"], "catalog_list");
7968 assert_eq!(status["last_probe"]["outcome"], "timed_out");
7969 assert!(status["last_probe"]["elapsed_ms"].as_u64().unwrap() >= 2000);
7970 }
7971
7972 #[cfg(debug_assertions)] #[test]
7974 fn probe_exceeded_at_open_route_names_stage_and_budget_in_status_and_refusal() {
7975 let daemon = SlowTestDaemon::spawn(SlowDaemonConfig {
7976 handshake_delay: Duration::ZERO,
7977 catalog_delay: Duration::ZERO,
7978 open_route_delay: Duration::from_secs(6),
7979 first_connection_only: false,
7980 });
7981 let temp = tempfile::tempdir().unwrap();
7982 let paths = StatePaths::from_root(temp.path().join("state"));
7983 let conn_file = temp.path().join("subc-connection.json");
7984 daemon.write_connection_file(&conn_file);
7985 let project = write_test_project_repo(temp.path(), "cortexkit/aft");
7986
7987 let manifest = v12_fixture_manifest();
7988 let now = unix_seconds();
7989 write_signed_manifest(&paths, manifest, now);
7990
7991 let doc = json!({
7992 "subc": { "connection_file": conn_file.to_str().unwrap() }
7993 })
7994 .to_string();
7995
7996 let determination = determine_rung_from_doc(
7997 &paths,
7998 &project,
7999 now,
8000 Instant::now() + STAGE_TEST_DEADLINE,
8001 Some(&doc),
8002 );
8003 assert_eq!(determination.record.rung, Rung::R1);
8004 assert_eq!(
8005 determination.refusal_detail.as_deref(),
8006 Some("governance probe timed out after 2000 ms at open_route (daemon may be busy; host load?) - this repository's actions are identity-governed, so the command was not run; retry")
8007 );
8008
8009 let last_probe = read_last_probe(&paths).expect("last probe record");
8010 assert_eq!(last_probe.stage, "open_route");
8011 assert_eq!(last_probe.outcome, "timed_out");
8012 assert!(last_probe.elapsed_ms >= 2000);
8013
8014 let report = render_self_report(&paths).expect("self report");
8015 let status: Value = serde_json::from_str(&report).expect("status json");
8016 assert_eq!(status["last_probe"]["stage"], "open_route");
8017 assert_eq!(status["last_probe"]["outcome"], "timed_out");
8018 assert!(status["last_probe"]["elapsed_ms"].as_u64().unwrap() >= 2000);
8019 }
8020
8021 #[cfg(debug_assertions)] #[test]
8023 fn discovery_retry_succeeds_when_only_the_first_attempt_times_out() {
8024 let daemon = SlowTestDaemon::spawn(SlowDaemonConfig {
8028 handshake_delay: Duration::ZERO,
8029 catalog_delay: Duration::from_secs(6),
8030 open_route_delay: Duration::ZERO,
8031 first_connection_only: true,
8032 });
8033 let temp = tempfile::tempdir().unwrap();
8034 let paths = StatePaths::from_root(temp.path().join("state"));
8035 let conn_file = temp.path().join("subc-connection.json");
8036 daemon.write_connection_file(&conn_file);
8037 let project = write_test_project_repo(temp.path(), "cortexkit/aft");
8038
8039 let manifest = v12_fixture_manifest();
8040 let now = unix_seconds();
8041 write_signed_manifest(&paths, manifest, now);
8042
8043 let doc = json!({
8044 "subc": { "connection_file": conn_file.to_str().unwrap() }
8045 })
8046 .to_string();
8047
8048 let determination = determine_rung_from_doc(
8049 &paths,
8050 &project,
8051 now,
8052 Instant::now() + STAGE_TEST_DEADLINE,
8053 Some(&doc),
8054 );
8055 assert_eq!(determination.record.rung, Rung::R3);
8056 assert!(determination.refusal_detail.is_none());
8057
8058 let last_probe = read_last_probe(&paths).expect("last probe record");
8060 assert_eq!(last_probe.outcome, "ready");
8061 }
8062
8063 #[cfg(debug_assertions)] #[test]
8065 fn probe_connect_refused_keeps_unreachable_outcome() {
8066 let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("reserve port");
8067 let port = listener.local_addr().expect("port").port();
8068 drop(listener);
8069
8070 let temp = tempfile::tempdir().unwrap();
8071 let paths = StatePaths::from_root(temp.path().join("state"));
8072 let conn_file = temp.path().join("subc-connection.json");
8073 let conn = ConnectionInfo {
8074 schema: SCHEMA_VERSION,
8075 wire_version: Some(PROTOCOL_VERSION),
8076 endpoints: vec![Endpoint {
8077 host: "127.0.0.1".to_string(),
8078 port,
8079 }],
8080 key: vec![0x42; subc_transport::KEY_LEN],
8081 daemon_id: [0x24; subc_transport::DAEMON_ID_LEN],
8082 pid: std::process::id(),
8083 daemon_ver: "dead".to_string(),
8084 };
8085 connection_file::write_atomic(&conn_file, &conn).expect("write connection file");
8086 let project = write_test_project_repo(temp.path(), "cortexkit/aft");
8087
8088 let manifest = v12_fixture_manifest();
8089 let now = unix_seconds();
8090 write_signed_manifest(&paths, manifest, now);
8091
8092 let doc = json!({
8093 "subc": { "connection_file": conn_file.to_str().unwrap() }
8094 })
8095 .to_string();
8096
8097 let determination = determine_rung_from_doc(
8098 &paths,
8099 &project,
8100 now,
8101 Instant::now() + DISCOVERY_BUDGET,
8102 Some(&doc),
8103 );
8104 assert_eq!(determination.record.rung, Rung::R2);
8105 assert_eq!(determination.refusal_detail, None);
8106
8107 let last_probe = read_last_probe(&paths).expect("last probe record");
8108 assert_eq!(last_probe.stage, "connect");
8109 #[cfg(windows)]
8110 assert!(
8111 last_probe.outcome == "timed_out" || last_probe.outcome == "unreachable",
8112 "windows connect-refused probe outcome was {}",
8113 last_probe.outcome
8114 );
8115 #[cfg(not(windows))]
8116 assert_eq!(last_probe.outcome, "unreachable");
8117
8118 let report = render_self_report(&paths).expect("self report");
8119 let status: Value = serde_json::from_str(&report).expect("status json");
8120 assert_eq!(status["last_probe"]["stage"], "connect");
8121 #[cfg(windows)]
8122 assert!(
8123 status["last_probe"]["outcome"] == "timed_out"
8124 || status["last_probe"]["outcome"] == "unreachable"
8125 );
8126 #[cfg(not(windows))]
8127 assert_eq!(status["last_probe"]["outcome"], "unreachable");
8128 }
8129
8130 #[cfg(debug_assertions)] #[test]
8132 fn probe_connect_stage_budget_exceeded_determines_r2_and_records_last_probe() {
8133 let daemon = SlowTestDaemon::spawn(SlowDaemonConfig {
8134 handshake_delay: Duration::from_secs(6),
8135 catalog_delay: Duration::ZERO,
8136 open_route_delay: Duration::ZERO,
8137 first_connection_only: false,
8138 });
8139 let temp = tempfile::tempdir().unwrap();
8140 let paths = StatePaths::from_root(temp.path().join("state"));
8141 let conn_file = temp.path().join("subc-connection.json");
8142 daemon.write_connection_file(&conn_file);
8143 let project = write_test_project_repo(temp.path(), "cortexkit/aft");
8144
8145 let manifest = v12_fixture_manifest();
8146 let now = unix_seconds();
8147 write_signed_manifest(&paths, manifest, now);
8148
8149 let doc = json!({
8150 "subc": { "connection_file": conn_file.to_str().unwrap() }
8151 })
8152 .to_string();
8153
8154 let determination = determine_rung_from_doc(
8155 &paths,
8156 &project,
8157 now,
8158 Instant::now() + DISCOVERY_BUDGET,
8159 Some(&doc),
8160 );
8161 assert_eq!(determination.record.rung, Rung::R2);
8162 assert_eq!(determination.refusal_detail, None);
8163
8164 let last_probe = read_last_probe(&paths).expect("last probe record");
8165 assert_eq!(last_probe.stage, "connect");
8166 assert_eq!(last_probe.outcome, "timed_out");
8167 assert!(last_probe.elapsed_ms >= 2000);
8168
8169 let report = render_self_report(&paths).expect("self report");
8170 let status: Value = serde_json::from_str(&report).expect("status json");
8171 assert_eq!(status["last_probe"]["stage"], "connect");
8172 assert_eq!(status["last_probe"]["outcome"], "timed_out");
8173 assert!(status["last_probe"]["elapsed_ms"].as_u64().unwrap() >= 2000);
8174 }
8175
8176 #[cfg(debug_assertions)] #[test]
8178 fn slow_daemon_connect_delay_fallback_active_determines_r3_and_records_last_probe() {
8179 let daemon = SlowTestDaemon::spawn(SlowDaemonConfig {
8180 handshake_delay: Duration::from_secs(6),
8181 catalog_delay: Duration::ZERO,
8182 open_route_delay: Duration::ZERO,
8183 first_connection_only: false,
8184 });
8185 let temp = tempfile::tempdir().unwrap();
8186 let paths = StatePaths::from_root(temp.path().join("state"));
8187 let conn_file = temp.path().join("subc-connection.json");
8188 daemon.write_connection_file(&conn_file);
8189 let project = write_test_project_repo(temp.path(), "cortexkit/aft");
8190
8191 let manifest = v12_fixture_manifest();
8192 let now = unix_seconds();
8193 write_signed_manifest(&paths, manifest, now);
8194
8195 let mut cached_record = RungDetermination::r3(now - 30, 12, &test_rung_provenance()).record;
8196 cached_record.last_reachable_unix_secs = Some(now - 30);
8197 write_rung_record_silently(&paths, &cached_record);
8198
8199 let doc = json!({
8200 "subc": { "connection_file": conn_file.to_str().unwrap() }
8201 })
8202 .to_string();
8203
8204 let determination = determine_rung_from_doc(
8205 &paths,
8206 &project,
8207 now,
8208 Instant::now() + DISCOVERY_BUDGET,
8209 Some(&doc),
8210 );
8211 assert_eq!(determination.record.rung, Rung::R3);
8212 assert!(determination.refusal_detail.is_none());
8213
8214 let last_probe = read_last_probe(&paths).expect("last probe record");
8215 assert_eq!(last_probe.stage, "connect");
8216 assert_eq!(last_probe.outcome, "timed_out");
8217 assert!(last_probe.elapsed_ms >= 2000);
8218
8219 let report = render_self_report(&paths).expect("self report");
8220 let status: Value = serde_json::from_str(&report).expect("status json");
8221 assert_eq!(status["last_probe"]["stage"], "connect");
8222 assert_eq!(status["last_probe"]["outcome"], "timed_out");
8223 assert!(status["last_probe"]["elapsed_ms"].as_u64().unwrap() >= 2000);
8224 }
8225
8226 #[cfg(debug_assertions)] #[test]
8228 fn slow_daemon_catalog_list_delay_fallback_active_determines_r3_and_records_last_probe() {
8229 let daemon = SlowTestDaemon::spawn(SlowDaemonConfig {
8230 handshake_delay: Duration::ZERO,
8231 catalog_delay: Duration::from_secs(6),
8232 open_route_delay: Duration::ZERO,
8233 first_connection_only: false,
8234 });
8235 let temp = tempfile::tempdir().unwrap();
8236 let paths = StatePaths::from_root(temp.path().join("state"));
8237 let conn_file = temp.path().join("subc-connection.json");
8238 daemon.write_connection_file(&conn_file);
8239 let project = write_test_project_repo(temp.path(), "cortexkit/aft");
8240
8241 let manifest = v12_fixture_manifest();
8242 let now = unix_seconds();
8243 write_signed_manifest(&paths, manifest, now);
8244
8245 let mut cached_record = RungDetermination::r3(now - 30, 12, &test_rung_provenance()).record;
8246 cached_record.last_reachable_unix_secs = Some(now - 30);
8247 write_rung_record_silently(&paths, &cached_record);
8248
8249 let doc = json!({
8250 "subc": { "connection_file": conn_file.to_str().unwrap() }
8251 })
8252 .to_string();
8253
8254 let determination = determine_rung_from_doc(
8255 &paths,
8256 &project,
8257 now,
8258 Instant::now() + DISCOVERY_BUDGET,
8259 Some(&doc),
8260 );
8261 assert_eq!(determination.record.rung, Rung::R3);
8262 assert!(determination.refusal_detail.is_none());
8263
8264 let last_probe = read_last_probe(&paths).expect("last probe record");
8265 assert_eq!(last_probe.stage, "catalog_list");
8266 assert_eq!(last_probe.outcome, "timed_out");
8267 assert!(last_probe.elapsed_ms >= 2000);
8268
8269 let report = render_self_report(&paths).expect("self report");
8270 let status: Value = serde_json::from_str(&report).expect("status json");
8271 assert_eq!(status["last_probe"]["stage"], "catalog_list");
8272 assert_eq!(status["last_probe"]["outcome"], "timed_out");
8273 assert!(status["last_probe"]["elapsed_ms"].as_u64().unwrap() >= 2000);
8274 }
8275
8276 #[cfg(debug_assertions)] #[test]
8278 fn slow_daemon_catalog_list_delay_expired_fallback_refuses_naming_stage() {
8279 let daemon = SlowTestDaemon::spawn(SlowDaemonConfig {
8280 handshake_delay: Duration::ZERO,
8281 catalog_delay: Duration::from_secs(6),
8282 open_route_delay: Duration::ZERO,
8283 first_connection_only: false,
8284 });
8285 let temp = tempfile::tempdir().unwrap();
8286 let paths = StatePaths::from_root(temp.path().join("state"));
8287 let conn_file = temp.path().join("subc-connection.json");
8288 daemon.write_connection_file(&conn_file);
8289 let project = write_test_project_repo(temp.path(), "cortexkit/aft");
8290
8291 let manifest = v12_fixture_manifest();
8292 let now = unix_seconds();
8293 write_signed_manifest(&paths, manifest, now);
8294
8295 let mut cached_record =
8296 RungDetermination::r3(now - 301, 12, &test_rung_provenance()).record;
8297 cached_record.last_reachable_unix_secs = Some(now - 301);
8298 write_rung_record_silently(&paths, &cached_record);
8299
8300 let doc = json!({
8301 "subc": { "connection_file": conn_file.to_str().unwrap() }
8302 })
8303 .to_string();
8304
8305 let determination = determine_rung_from_doc(
8306 &paths,
8307 &project,
8308 now,
8309 Instant::now() + DISCOVERY_BUDGET,
8310 Some(&doc),
8311 );
8312 assert_eq!(determination.record.rung, Rung::R1);
8313 assert_eq!(
8314 determination.refusal_detail.as_deref(),
8315 Some(
8316 "governance probe timed out after 2000 ms at catalog_list (daemon may be busy; host load?) - this repository's actions are identity-governed, so the command was not run; retry"
8317 )
8318 );
8319
8320 let report = render_self_report(&paths).expect("self report");
8321 let status: Value = serde_json::from_str(&report).expect("status json");
8322 assert_eq!(status["last_probe"]["stage"], "catalog_list");
8323 assert_eq!(status["last_probe"]["outcome"], "timed_out");
8324 assert!(status["last_probe"]["elapsed_ms"].as_u64().unwrap() >= 2000);
8325 }
8326
8327 #[cfg(debug_assertions)] #[test]
8329 fn slow_daemon_open_route_delay_fallback_active_determines_r3_and_records_last_probe() {
8330 let daemon = SlowTestDaemon::spawn(SlowDaemonConfig {
8331 handshake_delay: Duration::ZERO,
8332 catalog_delay: Duration::ZERO,
8333 open_route_delay: Duration::from_secs(6),
8334 first_connection_only: false,
8335 });
8336 let temp = tempfile::tempdir().unwrap();
8337 let paths = StatePaths::from_root(temp.path().join("state"));
8338 let conn_file = temp.path().join("subc-connection.json");
8339 daemon.write_connection_file(&conn_file);
8340 let project = write_test_project_repo(temp.path(), "cortexkit/aft");
8341
8342 let manifest = v12_fixture_manifest();
8343 let now = unix_seconds();
8344 write_signed_manifest(&paths, manifest, now);
8345
8346 let mut cached_record = RungDetermination::r3(now - 30, 12, &test_rung_provenance()).record;
8347 cached_record.last_reachable_unix_secs = Some(now - 30);
8348 write_rung_record_silently(&paths, &cached_record);
8349
8350 let doc = json!({
8351 "subc": { "connection_file": conn_file.to_str().unwrap() }
8352 })
8353 .to_string();
8354
8355 let determination = determine_rung_from_doc(
8356 &paths,
8357 &project,
8358 now,
8359 Instant::now() + DISCOVERY_BUDGET,
8360 Some(&doc),
8361 );
8362 assert_eq!(determination.record.rung, Rung::R3);
8363 assert!(determination.refusal_detail.is_none());
8364
8365 let last_probe = read_last_probe(&paths).expect("last probe record");
8366 assert_eq!(last_probe.stage, "open_route");
8367 assert_eq!(last_probe.outcome, "timed_out");
8368 assert!(last_probe.elapsed_ms >= 2000);
8369
8370 let report = render_self_report(&paths).expect("self report");
8371 let status: Value = serde_json::from_str(&report).expect("status json");
8372 assert_eq!(status["last_probe"]["stage"], "open_route");
8373 assert_eq!(status["last_probe"]["outcome"], "timed_out");
8374 assert!(status["last_probe"]["elapsed_ms"].as_u64().unwrap() >= 2000);
8375 }
8376
8377 #[cfg(debug_assertions)] #[test]
8379 fn slow_daemon_open_route_delay_expired_fallback_refuses_naming_stage() {
8380 let daemon = SlowTestDaemon::spawn(SlowDaemonConfig {
8381 handshake_delay: Duration::ZERO,
8382 catalog_delay: Duration::ZERO,
8383 open_route_delay: Duration::from_secs(6),
8384 first_connection_only: false,
8385 });
8386 let temp = tempfile::tempdir().unwrap();
8387 let paths = StatePaths::from_root(temp.path().join("state"));
8388 let conn_file = temp.path().join("subc-connection.json");
8389 daemon.write_connection_file(&conn_file);
8390 let project = write_test_project_repo(temp.path(), "cortexkit/aft");
8391
8392 let manifest = v12_fixture_manifest();
8393 let now = unix_seconds();
8394 write_signed_manifest(&paths, manifest, now);
8395
8396 let mut cached_record =
8397 RungDetermination::r3(now - 301, 12, &test_rung_provenance()).record;
8398 cached_record.last_reachable_unix_secs = Some(now - 301);
8399 write_rung_record_silently(&paths, &cached_record);
8400
8401 let doc = json!({
8402 "subc": { "connection_file": conn_file.to_str().unwrap() }
8403 })
8404 .to_string();
8405
8406 let determination = determine_rung_from_doc(
8407 &paths,
8408 &project,
8409 now,
8410 Instant::now() + DISCOVERY_BUDGET,
8411 Some(&doc),
8412 );
8413 assert_eq!(determination.record.rung, Rung::R1);
8414 assert_eq!(
8415 determination.refusal_detail.as_deref(),
8416 Some("governance probe timed out after 2000 ms at open_route (daemon may be busy; host load?) - this repository's actions are identity-governed, so the command was not run; retry")
8417 );
8418
8419 let report = render_self_report(&paths).expect("self report");
8420 let status: Value = serde_json::from_str(&report).expect("status json");
8421 assert_eq!(status["last_probe"]["stage"], "open_route");
8422 assert_eq!(status["last_probe"]["outcome"], "timed_out");
8423 assert!(status["last_probe"]["elapsed_ms"].as_u64().unwrap() >= 2000);
8424 }
8425}
8426
8427#[cfg(test)]
8428mod github_read_mutation_tests {
8429 use super::invalidate_successful_github_read_mutation_at;
8435 use super::{GithubReadMutation, GovernedRequest, RouteOutcome};
8436 use crate::db::github_read_cache::GithubReadResourceKind;
8437
8438 use crate::db::github_read_cache::{
8439 lookup_github_read_cache_entry, upsert_github_read_cache_entry, GithubReadCacheKey,
8440 };
8441 use rusqlite::Connection;
8442
8443 fn github_read_mutation_request(
8444 action: &str,
8445 repository: &str,
8446 resource_number: i64,
8447 ) -> GovernedRequest {
8448 let mut target = serde_json::Map::new();
8449 target.insert(
8450 "number".to_string(),
8451 serde_json::Value::String(resource_number.to_string()),
8452 );
8453 GovernedRequest {
8454 action: action.to_string(),
8455 target,
8456 body: serde_json::Map::new(),
8457 repository: Some(repository.to_string()),
8458 manifest_version: 1,
8459 edit_last: false,
8460 }
8461 }
8462
8463 fn cache_key(repository: &str, resource_number: i64, identity: &str) -> GithubReadCacheKey {
8464 GithubReadCacheKey::new(
8465 GithubReadResourceKind::Issue,
8466 repository,
8467 resource_number,
8468 identity,
8469 )
8470 }
8471
8472 fn write_cached_issue(
8473 conn: &Connection,
8474 repository: &str,
8475 resource_number: i64,
8476 identity: &str,
8477 ) {
8478 upsert_github_read_cache_entry(
8479 conn,
8480 &cache_key(repository, resource_number, identity),
8481 "# Cached issue\n",
8482 1_000,
8483 )
8484 .expect("write cached issue");
8485 }
8486
8487 fn cached_issue_exists(
8488 conn: &Connection,
8489 repository: &str,
8490 resource_number: i64,
8491 identity: &str,
8492 ) -> bool {
8493 lookup_github_read_cache_entry(conn, &cache_key(repository, resource_number, identity))
8494 .expect("look up cached issue")
8495 .is_some()
8496 }
8497
8498 #[test]
8499 fn successful_structured_comment_mutation_invalidates_the_touched_issue_for_all_identities() {
8500 let storage = tempfile::tempdir().expect("create storage");
8501 let conn = crate::db::open(&storage.path().join("aft.db")).expect("open cache database");
8502 write_cached_issue(&conn, "cortexkit/aft", 42, "principal:alice");
8503 write_cached_issue(&conn, "cortexkit/aft", 42, "principal:bob");
8504
8505 let request = github_read_mutation_request("issue comment", "CortexKit/AFT", 42);
8506 let mutation = GithubReadMutation::from_governed_request(&request)
8507 .expect("structured issue comment has a cache resource");
8508 assert_eq!(mutation.normalized_repository, "cortexkit/aft");
8509 assert_eq!(mutation.resource_kind, GithubReadResourceKind::Issue);
8510 assert_eq!(mutation.resource_number, 42);
8511
8512 invalidate_successful_github_read_mutation_at(
8513 storage.path(),
8514 Some(&mutation),
8515 &RouteOutcome::Result("comment created".to_string()),
8516 );
8517
8518 assert!(
8519 !cached_issue_exists(&conn, "cortexkit/aft", 42, "principal:alice"),
8520 "a successful comment invalidates Alice's cached issue"
8521 );
8522 assert!(
8523 !cached_issue_exists(&conn, "cortexkit/aft", 42, "principal:bob"),
8524 "a successful comment invalidates every identity's cached issue"
8525 );
8526 }
8527
8528 #[test]
8529 fn failed_structured_comment_mutation_does_not_invalidate_the_touched_issue() {
8530 let storage = tempfile::tempdir().expect("create storage");
8531 let conn = crate::db::open(&storage.path().join("aft.db")).expect("open cache database");
8532 write_cached_issue(&conn, "cortexkit/aft", 42, "principal:alice");
8533
8534 let request = github_read_mutation_request("issue comment", "cortexkit/aft", 42);
8535 let mutation = GithubReadMutation::from_governed_request(&request)
8536 .expect("structured issue comment has a cache resource");
8537 invalidate_successful_github_read_mutation_at(
8538 storage.path(),
8539 Some(&mutation),
8540 &RouteOutcome::UpstreamError("comment rejected".to_string()),
8541 );
8542
8543 assert!(
8544 cached_issue_exists(&conn, "cortexkit/aft", 42, "principal:alice"),
8545 "a failed mutation must preserve the cached issue"
8546 );
8547 }
8548
8549 #[test]
8550 fn successful_mutation_for_a_different_issue_leaves_the_control_entry_intact() {
8551 let storage = tempfile::tempdir().expect("create storage");
8552 let conn = crate::db::open(&storage.path().join("aft.db")).expect("open cache database");
8553 write_cached_issue(&conn, "cortexkit/aft", 42, "principal:alice");
8554 write_cached_issue(&conn, "cortexkit/aft", 43, "principal:alice");
8555
8556 let request = github_read_mutation_request("issue comment", "cortexkit/aft", 43);
8557 let mutation = GithubReadMutation::from_governed_request(&request)
8558 .expect("structured issue comment has a cache resource");
8559 invalidate_successful_github_read_mutation_at(
8560 storage.path(),
8561 Some(&mutation),
8562 &RouteOutcome::Result("comment created".to_string()),
8563 );
8564
8565 assert!(
8566 cached_issue_exists(&conn, "cortexkit/aft", 42, "principal:alice"),
8567 "a mutation for another issue must not evict the control entry"
8568 );
8569 assert!(
8570 !cached_issue_exists(&conn, "cortexkit/aft", 43, "principal:alice"),
8571 "the successful mutation must still evict its own issue"
8572 );
8573 }
8574}