Skip to content
Open
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Changelog tracking starts with 0.2.0. Prior versions were not tracked.
### Added

- Added a standalone `fuzz/` package with initial structured fuzz targets for Tier 1 security predicates: IP block-set behavior, topic wildcard semantics, crypto wire encodings, capability resource patterns, and SSRF/local-egress helper predicates. The storage-heavy capability target and Wasmtime-heavy SSRF target are feature-gated so normal workspace builds stay unchanged. Closes #1086.
- Added parser and wire-format fuzz targets for capsule manifest TOML, runtime config TOML, admin/kernel JSON messages, and opt-in gateway request-body JSON parsing. The manifest and gateway targets are feature-gated so the cheap default fuzz target set does not pull the full capsule or HTTP gateway dependency graphs. Closes #1087.
- Runtime capsule commands can now use the existing `elicit()` host API, not only install/upgrade lifecycle hooks. The same host-generated request id, principal-stamped request stream, same-principal response filter, bounded timeout, and cancellation path apply. Runtime E2E now proves a normal capsule command elicit ignores a wrong-principal response before accepting the caller's answer, and crash recovery now kills the daemon while an elicit waiter is in flight to prove the command fails bounded instead of hanging. Closes #1068.
- Daemon capsule discovery now includes the configured workspace root's `.astrid/capsules` directory in addition to the default principal install directory, so capsules installed by local workspace flows are picked up when the daemon starts or reloads from that workspace. Closes #1068.
- Added runtime e2e coverage gates for the built-in CLI, gateway HTTP routes, and first-party capsule commands, plus a dedicated fake OpenAI-compatible runtime harness and CI workflow that installs current capsule artifacts in an isolated `ASTRID_HOME`. Closes #1068.
Expand Down
51 changes: 49 additions & 2 deletions fuzz/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,41 @@ cargo-fuzz = true
arbitrary = { version = "1", features = ["derive"] }
astrid-capabilities = { path = "../crates/astrid-capabilities", optional = true }
astrid-capsule = { path = "../crates/astrid-capsule", features = ["fuzzing"], optional = true }
astrid-config = { path = "../crates/astrid-config", optional = true }
astrid-core = { path = "../crates/astrid-core", optional = true }
astrid-crypto = { path = "../crates/astrid-crypto", optional = true }
astrid-events = { path = "../crates/astrid-events", optional = true }
astrid-gateway = { path = "../crates/astrid-gateway", optional = true }
libfuzzer-sys = "0.4"
serde = { version = "1", optional = true }
serde_json = { version = "1", optional = true }
toml = { version = "0.9", optional = true }

[features]
default = ["core-target", "crypto-target", "events-target"]
default = [
"admin-json-target",
"config-target",
"core-target",
"crypto-target",
"events-target",
]
admin-json-target = ["core-target", "dep:serde", "dep:serde_json"]
capabilities-target = ["dep:astrid-capabilities"]
capsule-target = ["dep:astrid-capsule", "dep:toml"]
config-target = ["dep:astrid-config", "dep:toml"]
core-target = ["dep:astrid-core"]
crypto-target = ["dep:astrid-crypto"]
events-target = ["dep:astrid-events"]
ssrf-target = ["core-target", "dep:astrid-capsule"]
gateway-target = ["dep:astrid-gateway", "dep:serde", "dep:serde_json"]
ssrf-target = ["core-target", "capsule-target"]

[[bin]]
name = "admin_json"
path = "fuzz_targets/admin_json.rs"
test = false
doc = false
bench = false
required-features = ["admin-json-target"]

[[bin]]
name = "capability_patterns"
Expand All @@ -42,6 +65,22 @@ doc = false
bench = false
required-features = ["crypto-target"]

[[bin]]
name = "config_toml"
path = "fuzz_targets/config_toml.rs"
test = false
doc = false
bench = false
required-features = ["config-target"]

[[bin]]
name = "gateway_request_json"
path = "fuzz_targets/gateway_request_json.rs"
test = false
doc = false
bench = false
required-features = ["gateway-target"]

[[bin]]
name = "ip_blockset"
path = "fuzz_targets/ip_blockset.rs"
Expand All @@ -50,6 +89,14 @@ doc = false
bench = false
required-features = ["core-target"]

[[bin]]
name = "manifest_toml"
path = "fuzz_targets/manifest_toml.rs"
test = false
doc = false
bench = false
required-features = ["capsule-target"]

[[bin]]
name = "ssrf_helpers"
path = "fuzz_targets/ssrf_helpers.rs"
Expand Down
16 changes: 11 additions & 5 deletions fuzz/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@ Install `cargo-fuzz`, then run targets from the repository root:
cargo fuzz run ip_blockset
cargo fuzz run topic_patterns
cargo fuzz run crypto_wire
cargo fuzz run admin_json
cargo fuzz run config_toml
cargo fuzz run capability_patterns --features capabilities-target
cargo fuzz run manifest_toml --features capsule-target
cargo fuzz run gateway_request_json --features gateway-target
cargo fuzz run ssrf_helpers --features ssrf-target
```

Expand All @@ -39,8 +43,10 @@ Fuzz targets should assert Astrid invariants, not only "does not panic":
crate.

The default target set covers cheap, deterministic Tier 1 predicates from
issue #1084. `capability_patterns` is feature-gated because the full
`astrid-capabilities` crate pulls storage dependencies, and `ssrf_helpers` is
feature-gated because it compiles the Wasmtime-backed `astrid-capsule` crate.
Parser, archive, admin-state, HTTP, CLI, and Wasm/WIT targets should be added
in follow-up slices.
issue #1084 plus the cheap parser/wire-format targets. `capability_patterns`
is feature-gated because the full `astrid-capabilities` crate pulls storage
dependencies, `manifest_toml` and `ssrf_helpers` are feature-gated because they
compile the Wasmtime-backed `astrid-capsule` crate, and
`gateway_request_json` is feature-gated because it pulls the HTTP gateway
dependency graph. Archive, admin-state, full HTTP boundary, CLI, and Wasm/WIT
targets should be added in follow-up slices.
110 changes: 110 additions & 0 deletions fuzz/fuzz_targets/admin_json.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
#![no_main]

use astrid_core::kernel_api::{
AdminKernelRequest, AdminKernelResponse, KernelRequest, KernelResponse,
};
use libfuzzer_sys::fuzz_target;
use serde_json::Value;

fuzz_target!(|data: &[u8]| {
let Ok(value) = serde_json::from_slice::<Value>(data) else {
return;
};

assert_unknown_admin_methods_fail_closed(&value);

try_roundtrip::<AdminKernelRequest>(&value, assert_admin_request_invariants);
try_roundtrip::<AdminKernelResponse>(&value, assert_admin_response_invariants);
try_roundtrip::<KernelRequest>(&value, |_| {});
try_roundtrip::<KernelResponse>(&value, |_| {});
});

fn try_roundtrip<T>(value: &Value, check: impl Fn(&T))
where
T: serde::Serialize + serde::de::DeserializeOwned,
{
let Ok(parsed) = serde_json::from_value::<T>(value.clone()) else {
return;
};
check(&parsed);

let encoded = serde_json::to_value(&parsed).expect("parsed wire type must serialize");
let reparsed: T = serde_json::from_value(encoded).expect("serialized wire type must reparse");
check(&reparsed);
}

fn assert_admin_request_invariants(req: &AdminKernelRequest) {
let encoded = serde_json::to_value(req).expect("admin request must serialize");
assert!(
encoded.get("method").and_then(Value::as_str).is_some(),
"serialized admin requests must carry an explicit method tag"
);

if let Some(request_id) = &req.request_id {
assert_eq!(
encoded.get("request_id").and_then(Value::as_str),
Some(request_id.as_str())
);
}
}

fn assert_admin_response_invariants(resp: &AdminKernelResponse) {
let encoded = serde_json::to_value(resp).expect("admin response must serialize");
assert!(
encoded.get("status").and_then(Value::as_str).is_some(),
"serialized admin responses must carry an explicit status tag"
);

if let Some(request_id) = &resp.request_id {
assert_eq!(
encoded.get("request_id").and_then(Value::as_str),
Some(request_id.as_str())
);
}
}

fn assert_unknown_admin_methods_fail_closed(value: &Value) {
let Some(method) = value
.as_object()
.and_then(|obj| obj.get("method"))
.and_then(Value::as_str)
else {
return;
};

if !KNOWN_ADMIN_METHODS.contains(&method) {
assert!(
serde_json::from_value::<AdminKernelRequest>(value.clone()).is_err(),
"unknown admin method {method:?} must not deserialize as an allowed request"
);
Comment on lines +75 to +79
}
}

const KNOWN_ADMIN_METHODS: &[&str] = &[
"AgentCreate",
"AgentDelete",
"AgentDisable",
"AgentEnable",
"AgentList",
"AgentModify",
"CapsGrant",
"CapsRevoke",
"CapsTokenList",
"CapsTokenMint",
"CapsTokenRevoke",
"GroupCreate",
"GroupDelete",
"GroupList",
"GroupModify",
"InviteIssue",
"InviteList",
"InviteRedeem",
"InviteRevoke",
"PairDeviceIssue",
"PairDeviceList",
"PairDeviceRedeem",
"PairDeviceRevoke",
"QuotaGet",
"QuotaSet",
"UsageGet",
];
25 changes: 25 additions & 0 deletions fuzz/fuzz_targets/config_toml.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
#![no_main]

use astrid_config::Config;
use libfuzzer_sys::fuzz_target;

fuzz_target!(|data: &[u8]| {
let Ok(src) = std::str::from_utf8(data) else {
return;
};

let value_result = toml::from_str::<toml::Value>(src);
let config_result = toml::from_str::<Config>(src);

if let Ok(config) = config_result {
assert!(
value_result.is_ok(),
"typed config deserialization implies syntactically valid TOML"
);

let serialized = toml::to_string(&config).expect("parsed config must serialize");
let roundtrip: Config =
toml::from_str(&serialized).expect("serialized config must deserialize");
let _ = astrid_config::validate::validate(&roundtrip);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Instead of discarding the validation result, we can assert a stronger semantic invariant: if the original deserialized config is valid, the roundtripped config must also remain valid. This helps catch any serialization/deserialization bugs that silently corrupt valid configurations.

        if astrid_config::validate::validate(&config).is_ok() {
            astrid_config::validate::validate(&roundtrip)
                .expect("a valid config must remain valid after a serialization roundtrip");
        }
References
  1. When a Result is assumed to never be an Err, use .expect() with a descriptive message to enforce this invariant rather than silently ignoring a potential error with if let Ok(...).

}
});
32 changes: 32 additions & 0 deletions fuzz/fuzz_targets/gateway_request_json.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
#![no_main]

use libfuzzer_sys::fuzz_target;
use serde::de::DeserializeOwned;

fuzz_target!(|data: &[u8]| {
try_parse::<astrid_gateway::routes::agent::ApprovalResponseRequest>(data);
try_parse::<astrid_gateway::routes::agent::ElicitResponseRequest>(data);
try_parse::<astrid_gateway::routes::agent::PromptRequest>(data);
Comment on lines +6 to +9
try_parse::<astrid_gateway::routes::auth::PairDeviceIssueRequest>(data);
try_parse::<astrid_gateway::routes::auth::PairDeviceRedeemRequest>(data);
try_parse::<astrid_gateway::routes::auth::RedeemRequest>(data);
try_parse::<astrid_gateway::routes::caps::GrantRequest>(data);
try_parse::<astrid_gateway::routes::caps::RevokeRequest>(data);
try_parse::<astrid_gateway::routes::capsules::InstallRequest>(data);
try_parse::<astrid_gateway::routes::env::EnvWriteRequest>(data);
try_parse::<astrid_gateway::routes::groups::CreateGroupRequest>(data);
try_parse::<astrid_gateway::routes::groups::ModifyGroupRequest>(data);
try_parse::<astrid_gateway::routes::invites::IssueRequest>(data);
try_parse::<astrid_gateway::routes::models::SetActiveModelRequest>(data);
try_parse::<astrid_gateway::routes::principals::CreatePrincipalRequest>(data);
try_parse::<astrid_gateway::routes::principals::ModifyPrincipalRequest>(data);
try_parse::<astrid_gateway::routes::quotas::QuotaRequest>(data);
try_parse::<astrid_gateway::routes::sessions::SessionUpdateRequest>(data);
});

fn try_parse<T>(data: &[u8])
where
T: DeserializeOwned,
{
let _ = serde_json::from_slice::<T>(data);
}
Loading
Loading