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