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