Watch
1
0
Fork
You've already forked RedFlag
0

feat(helper): execute verified pacman envelopes

This commit is contained in:
Fimeg 2026-08-31 20:43:19 -04:00
commit fb53568d37
5 changed files with 855 additions and 54 deletions

View file

@ -13,8 +13,8 @@ import (
const (
// MutationProtocolVersion belongs to the manifest namespace, independently
// of the current closure-based Token format. The manifest path is dormant
// until a backend explicitly opts into it.
// of the current closure-based Token format. Backends opt into the envelope
// path explicitly; pacman begins at the helper boundary.
MutationProtocolVersion = 1
// MaxAuthorizationLifetimeSeconds is the ceiling on expires_at - not_before.

View file

@ -66,6 +66,12 @@ const EXIT_AUTHORIZATION_DENIED: i32 = 27; // envelope carries a non-allow autho
// Default on-host locations. All overridable by env so packaging/tests can relocate.
const DEFAULT_KEYRING_DIR: &str = "/etc/redflag/trusted-keys";
const DEFAULT_STATE_FILE: &str = "/var/lib/redflag/helper/consumed-tokens";
#[cfg(unix)]
const DEFAULT_MUTATION_REPLAY_DIR: &str = "/var/lib/redflag/helper/consumed-authorizations";
#[cfg(unix)]
const DEFAULT_MUTATION_STAGING_DIR: &str = "/var/lib/redflag/helper/mutations";
#[cfg(unix)]
const PACMAN_BINARY: &str = "/usr/bin/pacman";
const AGENT_ID_FILES: &[&str] = &["/etc/redflag/agent_id", "/var/lib/redflag/agent_id"];
// Agent self-upgrade (package_type "agent-self"). The agent drops the downloaded
@ -2078,9 +2084,9 @@ mod tests {
req.operation = "remove".into();
assert_eq!(run_mint(&req, &fx.paths, now).unwrap_err().code, EXIT_UNSUPPORTED_OP);
}
// ---- verify-envelope (ARCH-002 cut 2) ----
// The envelope path refuses everything today. These pin the refusal order,
// and that no refusal touches the replay ledger.
// ---- verify-envelope (ARCH-002 cut 2 compatibility path) ----
// Verification-only still refuses everything. These pin the refusal order,
// and that inspection never consumes the executor's replay claim.
const TEST_TARGET: &str = "agent-123";
const ENVELOPE_NOW: i64 = 1_700_000_100;
@ -2187,8 +2193,8 @@ mod tests {
assert_eq!(denial.reason, "backend_payload_not_object");
}
// The whole point of cut 2: a well-formed envelope walks the real pipeline
// as far as the trusted keyring, and nothing it does burns a replay slot.
// A well-formed envelope walks the real verification pipeline as far as the
// trusted keyring, and inspection never burns an execution replay slot.
#[test]
fn envelope_reaches_the_keyring_and_never_records_replay() {
std::env::set_var("REDFLAG_AGENT_ID", TEST_TARGET);
@ -2220,20 +2226,15 @@ mod tests {
}
// ============================================================================
// verify-envelope — dormant mutation-envelope verification (ARCH-002 cut 2)
// mutation-envelope verification and pacman execution (ARCH-002)
//
// Proves the replacement authority object can enter this helper, be understood
// independently, and be refused safely. No backend is executable through it, so
// every invocation ends in a typed refusal and leaves a receipt.
// Common verification remains independent of backend execution. The legacy
// verify-envelope command proves and refuses without consuming replay state;
// execute-envelope admits only pacman and produces a joined receipt.
//
// Deliberately absent: the replay ledger. Execute mode records a token only
// after a plan is built, so an operation that cannot run never burns its slot.
// Nothing here can run; nothing here records. Replay stays unproven until the
// first backend migrates.
//
// Also deliberately absent: artifact custody. When a backend carries a local
// cached artifact as executor input, the helper must rehash it before use.
// Remote repository execution keeps the limits RAF already states.
// Pacman execution copies signed cache paths into root-owned custody, verifies
// the signed hash and archive identity, atomically consumes authorization_id,
// and invokes one fixed pacman -U argv. Other backends still fail closed.
// ============================================================================
fn read_envelope_from_file(path: &str) -> Result<MutationEnvelope, Denial> {
@ -2270,20 +2271,613 @@ fn validate_backend_payload_shape(manifest: &MutationManifest) -> Result<(), Den
Ok(())
}
// Always denies. Ordered so each refusal gets its own exit code instead of one
// opaque verification failure.
fn run_envelope(envelope_file: &str, now: i64) -> (Option<MutationEnvelope>, Denial) {
let envelope = match read_envelope_from_file(envelope_file) {
Ok(e) => e,
Err(d) => return (None, d),
};
#[cfg(unix)]
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct PacmanExecutionLocation {
kind: String,
value: String,
}
#[cfg(unix)]
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct PacmanActionPayload {
artifact_sha256: String,
execution_location: PacmanExecutionLocation,
repository: String,
}
#[cfg(unix)]
#[derive(Debug)]
struct PacmanResolvedAction {
name: String,
version: String,
artifact_sha256: String,
source_path: PathBuf,
}
#[cfg(unix)]
#[derive(Debug)]
struct PacmanStagedAction {
name: String,
version: String,
path: PathBuf,
}
#[cfg(unix)]
fn plain_pacman_name(value: &str) -> bool {
!value.is_empty()
&& !value.starts_with('-')
&& value.bytes().all(|byte| {
byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'-' | b'.' | b'_' | b'@')
})
}
#[cfg(unix)]
fn plain_pacman_version(value: &str) -> bool {
!value.is_empty()
&& !value.starts_with('-')
&& value.bytes().all(|byte| {
byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'-' | b'.' | b'_' | b':' | b'~')
})
}
#[cfg(unix)]
fn plain_pacman_repository(value: &str) -> bool {
!value.is_empty()
&& !value.starts_with('-')
&& value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_'))
}
#[cfg(unix)]
fn parse_pacman_actions(manifest: &MutationManifest) -> Result<Vec<PacmanResolvedAction>, Denial> {
if manifest.backend != "pacman" {
return Err(Denial::new(
EXIT_UNSUPPORTED_OP,
"backend_not_migrated",
format!(
"backend={} has no executable path through the envelope yet",
manifest.backend
),
));
}
if !matches!(manifest.operation.as_str(), "install" | "upgrade") {
return Err(Denial::new(
EXIT_UNSUPPORTED_OP,
"operation_not_allowed",
format!(
"pacman operation={} (forward-only: install|upgrade)",
manifest.operation
),
));
}
let mut identities = BTreeSet::new();
let mut resolved = Vec::with_capacity(manifest.resolved_actions.len());
for (index, action) in manifest.resolved_actions.iter().enumerate() {
if action.kind != "package" {
return Err(Denial::new(
EXIT_BAD_TOKEN,
"pacman_action_kind_invalid",
format!("resolved action {} kind={}", index, action.kind),
));
}
if !identities.insert(action.identity.clone()) {
return Err(Denial::new(
EXIT_BAD_TOKEN,
"pacman_duplicate_identity",
format!("identity={}", action.identity),
));
}
let (name, version) = action.identity.rsplit_once('@').ok_or_else(|| {
Denial::new(
EXIT_BAD_TOKEN,
"pacman_identity_invalid",
format!("identity={} expected=name@version", action.identity),
)
})?;
if !plain_pacman_name(name) || !plain_pacman_version(version) {
return Err(Denial::new(
EXIT_BAD_TOKEN,
"pacman_identity_invalid",
format!("identity={}", action.identity),
));
}
let payload: PacmanActionPayload = serde_json::from_str(&action.payload).map_err(|e| {
Denial::new(
EXIT_BAD_TOKEN,
"pacman_payload_invalid",
format!("identity={} error={}", action.identity, e),
)
})?;
if payload.execution_location.kind != "cache" {
return Err(Denial::new(
EXIT_BAD_TOKEN,
"pacman_execution_location_invalid",
format!(
"identity={} kind={} expected=cache",
action.identity, payload.execution_location.kind
),
));
}
if !plain_pacman_repository(&payload.repository) {
return Err(Denial::new(
EXIT_BAD_TOKEN,
"pacman_repository_invalid",
format!(
"identity={} repository={}",
action.identity, payload.repository
),
));
}
if payload.artifact_sha256.len() != 64
|| !payload
.artifact_sha256
.bytes()
.all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
{
return Err(Denial::new(
EXIT_ARTIFACT,
"pacman_artifact_hash_invalid",
format!("identity={}", action.identity),
));
}
let source_path = PathBuf::from(&payload.execution_location.value);
if !source_path.is_absolute() || !source_path.is_file() {
return Err(Denial::new(
EXIT_ARTIFACT,
"pacman_artifact_unavailable",
format!(
"identity={} path={}",
action.identity,
source_path.display()
),
));
}
let file_name = source_path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("");
if !file_name.contains(".pkg.tar.") || file_name.bytes().any(|byte| byte.is_ascii_control())
{
return Err(Denial::new(
EXIT_ARTIFACT,
"pacman_artifact_name_invalid",
format!(
"identity={} path={}",
action.identity,
source_path.display()
),
));
}
resolved.push(PacmanResolvedAction {
name: name.to_string(),
version: version.to_string(),
artifact_sha256: payload.artifact_sha256,
source_path,
});
}
Ok(resolved)
}
#[cfg(unix)]
fn prepare_mutation_staging_dir(
root: &Path,
authorization_id: &str,
required_uid: u32,
) -> Result<PathBuf, Denial> {
match fs::symlink_metadata(root) {
Ok(_) => validate_trusted_path_as(root, required_uid)?,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
fs::create_dir_all(root).map_err(|e| {
Denial::new(
EXIT_INTERNAL,
"mutation_staging_root_create_failed",
format!("{}: {}", root.display(), e),
)
})?;
}
Err(e) => return Err(Denial::new(
EXIT_INTERNAL,
"mutation_staging_root_unreadable",
format!("{}: {}", root.display(), e),
)),
}
fs::set_permissions(root, fs::Permissions::from_mode(0o700)).map_err(|e| {
Denial::new(
EXIT_INTERNAL,
"mutation_staging_root_chmod_failed",
format!("{}: {}", root.display(), e),
)
})?;
validate_trusted_path_as(root, required_uid)?;
if let Some(parent) = root.parent() {
validate_trusted_path_as(parent, required_uid)?;
}
let operation_dir = root.join(authorization_id);
if fs::symlink_metadata(&operation_dir).is_ok() {
validate_trusted_path_as(&operation_dir, required_uid)?;
fs::remove_dir_all(&operation_dir).map_err(|e| {
Denial::new(
EXIT_INTERNAL,
"mutation_staging_reset_failed",
format!("{}: {}", operation_dir.display(), e),
)
})?;
}
fs::create_dir(&operation_dir).map_err(|e| {
Denial::new(
EXIT_INTERNAL,
"mutation_staging_create_failed",
format!("{}: {}", operation_dir.display(), e),
)
})?;
fs::set_permissions(&operation_dir, fs::Permissions::from_mode(0o700)).map_err(|e| {
Denial::new(
EXIT_INTERNAL,
"mutation_staging_chmod_failed",
format!("{}: {}", operation_dir.display(), e),
)
})?;
validate_trusted_path_as(&operation_dir, required_uid)?;
Ok(operation_dir)
}
#[cfg(unix)]
fn stage_pacman_actions_as(
actions: &[PacmanResolvedAction],
staging_root: &Path,
authorization_id: &str,
required_uid: u32,
) -> Result<(PathBuf, Vec<PacmanStagedAction>), Denial> {
let operation_dir = prepare_mutation_staging_dir(staging_root, authorization_id, required_uid)?;
let mut staged = Vec::with_capacity(actions.len());
for (index, action) in actions.iter().enumerate() {
let source_name = action
.source_path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("package.pkg.tar.zst");
let destination = operation_dir.join(format!("{:04}-{}", index, source_name));
if let Err(e) = fs::copy(&action.source_path, &destination) {
let _ = fs::remove_dir_all(&operation_dir);
return Err(Denial::new(
EXIT_ARTIFACT,
"pacman_artifact_stage_failed",
format!(
"{} -> {}: {}",
action.source_path.display(),
destination.display(),
e
),
));
}
if let Err(e) = fs::set_permissions(&destination, fs::Permissions::from_mode(0o600)) {
let _ = fs::remove_dir_all(&operation_dir);
return Err(Denial::new(
EXIT_INTERNAL,
"pacman_artifact_stage_chmod_failed",
format!("{}: {}", destination.display(), e),
));
}
if let Err(d) = validate_trusted_path_as(&destination, required_uid) {
let _ = fs::remove_dir_all(&operation_dir);
return Err(d);
}
let actual = match compute_file_sha256(&destination) {
Ok(hash) => hash,
Err(e) => {
let _ = fs::remove_dir_all(&operation_dir);
return Err(Denial::new(
EXIT_ARTIFACT,
"pacman_artifact_hash_read_failed",
format!("{}: {}", destination.display(), e),
));
}
};
if !ct_eq_hex(&actual, &action.artifact_sha256) {
let _ = fs::remove_dir_all(&operation_dir);
return Err(Denial::new(
EXIT_ARTIFACT,
"pacman_artifact_hash_mismatch",
format!(
"identity={} expected={} actual={}",
action.name, action.artifact_sha256, actual
),
));
}
staged.push(PacmanStagedAction {
name: action.name.clone(),
version: action.version.clone(),
path: destination,
});
}
Ok((operation_dir, staged))
}
#[cfg(unix)]
fn validate_pacman_archive_identity_output(
action: &PacmanStagedAction,
output: &[u8],
) -> Result<(), Denial> {
let found = String::from_utf8_lossy(output);
let mut fields = found.split_ascii_whitespace();
let found_name = fields.next().unwrap_or("");
let found_version = fields.next().unwrap_or("");
if found_name != action.name || found_version != action.version || fields.next().is_some() {
return Err(Denial::new(
EXIT_ARTIFACT,
"pacman_archive_identity_mismatch",
format!(
"expected={}@{} found={} path={}",
action.name,
action.version,
found.trim(),
action.path.display()
),
));
}
Ok(())
}
#[cfg(unix)]
fn verify_pacman_archive_identity(action: &PacmanStagedAction) -> Result<(), Denial> {
let output = Command::new(PACMAN_BINARY)
.args(["-Qp", "--"])
.arg(&action.path)
.env_clear()
.env(
"PATH",
"/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
)
.env("LC_ALL", "C")
.output()
.map_err(|e| {
Denial::new(
EXIT_EXEC_FAILED,
"pacman_archive_inspection_spawn_failed",
e.to_string(),
)
})?;
if !output.status.success() {
return Err(Denial::new(
EXIT_ARTIFACT,
"pacman_archive_inspection_failed",
format!(
"path={} exit={:?}",
action.path.display(),
output.status.code()
),
));
}
validate_pacman_archive_identity_output(action, &output.stdout)
}
#[cfg(unix)]
fn claim_mutation_authorization_as(
authorization_id: &str,
replay_dir: &Path,
required_uid: u32,
) -> Result<(), Denial> {
if !mutation_protocol::is_canonical_uuid_v4(authorization_id) {
return Err(Denial::new(
EXIT_BAD_TOKEN,
"authorization_id_not_uuid_v4",
"authorization_id cannot key the replay ledger",
));
}
match fs::symlink_metadata(replay_dir) {
Ok(_) => validate_trusted_path_as(replay_dir, required_uid)?,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
fs::create_dir_all(replay_dir).map_err(|e| {
Denial::new(
EXIT_INTERNAL,
"mutation_replay_dir_create_failed",
format!("{}: {}", replay_dir.display(), e),
)
})?;
}
Err(e) => return Err(Denial::new(
EXIT_INTERNAL,
"mutation_replay_dir_unreadable",
format!("{}: {}", replay_dir.display(), e),
)),
}
fs::set_permissions(replay_dir, fs::Permissions::from_mode(0o700)).map_err(|e| {
Denial::new(
EXIT_INTERNAL,
"mutation_replay_dir_chmod_failed",
format!("{}: {}", replay_dir.display(), e),
)
})?;
validate_trusted_path_as(replay_dir, required_uid)?;
if let Some(parent) = replay_dir.parent() {
validate_trusted_path_as(parent, required_uid)?;
}
let claim = replay_dir.join(authorization_id);
let mut options = fs::OpenOptions::new();
options.write(true).create_new(true).mode(0o600);
match options.open(&claim) {
Ok(_) => {
validate_trusted_path_as(&claim, required_uid)?;
Ok(())
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Err(Denial::new(
EXIT_REPLAY,
"authorization_already_consumed",
format!("authorization_id={}", authorization_id),
)),
Err(e) => Err(Denial::new(
EXIT_INTERNAL,
"mutation_replay_claim_failed",
format!("{}: {}", claim.display(), e),
)),
}
}
#[cfg(unix)]
fn execute_pacman_actions(actions: &[PacmanStagedAction]) -> Result<(), Denial> {
let mut command = Command::new(PACMAN_BINARY);
command.args(["-U", "--noconfirm", "--needed", "--"]);
for action in actions {
command.arg(&action.path);
}
let status = command
.env_clear()
.env(
"PATH",
"/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
)
.status()
.map_err(|e| Denial::new(EXIT_EXEC_FAILED, "pacman_exec_spawn_failed", e.to_string()))?;
if !status.success() {
return Err(Denial::new(
EXIT_EXEC_FAILED,
"pacman_exec_nonzero_exit",
format!("{} exit={:?}", PACMAN_BINARY, status.code()),
));
}
Ok(())
}
#[cfg(all(test, unix))]
mod pacman_envelope_tests {
use super::*;
const TEST_AUTHORIZATION_ID: &str = "550e8400-e29b-41d4-a716-446655440011";
fn fixture_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"redflag-pacman-envelope-test-{}-{}",
name,
std::process::id()
));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
fs::set_permissions(&dir, fs::Permissions::from_mode(0o700)).unwrap();
dir
}
fn manifest_for(path: &Path, sha256: &str, identity: &str) -> MutationManifest {
MutationManifest {
protocol_version: mutation_protocol::MUTATION_PROTOCOL_VERSION,
operation_id: "550e8400-e29b-41d4-a716-446655440010".into(),
target_id: "agent-123".into(),
backend: "pacman".into(),
operation: "upgrade".into(),
resolved_actions: vec![mutation_protocol::ResolvedAction {
kind: "package".into(),
identity: identity.into(),
payload: serde_json::json!({
"artifact_sha256": sha256,
"execution_location": {
"kind": "cache",
"value": path,
},
"repository": "core-testing",
})
.to_string(),
}],
evidence: vec![],
}
}
#[test]
fn pacman_payload_pins_cache_hash_and_epoch_identity() {
let root = fixture_dir("pins");
let source = root.join("acl-2.3.2-1-x86_64.pkg.tar.zst");
fs::write(&source, b"signed package bytes").unwrap();
let expected = compute_file_sha256(&source).unwrap();
let actions = parse_pacman_actions(&manifest_for(&source, &expected, "acl@1:2.3.2-1"))
.expect("signed pacman payload should parse");
let staging = root.join("staging");
let uid = fs::symlink_metadata(&root).unwrap().uid();
let (operation_dir, staged) =
stage_pacman_actions_as(&actions, &staging, TEST_AUTHORIZATION_ID, uid)
.expect("artifact should stage under executor custody");
assert_eq!(staged.len(), 1);
assert_eq!(staged[0].name, "acl");
assert_eq!(staged[0].version, "1:2.3.2-1");
assert_eq!(compute_file_sha256(&staged[0].path).unwrap(), expected);
assert!(staged[0].path.starts_with(&operation_dir));
let _ = fs::remove_dir_all(&root);
}
#[test]
fn pacman_staging_refuses_bytes_outside_signed_hash() {
let root = fixture_dir("tamper");
let source = root.join("acl-2.3.2-1-x86_64.pkg.tar.zst");
fs::write(&source, b"different package bytes").unwrap();
let actions = parse_pacman_actions(&manifest_for(&source, &"a".repeat(64), "acl@2.3.2-1"))
.expect("payload shape should parse before custody verifies bytes");
let staging = root.join("staging");
let uid = fs::symlink_metadata(&root).unwrap().uid();
let denial = stage_pacman_actions_as(&actions, &staging, TEST_AUTHORIZATION_ID, uid)
.expect_err("tampered package must fail closed");
assert_eq!(denial.code, EXIT_ARTIFACT);
assert_eq!(denial.reason, "pacman_artifact_hash_mismatch");
assert!(!staging.join(TEST_AUTHORIZATION_ID).exists());
let _ = fs::remove_dir_all(&root);
}
#[test]
fn mutation_authorization_claim_is_atomic_and_one_shot() {
let root = fixture_dir("replay");
let replay = root.join("consumed-authorizations");
let uid = fs::symlink_metadata(&root).unwrap().uid();
claim_mutation_authorization_as(TEST_AUTHORIZATION_ID, &replay, uid)
.expect("first claim should own the authorization");
let denial = claim_mutation_authorization_as(TEST_AUTHORIZATION_ID, &replay, uid)
.expect_err("second claim must be replay");
assert_eq!(denial.code, EXIT_REPLAY);
assert_eq!(denial.reason, "authorization_already_consumed");
assert!(replay.join(TEST_AUTHORIZATION_ID).is_file());
let _ = fs::remove_dir_all(&root);
}
#[test]
fn pacman_archive_identity_output_is_exact() {
let action = PacmanStagedAction {
name: "acl".into(),
version: "1:2.3.2-1".into(),
path: PathBuf::from("/root/custody/acl.pkg.tar.zst"),
};
validate_pacman_archive_identity_output(&action, b"acl 1:2.3.2-1\n")
.expect("pacman query identity should match the signed action");
let denial = validate_pacman_archive_identity_output(&action, b"acl 2.3.2-1\n")
.expect_err("different archive version must fail closed");
assert_eq!(denial.reason, "pacman_archive_identity_mismatch");
}
}
// Ordered so each refusal gets its own exit code instead of one opaque
// verification failure. Execution is a separate step after this returns.
fn verify_envelope(
envelope_file: &str,
now: i64,
) -> Result<MutationEnvelope, (Option<MutationEnvelope>, Denial)> {
let envelope = read_envelope_from_file(envelope_file).map_err(|d| (None, d))?;
// Evaluate the denial before moving the envelope into the return — every
// detail string reads from it.
macro_rules! deny {
($d:expr) => {{
let denial = $d;
return (Some(envelope), denial);
return Err((Some(envelope), denial));
}};
}
@ -2339,14 +2933,20 @@ fn run_envelope(envelope_file: &str, now: i64) -> (Option<MutationEnvelope>, Den
deny!(Denial::new(
EXIT_TIME_WINDOW,
"envelope_not_yet_valid",
format!("now={} not_before={}", now, envelope.authorization.not_before),
format!(
"now={} not_before={}",
now, envelope.authorization.not_before
),
));
}
if now > envelope.authorization.expires_at {
deny!(Denial::new(
EXIT_TIME_WINDOW,
"envelope_expired",
format!("now={} expires_at={}", now, envelope.authorization.expires_at),
format!(
"now={} expires_at={}",
now, envelope.authorization.expires_at
),
));
}
let lifetime = match envelope
@ -2402,11 +3002,16 @@ fn run_envelope(envelope_file: &str, now: i64) -> (Option<MutationEnvelope>, Den
)),
};
if let Err(e) = envelope
.authorization
.verify_for_execution_at(&verifying_key, &envelope.manifest, now)
if let Err(e) =
envelope
.authorization
.verify_for_execution_at(&verifying_key, &envelope.manifest, now)
{
deny!(Denial::new(EXIT_SIGNATURE, "envelope_verification_failed", e));
deny!(Denial::new(
EXIT_SIGNATURE,
"envelope_verification_failed",
e
));
}
if let Err(d) = validate_backend_payload_shape(&envelope.manifest) {
@ -2422,11 +3027,29 @@ fn run_envelope(envelope_file: &str, now: i64) -> (Option<MutationEnvelope>, Den
backend,
envelope.manifest.target_id
));
deny!(Denial::new(
EXIT_UNSUPPORTED_OP,
"backend_not_migrated",
format!("backend={} has no executable path through the envelope yet", backend),
))
Ok(envelope)
}
// Verification-only compatibility path. It proves the envelope without
// consuming replay state or executing a backend.
fn run_envelope(envelope_file: &str, now: i64) -> (Option<MutationEnvelope>, Denial) {
match verify_envelope(envelope_file, now) {
Ok(envelope) => {
let backend = envelope.manifest.backend.clone();
(
Some(envelope),
Denial::new(
EXIT_UNSUPPORTED_OP,
"backend_not_migrated",
format!(
"backend={} has no executable path through verify-envelope",
backend
),
),
)
}
Err(result) => result,
}
}
fn run_verify_envelope_cli(args: &[String]) -> i32 {
@ -2501,6 +3124,173 @@ fn run_verify_envelope_cli(args: &[String]) -> i32 {
denial.code
}
#[cfg(unix)]
fn empty_mutation_receipt(denial: &Denial, decision: &str, now: i64) -> MutationReceipt {
MutationReceipt {
protocol_version: mutation_protocol::MUTATION_PROTOCOL_VERSION,
operation_id: String::new(),
manifest_hash: String::new(),
authorization_id: String::new(),
target_id: String::new(),
backend: String::new(),
operation: String::new(),
decision: decision.to_string(),
reason: denial.reason.to_string(),
executed: false,
verified_actions: 0,
exit_code: denial.code,
error: denial.detail.clone(),
timestamp: now,
}
}
#[cfg(unix)]
fn execute_verified_envelope(envelope: &MutationEnvelope) -> Result<u32, (Denial, u32)> {
let actions = parse_pacman_actions(&envelope.manifest).map_err(|denial| (denial, 0))?;
let (operation_dir, staged) = stage_pacman_actions_as(
&actions,
Path::new(DEFAULT_MUTATION_STAGING_DIR),
&envelope.authorization.authorization_id,
0,
).map_err(|denial| (denial, 0))?;
for (index, action) in staged.iter().enumerate() {
if let Err(denial) = verify_pacman_archive_identity(action) {
let _ = fs::remove_dir_all(&operation_dir);
return Err((denial, index as u32));
}
}
if let Err(denial) = claim_mutation_authorization_as(
&envelope.authorization.authorization_id,
Path::new(DEFAULT_MUTATION_REPLAY_DIR),
0,
) {
let _ = fs::remove_dir_all(&operation_dir);
return Err((denial, staged.len() as u32));
}
log_security(&format!(
"authorized mutation operation_id={} authorization_id={} target_id={} backend=pacman operation={} actions={}",
envelope.manifest.operation_id,
envelope.authorization.authorization_id,
envelope.manifest.target_id,
envelope.manifest.operation,
staged.len()
));
let result = execute_pacman_actions(&staged);
let _ = fs::remove_dir_all(&operation_dir);
result.map_err(|denial| (denial, staged.len() as u32))?;
Ok(staged.len() as u32)
}
#[cfg(unix)]
fn run_execute_envelope_cli(args: &[String]) -> i32 {
let mut envelope_file: Option<String> = None;
let mut receipt_file: Option<String> = None;
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--envelope-file" => {
envelope_file = args.get(i + 1).cloned();
i += 2;
}
"--receipt-file" => {
receipt_file = args.get(i + 1).cloned();
i += 2;
}
other => {
log_error(&format!("execute-envelope unknown argument {}", other));
return EXIT_BAD_TOKEN;
}
}
}
let envelope_file = match envelope_file {
Some(path) => path,
None => {
log_error("execute-envelope usage: redflag-helper execute-envelope --envelope-file <path> [--receipt-file <path>]");
return EXIT_BAD_TOKEN;
}
};
let now = now_unix();
let (receipt, exit_code) = match verify_envelope(&envelope_file, now) {
Ok(envelope) => match execute_verified_envelope(&envelope) {
Ok(verified_actions) => (
envelope.receipt_for(
MutationOutcome {
decision: "executed".to_string(),
reason: "operation_completed".to_string(),
executed: true,
verified_actions,
exit_code: EXIT_OK,
detail: String::new(),
},
now,
),
EXIT_OK,
),
Err((denial, verified_actions)) => {
let decision = if denial.code == EXIT_EXEC_FAILED {
"failed"
} else {
"denied"
};
log_security(&format!(
"{} reason={} detail={} exit={}",
decision, denial.reason, denial.detail, denial.code
));
(
envelope.receipt_for(
MutationOutcome {
decision: decision.to_string(),
reason: denial.reason.to_string(),
executed: false,
verified_actions,
exit_code: denial.code,
detail: denial.detail.clone(),
},
now,
),
denial.code,
)
}
},
Err((envelope, denial)) => {
let decision = if denial.code == EXIT_EXEC_FAILED {
"failed"
} else {
"denied"
};
log_security(&format!(
"{} reason={} detail={} exit={}",
decision, denial.reason, denial.detail, denial.code
));
let receipt = match envelope {
Some(envelope) => envelope.receipt_for(
MutationOutcome {
decision: decision.to_string(),
reason: denial.reason.to_string(),
executed: false,
verified_actions: 0,
exit_code: denial.code,
detail: denial.detail.clone(),
},
now,
),
None => empty_mutation_receipt(&denial, decision, now),
};
(receipt, denial.code)
}
};
if let Some(path) = receipt_file {
emit_result_to_file(&receipt, &path);
}
emit_result(&receipt);
exit_code
}
#[derive(Debug, Serialize)]
struct IntegrityResult {
target: String,
@ -2586,12 +3376,18 @@ fn main() {
std::process::exit(run_mint_cli(&args[2..]));
}
// "verify-envelope" is the dormant mutation-envelope path. It verifies and
// refuses; no backend executes through it yet.
// Verification-only compatibility path: proves and refuses without
// consuming authorization replay state.
if args.get(1).map(|s| s.as_str()) == Some("verify-envelope") {
std::process::exit(run_verify_envelope_cli(&args[2..]));
}
// The mutation-envelope executor. Pacman is the first migrated backend;
// every other backend still fails closed after common verification.
if args.get(1).map(|s| s.as_str()) == Some("execute-envelope") {
std::process::exit(run_execute_envelope_cli(&args[2..]));
}
// --token-file <path> reads the capability token from a file instead of stdin.
// This avoids SCM_RIGHTS fd-passing via systemd-run --pipe, which dbus-broker 37
// on Fedora 43 drops (MSG_CTRUNC) for new-connection handshake messages.

View file

@ -1,7 +1,8 @@
//! Dormant mutation manifest and authorization contract.
//! Mutation manifest and authorization contract.
//!
//! Current closure-token execution remains unchanged. This module pins the
//! backend-neutral bytes that Server, Agent, and helper will adopt together.
//! Current closure-token execution remains unchanged. Pacman is the first
//! envelope executor; the bytes remain backend-neutral across Server, Agent,
//! and helper.
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
use serde::{Deserialize, Serialize};

View file

@ -217,12 +217,16 @@ Go tests live in both capability packages. Rust tests live in
`redflag-helper verify-envelope --envelope-file <path> [--receipt-file <path>]` verifies an
envelope through the helper's real pipeline — parse, host binding, trusted keyring,
signature, time, lifetime ceiling, backend payload shape — and then refuses with
`backend_not_migrated`, because no backend executes through this path yet.
`backend_not_migrated`. This remains the inspection-only compatibility path and consumes no
replay state.
It records **no replay state**. Execute mode records a token only after a plan is built, so
an operation that cannot run never burns its slot; nothing here can run, so nothing here
records. Replay identity stays unproven until the first backend migrates.
`redflag-helper execute-envelope --envelope-file <path> [--receipt-file <path>]` admits the
first migrated backend: pacman `install` and `upgrade` over exact cached package archives.
The helper copies every signed cache path into root-owned custody, verifies the signed
SHA-256 and archive `name@version`, then atomically consumes `authorization_id` before one
fixed `pacman -U` invocation. A receipt records the joined operation, manifest,
authorization, verified-action count, and outcome.
It also enforces no artifact custody. When a backend carries a local cached artifact as
executor input, the helper must rehash it before use. Remote repository execution keeps the
limits `RAF/security/05-supply-chain-gate.md` already states.
Every other backend still fails closed with `backend_not_migrated`. The Agent request,
resolver, authorization mint, and Desktop handoff remain separate cuts; this executor does
not infer or create any of them.

View file

@ -13,8 +13,8 @@ import (
const (
// MutationProtocolVersion belongs to the manifest namespace, independently
// of the current closure-based Token format. The manifest path is dormant
// until a backend explicitly opts into it.
// of the current closure-based Token format. Backends opt into the envelope
// path explicitly; pacman begins at the helper boundary.
MutationProtocolVersion = 1
// MaxAuthorizationLifetimeSeconds is the ceiling on expires_at - not_before.