Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
3941f8e
fix(gateway): enforce live audit authority
joshuajbouw Jul 14, 2026
83c055b
test(gateway): cover scoped admin audit attenuation
joshuajbouw Jul 15, 2026
7a29879
test(gateway): factor audit attenuation sweep
joshuajbouw Jul 15, 2026
74f6a95
fix(gateway): stabilize audit cursors across live narrowing
Copilot Jul 15, 2026
6379e14
fix(gateway): reject widened audit cursors
Copilot Jul 15, 2026
9f5e443
fix(gateway): fail closed on widened audit cursors
Copilot Jul 15, 2026
132d3e6
fix(gateway): clip audit pages on mid-request scope widening
Copilot Jul 15, 2026
df1c530
fix(gateway): reject unsafe audit scope transitions
joshuajbouw Jul 15, 2026
3e8c185
fix(gateway): repair exact-head audit test validation
Copilot Jul 15, 2026
63c606e
fix(gateway): tighten audit cursor resumes
Copilot Jul 15, 2026
702bc1c
fix(gateway): bind legacy audit cursor scope
joshuajbouw Jul 15, 2026
79bf221
fix(gateway): suppress false audit continuation cursors
joshuajbouw Jul 15, 2026
2109cd4
test(gateway): bind audit enforcement to registry
joshuajbouw Jul 15, 2026
92bd7a0
fix(gateway): anchor audit cursors to entry identity
joshuajbouw Jul 15, 2026
b477f65
fix(gateway): expose audit capability probe on router builder
Copilot Jul 15, 2026
39720da
fix(cli): accept every issued invite token
joshuajbouw Jul 15, 2026
0a38bfe
docs(gateway): note connect-info requirement for embedded router
Copilot Jul 15, 2026
1f8f5a5
docs(gateway): cover default router embeddings
joshuajbouw Jul 15, 2026
25077fa
docs(changelog): note probe migration for audit firehose
Copilot Jul 15, 2026
bf3eb25
docs(gateway): dedupe router probe guidance
Copilot Jul 15, 2026
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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,10 @@ Changelog tracking starts with 0.2.0. Prior versions were not tracked.

### Fixed

- **Invite commands accept every token the runtime can issue.** `invite redeem`
and `invite revoke` now treat a leading hyphen in an opaque base64url token as
token data instead of misparsing it as an unknown option.

- **Security Audit now uses a reproducible cargo-audit installation.** CI installs
cargo-audit 0.22.2 with its published lockfile before invoking the pinned RustSec
action, keeping the advisory scan compatible with the repository toolchain.
Expand Down Expand Up @@ -140,6 +144,16 @@ Changelog tracking starts with 0.2.0. Prior versions were not tracked.
identities fail closed. `capsule:list` controls global describe visibility,
while exact `capsule:access:any` controls unrestricted execution. Closes
#1239.
- **Audit views now use the live kernel authority evaluator.** Historical and
streaming firehose access carries the authenticated device scope; revoked,
disabled, malformed, unknown, or narrowed credentials fall back to the
caller's own records. Historical continuation cursors bind the immutable
audit-entry identity and visibility scope, preventing same-second appends
from shifting the page boundary and rejecting unsafe widening. Co-located
callers that need firehose visibility must now pass the kernel evaluator via
`astrid_gateway::run_with_capability_probe(...)` or
`routes::build_with_capability_probe(...)`; the legacy default runner and
router remain self-only. Closes #1241.

- **Device attenuation now applies to every kernel authority view.** Capsule,
agent, and group inventory checks use the authenticating device scope, and a
Expand Down
75 changes: 64 additions & 11 deletions crates/astrid-capabilities/src/policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@
//! caching. The caller is expected to have resolved the profile and
//! the group config beforehand.

use std::borrow::Cow;

use astrid_core::{
CapabilityPattern, DeviceScope, GroupConfig, PrincipalId, PrincipalProfile,
ValidatedGroupConfig, ValidatedProfileFields, capability_matches,
Expand Down Expand Up @@ -110,7 +112,7 @@ pub enum PermissionError {
pub struct CapabilityCheck<'a> {
profile: Result<ValidatedProfileFields<'a>, String>,
groups: Result<ValidatedGroupConfig<'a>, String>,
principal: PrincipalId,
principal: Cow<'a, PrincipalId>,
/// Optional per-device attenuation floor. `None` (the default) means the
/// check is unattenuated — the principal's full effective capability set
/// applies, which is the behaviour for every full-scope / un-paired
Expand All @@ -136,7 +138,29 @@ impl<'a> CapabilityCheck<'a> {
Self {
profile,
groups,
principal,
principal: Cow::Owned(principal),
device_scope: None,
}
}

/// Build a new check while borrowing the resolved principal identifier.
///
/// This is equivalent to [`Self::new`] but avoids cloning an identifier
/// for checks that only need the boolean result from [`Self::has`]. A
/// denied [`Self::require`] still returns an owned identifier in its
/// [`PermissionError`].
#[must_use]
pub fn new_borrowed(
profile: &'a PrincipalProfile,
groups: &'a GroupConfig,
principal: &'a PrincipalId,
) -> Self {
let profile = profile.typed_fields().map_err(|e| e.to_string());
let groups = groups.typed().map_err(|e| e.to_string());
Self {
profile,
groups,
principal: Cow::Borrowed(principal),
device_scope: None,
}
}
Expand Down Expand Up @@ -167,7 +191,7 @@ impl<'a> CapabilityCheck<'a> {
Err(e) => {
warn!(
security_event = true,
principal = %self.principal,
principal = %self.principal.as_ref(),
error = %e,
"Principal profile contains invalid typed fields — no capabilities inherited"
);
Expand Down Expand Up @@ -197,19 +221,19 @@ impl<'a> CapabilityCheck<'a> {
Err(e) => {
warn!(
security_event = true,
principal = %self.principal,
principal = %self.principal.as_ref(),
error = %e,
"Principal profile contains invalid typed fields — denying capability"
);
return Err(PermissionError::MissingCapability {
principal: self.principal.clone(),
principal: self.principal.as_ref().clone(),
required: cap.to_string(),
});
},
};
if let Some(revoke) = Self::first_matching_revoke(profile, cap) {
return Err(PermissionError::RevokedCapability {
principal: self.principal.clone(),
principal: self.principal.as_ref().clone(),
required: cap.to_string(),
revoke_pattern: revoke.to_string(),
});
Expand All @@ -219,15 +243,15 @@ impl<'a> CapabilityCheck<'a> {
|| self.holds_via_groups(profile, cap);
if !principal_holds {
return Err(PermissionError::MissingCapability {
principal: self.principal.clone(),
principal: self.principal.as_ref().clone(),
required: cap.to_string(),
});
}
// Principal floor satisfied. Apply the per-device scope floor last so
// a scoped device's narrower grant can deny without ever widening.
if !self.device_scope_admits(cap) {
return Err(PermissionError::DeviceScopeDenied {
principal: self.principal.clone(),
principal: self.principal.as_ref().clone(),
required: cap.to_string(),
});
}
Expand Down Expand Up @@ -277,7 +301,7 @@ impl<'a> CapabilityCheck<'a> {
Err(e) => {
warn!(
security_event = true,
principal = %self.principal,
principal = %self.principal.as_ref(),
error = %e,
"Group config contains invalid typed fields — no group capabilities inherited"
);
Expand All @@ -288,7 +312,7 @@ impl<'a> CapabilityCheck<'a> {
let Some(group) = groups.get(name) else {
warn!(
security_event = true,
principal = %self.principal,
principal = %self.principal.as_ref(),
group = %name,
"Principal profile references unknown group — no capabilities inherited"
);
Expand Down Expand Up @@ -348,7 +372,7 @@ pub fn device_scope_within(
for pattern in allow {
if !issuer_check.has(pattern) {
return Err(PermissionError::MissingCapability {
principal: issuer_check.principal.clone(),
principal: issuer_check.principal.as_ref().clone(),
required: pattern.clone(),
});
}
Expand Down Expand Up @@ -379,6 +403,35 @@ mod tests {
PrincipalId::new("alice").unwrap()
}

#[test]
fn borrowed_principal_check_preserves_allow_semantics() {
let profile = profile_in(&["agent"]);
let groups = gc();
let principal = pid();
let check = CapabilityCheck::new_borrowed(&profile, &groups, &principal);

assert!(check.has("self:capsule:install"));
assert!(!check.has("system:shutdown"));
assert_eq!(principal, pid());
}

#[test]
fn borrowed_principal_is_owned_in_permission_errors() {
let profile = profile_in(&["restricted"]);
let groups = gc();
let principal = pid();
let check = CapabilityCheck::new_borrowed(&profile, &groups, &principal);

assert_eq!(
check.require("system:shutdown"),
Err(PermissionError::MissingCapability {
principal: principal.clone(),
required: "system:shutdown".to_owned(),
})
);
assert_eq!(principal, pid());
}

#[test]
fn admin_has_universal() {
let p = profile_in(&["admin"]);
Expand Down
32 changes: 31 additions & 1 deletion crates/astrid-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -563,7 +563,7 @@ pub(crate) enum DistroCommands {

#[cfg(test)]
mod tests {
use super::{CapsuleCommands, Cli, Commands};
use super::{CapsuleCommands, Cli, Commands, InviteCommand};
use clap::{CommandFactory, Parser, Subcommand};
use std::collections::BTreeSet;

Expand Down Expand Up @@ -654,6 +654,36 @@ mod tests {
assert_eq!(cli.principal.as_deref(), Some("operator-1"));
}

#[test]
fn opaque_invite_tokens_may_start_with_a_hyphen() {
let redeem = Cli::try_parse_from([
"astrid",
"invite",
"redeem",
"-opaque-token",
"--public-key",
"ed25519:0000000000000000000000000000000000000000000000000000000000000000",
])
.expect("an issued base64url token may begin with a hyphen");
assert!(matches!(
redeem.command,
Some(Commands::Invite {
command: InviteCommand::Redeem(ref args),
}) if args.token == "-opaque-token"
&& args.public_key.as_deref()
== Some("ed25519:0000000000000000000000000000000000000000000000000000000000000000")
));

let revoke = Cli::try_parse_from(["astrid", "invite", "revoke", "-opaque-token"])
.expect("the same issued token must be accepted by revoke");
assert!(matches!(
revoke.command,
Some(Commands::Invite {
command: InviteCommand::Revoke(ref args),
}) if args.token_or_fingerprint == "-opaque-token"
));
}

#[test]
fn grant_capsules_parsing_stays_open_for_embedding_composition() {
let cli = Cli::try_parse_from(["astrid", "init", "--grant-capsules"])
Expand Down
2 changes: 2 additions & 0 deletions crates/astrid-cli/src/commands/invite.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ pub(crate) struct IssueArgs {
#[derive(Args, Debug, Clone)]
pub(crate) struct RedeemArgs {
/// The opaque token returned by a prior `astrid invite issue`.
#[arg(allow_hyphen_values = true)]
pub token: String,
/// Hex-encoded ed25519 public key. Accepts bare 64 hex chars or
/// the self-describing `ed25519:<hex>` form. The new principal's
Expand Down Expand Up @@ -89,6 +90,7 @@ pub(crate) struct ListArgs {
#[derive(Args, Debug, Clone)]
pub(crate) struct RevokeArgs {
/// Either the raw token or its hex fingerprint (from `invite list`).
#[arg(allow_hyphen_values = true)]
pub token_or_fingerprint: String,
}

Expand Down
10 changes: 9 additions & 1 deletion crates/astrid-daemon/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,7 @@ fn spawn_gateway(
let bus = std::sync::Arc::clone(&kernel.event_bus);
let audit_log = std::sync::Arc::clone(&kernel.audit_log);
let session_id = kernel.session_id.clone();
let capability_kernel = std::sync::Arc::clone(kernel);
let readiness_probe = kernel.agent_readiness_probe();
let topic_probe = kernel.capsule_topic_probe_with_warm();
let state = astrid_gateway::GatewayState::new(
Expand All @@ -377,7 +378,14 @@ fn spawn_gateway(
let shutdown = async move {
notify_for_task.notified().await;
};
if let Err(e) = astrid_gateway::run(state, shutdown).await {
let capability_probe = move |principal: &astrid_core::PrincipalId,
device_key_id: Option<&str>,
capability: &str| {
capability_kernel.runtime_capability_allows(principal, device_key_id, capability)
};
if let Err(e) =
astrid_gateway::run_with_capability_probe(state, shutdown, capability_probe).await
{
tracing::error!(error = %e, "astrid-gateway exited with error");
}
});
Expand Down
Loading
Loading