diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..52d2365 --- /dev/null +++ b/.pre-commit-config.yaml @@ -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 diff --git a/Cargo.lock b/Cargo.lock index ef50523..f8a7beb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -58,11 +58,22 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "cak-host-adapter" +version = "0.1.0" +dependencies = [ + "cak-runtime-core", + "pretty_assertions", + "serde", + "serde_json", +] + [[package]] name = "cak-runtime-cli" version = "0.1.0" dependencies = [ "anyhow", + "cak-host-adapter", "cak-runtime-core", "clap", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 975e607..a53a336 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] members = [ "crates/cak-runtime-core", + "crates/cak-host-adapter", "crates/cak-runtime-cli", ] resolver = "2" @@ -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" } diff --git a/crates/cak-host-adapter/Cargo.toml b/crates/cak-host-adapter/Cargo.toml new file mode 100644 index 0000000..9b24fa9 --- /dev/null +++ b/crates/cak-host-adapter/Cargo.toml @@ -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 } diff --git a/crates/cak-host-adapter/src/lib.rs b/crates/cak-host-adapter/src/lib.rs new file mode 100644 index 0000000..3ab92f6 --- /dev/null +++ b/crates/cak-host-adapter/src/lib.rs @@ -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, + } +} diff --git a/crates/cak-host-adapter/tests/adapter.rs b/crates/cak-host-adapter/tests/adapter.rs new file mode 100644 index 0000000..83e0a50 --- /dev/null +++ b/crates/cak-host-adapter/tests/adapter.rs @@ -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") + ); +} diff --git a/crates/cak-runtime-cli/Cargo.toml b/crates/cak-runtime-cli/Cargo.toml index d277216..b8bd23f 100644 --- a/crates/cak-runtime-cli/Cargo.toml +++ b/crates/cak-runtime-cli/Cargo.toml @@ -18,3 +18,4 @@ anyhow = { workspace = true } clap = { workspace = true } serde_json = { workspace = true } cak-runtime-core = { workspace = true } +cak-host-adapter = { workspace = true } diff --git a/crates/cak-runtime-cli/src/main.rs b/crates/cak-runtime-cli/src/main.rs index 2b49801..fb7d59e 100644 --- a/crates/cak-runtime-cli/src/main.rs +++ b/crates/cak-runtime-cli/src/main.rs @@ -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}; @@ -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 { @@ -67,6 +78,10 @@ fn run(cli: Cli) -> anyhow::Result { 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), } } @@ -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 { + read_request(path).with_context(|| format!("parsing proposal file {}", path.display())) +} + +fn cmd_gate(path: &Path, enforce_exit_code: bool) -> anyhow::Result { + 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 diff --git a/crates/cak-runtime-cli/tests/cli.rs b/crates/cak-runtime-cli/tests/cli.rs index f5b0db6..8edf706 100644 --- a/crates/cak-runtime-cli/tests/cli.rs +++ b/crates/cak-runtime-cli/tests/cli.rs @@ -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"); +} diff --git a/docs/22_cak_runtime_v0.md b/docs/22_cak_runtime_v0.md index ef2a623..4c06b2c 100644 --- a/docs/22_cak_runtime_v0.md +++ b/docs/22_cak_runtime_v0.md @@ -14,16 +14,21 @@ it does. It is implemented as a small Rust workspace inside this repository: ```text Cargo.toml workspace root (resolver 2, edition 2021) crates/cak-runtime-core/ the engine: data models + evaluators (no I/O) +crates/cak-host-adapter/ maps runtime decisions to host-facing outcomes crates/cak-runtime-cli/ cakrt: a thin CLI over the core runtime-fixtures/ request/expected JSON pairs (the contract, executable) skills/cak-rdr-review/ Agent-Skills-compatible pilot host package +skills/cak-host-adapter/ thin Python launcher for the Rust adapter ``` ## What it is - A pure, deterministic function from a request to a decision. - A set of four small **evaluators** (gates) composed by priority. -- A CLI, `cakrt`, that reads a request file and prints a decision. +- A host adapter that maps runtime decisions to `proceed`, `deny`, + `inject_context`, and `needs_*` outcomes. +- A CLI, `cakrt`, that reads request/proposal files and prints decisions or + host-facing outcomes. The design follows the RDR-001 working hypothesis that an agent-native skill is a *state/action-conditioned intervention* with an activation predicate, a @@ -131,6 +136,9 @@ cakrt eval --request --enforce-exit-code cakrt fixture-check \ --request runtime-fixtures/rdr-review/not_ready_merge.request.json \ --expected runtime-fixtures/rdr-review/not_ready_merge.expected.json + +# Evaluate a host proposal and print a host-facing outcome: +cakrt gate --proposal runtime-fixtures/rdr-review/pending_trace_status_blocked.request.json ``` A host skill calls the runtime exactly the same way: it assembles an @@ -139,6 +147,10 @@ A host skill calls the runtime exactly the same way: it assembles an decision is authoritative over prose. See `skills/cak-rdr-review/SKILL.md` for the pilot host package. +For hosts that need a pre-execution adapter, `cakrt gate --proposal ` +returns a host-facing outcome. The bundled `skills/cak-host-adapter` package is +only a Python launcher around that Rust command; it does not implement policy. + ## Running the fixtures There are 15 canonical request/expected pairs in `runtime-fixtures/`, one or diff --git a/skills/cak-host-adapter/SKILL.md b/skills/cak-host-adapter/SKILL.md new file mode 100644 index 0000000..ba94862 --- /dev/null +++ b/skills/cak-host-adapter/SKILL.md @@ -0,0 +1,17 @@ +--- +name: cak-host-adapter +description: Run the Rust CAK host adapter gate from an Agent-Skills-compatible wrapper. +--- + +# CAK Host Adapter + +This skill is a thin launcher for the Rust host adapter. It does not implement +policy logic in Python. The script calls: + +```sh +cakrt gate --proposal +``` + +The proposal JSON currently uses the same shape as `EvalRequest`; the Rust +adapter maps the runtime `Decision` to a host-facing outcome before any host +executes the proposed action. diff --git a/skills/cak-host-adapter/scripts/cak_gate.py b/skills/cak-host-adapter/scripts/cak_gate.py new file mode 100755 index 0000000..34793db --- /dev/null +++ b/skills/cak-host-adapter/scripts/cak_gate.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +"""Thin Python launcher for `cakrt gate`. + +The policy decision stays in Rust. This script exists so Agent-Skills-style +hosts that expect a Python script can invoke the canonical CAK adapter binary. +""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +from pathlib import Path + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run the CAK Rust host adapter gate.") + parser.add_argument("--proposal", required=True, type=Path, help="Path to proposal JSON.") + parser.add_argument("--cakrt", default="cakrt", help="Path to the cakrt binary.") + parser.add_argument( + "--enforce-exit-code", + action="store_true", + help="Exit 2 when the Rust adapter returns outcome=deny.", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + command = [args.cakrt, "gate", "--proposal", str(args.proposal)] + if args.enforce_exit_code: + command.append("--enforce-exit-code") + + result = subprocess.run(command, check=False, capture_output=True, text=True) + sys.stdout.write(result.stdout) + sys.stderr.write(result.stderr) + return result.returncode + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_cak_host_adapter_skill.py b/tests/test_cak_host_adapter_skill.py new file mode 100644 index 0000000..77bf9af --- /dev/null +++ b/tests/test_cak_host_adapter_skill.py @@ -0,0 +1,54 @@ +import json +import os +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "skills" / "cak-host-adapter" / "scripts" / "cak_gate.py" + + +def test_skill_shim_delegates_to_cakrt_gate(tmp_path: Path) -> None: + proposal = tmp_path / "proposal.json" + proposal.write_text('{"schema_version":"0.1.0"}', encoding="utf-8") + argv_path = tmp_path / "argv.json" + + fake_cakrt = tmp_path / "fake-cakrt" + fake_cakrt.write_text( + "\n".join( + [ + "#!/usr/bin/env python3", + "import json, pathlib, sys", + ( + f"pathlib.Path({str(argv_path)!r}).write_text(" + "json.dumps(sys.argv), encoding='utf-8')" + ), + "print(json.dumps({'schema_version':'0.1.0','outcome':'proceed'}))", + ] + ), + encoding="utf-8", + ) + os.chmod(fake_cakrt, 0o755) + + result = subprocess.run( + [ + sys.executable, + str(SCRIPT), + "--proposal", + str(proposal), + "--cakrt", + str(fake_cakrt), + ], + check=False, + capture_output=True, + text=True, + ) + + assert result.returncode == 0 + assert json.loads(result.stdout)["outcome"] == "proceed" + assert json.loads(argv_path.read_text(encoding="utf-8")) == [ + str(fake_cakrt), + "gate", + "--proposal", + str(proposal), + ]