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
50 changes: 50 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
repos:
- repo: local
hooks:
- id: docs-check
name: docs check
entry: python3 scripts/check_docs.py
language: system
pass_filenames: false

- id: python-ruff
name: python ruff
entry: python3 -m ruff check src tests scripts examples
language: system
pass_filenames: false

- id: python-mypy
name: python mypy
entry: python3 -m mypy src --show-error-codes --pretty
language: system
pass_filenames: false

- id: python-pytest
name: python pytest
entry: python3 -m pytest
language: system
pass_filenames: false

- id: python-demo-smoke
name: python demo smoke
entry: env PYTHONPATH=src python3 examples/v0_1/demo.py
language: system
pass_filenames: false

- id: rust-fmt
name: rust fmt
entry: cargo fmt --check
language: system
pass_filenames: false

- id: rust-clippy
name: rust clippy
entry: cargo clippy --workspace --all-targets -- -D warnings
language: system
pass_filenames: false

- id: rust-test
name: rust test
entry: cargo test --workspace
language: system
pass_filenames: false
11 changes: 11 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
[workspace]
members = [
"crates/cak-runtime-core",
"crates/cak-host-adapter",
"crates/cak-runtime-cli",
]
resolver = "2"
Expand All @@ -25,3 +26,4 @@ anyhow = "1"
clap = { version = "4", features = ["derive"] }
pretty_assertions = "1"
cak-runtime-core = { path = "crates/cak-runtime-core" }
cak-host-adapter = { path = "crates/cak-host-adapter" }
18 changes: 18 additions & 0 deletions crates/cak-host-adapter/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
[package]
name = "cak-host-adapter"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
description = "CAK host adapter: maps EvalRequest proposals through the Rust runtime into host-facing outcomes."

[lints]
workspace = true

[dependencies]
serde = { workspace = true }
cak-runtime-core = { workspace = true }

[dev-dependencies]
serde_json = { workspace = true }
pretty_assertions = { workspace = true }
61 changes: 61 additions & 0 deletions crates/cak-host-adapter/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
//! Host-facing adapter over the CAK runtime.
//!
//! The adapter is the first Rust boundary above `cak-runtime-core`: it accepts
//! a host proposal, evaluates it with the pure runtime, and translates the
//! domain [`Decision`](cak_runtime_core::Decision) into a small outcome enum a
//! host gateway or skill shim can honor before executing an action.

use cak_runtime_core::{Decision, DecisionKind, EvalRequest};
use serde::{Deserialize, Serialize};

/// v0 host proposals deliberately reuse the runtime request shape.
///
/// The host adapter owns the enforcement mapping, not a new schema. A narrower
/// proposal input can be introduced later once real host integrations prove
/// which defaults are stable.
pub type HostProposal = EvalRequest;

/// Host-facing action after runtime evaluation.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HostOutcomeKind {
Proceed,
Deny,
NeedsModification,
InjectContext,
NeedsConfirmation,
NeedsVerification,
}

impl HostOutcomeKind {
#[must_use]
pub fn from_decision(decision: DecisionKind) -> Self {
match decision {
DecisionKind::Allow => HostOutcomeKind::Proceed,
DecisionKind::Block => HostOutcomeKind::Deny,
DecisionKind::Modify => HostOutcomeKind::NeedsModification,
DecisionKind::InjectContext => HostOutcomeKind::InjectContext,
DecisionKind::Ask => HostOutcomeKind::NeedsConfirmation,
DecisionKind::VerifyOnly => HostOutcomeKind::NeedsVerification,
}
}
}

/// Full adapter result: the host-facing outcome plus the underlying decision.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct HostOutcome {
pub schema_version: String,
pub outcome: HostOutcomeKind,
pub decision: Decision,
}

/// Evaluate a host proposal and map the decision into a host outcome.
#[must_use]
pub fn evaluate_proposal(proposal: &HostProposal) -> HostOutcome {
let decision = cak_runtime_core::evaluate(proposal);
HostOutcome {
schema_version: cak_runtime_core::SCHEMA_VERSION.to_string(),
outcome: HostOutcomeKind::from_decision(decision.decision),
decision,
}
}
95 changes: 95 additions & 0 deletions crates/cak-host-adapter/tests/adapter.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
use cak_host_adapter::{evaluate_proposal, HostOutcomeKind, HostProposal};
use cak_runtime_core::DecisionKind;
use pretty_assertions::assert_eq;
use serde_json::json;

fn proposal(value: serde_json::Value) -> HostProposal {
serde_json::from_value(value).expect("valid host proposal")
}

#[test]
fn maps_all_decision_kinds_to_host_outcomes() {
assert_eq!(
HostOutcomeKind::from_decision(DecisionKind::Allow),
HostOutcomeKind::Proceed
);
assert_eq!(
HostOutcomeKind::from_decision(DecisionKind::Block),
HostOutcomeKind::Deny
);
assert_eq!(
HostOutcomeKind::from_decision(DecisionKind::Modify),
HostOutcomeKind::NeedsModification
);
assert_eq!(
HostOutcomeKind::from_decision(DecisionKind::InjectContext),
HostOutcomeKind::InjectContext
);
assert_eq!(
HostOutcomeKind::from_decision(DecisionKind::Ask),
HostOutcomeKind::NeedsConfirmation
);
assert_eq!(
HostOutcomeKind::from_decision(DecisionKind::VerifyOnly),
HostOutcomeKind::NeedsVerification
);
}

#[test]
fn maps_allow_to_proceed() {
let proposal = proposal(json!({
"schema_version": "0.1.0",
"host": { "name": "test-host", "mode": "cli" },
"task": { "kind": "run_workflow" },
"proposed_action": { "kind": "mark_ready" },
"skill": { "id": "workflow.runner" }
}));

let outcome = evaluate_proposal(&proposal);

assert_eq!(outcome.outcome, HostOutcomeKind::Proceed);
assert_eq!(
outcome.decision.decision,
cak_runtime_core::DecisionKind::Allow
);
}

#[test]
fn maps_block_to_deny() {
let proposal = proposal(json!({
"schema_version": "0.1.0",
"host": { "name": "test-host", "mode": "ci" },
"task": { "kind": "review_trace_corpus" },
"state": { "trace_plan_status": "pending" },
"proposed_action": { "kind": "accept_trace_corpus" },
"skill": { "id": "cak.rdr-review" }
}));

let outcome = evaluate_proposal(&proposal);

assert_eq!(outcome.outcome, HostOutcomeKind::Deny);
assert_eq!(
outcome.decision.selected_evaluator.as_deref(),
Some("rdr_review")
);
}

#[test]
fn maps_ask_to_needs_confirmation() {
let proposal = proposal(json!({
"schema_version": "0.1.0",
"host": { "name": "test-host", "mode": "cli" },
"task": { "kind": "run_workflow" },
"proposed_action": { "kind": "mark_ready" },
"skill": { "id": "workflow.runner" },
"stage": { "stage_status": "preconditions_missing" }
}));

let outcome = evaluate_proposal(&proposal);

assert_eq!(outcome.outcome, HostOutcomeKind::NeedsConfirmation);
assert_eq!(
outcome.decision.selected_evaluator.as_deref(),
Some("stage_gate")
);
}
1 change: 1 addition & 0 deletions crates/cak-runtime-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,4 @@ anyhow = { workspace = true }
clap = { workspace = true }
serde_json = { workspace = true }
cak-runtime-core = { workspace = true }
cak-host-adapter = { workspace = true }
31 changes: 31 additions & 0 deletions crates/cak-runtime-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ use std::path::{Path, PathBuf};
use std::process::ExitCode;

use anyhow::Context;
use cak_host_adapter::{HostOutcomeKind, HostProposal};
use cak_runtime_core::{DecisionKind, EvalRequest};
use clap::{Parser, Subcommand};

Expand Down Expand Up @@ -47,6 +48,16 @@ enum Command {
#[arg(long)]
expected: PathBuf,
},
/// Evaluate a host proposal and print a host-facing outcome as pretty JSON.
Gate {
/// Path to the host proposal JSON file.
#[arg(long)]
proposal: PathBuf,
/// Exit 2 when `outcome == deny` (default: exit 0 for any valid
/// outcome).
#[arg(long)]
enforce_exit_code: bool,
},
}

fn main() -> ExitCode {
Expand All @@ -67,6 +78,10 @@ fn run(cli: Cli) -> anyhow::Result<ExitCode> {
enforce_exit_code,
} => cmd_eval(&request, enforce_exit_code),
Command::FixtureCheck { request, expected } => cmd_fixture_check(&request, &expected),
Command::Gate {
proposal,
enforce_exit_code,
} => cmd_gate(&proposal, enforce_exit_code),
}
}

Expand Down Expand Up @@ -113,6 +128,22 @@ fn cmd_fixture_check(request_path: &Path, expected_path: &Path) -> anyhow::Resul
Ok(ExitCode::from(1))
}

fn read_proposal(path: &Path) -> anyhow::Result<HostProposal> {
read_request(path).with_context(|| format!("parsing proposal file {}", path.display()))
}

fn cmd_gate(path: &Path, enforce_exit_code: bool) -> anyhow::Result<ExitCode> {
let proposal = read_proposal(path)?;
let outcome = cak_host_adapter::evaluate_proposal(&proposal);
println!("{}", serde_json::to_string_pretty(&outcome)?);

if enforce_exit_code && outcome.outcome == HostOutcomeKind::Deny {
Ok(ExitCode::from(2))
} else {
Ok(ExitCode::SUCCESS)
}
}

/// Print a human-readable diff between expected and actual decisions.
///
/// Lists the top-level keys that differ, then dumps both pretty bodies so the
Expand Down
53 changes: 53 additions & 0 deletions crates/cak-runtime-cli/tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,3 +115,56 @@ fn fixture_check_fails_on_mismatch() {
.expect("run cakrt");
assert_eq!(status.code(), Some(1));
}

#[test]
fn gate_denies_blocking_decision_by_default_with_zero_exit() {
let output = Command::new(BIN)
.arg("gate")
.arg("--proposal")
.arg(request("rdr-review/pending_trace_status_blocked"))
.output()
.expect("run cakrt gate");
assert_eq!(
output.status.code(),
Some(0),
"gate must exit 0 by default for a valid deny outcome"
);

let outcome: serde_json::Value =
serde_json::from_slice(&output.stdout).expect("stdout is JSON");
assert_eq!(outcome["outcome"], "deny");
assert_eq!(outcome["decision"]["decision"], "block");
assert_eq!(outcome["decision"]["selected_evaluator"], "rdr_review");
}

#[test]
fn gate_denies_blocking_decision_with_exit_two_when_enforced() {
let status = Command::new(BIN)
.arg("gate")
.arg("--proposal")
.arg(request("rdr-review/pending_trace_status_blocked"))
.arg("--enforce-exit-code")
.status()
.expect("run cakrt gate");
assert_eq!(
status.code(),
Some(2),
"deny must exit 2 under --enforce-exit-code"
);
}

#[test]
fn gate_allows_non_rdr_mark_ready() {
let output = Command::new(BIN)
.arg("gate")
.arg("--proposal")
.arg(request("rdr-review/non_rdr_mark_ready_allowed"))
.output()
.expect("run cakrt gate");
assert_eq!(output.status.code(), Some(0));

let outcome: serde_json::Value =
serde_json::from_slice(&output.stdout).expect("stdout is JSON");
assert_eq!(outcome["outcome"], "proceed");
assert_eq!(outcome["decision"]["decision"], "allow");
}
Loading
Loading