Skip to content
This repository was archived by the owner on May 4, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
# RULE_ID|re:<bash-regex>
#
# Notes:
# - `RULE_ID` values come from scripts/ci/test_safety_guard.sh.
# - `RULE_ID` values come from crates/apm2-cli/src/commands/fac_review/gate_checks.rs.
# - `*` matches every rule.
# - Regex entries are matched against:
# RULE_ID|path:line|line_text
Expand Down
11 changes: 6 additions & 5 deletions .github/workflows/forge-admission-cycle.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,10 @@ jobs:
- name: Enforce FAC credential posture lint
run: |
set -euo pipefail
./scripts/ci/credential_hardening.sh lint \
cargo run --quiet -p apm2-cli -- \
fac preflight credential lint \
.github/workflows/forge-admission-cycle.yml \
scripts/ci/fac_preflight_authorization.sh
crates/apm2-cli/src/commands/fac_preflight.rs

- name: Evaluate trusted workflow context
env:
Expand All @@ -86,7 +87,7 @@ jobs:
GITHUB_REPOSITORY: ${{ github.repository }}
run: |
set -euo pipefail
./scripts/ci/fac_preflight_authorization.sh
cargo run --quiet -p apm2-cli -- fac preflight authorization

forge-admission-cycle:
name: apm2 / Forge Admission Cycle
Expand Down Expand Up @@ -118,7 +119,7 @@ jobs:
GITHUB_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
./scripts/ci/credential_hardening.sh runtime
cargo run --quiet -p apm2-cli -- fac preflight credential runtime

if [[ "${{ github.event_name }}" == "pull_request_target" ]]; then
pr_number="${{ github.event.pull_request.number }}"
Expand All @@ -138,7 +139,7 @@ jobs:
GITHUB_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
./scripts/ci/credential_hardening.sh runtime
cargo run --quiet -p apm2-cli -- fac preflight credential runtime

repo="${{ github.repository }}"
pr_number="${{ steps.pr-context.outputs.pr_number }}"
Expand Down
201 changes: 199 additions & 2 deletions crates/apm2-cli/src/commands/fac.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ use subtle::ConstantTimeEq;
use crate::client::protocol::{OperatorClient, ProtocolClientError};
pub use crate::commands::fac_broker::BrokerArgs;
use crate::commands::role_launch::{self, RoleLaunchArgs};
use crate::commands::{fac_broker, fac_gc, fac_pr, fac_quarantine, fac_review};
use crate::commands::{fac_broker, fac_gc, fac_pr, fac_preflight, fac_quarantine, fac_review};
use crate::exit_codes::{codes as exit_codes, map_protocol_error};

// =============================================================================
Expand Down Expand Up @@ -136,6 +136,10 @@ pub enum FacSubcommand {
/// `apm2 fac pipeline` can skip gates that already passed.
Gates(GatesArgs),

/// Internal: CI preflight checks for credential posture and workflow trust.
#[command(hide = true)]
Preflight(PreflightArgs),

/// Query projection-backed work authority via daemon operator IPC.
///
/// Displays work status or lists work items from runtime projection state.
Expand Down Expand Up @@ -343,6 +347,7 @@ pub struct BenchArgs {

/// Arguments for `apm2 fac gates`.
#[derive(Debug, Args)]
#[allow(clippy::struct_excessive_bools)]
pub struct GatesArgs {
/// Force re-run all gates, ignoring cache.
#[arg(long, default_value_t = false)]
Expand Down Expand Up @@ -381,6 +386,57 @@ pub struct GatesArgs {
/// Emit JSON output for this command.
#[arg(long, default_value_t = false)]
pub json: bool,

/// Wait for queued gates job completion (default).
#[arg(long, default_value_t = true)]
pub wait: bool,

/// Disable wait mode and return immediately after enqueue.
#[arg(long, default_value_t = false, conflicts_with = "wait")]
pub no_wait: bool,

/// Maximum wait time in seconds when wait mode is enabled.
#[arg(long, default_value_t = 1200)]
pub wait_timeout_secs: u64,
}

/// Arguments for `apm2 fac preflight`.
#[derive(Debug, Args)]
pub struct PreflightArgs {
#[command(subcommand)]
pub subcommand: PreflightSubcommand,
}

#[derive(Debug, Subcommand)]
pub enum PreflightSubcommand {
/// Credential posture checks (`runtime` or `lint`).
Credential(PreflightCredentialArgs),
/// Workflow trust-policy authorization check.
Authorization(PreflightAuthorizationArgs),
}

#[derive(Debug, Args)]
pub struct PreflightCredentialArgs {
/// Credential preflight mode.
#[arg(value_enum)]
pub mode: fac_preflight::CredentialMode,

/// Optional paths to scan for lint mode.
///
/// Ignored in `runtime` mode.
#[arg(value_name = "PATH")]
pub paths: Vec<PathBuf>,

/// Emit JSON output.
#[arg(long, default_value_t = false)]
pub json: bool,
}

#[derive(Debug, Args)]
pub struct PreflightAuthorizationArgs {
/// Emit JSON output.
#[arg(long, default_value_t = false)]
pub json: bool,
}

/// Arguments for `apm2 fac work`.
Expand Down Expand Up @@ -1972,6 +2028,7 @@ pub fn run_fac(
if !matches!(
cmd.subcommand,
FacSubcommand::Gates(_)
| FacSubcommand::Preflight(_)
| FacSubcommand::Doctor(_)
| FacSubcommand::Lane(_)
| FacSubcommand::Services(_)
Expand Down Expand Up @@ -2003,7 +2060,19 @@ pub fn run_fac(
&args.cpu_quota,
args.gate_profile,
resolve_json(args.json),
args.wait && !args.no_wait,
args.wait_timeout_secs,
),
FacSubcommand::Preflight(args) => match &args.subcommand {
PreflightSubcommand::Credential(credential_args) => fac_preflight::run_credential(
credential_args.mode,
&credential_args.paths,
resolve_json(credential_args.json),
),
PreflightSubcommand::Authorization(auth_args) => {
fac_preflight::run_workflow_authorization(resolve_json(auth_args.json))
},
},
FacSubcommand::Work(args) => match &args.subcommand {
WorkSubcommand::Status(status_args) => {
run_work_status(status_args, operator_socket, resolve_json(status_args.json))
Expand Down Expand Up @@ -2576,6 +2645,7 @@ fn run_reconcile(args: &ReconcileArgs, json_output: bool) -> u8 {
const fn subcommand_requests_machine_output(subcommand: &FacSubcommand) -> bool {
match subcommand {
FacSubcommand::Gates(_)
| FacSubcommand::Preflight(_)
| FacSubcommand::Work(_)
| FacSubcommand::Doctor(_)
| FacSubcommand::Services(_)
Expand Down Expand Up @@ -5445,6 +5515,7 @@ mod tests {
// restores the previous value on Drop, limiting the blast radius.
struct Apm2HomeGuard {
previous: Option<std::ffi::OsString>,
_env_lock: std::sync::MutexGuard<'static, ()>,
}

#[allow(unsafe_code)] // Env var mutation is required for test setup and teardown.
Expand Down Expand Up @@ -5473,9 +5544,15 @@ mod tests {

impl Apm2HomeGuard {
fn new(home: &std::path::Path) -> Self {
let env_lock = crate::commands::env_var_test_lock()
.lock()
.expect("serialize env-mutating test");
let previous = std::env::var_os("APM2_HOME");
set_apm2_home(home);
Self { previous }
Self {
previous,
_env_lock: env_lock,
}
}
}

Expand Down Expand Up @@ -6008,11 +6085,124 @@ mod tests {
fac_review::GateThroughputProfile::Throughput
);
assert_eq!(args.cpu_quota, "auto");
assert!(args.wait);
assert!(!args.no_wait);
assert_eq!(args.wait_timeout_secs, 1200);
},
other => panic!("expected gates subcommand, got {other:?}"),
}
}

#[test]
fn test_gates_wait_flags_parse() {
let parsed = FacLogsCliHarness::try_parse_from([
"fac",
"gates",
"--wait",
"--wait-timeout-secs",
"90",
])
.expect("gates parser should accept wait flags");
match parsed.subcommand {
FacSubcommand::Gates(args) => {
assert!(args.wait);
assert!(!args.no_wait);
assert_eq!(args.wait_timeout_secs, 90);
},
other => panic!("expected gates subcommand, got {other:?}"),
}
}

#[test]
fn test_gates_no_wait_flag_parse() {
let parsed = FacLogsCliHarness::try_parse_from(["fac", "gates", "--no-wait"])
.expect("gates parser should accept --no-wait");
match parsed.subcommand {
FacSubcommand::Gates(args) => {
assert!(args.wait);
assert!(args.no_wait);
},
other => panic!("expected gates subcommand, got {other:?}"),
}
}

#[test]
fn test_preflight_credential_runtime_flags_parse() {
let parsed = FacLogsCliHarness::try_parse_from([
"fac",
"preflight",
"credential",
"runtime",
"--json",
])
.expect("preflight credential runtime should parse");
match parsed.subcommand {
FacSubcommand::Preflight(args) => match args.subcommand {
PreflightSubcommand::Credential(credential_args) => {
assert_eq!(credential_args.mode, fac_preflight::CredentialMode::Runtime);
assert!(credential_args.paths.is_empty());
assert!(credential_args.json);
},
other @ PreflightSubcommand::Authorization(_) => {
panic!("expected preflight credential subcommand, got {other:?}")
},
},
other => panic!("expected preflight subcommand, got {other:?}"),
}
}

#[test]
fn test_preflight_credential_lint_paths_parse() {
let parsed = FacLogsCliHarness::try_parse_from([
"fac",
"preflight",
"credential",
"lint",
".github/workflows/forge-admission-cycle.yml",
"crates/apm2-cli/src/commands/fac_preflight.rs",
])
.expect("preflight credential lint should parse");
match parsed.subcommand {
FacSubcommand::Preflight(args) => match args.subcommand {
PreflightSubcommand::Credential(credential_args) => {
assert_eq!(credential_args.mode, fac_preflight::CredentialMode::Lint);
assert_eq!(credential_args.paths.len(), 2);
assert_eq!(
credential_args.paths[0],
PathBuf::from(".github/workflows/forge-admission-cycle.yml")
);
assert_eq!(
credential_args.paths[1],
PathBuf::from("crates/apm2-cli/src/commands/fac_preflight.rs")
);
assert!(!credential_args.json);
},
other @ PreflightSubcommand::Authorization(_) => {
panic!("expected preflight credential subcommand, got {other:?}")
},
},
other => panic!("expected preflight subcommand, got {other:?}"),
}
}

#[test]
fn test_preflight_authorization_flags_parse() {
let parsed =
FacLogsCliHarness::try_parse_from(["fac", "preflight", "authorization", "--json"])
.expect("preflight authorization should parse");
match parsed.subcommand {
FacSubcommand::Preflight(args) => match args.subcommand {
PreflightSubcommand::Authorization(auth_args) => {
assert!(auth_args.json);
},
other @ PreflightSubcommand::Credential(_) => {
panic!("expected preflight authorization subcommand, got {other:?}")
},
},
other => panic!("expected preflight subcommand, got {other:?}"),
}
}

#[test]
fn test_subcommand_machine_output_detection_for_nested_json() {
let review_status = FacSubcommand::Review(ReviewArgs {
Expand Down Expand Up @@ -6051,6 +6241,13 @@ mod tests {
json: false,
});
assert!(subcommand_requests_machine_output(&worker));

let preflight = FacSubcommand::Preflight(PreflightArgs {
subcommand: PreflightSubcommand::Authorization(PreflightAuthorizationArgs {
json: true,
}),
});
assert!(subcommand_requests_machine_output(&preflight));
}

#[test]
Expand Down
30 changes: 4 additions & 26 deletions crates/apm2-cli/src/commands/fac_broker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,17 @@
//! This module exposes broker introspection for status checks used by
//! local operators when debugging FAC admission state and authority freshness.

use std::fs;
use std::path::{Path, PathBuf};

use anyhow::{Result, anyhow, bail};
use anyhow::{Result, anyhow};
use apm2_core::crypto::Signer;
use apm2_core::fac::FacBroker;
use apm2_core::github::resolve_apm2_home;
use blake3::hash as blake3_hash;
use clap::{Args, Subcommand};
use serde::Serialize;

use crate::commands::{fac_key_material, fac_secure_io};
use crate::exit_codes::codes as exit_codes;

/// Relative path for broker state persisted on disk.
Expand Down Expand Up @@ -131,15 +131,7 @@ fn load_broker_for_status() -> Result<FacBroker> {

fn load_persistent_signer() -> Result<Signer> {
let fac_root = resolve_fac_root()?;
let key_path = fac_root.join("signing_key");
if !key_path.exists() {
bail!("no persistent signing key found");
}

let bytes = fs::read(&key_path)
.map_err(|error| anyhow!("cannot read signing key {}: {error}", key_path.display()))?;

Signer::from_bytes(&bytes).map_err(|error| anyhow!("invalid signing key: {error}"))
fac_key_material::load_persistent_signer(&fac_root).map_err(|error| anyhow!("{error}"))
}

fn resolve_state_path() -> Result<PathBuf> {
Expand All @@ -157,21 +149,7 @@ fn load_state(path: &Path) -> Result<Option<apm2_core::fac::BrokerState>> {
if !path.exists() {
return Ok(None);
}

let metadata = fs::metadata(path).map_err(|error| {
anyhow!(
"cannot read broker state metadata {}: {error}",
path.display()
)
})?;
if metadata.len() > MAX_BROKER_STATE_FILE_SIZE as u64 {
bail!(
"broker state file {} exceeds max size {MAX_BROKER_STATE_FILE_SIZE}",
path.display()
);
}

let bytes = fs::read(path)
let bytes = fac_secure_io::read_bounded(path, MAX_BROKER_STATE_FILE_SIZE)
.map_err(|error| anyhow!("cannot read broker state {}: {error}", path.display()))?;

let state = FacBroker::deserialize_state(&bytes)
Expand Down
Loading