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 expected["repository"] = actual["repository"].clone();
5411 actual["metadata"]
5412 .as_object_mut()
5413 .expect("actual metadata object")
5414 .remove("agent_id");
5415 assert_eq!(
5416 actual["body"]
5417 .as_object()
5418 .expect("actual request body")
5419 .keys()
5420 .cloned()
5421 .collect::<Vec<_>>(),
5422 happy_body_fields,
5423 "consumer body fields drifted from producer shape"
5424 );
5425 assert_eq!(
5426 actual, expected,
5427 "consumer request drifted from producer shape"
5428 );
5429 assert_eq!(
5430 actual["edit_last"], true,
5431 "edit_last marker must be present"
5432 );
5433
5434 for case_name in ["edit_last_no_own_comment", "edit_last_unsupported_action"] {
5435 let code = vector_case(case_name)["response"]["refusal_code"]
5436 .as_str()
5437 .expect("producer refusal code");
5438 let response = json!({"outcome": "refusal", "refusal_code": code});
5439 let outcome = parse_governed_response(&serde_json::to_vec(&response).unwrap())
5440 .expect("producer refusal should parse");
5441 assert!(matches!(outcome, RouteOutcome::Refusal(ref actual) if actual == code));
5442 assert_eq!(
5443 RefusalCode::SeamRefusal.as_str(),
5444 "gh_shim_seam_refusal",
5445 "open-world holder refusal codes must use the seam refusal classification"
5446 );
5447 assert_eq!(
5448 seam_refusal_text(code),
5449 format!("governance seam refused the action: {code}"),
5450 "holder refusal code must pass through without remapping"
5451 );
5452 }
5453 }
5454
5455 #[test]
5461 fn signed_v10_reasoning_prose_survives_parse_and_cache_round_trip() {
5462 let signed_row = r#"{
5464 "tuple": "workflow run",
5465 "platform": ["macos", "linux"],
5466 "reasoning": "Administration: dispatching a workflow runs code but carries no public attribution surface; operator identity under explicit bypass."
5467 }"#;
5468 let parsed: TupleDecl = serde_json::from_str(signed_row).expect("signed row parses");
5469 let TupleDecl::Details { rationale, .. } = &parsed else {
5470 panic!("signed row must parse as a detailed declaration");
5471 };
5472 let prose = rationale
5473 .as_deref()
5474 .expect("signed `reasoning` prose must survive the parse, not default to None");
5475 assert!(
5476 prose.starts_with("Administration:"),
5477 "prose intact: {prose}"
5478 );
5479
5480 let cache_bytes = serde_json::to_string(&parsed).expect("cache serialization");
5484 let reparsed: TupleDecl = serde_json::from_str(&cache_bytes).expect("cache view reparses");
5485 let TupleDecl::Details {
5486 rationale: cached, ..
5487 } = &reparsed
5488 else {
5489 panic!("cache view must stay a detailed declaration");
5490 };
5491 assert_eq!(
5492 cached.as_deref(),
5493 Some(prose),
5494 "prose must survive the parse->serialize->parse cache round trip verbatim"
5495 );
5496 }
5497
5498 #[test]
5499 fn manifest_rejects_duplicate_tiers_and_empty_api_rationales() {
5500 let mut duplicate = fixture_manifest();
5501 duplicate
5502 .tiers
5503 .get_mut(&Tier::Admin)
5504 .unwrap()
5505 .push(TupleDecl::Details {
5506 tuple: "issue comment".to_string(),
5507 platform: vec!["macos".to_string()],
5508 api_match: None,
5509 rationale: None,
5510 });
5511 assert!(duplicate.validate().unwrap_err().contains("both"));
5512
5513 let mut empty_api = fixture_manifest();
5514 empty_api
5515 .tiers
5516 .get_mut(&Tier::Admin)
5517 .unwrap()
5518 .push(TupleDecl::Details {
5519 tuple: "api patch close".to_string(),
5520 platform: vec!["macos".to_string()],
5521 api_match: Some(String::new()),
5522 rationale: None,
5523 });
5524 assert!(empty_api.validate().unwrap_err().contains("rationale"));
5525
5526 let mut malformed_binding = fixture_manifest();
5527 malformed_binding.bindings.insert(
5528 "https://github.com/cortexkit/aft.git".to_string(),
5529 "alfonso-aft".to_string(),
5530 );
5531 assert!(malformed_binding
5532 .validate()
5533 .unwrap_err()
5534 .contains("canonical owner/name"));
5535 }
5536
5537 #[test]
5538 fn manifest_rejects_api_rules_for_unknown_host_platforms() {
5539 let mut manifest = branch_protection_manifest("PUT", Tier::Admin);
5540 manifest
5541 .api_rules
5542 .last_mut()
5543 .expect("branch protection API rule")
5544 .platform = vec!["github".to_string()];
5545 assert_eq!(
5546 manifest.validate().unwrap_err(),
5547 "api rule PUT /repos/*/*/branches/*/protection names unknown host platform github"
5548 );
5549 }
5550
5551 #[test]
5552 fn binding_keys_and_governed_session_identity_are_stable() {
5553 assert_eq!(
5554 canonical_repository_key("https://github.com/CortexKit/aft.git"),
5555 Some("cortexkit/aft".to_string())
5556 );
5557 assert_eq!(
5558 canonical_repository_key("git@github.com:cortexkit/aft.git"),
5559 Some("cortexkit/aft".to_string())
5560 );
5561 assert_eq!(gh_session_id("alfonso-aft"), "gh-shim:alfonso-aft");
5562
5563 let request = GovernedRequest {
5564 action: "issue comment".to_string(),
5565 target: Map::new(),
5566 body: Map::new(),
5567 repository: Some("cortexkit/aft".to_string()),
5568 manifest_version: 1,
5569 edit_last: false,
5570 };
5571 let determination = RungDetermination::r3(7, 1, &test_rung_provenance());
5572 let wire = governed_wire_request(&determination.record, "alfonso-aft", request);
5573 assert_eq!(wire["metadata"]["agent_id"], "alfonso-aft");
5574 assert_eq!(wire["metadata"]["pid"], std::process::id());
5575 }
5576
5577 #[test]
5578 fn manifest_rejects_repo_sections_that_add_or_lower_a_tuple() {
5579 let mut manifest = fixture_manifest();
5580 manifest.repository_sections.insert(
5581 "owner/repo".to_string(),
5582 RepositorySection {
5583 tiers: BTreeMap::from([(
5584 Tier::Mechanical,
5585 vec![TupleDecl::Details {
5586 tuple: "issue comment".to_string(),
5587 platform: vec!["macos".to_string()],
5588 api_match: None,
5589 rationale: None,
5590 }],
5591 )]),
5592 removed_tuples: Vec::new(),
5593 },
5594 );
5595 assert!(manifest.validate().unwrap_err().contains("lowers"));
5596
5597 manifest.repository_sections.insert(
5598 "owner/repo".to_string(),
5599 RepositorySection {
5600 tiers: BTreeMap::from([(
5601 Tier::Admin,
5602 vec![TupleDecl::Details {
5603 tuple: "workflow dispatch".to_string(),
5604 platform: vec!["macos".to_string()],
5605 api_match: None,
5606 rationale: None,
5607 }],
5608 )]),
5609 removed_tuples: Vec::new(),
5610 },
5611 );
5612 assert!(manifest.validate().unwrap_err().contains("adds"));
5613 }
5614
5615 #[cfg(debug_assertions)] #[test]
5617 fn signed_cache_rejects_tampering_and_old_schema_floor() {
5618 let directory = tempfile::tempdir().unwrap();
5619 let paths = StatePaths::from_root(directory.path().to_path_buf());
5620 let now = TEST_NOW;
5621 write_signed_manifest(&paths, fixture_manifest(), now);
5622 assert_eq!(load_manifest(&paths, now).unwrap().manifest_version, 1);
5623
5624 let mut value: Value = serde_json::from_slice(&fs::read(&paths.manifest).unwrap()).unwrap();
5627 let tampered =
5628 value["manifest_bytes"]
5629 .as_str()
5630 .unwrap()
5631 .replacen("issue view", "issue View", 1);
5632 value["manifest_bytes"] = Value::String(tampered);
5633 fs::write(&paths.manifest, serde_json::to_vec(&value).unwrap()).unwrap();
5634 assert!(matches!(
5635 load_manifest(&paths, now),
5636 Err(ManifestProblem::Invalid(_))
5637 ));
5638 assert_eq!(
5641 cached_manifest_report_at(&paths, now).diagnostics,
5642 vec![
5643 SelfReportDiagnostic::ManifestRegressed.as_str(),
5644 SelfReportDiagnostic::ManifestInvalid.as_str(),
5645 ]
5646 );
5647
5648 let mut below_floor = fixture_manifest();
5649 below_floor.schema_floor = 0;
5650 write_signed_manifest(&paths, below_floor, now);
5651 assert!(matches!(
5652 load_manifest(&paths, now),
5653 Err(ManifestProblem::BelowFloor { manifest_floor: 0 })
5654 ));
5655 }
5656
5657 #[test]
5658 fn no_verb_and_help_invocations_are_mechanical_on_a_governed_manifest() {
5659 let manifest = fixture_manifest();
5660 for args in [
5661 Vec::new(),
5662 vec![OsString::from("--version")],
5663 vec![OsString::from("--help")],
5664 vec![OsString::from("-h")],
5665 vec![OsString::from("help"), OsString::from("pr")],
5666 ] {
5667 assert!(
5668 matches!(
5669 classify(&args, &manifest, "macos"),
5670 Classification::Mechanical
5671 ),
5672 "expected passthrough classification for {args:?}"
5673 );
5674 }
5675 }
5676
5677 #[test]
5678 fn unmapped_get_and_actions_reads_are_mechanical_but_writes_remain_unclassified() {
5679 let mut manifest = fixture_manifest();
5680 manifest.api_rules.clear();
5681
5682 for args in [
5683 vec![
5684 OsString::from("api"),
5685 OsString::from("/repos/cortexkit/aft/actions/runs"),
5686 ],
5687 vec![
5688 OsString::from("api"),
5689 OsString::from("--method"),
5690 OsString::from("GET"),
5691 OsString::from("/repos/cortexkit/aft/actions/runs"),
5692 ],
5693 vec![
5694 OsString::from("api"),
5695 OsString::from("-X"),
5696 OsString::from("GET"),
5697 OsString::from("/repos/cortexkit/aft/actions/runs"),
5698 ],
5699 vec![OsString::from("run"), OsString::from("view")],
5700 vec![OsString::from("run"), OsString::from("list")],
5701 vec![OsString::from("run"), OsString::from("watch")],
5702 vec![OsString::from("workflow"), OsString::from("view")],
5703 vec![OsString::from("workflow"), OsString::from("list")],
5704 ] {
5705 assert!(
5706 matches!(
5707 classify(&args, &manifest, "macos"),
5708 Classification::Mechanical
5709 ),
5710 "expected read passthrough classification for {args:?}"
5711 );
5712 }
5713
5714 for args in [
5715 vec![
5716 OsString::from("api"),
5717 OsString::from("-X"),
5718 OsString::from("POST"),
5719 OsString::from("/repos/cortexkit/aft/actions/runs"),
5720 ],
5721 vec![
5722 OsString::from("api"),
5723 OsString::from("-f"),
5724 OsString::from("key=value"),
5725 OsString::from("/repos/cortexkit/aft/actions/runs"),
5726 ],
5727 ] {
5728 assert!(
5729 matches!(
5730 classify(&args, &manifest, "macos"),
5731 Classification::Unclassified
5732 ),
5733 "expected fail-closed classification for {args:?}"
5734 );
5735 }
5736 }
5737
5738 #[test]
5739 fn classification_is_allowlist_driven_without_a_write_heuristic() {
5740 let manifest = fixture_manifest();
5741 assert!(matches!(
5742 classify(
5743 &[OsString::from("issue"), OsString::from("view")],
5744 &manifest,
5745 "macos"
5746 ),
5747 Classification::Mechanical
5748 ));
5749 assert!(matches!(
5750 classify(
5751 &[OsString::from("api"), OsString::from("/repos/a/b")],
5752 &manifest,
5753 "macos"
5754 ),
5755 Classification::Mechanical
5756 ));
5757 assert!(matches!(
5758 classify(
5759 &[
5760 OsString::from("api"),
5761 OsString::from("--method=POST"),
5762 OsString::from("/repos/a/b")
5763 ],
5764 &manifest,
5765 "macos"
5766 ),
5767 Classification::Unclassified
5768 ));
5769 assert!(matches!(
5770 classify(
5771 &[
5772 OsString::from("api"),
5773 OsString::from("--method"),
5774 OsString::from("POST"),
5775 OsString::from("/repos/a/b")
5776 ],
5777 &manifest,
5778 "macos"
5779 ),
5780 Classification::Unclassified
5781 ));
5782 assert!(matches!(
5783 classify(
5784 &[OsString::from("alias"), OsString::from("set")],
5785 &manifest,
5786 "macos"
5787 ),
5788 Classification::Unclassified
5789 ));
5790 assert!(matches!(
5791 classify(
5792 &[
5793 OsString::from("alias"),
5794 OsString::from("set"),
5795 OsString::from("--write")
5796 ],
5797 &manifest,
5798 "macos"
5799 ),
5800 Classification::Unclassified
5801 ));
5802 }
5803
5804 #[test]
5805 fn canonical_repository_key_parses_github_remotes_and_rejects_foreign_hosts() {
5806 for remote in [
5807 "https://github.com/CortexKit/Aft",
5808 "https://github.com/cortexkit/aft.git",
5809 "https://github.com/cortexkit/aft/",
5810 "https://github.com/cortexkit/aft.git/",
5811 "git@github.com:cortexkit/aft.git",
5812 "ssh://git@github.com/cortexkit/aft",
5813 "cortexkit/aft",
5814 ] {
5815 assert_eq!(
5816 canonical_repository_key(remote).as_deref(),
5817 Some("cortexkit/aft")
5818 );
5819 }
5820 for remote in [
5821 "https://gitlab.com/cortexkit/aft.git",
5822 "ssh://git@gitlab.com/cortexkit/aft",
5823 "git@gitlab.com:cortexkit/aft.git",
5824 ] {
5825 assert_eq!(canonical_repository_key(remote), None);
5826 }
5827 }
5828
5829 #[test]
5830 fn invalid_repository_argument_refuses_before_seam_routing() {
5831 let manifest = fixture_manifest();
5832 let canonical = manifest.canonicalization["issue comment"].clone();
5833 let error = canonicalize_governed(
5834 &[
5835 OsString::from("--repo"),
5836 OsString::from("not/an/owner-name"),
5837 OsString::from("issue"),
5838 OsString::from("comment"),
5839 OsString::from("42"),
5840 OsString::from("--body"),
5841 OsString::from("hello"),
5842 ],
5843 "issue comment",
5844 &canonical,
5845 1,
5846 )
5847 .expect_err("an unparseable repository must abort before seam routing");
5848 assert_eq!(error, "repository not/an/owner-name is not owner/name");
5849 assert_eq!(
5850 refuse_governed_canonicalization(&error),
5851 REFUSAL_EXIT_STATUS,
5852 "a pre-routing governance refusal must have a nonzero exit status"
5853 );
5854 }
5855
5856 #[test]
5857 fn governed_canonicalization_normalizes_flags_and_explicit_repo_wins() {
5858 let manifest = fixture_manifest();
5859 let canonical = manifest.canonicalization["issue comment"].clone();
5860 let request = canonicalize_governed(
5861 &[
5862 OsString::from("--repo=owner/explicit"),
5863 OsString::from("issue"),
5864 OsString::from("comment"),
5865 OsString::from("42"),
5866 OsString::from("--body"),
5867 OsString::from("hello"),
5868 ],
5869 "issue comment",
5870 &canonical,
5871 1,
5872 )
5873 .unwrap();
5874 assert_eq!(request.repository.as_deref(), Some("owner/explicit"));
5875 assert_eq!(request.target["number"], "42");
5876 assert_eq!(request.body["body"], "hello");
5877 }
5878
5879 #[test]
5880 fn speech_body_file_forms_are_allowed_and_forward_fixture_contents() {
5881 let manifest = fixture_manifest();
5882 let body_file = fixture_dir().join("governed-speech.md");
5883 let expected_body = fs::read_to_string(&body_file).expect("speech body fixture");
5884
5885 for (expected_tuple, verb, subcommand, target) in [
5886 ("issue comment", "issue", "comment", "42"),
5887 ("pr comment", "pr", "comment", "7"),
5888 ("pr review", "pr", "review", "7"),
5889 ] {
5890 let canonical = manifest.canonicalization[expected_tuple].clone();
5891 for (flag, suffix) in [("--body-file", ""), ("-F", "")]
5892 .into_iter()
5893 .chain([("--body-file=", "equals"), ("-F=", "equals")])
5894 {
5895 let file_arg = if suffix.is_empty() {
5896 body_file.to_string_lossy().into_owned()
5897 } else {
5898 format!("{flag}{}", body_file.display())
5899 };
5900 let args = if suffix.is_empty() {
5901 vec![
5902 OsString::from(verb),
5903 OsString::from(subcommand),
5904 OsString::from(target),
5905 OsString::from(flag),
5906 OsString::from(file_arg),
5907 ]
5908 } else {
5909 vec![
5910 OsString::from(verb),
5911 OsString::from(subcommand),
5912 OsString::from(target),
5913 OsString::from(file_arg),
5914 ]
5915 };
5916 assert!(matches!(
5917 classify(&args, &manifest, "macos"),
5918 Classification::Governed { ref tuple, .. } if tuple == expected_tuple
5919 ));
5920 let request = canonicalize_governed(&args, expected_tuple, &canonical, 1)
5921 .expect("body-file form should canonicalize");
5922 let determination = RungDetermination::r3(1, 1, &test_rung_provenance());
5923 let wire = governed_wire_request(&determination.record, "agent-7", request);
5924 assert_eq!(wire["body"]["body"], expected_body);
5925 }
5926 }
5927
5928 let reaction = manifest.canonicalization["issue reaction"].clone();
5929 let error = canonicalize_governed(
5930 &[
5931 OsString::from("issue"),
5932 OsString::from("reaction"),
5933 OsString::from("42"),
5934 OsString::from("--body-file"),
5935 OsString::from(body_file),
5936 ],
5937 "issue reaction",
5938 &reaction,
5939 1,
5940 )
5941 .expect_err("body-file is speech-only vocabulary");
5942 assert_eq!(error, "undeclared flag --body-file");
5943 }
5944
5945 #[test]
5946 fn body_file_failures_refuse_instead_of_forwarding_an_empty_body() {
5947 let manifest = fixture_manifest();
5948 let canonical = manifest.canonicalization["pr comment"].clone();
5949 let directory = tempfile::tempdir().unwrap();
5950 let missing = directory.path().join("missing.md");
5951 let invalid = directory.path().join("invalid-utf8.md");
5952 fs::write(&invalid, [0xff, 0xfe]).unwrap();
5953
5954 for path in [missing, invalid] {
5955 let error = canonicalize_governed(
5956 &[
5957 OsString::from("pr"),
5958 OsString::from("comment"),
5959 OsString::from("7"),
5960 OsString::from("--body-file"),
5961 OsString::from(&path),
5962 ],
5963 "pr comment",
5964 &canonical,
5965 1,
5966 )
5967 .expect_err("an unreadable body file must refuse");
5968 assert!(error.starts_with("--body-file: could not read body file "));
5969 assert!(error.contains(&path.display().to_string()));
5970 assert_eq!(
5971 refuse_governed_canonicalization(&error),
5972 REFUSAL_EXIT_STATUS
5973 );
5974 }
5975 }
5976
5977 #[test]
5978 fn body_file_dash_reads_stdin_under_the_caller_permissions() {
5979 let mut stdin = std::io::Cursor::new("body supplied through stdin");
5980 assert_eq!(
5981 read_body_file_from(Path::new("-"), &mut stdin).unwrap(),
5982 "body supplied through stdin"
5983 );
5984 }
5985
5986 #[test]
5987 fn pr_review_action_and_body_matrix_reaches_the_governed_payload() {
5988 let manifest = fixture_manifest();
5989 let canonical = manifest.canonicalization["pr review"].clone();
5990 let body_file = fixture_dir().join("governed-speech.md");
5991 let expected_body = fs::read_to_string(&body_file).expect("speech body fixture");
5992
5993 for (action_flag, event) in [
5994 ("--approve", "APPROVE"),
5995 ("--comment", "COMMENT"),
5996 ("--request-changes", "REQUEST_CHANGES"),
5997 ] {
5998 for (body_flag, body_value) in [("--body", "inline review"), ("-b", "short review")] {
5999 let args = vec![
6000 OsString::from("pr"),
6001 OsString::from("review"),
6002 OsString::from("7"),
6003 OsString::from(action_flag),
6004 OsString::from(body_flag),
6005 OsString::from(body_value),
6006 ];
6007 assert!(matches!(
6008 classify(&args, &manifest, "macos"),
6009 Classification::Governed { ref tuple, .. } if tuple == "pr review"
6010 ));
6011 let request = canonicalize_governed(&args, "pr review", &canonical, 1)
6012 .expect("review action with inline body should canonicalize");
6013 assert_eq!(request.body["event"], event);
6014 assert_eq!(request.body["body"], body_value);
6015 }
6016
6017 let args = vec![
6018 OsString::from("pr"),
6019 OsString::from("review"),
6020 OsString::from("7"),
6021 OsString::from(action_flag),
6022 OsString::from("--body-file"),
6023 OsString::from(&body_file),
6024 ];
6025 let request = canonicalize_governed(&args, "pr review", &canonical, 1)
6026 .expect("review action with body-file should canonicalize");
6027 assert_eq!(request.body["event"], event);
6028 assert_eq!(request.body["body"], expected_body);
6029 }
6030
6031 for action_flag in ["--approve", "--request-changes"] {
6032 let args = vec![
6033 OsString::from("pr"),
6034 OsString::from("review"),
6035 OsString::from("7"),
6036 OsString::from(action_flag),
6037 ];
6038 let request = canonicalize_governed(&args, "pr review", &canonical, 1)
6039 .expect("approve/request-changes may omit review prose");
6040 assert_eq!(
6041 request.body["event"],
6042 action_flag
6043 .trim_start_matches("--")
6044 .to_ascii_uppercase()
6045 .replace('-', "_")
6046 );
6047 assert!(!request.body.contains_key("body"));
6048 }
6049
6050 let duplicate = [
6051 OsString::from("pr"),
6052 OsString::from("review"),
6053 OsString::from("7"),
6054 OsString::from("--approve"),
6055 OsString::from("--comment"),
6056 OsString::from("--body"),
6057 OsString::from("review"),
6058 ];
6059 assert_eq!(
6060 canonicalize_governed(&duplicate, "pr review", &canonical, 1).unwrap_err(),
6061 "pr review accepts only one of --approve, --comment, or --request-changes"
6062 );
6063 }
6064
6065 #[test]
6066 fn upstream_api_errors_fail_without_changing_success_status() {
6067 let error_response = json!({
6068 "outcome": "result",
6069 "gh_route_schema": 1,
6070 "result": {
6071 "status": 404,
6072 "error": {"message": "Not Found", "documentation_url": "https://docs.github.com"}
6073 }
6074 });
6075 let error_outcome =
6076 parse_governed_response(&serde_json::to_vec(&error_response).unwrap()).unwrap();
6077 let error_body = match error_outcome {
6078 RouteOutcome::UpstreamError(body) => body,
6079 other => panic!("expected upstream error, got {other:?}"),
6080 };
6081 assert!(error_body.contains("Not Found"));
6082 let directory = tempfile::tempdir().unwrap();
6083 let paths = StatePaths::from_root(directory.path().to_path_buf());
6084 let binding = AgentBinding {
6085 repo: "owner/repo".to_string(),
6086 agent_id: "agent-7".to_string(),
6087 };
6088 assert_eq!(
6089 governed_outcome_status(
6090 &paths,
6091 &binding,
6092 123,
6093 RouteOutcome::UpstreamError(error_body)
6094 ),
6095 UPSTREAM_FAILURE_EXIT_STATUS
6096 );
6097
6098 let success_response = json!({
6099 "outcome": "result",
6100 "gh_route_schema": 1,
6101 "result": {"status": 201, "url": "https://github.com/example"},
6102 "field_order": ["status", "url"]
6103 });
6104 let success_outcome =
6105 parse_governed_response(&serde_json::to_vec(&success_response).unwrap()).unwrap();
6106 assert!(matches!(&success_outcome, RouteOutcome::Result(_)));
6107 assert_eq!(
6108 governed_outcome_status(&paths, &binding, 123, success_outcome),
6109 0
6110 );
6111 }
6112
6113 #[test]
6114 fn governed_renderer_is_deterministic_for_scalars_arrays_and_escapes() {
6115 let result = json!({"message":"snowman ☃\n", "items":["a", 2], "ok":true});
6116 let order = vec![json!("ok"), json!("message"), json!("items")];
6117 assert_eq!(
6118 render_governed_response(&result, &order).unwrap(),
6119 "ok: true\nmessage: \"snowman ☃\\n\"\nitems:\n \"a\"\n 2\n"
6120 );
6121 assert!(matches!(
6122 render_governed_response(&json!("scalar"), &order),
6123 Err(RouteOutcome::SchemaMismatch(_))
6124 ));
6125 }
6126
6127 #[test]
6128 fn lower_rungs_are_cached_durably_but_r1_is_not_written() {
6129 let directory = tempfile::tempdir().unwrap();
6130 let paths = StatePaths::from_root(directory.path().to_path_buf());
6131 let determination = RungDetermination::r2(
6132 123,
6133 R2Reason::DaemonUnreachable,
6134 None,
6135 &test_rung_provenance(),
6136 );
6137 write_rung_record_silently(&paths, &determination.record);
6138 assert_eq!(load_rung_record(&paths).unwrap().rung, Rung::R2);
6139 assert!(!paths.root.join("r1-cache.json").exists());
6140 }
6141
6142 #[test]
6143 fn governed_bound_disposition_is_reason_independent_except_operator_hard_off() {
6144 const EXPECTED_RUNG_SHAPE_COUNT: usize = 11;
6145
6146 let directory = tempfile::tempdir().unwrap();
6147 let paths = StatePaths::from_root(directory.path().to_path_buf());
6148 let connection_file = directory.path().join("connection.json");
6149 fs::write(&connection_file, "present").unwrap();
6150 let missing_connection = directory.path().join("missing-connection.json");
6151 let disabled_doc = serde_json::json!({
6152 "gh_shim": { "enabled": false },
6153 "subc": { "connection_file": missing_connection }
6154 })
6155 .to_string();
6156 let unreachable_doc = serde_json::json!({
6157 "subc": { "connection_file": directory.path().join("still-missing.json") }
6158 })
6159 .to_string();
6160 let budget_doc = serde_json::json!({
6161 "subc": { "connection_file": connection_file }
6162 })
6163 .to_string();
6164 let future_deadline = || std::time::Instant::now() + DISCOVERY_BUDGET;
6165 let r1_cases = [
6166 (
6167 R1Reason::DisabledByConfig,
6168 determine_rung_from_doc(
6169 &paths,
6170 directory.path(),
6171 1,
6172 future_deadline(),
6173 Some(&disabled_doc),
6174 ),
6175 ),
6176 (
6177 R1Reason::AbsentOrUnparseable,
6178 determine_rung_from_doc(&paths, directory.path(), 1, future_deadline(), Some("{}")),
6179 ),
6180 (
6181 R1Reason::Unreachable,
6182 determine_rung_from_doc(
6183 &paths,
6184 directory.path(),
6185 1,
6186 future_deadline(),
6187 Some(&unreachable_doc),
6188 ),
6189 ),
6190 (
6191 R1Reason::DiscoveryBudgetExhausted,
6192 determine_rung_from_doc(
6193 &paths,
6194 directory.path(),
6195 1,
6196 std::time::Instant::now() - Duration::from_millis(1),
6197 Some(&budget_doc),
6198 ),
6199 ),
6200 ];
6201 assert_eq!(r1_cases.len(), R1Reason::ALL.len());
6202 for (reason, determination) in &r1_cases {
6203 assert_eq!(determination.record.rung, Rung::R1);
6204 assert_eq!(
6205 determination
6206 .record
6207 .inputs
6208 .get("connection_file")
6209 .map(String::as_str),
6210 Some(reason.diagnostic())
6211 );
6212 }
6213
6214 let mut determinations = r1_cases
6215 .into_iter()
6216 .map(|(_, determination)| determination)
6217 .collect::<Vec<_>>();
6218 determinations.extend(
6219 R2Reason::ALL
6220 .into_iter()
6221 .map(|reason| RungDetermination::r2(1, reason, Some(1), &test_rung_provenance())),
6222 );
6223 determinations.push(RungDetermination::r3(1, 1, &test_rung_provenance()));
6224 assert_eq!(
6225 R1Reason::ALL.len() + R2Reason::ALL.len() + 1,
6226 EXPECTED_RUNG_SHAPE_COUNT,
6227 "update the explicit disposition matrix when a rung shape is added"
6228 );
6229 assert_eq!(determinations.len(), EXPECTED_RUNG_SHAPE_COUNT);
6230
6231 let manifest = fixture_manifest();
6232 let governed_args = [
6233 OsString::from("issue"),
6234 OsString::from("comment"),
6235 OsString::from("42"),
6236 OsString::from("--body"),
6237 OsString::from("hello"),
6238 ];
6239 let admin_args = [
6240 OsString::from("pr"),
6241 OsString::from("merge"),
6242 OsString::from("42"),
6243 ];
6244 let mechanical_args = [
6245 OsString::from("issue"),
6246 OsString::from("view"),
6247 OsString::from("42"),
6248 ];
6249 let governed = classify(&governed_args, &manifest, "macos");
6250 let admin = classify(&admin_args, &manifest, "macos");
6251 let mechanical = classify(&mechanical_args, &manifest, "macos");
6252 let binding = || AgentBinding {
6253 repo: "cortexkit/aft".to_string(),
6254 agent_id: "alfonso-aft".to_string(),
6255 };
6256
6257 for determination in &determinations {
6258 let bound_governed = structural_governance_disposition(
6259 determination,
6260 &governed,
6261 Some(binding()),
6262 manifest.manifest_version,
6263 );
6264 if determination.operator_disabled {
6265 assert!(matches!(bound_governed, GovernanceDisposition::Delegate));
6266 } else if determination.record.rung == Rung::R3 {
6267 assert!(matches!(bound_governed, GovernanceDisposition::Ready));
6268 } else {
6269 assert!(matches!(
6270 bound_governed,
6271 GovernanceDisposition::Unavailable(_)
6272 ));
6273 }
6274
6275 assert!(matches!(
6276 structural_governance_disposition(
6277 determination,
6278 &governed,
6279 None,
6280 manifest.manifest_version,
6281 ),
6282 GovernanceDisposition::Delegate
6283 ));
6284 assert!(matches!(
6285 structural_governance_disposition(
6286 determination,
6287 &mechanical,
6288 Some(binding()),
6289 manifest.manifest_version,
6290 ),
6291 GovernanceDisposition::Delegate
6292 ));
6293
6294 if determination.record.rung != Rung::R3 && !determination.operator_disabled {
6295 assert!(matches!(
6296 structural_governance_disposition(
6297 determination,
6298 &admin,
6299 Some(binding()),
6300 manifest.manifest_version,
6301 ),
6302 GovernanceDisposition::Unavailable(_)
6303 ));
6304 }
6305 }
6306 }
6307
6308 #[test]
6309 fn ambient_credentials_on_a_bound_governed_invocation_refuse_identity_ambiguity() {
6310 let manifest = fixture_manifest();
6311 let governed = classify(
6312 &[
6313 OsString::from("issue"),
6314 OsString::from("comment"),
6315 OsString::from("42"),
6316 OsString::from("--body"),
6317 OsString::from("hello"),
6318 ],
6319 &manifest,
6320 "macos",
6321 );
6322 let determination = RungDetermination::r2(
6323 1,
6324 R2Reason::AgentCredentialsPresent,
6325 Some(manifest.manifest_version),
6326 &test_rung_provenance(),
6327 );
6328 let binding = AgentBinding {
6329 repo: "cortexkit/aft".to_string(),
6330 agent_id: "alfonso-aft".to_string(),
6331 };
6332
6333 assert!(matches!(
6334 structural_governance_disposition(
6335 &determination,
6336 &governed,
6337 Some(binding),
6338 manifest.manifest_version,
6339 ),
6340 GovernanceDisposition::Unavailable(_)
6341 ));
6342 }
6343
6344 #[cfg(unix)]
6345 #[test]
6346 fn resolved_image_identity_skips_a_shim_reached_through_a_symlinked_parent() {
6347 use std::os::unix::fs::symlink;
6348
6349 let directory = tempfile::tempdir().unwrap();
6350 let image = directory.path().join("aft");
6351 fs::write(&image, b"shim image").unwrap();
6352 let bin = directory.path().join("bin");
6353 fs::create_dir(&bin).unwrap();
6354 symlink(&image, bin.join("gh")).unwrap();
6355 let linked_parent = directory.path().join("linked-bin");
6356 symlink(&bin, &linked_parent).unwrap();
6357
6358 assert!(same_image(&linked_parent.join("gh"), &image));
6359 }
6360
6361 #[test]
6362 fn bypass_audit_is_visible_to_a_later_self_report_reader() {
6363 let directory = tempfile::tempdir().unwrap();
6364 let paths = StatePaths::from_root(directory.path().to_path_buf());
6365 append_bypass_audit(&paths, "issue close", Some("owner/repo"), 99).unwrap();
6366 let (records, error) = read_bypass_audit(&paths);
6367 assert!(error.is_none());
6368 let records = records.unwrap();
6369 assert_eq!(records.len(), 1);
6370 assert_eq!(records[0]["tuple"], "issue close");
6371 }
6372
6373 #[test]
6374 fn refusal_and_self_report_codes_are_separate_closed_sets() {
6375 assert_eq!(RefusalCode::ALL.len(), 13);
6376 assert!(RefusalCode::ALL
6377 .iter()
6378 .all(|code| code.as_str().starts_with("gh_shim_")));
6379 assert_eq!(
6380 RefusalCode::GovernanceUnavailable.as_str(),
6381 "gh_shim_governance_unavailable"
6382 );
6383 assert_eq!(
6384 GOVERNANCE_UNAVAILABLE_TEXT,
6385 "the governance daemon is unreachable and this repository's actions are identity-governed; retry after the daemon returns"
6386 );
6387 assert_eq!(SelfReportDiagnostic::ALL.len(), 6);
6388 assert!(SelfReportDiagnostic::ALL
6389 .iter()
6390 .all(|code| code.as_str().starts_with("gh_shim_status_")));
6391 assert!(SelfReportDiagnostic::ALL
6392 .iter()
6393 .all(|code| !code.as_str().contains("stale")));
6394 assert_eq!(REFUSAL_EXIT_STATUS, 86);
6395 }
6396
6397 #[test]
6398 fn v1_write_classification_accepts_only_the_reviewed_tuple_sets() {
6399 let manifest = fixture_manifest();
6400 for tuple in V1_GOVERNED_TUPLES {
6401 let args = tuple
6402 .split_whitespace()
6403 .map(OsString::from)
6404 .collect::<Vec<_>>();
6405 assert!(matches!(
6406 classify(&args, &manifest, "macos"),
6407 Classification::Governed { .. }
6408 ));
6409 }
6410 for tuple in V1_ADMIN_TUPLES {
6411 let args = tuple
6412 .split_whitespace()
6413 .map(OsString::from)
6414 .collect::<Vec<_>>();
6415 assert!(matches!(
6416 classify(&args, &manifest, "macos"),
6417 Classification::Admin { .. }
6418 ));
6419 }
6420 for args in [
6421 ["release", "publish"].as_slice(),
6422 ["issue", "create"].as_slice(),
6423 ["pr", "reopen"].as_slice(),
6424 ] {
6425 let args = args.iter().map(OsString::from).collect::<Vec<_>>();
6426 assert!(matches!(
6427 classify(&args, &manifest, "macos"),
6428 Classification::Unclassified
6429 ));
6430 }
6431 }
6432
6433 #[test]
6434 fn v13_release_maintenance_rows_allow_reviewed_flags_and_refuse_destructive_forms() {
6435 let mut manifest = branch_protection_manifest("PUT", Tier::Admin);
6441 manifest.api_rules.push(ApiRule {
6442 method: "DELETE".to_string(),
6443 path_glob: BRANCH_PROTECTION_PATH_GLOB.to_string(),
6444 tier: Tier::Admin,
6445 platform: vec!["macos".to_string(), "linux".to_string()],
6446 rationale: Some(
6447 "branch protection is a repository setting; operator identity, audited bypass"
6448 .to_string(),
6449 ),
6450 });
6451 let admin = manifest
6452 .tiers
6453 .get_mut(&Tier::Admin)
6454 .expect("v13 admin tier");
6455 for tuple in ["release edit", "release upload"] {
6456 admin.push(TupleDecl::Details {
6457 tuple: tuple.to_string(),
6458 platform: vec!["macos".to_string(), "linux".to_string()],
6459 api_match: None,
6460 rationale: None,
6461 });
6462 }
6463 manifest.validate().expect("valid v13 admin extensions");
6464 for method in ["PUT", "DELETE"] {
6465 let rule = manifest
6466 .api_rules
6467 .iter()
6468 .find(|rule| rule.method == method && rule.path_glob == BRANCH_PROTECTION_PATH_GLOB)
6469 .expect("v13 branch protection API rule");
6470 assert_eq!(rule.tier, Tier::Admin);
6471 assert_eq!(rule.platform, ["macos", "linux"]);
6472 assert_eq!(
6473 rule.rationale.as_deref(),
6474 Some(
6475 "branch protection is a repository setting; operator identity, audited bypass"
6476 )
6477 );
6478 }
6479
6480 for args in [
6481 vec![
6482 "release",
6483 "edit",
6484 "v1.2.3",
6485 "--notes",
6486 "notes",
6487 "--notes-file",
6488 "notes.md",
6489 "--title",
6490 "Dashboard",
6491 "--draft=false",
6492 "--latest",
6493 "--prerelease",
6494 ],
6495 vec!["release", "upload", "v1.2.3", "dashboard.json", "--clobber"],
6496 ] {
6497 let args = os_args(&args);
6498 assert!(matches!(
6499 classify(&args, &manifest, "macos"),
6500 Classification::Admin { ref tuple }
6501 if tuple == if args[1] == "edit" { "release edit" } else { "release upload" }
6502 ));
6503 }
6504 assert!(is_reviewed_admin_tuple(13, "release edit"));
6505 assert!(is_reviewed_admin_tuple(13, "release upload"));
6506 assert!(!is_reviewed_admin_tuple(12, "release edit"));
6507 assert!(!is_reviewed_admin_tuple(12, "release upload"));
6508
6509 for args in [
6510 os_args(&["release", "delete", "v1.2.3"]),
6511 os_args(&["release", "delete-asset", "v1.2.3", "dashboard.json"]),
6512 os_args(&["release", "edit", "v1.2.3", "--delete-tag"]),
6513 os_args(&["release", "upload", "v1.2.3", "--delete-asset"]),
6514 ] {
6515 assert!(matches!(
6516 classify(&args, &manifest, "macos"),
6517 Classification::Destructive
6518 ));
6519 assert_eq!(
6520 RefusalCode::DestructiveFlag.as_str(),
6521 "gh_shim_destructive_flag"
6522 );
6523 assert_eq!(
6524 refuse(
6525 RefusalCode::DestructiveFlag,
6526 "destructive GitHub operations are not available through the shim"
6527 ),
6528 REFUSAL_EXIT_STATUS
6529 );
6530 }
6531 }
6532
6533 #[test]
6534 fn admin_api_rule_classifies_field_bearing_branch_protection_puts() {
6535 let input_args = os_args(&[
6536 "api",
6537 "-X",
6538 "PUT",
6539 "/repos/o/r/branches/main/protection",
6540 "--input",
6541 "body.json",
6542 ]);
6543 let admin_manifest = branch_protection_manifest("PUT", Tier::Admin);
6544 assert!(matches!(
6545 classify(&input_args, &admin_manifest, "macos"),
6546 Classification::Admin { ref tuple } if tuple == BRANCH_PROTECTION_API_TUPLE
6547 ));
6548 assert!(matches!(
6549 classify(&input_args, &v12_fixture_manifest(), "macos"),
6550 Classification::Unclassified
6551 ));
6552 assert!(matches!(
6553 classify(
6554 &input_args,
6555 &branch_protection_manifest("PUT", Tier::Governed),
6556 "macos"
6557 ),
6558 Classification::Unclassified
6559 ));
6560
6561 let field_args = os_args(&[
6562 "api",
6563 "-X",
6564 "PUT",
6565 "/repos/o/r/branches/main/protection",
6566 "-f",
6567 "enforce_admins=true",
6568 ]);
6569 assert!(matches!(
6570 classify(&field_args, &admin_manifest, "macos"),
6571 Classification::Admin { ref tuple } if tuple == BRANCH_PROTECTION_API_TUPLE
6572 ));
6573 }
6574
6575 #[test]
6576 fn delete_branch_protection_is_admin_only_when_declared_and_not_destructive() {
6577 let args = os_args(&["api", "-X", "DELETE", "/repos/o/r/branches/main/protection"]);
6578 assert!(matches!(
6579 classify(
6580 &args,
6581 &branch_protection_manifest("DELETE", Tier::Admin),
6582 "macos"
6583 ),
6584 Classification::Admin { ref tuple }
6585 if tuple == "api:DELETE:/repos/*/*/branches/*/protection"
6586 ));
6587 assert!(matches!(
6588 classify(&args, &v12_fixture_manifest(), "macos"),
6589 Classification::Unclassified
6590 ));
6591 }
6592
6593 #[test]
6597 fn slashless_api_endpoint_classifies_like_the_declared_glob() {
6598 let manifest = branch_protection_manifest("PUT", Tier::Admin);
6599 for spelling in [
6600 "/repos/o/r/branches/main/protection",
6601 "repos/o/r/branches/main/protection",
6602 ] {
6603 let args = os_args(&["api", "-X", "PUT", spelling, "--input", "-"]);
6604 assert!(
6605 matches!(
6606 classify(&args, &manifest, "macos"),
6607 Classification::Admin { ref tuple } if tuple == BRANCH_PROTECTION_API_TUPLE
6608 ),
6609 "{spelling} must classify as the declared admin endpoint"
6610 );
6611 }
6612 let args = os_args(&["api", "-X", "PUT", "repos/o/r/topics", "--input", "-"]);
6614 assert!(matches!(
6615 classify(&args, &manifest, "macos"),
6616 Classification::Unclassified
6617 ));
6618 }
6619
6620 #[cfg(debug_assertions)] #[test]
6622 fn dev_signed_admin_api_dispatch_requires_bypass_and_audits_delegation() {
6623 use std::cell::Cell;
6624
6625 let _env_lock = crate::test_env::process_env_lock();
6626 let directory = tempfile::tempdir().expect("create admin dispatch state directory");
6627 let paths = StatePaths::from_root(directory.path().to_path_buf());
6628 let manifest = branch_protection_manifest("PUT", Tier::Admin);
6633 manifest.validate().expect("valid admin API manifest");
6634 write_signed_manifest(&paths, manifest, TEST_NOW);
6635 let manifest = load_manifest(&paths, TEST_NOW).expect("dev-signed manifest verifies");
6636 let args = os_args(&[
6637 "api",
6638 "-X",
6639 "PUT",
6640 "/repos/o/r/branches/main/protection",
6641 "--input",
6642 "body.json",
6643 ]);
6644 let rung =
6645 RungDetermination::r3(TEST_NOW, manifest.manifest_version, &test_rung_provenance())
6646 .record;
6647 let binding = AgentBinding {
6648 repo: "cortexkit/aft".to_string(),
6649 agent_id: "alfonso-aft".to_string(),
6650 };
6651
6652 {
6653 let _bypass = ScopedTestEnvVar::set("GH_SHIM_BYPASS", None);
6654 let status = dispatch_r3(
6655 &args,
6656 classify(&args, &manifest, "macos"),
6657 &manifest,
6658 &paths,
6659 &rung,
6660 &binding,
6661 TEST_NOW,
6662 |_| panic!("ADMIN request delegated without operator bypass"),
6663 );
6664 assert_eq!(status, REFUSAL_EXIT_STATUS);
6665 assert_eq!(RefusalCode::AdminTier.as_str(), "gh_shim_admin_tier");
6666 }
6667
6668 let delegated = Cell::new(0);
6669 {
6670 let _bypass = ScopedTestEnvVar::set("GH_SHIM_BYPASS", Some("operator"));
6671 let status = dispatch_r3(
6672 &args,
6673 classify(&args, &manifest, "macos"),
6674 &manifest,
6675 &paths,
6676 &rung,
6677 &binding,
6678 TEST_NOW,
6679 |delegated_args| {
6680 assert_eq!(delegated_args, args);
6681 delegated.set(delegated.get() + 1);
6682 73
6683 },
6684 );
6685 assert_eq!(status, 73);
6686 }
6687 assert_eq!(delegated.get(), 1);
6688 let (records, error) = read_bypass_audit(&paths);
6689 assert!(error.is_none());
6690 let records = records.expect("operator bypass audit records");
6691 assert_eq!(records.len(), 1);
6692 assert_eq!(records[0]["tuple"], BRANCH_PROTECTION_API_TUPLE);
6693 }
6694
6695 #[test]
6696 fn release_api_mutations_remain_unclassified_because_api_rules_are_get_only() {
6697 let manifest = v12_fixture_manifest();
6698 for args in [
6700 os_args(&["api", "-X", "PATCH", "repos/owner/repo/releases/42"]),
6701 os_args(&["api", "-X", "POST", "repos/owner/repo/releases/42/assets"]),
6702 ] {
6703 assert!(matches!(
6704 classify(&args, &manifest, "macos"),
6705 Classification::Unclassified
6706 ));
6707 }
6708 }
6709
6710 #[test]
6711 fn field_bearing_get_remains_unclassified_without_widening_the_mechanical_fallback() {
6712 let manifest = fixture_manifest();
6713 for field_flag in [
6714 "--field=name=value",
6715 "--raw-field=name=value",
6716 "--input=body.json",
6717 "-fname=value",
6718 "-Fname=value",
6719 ] {
6720 let args = vec![
6721 OsString::from("api"),
6722 OsString::from("/repos/owner/repo"),
6723 OsString::from(field_flag),
6724 ];
6725 assert!(matches!(
6726 classify(&args, &manifest, "macos"),
6727 Classification::Unclassified
6728 ));
6729 }
6730 assert!(matches!(
6731 classify(
6732 &os_args(&[
6733 "api",
6734 "/repos/o/r/branches/main/protection",
6735 "--input",
6736 "body.json"
6737 ]),
6738 &manifest,
6739 "macos"
6740 ),
6741 Classification::Unclassified
6742 ));
6743 assert!(matches!(
6744 classify(
6745 &os_args(&["api", "/repos/o/r/branches/main/protection"]),
6746 &manifest,
6747 "macos"
6748 ),
6749 Classification::Mechanical
6750 ));
6751 }
6752
6753 #[test]
6754 fn holder_refusals_preserve_any_string_code_and_reject_non_strings() {
6755 for code in FIXTURE_ACCEPTED_SEAM_REFUSAL_CODES {
6756 let response = json!({"outcome": "refusal", "refusal_code": code});
6757 let outcome = parse_governed_response(&serde_json::to_vec(&response).unwrap()).unwrap();
6758 assert!(matches!(outcome, RouteOutcome::Refusal(ref actual) if actual == code));
6759 assert_eq!(RefusalCode::SeamRefusal.as_str(), "gh_shim_seam_refusal");
6760 assert_eq!(
6761 seam_refusal_text(code),
6762 format!("governance seam refused the action: {code}")
6763 );
6764 assert_eq!(REFUSAL_EXIT_STATUS, 86);
6765 }
6766 let unknown = "quota_exhausted_v2";
6767 let response = json!({"outcome": "refusal", "refusal_code": unknown});
6768 let outcome = parse_governed_response(&serde_json::to_vec(&response).unwrap()).unwrap();
6769 assert!(matches!(outcome, RouteOutcome::Refusal(ref actual) if actual == unknown));
6770 assert_eq!(RefusalCode::SeamRefusal.as_str(), "gh_shim_seam_refusal");
6771 assert_eq!(
6772 seam_refusal_text(unknown),
6773 "governance seam refused the action: quota_exhausted_v2"
6774 );
6775 assert_eq!(REFUSAL_EXIT_STATUS, 86);
6776
6777 for response in [
6778 json!({"outcome": "refusal", "refusal_code": 7}),
6779 json!({"outcome": "refusal", "refusal_code": null}),
6780 json!({"outcome": "refusal"}),
6781 ] {
6782 assert!(matches!(
6783 parse_governed_response(&serde_json::to_vec(&response).unwrap()),
6784 Err(RouteOutcome::SchemaMismatch(_))
6785 ));
6786 }
6787 }
6788
6789 #[test]
6790 fn governed_self_report_transitions_are_durable_and_mechanical_classification_preserves_them() {
6791 let directory = tempfile::tempdir().unwrap();
6792 let paths = StatePaths::from_root(directory.path().to_path_buf());
6793 let binding = AgentBinding {
6794 repo: "owner/repo".to_string(),
6795 agent_id: "agent-7".to_string(),
6796 };
6797 write_seam_state(
6798 &paths,
6799 SeamState {
6800 bound_holder: None,
6801 agent_binding: Some(binding.clone()),
6802 last_seam_refusal: None,
6803 },
6804 )
6805 .unwrap();
6806 let report = build_self_report(&paths);
6807 assert_eq!(report.bound_holder, None);
6808 assert_eq!(report.agent_binding, Some(binding.clone()));
6809 assert_eq!(report.last_seam_refusal, None);
6810
6811 write_seam_state(
6812 &paths,
6813 SeamState {
6814 bound_holder: Some(ROUTING_HOLDER_MODULE_ID.to_string()),
6815 agent_binding: Some(binding.clone()),
6816 last_seam_refusal: Some(LastSeamRefusal {
6817 code: "rate_limited".to_string(),
6818 at_unix_secs: 77,
6819 }),
6820 },
6821 )
6822 .unwrap();
6823 let report = build_self_report(&paths);
6824 assert_eq!(
6825 report.bound_holder.as_deref(),
6826 Some(ROUTING_HOLDER_MODULE_ID)
6827 );
6828 assert_eq!(report.agent_binding, Some(binding.clone()));
6829 assert_eq!(
6830 report
6831 .last_seam_refusal
6832 .as_ref()
6833 .map(|refusal| refusal.code.as_str()),
6834 Some("rate_limited")
6835 );
6836
6837 write_seam_state(
6838 &paths,
6839 governed_seam_state(&paths, Some(ROUTING_HOLDER_MODULE_ID.to_string()), &binding),
6840 )
6841 .unwrap();
6842 assert_eq!(
6843 seam_state(&paths)
6844 .last_seam_refusal
6845 .as_ref()
6846 .map(|refusal| refusal.code.as_str()),
6847 Some("rate_limited")
6848 );
6849
6850 let mechanical = [OsString::from("issue"), OsString::from("view")];
6851 assert!(matches!(
6852 classify(&mechanical, &fixture_manifest(), "macos"),
6853 Classification::Mechanical
6854 ));
6855 assert_eq!(
6856 seam_state(&paths)
6857 .last_seam_refusal
6858 .as_ref()
6859 .map(|refusal| refusal.at_unix_secs),
6860 Some(77)
6861 );
6862 }
6863
6864 #[test]
6865 fn governed_self_report_persistence_failure_is_loud() {
6866 let directory = tempfile::tempdir().unwrap();
6867 let state_root = directory.path().join("not-a-directory");
6868 fs::write(&state_root, b"file").unwrap();
6869 let paths = StatePaths::from_root(state_root);
6870 assert!(write_seam_state(&paths, SeamState::default()).is_err());
6871 }
6872
6873 #[cfg(debug_assertions)] #[test]
6875 fn raw_bytes_round_trip_verifies_then_parses_from_the_fixture_envelope() {
6876 let envelope: SignedManifest = serde_json::from_str(include_str!(
6877 "../tests/fixtures/gh_shim/signed-envelope-v2.json"
6878 ))
6879 .expect("signed envelope fixture");
6880 assert_eq!(
6882 envelope.manifest_bytes,
6883 include_str!("../tests/fixtures/gh_shim/initial-manifest-v1.json")
6884 );
6885 let manifest = verify_manifest_signature(&envelope).expect("fixture signature verifies");
6887 assert_eq!(manifest.manifest_version, 1);
6888 assert_eq!(manifest.issued_at_unix_secs, FIXTURE_ISSUED_AT);
6889 manifest.validate().expect("fixture manifest validates");
6890 }
6891
6892 #[test]
6893 fn tampered_single_byte_fixture_fails_signature_verification() {
6894 let canonical: SignedManifest = serde_json::from_str(include_str!(
6895 "../tests/fixtures/gh_shim/signed-envelope-v2.json"
6896 ))
6897 .expect("canonical envelope fixture");
6898 let tampered: SignedManifest = serde_json::from_str(include_str!(
6899 "../tests/fixtures/gh_shim/signed-envelope-v2-tampered.json"
6900 ))
6901 .expect("tampered envelope fixture");
6902 assert_eq!(
6905 canonical.manifest_bytes.len(),
6906 tampered.manifest_bytes.len()
6907 );
6908 assert_eq!(
6909 canonical
6910 .manifest_bytes
6911 .bytes()
6912 .zip(tampered.manifest_bytes.bytes())
6913 .filter(|(left, right)| left != right)
6914 .count(),
6915 1
6916 );
6917 assert_eq!(canonical.signature, tampered.signature);
6918 assert!(matches!(
6919 verify_manifest_signature(&tampered),
6920 Err(ManifestProblem::Invalid(_))
6921 ));
6922 }
6923
6924 #[cfg(debug_assertions)] #[test]
6926 fn future_issued_at_fixture_is_refused_and_aged_fixture_serves_governed_classification() {
6927 let directory = tempfile::tempdir().unwrap();
6928 let paths = StatePaths::from_root(directory.path().to_path_buf());
6929
6930 write_envelope_fixture(
6931 &paths,
6932 include_str!("../tests/fixtures/gh_shim/signed-envelope-v2-future-issued-at.json"),
6933 );
6934 match load_manifest(&paths, TEST_NOW) {
6935 Err(ManifestProblem::Invalid(error)) => {
6936 assert!(error.contains("future"), "unexpected error: {error}")
6937 }
6938 other => panic!("expected future issued_at refusal, got {other:?}"),
6939 }
6940
6941 write_envelope_fixture(
6945 &paths,
6946 include_str!("../tests/fixtures/gh_shim/signed-envelope-v2-stale-issued-at.json"),
6947 );
6948 let ManifestResolution::Active(manifest) = resolve_manifest(&paths, TEST_NOW) else {
6949 panic!("expected the aged signed manifest to remain active");
6950 };
6951 assert_eq!(manifest.issued_at_unix_secs, FIXTURE_ISSUED_AT - 2_000_000);
6952 assert!(matches!(
6953 classify(
6954 &[
6955 OsString::from("issue"),
6956 OsString::from("comment"),
6957 OsString::from("42"),
6958 OsString::from("--body"),
6959 OsString::from("hello"),
6960 ],
6961 &manifest,
6962 "macos"
6963 ),
6964 Classification::Governed { tuple, .. } if tuple == "issue comment"
6965 ));
6966
6967 let report: Value =
6968 serde_json::from_str(&render_self_report(&paths).expect("self report serialization"))
6969 .expect("self report JSON");
6970 assert_eq!(
6971 report["cached_manifest"]["issued_at_unix_secs"],
6972 FIXTURE_ISSUED_AT - 2_000_000
6973 );
6974 }
6975
6976 #[test]
6977 fn standby_key_fixture_verifies_under_a_two_slot_trust_set_and_unknown_key_ids_are_refused() {
6978 let envelope: SignedManifest = serde_json::from_str(include_str!(
6979 "../tests/fixtures/gh_shim/signed-envelope-v2-standby-key.json"
6980 ))
6981 .expect("standby envelope fixture");
6982
6983 let standby = Ed25519KeyPair::from_seed_unchecked(&STANDBY_TEST_SEED).expect("standby key");
6984 assert_ne!(standby.public_key().as_ref(), DEV_MANIFEST_PUBLIC_KEY);
6985 let standby_public: &'static [u8] =
6986 Box::leak(standby.public_key().as_ref().to_vec().into_boxed_slice());
6987 let trust_set = [
6988 Some(ManifestTrustKey {
6989 key_id: DEV_MANIFEST_KEY_ID,
6990 public_key: &DEV_MANIFEST_PUBLIC_KEY,
6991 }),
6992 Some(ManifestTrustKey {
6993 key_id: DEV_STANDBY_MANIFEST_KEY_ID,
6994 public_key: standby_public,
6995 }),
6996 ];
6997
6998 let manifest =
7000 verify_manifest_signature_with(&envelope, &trust_set).expect("standby slot verifies");
7001 assert_eq!(
7002 manifest.manifest_version,
7003 fixture_manifest().manifest_version
7004 );
7005
7006 let mut unknown = envelope.clone();
7008 unknown.key_id = "gh-routing-unknown-key".to_string();
7009 assert!(matches!(
7010 verify_manifest_signature_with(&unknown, &trust_set),
7011 Err(ManifestProblem::Invalid(_))
7012 ));
7013 }
7014
7015 #[test]
7016 fn compiled_trust_set_shape_matches_the_two_slot_design() {
7017 let slots = compiled_manifest_trust_set();
7018 let live = slots[0].expect("live slot carries the production root");
7023 assert_eq!(live.key_id, PROD_MANIFEST_KEY_ID);
7024 assert_eq!(live.public_key, &PROD_MANIFEST_PUBLIC_KEY);
7025 #[cfg(debug_assertions)]
7026 {
7027 assert_eq!(slots.len(), 2);
7030 assert_eq!(slots[1].unwrap().key_id, DEV_MANIFEST_KEY_ID);
7031 }
7032 #[cfg(not(debug_assertions))]
7033 {
7034 assert_eq!(slots.len(), 2);
7037 assert!(slots[1].is_none());
7038 }
7039 }
7040
7041 #[test]
7042 fn envelope_v1_shapes_are_refused_by_the_v2_verifier() {
7043 let directory = tempfile::tempdir().unwrap();
7044 let paths = StatePaths::from_root(directory.path().to_path_buf());
7045 let manifest = fixture_manifest();
7046 let bytes = serde_json::to_vec(&manifest).unwrap();
7047 let key = Ed25519KeyPair::from_seed_unchecked(&TEST_SEED).unwrap();
7048 let signature = base64::engine::general_purpose::STANDARD.encode(key.sign(&bytes).as_ref());
7049
7050 let v1_object = json!({
7052 "artifact_id": MANIFEST_ARTIFACT_ID,
7053 "key_id": DEV_MANIFEST_KEY_ID,
7054 "fetched_at_unix_secs": TEST_NOW,
7055 "signature": signature,
7056 "manifest": serde_json::to_value(&manifest).unwrap(),
7057 });
7058 fs::write(&paths.manifest, serde_json::to_vec(&v1_object).unwrap()).unwrap();
7059 assert!(matches!(
7060 load_manifest(&paths, TEST_NOW),
7061 Err(ManifestProblem::Invalid(_))
7062 ));
7063
7064 let mut old_version = signed(&manifest, TEST_NOW);
7066 old_version.envelope_version = 1;
7067 fs::write(&paths.manifest, serde_json::to_vec(&old_version).unwrap()).unwrap();
7068 match load_manifest(&paths, TEST_NOW) {
7069 Err(ManifestProblem::Invalid(error)) => {
7070 assert!(
7071 error.contains("envelope version"),
7072 "unexpected error: {error}"
7073 )
7074 }
7075 other => panic!("expected envelope version refusal, got {other:?}"),
7076 }
7077 }
7078
7079 #[test]
7080 fn dormant_resolution_is_presence_based() {
7081 let directory = tempfile::tempdir().unwrap();
7082 let paths = StatePaths::from_root(directory.path().to_path_buf());
7083 assert!(matches!(
7085 resolve_manifest(&paths, TEST_NOW),
7086 ManifestResolution::Dormant
7087 ));
7088
7089 let untrusted = signed_with(
7093 &fixture_manifest(),
7094 TEST_NOW,
7095 &STANDBY_TEST_SEED,
7096 "gh-routing-unknown-key",
7097 );
7098 fs::write(&paths.manifest, serde_json::to_vec(&untrusted).unwrap()).unwrap();
7099 assert!(matches!(
7100 resolve_manifest(&paths, TEST_NOW),
7101 ManifestResolution::Invalid(ManifestProblem::Invalid(_))
7102 ));
7103 }
7104
7105 #[cfg(debug_assertions)] #[test]
7107 fn regressed_invalid_artifact_refuses_governed_and_admin_and_passes_mechanical() {
7108 let directory = tempfile::tempdir().unwrap();
7109 let paths = StatePaths::from_root(directory.path().to_path_buf());
7110 let now = TEST_NOW;
7111
7112 write_signed_manifest(&paths, fixture_manifest(), now);
7114 load_manifest(&paths, now).expect("canonical manifest verifies");
7115
7116 write_envelope_fixture(
7118 &paths,
7119 include_str!("../tests/fixtures/gh_shim/signed-envelope-v2-tampered.json"),
7120 );
7121
7122 let ManifestResolution::Regressed { manifest, problem } = resolve_manifest(&paths, now)
7125 else {
7126 panic!("expected the regressed arm");
7127 };
7128 let governed = [
7129 OsString::from("issue"),
7130 OsString::from("comment"),
7131 OsString::from("42"),
7132 OsString::from("--body"),
7133 OsString::from("hello"),
7134 ];
7135 assert!(matches!(
7136 regressed_disposition(&governed, &manifest, "macos", &problem),
7137 RegressedDisposition::Refuse {
7138 code: RefusalCode::ManifestRegressed,
7139 ..
7140 }
7141 ));
7142 let admin = [
7143 OsString::from("pr"),
7144 OsString::from("merge"),
7145 OsString::from("1"),
7146 ];
7147 assert!(matches!(
7148 regressed_disposition(&admin, &manifest, "macos", &problem),
7149 RegressedDisposition::Refuse {
7150 code: RefusalCode::ManifestRegressed,
7151 ..
7152 }
7153 ));
7154 let mechanical = [OsString::from("issue"), OsString::from("view")];
7155 assert!(matches!(
7156 regressed_disposition(&mechanical, &manifest, "macos", &problem),
7157 RegressedDisposition::Passthrough
7158 ));
7159 let undeclared = [OsString::from("alias"), OsString::from("set")];
7160 assert!(matches!(
7161 regressed_disposition(&undeclared, &manifest, "macos", &problem),
7162 RegressedDisposition::Refuse {
7163 code: RefusalCode::Unclassified,
7164 ..
7165 }
7166 ));
7167
7168 let report = cached_manifest_report_at(&paths, now);
7170 assert_eq!(report.state, Some("regressed"));
7171 assert_eq!(report.version, Some(1));
7172 assert_eq!(report.issued_at_unix_secs, Some(FIXTURE_ISSUED_AT));
7173 assert_eq!(
7174 report.diagnostics,
7175 vec![
7176 SelfReportDiagnostic::ManifestRegressed.as_str(),
7177 SelfReportDiagnostic::ManifestInvalid.as_str(),
7178 ]
7179 );
7180 }
7181
7182 #[cfg(debug_assertions)] #[test]
7184 fn self_report_exposes_manifest_and_rung_record_provenance() {
7185 let directory = tempfile::tempdir().unwrap();
7186 let paths = StatePaths::from_root(directory.path().to_path_buf());
7187 write_signed_manifest(&paths, fixture_manifest(), TEST_NOW);
7188
7189 let manifest_report = cached_manifest_report_at(&paths, TEST_NOW);
7190 assert_eq!(manifest_report.state, Some("valid"));
7191 assert_eq!(
7192 manifest_report.verified_by_key_id.as_deref(),
7193 Some(DEV_MANIFEST_KEY_ID)
7194 );
7195 assert_eq!(
7196 manifest_report.compiled_trust_set_key_ids,
7197 trust_set_key_ids(compiled_manifest_trust_set())
7198 );
7199
7200 let provenance = RungRecordProvenance {
7201 image_path: "/opt/cortexkit/aft-gh-shim".to_string(),
7202 version: "0.53.0-test".to_string(),
7203 repo_key: "cortexkit/aft".to_string(),
7204 };
7205 let determination =
7206 RungDetermination::r2(TEST_NOW, R2Reason::DaemonUnreachable, Some(1), &provenance);
7207 write_rung_record_silently(&paths, &determination.record);
7208 let fresh_rung = last_rung_report(&paths);
7209 assert_eq!(
7210 fresh_rung.recorded_by_image_path.as_deref(),
7211 Some("/opt/cortexkit/aft-gh-shim")
7212 );
7213 assert_eq!(
7214 fresh_rung.recorded_by_version.as_deref(),
7215 Some("0.53.0-test")
7216 );
7217 assert_eq!(
7218 fresh_rung.recorded_by_repo_key.as_deref(),
7219 Some("cortexkit/aft")
7220 );
7221
7222 fs::write(
7223 &paths.rung,
7224 serde_json::to_vec(&json!({
7225 "rung": "R2",
7226 "as_of_unix_secs": TEST_NOW,
7227 "inputs": { "daemon_unreachable": "failed" },
7228 "manifest_version": 1
7229 }))
7230 .unwrap(),
7231 )
7232 .unwrap();
7233 let legacy_rung = last_rung_report(&paths);
7234 assert_eq!(
7235 legacy_rung.recorded_by_image_path.as_deref(),
7236 Some(PRE_PROVENANCE_RECORD)
7237 );
7238 assert_eq!(
7239 legacy_rung.recorded_by_version.as_deref(),
7240 Some(PRE_PROVENANCE_RECORD)
7241 );
7242 assert_eq!(
7243 legacy_rung.recorded_by_repo_key.as_deref(),
7244 Some(PRE_PROVENANCE_RECORD)
7245 );
7246 }
7247
7248 #[test]
7249 fn trust_set_provenance_explains_image_level_untrusted_key_regression() {
7250 let directory = tempfile::tempdir().unwrap();
7251 let paths = StatePaths::from_root(directory.path().to_path_buf());
7252 write_signed_manifest(&paths, fixture_manifest(), TEST_NOW);
7253 let verifier_a = [Some(ManifestTrustKey {
7254 key_id: DEV_MANIFEST_KEY_ID,
7255 public_key: &DEV_MANIFEST_PUBLIC_KEY,
7256 })];
7257 let verifier_b = [Some(PROD_MANIFEST_TRUST_KEY)];
7258
7259 let report_a = cached_manifest_report_at_with(&paths, TEST_NOW, &verifier_a);
7260 assert_eq!(report_a.state, Some("valid"));
7261 assert_eq!(
7262 report_a.verified_by_key_id.as_deref(),
7263 Some(DEV_MANIFEST_KEY_ID)
7264 );
7265 assert_eq!(
7266 report_a.compiled_trust_set_key_ids,
7267 vec![DEV_MANIFEST_KEY_ID]
7268 );
7269
7270 let report_b = cached_manifest_report_at_with(&paths, TEST_NOW, &verifier_b);
7271 assert_eq!(report_b.state, Some("regressed"));
7272 assert_eq!(report_b.verified_by_key_id, None);
7273 assert_eq!(
7274 report_b.compiled_trust_set_key_ids,
7275 vec![PROD_MANIFEST_KEY_ID]
7276 );
7277 assert_eq!(
7278 report_b.diagnostic_guidance,
7279 Some(UNTRUSTED_MANIFEST_KEY_STEERING)
7280 );
7281
7282 let cached = read_last_valid_manifest(&paths).expect("verifier A wrote last-valid cache");
7283 let governed = [
7284 OsString::from("issue"),
7285 OsString::from("comment"),
7286 OsString::from("42"),
7287 OsString::from("--body"),
7288 OsString::from("hello"),
7289 ];
7290 let untrusted =
7291 ManifestProblem::Invalid(format!("untrusted manifest key id {DEV_MANIFEST_KEY_ID}"));
7292 let RegressedDisposition::Refuse { text, .. } =
7293 regressed_disposition(&governed, &cached.manifest, "macos", &untrusted)
7294 else {
7295 panic!("a governed command must refuse under verifier B");
7296 };
7297 assert!(text.ends_with(UNTRUSTED_MANIFEST_KEY_STEERING));
7298 }
7299
7300 #[cfg(debug_assertions)] #[test]
7302 fn version_high_water_refuses_rollbacks_and_status_reports_them() {
7303 let directory = tempfile::tempdir().unwrap();
7304 let paths = StatePaths::from_root(directory.path().to_path_buf());
7305
7306 write_envelope_fixture(
7308 &paths,
7309 include_str!("../tests/fixtures/gh_shim/signed-envelope-v2-version-2.json"),
7310 );
7311 assert_eq!(load_manifest(&paths, TEST_NOW).unwrap().manifest_version, 2);
7312 assert_eq!(version_high_water(&paths), 2);
7313
7314 write_envelope_fixture(
7317 &paths,
7318 include_str!("../tests/fixtures/gh_shim/signed-envelope-v2.json"),
7319 );
7320 assert!(matches!(
7321 load_manifest(&paths, TEST_NOW),
7322 Err(ManifestProblem::RolledBack {
7323 manifest_version: 1,
7324 newest_accepted: 2,
7325 })
7326 ));
7327 let report = cached_manifest_report_at(&paths, TEST_NOW);
7328 assert_eq!(
7329 report.diagnostics,
7330 vec![
7331 SelfReportDiagnostic::ManifestRegressed.as_str(),
7332 SelfReportDiagnostic::ManifestRollback.as_str(),
7333 ]
7334 );
7335 let document = render_self_report(&paths).expect("self report");
7337 assert!(document.contains(SelfReportDiagnostic::ManifestRollback.as_str()));
7338
7339 write_envelope_fixture(
7341 &paths,
7342 include_str!("../tests/fixtures/gh_shim/signed-envelope-v2-version-2.json"),
7343 );
7344 assert_eq!(load_manifest(&paths, TEST_NOW).unwrap().manifest_version, 2);
7345 }
7346
7347 fn fixture_dir() -> PathBuf {
7348 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/gh_shim")
7349 }
7350
7351 fn canonical_manifest_bytes() -> Vec<u8> {
7352 fs::read(fixture_dir().join("initial-manifest-v1.json"))
7353 .expect("canonical manifest fixture")
7354 }
7355
7356 fn envelope_json(envelope: &SignedManifest) -> Vec<u8> {
7357 let mut bytes = serde_json::to_vec_pretty(envelope).expect("envelope serialization");
7358 bytes.push(b'\n');
7359 bytes
7360 }
7361
7362 fn generate_envelope_fixtures() -> Vec<(String, Vec<u8>)> {
7366 let sign = |bytes: &[u8], seed: &[u8; 32]| {
7367 let key = Ed25519KeyPair::from_seed_unchecked(seed).expect("fixture key");
7368 base64::engine::general_purpose::STANDARD.encode(key.sign(bytes).as_ref())
7369 };
7370 let envelope = |key_id: &str, seed: &[u8; 32], manifest_bytes: String| {
7371 envelope_json(&SignedManifest {
7372 artifact_id: MANIFEST_ARTIFACT_ID.to_string(),
7373 envelope_version: ENVELOPE_VERSION,
7374 key_id: key_id.to_string(),
7375 fetched_at_unix_secs: FIXTURE_ISSUED_AT,
7376 signature: sign(manifest_bytes.as_bytes(), seed),
7377 manifest_bytes,
7378 })
7379 };
7380
7381 let canonical = canonical_manifest_bytes();
7382 let canonical_text = String::from_utf8(canonical.clone()).expect("UTF-8 manifest");
7383 let canonical_signature = sign(&canonical, &TEST_SEED);
7384
7385 let mut fixtures = Vec::new();
7386 fixtures.push((
7388 "signed-envelope-v2.json".to_string(),
7389 envelope_json(&SignedManifest {
7390 artifact_id: MANIFEST_ARTIFACT_ID.to_string(),
7391 envelope_version: ENVELOPE_VERSION,
7392 key_id: DEV_MANIFEST_KEY_ID.to_string(),
7393 fetched_at_unix_secs: FIXTURE_ISSUED_AT,
7394 signature: canonical_signature.clone(),
7395 manifest_bytes: canonical_text.clone(),
7396 }),
7397 ));
7398 let tampered = canonical_text.replacen("issue view", "issue View", 1);
7401 assert_ne!(tampered, canonical_text);
7402 fixtures.push((
7403 "signed-envelope-v2-tampered.json".to_string(),
7404 envelope_json(&SignedManifest {
7405 artifact_id: MANIFEST_ARTIFACT_ID.to_string(),
7406 envelope_version: ENVELOPE_VERSION,
7407 key_id: DEV_MANIFEST_KEY_ID.to_string(),
7408 fetched_at_unix_secs: FIXTURE_ISSUED_AT,
7409 signature: canonical_signature,
7410 manifest_bytes: tampered,
7411 }),
7412 ));
7413
7414 let mut variant = |name: &str, mutate: fn(&mut Manifest), seed: &[u8; 32], key_id: &str| {
7415 let mut manifest = fixture_manifest();
7416 mutate(&mut manifest);
7417 let bytes = serde_json::to_vec(&manifest).expect("variant manifest bytes");
7418 fixtures.push((
7419 name.to_string(),
7420 envelope(
7421 key_id,
7422 seed,
7423 String::from_utf8(bytes).expect("UTF-8 variant bytes"),
7424 ),
7425 ));
7426 };
7427 variant(
7428 "signed-envelope-v2-future-issued-at.json",
7429 |manifest| {
7430 manifest.issued_at_unix_secs =
7431 FIXTURE_ISSUED_AT + ISSUED_AT_FUTURE_SKEW.as_secs() + 3300;
7432 },
7433 &TEST_SEED,
7434 DEV_MANIFEST_KEY_ID,
7435 );
7436 variant(
7437 "signed-envelope-v2-stale-issued-at.json",
7438 |manifest| {
7439 manifest.issued_at_unix_secs = FIXTURE_ISSUED_AT - 2_000_000;
7440 },
7441 &TEST_SEED,
7442 DEV_MANIFEST_KEY_ID,
7443 );
7444 variant(
7445 "signed-envelope-v2-version-2.json",
7446 |manifest| {
7447 manifest.manifest_version = 2;
7448 },
7449 &TEST_SEED,
7450 DEV_MANIFEST_KEY_ID,
7451 );
7452 variant(
7453 "signed-envelope-v2-standby-key.json",
7454 |_manifest| {},
7455 &STANDBY_TEST_SEED,
7456 DEV_STANDBY_MANIFEST_KEY_ID,
7457 );
7458 fixtures
7459 }
7460
7461 #[test]
7462 fn signed_envelope_fixtures_match_their_generator() {
7463 let regen = std::env::var_os("AFT_GH_SHIM_REGEN").is_some();
7464 for (name, bytes) in generate_envelope_fixtures() {
7465 let path = fixture_dir().join(&name);
7466 if regen {
7467 fs::write(&path, &bytes).expect("write fixture");
7468 continue;
7469 }
7470 let disk = fs::read(&path)
7471 .unwrap_or_else(|error| panic!("fixture {name} is missing: {error}"));
7472 assert_eq!(
7473 disk, bytes,
7474 "fixture {name} drifted from its generator; rerun with AFT_GH_SHIM_REGEN=1"
7475 );
7476 }
7477 }
7478
7479 fn retained_files(paths: &StatePaths) -> Vec<PathBuf> {
7480 fs::read_dir(&paths.manifests_dir)
7481 .map(|entries| {
7482 entries
7483 .filter_map(Result::ok)
7484 .map(|entry| entry.path())
7485 .collect()
7486 })
7487 .unwrap_or_default()
7488 }
7489
7490 #[cfg(debug_assertions)] #[test]
7492 fn activation_retains_every_accepted_manifest_with_exact_bytes() {
7493 let directory = tempfile::tempdir().unwrap();
7494 let paths = StatePaths::from_root(directory.path().to_path_buf());
7495
7496 write_signed_manifest(&paths, v11_fixture_manifest(), TEST_NOW);
7498 assert_eq!(
7499 load_manifest(&paths, TEST_NOW).unwrap().manifest_version,
7500 11
7501 );
7502 write_signed_manifest(&paths, v12_fixture_manifest(), TEST_NOW);
7503 assert_eq!(
7504 load_manifest(&paths, TEST_NOW).unwrap().manifest_version,
7505 12
7506 );
7507
7508 let files = retained_files(&paths);
7510 assert_eq!(files.len(), 2);
7511
7512 let mut versions = BTreeSet::new();
7515 for file in &files {
7516 let record: RetainedManifest =
7517 serde_json::from_slice(&fs::read(file).unwrap()).expect("retained record");
7518 let envelope = SignedManifest {
7519 artifact_id: MANIFEST_ARTIFACT_ID.to_string(),
7520 envelope_version: ENVELOPE_VERSION,
7521 key_id: record.key_id.clone(),
7522 fetched_at_unix_secs: TEST_NOW,
7523 signature: record.signature.clone(),
7524 manifest_bytes: record.manifest_bytes.clone(),
7525 };
7526 let verified =
7527 verify_manifest_signature(&envelope).expect("retained signature re-verifies");
7528 versions.insert(verified.manifest_version);
7529 }
7530 assert_eq!(versions, BTreeSet::from([11, 12]));
7531
7532 let document = render_self_report(&paths).expect("self report");
7534 let value: Value = serde_json::from_str(&document).expect("self report JSON");
7535 assert_eq!(value["manifests_retained"], json!(2));
7536 assert_eq!(
7537 value["manifests_dir"],
7538 json!(paths.manifests_dir.to_string_lossy())
7539 );
7540 }
7541
7542 #[test]
7543 fn tampered_payload_is_not_retained() {
7544 let directory = tempfile::tempdir().unwrap();
7545 let paths = StatePaths::from_root(directory.path().to_path_buf());
7546
7547 write_envelope_fixture(
7550 &paths,
7551 include_str!("../tests/fixtures/gh_shim/signed-envelope-v2-tampered.json"),
7552 );
7553 assert!(load_manifest(&paths, TEST_NOW).is_err());
7554 assert!(retained_files(&paths).is_empty());
7555 }
7556
7557 #[test]
7558 fn version_mismatch_between_payload_and_filing_is_refused() {
7559 let directory = tempfile::tempdir().unwrap();
7560 let paths = StatePaths::from_root(directory.path().to_path_buf());
7561
7562 let envelope = signed(&v11_fixture_manifest(), TEST_NOW);
7565 retain_manifest(&paths, &envelope, 12);
7566
7567 assert!(retained_files(&paths).is_empty());
7568 }
7569
7570 #[test]
7571 fn same_name_different_bytes_is_refused_and_existing_file_untouched() {
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);
7579 let digest = Sha256::digest(envelope.manifest_bytes.as_bytes());
7580 let digest_hex = format!("{digest:x}");
7581 let destination = paths
7582 .manifests_dir
7583 .join(format!("v11-{}.json", &digest_hex[..16]));
7584 fs::create_dir_all(&paths.manifests_dir).unwrap();
7585 let original = b"{\"different\":\"bytes\"}".to_vec();
7586 fs::write(&destination, &original).unwrap();
7587
7588 retain_manifest(&paths, &envelope, 11);
7589
7590 assert_eq!(
7591 fs::read(&destination).unwrap(),
7592 original,
7593 "existing file must be untouched on collision"
7594 );
7595 }
7596
7597 #[cfg(unix)]
7598 #[cfg(debug_assertions)] #[test]
7600 fn retained_manifest_is_mode_0600() {
7601 use std::os::unix::fs::PermissionsExt;
7602
7603 let directory = tempfile::tempdir().unwrap();
7604 let paths = StatePaths::from_root(directory.path().to_path_buf());
7605
7606 write_signed_manifest(&paths, v11_fixture_manifest(), TEST_NOW);
7607 load_manifest(&paths, TEST_NOW).unwrap();
7608
7609 let files = retained_files(&paths);
7610 assert_eq!(files.len(), 1);
7611 let mode = fs::metadata(&files[0]).unwrap().permissions().mode();
7612 assert_eq!(mode & 0o777, 0o600);
7613 }
7614
7615 use subc_protocol::{Flags, Frame, FrameType, ModuleHelloAckBody, Priority, PROTOCOL_VERSION};
7616 use subc_transport::connection_file::{self, ConnectionInfo, Endpoint, SCHEMA_VERSION};
7617
7618 fn control_flags() -> Flags {
7619 Flags::new(false, Priority::Passive, false)
7620 }
7621
7622 struct SlowDaemonConfig {
7623 handshake_delay: Duration,
7624 catalog_delay: Duration,
7625 open_route_delay: Duration,
7626 first_connection_only: bool,
7631 }
7632
7633 struct SlowTestDaemon {
7634 port: u16,
7635 key: Vec<u8>,
7636 daemon_id: [u8; subc_transport::DAEMON_ID_LEN],
7637 shutdown_tx: Option<tokio::sync::oneshot::Sender<()>>,
7638 server_task: Option<std::thread::JoinHandle<()>>,
7639 }
7640
7641 impl SlowTestDaemon {
7642 fn spawn(config: SlowDaemonConfig) -> Self {
7643 let std_listener =
7644 std::net::TcpListener::bind("127.0.0.1:0").expect("bind test daemon");
7645 std_listener.set_nonblocking(true).expect("set nonblocking");
7646 let port = std_listener.local_addr().expect("local addr").port();
7647 let key = vec![0x42; subc_transport::KEY_LEN];
7648 let daemon_id = [0x24; subc_transport::DAEMON_ID_LEN];
7649 let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel::<()>();
7650
7651 let key_clone = key.clone();
7652 let daemon_id_clone = daemon_id;
7653
7654 let server_task = std::thread::spawn(move || {
7655 let rt = tokio::runtime::Builder::new_current_thread()
7656 .enable_io()
7657 .enable_time()
7658 .build()
7659 .expect("build daemon tokio runtime");
7660 rt.block_on(async move {
7661 let listener =
7662 tokio::net::TcpListener::from_std(std_listener).expect("tokio listener");
7663 let mut connection_count = 0usize;
7664 loop {
7665 tokio::select! {
7666 _ = &mut shutdown_rx => break,
7667 accepted = listener.accept() => {
7668 let Ok((mut stream, _)) = accepted else { break; };
7669 let k = key_clone.clone();
7670 let d = daemon_id_clone;
7671 let first_connection = connection_count == 0;
7672 connection_count += 1;
7673 let hs_delay = if config.first_connection_only && !first_connection {
7674 Duration::ZERO
7675 } else {
7676 config.handshake_delay
7677 };
7678 let cat_delay = if config.first_connection_only && !first_connection {
7679 Duration::ZERO
7680 } else {
7681 config.catalog_delay
7682 };
7683 let open_delay = if config.first_connection_only && !first_connection {
7684 Duration::ZERO
7685 } else {
7686 config.open_route_delay
7687 };
7688 tokio::spawn(async move {
7689 if hs_delay > Duration::ZERO {
7690 tokio::time::sleep(hs_delay).await;
7691 }
7692 if subc_transport::authenticate_server(
7693 &mut stream,
7694 &k,
7695 &d,
7696 "subc-test",
7697 Duration::from_secs(5),
7698 )
7699 .await
7700 .is_err()
7701 {
7702 return;
7703 }
7704
7705 loop {
7706 let frame = match subc_transport::read_frame(&mut stream).await {
7707 Ok(Some(frame)) => frame,
7708 _ => break,
7709 };
7710
7711 match frame.header.ty {
7712 FrameType::Hello => {
7713 let ack = Frame::build(
7714 FrameType::HelloAck,
7715 control_flags(),
7716 0,
7717 0,
7718 frame.header.corr,
7719 serde_json::to_vec(&ModuleHelloAckBody {
7720 negotiated_ver: PROTOCOL_VERSION,
7721 subc_ops: Vec::new(),
7722 subc_capabilities: Vec::new(),
7723 storage: None,
7724 })
7725 .expect("hello ack body"),
7726 )
7727 .expect("hello ack frame");
7728 if subc_transport::write_frame(&mut stream, &ack).await.is_err() {
7729 break;
7730 }
7731 }
7732 FrameType::Request => {
7733 let op: Option<String> = serde_json::from_slice::<Value>(&frame.body)
7734 .ok()
7735 .and_then(|v| v.get("op").and_then(Value::as_str).map(String::from));
7736
7737 if op.as_deref() == Some("catalog.list") {
7738 if !cat_delay.is_zero() {
7739 tokio::time::sleep(cat_delay).await;
7740 }
7741 let response_body = json!({
7742 "op": "catalog.list",
7743 "generation": 1,
7744 "modules": [{
7745 "module_id": "prefrontal-core",
7746 "module_version": "0.1.0",
7747 "roles": [{
7748 "role": "management_surface",
7749 "operations": [{ "name": "gh.route", "kind": "query" }],
7750 "config_schema": {},
7751 "observability": [],
7752 "identity_scope": ["project"]
7753 }],
7754 "control_ops": []
7755 }],
7756 "subc_ops": ["catalog.list", "route.open"]
7757 });
7758 let resp = Frame::build_with_version(
7759 frame.header.ver,
7760 FrameType::Response,
7761 frame.header.flags,
7762 frame.header.channel,
7763 frame.header.epoch,
7764 frame.header.corr,
7765 serde_json::to_vec(&response_body).expect("catalog json"),
7766 )
7767 .expect("catalog response frame");
7768 if subc_transport::write_frame(&mut stream, &resp).await.is_err() {
7769 break;
7770 }
7771 } else if op.as_deref() == Some("route.open") {
7772 if !open_delay.is_zero() {
7773 tokio::time::sleep(open_delay).await;
7774 }
7775 let response_body = json!({
7776 "op": "route.open",
7777 "route_channel": 42,
7778 "route_epoch": 1
7779 });
7780 let resp = Frame::build_with_version(
7781 frame.header.ver,
7782 FrameType::Response,
7783 frame.header.flags,
7784 frame.header.channel,
7785 frame.header.epoch,
7786 frame.header.corr,
7787 serde_json::to_vec(&response_body).expect("route open json"),
7788 )
7789 .expect("route open frame");
7790 if subc_transport::write_frame(&mut stream, &resp).await.is_err() {
7791 break;
7792 }
7793 } else if op.as_deref() == Some("route.close") {
7794 let response_body = json!({ "op": "route.close" });
7795 let resp = Frame::build_with_version(
7796 frame.header.ver,
7797 FrameType::Response,
7798 frame.header.flags,
7799 frame.header.channel,
7800 frame.header.epoch,
7801 frame.header.corr,
7802 serde_json::to_vec(&response_body).expect("route close json"),
7803 )
7804 .expect("route close frame");
7805 if subc_transport::write_frame(&mut stream, &resp).await.is_err() {
7806 break;
7807 }
7808 } else if frame.header.channel == 42 {
7809 let response_body = json!({
7810 "outcome": "result",
7811 "gh_route_schema": 1,
7812 "result": { "url": "https://github.com/cortexkit/aft/issues/1#issuecomment-123" },
7813 "field_order": ["url"]
7814 });
7815 let resp = Frame::build_with_version(
7816 frame.header.ver,
7817 FrameType::Response,
7818 frame.header.flags,
7819 frame.header.channel,
7820 frame.header.epoch,
7821 frame.header.corr,
7822 serde_json::to_vec(&response_body).expect("result json"),
7823 )
7824 .expect("result frame");
7825 if subc_transport::write_frame(&mut stream, &resp).await.is_err() {
7826 break;
7827 }
7828 }
7829 }
7830 _ => {}
7831 }
7832 }
7833 });
7834 }
7835 }
7836 }
7837 });
7838 });
7839
7840 Self {
7841 port,
7842 key,
7843 daemon_id,
7844 shutdown_tx: Some(shutdown_tx),
7845 server_task: Some(server_task),
7846 }
7847 }
7848
7849 fn write_connection_file(&self, path: &Path) {
7850 let conn = ConnectionInfo {
7851 schema: SCHEMA_VERSION,
7852 wire_version: Some(PROTOCOL_VERSION),
7853 endpoints: vec![Endpoint {
7854 host: "127.0.0.1".to_string(),
7855 port: self.port,
7856 }],
7857 key: self.key.clone(),
7858 daemon_id: self.daemon_id,
7859 pid: std::process::id(),
7860 daemon_ver: "gh-shim-test-daemon".to_string(),
7861 };
7862 connection_file::write_atomic(path, &conn).expect("write test daemon connection file");
7863 }
7864 }
7865
7866 impl Drop for SlowTestDaemon {
7867 fn drop(&mut self) {
7868 if let Some(tx) = self.shutdown_tx.take() {
7869 let _ = tx.send(());
7870 }
7871 let _ = std::net::TcpStream::connect(("127.0.0.1", self.port));
7872 if let Some(task) = self.server_task.take() {
7873 let _ = task.join();
7874 }
7875 }
7876 }
7877
7878 fn write_test_project_repo(root: &Path, repository: &str) -> PathBuf {
7879 let project = root.join("test-project");
7880 fs::create_dir_all(&project).expect("create project directory");
7881 Command::new("git")
7882 .args(["init", "--quiet"])
7883 .current_dir(&project)
7884 .status()
7885 .expect("init git repo");
7886 Command::new("git")
7887 .args([
7888 "remote",
7889 "add",
7890 "origin",
7891 &format!("https://github.com/{repository}.git"),
7892 ])
7893 .current_dir(&project)
7894 .status()
7895 .expect("add git origin");
7896 project
7897 }
7898
7899 const STAGE_TEST_DEADLINE: Duration = Duration::from_secs(2);
7907
7908 #[cfg(debug_assertions)] #[test]
7910 fn probe_exceeded_at_catalog_list_names_stage_and_budget_in_status_and_refusal() {
7911 let daemon = SlowTestDaemon::spawn(SlowDaemonConfig {
7912 handshake_delay: Duration::ZERO,
7913 catalog_delay: Duration::from_secs(6),
7914 open_route_delay: Duration::ZERO,
7915 first_connection_only: false,
7916 });
7917 let temp = tempfile::tempdir().unwrap();
7918 let paths = StatePaths::from_root(temp.path().join("state"));
7919 let conn_file = temp.path().join("subc-connection.json");
7920 daemon.write_connection_file(&conn_file);
7921 let project = write_test_project_repo(temp.path(), "cortexkit/aft");
7922
7923 let manifest = v12_fixture_manifest();
7924 let now = unix_seconds();
7925 write_signed_manifest(&paths, manifest, now);
7926
7927 let doc = json!({
7928 "subc": { "connection_file": conn_file.to_str().unwrap() }
7929 })
7930 .to_string();
7931
7932 let determination = determine_rung_from_doc(
7933 &paths,
7934 &project,
7935 now,
7936 Instant::now() + STAGE_TEST_DEADLINE,
7937 Some(&doc),
7938 );
7939 assert_eq!(determination.record.rung, Rung::R1);
7940 assert_eq!(
7941 determination.refusal_detail.as_deref(),
7942 Some(
7943 "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"
7944 )
7945 );
7946
7947 let last_probe = read_last_probe(&paths).expect("last probe record");
7948 assert_eq!(last_probe.stage, "catalog_list");
7949 assert_eq!(last_probe.outcome, "timed_out");
7950 assert!(last_probe.elapsed_ms >= 2000);
7951
7952 let report = render_self_report(&paths).expect("self report");
7953 let status: Value = serde_json::from_str(&report).expect("status json");
7954 assert_eq!(status["last_probe"]["stage"], "catalog_list");
7955 assert_eq!(status["last_probe"]["outcome"], "timed_out");
7956 assert!(status["last_probe"]["elapsed_ms"].as_u64().unwrap() >= 2000);
7957 }
7958
7959 #[cfg(debug_assertions)] #[test]
7961 fn probe_exceeded_at_open_route_names_stage_and_budget_in_status_and_refusal() {
7962 let daemon = SlowTestDaemon::spawn(SlowDaemonConfig {
7963 handshake_delay: Duration::ZERO,
7964 catalog_delay: Duration::ZERO,
7965 open_route_delay: Duration::from_secs(6),
7966 first_connection_only: false,
7967 });
7968 let temp = tempfile::tempdir().unwrap();
7969 let paths = StatePaths::from_root(temp.path().join("state"));
7970 let conn_file = temp.path().join("subc-connection.json");
7971 daemon.write_connection_file(&conn_file);
7972 let project = write_test_project_repo(temp.path(), "cortexkit/aft");
7973
7974 let manifest = v12_fixture_manifest();
7975 let now = unix_seconds();
7976 write_signed_manifest(&paths, manifest, now);
7977
7978 let doc = json!({
7979 "subc": { "connection_file": conn_file.to_str().unwrap() }
7980 })
7981 .to_string();
7982
7983 let determination = determine_rung_from_doc(
7984 &paths,
7985 &project,
7986 now,
7987 Instant::now() + STAGE_TEST_DEADLINE,
7988 Some(&doc),
7989 );
7990 assert_eq!(determination.record.rung, Rung::R1);
7991 assert_eq!(
7992 determination.refusal_detail.as_deref(),
7993 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")
7994 );
7995
7996 let last_probe = read_last_probe(&paths).expect("last probe record");
7997 assert_eq!(last_probe.stage, "open_route");
7998 assert_eq!(last_probe.outcome, "timed_out");
7999 assert!(last_probe.elapsed_ms >= 2000);
8000
8001 let report = render_self_report(&paths).expect("self report");
8002 let status: Value = serde_json::from_str(&report).expect("status json");
8003 assert_eq!(status["last_probe"]["stage"], "open_route");
8004 assert_eq!(status["last_probe"]["outcome"], "timed_out");
8005 assert!(status["last_probe"]["elapsed_ms"].as_u64().unwrap() >= 2000);
8006 }
8007
8008 #[cfg(debug_assertions)] #[test]
8010 fn discovery_retry_succeeds_when_only_the_first_attempt_times_out() {
8011 let daemon = SlowTestDaemon::spawn(SlowDaemonConfig {
8015 handshake_delay: Duration::ZERO,
8016 catalog_delay: Duration::from_secs(6),
8017 open_route_delay: Duration::ZERO,
8018 first_connection_only: true,
8019 });
8020 let temp = tempfile::tempdir().unwrap();
8021 let paths = StatePaths::from_root(temp.path().join("state"));
8022 let conn_file = temp.path().join("subc-connection.json");
8023 daemon.write_connection_file(&conn_file);
8024 let project = write_test_project_repo(temp.path(), "cortexkit/aft");
8025
8026 let manifest = v12_fixture_manifest();
8027 let now = unix_seconds();
8028 write_signed_manifest(&paths, manifest, now);
8029
8030 let doc = json!({
8031 "subc": { "connection_file": conn_file.to_str().unwrap() }
8032 })
8033 .to_string();
8034
8035 let determination = determine_rung_from_doc(
8036 &paths,
8037 &project,
8038 now,
8039 Instant::now() + STAGE_TEST_DEADLINE,
8040 Some(&doc),
8041 );
8042 assert_eq!(determination.record.rung, Rung::R3);
8043 assert!(determination.refusal_detail.is_none());
8044
8045 let last_probe = read_last_probe(&paths).expect("last probe record");
8047 assert_eq!(last_probe.outcome, "ready");
8048 }
8049
8050 #[cfg(debug_assertions)] #[test]
8052 fn probe_connect_refused_keeps_unreachable_outcome() {
8053 let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("reserve port");
8054 let port = listener.local_addr().expect("port").port();
8055 drop(listener);
8056
8057 let temp = tempfile::tempdir().unwrap();
8058 let paths = StatePaths::from_root(temp.path().join("state"));
8059 let conn_file = temp.path().join("subc-connection.json");
8060 let conn = ConnectionInfo {
8061 schema: SCHEMA_VERSION,
8062 wire_version: Some(PROTOCOL_VERSION),
8063 endpoints: vec![Endpoint {
8064 host: "127.0.0.1".to_string(),
8065 port,
8066 }],
8067 key: vec![0x42; subc_transport::KEY_LEN],
8068 daemon_id: [0x24; subc_transport::DAEMON_ID_LEN],
8069 pid: std::process::id(),
8070 daemon_ver: "dead".to_string(),
8071 };
8072 connection_file::write_atomic(&conn_file, &conn).expect("write connection file");
8073 let project = write_test_project_repo(temp.path(), "cortexkit/aft");
8074
8075 let manifest = v12_fixture_manifest();
8076 let now = unix_seconds();
8077 write_signed_manifest(&paths, manifest, now);
8078
8079 let doc = json!({
8080 "subc": { "connection_file": conn_file.to_str().unwrap() }
8081 })
8082 .to_string();
8083
8084 let determination = determine_rung_from_doc(
8085 &paths,
8086 &project,
8087 now,
8088 Instant::now() + DISCOVERY_BUDGET,
8089 Some(&doc),
8090 );
8091 assert_eq!(determination.record.rung, Rung::R2);
8092 assert_eq!(determination.refusal_detail, None);
8093
8094 let last_probe = read_last_probe(&paths).expect("last probe record");
8095 assert_eq!(last_probe.stage, "connect");
8096 #[cfg(windows)]
8097 assert!(
8098 last_probe.outcome == "timed_out" || last_probe.outcome == "unreachable",
8099 "windows connect-refused probe outcome was {}",
8100 last_probe.outcome
8101 );
8102 #[cfg(not(windows))]
8103 assert_eq!(last_probe.outcome, "unreachable");
8104
8105 let report = render_self_report(&paths).expect("self report");
8106 let status: Value = serde_json::from_str(&report).expect("status json");
8107 assert_eq!(status["last_probe"]["stage"], "connect");
8108 #[cfg(windows)]
8109 assert!(
8110 status["last_probe"]["outcome"] == "timed_out"
8111 || status["last_probe"]["outcome"] == "unreachable"
8112 );
8113 #[cfg(not(windows))]
8114 assert_eq!(status["last_probe"]["outcome"], "unreachable");
8115 }
8116
8117 #[cfg(debug_assertions)] #[test]
8119 fn probe_connect_stage_budget_exceeded_determines_r2_and_records_last_probe() {
8120 let daemon = SlowTestDaemon::spawn(SlowDaemonConfig {
8121 handshake_delay: Duration::from_secs(6),
8122 catalog_delay: Duration::ZERO,
8123 open_route_delay: Duration::ZERO,
8124 first_connection_only: false,
8125 });
8126 let temp = tempfile::tempdir().unwrap();
8127 let paths = StatePaths::from_root(temp.path().join("state"));
8128 let conn_file = temp.path().join("subc-connection.json");
8129 daemon.write_connection_file(&conn_file);
8130 let project = write_test_project_repo(temp.path(), "cortexkit/aft");
8131
8132 let manifest = v12_fixture_manifest();
8133 let now = unix_seconds();
8134 write_signed_manifest(&paths, manifest, now);
8135
8136 let doc = json!({
8137 "subc": { "connection_file": conn_file.to_str().unwrap() }
8138 })
8139 .to_string();
8140
8141 let determination = determine_rung_from_doc(
8142 &paths,
8143 &project,
8144 now,
8145 Instant::now() + DISCOVERY_BUDGET,
8146 Some(&doc),
8147 );
8148 assert_eq!(determination.record.rung, Rung::R2);
8149 assert_eq!(determination.refusal_detail, None);
8150
8151 let last_probe = read_last_probe(&paths).expect("last probe record");
8152 assert_eq!(last_probe.stage, "connect");
8153 assert_eq!(last_probe.outcome, "timed_out");
8154 assert!(last_probe.elapsed_ms >= 2000);
8155
8156 let report = render_self_report(&paths).expect("self report");
8157 let status: Value = serde_json::from_str(&report).expect("status json");
8158 assert_eq!(status["last_probe"]["stage"], "connect");
8159 assert_eq!(status["last_probe"]["outcome"], "timed_out");
8160 assert!(status["last_probe"]["elapsed_ms"].as_u64().unwrap() >= 2000);
8161 }
8162
8163 #[cfg(debug_assertions)] #[test]
8165 fn slow_daemon_connect_delay_fallback_active_determines_r3_and_records_last_probe() {
8166 let daemon = SlowTestDaemon::spawn(SlowDaemonConfig {
8167 handshake_delay: Duration::from_secs(6),
8168 catalog_delay: Duration::ZERO,
8169 open_route_delay: Duration::ZERO,
8170 first_connection_only: false,
8171 });
8172 let temp = tempfile::tempdir().unwrap();
8173 let paths = StatePaths::from_root(temp.path().join("state"));
8174 let conn_file = temp.path().join("subc-connection.json");
8175 daemon.write_connection_file(&conn_file);
8176 let project = write_test_project_repo(temp.path(), "cortexkit/aft");
8177
8178 let manifest = v12_fixture_manifest();
8179 let now = unix_seconds();
8180 write_signed_manifest(&paths, manifest, now);
8181
8182 let mut cached_record = RungDetermination::r3(now - 30, 12, &test_rung_provenance()).record;
8183 cached_record.last_reachable_unix_secs = Some(now - 30);
8184 write_rung_record_silently(&paths, &cached_record);
8185
8186 let doc = json!({
8187 "subc": { "connection_file": conn_file.to_str().unwrap() }
8188 })
8189 .to_string();
8190
8191 let determination = determine_rung_from_doc(
8192 &paths,
8193 &project,
8194 now,
8195 Instant::now() + DISCOVERY_BUDGET,
8196 Some(&doc),
8197 );
8198 assert_eq!(determination.record.rung, Rung::R3);
8199 assert!(determination.refusal_detail.is_none());
8200
8201 let last_probe = read_last_probe(&paths).expect("last probe record");
8202 assert_eq!(last_probe.stage, "connect");
8203 assert_eq!(last_probe.outcome, "timed_out");
8204 assert!(last_probe.elapsed_ms >= 2000);
8205
8206 let report = render_self_report(&paths).expect("self report");
8207 let status: Value = serde_json::from_str(&report).expect("status json");
8208 assert_eq!(status["last_probe"]["stage"], "connect");
8209 assert_eq!(status["last_probe"]["outcome"], "timed_out");
8210 assert!(status["last_probe"]["elapsed_ms"].as_u64().unwrap() >= 2000);
8211 }
8212
8213 #[cfg(debug_assertions)] #[test]
8215 fn slow_daemon_catalog_list_delay_fallback_active_determines_r3_and_records_last_probe() {
8216 let daemon = SlowTestDaemon::spawn(SlowDaemonConfig {
8217 handshake_delay: Duration::ZERO,
8218 catalog_delay: Duration::from_secs(6),
8219 open_route_delay: Duration::ZERO,
8220 first_connection_only: false,
8221 });
8222 let temp = tempfile::tempdir().unwrap();
8223 let paths = StatePaths::from_root(temp.path().join("state"));
8224 let conn_file = temp.path().join("subc-connection.json");
8225 daemon.write_connection_file(&conn_file);
8226 let project = write_test_project_repo(temp.path(), "cortexkit/aft");
8227
8228 let manifest = v12_fixture_manifest();
8229 let now = unix_seconds();
8230 write_signed_manifest(&paths, manifest, now);
8231
8232 let mut cached_record = RungDetermination::r3(now - 30, 12, &test_rung_provenance()).record;
8233 cached_record.last_reachable_unix_secs = Some(now - 30);
8234 write_rung_record_silently(&paths, &cached_record);
8235
8236 let doc = json!({
8237 "subc": { "connection_file": conn_file.to_str().unwrap() }
8238 })
8239 .to_string();
8240
8241 let determination = determine_rung_from_doc(
8242 &paths,
8243 &project,
8244 now,
8245 Instant::now() + DISCOVERY_BUDGET,
8246 Some(&doc),
8247 );
8248 assert_eq!(determination.record.rung, Rung::R3);
8249 assert!(determination.refusal_detail.is_none());
8250
8251 let last_probe = read_last_probe(&paths).expect("last probe record");
8252 assert_eq!(last_probe.stage, "catalog_list");
8253 assert_eq!(last_probe.outcome, "timed_out");
8254 assert!(last_probe.elapsed_ms >= 2000);
8255
8256 let report = render_self_report(&paths).expect("self report");
8257 let status: Value = serde_json::from_str(&report).expect("status json");
8258 assert_eq!(status["last_probe"]["stage"], "catalog_list");
8259 assert_eq!(status["last_probe"]["outcome"], "timed_out");
8260 assert!(status["last_probe"]["elapsed_ms"].as_u64().unwrap() >= 2000);
8261 }
8262
8263 #[cfg(debug_assertions)] #[test]
8265 fn slow_daemon_catalog_list_delay_expired_fallback_refuses_naming_stage() {
8266 let daemon = SlowTestDaemon::spawn(SlowDaemonConfig {
8267 handshake_delay: Duration::ZERO,
8268 catalog_delay: Duration::from_secs(6),
8269 open_route_delay: Duration::ZERO,
8270 first_connection_only: false,
8271 });
8272 let temp = tempfile::tempdir().unwrap();
8273 let paths = StatePaths::from_root(temp.path().join("state"));
8274 let conn_file = temp.path().join("subc-connection.json");
8275 daemon.write_connection_file(&conn_file);
8276 let project = write_test_project_repo(temp.path(), "cortexkit/aft");
8277
8278 let manifest = v12_fixture_manifest();
8279 let now = unix_seconds();
8280 write_signed_manifest(&paths, manifest, now);
8281
8282 let mut cached_record =
8283 RungDetermination::r3(now - 301, 12, &test_rung_provenance()).record;
8284 cached_record.last_reachable_unix_secs = Some(now - 301);
8285 write_rung_record_silently(&paths, &cached_record);
8286
8287 let doc = json!({
8288 "subc": { "connection_file": conn_file.to_str().unwrap() }
8289 })
8290 .to_string();
8291
8292 let determination = determine_rung_from_doc(
8293 &paths,
8294 &project,
8295 now,
8296 Instant::now() + DISCOVERY_BUDGET,
8297 Some(&doc),
8298 );
8299 assert_eq!(determination.record.rung, Rung::R1);
8300 assert_eq!(
8301 determination.refusal_detail.as_deref(),
8302 Some(
8303 "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"
8304 )
8305 );
8306
8307 let report = render_self_report(&paths).expect("self report");
8308 let status: Value = serde_json::from_str(&report).expect("status json");
8309 assert_eq!(status["last_probe"]["stage"], "catalog_list");
8310 assert_eq!(status["last_probe"]["outcome"], "timed_out");
8311 assert!(status["last_probe"]["elapsed_ms"].as_u64().unwrap() >= 2000);
8312 }
8313
8314 #[cfg(debug_assertions)] #[test]
8316 fn slow_daemon_open_route_delay_fallback_active_determines_r3_and_records_last_probe() {
8317 let daemon = SlowTestDaemon::spawn(SlowDaemonConfig {
8318 handshake_delay: Duration::ZERO,
8319 catalog_delay: Duration::ZERO,
8320 open_route_delay: Duration::from_secs(6),
8321 first_connection_only: false,
8322 });
8323 let temp = tempfile::tempdir().unwrap();
8324 let paths = StatePaths::from_root(temp.path().join("state"));
8325 let conn_file = temp.path().join("subc-connection.json");
8326 daemon.write_connection_file(&conn_file);
8327 let project = write_test_project_repo(temp.path(), "cortexkit/aft");
8328
8329 let manifest = v12_fixture_manifest();
8330 let now = unix_seconds();
8331 write_signed_manifest(&paths, manifest, now);
8332
8333 let mut cached_record = RungDetermination::r3(now - 30, 12, &test_rung_provenance()).record;
8334 cached_record.last_reachable_unix_secs = Some(now - 30);
8335 write_rung_record_silently(&paths, &cached_record);
8336
8337 let doc = json!({
8338 "subc": { "connection_file": conn_file.to_str().unwrap() }
8339 })
8340 .to_string();
8341
8342 let determination = determine_rung_from_doc(
8343 &paths,
8344 &project,
8345 now,
8346 Instant::now() + DISCOVERY_BUDGET,
8347 Some(&doc),
8348 );
8349 assert_eq!(determination.record.rung, Rung::R3);
8350 assert!(determination.refusal_detail.is_none());
8351
8352 let last_probe = read_last_probe(&paths).expect("last probe record");
8353 assert_eq!(last_probe.stage, "open_route");
8354 assert_eq!(last_probe.outcome, "timed_out");
8355 assert!(last_probe.elapsed_ms >= 2000);
8356
8357 let report = render_self_report(&paths).expect("self report");
8358 let status: Value = serde_json::from_str(&report).expect("status json");
8359 assert_eq!(status["last_probe"]["stage"], "open_route");
8360 assert_eq!(status["last_probe"]["outcome"], "timed_out");
8361 assert!(status["last_probe"]["elapsed_ms"].as_u64().unwrap() >= 2000);
8362 }
8363
8364 #[cfg(debug_assertions)] #[test]
8366 fn slow_daemon_open_route_delay_expired_fallback_refuses_naming_stage() {
8367 let daemon = SlowTestDaemon::spawn(SlowDaemonConfig {
8368 handshake_delay: Duration::ZERO,
8369 catalog_delay: Duration::ZERO,
8370 open_route_delay: Duration::from_secs(6),
8371 first_connection_only: false,
8372 });
8373 let temp = tempfile::tempdir().unwrap();
8374 let paths = StatePaths::from_root(temp.path().join("state"));
8375 let conn_file = temp.path().join("subc-connection.json");
8376 daemon.write_connection_file(&conn_file);
8377 let project = write_test_project_repo(temp.path(), "cortexkit/aft");
8378
8379 let manifest = v12_fixture_manifest();
8380 let now = unix_seconds();
8381 write_signed_manifest(&paths, manifest, now);
8382
8383 let mut cached_record =
8384 RungDetermination::r3(now - 301, 12, &test_rung_provenance()).record;
8385 cached_record.last_reachable_unix_secs = Some(now - 301);
8386 write_rung_record_silently(&paths, &cached_record);
8387
8388 let doc = json!({
8389 "subc": { "connection_file": conn_file.to_str().unwrap() }
8390 })
8391 .to_string();
8392
8393 let determination = determine_rung_from_doc(
8394 &paths,
8395 &project,
8396 now,
8397 Instant::now() + DISCOVERY_BUDGET,
8398 Some(&doc),
8399 );
8400 assert_eq!(determination.record.rung, Rung::R1);
8401 assert_eq!(
8402 determination.refusal_detail.as_deref(),
8403 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")
8404 );
8405
8406 let report = render_self_report(&paths).expect("self report");
8407 let status: Value = serde_json::from_str(&report).expect("status json");
8408 assert_eq!(status["last_probe"]["stage"], "open_route");
8409 assert_eq!(status["last_probe"]["outcome"], "timed_out");
8410 assert!(status["last_probe"]["elapsed_ms"].as_u64().unwrap() >= 2000);
8411 }
8412}
8413
8414#[cfg(test)]
8415mod github_read_mutation_tests {
8416 use super::invalidate_successful_github_read_mutation_at;
8422 use super::{GithubReadMutation, GovernedRequest, RouteOutcome};
8423 use crate::db::github_read_cache::GithubReadResourceKind;
8424
8425 use crate::db::github_read_cache::{
8426 lookup_github_read_cache_entry, upsert_github_read_cache_entry, GithubReadCacheKey,
8427 };
8428 use rusqlite::Connection;
8429
8430 fn github_read_mutation_request(
8431 action: &str,
8432 repository: &str,
8433 resource_number: i64,
8434 ) -> GovernedRequest {
8435 let mut target = serde_json::Map::new();
8436 target.insert(
8437 "number".to_string(),
8438 serde_json::Value::String(resource_number.to_string()),
8439 );
8440 GovernedRequest {
8441 action: action.to_string(),
8442 target,
8443 body: serde_json::Map::new(),
8444 repository: Some(repository.to_string()),
8445 manifest_version: 1,
8446 edit_last: false,
8447 }
8448 }
8449
8450 fn cache_key(repository: &str, resource_number: i64, identity: &str) -> GithubReadCacheKey {
8451 GithubReadCacheKey::new(
8452 GithubReadResourceKind::Issue,
8453 repository,
8454 resource_number,
8455 identity,
8456 )
8457 }
8458
8459 fn write_cached_issue(
8460 conn: &Connection,
8461 repository: &str,
8462 resource_number: i64,
8463 identity: &str,
8464 ) {
8465 upsert_github_read_cache_entry(
8466 conn,
8467 &cache_key(repository, resource_number, identity),
8468 "# Cached issue\n",
8469 1_000,
8470 )
8471 .expect("write cached issue");
8472 }
8473
8474 fn cached_issue_exists(
8475 conn: &Connection,
8476 repository: &str,
8477 resource_number: i64,
8478 identity: &str,
8479 ) -> bool {
8480 lookup_github_read_cache_entry(conn, &cache_key(repository, resource_number, identity))
8481 .expect("look up cached issue")
8482 .is_some()
8483 }
8484
8485 #[test]
8486 fn successful_structured_comment_mutation_invalidates_the_touched_issue_for_all_identities() {
8487 let storage = tempfile::tempdir().expect("create storage");
8488 let conn = crate::db::open(&storage.path().join("aft.db")).expect("open cache database");
8489 write_cached_issue(&conn, "cortexkit/aft", 42, "principal:alice");
8490 write_cached_issue(&conn, "cortexkit/aft", 42, "principal:bob");
8491
8492 let request = github_read_mutation_request("issue comment", "CortexKit/AFT", 42);
8493 let mutation = GithubReadMutation::from_governed_request(&request)
8494 .expect("structured issue comment has a cache resource");
8495 assert_eq!(mutation.normalized_repository, "cortexkit/aft");
8496 assert_eq!(mutation.resource_kind, GithubReadResourceKind::Issue);
8497 assert_eq!(mutation.resource_number, 42);
8498
8499 invalidate_successful_github_read_mutation_at(
8500 storage.path(),
8501 Some(&mutation),
8502 &RouteOutcome::Result("comment created".to_string()),
8503 );
8504
8505 assert!(
8506 !cached_issue_exists(&conn, "cortexkit/aft", 42, "principal:alice"),
8507 "a successful comment invalidates Alice's cached issue"
8508 );
8509 assert!(
8510 !cached_issue_exists(&conn, "cortexkit/aft", 42, "principal:bob"),
8511 "a successful comment invalidates every identity's cached issue"
8512 );
8513 }
8514
8515 #[test]
8516 fn failed_structured_comment_mutation_does_not_invalidate_the_touched_issue() {
8517 let storage = tempfile::tempdir().expect("create storage");
8518 let conn = crate::db::open(&storage.path().join("aft.db")).expect("open cache database");
8519 write_cached_issue(&conn, "cortexkit/aft", 42, "principal:alice");
8520
8521 let request = github_read_mutation_request("issue comment", "cortexkit/aft", 42);
8522 let mutation = GithubReadMutation::from_governed_request(&request)
8523 .expect("structured issue comment has a cache resource");
8524 invalidate_successful_github_read_mutation_at(
8525 storage.path(),
8526 Some(&mutation),
8527 &RouteOutcome::UpstreamError("comment rejected".to_string()),
8528 );
8529
8530 assert!(
8531 cached_issue_exists(&conn, "cortexkit/aft", 42, "principal:alice"),
8532 "a failed mutation must preserve the cached issue"
8533 );
8534 }
8535
8536 #[test]
8537 fn successful_mutation_for_a_different_issue_leaves_the_control_entry_intact() {
8538 let storage = tempfile::tempdir().expect("create storage");
8539 let conn = crate::db::open(&storage.path().join("aft.db")).expect("open cache database");
8540 write_cached_issue(&conn, "cortexkit/aft", 42, "principal:alice");
8541 write_cached_issue(&conn, "cortexkit/aft", 43, "principal:alice");
8542
8543 let request = github_read_mutation_request("issue comment", "cortexkit/aft", 43);
8544 let mutation = GithubReadMutation::from_governed_request(&request)
8545 .expect("structured issue comment has a cache resource");
8546 invalidate_successful_github_read_mutation_at(
8547 storage.path(),
8548 Some(&mutation),
8549 &RouteOutcome::Result("comment created".to_string()),
8550 );
8551
8552 assert!(
8553 cached_issue_exists(&conn, "cortexkit/aft", 42, "principal:alice"),
8554 "a mutation for another issue must not evict the control entry"
8555 );
8556 assert!(
8557 !cached_issue_exists(&conn, "cortexkit/aft", 43, "principal:alice"),
8558 "the successful mutation must still evict its own issue"
8559 );
8560 }
8561}