Skip to content
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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file.

## [Unreleased]

### Fixed
- Runner kernel events now use `assay.runner.kernel_event.v1`, whose `operation_flags`
label `exclusive` means `O_EXCL` (`0o200`). The v0 mapping tested bit `0o400` (`O_NOCTTY`), so
v0 `exclusive` labels did not mean an exclusive create and `O_EXCL` opens went unlabelled. v0 stays
frozen so retained archives keep verifying, and incident packages admit the new
`runner-kernel-ndjson-v1` input format with the same rules and observation ceiling. `flags` was
always authoritative.

## [6.15.0] - 2026-10-21

Candidate source for optional MCP startup context, CLI refusal handling, SDK
Expand Down
48 changes: 33 additions & 15 deletions crates/assay-evidence/src/incident_package/verify.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ use assay_runner_schema::{
is_known_kernel_event_key, CaptureOrigin, CgroupCorrelationStatus, CoverageCompleteness,
CoverageDescriptor, EffectDimension, KernelEventValidationError, KernelLayerEvent,
KernelLayerStatus, ObservationHealth, COVERAGE_DESCRIPTOR_SCHEMA, KERNEL_EVENT_SCHEMA,
OBSERVATION_HEALTH_SCHEMA, REQUIRED_KERNEL_EVENT_KEYS,
KERNEL_EVENT_SCHEMA_V1, OBSERVATION_HEALTH_SCHEMA, REQUIRED_KERNEL_EVENT_KEYS,
};
use ed25519_dalek::pkcs8::DecodePublicKey;
use ed25519_dalek::VerifyingKey;
Expand Down Expand Up @@ -274,7 +274,7 @@ const VALID_SURFACES: [&str; 5] = [
"transcript_join",
];

const ADMITTED_FORMATS: [&str; 12] = [
const ADMITTED_FORMATS: [&str; 13] = [
"assay-bundle-v1",
"assay-health-json",
"assay-coverage-json",
Expand All @@ -283,6 +283,7 @@ const ADMITTED_FORMATS: [&str; 12] = [
"ed25519-public-key-pem",
"proxy-decision-ndjson",
"runner-kernel-ndjson-v0",
"runner-kernel-ndjson-v1",
"policy-bytes",
"dsse-attestation",
"attestation-report",
Expand Down Expand Up @@ -1408,9 +1409,10 @@ fn source_byte_limit(format: &str, limits: &ContextLimits) -> Option<BudgetLimit
let field: fn(&ContextLimits) -> u64 = match format {
"policy-bytes" => |l| l.policy_bytes,
"ed25519-public-key-pem" => |l| l.key_bytes,
"proxy-decision-ndjson" | "runner-kernel-ndjson-v0" | "inspector-protocol-793d103" => {
|l| l.transcript_bytes
}
"proxy-decision-ndjson"
| "runner-kernel-ndjson-v0"
| "runner-kernel-ndjson-v1"
| "inspector-protocol-793d103" => |l| l.transcript_bytes,
"assay-health-json" | "assay-coverage-json" | "incident-context-v1" => |l| l.health_bytes,
"incident-results-v1" | "attestation-report" => |l| l.retained_report_bytes,
"dsse-attestation" => |l| l.envelope_bytes,
Expand Down Expand Up @@ -1461,7 +1463,10 @@ fn preflight_non_bundle_source(
check_source_bytes(&input.format, bytes.len() as u64, context)?;
return Ok(());
}
"proxy-decision-ndjson" | "runner-kernel-ndjson-v0" | "inspector-protocol-793d103" => {
"proxy-decision-ndjson"
| "runner-kernel-ndjson-v0"
| "runner-kernel-ndjson-v1"
| "inspector-protocol-793d103" => {
check_source_bytes(&input.format, bytes.len() as u64, context)?;
std::str::from_utf8(bytes).map_err(|_| ResolveRefError::InputShape)?
}
Expand Down Expand Up @@ -1495,7 +1500,7 @@ fn preflight_non_bundle_source(
preflight_json(line, SourceJsonKind::Proxy, source_budget, context)?;
}
}
"runner-kernel-ndjson-v0" => {
"runner-kernel-ndjson-v0" | "runner-kernel-ndjson-v1" => {
for raw_line in text.split_inclusive('\n') {
let line = kernel_record_line(raw_line)?;
source_budget
Expand Down Expand Up @@ -1807,12 +1812,12 @@ fn original_ref_input<'a>(
.any(|event| {
reference.locator == canonical_event_locator(&event.typed.run_id, event.typed.seq)
}),
"proxy-decision-ndjson" | "runner-kernel-ndjson-v0" => {
"proxy-decision-ndjson" | "runner-kernel-ndjson-v0" | "runner-kernel-ndjson-v1" => {
let text = std::str::from_utf8(bytes).map_err(|_| ResolveRefError::InputShape)?;
let mut offset = 0usize;
let mut found = false;
for (ordinal, raw_line) in text.split_inclusive('\n').enumerate() {
let line = if input.format == "runner-kernel-ndjson-v0" {
let line = if kernel_event_schema_for_format(&input.format).is_some() {
kernel_record_line(raw_line)?
} else {
let line = raw_line.strip_suffix('\n').unwrap_or(raw_line);
Expand Down Expand Up @@ -2153,7 +2158,7 @@ fn retained_context_inventory(
}
}
for input in &inventory.inputs {
if input.format == "runner-kernel-ndjson-v0" {
if kernel_event_schema_for_format(&input.format).is_some() {
for surface in &input.surfaces {
let static_surface = match surface.as_str() {
"filesystem" => Some("filesystem"),
Expand Down Expand Up @@ -2352,13 +2357,26 @@ fn canonical_action_payload_is_valid(event: &ParsedCanonicalEvent) -> bool {
}
}

fn validate_kernel_event_value(value: &serde_json::Value) -> Result<&'static str, ResolveRefError> {
/// The kernel event schema each runner kernel source format carries. A format admits
/// only its own schema, so one format token never stands for two label mappings.
fn kernel_event_schema_for_format(format: &str) -> Option<&'static str> {
match format {
"runner-kernel-ndjson-v0" => Some(KERNEL_EVENT_SCHEMA),
"runner-kernel-ndjson-v1" => Some(KERNEL_EVENT_SCHEMA_V1),
_ => None,
}
}

fn validate_kernel_event_value(
value: &serde_json::Value,
format: &str,
) -> Result<&'static str, ResolveRefError> {
let obj = value.as_object().ok_or(ResolveRefError::InputShape)?;
let schema = obj
.get("schema")
.and_then(serde_json::Value::as_str)
.ok_or(ResolveRefError::InputShape)?;
if schema != KERNEL_EVENT_SCHEMA {
if Some(schema) != kernel_event_schema_for_format(format) {
return Err(ResolveRefError::UnknownSchema);
}
if obj.keys().any(|key| !is_known_kernel_event_key(key))
Expand Down Expand Up @@ -2471,7 +2489,7 @@ fn admitted_action_records(
offset += raw_line.len();
}
}
"runner-kernel-ndjson-v0" => {
"runner-kernel-ndjson-v0" | "runner-kernel-ndjson-v1" => {
let text = std::str::from_utf8(bytes).map_err(|_| ResolveRefError::InputShape)?;
let mut offset = 0usize;
let mut observed_surfaces = BTreeSet::new();
Expand All @@ -2480,7 +2498,7 @@ fn admitted_action_records(
validate_json_strict(line).map_err(|_| ResolveRefError::InputShape)?;
let value: serde_json::Value =
serde_json::from_str(line).map_err(|_| ResolveRefError::InputShape)?;
let surface = validate_kernel_event_value(&value)?;
let surface = validate_kernel_event_value(&value, &input.format)?;
observed_surfaces.insert(surface);
records.push(AdmittedActionRecord {
reference: PackageRef {
Expand Down Expand Up @@ -3379,7 +3397,7 @@ fn admit_metadata(
return Err(IncidentReason::Mapping);
}
}
"runner-kernel-ndjson-v0" => {
"runner-kernel-ndjson-v0" | "runner-kernel-ndjson-v1" => {
let allowed = ["filesystem", "network", "process"];
let mut last_idx = None;
for surface in &input.surfaces {
Expand Down
95 changes: 89 additions & 6 deletions crates/assay-evidence/tests/incident_kernel_admission.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,17 @@ fn examined_unit(
locator: &str,
surface: &str,
value: Value,
) -> Value {
examined_unit_in_format(KERNEL_FORMAT, input_id, sha256, locator, surface, value)
}

fn examined_unit_in_format(
format: &str,
input_id: &str,
sha256: &str,
locator: &str,
surface: &str,
value: Value,
) -> Value {
json!({
"id": unit_id_hex(input_id, sha256, locator),
Expand All @@ -117,7 +128,7 @@ fn examined_unit(
"result": {
"mode": "inline",
"value": {
"operation": KERNEL_FORMAT,
"operation": format,
"source": {
"input_id": input_id,
"sha256": sha256,
Expand Down Expand Up @@ -175,6 +186,14 @@ fn build_package(
}

fn single_line_kernel_package(line: &str, declared_surfaces: &[&str]) -> Vec<u8> {
single_line_kernel_package_in_format(line, declared_surfaces, KERNEL_FORMAT)
}

fn single_line_kernel_package_in_format(
line: &str,
declared_surfaces: &[&str],
format: &str,
) -> Vec<u8> {
let raw_bytes = format!("{line}\n").into_bytes();
let sha = sha256_hex(&raw_bytes);
let locator = format!("line:0:0:{}", line.len());
Expand All @@ -187,6 +206,8 @@ fn single_line_kernel_package(line: &str, declared_surfaces: &[&str]) -> Vec<u8>
unit_surface,
parsed_val,
)];
// The unit's operation names the input format it came from.
units[0]["result"]["value"]["operation"] = json!(format);
sort_units_by_id(&mut units);

let fs_sources: &[&str] = if declared_surfaces.contains(&"filesystem") {
Expand Down Expand Up @@ -237,7 +258,7 @@ fn single_line_kernel_package(line: &str, declared_surfaces: &[&str]) -> Vec<u8>
});
let inputs = json!([{
"id": "kernel",
"format": KERNEL_FORMAT,
"format": format,
"sha256": sha,
"bytes": raw_bytes.len() as u64,
"surfaces": declared_surfaces,
Expand Down Expand Up @@ -561,7 +582,18 @@ fn kernel_all_emitted_variants_optional_fields_and_present_empty_verify() {
#[test]
fn kernel_surface_observation_is_unknown_without_context_and_at_most_limited_with_complete_health()
{
let line = r#"{"schema":"assay.runner.kernel_event.v0","run_id":"run_obs","seq":0,"pid":77,"event_type":1,"kind":"openat","value":"/tmp/a","flags":0,"mode":420,"return_value":3,"access_mode":"read","status":"success"}"#;
assert_kernel_observation_ceiling(KERNEL_FORMAT, KERNEL_SCHEMA);
}

#[test]
fn kernel_v1_surface_observation_has_the_same_ceiling_as_v0() {
assert_kernel_observation_ceiling(KERNEL_FORMAT_V1, "assay.runner.kernel_event.v1");
}

fn assert_kernel_observation_ceiling(format: &str, schema: &str) {
let line = &format!(
r#"{{"schema":"{schema}","run_id":"run_obs","seq":0,"pid":77,"event_type":1,"kind":"openat","value":"/tmp/a","flags":0,"mode":420,"return_value":3,"access_mode":"read","status":"success"}}"#
);
let kernel_bytes = format!("{line}\n").into_bytes();
let kernel_sha = sha256_hex(&kernel_bytes);
let locator = format!("line:0:0:{}", line.len());
Expand All @@ -583,7 +615,8 @@ fn kernel_surface_observation_is_unknown_without_context_and_at_most_limited_wit
let health_bytes = jcs_line_bytes(&health_doc);
let health_sha = sha256_hex(&health_bytes);

let units = vec![examined_unit(
let units = vec![examined_unit_in_format(
format,
"kernel",
&kernel_sha,
&locator,
Expand All @@ -601,7 +634,7 @@ fn kernel_surface_observation_is_unknown_without_context_and_at_most_limited_wit
},
{
"id": "kernel",
"format": KERNEL_FORMAT,
"format": format,
"sha256": kernel_sha,
"bytes": kernel_bytes.len() as u64,
"surfaces": ["filesystem"],
Expand Down Expand Up @@ -738,7 +771,7 @@ fn kernel_surface_observation_is_unknown_without_context_and_at_most_limited_wit
},
{
"id": "kernel",
"format": KERNEL_FORMAT,
"format": format,
"sha256": kernel_sha,
"bytes": kernel_bytes.len() as u64,
"surfaces": ["filesystem"],
Expand Down Expand Up @@ -774,6 +807,56 @@ fn kernel_surface_observation_is_unknown_without_context_and_at_most_limited_wit
assert_eq!(limited_with_cov_report.reason, None);
}

// Linux open(2) bits: O_EXCL is 0o200 and O_NOCTTY is 0o400. 524481 is
// O_WRONLY|O_CREAT|O_EXCL|O_CLOEXEC and 655616 is O_CLOEXEC|O_NOFOLLOW|O_NOCTTY.
const KERNEL_FORMAT_V1: &str = "runner-kernel-ndjson-v1";
const V1_O_EXCL: &str = r#"{"schema":"assay.runner.kernel_event.v1","run_id":"r1","seq":0,"pid":1,"event_type":1,"kind":"openat","value":"/tmp/a","flags":524481,"mode":384,"return_value":3,"access_mode":"write","operation_flags":["create","exclusive"],"status":"success"}"#;
const V1_O_NOCTTY: &str = r#"{"schema":"assay.runner.kernel_event.v1","run_id":"r1","seq":0,"pid":1,"event_type":1,"kind":"openat","value":"/tmp/a","flags":655616,"mode":0,"return_value":3,"access_mode":"read","status":"success"}"#;
const V0_O_EXCL: &str = r#"{"schema":"assay.runner.kernel_event.v0","run_id":"r1","seq":0,"pid":1,"event_type":1,"kind":"openat","value":"/tmp/a","flags":524481,"mode":384,"return_value":3,"access_mode":"write","operation_flags":["create"],"status":"success"}"#;
const V0_O_NOCTTY: &str = r#"{"schema":"assay.runner.kernel_event.v0","run_id":"r1","seq":0,"pid":1,"event_type":1,"kind":"openat","value":"/tmp/a","flags":655616,"mode":0,"return_value":3,"access_mode":"read","operation_flags":["exclusive"],"status":"success"}"#;

#[test]
fn kernel_v1_format_admits_corrected_open_labels() {
let ctx = ContextInput::default();
for line in [V1_O_EXCL, V1_O_NOCTTY] {
let pkg = single_line_kernel_package_in_format(line, &["filesystem"], KERNEL_FORMAT_V1);
let report = verify_incident_package(&pkg, &ctx);
assert_eq!(
(report.outcome, report.reason),
(IncidentOutcome::PackageVerified, None),
"{line}"
);
}
let legacy_label = V1_O_EXCL.replace(r#"["create","exclusive"]"#, r#"["create"]"#);
let pkg =
single_line_kernel_package_in_format(&legacy_label, &["filesystem"], KERNEL_FORMAT_V1);
assert_eq!(
verify_incident_package(&pkg, &ctx).reason,
Some(IncidentReason::InputShape)
);
}

#[test]
fn kernel_v0_format_keeps_its_frozen_open_labels_and_formats_do_not_mix() {
let ctx = ContextInput::default();
for line in [V0_O_EXCL, V0_O_NOCTTY] {
let report =
verify_incident_package(&single_line_kernel_package(line, &["filesystem"]), &ctx);
assert_eq!(report.outcome, IncidentOutcome::PackageVerified, "{line}");
}
let v1_in_v0 = single_line_kernel_package(V1_O_EXCL, &["filesystem"]);
assert_eq!(
verify_incident_package(&v1_in_v0, &ctx).reason,
Some(IncidentReason::UnknownSchema)
);
let v0_in_v1 =
single_line_kernel_package_in_format(V0_O_EXCL, &["filesystem"], KERNEL_FORMAT_V1);
assert_eq!(
verify_incident_package(&v0_in_v1, &ctx).reason,
Some(IncidentReason::UnknownSchema)
);
}

#[test]
fn kernel_rejects_invalid_schema_and_inconsistent_event_type_kind_or_fields() {
let ctx = ContextInput::default();
Expand Down
7 changes: 5 additions & 2 deletions crates/assay-runner-core/src/kernel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,11 @@ use assay_runner_schema::{
use std::collections::BTreeMap;
use thiserror::Error;

// Keep a local public constant for rustdoc/semver consumers; the schema owns its value.
// Keep local public constants for rustdoc/semver consumers; the schema owns their values.
/// The schema kernel events were written with before v1. Retained archives still carry it.
pub const KERNEL_EVENT_SCHEMA: &str = assay_runner_schema::KERNEL_EVENT_SCHEMA;
/// The schema this builder writes kernel events with.
pub const KERNEL_EVENT_SCHEMA_V1: &str = assay_runner_schema::KERNEL_EVENT_SCHEMA_V1;

mod budget;
mod decode;
Expand Down Expand Up @@ -219,7 +222,7 @@ impl KernelLayerBuilder {
}

let record = KernelLayerEvent {
schema: KERNEL_EVENT_SCHEMA.to_string(),
schema: KERNEL_EVENT_SCHEMA_V1.to_string(),
run_id: self.run_id.clone(),
seq: self.next_seq,
pid: event.pid,
Expand Down
4 changes: 2 additions & 2 deletions crates/assay-runner-core/src/kernel/decode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use assay_common::{
MonitorEvent, EVENT_CONNECT, EVENT_CONNECT_BLOCKED, EVENT_EXEC, EVENT_FILE_BLOCKED,
EVENT_OPENAT, EVENT_SENDMSG, EVENT_SENDTO,
};
use assay_runner_schema::{open_access_mode, open_operation_flags, open_status};
use assay_runner_schema::{open_access_mode, open_operation_flags_v1, open_status};
use std::net::{Ipv4Addr, Ipv6Addr};

#[derive(Debug, Clone, PartialEq, Eq)]
Expand Down Expand Up @@ -67,7 +67,7 @@ fn decoded_open_event(event: &MonitorEvent) -> DecodedKernelEvent {
resolve: (event.resolve != 0).then_some(event.resolve),
return_value: Some(event.return_value),
access_mode: Some(open_access_mode(flags).to_string()),
operation_flags: open_operation_flags(flags),
operation_flags: open_operation_flags_v1(flags),
status: Some(open_status(event.return_value).to_string()),
}
}
Expand Down
Loading
Loading