diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index 329e08183..08063cb41 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -81,6 +81,7 @@ jobs: publish sema-fmt publish sema-vm publish sema-otel + publish sema-policy publish sema-workflow publish sema-llm publish sema-stdlib diff --git a/CHANGELOG.md b/CHANGELOG.md index dd27a483f..b653b6952 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,35 @@ ### Added +- **Workflow policies — `defpolicy` (#50).** A policy is a compiled, immutable + map that guards a workflow at its boundaries: `:models` (which + provider/model a call may use), `:tools` (per-tool argument constraints over + paths, domains, and commands), `:subjects` (semantic allow/deny rules for the + file, network, command, and external-action a tool actually performs), + `:input` and `:output` (content scanning, with `:redact` and `:block` + actions). Install one with `:policy` in a `defworkflow` or `step` meta map. + Layers compose: any layer may deny, a later layer can only tighten an earlier + one, and the strictest action wins. Every decision is journaled to the run + directory as evidence. `policy/without` is a lexical, audited bypass that + emits a `policy.bypassed` event and never widens the workflow's `:permissions` + sandbox. `sema workflow check` rejects a source file whose policy or approval + use is malformed before the run starts. +- **Durable human approval gates — `approval` (#50).** `(approval :key {…})` + stops a workflow before a sensitive action until a human records a decision. + In `auto` mode on a terminal it prompts; with an approval authority it writes + a request sidecar, exits 3, and waits. A separate trusted process decides with + `sema workflow approve` / `sema workflow reject`, signing with an Ed25519 key + that is never passed to the workflow process; `sema workflow run --resume` + then applies it. Decisions are bound to the run, workflow, code version, + arguments, phase, key, occurrence, and subject digest, so a decision cannot be + replayed onto a different gate or run. `sema workflow approvals` lists pending + requests as text or JSON, and the loopback `sema workflow view` viewer can + record the same signed decision. `sema workflow approval-keygen` creates the + authority. +- **`sema_version_req` in `sema.toml`** — a package can declare the Sema + versions it supports, and `sema pkg` refuses an incompatible install with the + requirement in the error. + - **macOS release binaries are now Developer-ID signed, notarized, and Sigstore-attested (#109, #107, #133).** Every `*-apple-darwin.tar.xz` carries a hardened-runtime signature and a GitHub Artifact Attestation diff --git a/Cargo.lock b/Cargo.lock index e968cf089..9c916476e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -307,6 +307,16 @@ dependencies = [ "objc2", ] +[[package]] +name = "bstr" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" +dependencies = [ + "memchr", + "serde_core", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -1305,6 +1315,19 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "globset" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e47d37d2ae4464254884b60ab7071be2b876a9c35b696bd018ddcc76847309cd" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + [[package]] name = "h2" version = "0.4.13" @@ -3555,6 +3578,7 @@ dependencies = [ "toml", "toml_edit", "tungstenite 0.28.0", + "url", "windows-sys 0.61.2", "xz2", "zip", @@ -3571,6 +3595,7 @@ dependencies = [ "sema-core", "sema-io", "sema-otel", + "sema-policy", "serde", "serde_json", "sha2 0.10.9", @@ -3655,6 +3680,19 @@ dependencies = [ "tokio", ] +[[package]] +name = "sema-policy" +version = "1.33.0" +dependencies = [ + "globset", + "regex", + "sema-core", + "serde_json", + "sha2 0.10.9", + "thiserror 2.0.18", + "url", +] + [[package]] name = "sema-reader" version = "1.33.0" @@ -3702,6 +3740,7 @@ dependencies = [ "sema-io", "sema-llm", "sema-otel", + "sema-policy", "sema-reader", "sema-vm", "sema-workflow", @@ -3763,10 +3802,15 @@ dependencies = [ name = "sema-workflow" version = "1.33.0" dependencies = [ + "base64", + "libc", "md5", + "ring", "sema-core", "serde", "serde_json", + "sha2 0.10.9", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index fc323dfdf..55b031aae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ members = [ "crates/sema-docs", "crates/sema-mcp", "crates/sema-otel", + "crates/sema-policy", "crates/sema-workflow", ] resolver = "2" @@ -43,6 +44,7 @@ default-members = [ "crates/sema-docs", "crates/sema-mcp", "crates/sema-otel", + "crates/sema-policy", "crates/sema-workflow", ] @@ -68,6 +70,7 @@ sema-notebook = { version = "=1.33.0", path = "crates/sema-notebook" } sema-docs = { version = "=1.33.0", path = "crates/sema-docs" } sema-mcp = { version = "=1.33.0", path = "crates/sema-mcp" } sema-otel = { version = "=1.33.0", path = "crates/sema-otel" } +sema-policy = { version = "=1.33.0", path = "crates/sema-policy" } sema-workflow = { version = "=1.33.0", path = "crates/sema-workflow" } tower-lsp = "0.20" @@ -96,6 +99,7 @@ rand = "0.10" uuid = { version = "1", features = ["v4"] } base64 = "0.22" sha2 = "0.10" +ring = "0.17" chrono = "0.4" csv = "1" md5 = "0.8" @@ -109,6 +113,7 @@ unicode-width = "0.1" unicode-segmentation = "1" caseless = "0.2" glob = "0.3" +globset = "0.4" hostname = "0.4" libc = "0.2" pdf-extract = "0.12" diff --git a/crates/sema-core/src/context.rs b/crates/sema-core/src/context.rs index 66a90fec2..02d10af4b 100644 --- a/crates/sema-core/src/context.rs +++ b/crates/sema-core/src/context.rs @@ -101,6 +101,7 @@ impl Drop for ContextStacksMut<'_> { pub struct EvalContext { pub module_cache: RefCell>>, pub embedded_files: RefCell>>, + embedded_files_only: Cell, pub current_file: RefCell>, pub module_exports: RefCell>>>, pub module_load_stack: RefCell>, @@ -235,6 +236,7 @@ impl EvalContext { EvalContext { module_cache: RefCell::new(BTreeMap::new()), embedded_files: RefCell::new(BTreeMap::new()), + embedded_files_only: Cell::new(false), current_file: RefCell::new(Vec::new()), module_exports: RefCell::new(Vec::new()), module_load_stack: RefCell::new(Vec::new()), @@ -266,6 +268,7 @@ impl EvalContext { EvalContext { module_cache: RefCell::new(BTreeMap::new()), embedded_files: RefCell::new(BTreeMap::new()), + embedded_files_only: Cell::new(false), current_file: RefCell::new(Vec::new()), module_exports: RefCell::new(Vec::new()), module_load_stack: RefCell::new(Vec::new()), @@ -484,6 +487,15 @@ impl EvalContext { self.embedded_files.borrow_mut().clear(); } + /// When enabled, `import` and `load` may only resolve host-provided embedded files. + pub fn set_embedded_files_only(&self, enabled: bool) { + self.embedded_files_only.set(enabled); + } + + pub fn embedded_files_only(&self) -> bool { + self.embedded_files_only.get() + } + pub fn set_module_exports(&self, names: Vec) { if let Some(state) = self.module_task_state() { state.set_current_exports(names); diff --git a/crates/sema-core/src/error.rs b/crates/sema-core/src/error.rs index 51276fac8..a532f0696 100644 --- a/crates/sema-core/src/error.rs +++ b/crates/sema-core/src/error.rs @@ -147,6 +147,42 @@ impl fmt::Display for StackTrace { /// Maps Rc pointer addresses to source spans for expression tracking. pub type SpanMap = HashMap; +/// Structured details for a policy denial. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PolicyDenial { + pub policy: Option, + pub boundary: String, + pub subject: String, + pub rule: String, + pub reason: String, + pub action: String, + pub source: String, +} + +impl fmt::Display for PolicyDenial { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if let Some(policy) = &self.policy { + write!( + f, + "Policy '{policy}' denied {} '{}': {}", + self.boundary, self.subject, self.reason + ) + } else { + write!( + f, + "Policy denied {} '{}': {}", + self.boundary, self.subject, self.reason + ) + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TypeContext { + pub function: String, + pub argument: Option, +} + #[derive(Debug, Clone, thiserror::Error)] pub enum SemaError { #[error("Reader error at {span}: {message}")] @@ -155,14 +191,18 @@ pub enum SemaError { #[error("Eval error: {0}")] Eval(String), - #[error("Type error: expected {expected}, got {got}{}", got_value.as_ref().map(|v| format!(" ({v})")).unwrap_or_default())] + #[error("Type error: {}expected {expected}, got {got}{}", type_context(context.as_deref()), got_value.as_ref().map(|v| format!(" ({v})")).unwrap_or_default())] Type { + context: Option>, expected: String, got: String, got_value: Option, }, - #[error("Arity error: {name} expects {expected} args, got {got}")] + #[error( + "Arity error: {name} expects {}, got {got}", + format_expected_arity(expected) + )] Arity { name: String, expected: String, @@ -187,6 +227,33 @@ pub enum SemaError { #[error("Permission denied: {function} — path '{path}' is outside allowed directories")] PathDenied { function: String, path: String }, + #[error("{0}")] + PolicyDenied(Box), + + /// Internal workflow control transfer emitted after a durable approval request has + /// been created. This is deliberately not catchable by Sema `try`/`catch`; only the + /// enclosing `workflow/run` consumes it and returns a `:needs-approval` envelope. + #[error("workflow approval required: {approval_id}")] + WorkflowApprovalRequired { approval_id: String }, + + /// Internal workflow control transfer emitted when a durable rejection is observed. + /// Like [`Self::WorkflowApprovalRequired`], user code cannot catch it and continue + /// past the protected action. + #[error("workflow approval rejected: {approval_id}")] + WorkflowApprovalRejected { + approval_id: String, + reason: Option, + }, + + /// Fail-closed approval infrastructure or placement error. It is host-owned and + /// uncatchable for the same reason as pending/rejected controls: user code must not + /// continue to the protected action after authority validation fails. + #[error("workflow approval failed: {message}")] + WorkflowApprovalFailed { message: String }, + + #[error("Internal error: {0}")] + Internal(String), + #[error("User exception: {0}")] UserException(Value), @@ -213,6 +280,50 @@ pub enum SemaError { }, } +fn type_context(context: Option<&TypeContext>) -> String { + match context { + Some(TypeContext { + function, + argument: Some(argument), + }) => format!("{function} argument {argument} "), + Some(TypeContext { + function, + argument: None, + }) => format!("{function} "), + None => String::new(), + } +} + +fn format_expected_arity(expected: &str) -> String { + if let Some(minimum) = expected.strip_suffix('+') { + return format!("{minimum} or more arguments"); + } + if let Some((minimum, maximum)) = expected.split_once('-') { + return format!("{minimum} to {maximum} arguments"); + } + if expected.contains(" or ") { + return format!("{expected} arguments"); + } + match expected { + "0" => "no arguments".to_string(), + "1" => "1 argument".to_string(), + _ => format!("{expected} arguments"), + } +} + +fn type_message( + context: Option<&TypeContext>, + expected: &str, + got: &str, + got_value: Option<&str>, +) -> String { + let value = got_value.map_or_else(String::new, |value| format!(" ({value})")); + format!( + "{}expected {expected}, got {got}{value}", + type_context(context) + ) +} + /// Compute the Levenshtein edit distance between two strings. fn edit_distance(a: &str, b: &str) -> usize { let a_len = a.len(); @@ -330,6 +441,8 @@ const CONDITION_TYPES: &[&str] = &[ "llm", "reader", "permission-denied", + "policy-denied", + "internal", "cancelled", "timeout", ]; @@ -389,6 +502,27 @@ impl SemaError { SemaError::Eval(msg.into()) } + pub fn policy_denied(denial: PolicyDenial) -> Self { + let rule = denial.rule.clone(); + SemaError::PolicyDenied(Box::new(denial)).with_note(format!("policy rule: {rule}")) + } + + /// Whether this error is a host-owned control transfer that language-level exception + /// handlers must not intercept. + pub fn is_uncatchable(&self) -> bool { + matches!( + self.inner(), + SemaError::WorkflowApprovalRequired { .. } + | SemaError::WorkflowApprovalRejected { .. } + | SemaError::WorkflowApprovalFailed { .. } + ) + } + + pub fn internal(message: impl Into) -> Self { + SemaError::Internal(message.into()) + .with_hint("report this as a Sema bug and include the stack trace") + } + #[allow(clippy::too_many_arguments)] pub fn cancelled_condition( message: &str, @@ -457,6 +591,7 @@ impl SemaError { pub fn type_error(expected: impl Into, got: impl Into) -> Self { SemaError::Type { + context: None, expected: expected.into(), got: got.into(), got_value: None, @@ -468,16 +603,50 @@ impl SemaError { got: impl Into, value: &Value, ) -> Self { + SemaError::Type { + context: None, + expected: expected.into(), + got: got.into(), + got_value: Some(Self::value_preview(value)), + } + } + + pub fn argument_type( + function: impl Into, + argument: usize, + expected: impl Into, + value: &Value, + ) -> Self { + SemaError::Type { + context: Some(Box::new(TypeContext { + function: function.into(), + argument: Some(argument), + })), + expected: expected.into(), + got: value.type_name().to_string(), + got_value: None, + } + } + + pub fn argument_type_with_value( + function: impl Into, + argument: usize, + expected: impl Into, + value: &Value, + ) -> Self { + let mut error = Self::argument_type(function, argument, expected, value); + if let SemaError::Type { got_value, .. } = &mut error { + *got_value = Some(Self::value_preview(value)); + } + error + } + + fn value_preview(value: &Value) -> String { let display = format!("{value}"); - let truncated = if display.len() > 40 { + if display.len() > 40 { format!("{}…", crate::text_util::truncate_chars(&display, 39)) } else { display - }; - SemaError::Type { - expected: expected.into(), - got: got.into(), - got_value: Some(truncated), } } @@ -598,6 +767,92 @@ impl SemaError { other => other, } } + + /// Return the primary user-facing message without wrapper prefixes. + pub fn user_message(&self) -> String { + match self.inner() { + SemaError::Reader { message, .. } | SemaError::Eval(message) => message.clone(), + SemaError::Type { + context, + expected, + got, + got_value, + } => type_message(context.as_deref(), expected, got, got_value.as_deref()), + SemaError::Arity { + name, + expected, + got, + } => format!( + "{name} expects {}, got {got}", + format_expected_arity(expected) + ), + SemaError::Unbound(name) => format!("Unbound variable: {name}"), + SemaError::Llm(message) => format!("LLM error: {message}"), + SemaError::Io(message) => format!("I/O error: {message}"), + SemaError::PermissionDenied { + function, + capability, + } => format!("Permission denied: {function} requires '{capability}' capability"), + SemaError::PathDenied { function, path } => format!( + "Permission denied: {function} — path '{path}' is outside allowed directories" + ), + SemaError::PolicyDenied(denial) => denial.to_string(), + SemaError::WorkflowApprovalRequired { approval_id } => { + format!("workflow approval required: {approval_id}") + } + SemaError::WorkflowApprovalRejected { + approval_id, + reason, + } => reason.as_ref().map_or_else( + || format!("workflow approval rejected: {approval_id}"), + |reason| format!("workflow approval rejected: {approval_id}: {reason}"), + ), + SemaError::WorkflowApprovalFailed { message } => { + format!("workflow approval failed: {message}") + } + SemaError::Internal(message) => format!("Internal error: {message}"), + SemaError::UserException(value) => format!("User exception: {value}"), + SemaError::Condition(condition) => condition_message(condition), + SemaError::WithTrace { .. } | SemaError::WithContext { .. } => { + unreachable!("inner() already unwraps wrappers") + } + } + } + + /// Format a diagnostic message without source location or stack frames. + pub fn format_diagnostic(&self) -> String { + let mut message = self.user_message(); + if let Some(hint) = self.hint() { + message.push_str("\n hint: "); + message.push_str(hint); + } + if let Some(note) = self.note() { + message.push_str("\n note: "); + message.push_str(note); + } + message + } + + /// Format an error for a plain-text channel. + pub fn format_plain(&self) -> String { + let mut message = self.user_message(); + if let SemaError::Reader { span, .. } = self.inner() { + message.push_str(&format!("\n at :{span}")); + } + if let Some(trace) = self.stack_trace() { + message.push('\n'); + message.push_str(trace.to_string().trim_end()); + } + if let Some(hint) = self.hint() { + message.push_str("\n hint: "); + message.push_str(hint); + } + if let Some(note) = self.note() { + message.push_str("\n note: "); + message.push_str(note); + } + message + } } #[cfg(test)] @@ -683,7 +938,7 @@ mod tests { assert!( matches!( &e, - SemaError::Type { expected, got, got_value } + SemaError::Type { expected, got, got_value, .. } if expected == "string" && got == "integer" && got_value.is_none() ), "expected Type variant with expected='string', got='integer', got_value=None, got {e:?}" @@ -706,7 +961,10 @@ mod tests { "expected Arity variant with name='my-fn', expected='2', got=5, got {e:?}" ); // Display check (intentionally testing Display format) - assert_eq!(e.to_string(), "Arity error: my-fn expects 2 args, got 5"); + assert_eq!( + e.to_string(), + "Arity error: my-fn expects 2 arguments, got 5" + ); } // 6. with_hint attaches hint retrievable via .hint() @@ -943,7 +1201,7 @@ mod tests { assert!( matches!( &e, - SemaError::Type { expected, got, got_value } + SemaError::Type { expected, got, got_value, .. } if expected == "string" && got == "integer" && got_value.as_deref() == Some("42") ), "expected Type variant with expected='string', got='integer', got_value=Some(\"42\"), got {e:?}" @@ -970,4 +1228,62 @@ mod tests { // Display check (intentionally testing Display format) assert_eq!(e.to_string(), "Type error: expected string, got integer"); } + + #[test] + fn argument_type_includes_call_context() { + let e = SemaError::argument_type_with_value("string/split", 1, "string", &Value::int(42)); + assert_eq!( + e.user_message(), + "string/split argument 1 expected string, got int (42)" + ); + } + + #[test] + fn arity_expectations_use_readable_grammar() { + let cases = [ + ("0", "f expects no arguments, got 9"), + ("1", "f expects 1 argument, got 9"), + ("2", "f expects 2 arguments, got 9"), + ("1+", "f expects 1 or more arguments, got 9"), + ("2-4", "f expects 2 to 4 arguments, got 9"), + ("2 or 3", "f expects 2 or 3 arguments, got 9"), + ]; + for (expected, message) in cases { + assert_eq!(SemaError::arity("f", expected, 9).user_message(), message); + } + } + + #[test] + fn policy_denial_preserves_details_and_renders_context() { + let e = SemaError::policy_denied(PolicyDenial { + policy: Some("safe-agent".to_string()), + boundary: "tool".to_string(), + subject: "shell/run".to_string(), + rule: "tools.shell.deny".to_string(), + reason: "command execution is not allowed".to_string(), + action: "fail".to_string(), + source: "request".to_string(), + }); + assert_eq!( + e.user_message(), + "Policy 'safe-agent' denied tool 'shell/run': command execution is not allowed" + ); + assert_eq!(e.note(), Some("policy rule: tools.shell.deny")); + } + + #[test] + fn plain_format_orders_trace_hint_and_note() { + let e = SemaError::eval("failed") + .with_stack_trace(StackTrace(vec![CallFrame { + name: "main".to_string(), + file: None, + span: Some(Span::point(2, 3)), + }])) + .with_hint("try again") + .with_note("extra context"); + assert_eq!( + e.format_plain(), + "failed\n at main (:2:3)\n hint: try again\n note: extra context" + ); + } } diff --git a/crates/sema-core/src/lib.rs b/crates/sema-core/src/lib.rs index 354e38bbb..f6598ff8d 100644 --- a/crates/sema-core/src/lib.rs +++ b/crates/sema-core/src/lib.rs @@ -50,7 +50,7 @@ pub use cycle::{ GcPassEvent, GcStats, GcTrigger, NodePtr, OpaqueSeverFn, OpaqueTraceFn, PayloadTracer, RuntimeInteriorHooks, }; -pub use error::{CallFrame, SemaError, Span, SpanMap, StackTrace}; +pub use error::{suggest_similar, CallFrame, PolicyDenial, SemaError, Span, SpanMap, StackTrace}; pub use home::sema_home; pub use io_backend::{ io_backend, io_block_on, io_spawn, io_spawn_blocking, set_io_backend, AbortHook, BoxIoFuture, @@ -73,11 +73,11 @@ pub use text_util::truncate_chars; pub use value::{ bits_to_spur, compare_spurs, intern, interner_stats, next_gensym, pretty_print, resolve, resolve_multimethod_handler, select_multimethod_handler, spur_to_bits, with_resolved, Agent, - AsyncPromise, Channel, Conversation, Env, ImageAttachment, Lambda, Macro, Message, MultiMethod, - MutableArray, MutableCell, NativeFn, NativeSuspensionClass, PromiseState, Prompt, Record, Role, - SemaStream, StreamBox, SyntaxRules, Thunk, ToolDefinition, Value, ValueView, ValueViewRef, - NAN_INT_SIGN_BIT, NAN_INT_SMALL_PATTERN, NAN_PAYLOAD_BITS, NAN_PAYLOAD_MASK, NAN_TAG_MASK, - TAG_NATIVE_FN, + AsyncPromise, Channel, Conversation, Env, FileAccess, ImageAttachment, Lambda, Macro, Message, + MultiMethod, MutableArray, MutableCell, NativeFn, NativeSuspensionClass, PromiseState, Prompt, + Record, Role, SemaStream, StreamBox, SyntaxRules, Thunk, ToolDefinition, ToolPolicySubject, + Value, ValueView, ValueViewRef, NAN_INT_SIGN_BIT, NAN_INT_SMALL_PATTERN, NAN_PAYLOAD_BITS, + NAN_PAYLOAD_MASK, NAN_TAG_MASK, TAG_NATIVE_FN, }; pub mod runtime; diff --git a/crates/sema-core/src/runtime/task_context.rs b/crates/sema-core/src/runtime/task_context.rs index 91f49dda2..8fd4dc235 100644 --- a/crates/sema-core/src/runtime/task_context.rs +++ b/crates/sema-core/src/runtime/task_context.rs @@ -5,12 +5,20 @@ use std::rc::Rc; use hashbrown::HashMap; use crate::cycle::GcEdge; +use crate::SemaError; use super::Trace; pub trait TaskLocalValue: Trace + Any { fn inherit(&self) -> Rc; fn as_any(&self) -> &dyn Any; + + /// A task-local extension may fail a task before its next VM quantum. This is for + /// shared fail-closed state (for example, a workflow child invalidating its owner), + /// not ordinary language-level cancellation. + fn preflight_error(&self) -> Option { + None + } } #[derive(Default)] @@ -75,6 +83,16 @@ impl TaskContext { .collect(), } } + + pub fn preflight_error(&self) -> Option { + let mut errors = self + .extensions + .values() + .filter_map(|value| value.preflight_error()) + .collect::>(); + errors.sort_by_key(SemaError::user_message); + errors.into_iter().next() + } } impl Trace for TaskContext { @@ -102,6 +120,10 @@ impl TaskContextHandle { pub fn inherit_for_child(&self) -> Self { Self(Rc::new(RefCell::new(self.borrow().inherit_for_child()))) } + + pub fn preflight_error(&self) -> Option { + self.borrow().preflight_error() + } } impl Trace for TaskContextHandle { diff --git a/crates/sema-core/src/value.rs b/crates/sema-core/src/value.rs index 4687954bd..fd3883100 100644 --- a/crates/sema-core/src/value.rs +++ b/crates/sema-core/src/value.rs @@ -727,9 +727,41 @@ pub struct ToolDefinition { pub name: String, pub description: String, pub parameters: Value, + pub policy_subjects: Vec, pub handler: Value, } +/// Static, inspectable description of the security-relevant subject a tool acts on. +/// +/// Argument names refer to the tool's JSON schema. The policy runtime resolves +/// them before invoking the handler and never infers authority from the tool name. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ToolPolicySubject { + File { + access: FileAccess, + path_arg: String, + }, + NetworkRequest { + method: Option, + url_arg: String, + }, + Command { + command_arg: String, + }, + ExternalAction { + action: String, + target_arg: Option, + }, +} + +/// File-system authority represented by a tool policy subject. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FileAccess { + Read, + Write, + Delete, +} + /// An agent: system prompt + tools + config for autonomous loops. #[derive(Debug, Clone)] pub struct Agent { diff --git a/crates/sema-docs/builtin_docs.generated.json b/crates/sema-docs/builtin_docs.generated.json index ac5aaa849..6b8ad3cbc 100644 --- a/crates/sema-docs/builtin_docs.generated.json +++ b/crates/sema-docs/builtin_docs.generated.json @@ -13068,6 +13068,22 @@ ], "body": "Return the parameter schema of a tool definition (the map describing the tool's accepted arguments).\n\n```sema\n(tool/parameters get-weather)\n```" }, + { + "name": "tool/policy-subjects", + "module": "tool", + "summary": "Return the semantic policy subjects declared by a tool definition.", + "params": [ + { + "name": "tool", + "type": "tool" + } + ], + "returns": "vector", + "examples": [ + "(deftool read-source\n \"Read a source file.\"\n {:path {:type :string}}\n {:policy-subjects [{:kind :file-read :path-arg :path}]}\n (fn (path) (file/read path)))\n\n(tool/policy-subjects read-source)\n; => [{:kind :file-read :path-arg :path}]" + ], + "body": "Return the semantic policy subjects declared by a tool definition.\n\nEach subject is a map with a `:kind`. File subjects include `:path-arg`,\nnetwork subjects include `:url-arg` and optional `:method`, command subjects\ninclude `:command-arg`, and external-action subjects include `:action` and an\noptional `:target-arg`.\n\n```sema\n(deftool read-source\n \"Read a source file.\"\n {:path {:type :string}}\n {:policy-subjects [{:kind :file-read :path-arg :path}]}\n (fn (path) (file/read path)))\n\n(tool/policy-subjects read-source)\n; => [{:kind :file-read :path-arg :path}]\n```\n\nSee also: `deftool`, `tool/name`, `tool/parameters`, `defpolicy`." + }, { "name": "f64-array", "module": "typed-arrays", @@ -13837,6 +13853,17 @@ "body": "Build an anonymous, reusable **actor** value from an options map: a configured brain with\na system prompt, tools, model, and turn budget that owns its own tool loop. This is the\nplain constructor; the named form is `defagent`. Run one with `agent/run`, or\nhand it to a workflow `step` via `:agent` to run it as a journaled step.\n\n`opts` keys (all optional): `:system` (system prompt string), `:tools` (a list/vector of\n`deftool` values), `:model` (provider model id; omit for the default), `:max-turns` (tool-loop\ncap, default `10`), and `:name` (empty for an anonymous agent).\n\n```sema\n(deftool get-weather \"Get weather\" {:city {:type :string}}\n (lambda (city) (format \"{\\\"temp\\\": 22}\")))\n\n;; anonymous actor — omit :model to use the default provider/model\n(define bot (agent {:tools [get-weather]}))\n(agent/run bot \"Weather in Oslo?\") ; multi-turn tool loop\n\n;; the same brain, run as a journaled workflow step\n(step \"Weather in Oslo?\" {:agent bot})\n```\n\nSee also: `defagent`, `agent/run`, `agent/name`, `step`.", "syntax": "(agent opts)" }, + { + "name": "approval", + "module": "workflow", + "section": "Dynamic Workflows", + "summary": "Stop a workflow at a durable, host-controlled human approval gate. `key` is a keyword or string. `:reason` explains why approval is required, `:subject` identifies the exact action and is stored only as a SHA-256 digest, and optional `:preview` is operator-safe text that may be written to the request sidecar and shown in a prompt.", + "examples": [ + "(approval :release-signoff\n {:reason \"Publish the release\"\n :subject {:kind :external-action\n :target \"pkg.sema-lang.com\"\n :digest package-digest}\n :preview \"Publish sema-policies@1.0.0\"})" + ], + "body": "Stop a workflow at a durable, host-controlled human approval gate. `key` is a keyword or string. `:reason` explains why approval is required, `:subject` identifies the exact action and is stored only as a SHA-256 digest, and optional `:preview` is operator-safe text that may be written to the request sidecar and shown in a prompt.\n\n```sema\n(approval :release-signoff\n {:reason \"Publish the release\"\n :subject {:kind :external-action\n :target \"pkg.sema-lang.com\"\n :digest package-digest}\n :preview \"Publish sema-policies@1.0.0\"})\n```\n\nWith no decision, the run ends `{:status :needs-approval …}` before later forms execute. `sema workflow run` prompts on a terminal by default. For a durable headless pause, create an approval key pair, pass the public-key file to `run`, then use the private-key file only with the separate `approve` or `reject` command. Decisions are Ed25519-signed and bound to the run, complete static import/package dependency closure, arguments, phase, key, occurrence, subject digest, request timestamp, and authority key. Imports and loads execute from the exact snapshotted bytes; files outside the preflight closure fail closed.\n\nApproval is a sequential workflow gate. Call `approval` directly; `workflow/approval` cannot be aliased, stored, or passed as a first-class value. Put the gate before `parallel`, `pipeline`, async task combinators, steps, retry/timeout wrappers, resource-cleanup forms, or a nested workflow; the static checker rejects gates inside those constructs. The subject must be canonical immutable data (scalars, lists/vectors, maps, bytevectors, or typed numeric arrays). Pending, rejected, malformed, and authority-invalid gates cannot be bypassed with Sema `try`/`catch`.\n\nSee also: `workflow/approval`, `defworkflow`, `checkpoint`, `workflow/run`.", + "syntax": "(approval key {:reason string :subject value [:preview string]})" + }, { "name": "checkpoint", "module": "workflow", @@ -13847,15 +13874,27 @@ ], "body": "Record or read a keyed step value within a workflow run. `(checkpoint :k v)` stores `v` under key `:k`, emits a `checkpoint` event (with a `content_key` and opaque value digest), and returns `v` — so it threads naturally through a `let` or as a phase's last form. On `--resume`, a memoized checkpoint returns the stored value before evaluating `v`, so expensive or side-effecting write expressions do not rerun. `(checkpoint :k)` reads the previously-stored value back (or `nil` if unset), letting a later phase consume what an earlier one produced. It doubles as the run-scoped state bag. Errors if called outside a `workflow/run`.\n\n`phase` is a one-argument marker, so the `checkpoint` calls follow it as siblings (not\nnested inside it):\n\n```sema\n(phase \"Inventory\")\n(checkpoint :files (list \"a.php\" \"b.php\" \"c.php\")) ; record + return\n\n(phase \"Audit\")\n(count (checkpoint :files)) ; read back => 3\n```\n\nSee also: `workflow/checkpoint`, `workflow/run`, `workflow/phase`." }, + { + "name": "defpolicy", + "module": "workflow", + "section": "Dynamic Workflows", + "summary": "Define a reusable model and tool policy. Model rules match exact `\"provider/model\"` identities or the `\"provider/*\"` wildcard. Tool rules can allow or deny tool names and constrain model-supplied path, URL, and command arguments. A present `:models` or `:tools` section defaults to `:deny`.", + "examples": [ + "(defpolicy repository-auditor\n {:models {:default :deny\n :allow [\"openai/gpt-5\" \"anthropic/*\"]}\n :tools {:default :deny\n :allow\n {\"read-file\" {:paths [\"src/**\" \"Cargo.toml\"]}\n \"run-command\" {:commands [\"cargo test\" \"cargo check\"]}}}})\n\n(defworkflow audit \"Guarded audit\" {:policy repository-auditor}\n (phase \"Audit\")\n (step \"Inspect the repository.\"\n {:tools [read-file run-command]})\n {:status :success})", + "(try\n (llm/complete \"Review this change.\")\n (catch denial\n {:policy (:policy denial)\n :rule (:rule denial)\n :reason (:reason denial)}))" + ], + "body": "Define a reusable model and tool policy. Model rules match exact\n`\"provider/model\"` identities or the `\"provider/*\"` wildcard. Tool rules can\nallow or deny tool names and constrain model-supplied path, URL, and command\narguments. A present `:models` or `:tools` section defaults to `:deny`.\n\n```sema\n(defpolicy repository-auditor\n {:models {:default :deny\n :allow [\"openai/gpt-5\" \"anthropic/*\"]}\n :tools {:default :deny\n :allow\n {\"read-file\" {:paths [\"src/**\" \"Cargo.toml\"]}\n \"run-command\" {:commands [\"cargo test\" \"cargo check\"]}}}})\n\n(defworkflow audit \"Guarded audit\" {:policy repository-auditor}\n (phase \"Audit\")\n (step \"Inspect the repository.\"\n {:tools [read-file run-command]})\n {:status :success})\n```\n\nAttach a policy with workflow or step `:policy`. Active workflow and step\npolicies compose with logical AND. `:permissions` and the CLI sandbox remain the\nouter capability limit.\n\nStep policies may contain model, tool, subject, input, and output controls.\n`:metadata` and `:completion` describe evidence for the whole run and must be\nattached to the enclosing workflow. The runtime and `workflow check` reject\nthose sections on a step instead of silently ignoring them.\n\nPolicy denials raise a `:policy-denied` condition. The condition contains\n`:message`, `:policy`, `:boundary`, `:subject`, `:rule`, `:reason`, `:action`,\nand `:source`. This lets a `catch` handler use the exact deciding policy layer:\n\n```sema\n(try\n (llm/complete \"Review this change.\")\n (catch denial\n {:policy (:policy denial)\n :rule (:rule denial)\n :reason (:reason denial)}))\n```\n\nInvalid policy maps identify the invalid field or one-based list entry. Unknown\nkeys suggest a close valid key when possible. Invalid enum values list the\naccepted keywords.\n\nSee also: `defworkflow`, `step`, `policy/without`, `workflow/check`.", + "syntax": "(defpolicy name policy-map)" + }, { "name": "defworkflow", "module": "workflow", "section": "Dynamic Workflows", - "summary": "Macro: define and run a sequential, journaled workflow. `(defworkflow name \"doc\" meta body…)` expands to `(workflow/run \"name\" \"doc\" meta (lambda () body…))` — so the form *is* the run: it opens the run directory, journals every event, and returns the `{:status …}` envelope. `meta` is a metadata map (`{:phases … :budget … :permissions … :args …}`) recorded into `metadata.json`; list `:phases` so the dashboard can show them before they start. A `:budget` submap caps spend — `{:tokens N}` (deterministic) and/or `{:usd N}` (best-effort, pricing-table dependent); exceeding a cap latches the run, refuses to launch further `step` leaves, and ends `{:status :failed :reason \"budget exceeded\"}`. A `:permissions` string tightens the CLI sandbox for `sema workflow run` using the same syntax as `--sandbox` (for example `\"no-fs-write,no-network\"`). Valid permission values are `none`, `strict`, `all`, `no-fs-read`, `no-fs-write`, `no-shell`, `no-network`, `no-env-read`, `no-env-write`, `no-process`, `no-llm`, and `no-serial`; capability names also parse without the `no-` prefix. The body is ordinary Sema code — a flat sequence of forms with `phase` **markers** interleaved, ending in a `{:status …}` map. Shared values flow through ordinary `def`; `step` leaves return typed data that `pipeline`/`parallel` fan out. Keeping `defworkflow` a prelude macro leaves the VM untouched.", + "summary": "Macro: define and run a sequential, journaled workflow. `(defworkflow name \"doc\" meta body…)` expands to `(workflow/run \"name\" \"doc\" meta (lambda () body…))` — so the form *is* the run: it opens the run directory, journals every event, and returns the `{:status …}` envelope. `meta` is a metadata map (`{:phases … :budget … :permissions … :policy … :args …}`) recorded into `metadata.json`; list `:phases` so the dashboard can show them before they start. A `:budget` submap caps spend — `{:tokens N}` (deterministic) and/or `{:usd N}` (best-effort, pricing-table dependent); exceeding a cap latches the run, refuses to launch further `step` leaves, and ends `{:status :failed :reason \"budget exceeded\"}`. A `:permissions` string tightens the CLI sandbox for `sema workflow run` using the same syntax as `--sandbox` (for example `\"no-fs-write,no-network\"`). A `:policy` value constrains resolved models and model-requested tools for the full workflow body. Valid permission values are `none`, `strict`, `all`, `no-fs-read`, `no-fs-write`, `no-shell`, `no-network`, `no-env-read`, `no-env-write`, `no-process`, `no-llm`, and `no-serial`; capability names also parse without the `no-` prefix. The body is ordinary Sema code — a flat sequence of forms with `phase` **markers** interleaved, ending in a `{:status …}` map. Shared values flow through ordinary `def`; `step` leaves return typed data that `pipeline`/`parallel` fan out. Keeping `defworkflow` a prelude macro leaves the VM untouched.", "examples": [ "(defworkflow audit-auth\n \"Audit a codebase for missing authorization checks.\"\n {:phases [\"Inventory\" \"Audit\" \"Report\"]\n :permissions \"no-fs-write\"}\n\n (phase \"Inventory\")\n (def files (step \"List auth-relevant files under src/.\" {:schema [:list :string]}))\n\n (phase \"Audit\")\n (def findings\n (pipeline files\n (fn (f) (step (str \"Audit \" f) {:schema finding}))\n (fn (x) (step (str \"Verify \" (:claim x)) {:schema verdict}))))\n\n (phase \"Report\")\n {:status :success :confirmed (filter (fn (x) (:real x)) findings)})" ], - "body": "Macro: define and run a sequential, journaled workflow. `(defworkflow name \"doc\" meta body…)` expands to `(workflow/run \"name\" \"doc\" meta (lambda () body…))` — so the form *is* the run: it opens the run directory, journals every event, and returns the `{:status …}` envelope. `meta` is a metadata map (`{:phases … :budget … :permissions … :args …}`) recorded into `metadata.json`; list `:phases` so the dashboard can show them before they start. A `:budget` submap caps spend — `{:tokens N}` (deterministic) and/or `{:usd N}` (best-effort, pricing-table dependent); exceeding a cap latches the run, refuses to launch further `step` leaves, and ends `{:status :failed :reason \"budget exceeded\"}`. A `:permissions` string tightens the CLI sandbox for `sema workflow run` using the same syntax as `--sandbox` (for example `\"no-fs-write,no-network\"`). Valid permission values are `none`, `strict`, `all`, `no-fs-read`, `no-fs-write`, `no-shell`, `no-network`, `no-env-read`, `no-env-write`, `no-process`, `no-llm`, and `no-serial`; capability names also parse without the `no-` prefix. The body is ordinary Sema code — a flat sequence of forms with `phase` **markers** interleaved, ending in a `{:status …}` map. Shared values flow through ordinary `def`; `step` leaves return typed data that `pipeline`/`parallel` fan out. Keeping `defworkflow` a prelude macro leaves the VM untouched.\n\n```sema\n(defworkflow audit-auth\n \"Audit a codebase for missing authorization checks.\"\n {:phases [\"Inventory\" \"Audit\" \"Report\"]\n :permissions \"no-fs-write\"}\n\n (phase \"Inventory\")\n (def files (step \"List auth-relevant files under src/.\" {:schema [:list :string]}))\n\n (phase \"Audit\")\n (def findings\n (pipeline files\n (fn (f) (step (str \"Audit \" f) {:schema finding}))\n (fn (x) (step (str \"Verify \" (:claim x)) {:schema verdict}))))\n\n (phase \"Report\")\n {:status :success :confirmed (filter (fn (x) (:real x)) findings)})\n```\n\nRun a workflow file with `sema workflow run --args `.\n\nSee also: `workflow/run`, `phase`, `checkpoint`.", + "body": "Macro: define and run a sequential, journaled workflow. `(defworkflow name \"doc\" meta body…)` expands to `(workflow/run \"name\" \"doc\" meta (lambda () body…))` — so the form *is* the run: it opens the run directory, journals every event, and returns the `{:status …}` envelope. `meta` is a metadata map (`{:phases … :budget … :permissions … :policy … :args …}`) recorded into `metadata.json`; list `:phases` so the dashboard can show them before they start. A `:budget` submap caps spend — `{:tokens N}` (deterministic) and/or `{:usd N}` (best-effort, pricing-table dependent); exceeding a cap latches the run, refuses to launch further `step` leaves, and ends `{:status :failed :reason \"budget exceeded\"}`. A `:permissions` string tightens the CLI sandbox for `sema workflow run` using the same syntax as `--sandbox` (for example `\"no-fs-write,no-network\"`). A `:policy` value constrains resolved models and model-requested tools for the full workflow body. Valid permission values are `none`, `strict`, `all`, `no-fs-read`, `no-fs-write`, `no-shell`, `no-network`, `no-env-read`, `no-env-write`, `no-process`, `no-llm`, and `no-serial`; capability names also parse without the `no-` prefix. The body is ordinary Sema code — a flat sequence of forms with `phase` **markers** interleaved, ending in a `{:status …}` map. Shared values flow through ordinary `def`; `step` leaves return typed data that `pipeline`/`parallel` fan out. Keeping `defworkflow` a prelude macro leaves the VM untouched.\n\n```sema\n(defworkflow audit-auth\n \"Audit a codebase for missing authorization checks.\"\n {:phases [\"Inventory\" \"Audit\" \"Report\"]\n :permissions \"no-fs-write\"}\n\n (phase \"Inventory\")\n (def files (step \"List auth-relevant files under src/.\" {:schema [:list :string]}))\n\n (phase \"Audit\")\n (def findings\n (pipeline files\n (fn (f) (step (str \"Audit \" f) {:schema finding}))\n (fn (x) (step (str \"Verify \" (:claim x)) {:schema verdict}))))\n\n (phase \"Report\")\n {:status :success :confirmed (filter (fn (x) (:real x)) findings)})\n```\n\nRun a workflow file with `sema workflow run --args `.\n\nSee also: `workflow/run`, `defpolicy`, `policy/without`, `phase`, `checkpoint`.", "syntax": "(defworkflow name doc meta body ...)" }, { @@ -13869,26 +13908,45 @@ "body": "Macro: a journaled phase **marker** inside a workflow body (Claude Code `workflow.js`\nsemantics). `(phase label)` expands to `(workflow/phase label)`: it closes the\npreviously-open phase and opens `label`. Every `step`/`checkpoint` that follows belongs\nto this phase until the next `(phase …)` marker or the run end. A phase is a journaling\nboundary, not control flow — markers sit between the body's top-level forms rather than\nwrapping them.\n\n```sema\n(phase \"Inventory\")\n(checkpoint :files (list \"a.php\" \"b.php\" \"c.php\"))\n\n(phase \"Audit\")\n(checkpoint :findings (count (checkpoint :files)))\n```\n\nSee also: `defworkflow`, `workflow/phase`, `checkpoint`.", "syntax": "(phase label)" }, + { + "name": "policy/without", + "module": "workflow", + "section": "Dynamic Workflows", + "summary": "Bypass active model and tool policies for a trusted lexical scope. `reason` must be a non-empty literal string of at most 256 characters, and the form must contain at least one body expression. Each protected boundary emits a `policy.bypassed` journal event with the reason.", + "examples": [ + "(policy/without \"read the migration fixture\"\n (step \"Inspect the legacy fixture.\" {:tools [read-file]}))" + ], + "body": "Bypass active model and tool policies for a trusted lexical scope. `reason`\nmust be a non-empty literal string of at most 256 characters, and the form must\ncontain at least one body expression. Each protected boundary emits a\n`policy.bypassed` journal event with the reason.\n\n```sema\n(policy/without \"read the migration fixture\"\n (step \"Inspect the legacy fixture.\" {:tools [read-file]}))\n```\n\nThe bypass is task-local and applies only to its body. It does not bypass\nworkflow `:permissions`, the CLI sandbox, or allowed-path limits.\n\nSee also: `defpolicy`, `defworkflow`, `workflow/check`.", + "syntax": "(policy/without reason body ...)" + }, { "name": "step", "module": "workflow", "section": "Dynamic Workflows", - "summary": "Macro: a journaled **step** — a workflow's atomic orchestration unit (Claude Code `workflow.js` `step(prompt, {…})` semantics). A step is an anonymous, workflow-owned call site; the named, reusable counterpart is an `agent`. Runs `prompt` through the configured provider and returns **typed data** when `opts` carries a `:schema` (validated via `llm/extract`), or the completion text otherwise — so the next stage of a `pipeline` can consume the result directly without re-parsing. `opts` also carries `:name`, the role label shown in the dashboard (default `\"step\"`).", + "summary": "Macro: a journaled **step** — a workflow's atomic orchestration unit (Claude Code `workflow.js` `step(prompt, {…})` semantics). A step is an anonymous, workflow-owned call site; the named, reusable counterpart is an `agent`. Runs `prompt` through the configured provider and returns **typed data** when `opts` carries a `:schema` (validated via `llm/extract`), or the completion text otherwise — so the next stage of a `pipeline` can consume the result directly without re-parsing. `opts` also carries `:name`, the role label shown in the dashboard (default `\"step\"`), and `:policy`, which adds a policy layer for this step. A step policy can tighten an enclosing workflow policy but cannot loosen it.", "examples": [ ";; typed: returns the parsed list of strings\n(step \"List the auth-relevant source files under src/.\"\n {:name \"scout\" :schema [:list :string]})\n\n;; untyped: returns completion text\n(step \"Summarize the changelog in one line.\")\n\n;; run a configured defagent as a step\n(step \"Review file Y\" {:agent code-reviewer :schema verdict})\n\n;; fanned out — one row per item, two stages overlapping across items\n(pipeline files\n (fn (f) (step (str \"Audit \" f) {:name \"auditor\" :schema finding}))\n (fn (x) (step (str \"Verify \" (:claim x)) {:name \"verifier\" :schema verdict})))" ], - "body": "Macro: a journaled **step** — a workflow's atomic orchestration unit (Claude Code\n`workflow.js` `step(prompt, {…})` semantics). A step is an anonymous, workflow-owned call\nsite; the named, reusable counterpart is an `agent`. Runs `prompt` through the configured\nprovider and returns **typed data** when `opts` carries a `:schema` (validated via\n`llm/extract`), or the completion text otherwise — so the next stage of a `pipeline`\ncan consume the result directly without re-parsing. `opts` also carries `:name`, the role\nlabel shown in the dashboard (default `\"step\"`).\n\nThe call is wrapped by `workflow/step`, which emits `agent.started`/`agent.result`\nplus a per-step `budget` event, so each invocation becomes a correlated row under the\ncurrent phase. (The `agent.*` event names are the journal's frozen internal contract and\npredate the step rename.) Outside a `workflow/run` the journaling is transparent — the LLM\ncall still runs.\n\nRouting on `opts` (`:agent` and inline `:tools`/`:model` are mutually exclusive — the agent\nowns those):\n\n- `:agent A` — run the configured `defagent` `A` **as** this step via `agent/run` (its\n own system prompt + tools + model + max-turns), with `prompt` as the user message. The\n agent's genuine tool calls still journal as `agent.tool_call`. `:name` defaults to `A`'s\n own name. With `:schema`, `A`'s text is validated.\n- `:tools [...]` (a list of `deftool` values) — run the real multi-round tool loop and\n journal **each genuine tool call** as an `agent.tool_call` event (a tool twig in the\n drill-in). With no `:schema` it returns the loop's final text; with `:schema` the text is\n validated. Per-step budget for a multi-round tool loop is best-effort (the Budget event\n reflects the final round's usage).\n- `:schema S` — `llm/extract` (typed data).\n- otherwise — `llm/complete` (text).\n\n```sema\n;; typed: returns the parsed list of strings\n(step \"List the auth-relevant source files under src/.\"\n {:name \"scout\" :schema [:list :string]})\n\n;; untyped: returns completion text\n(step \"Summarize the changelog in one line.\")\n\n;; run a configured defagent as a step\n(step \"Review file Y\" {:agent code-reviewer :schema verdict})\n\n;; fanned out — one row per item, two stages overlapping across items\n(pipeline files\n (fn (f) (step (str \"Audit \" f) {:name \"auditor\" :schema finding}))\n (fn (x) (step (str \"Verify \" (:claim x)) {:name \"verifier\" :schema verdict})))\n```\n\nSee also: `agent`, `workflow/step`, `pipeline`, `parallel`, `defworkflow`, `checkpoint`.", + "body": "Macro: a journaled **step** — a workflow's atomic orchestration unit (Claude Code\n`workflow.js` `step(prompt, {…})` semantics). A step is an anonymous, workflow-owned call\nsite; the named, reusable counterpart is an `agent`. Runs `prompt` through the configured\nprovider and returns **typed data** when `opts` carries a `:schema` (validated via\n`llm/extract`), or the completion text otherwise — so the next stage of a `pipeline`\ncan consume the result directly without re-parsing. `opts` also carries `:name`, the role\nlabel shown in the dashboard (default `\"step\"`), and `:policy`, which adds a policy layer\nfor this step. A step policy can tighten an enclosing workflow policy but cannot loosen it.\n\nThe call is wrapped by `workflow/step`, which emits `agent.started`/`agent.result`\nplus a per-step `budget` event, so each invocation becomes a correlated row under the\ncurrent phase. (The `agent.*` event names are the journal's frozen internal contract and\npredate the step rename.) Outside a `workflow/run` the journaling is transparent — the LLM\ncall still runs.\n\nRouting on `opts` (`:agent` and inline `:tools`/`:model` are mutually exclusive — the agent\nowns those):\n\n- `:agent A` — run the configured `defagent` `A` **as** this step via `agent/run` (its\n own system prompt + tools + model + max-turns), with `prompt` as the user message. The\n agent's genuine tool calls still journal as `agent.tool_call`. `:name` defaults to `A`'s\n own name. With `:schema`, `A`'s text is validated.\n- `:tools [...]` (a list of `deftool` values) — run the real multi-round tool loop and\n journal **each genuine tool call** as an `agent.tool_call` event (a tool twig in the\n drill-in). With no `:schema` it returns the loop's final text; with `:schema` the text is\n validated. Per-step budget for a multi-round tool loop is best-effort (the Budget event\n reflects the final round's usage).\n- `:schema S` — `llm/extract` (typed data).\n- `:policy P` — add model and tool restrictions for this step.\n- otherwise — `llm/complete` (text).\n\n```sema\n;; typed: returns the parsed list of strings\n(step \"List the auth-relevant source files under src/.\"\n {:name \"scout\" :schema [:list :string]})\n\n;; untyped: returns completion text\n(step \"Summarize the changelog in one line.\")\n\n;; run a configured defagent as a step\n(step \"Review file Y\" {:agent code-reviewer :schema verdict})\n\n;; fanned out — one row per item, two stages overlapping across items\n(pipeline files\n (fn (f) (step (str \"Audit \" f) {:name \"auditor\" :schema finding}))\n (fn (x) (step (str \"Verify \" (:claim x)) {:name \"verifier\" :schema verdict})))\n```\n\nSee also: `agent`, `workflow/step`, `pipeline`, `parallel`, `defworkflow`, `defpolicy`, `checkpoint`.", "syntax": "(step prompt [opts])" }, + { + "name": "workflow/approval", + "module": "workflow", + "section": "Dynamic Workflows", + "summary": "Backend for the `approval` macro. Atomically creates or reads the approval request and immutable, Ed25519-signed decision sidecars under the active run's `approvals/` directory. Filesystem work is quarantined off the cooperative VM. Pending, rejected, invalid, or misplaced requests stop the workflow with an internal, uncatchable control transfer. Only the outer owning `workflow/run` converts that control into a terminal envelope. An approved decision emits `approval.granted` and `approval.applied`, then returns `#t`.", + "body": "Backend for the `approval` macro. Atomically creates or reads the approval request and immutable, Ed25519-signed decision sidecars under the active run's `approvals/` directory. Filesystem work is quarantined off the cooperative VM. Pending, rejected, invalid, or misplaced requests stop the workflow with an internal, uncatchable control transfer. Only the outer owning `workflow/run` converts that control into a terminal envelope. An approved decision emits `approval.granted` and `approval.applied`, then returns `#t`.\n\nUse the public `approval` macro in workflow source.\n\nSee also: `approval`, `workflow/run`, `defworkflow`.", + "syntax": "(workflow/approval key opts)" + }, { "name": "workflow/check", "module": "workflow", "section": "Dynamic Workflows", - "summary": "Static-analyse a workflow source string (or any value, which is pretty-printed to source) and return diagnostics as a list of maps — **no evaluation, no LLM calls, no I/O**. An empty list means the source is clean.", + "summary": "Static-analyse a workflow source string (or any value, which is pretty-printed to source) and return diagnostics as a list of maps — **no evaluation, no LLM calls, no I/O**. An empty list means the source is clean. The checker validates literal `defpolicy`, workflow `:policy`, step `:policy`, and `policy/without` forms in addition to workflow shape and marker rules.", "examples": [ ";; check a source string\n(workflow/check \"(defworkflow bad \\\"doc\\\" {} (phase))\")\n; => ({:severity :error :code \"E-PHASE-ARITY\" :message \"...\" :line 1 :col 38 :hint \"...\"})\n\n;; check a live form value (pretty-printed to source)\n(define wf '(defworkflow ok \"doc\" {} (phase \"Inventory\") {:status :success}))\n(workflow/check wf) ; => ()\n\n;; gate a self-rewrite loop: only run if clean\n(let ((diags (workflow/check generated-src)))\n (if (null? diags)\n (workflow/run-form (read-many generated-src))\n (println (str \"check failed: \" (count diags) \" issues\"))))" ], - "body": "Static-analyse a workflow source string (or any value, which is pretty-printed to source)\nand return diagnostics as a list of maps — **no evaluation, no LLM calls, no I/O**. An\nempty list means the source is clean.\n\nEach diagnostic map has the keys:\n\n- `:severity` — `:error` or `:warning`.\n- `:code` — a short code string (e.g. `\"E-PHASE-ARITY\"`).\n- `:message` — human-readable description.\n- `:line` — 1-based line number, or `nil` when no span is available.\n- `:col` — 1-based column, or `nil` when no span is available.\n- `:hint` — optional actionable guidance string, or `nil`.\n\n```sema\n;; check a source string\n(workflow/check \"(defworkflow bad \\\"doc\\\" {} (phase))\")\n; => ({:severity :error :code \"E-PHASE-ARITY\" :message \"...\" :line 1 :col 38 :hint \"...\"})\n\n;; check a live form value (pretty-printed to source)\n(define wf '(defworkflow ok \"doc\" {} (phase \"Inventory\") {:status :success}))\n(workflow/check wf) ; => ()\n\n;; gate a self-rewrite loop: only run if clean\n(let ((diags (workflow/check generated-src)))\n (if (null? diags)\n (workflow/run-form (read-many generated-src))\n (println (str \"check failed: \" (count diags) \" issues\"))))\n```\n\nSee also: `workflow/run-form`, `defworkflow`.", + "body": "Static-analyse a workflow source string (or any value, which is pretty-printed to source)\nand return diagnostics as a list of maps — **no evaluation, no LLM calls, no I/O**. An\nempty list means the source is clean. The checker validates literal `defpolicy`,\nworkflow `:policy`, step `:policy`, and `policy/without` forms in addition to workflow\nshape and marker rules.\n\nEach diagnostic map has the keys:\n\n- `:severity` — `:error` or `:warning`.\n- `:code` — a short code string (e.g. `\"E-PHASE-ARITY\"`).\n- `:message` — human-readable description.\n- `:line` — 1-based line number, or `nil` when no span is available.\n- `:col` — 1-based column, or `nil` when no span is available.\n- `:hint` — optional actionable guidance string, or `nil`.\n\n```sema\n;; check a source string\n(workflow/check \"(defworkflow bad \\\"doc\\\" {} (phase))\")\n; => ({:severity :error :code \"E-PHASE-ARITY\" :message \"...\" :line 1 :col 38 :hint \"...\"})\n\n;; check a live form value (pretty-printed to source)\n(define wf '(defworkflow ok \"doc\" {} (phase \"Inventory\") {:status :success}))\n(workflow/check wf) ; => ()\n\n;; gate a self-rewrite loop: only run if clean\n(let ((diags (workflow/check generated-src)))\n (if (null? diags)\n (workflow/run-form (read-many generated-src))\n (println (str \"check failed: \" (count diags) \" issues\"))))\n```\n\nSee also: `workflow/run-form`, `defworkflow`, `defpolicy`, `policy/without`.", "syntax": "(workflow/check src)" }, { @@ -13921,15 +13979,26 @@ ], "body": "Open a journaled phase **marker** inside a workflow body (not a wrapper, not control\nflow). `(workflow/phase label)` closes the previously-open phase — emitting its\n`phase.ended` — then emits `phase.started` for `label`; the checkpoints and steps that\nfollow attribute to this phase until the next `(workflow/phase …)` or the run end (which\n`workflow/run` closes automatically). Returns `nil`. Usually written via the\n`phase` macro.\n\n```sema\n(phase \"Inventory\")\n(checkpoint :files (list \"a.php\" \"b.php\" \"c.php\"))\n\n(phase \"Audit\") ; closes \"Inventory\", opens \"Audit\"\n(checkpoint :findings (count (checkpoint :files)))\n```\n\nSee also: `phase`, `workflow/run`, `checkpoint`." }, + { + "name": "workflow/policy-without", + "module": "workflow", + "section": "Dynamic Workflows", + "summary": "Run `thunk` with active model and tool policies bypassed, and emit a `policy.bypassed` event for each protected boundary. `reason` must be a non-empty string of at most 256 characters. This low-level thunk form requires an active workflow policy.", + "examples": [ + "(policy/without \"read the migration fixture\"\n (step \"Inspect the legacy fixture.\" {:tools [read-file]}))" + ], + "body": "Run `thunk` with active model and tool policies bypassed, and emit a\n`policy.bypassed` event for each protected boundary. `reason` must be a\nnon-empty string of at most 256 characters. This low-level thunk form requires\nan active workflow policy.\n\nUse the `policy/without` macro in application code. It requires a literal\nreason and accepts ordinary body forms:\n\n```sema\n(policy/without \"read the migration fixture\"\n (step \"Inspect the legacy fixture.\" {:tools [read-file]}))\n```\n\nThe bypass does not change workflow `:permissions`, the CLI sandbox, or\nallowed-path limits.\n\nSee also: `policy/without`, `defpolicy`, `workflow/run`.", + "syntax": "(workflow/policy-without reason thunk)" + }, { "name": "workflow/run", "module": "workflow", "section": "Dynamic Workflows", - "summary": "Run a sequential, journaled workflow and return its discriminated-union `{:status …}` result. `(workflow/run name doc meta thunk)` opens a run directory under `./.sema/runs//`, emits a `run.started` event, evaluates `thunk` (the workflow body), then emits `run.ended` and writes `result.json`. `meta` may declare `:phases`, `:budget`, `:permissions`, and `:args`; `:permissions` tightens the CLI sandbox before the run starts. If the body's last value is already a `{:status …}` map it is returned verbatim (its keys land at the top level of `result.json`); otherwise the value is wrapped as `{:status :success :value …}`. An error in the body produces `{:status :failed :error \"…\"}`. Usually written via the `defworkflow` macro rather than called directly.", + "summary": "Run a sequential, journaled workflow and return its discriminated-union `{:status …}` result. `(workflow/run name doc meta thunk)` opens a run directory under `./.sema/runs//`, emits a `run.started` event, evaluates `thunk` (the workflow body), then emits `run.ended` and writes `result.json`. `meta` may declare `:phases`, `:budget`, `:permissions`, `:policy`, and `:args`; `:permissions` tightens the CLI sandbox before the run starts, and `:policy` constrains resolved models and model-requested tools. If the body's last value is already a `{:status …}` map it is returned verbatim (its keys land at the top level of `result.json`); otherwise the value is wrapped as `{:status :success :value …}`. An error in the body produces `{:status :failed :error \"…\"}`. Usually written via the `defworkflow` macro rather than called directly.", "examples": [ "(defworkflow hello \"demo\" {:args {:name :string}}\n (phase \"Inventory\") ; marker — body forms follow as siblings\n (checkpoint :files (list \"a\" \"b\"))\n {:status :success :files (checkpoint :files)})" ], - "body": "Run a sequential, journaled workflow and return its discriminated-union `{:status …}` result. `(workflow/run name doc meta thunk)` opens a run directory under `./.sema/runs//`, emits a `run.started` event, evaluates `thunk` (the workflow body), then emits `run.ended` and writes `result.json`. `meta` may declare `:phases`, `:budget`, `:permissions`, and `:args`; `:permissions` tightens the CLI sandbox before the run starts. If the body's last value is already a `{:status …}` map it is returned verbatim (its keys land at the top level of `result.json`); otherwise the value is wrapped as `{:status :success :value …}`. An error in the body produces `{:status :failed :error \"…\"}`. Usually written via the `defworkflow` macro rather than called directly.\n\n```sema\n(defworkflow hello \"demo\" {:args {:name :string}}\n (phase \"Inventory\") ; marker — body forms follow as siblings\n (checkpoint :files (list \"a\" \"b\"))\n {:status :success :files (checkpoint :files)})\n```\n\nThe run journal (`events.jsonl`) is the system of record; run with `sema workflow run --args `.\n\nSee also: `defworkflow`, `workflow/phase`, `checkpoint`." + "body": "Run a sequential, journaled workflow and return its discriminated-union `{:status …}` result. `(workflow/run name doc meta thunk)` opens a run directory under `./.sema/runs//`, emits a `run.started` event, evaluates `thunk` (the workflow body), then emits `run.ended` and writes `result.json`. `meta` may declare `:phases`, `:budget`, `:permissions`, `:policy`, and `:args`; `:permissions` tightens the CLI sandbox before the run starts, and `:policy` constrains resolved models and model-requested tools. If the body's last value is already a `{:status …}` map it is returned verbatim (its keys land at the top level of `result.json`); otherwise the value is wrapped as `{:status :success :value …}`. An error in the body produces `{:status :failed :error \"…\"}`. Usually written via the `defworkflow` macro rather than called directly.\n\n```sema\n(defworkflow hello \"demo\" {:args {:name :string}}\n (phase \"Inventory\") ; marker — body forms follow as siblings\n (checkpoint :files (list \"a\" \"b\"))\n {:status :success :files (checkpoint :files)})\n```\n\nThe run journal (`events.jsonl`) is the system of record; run with `sema workflow run --args `.\n\nSee also: `defworkflow`, `defpolicy`, `policy/without`, `workflow/phase`, `checkpoint`." }, { "name": "workflow/run-form", @@ -13961,6 +14030,17 @@ "(workflow/step \"assembler\"\n (fn ()\n (workflow/tool-call \"file/read\" \"drafts/intro.md\")\n (workflow/tool-call \"file/read\" \"drafts/scheduler.md\")\n (assemble-index (checkpoint :drafts))))" ], "body": "Journal a tool call made by the step currently executing. `(workflow/tool-call tool-name [args])` emits an `agent.tool_call` event attributed to the enclosing [`workflow/step`], so the dashboard renders it as a tool twig in that step's drill-in. `args` is an opaque/gated descriptor string (omit it for the `\"gated\"` sentinel — content is not captured). It is a no-op (returns `nil`) outside a `workflow/step`. Use it to make a leaf's tool usage visible in the run journal.\n\n```sema\n(workflow/step \"assembler\"\n (fn ()\n (workflow/tool-call \"file/read\" \"drafts/intro.md\")\n (workflow/tool-call \"file/read\" \"drafts/scheduler.md\")\n (assemble-index (checkpoint :drafts))))\n```\n\nSee also: `workflow/step`, `step`, `pipeline`." + }, + { + "name": "workflow/tool-result", + "module": "workflow", + "section": "Dynamic Workflows", + "summary": "Journal a successful tool completion for the step currently executing. `tool-name` is a keyword or string. The event records only the `\"gated\"` sentinel and does not store the tool result.", + "examples": [ + "(workflow/step \"custom tool\"\n (fn ()\n (workflow/tool-call \"lookup\" {:id 42})\n (def result (lookup 42))\n (workflow/tool-result \"lookup\")\n result))" + ], + "body": "Journal a successful tool completion for the step currently executing.\n`tool-name` is a keyword or string. The event records only the `\"gated\"`\nsentinel and does not store the tool result.\n\nThe function returns `nil`. It is a no-op outside a `workflow/step`. Agent and\ntool steps call it automatically after a successful tool invocation, so direct\nuse is only needed by custom workflow integrations.\n\n```sema\n(workflow/step \"custom tool\"\n (fn ()\n (workflow/tool-call \"lookup\" {:id 42})\n (def result (lookup 42))\n (workflow/tool-result \"lookup\")\n result))\n```\n\nSee also: `workflow/tool-call`, `workflow/step`, `step`.", + "syntax": "(workflow/tool-result tool-name)" } ] } diff --git a/crates/sema-docs/entries/stdlib/tool/policy-subjects.md b/crates/sema-docs/entries/stdlib/tool/policy-subjects.md new file mode 100644 index 000000000..0a578d911 --- /dev/null +++ b/crates/sema-docs/entries/stdlib/tool/policy-subjects.md @@ -0,0 +1,26 @@ +--- +name: "tool/policy-subjects" +module: "tool" +params: [{ name: tool, type: tool }] +returns: "vector" +--- + +Return the semantic policy subjects declared by a tool definition. + +Each subject is a map with a `:kind`. File subjects include `:path-arg`, +network subjects include `:url-arg` and optional `:method`, command subjects +include `:command-arg`, and external-action subjects include `:action` and an +optional `:target-arg`. + +```sema +(deftool read-source + "Read a source file." + {:path {:type :string}} + {:policy-subjects [{:kind :file-read :path-arg :path}]} + (fn (path) (file/read path))) + +(tool/policy-subjects read-source) +; => [{:kind :file-read :path-arg :path}] +``` + +See also: `deftool`, `tool/name`, `tool/parameters`, `defpolicy`. diff --git a/crates/sema-docs/entries/stdlib/workflow/approval.md b/crates/sema-docs/entries/stdlib/workflow/approval.md new file mode 100644 index 000000000..6c8c99383 --- /dev/null +++ b/crates/sema-docs/entries/stdlib/workflow/approval.md @@ -0,0 +1,23 @@ +--- +name: "approval" +module: "workflow" +section: "Dynamic Workflows" +syntax: "(approval key {:reason string :subject value [:preview string]})" +--- + +Stop a workflow at a durable, host-controlled human approval gate. `key` is a keyword or string. `:reason` explains why approval is required, `:subject` identifies the exact action and is stored only as a SHA-256 digest, and optional `:preview` is operator-safe text that may be written to the request sidecar and shown in a prompt. + +```sema +(approval :release-signoff + {:reason "Publish the release" + :subject {:kind :external-action + :target "pkg.sema-lang.com" + :digest package-digest} + :preview "Publish sema-policies@1.0.0"}) +``` + +With no decision, the run ends `{:status :needs-approval …}` before later forms execute. `sema workflow run` prompts on a terminal by default. For a durable headless pause, create an approval key pair, pass the public-key file to `run`, then use the private-key file only with the separate `approve` or `reject` command. Decisions are Ed25519-signed and bound to the run, complete static import/package dependency closure, arguments, phase, key, occurrence, subject digest, request timestamp, and authority key. Imports and loads execute from the exact snapshotted bytes; files outside the preflight closure fail closed. + +Approval is a sequential workflow gate. Call `approval` directly; `workflow/approval` cannot be aliased, stored, or passed as a first-class value. Put the gate before `parallel`, `pipeline`, async task combinators, steps, retry/timeout wrappers, resource-cleanup forms, or a nested workflow; the static checker rejects gates inside those constructs. The subject must be canonical immutable data (scalars, lists/vectors, maps, bytevectors, or typed numeric arrays). Pending, rejected, malformed, and authority-invalid gates cannot be bypassed with Sema `try`/`catch`. + +See also: `workflow/approval`, `defworkflow`, `checkpoint`, `workflow/run`. diff --git a/crates/sema-docs/entries/stdlib/workflow/defpolicy.md b/crates/sema-docs/entries/stdlib/workflow/defpolicy.md new file mode 100644 index 000000000..2de317863 --- /dev/null +++ b/crates/sema-docs/entries/stdlib/workflow/defpolicy.md @@ -0,0 +1,55 @@ +--- +name: "defpolicy" +module: "workflow" +section: "Dynamic Workflows" +syntax: "(defpolicy name policy-map)" +--- + +Define a reusable model and tool policy. Model rules match exact +`"provider/model"` identities or the `"provider/*"` wildcard. Tool rules can +allow or deny tool names and constrain model-supplied path, URL, and command +arguments. A present `:models` or `:tools` section defaults to `:deny`. + +```sema +(defpolicy repository-auditor + {:models {:default :deny + :allow ["openai/gpt-5" "anthropic/*"]} + :tools {:default :deny + :allow + {"read-file" {:paths ["src/**" "Cargo.toml"]} + "run-command" {:commands ["cargo test" "cargo check"]}}}}) + +(defworkflow audit "Guarded audit" {:policy repository-auditor} + (phase "Audit") + (step "Inspect the repository." + {:tools [read-file run-command]}) + {:status :success}) +``` + +Attach a policy with workflow or step `:policy`. Active workflow and step +policies compose with logical AND. `:permissions` and the CLI sandbox remain the +outer capability limit. + +Step policies may contain model, tool, subject, input, and output controls. +`:metadata` and `:completion` describe evidence for the whole run and must be +attached to the enclosing workflow. The runtime and `workflow check` reject +those sections on a step instead of silently ignoring them. + +Policy denials raise a `:policy-denied` condition. The condition contains +`:message`, `:policy`, `:boundary`, `:subject`, `:rule`, `:reason`, `:action`, +and `:source`. This lets a `catch` handler use the exact deciding policy layer: + +```sema +(try + (llm/complete "Review this change.") + (catch denial + {:policy (:policy denial) + :rule (:rule denial) + :reason (:reason denial)})) +``` + +Invalid policy maps identify the invalid field or one-based list entry. Unknown +keys suggest a close valid key when possible. Invalid enum values list the +accepted keywords. + +See also: `defworkflow`, `step`, `policy/without`, `workflow/check`. diff --git a/crates/sema-docs/entries/stdlib/workflow/defworkflow.md b/crates/sema-docs/entries/stdlib/workflow/defworkflow.md index beb082e72..ad042af6f 100644 --- a/crates/sema-docs/entries/stdlib/workflow/defworkflow.md +++ b/crates/sema-docs/entries/stdlib/workflow/defworkflow.md @@ -5,7 +5,7 @@ section: "Dynamic Workflows" syntax: "(defworkflow name doc meta body ...)" --- -Macro: define and run a sequential, journaled workflow. `(defworkflow name "doc" meta body…)` expands to `(workflow/run "name" "doc" meta (lambda () body…))` — so the form *is* the run: it opens the run directory, journals every event, and returns the `{:status …}` envelope. `meta` is a metadata map (`{:phases … :budget … :permissions … :args …}`) recorded into `metadata.json`; list `:phases` so the dashboard can show them before they start. A `:budget` submap caps spend — `{:tokens N}` (deterministic) and/or `{:usd N}` (best-effort, pricing-table dependent); exceeding a cap latches the run, refuses to launch further `step` leaves, and ends `{:status :failed :reason "budget exceeded"}`. A `:permissions` string tightens the CLI sandbox for `sema workflow run` using the same syntax as `--sandbox` (for example `"no-fs-write,no-network"`). Valid permission values are `none`, `strict`, `all`, `no-fs-read`, `no-fs-write`, `no-shell`, `no-network`, `no-env-read`, `no-env-write`, `no-process`, `no-llm`, and `no-serial`; capability names also parse without the `no-` prefix. The body is ordinary Sema code — a flat sequence of forms with `phase` **markers** interleaved, ending in a `{:status …}` map. Shared values flow through ordinary `def`; `step` leaves return typed data that `pipeline`/`parallel` fan out. Keeping `defworkflow` a prelude macro leaves the VM untouched. +Macro: define and run a sequential, journaled workflow. `(defworkflow name "doc" meta body…)` expands to `(workflow/run "name" "doc" meta (lambda () body…))` — so the form *is* the run: it opens the run directory, journals every event, and returns the `{:status …}` envelope. `meta` is a metadata map (`{:phases … :budget … :permissions … :policy … :args …}`) recorded into `metadata.json`; list `:phases` so the dashboard can show them before they start. A `:budget` submap caps spend — `{:tokens N}` (deterministic) and/or `{:usd N}` (best-effort, pricing-table dependent); exceeding a cap latches the run, refuses to launch further `step` leaves, and ends `{:status :failed :reason "budget exceeded"}`. A `:permissions` string tightens the CLI sandbox for `sema workflow run` using the same syntax as `--sandbox` (for example `"no-fs-write,no-network"`). A `:policy` value constrains resolved models and model-requested tools for the full workflow body. Valid permission values are `none`, `strict`, `all`, `no-fs-read`, `no-fs-write`, `no-shell`, `no-network`, `no-env-read`, `no-env-write`, `no-process`, `no-llm`, and `no-serial`; capability names also parse without the `no-` prefix. The body is ordinary Sema code — a flat sequence of forms with `phase` **markers** interleaved, ending in a `{:status …}` map. Shared values flow through ordinary `def`; `step` leaves return typed data that `pipeline`/`parallel` fan out. Keeping `defworkflow` a prelude macro leaves the VM untouched. ```sema (defworkflow audit-auth @@ -28,4 +28,4 @@ Macro: define and run a sequential, journaled workflow. `(defworkflow name "doc" Run a workflow file with `sema workflow run --args `. -See also: `workflow/run`, `phase`, `checkpoint`. +See also: `workflow/run`, `defpolicy`, `policy/without`, `phase`, `checkpoint`. diff --git a/crates/sema-docs/entries/stdlib/workflow/policy-without.md b/crates/sema-docs/entries/stdlib/workflow/policy-without.md new file mode 100644 index 000000000..ff3bec512 --- /dev/null +++ b/crates/sema-docs/entries/stdlib/workflow/policy-without.md @@ -0,0 +1,21 @@ +--- +name: "policy/without" +module: "workflow" +section: "Dynamic Workflows" +syntax: "(policy/without reason body ...)" +--- + +Bypass active model and tool policies for a trusted lexical scope. `reason` +must be a non-empty literal string of at most 256 characters, and the form must +contain at least one body expression. Each protected boundary emits a +`policy.bypassed` journal event with the reason. + +```sema +(policy/without "read the migration fixture" + (step "Inspect the legacy fixture." {:tools [read-file]})) +``` + +The bypass is task-local and applies only to its body. It does not bypass +workflow `:permissions`, the CLI sandbox, or allowed-path limits. + +See also: `defpolicy`, `defworkflow`, `workflow/check`. diff --git a/crates/sema-docs/entries/stdlib/workflow/step.md b/crates/sema-docs/entries/stdlib/workflow/step.md index b2892c44f..d6e5a4e22 100644 --- a/crates/sema-docs/entries/stdlib/workflow/step.md +++ b/crates/sema-docs/entries/stdlib/workflow/step.md @@ -11,7 +11,8 @@ site; the named, reusable counterpart is an `agent`. Runs `prompt` through the c provider and returns **typed data** when `opts` carries a `:schema` (validated via `llm/extract`), or the completion text otherwise — so the next stage of a `pipeline` can consume the result directly without re-parsing. `opts` also carries `:name`, the role -label shown in the dashboard (default `"step"`). +label shown in the dashboard (default `"step"`), and `:policy`, which adds a policy layer +for this step. A step policy can tighten an enclosing workflow policy but cannot loosen it. The call is wrapped by `workflow/step`, which emits `agent.started`/`agent.result` plus a per-step `budget` event, so each invocation becomes a correlated row under the @@ -32,6 +33,7 @@ owns those): validated. Per-step budget for a multi-round tool loop is best-effort (the Budget event reflects the final round's usage). - `:schema S` — `llm/extract` (typed data). +- `:policy P` — add model and tool restrictions for this step. - otherwise — `llm/complete` (text). ```sema @@ -51,4 +53,4 @@ owns those): (fn (x) (step (str "Verify " (:claim x)) {:name "verifier" :schema verdict}))) ``` -See also: `agent`, `workflow/step`, `pipeline`, `parallel`, `defworkflow`, `checkpoint`. +See also: `agent`, `workflow/step`, `pipeline`, `parallel`, `defworkflow`, `defpolicy`, `checkpoint`. diff --git a/crates/sema-docs/entries/stdlib/workflow/workflow-approval.md b/crates/sema-docs/entries/stdlib/workflow/workflow-approval.md new file mode 100644 index 000000000..6928731b0 --- /dev/null +++ b/crates/sema-docs/entries/stdlib/workflow/workflow-approval.md @@ -0,0 +1,12 @@ +--- +name: "workflow/approval" +module: "workflow" +section: "Dynamic Workflows" +syntax: "(workflow/approval key opts)" +--- + +Backend for the `approval` macro. Atomically creates or reads the approval request and immutable, Ed25519-signed decision sidecars under the active run's `approvals/` directory. Filesystem work is quarantined off the cooperative VM. Pending, rejected, invalid, or misplaced requests stop the workflow with an internal, uncatchable control transfer. Only the outer owning `workflow/run` converts that control into a terminal envelope. An approved decision emits `approval.granted` and `approval.applied`, then returns `#t`. + +Use the public `approval` macro in workflow source. + +See also: `approval`, `workflow/run`, `defworkflow`. diff --git a/crates/sema-docs/entries/stdlib/workflow/workflow-check.md b/crates/sema-docs/entries/stdlib/workflow/workflow-check.md index 1e581b586..d84aa7bd5 100644 --- a/crates/sema-docs/entries/stdlib/workflow/workflow-check.md +++ b/crates/sema-docs/entries/stdlib/workflow/workflow-check.md @@ -7,7 +7,9 @@ syntax: "(workflow/check src)" Static-analyse a workflow source string (or any value, which is pretty-printed to source) and return diagnostics as a list of maps — **no evaluation, no LLM calls, no I/O**. An -empty list means the source is clean. +empty list means the source is clean. The checker validates literal `defpolicy`, +workflow `:policy`, step `:policy`, and `policy/without` forms in addition to workflow +shape and marker rules. Each diagnostic map has the keys: @@ -34,4 +36,4 @@ Each diagnostic map has the keys: (println (str "check failed: " (count diags) " issues")))) ``` -See also: `workflow/run-form`, `defworkflow`. +See also: `workflow/run-form`, `defworkflow`, `defpolicy`, `policy/without`. diff --git a/crates/sema-docs/entries/stdlib/workflow/workflow-policy-without.md b/crates/sema-docs/entries/stdlib/workflow/workflow-policy-without.md new file mode 100644 index 000000000..86933c063 --- /dev/null +++ b/crates/sema-docs/entries/stdlib/workflow/workflow-policy-without.md @@ -0,0 +1,24 @@ +--- +name: "workflow/policy-without" +module: "workflow" +section: "Dynamic Workflows" +syntax: "(workflow/policy-without reason thunk)" +--- + +Run `thunk` with active model and tool policies bypassed, and emit a +`policy.bypassed` event for each protected boundary. `reason` must be a +non-empty string of at most 256 characters. This low-level thunk form requires +an active workflow policy. + +Use the `policy/without` macro in application code. It requires a literal +reason and accepts ordinary body forms: + +```sema +(policy/without "read the migration fixture" + (step "Inspect the legacy fixture." {:tools [read-file]})) +``` + +The bypass does not change workflow `:permissions`, the CLI sandbox, or +allowed-path limits. + +See also: `policy/without`, `defpolicy`, `workflow/run`. diff --git a/crates/sema-docs/entries/stdlib/workflow/workflow-run.md b/crates/sema-docs/entries/stdlib/workflow/workflow-run.md index f2f7aa26e..a33a9aeef 100644 --- a/crates/sema-docs/entries/stdlib/workflow/workflow-run.md +++ b/crates/sema-docs/entries/stdlib/workflow/workflow-run.md @@ -4,7 +4,7 @@ module: "workflow" section: "Dynamic Workflows" --- -Run a sequential, journaled workflow and return its discriminated-union `{:status …}` result. `(workflow/run name doc meta thunk)` opens a run directory under `./.sema/runs//`, emits a `run.started` event, evaluates `thunk` (the workflow body), then emits `run.ended` and writes `result.json`. `meta` may declare `:phases`, `:budget`, `:permissions`, and `:args`; `:permissions` tightens the CLI sandbox before the run starts. If the body's last value is already a `{:status …}` map it is returned verbatim (its keys land at the top level of `result.json`); otherwise the value is wrapped as `{:status :success :value …}`. An error in the body produces `{:status :failed :error "…"}`. Usually written via the `defworkflow` macro rather than called directly. +Run a sequential, journaled workflow and return its discriminated-union `{:status …}` result. `(workflow/run name doc meta thunk)` opens a run directory under `./.sema/runs//`, emits a `run.started` event, evaluates `thunk` (the workflow body), then emits `run.ended` and writes `result.json`. `meta` may declare `:phases`, `:budget`, `:permissions`, `:policy`, and `:args`; `:permissions` tightens the CLI sandbox before the run starts, and `:policy` constrains resolved models and model-requested tools. If the body's last value is already a `{:status …}` map it is returned verbatim (its keys land at the top level of `result.json`); otherwise the value is wrapped as `{:status :success :value …}`. An error in the body produces `{:status :failed :error "…"}`. Usually written via the `defworkflow` macro rather than called directly. ```sema (defworkflow hello "demo" {:args {:name :string}} @@ -15,4 +15,4 @@ Run a sequential, journaled workflow and return its discriminated-union `{:statu The run journal (`events.jsonl`) is the system of record; run with `sema workflow run --args `. -See also: `defworkflow`, `workflow/phase`, `checkpoint`. +See also: `defworkflow`, `defpolicy`, `policy/without`, `workflow/phase`, `checkpoint`. diff --git a/crates/sema-docs/entries/stdlib/workflow/workflow-tool-result.md b/crates/sema-docs/entries/stdlib/workflow/workflow-tool-result.md new file mode 100644 index 000000000..27ecddd46 --- /dev/null +++ b/crates/sema-docs/entries/stdlib/workflow/workflow-tool-result.md @@ -0,0 +1,25 @@ +--- +name: "workflow/tool-result" +module: "workflow" +section: "Dynamic Workflows" +syntax: "(workflow/tool-result tool-name)" +--- + +Journal a successful tool completion for the step currently executing. +`tool-name` is a keyword or string. The event records only the `"gated"` +sentinel and does not store the tool result. + +The function returns `nil`. It is a no-op outside a `workflow/step`. Agent and +tool steps call it automatically after a successful tool invocation, so direct +use is only needed by custom workflow integrations. + +```sema +(workflow/step "custom tool" + (fn () + (workflow/tool-call "lookup" {:id 42}) + (def result (lookup 42)) + (workflow/tool-result "lookup") + result)) +``` + +See also: `workflow/tool-call`, `workflow/step`, `step`. diff --git a/crates/sema-eval/src/eval.rs b/crates/sema-eval/src/eval.rs index a1d46f471..cb2057a14 100644 --- a/crates/sema-eval/src/eval.rs +++ b/crates/sema-eval/src/eval.rs @@ -19,6 +19,10 @@ pub enum Trampoline { pub type EvalResult = Result; +fn runtime_internal(message: &str, detail: impl std::fmt::Debug) -> SemaError { + SemaError::internal(message).with_note(format!("runtime detail: {detail:?}")) +} + /// Create an isolated module env: child of root (global/stdlib) env pub fn create_module_env(env: &Env) -> Env { // Walk parent chain to find root @@ -447,7 +451,9 @@ impl Interpreter { .expect("runtime is present outside of Drop"); runtime .submit_root_with_options(vm, &opts) - .map_err(|e| SemaError::eval(format!("root submission failed: {e:?}"))) + .map_err(|error| { + runtime_internal("could not submit the evaluation to the runtime", error) + }) } fn submit_exprs( @@ -481,7 +487,9 @@ impl Interpreter { .expect("runtime is present outside of Drop"); runtime .submit_root_with_options(vm, &opts) - .map_err(|e| SemaError::eval(format!("root submission failed: {e:?}"))) + .map_err(|error| { + runtime_internal("could not submit the evaluation to the runtime", error) + }) } /// Drive an already-submitted root (from [`submit_str`](Self::submit_str) @@ -508,7 +516,7 @@ impl Interpreter { let budget = sema_vm::runtime::DriveBudget::host_default(); runtime .drive(&budget) - .map_err(|e| SemaError::eval(format!("runtime fault: {e:?}"))) + .map_err(|error| runtime_internal("the runtime could not drive the evaluation", error)) } /// Drive one bounded turn while executing VM quanta only for `roots`. @@ -525,7 +533,7 @@ impl Interpreter { let budget = sema_vm::runtime::DriveBudget::host_default(); runtime .drive_roots(&budget, roots) - .map_err(|e| SemaError::eval(format!("runtime fault: {e:?}"))) + .map_err(|error| runtime_internal("the runtime could not drive the evaluation", error)) } /// Drain every [`OutputEvent`](sema_vm::runtime::OutputEvent) captured so @@ -565,7 +573,7 @@ impl Interpreter { ) -> Result { let result = self.runtime().shutdown(&opts); let _ = self.ctx.try_run_interpreter_teardown_hooks(); - result.map_err(|fault| SemaError::eval(format!("runtime fault during shutdown: {fault:?}"))) + result.map_err(|fault| runtime_internal("the runtime could not shut down cleanly", fault)) } /// Submit an already-seeded VM as a fresh root on this interpreter's @@ -628,9 +636,9 @@ impl Interpreter { .as_ref() .expect("runtime is present outside of Drop"); self.ensure_synchronous_runtime_entry_allowed()?; - let handle = runtime - .submit_root(vm) - .map_err(|e| SemaError::eval(format!("root submission failed: {e:?}")))?; + let handle = runtime.submit_root(vm).map_err(|error| { + runtime_internal("could not submit the evaluation to the runtime", error) + })?; self.drive_handle_to_settlement(&handle) } @@ -674,9 +682,14 @@ impl Interpreter { // that the common case; this keeps the drain going for any // teardown the drive scan still owes. loop { - match drive_runtime_root(runtime, &budget, handle.id()) - .map_err(|e| SemaError::eval(format!("runtime fault: {e:?}")))? - { + match drive_runtime_root(runtime, &budget, handle.id()).map_err( + |error| { + runtime_internal( + "the runtime could not drive the evaluation", + error, + ) + }, + )? { DriveState::Progress { ready_remaining: true, .. @@ -693,15 +706,18 @@ impl Interpreter { } RootPoll::Pending => {} RootPoll::Aborted(fault) => { - return Err(SemaError::eval(format!("root aborted: {fault:?}"))); + return Err(runtime_internal( + "the runtime aborted the evaluation", + fault, + )); } RootPoll::RuntimeDropped | RootPoll::InvariantViolation => { - return Err(SemaError::eval("runtime invariant violation")); + return Err(SemaError::internal("the runtime state is inconsistent")); } } - match drive_runtime_root(runtime, &budget, handle.id()) - .map_err(|e| SemaError::eval(format!("runtime fault: {e:?}")))? - { + match drive_runtime_root(runtime, &budget, handle.id()).map_err(|error| { + runtime_internal("the runtime could not drive the evaluation", error) + })? { DriveState::Progress { .. } => {} #[cfg(target_arch = "wasm32")] DriveState::Idle { .. } => { @@ -715,10 +731,11 @@ impl Interpreter { break; } if !matches!( - drive_runtime_root(runtime, &budget, handle.id()).map_err(|e| { - SemaError::eval(format!( - "runtime fault while cancelling suspended WASM root: {e:?}" - )) + drive_runtime_root(runtime, &budget, handle.id()).map_err(|error| { + runtime_internal( + "the runtime could not cancel a suspended WebAssembly evaluation", + error, + ) })?, DriveState::Progress { .. } ) { @@ -747,9 +764,10 @@ impl Interpreter { if !runtime.block_on_inbox(next_deadline) && next_deadline.is_none() { // The inbox closed with no completion and no timer to fall // back on: the parked task can never be resumed. - return Err(SemaError::eval( - "eval_via_runtime: external wait cannot be completed (executor inbox closed)", - )); + return Err(SemaError::internal( + "the runtime could not complete an external wait", + ) + .with_note("runtime detail: executor inbox closed")); } } // The root is parked purely on a timer (`async/sleep`): the only @@ -793,17 +811,24 @@ impl Interpreter { } => { if !runtime .settle_deadlocked_root(handle.id()) - .map_err(|e| SemaError::eval(format!("runtime fault: {e:?}")))? + .map_err(|error| { + runtime_internal( + "the runtime could not settle a deadlocked evaluation", + error, + ) + })? { - return Err(SemaError::eval( - "eval_via_runtime: root did not settle (unsupported suspension on the runtime path)", - )); + return Err(SemaError::internal( + "the runtime could not settle the evaluation", + ) + .with_note("runtime detail: unsupported suspension")); } } _ => { - return Err(SemaError::eval( - "eval_via_runtime: root did not settle (unsupported suspension on the runtime path)", - )); + return Err( + SemaError::internal("the runtime could not settle the evaluation") + .with_note("runtime detail: unsupported suspension"), + ); } } } @@ -3923,24 +3948,30 @@ pub fn register_vm_delegates(env: &Rc, ctx: &Rc) { })), ); - // __vm-deftool: the VM has already evaluated description/parameters/handler + // __vm-deftool: the VM has already evaluated description/parameters/options/handler // and passes them as values, so build the tool directly. let tool_env = Rc::downgrade(env); env.set( intern("__vm-deftool"), Value::native_fn(NativeFn::simple("__vm-deftool", move |args| { - if args.len() != 4 { - return Err(SemaError::arity("deftool", "4", args.len())); + if !matches!(args.len(), 4 | 5) { + return Err(SemaError::arity("deftool", "4 or 5", args.len())); } let name = args[0] .as_symbol() .ok_or_else(|| SemaError::eval("deftool: name must be a symbol"))?; + let (options, handler) = if args.len() == 5 { + (args[3].clone(), args[4].clone()) + } else { + (Value::nil(), args[3].clone()) + }; let tool_env = upgrade_delegate_env(&tool_env)?; special_forms::register_tool( &name, args[1].clone(), args[2].clone(), - args[3].clone(), + options, + handler, &tool_env, ) })), diff --git a/crates/sema-eval/src/prelude.rs b/crates/sema-eval/src/prelude.rs index f2f0d5d80..f71ced312 100644 --- a/crates/sema-eval/src/prelude.rs +++ b/crates/sema-eval/src/prelude.rs @@ -284,10 +284,27 @@ pub const PRELUDE: &str = r#" (defmacro with-session (id config . body) `(otel/with-session ,id ,config (lambda () ,@body))) +;; defpolicy: name a reusable workflow policy. The runtime compiles the resulting +;; immutable map before entering a protected workflow/step body. +(defmacro defpolicy (name rules) + `(define ,name + (assoc (assoc ,rules + :__policy-name (symbol->string (quote ,name))) + :__policy-version 1))) + +;; policy/without: trusted, lexical, audited policy bypass. It never changes the +;; workflow's outer :permissions sandbox ceiling. +(defmacro policy/without (reason . body) + (if (or (not (string? reason)) (null? body)) + (error "policy/without requires a literal reason string and at least one body form") + `(workflow/policy-without ,reason (fn () ,@body)))) + ;; defworkflow: define + run a sequential, journaled workflow. ;; (defworkflow audit-auth "doc" {:phases [...] :budget {:tokens N :usd N}} (phase ...) ...) ;; The meta map's `:budget` submap caps spend: `:tokens` (deterministic) and/or `:usd` -;; (best-effort, pricing-table dependent). Exceeding a cap latches the run and refuses +;; (best-effort, pricing-table dependent). `:policy` installs a model/tool policy for +;; the full workflow body; a step's own `:policy` can only tighten it. Exceeding a cap +;; latches the run and refuses ;; to launch further `step` leaves; the run ends {:status :failed :reason "budget ;; exceeded"}. Concurrent fan-out shares the aggregate budget while each task keeps ;; its own last-usage snapshot and leaf accumulator. @@ -343,6 +360,12 @@ pub const PRELUDE: &str = r#" (defmacro phase (label) `(workflow/phase ,label)) +;; approval: a durable host-controlled workflow gate. `opts` requires a human-readable +;; :reason and an identity-only :subject; optional :preview is the only subject text +;; written to the request sidecar. Pending/rejected gates stop the enclosing workflow. +(defmacro approval (key opts) + `(workflow/approval ,key ,opts)) + ;; checkpoint: a run-scoped state write/read. The write form delays its value ;; expression so `workflow/checkpoint` can return a resume memo before evaluating ;; expensive or side-effecting work. @@ -357,7 +380,8 @@ pub const PRELUDE: &str = r#" ;; named/reusable actor). Runs the prompt through the configured provider and returns ;; TYPED DATA when `:schema` is supplied (validated via `llm/extract`), or the ;; completion text otherwise. The optional opts map carries `:name` (the role label -;; shown in the dashboard, default "step"), `:schema`, `:tools`, and `:agent`. The +;; shown in the dashboard, default "step"), `:schema`, `:tools`, `:agent`, and +;; tightening `:policy`. The ;; call is wrapped by `workflow/step`, which emits agent.started/agent.result + a ;; per-step budget event. (The `agent.*` event names are the FROZEN internal journal ;; contract — they predate the step rename and stay; `agent_name` carries the step's @@ -443,10 +467,15 @@ pub const PRELUDE: &str = r#" (keys s#))) #t))) ;; the `:on-tool-call` shim — journals each genuine tool call as an - ;; agent.tool_call event. Shared by the `:agent` and `:tools` branches. + ;; agent.tool_call event and each successful completion as + ;; agent.tool_result. Shared by the `:agent` and `:tools` branches. (st-on-tool# (fn (ev#) - (when (= (:event ev#) "start") - (workflow/tool-call (:tool ev#) (:args ev#)))))) + (cond + ((= (:event ev#) "start") + (workflow/tool-call (:tool ev#) (:args ev#))) + ((and (= (:event ev#) "end") + (not (:error ev#))) + (workflow/tool-result (:tool ev#))))))) ;; validate the final text against `:schema` (no-op text passthrough when ;; no schema). Shared by the `:agent` and `:tools` branches. (let ((st-validate# (fn (txt# sch#) diff --git a/crates/sema-eval/src/special_forms.rs b/crates/sema-eval/src/special_forms.rs index a8423a41d..ea2a939f0 100644 --- a/crates/sema-eval/src/special_forms.rs +++ b/crates/sema-eval/src/special_forms.rs @@ -1,7 +1,8 @@ use std::rc::Rc; use sema_core::{ - intern, resolve, Agent, Env, EvalContext, Record, SemaError, Spur, ToolDefinition, Value, + intern, resolve, suggest_similar, Agent, Env, EvalContext, FileAccess, Record, SemaError, Spur, + ToolDefinition, ToolPolicySubject, Value, }; use crate::eval::{self, Trampoline}; @@ -70,28 +71,189 @@ pub const SPECIAL_FORM_NAMES: &[&str] = &[ /// Build a `ToolDefinition` from already-evaluated values and bind it in `env`. /// The VM's `__vm-deftool` native passes the pre-evaluated description / -/// parameters / handler straight here. +/// parameters / options / handler straight here. pub(crate) fn register_tool( name: &str, description: Value, parameters: Value, + options: Value, handler: Value, env: &Env, ) -> Result { let description = description .as_str() - .ok_or_else(|| SemaError::type_error("string", description.type_name()))? + .ok_or_else(|| SemaError::argument_type("deftool", 2, "string", &description))? .to_string(); + let policy_subjects = parse_tool_policy_subjects(&options)?; let tool = Value::tool_def(ToolDefinition { name: name.to_string(), description, parameters, + policy_subjects, handler, }); env.set(intern(name), tool.clone()); Ok(tool) } +fn parse_tool_policy_subjects(options: &Value) -> Result, SemaError> { + if options.is_nil() { + return Ok(Vec::new()); + } + let map = options.as_map_rc().ok_or_else(|| { + SemaError::eval(format!( + "deftool: options must be a map, got {}", + options.type_name() + )) + })?; + for key in map.keys() { + let Some(key) = key.as_keyword() else { + return Err(SemaError::eval(format!( + "deftool: option keys must be keywords, got {}", + key.type_name() + ))); + }; + if key != "policy-subjects" { + let error = SemaError::eval(format!("deftool: unknown option :{key}")); + return Err(match suggest_similar(&key, &["policy-subjects"]) { + Some(candidate) => error.with_hint(format!("did you mean :{candidate}?")), + None => error.with_hint("the valid option is :policy-subjects"), + }); + } + } + let Some(subjects) = map.get(&Value::keyword("policy-subjects")) else { + return Ok(Vec::new()); + }; + let subjects = subjects.as_seq().ok_or_else(|| { + SemaError::eval(format!( + "deftool: :policy-subjects must be a list or vector, got {}", + subjects.type_name() + )) + })?; + subjects + .iter() + .enumerate() + .map(|(index, value)| parse_tool_policy_subject(value, index + 1)) + .collect::, _>>() +} + +fn parse_tool_policy_subject( + value: &Value, + subject_index: usize, +) -> Result { + let context = format!("deftool: policy subject {subject_index}"); + let map = value.as_map_rc().ok_or_else(|| { + SemaError::eval(format!( + "{context} must be a map, got {}", + value.type_name() + )) + })?; + let kind = required_subject_name(&map, "kind", &context)?; + match kind.as_str() { + "file-read" | "file-write" | "file-delete" => { + reject_subject_keys(&map, &["kind", "path-arg"], &context)?; + let access = match kind.as_str() { + "file-read" => FileAccess::Read, + "file-write" => FileAccess::Write, + "file-delete" => FileAccess::Delete, + _ => unreachable!("matched file subject kind"), + }; + Ok(ToolPolicySubject::File { + access, + path_arg: required_subject_name(&map, "path-arg", &context)?, + }) + } + "network-request" => { + reject_subject_keys(&map, &["kind", "url-arg", "method"], &context)?; + Ok(ToolPolicySubject::NetworkRequest { + method: optional_subject_name(&map, "method", &context)?, + url_arg: required_subject_name(&map, "url-arg", &context)?, + }) + } + "command" => { + reject_subject_keys(&map, &["kind", "command-arg"], &context)?; + Ok(ToolPolicySubject::Command { + command_arg: required_subject_name(&map, "command-arg", &context)?, + }) + } + "external-action" => { + reject_subject_keys(&map, &["kind", "action", "target-arg"], &context)?; + Ok(ToolPolicySubject::ExternalAction { + action: required_subject_name(&map, "action", &context)?, + target_arg: optional_subject_name(&map, "target-arg", &context)?, + }) + } + _ => Err(SemaError::eval(format!( + "{context} has unsupported :kind :{kind}" + )) + .with_hint( + "valid kinds are :file-read, :file-write, :file-delete, :network-request, :command, and :external-action", + )), + } +} + +fn required_subject_name( + map: &std::collections::BTreeMap, + key: &str, + context: &str, +) -> Result { + map.get(&Value::keyword(key)) + .ok_or_else(|| SemaError::eval(format!("{context} is missing :{key}"))) + .and_then(|value| subject_name(value, key, context)) +} + +fn optional_subject_name( + map: &std::collections::BTreeMap, + key: &str, + context: &str, +) -> Result, SemaError> { + map.get(&Value::keyword(key)) + .map(|value| subject_name(value, key, context)) + .transpose() +} + +fn subject_name(value: &Value, key: &str, context: &str) -> Result { + value + .as_keyword() + .or_else(|| value.as_str().map(str::to_string)) + .ok_or_else(|| { + SemaError::eval(format!( + "{context} :{key} must be a keyword or string, got {}", + value.type_name() + )) + }) +} + +fn reject_subject_keys( + map: &std::collections::BTreeMap, + allowed: &[&str], + context: &str, +) -> Result<(), SemaError> { + for key in map.keys() { + let Some(key) = key.as_keyword() else { + return Err(SemaError::eval(format!( + "{context} keys must be keywords, got {}", + key.type_name() + ))); + }; + if !allowed.contains(&key.as_str()) { + let error = SemaError::eval(format!("{context} has unknown key :{key}")); + return Err(match suggest_similar(&key, allowed) { + Some(candidate) => error.with_hint(format!("did you mean :{candidate}?")), + None => error.with_hint(format!( + "valid keys are {}", + allowed + .iter() + .map(|key| format!(":{key}")) + .collect::>() + .join(", ") + )), + }); + } + } + Ok(()) +} + /// Build an `Agent` from an already-evaluated options map and bind it in `env`. /// The VM's `__vm-defagent` native passes the pre-evaluated options map here. pub(crate) fn register_agent(name: &str, opts: Value, env: &Env) -> Result { @@ -162,6 +324,16 @@ fn resolve_embedded_file( ctx: &EvalContext, spec: &str, ) -> Option<(std::path::PathBuf, std::path::PathBuf, Vec)> { + // Approval runs may snapshot a literal absolute import under its canonical + // filesystem identity. Hosts choose the embedded keys, so an exact absolute + // key is safe to consult before applying the portable VFS normalization. + let spec_path = std::path::PathBuf::from(spec); + if spec_path.is_absolute() { + if let Some(bytes) = ctx.get_embedded_file(&spec_path) { + return Some((spec_path.clone(), spec_path, bytes)); + } + } + // Archive keys are clean, lexically-normalized, root-relative paths (e.g. // "util.sema", "lib/util.sema"). Look the spec up in the same normalized form // — resolving "./", "../", and interior "." — so every spelling that names @@ -229,6 +401,11 @@ pub(crate) fn prepare_load( bytes, }); } + if ctx.embedded_files_only() { + return Err(SemaError::Io(format!( + "load {path_str}: file is not in the host dependency snapshot" + ))); + } if sema_core::vfs::is_vfs_active() { let base_dir = ctx @@ -319,6 +496,11 @@ pub(crate) fn prepare_import( path_str, identity, file_path, bytes, selective, ctx, )); } + if ctx.embedded_files_only() { + return Err(SemaError::Io(format!( + "import {path_str}: file is not in the host dependency snapshot" + ))); + } if sema_core::vfs::is_vfs_active() { let base_dir = ctx @@ -745,6 +927,35 @@ mod tests { ); } + #[cfg(unix)] + #[test] + fn embedded_import_absolute_identity() { + let interp = Interpreter::new(); + let key = PathBuf::from("/snapshots/u.sema"); + embed( + &interp, + key.to_str().unwrap(), + "(module u (export v) (define v 11))", + ); + assert_eq!( + interp.eval_str(&format!("(import {key:?}) v")).unwrap(), + Value::int(11) + ); + } + + #[test] + fn embedded_only_mode_rejects_unlisted_imports() { + let interp = Interpreter::new(); + interp.ctx.set_embedded_files_only(true); + let error = interp.eval_str(r#"(import "unlisted.sema")"#).unwrap_err(); + assert!( + error + .to_string() + .contains("file is not in the host dependency snapshot"), + "{error}" + ); + } + #[test] fn embedded_import_nested_relative() { // entry → lib/a, a (in lib/) → ./b resolves to lib/b via current_file. diff --git a/crates/sema-llm/Cargo.toml b/crates/sema-llm/Cargo.toml index 537f0decb..3a8e7b338 100644 --- a/crates/sema-llm/Cargo.toml +++ b/crates/sema-llm/Cargo.toml @@ -12,6 +12,7 @@ readme = "README.md" sema-core.workspace = true sema-io.workspace = true sema-otel.workspace = true +sema-policy.workspace = true thiserror.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/sema-llm/src/builtins.rs b/crates/sema-llm/src/builtins.rs index 2b0fe9424..eae524682 100644 --- a/crates/sema-llm/src/builtins.rs +++ b/crates/sema-llm/src/builtins.rs @@ -1,12 +1,13 @@ use std::cell::Cell; use std::cell::RefCell; use std::collections::{BTreeMap, VecDeque}; +use std::path::PathBuf; use std::rc::Rc; use sema_core::runtime::RuntimeTaskId; use sema_core::{ - resolve, Agent, Conversation, Env, EvalContext, ImageAttachment, Message, NativeFn, Prompt, - Role, SemaError, Value, ValueView, + resolve, Agent, Conversation, Env, EvalContext, ImageAttachment, Message, NativeFn, + PolicyDenial, Prompt, Role, SemaError, Value, ValueView, }; use sha2::{Digest, Sha256}; @@ -20,7 +21,7 @@ use crate::pricing; use crate::provider::{LlmProvider, ProviderRegistry}; use crate::types::{ ChatMessage, ChatRequest, ChatResponse, ContentBlock, EmbedRequest, EmbedResponse, LlmError, - RerankRequest, RerankResponse, ToolCall, ToolSchema, Usage, + MessageContent, RerankRequest, RerankResponse, ToolCall, ToolSchema, Usage, }; use crate::vector_store::{VectorDocument, VectorStore}; @@ -61,6 +62,16 @@ thread_local! { /// module. Allows `agent/run` to seed from and append to a memory handle without /// depending on `sema-stdlib` (which would be circular). static MEMORY_CALLBACKS: RefCell> = const { RefCell::new(None) }; + /// Ordered policy layers active for the current task. The LLM + /// dynamic-scope mechanism captures and swaps these with cache/budget/cassette + /// state, so workflow and step policies remain isolated across sibling tasks. + static ACTIVE_POLICIES: RefCell> = const { RefCell::new(Vec::new()) }; + /// Trusted lexical policy bypass reasons. A nonempty stack suppresses policy + /// enforcement but still emits a `policy.bypassed` observation per boundary. + static POLICY_BYPASS: RefCell> = const { RefCell::new(Vec::new()) }; + /// Workflow step attribution carried with the policy scope, independent of + /// the workflow crate's task-local state. + static POLICY_AGENT_ID: RefCell> = const { RefCell::new(None) }; } /// Function-pointer table injected by `sema-stdlib/memory.rs` via @@ -98,6 +109,790 @@ pub struct LastUsage { pub cost_usd: Option, } +/// Policy boundary recorded by the workflow journal sink. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PolicyBoundary { + Model, + Tool, + LlmInput, + LlmOutput, +} + +impl PolicyBoundary { + pub fn as_str(self) -> &'static str { + match self { + Self::Model => "model", + Self::Tool => "tool", + Self::LlmInput => "llm.input", + Self::LlmOutput => "llm.output", + } + } +} + +/// Where a policy-checked model result is about to come from. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PolicySource { + Request, + Cache, + Cassette, +} + +impl PolicySource { + pub fn as_str(self) -> &'static str { + match self { + Self::Request => "request", + Self::Cache => "cache", + Self::Cassette => "cassette", + } + } +} + +/// The journal-facing result of checking or bypassing one policy layer. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PolicyObservation { + pub kind: PolicyObservationKind, + pub policy: String, + pub policy_digest: String, + pub boundary: PolicyBoundary, + pub subject: String, + pub subject_digest: Option, + pub rule: String, + pub label: Option, + pub count: Option, + pub action: Option, + pub reason: Option, + pub source: PolicySource, + pub agent_id: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PolicyObservationKind { + Checked, + Flagged, + Redacted, + Violation, + Bypassed, +} + +/// Sink installed by `workflow/run`; it captures only a weak workflow context. +pub type PolicyDecisionSink = Rc; + +#[derive(Clone)] +struct ActivePolicy { + policy: Rc, + workspace_root: PathBuf, + sink: PolicyDecisionSink, +} + +/// Effective result of checking all active policy layers. +#[derive(Debug, Clone, PartialEq, Eq)] +enum PolicyGate { + Allow, + Deny(PolicyDecision), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct PolicyDecision { + action: T, + denial: PolicyDenial, +} + +/// RAII guard for one workflow or step policy layer. +pub struct PolicyScope { + previous: Option>, +} + +impl Drop for PolicyScope { + fn drop(&mut self) { + if let Some(previous) = self.previous.take() { + ACTIVE_POLICIES.with(|policies| *policies.borrow_mut() = previous); + } + } +} + +/// RAII guard for a trusted lexical policy bypass. +pub struct PolicyBypassScope { + previous: Option>, +} + +impl Drop for PolicyBypassScope { + fn drop(&mut self) { + if let Some(previous) = self.previous.take() { + POLICY_BYPASS.with(|bypass| *bypass.borrow_mut() = previous); + } + } +} + +/// RAII guard for step-level journal attribution. +pub struct PolicyAttributionScope { + previous: Option, +} + +impl Drop for PolicyAttributionScope { + fn drop(&mut self) { + POLICY_AGENT_ID.with(|agent| *agent.borrow_mut() = self.previous.take()); + } +} + +/// Install compiled policy layers atomically for the dynamic extent of a workflow or step. +pub fn open_policy_scopes( + policies_to_add: Vec>, + workspace_root: PathBuf, + sink: PolicyDecisionSink, +) -> PolicyScope { + let previous = ACTIVE_POLICIES.with(|policies| { + let previous = policies.borrow().clone(); + policies + .borrow_mut() + .extend(policies_to_add.into_iter().map(|policy| ActivePolicy { + policy, + workspace_root: workspace_root.clone(), + sink: sink.clone(), + })); + previous + }); + PolicyScope { + previous: Some(previous), + } +} + +/// Disable active policies for a trusted lexical extent while retaining audit +/// observations and all sandbox capability checks. +pub fn open_policy_bypass(reason: String) -> PolicyBypassScope { + let previous = POLICY_BYPASS.with(|bypass| { + let previous = bypass.borrow().clone(); + bypass.borrow_mut().push(reason); + previous + }); + PolicyBypassScope { + previous: Some(previous), + } +} + +/// Attribute policy observations to one workflow step. +pub fn open_policy_attribution(agent_id: String) -> PolicyAttributionScope { + let previous = POLICY_AGENT_ID.with(|agent| agent.borrow_mut().replace(agent_id)); + PolicyAttributionScope { previous } +} + +/// Whether at least one policy layer is currently active. +pub fn policy_active() -> bool { + ACTIVE_POLICIES.with(|policies| !policies.borrow().is_empty()) +} + +/// Stable digest of the ordered effective policy stack and bypass state. +pub fn effective_policy_fingerprint() -> String { + let policies = ACTIVE_POLICIES.with(|policies| policies.borrow().clone()); + if policies.is_empty() { + return String::new(); + } + let bypass = POLICY_BYPASS.with(|bypass| bypass.borrow().last().cloned()); + let mut hasher = Sha256::new(); + hasher.update(b"sema-effective-policy-v1\0"); + for layer in policies { + hasher.update(layer.policy.fingerprint().as_bytes()); + hasher.update(b"\0"); + } + if let Some(reason) = bypass { + hasher.update(b"bypass\0"); + hasher.update(reason.as_bytes()); + } + format!("sha256:{:x}", hasher.finalize()) +} + +/// Check all active model policy layers. `minimum_action` upgrades `:skip` +/// outside a real fallback selection so the journal records the action that +/// enforcement will actually take. +fn check_model_policy( + provider: &str, + model: &str, + source: PolicySource, + minimum_action: sema_policy::ModelDenyAction, +) -> PolicyGate { + let subject = format!("{provider}/{model}"); + check_active_policies( + PolicyBoundary::Model, + &subject, + None, + source, + |layer| layer.policy.check_model(provider, model), + |layer| layer.policy.model_action().max(Some(minimum_action)), + ) +} + +fn policy_denied(denial: PolicyDenial) -> SemaError { + SemaError::policy_denied(denial) +} + +fn unnamed_policy_denial( + boundary: PolicyBoundary, + subject: impl Into, + rule: impl Into, + reason: impl Into, + action: impl Into, + source: PolicySource, +) -> PolicyDenial { + PolicyDenial { + policy: None, + boundary: boundary.as_str().to_string(), + subject: subject.into(), + rule: rule.into(), + reason: reason.into(), + action: action.into(), + source: source.as_str().to_string(), + } +} + +/// Check a resolved model target. `Ok(false)` means a fallback-only `:skip` +/// denial; every other denial is a hard error. +fn model_target_allowed( + provider: &str, + model: &str, + source: PolicySource, + fallback_target: bool, +) -> Result { + let minimum_action = if fallback_target { + sema_policy::ModelDenyAction::Skip + } else { + sema_policy::ModelDenyAction::Fail + }; + match check_model_policy(provider, model, source, minimum_action) { + PolicyGate::Allow => Ok(true), + PolicyGate::Deny(decision) + if decision.action == sema_policy::ModelDenyAction::Skip && fallback_target => + { + Ok(false) + } + PolicyGate::Deny(decision) => Err(policy_denied(decision.denial)), + } +} + +/// Resolve and check every batch target before the provider starts any request. +fn resolve_batch_models( + provider: &dyn LlmProvider, + requests: impl IntoIterator, +) -> Result, SemaError> { + requests + .into_iter() + .map(|mut request| { + apply_input_policy_to_request(&mut request)?; + if request.model.is_empty() { + request.model = provider.default_model().to_string(); + } + model_target_allowed( + provider.name(), + &request.model, + PolicySource::Request, + false, + )?; + Ok(request) + }) + .collect() +} + +fn enforce_stored_model_policy( + provider: &str, + model: &str, + source: PolicySource, +) -> Result<(), SemaError> { + if !policy_active() { + return Ok(()); + } + if provider.is_empty() { + return Err(policy_denied(unnamed_policy_denial( + PolicyBoundary::Model, + model, + format!("{}.missing-provider", source.as_str()), + "stored model metadata does not identify a provider", + "fail", + source, + ))); + } + model_target_allowed(provider, model, source, false).map(|_| ()) +} + +fn preflight_tool_calls( + calls: &[ToolCall], + tools: &[Value], +) -> Result, SemaError> { + let mut denied = BTreeMap::new(); + let mut hard_denial = None; + for call in calls { + let definition = tools.iter().find_map(|tool| { + tool.as_tool_def_rc() + .filter(|definition| definition.name == call.name) + }); + let policy_subjects = definition + .as_deref() + .map_or(&[][..], |definition| definition.policy_subjects.as_slice()); + match check_tool_policy( + &call.name, + &call.arguments, + policy_subjects, + sema_policy::ToolDenyAction::ToolError, + ) { + PolicyGate::Allow => {} + PolicyGate::Deny(decision) + if decision.action == sema_policy::ToolDenyAction::ToolError => + { + denied.insert( + call.id.clone(), + format!( + "tool '{}' was blocked: {}", + call.name, decision.denial.reason + ), + ); + } + PolicyGate::Deny(decision) => { + hard_denial.get_or_insert(decision.denial); + } + } + } + if let Some(denial) = hard_denial { + return Err(policy_denied(denial)); + } + Ok(denied) +} + +fn enforce_direct_tool_policy( + tool: &str, + arguments: &serde_json::Value, + policy_subjects: &[sema_core::ToolPolicySubject], +) -> Result<(), SemaError> { + match check_tool_policy( + tool, + arguments, + policy_subjects, + sema_policy::ToolDenyAction::Fail, + ) { + PolicyGate::Allow => Ok(()), + PolicyGate::Deny(decision) => Err(policy_denied(decision.denial)), + } +} + +/// Check all active tool policy layers. +fn check_tool_policy( + tool: &str, + arguments: &serde_json::Value, + policy_subjects: &[sema_core::ToolPolicySubject], + minimum_action: sema_policy::ToolDenyAction, +) -> PolicyGate { + let subject_digest = policy_value_digest(arguments); + check_active_policies( + PolicyBoundary::Tool, + tool, + Some(subject_digest), + PolicySource::Request, + |layer| { + layer + .policy + .check_tool(tool, arguments, policy_subjects, &layer.workspace_root) + }, + |layer| layer.policy.tool_action().max(Some(minimum_action)), + ) +} + +trait PolicyAction: Copy + Ord { + fn name(self) -> &'static str; +} + +impl PolicyAction for sema_policy::ModelDenyAction { + fn name(self) -> &'static str { + match self { + Self::Skip => "skip", + Self::Fail => "fail", + } + } +} + +impl PolicyAction for sema_policy::ToolDenyAction { + fn name(self) -> &'static str { + match self { + Self::ToolError => "tool-error", + Self::Fail => "fail", + } + } +} + +fn check_active_policies( + boundary: PolicyBoundary, + subject: &str, + subject_digest: Option, + source: PolicySource, + check: impl Fn(&ActivePolicy) -> sema_policy::PolicyCheck, + action: impl Fn(&ActivePolicy) -> Option, +) -> PolicyGate { + let policies = ACTIVE_POLICIES.with(|policies| policies.borrow().clone()); + if policies.is_empty() { + return PolicyGate::Allow; + } + let agent_id = POLICY_AGENT_ID.with(|agent| agent.borrow().clone()); + if let Some(reason) = POLICY_BYPASS.with(|bypass| bypass.borrow().last().cloned()) { + let fingerprint = effective_policy_fingerprint(); + let observation = PolicyObservation { + kind: PolicyObservationKind::Bypassed, + policy: "effective-policy".to_string(), + policy_digest: fingerprint, + boundary, + subject: subject.to_string(), + subject_digest, + rule: "policy.without".to_string(), + label: None, + count: None, + action: Some("bypass".to_string()), + reason: Some(reason), + source, + agent_id, + }; + (policies.last().expect("nonempty policy stack").sink)(observation); + return PolicyGate::Allow; + } + + let decisions: Vec<_> = policies + .iter() + .map(|layer| { + let result = check(layer); + let configured_action = (!result.allowed).then(|| action(layer)).flatten(); + (result, configured_action) + }) + .collect(); + let denied_action = decisions.iter().filter_map(|(_, action)| *action).max(); + let denial = denied_action.and_then(|effective_action| { + policies + .iter() + .zip(&decisions) + .find(|(_, (result, configured_action))| { + !result.allowed && *configured_action == Some(effective_action) + }) + .map(|(layer, (result, _))| PolicyDecision { + action: effective_action, + denial: PolicyDenial { + policy: Some(layer.policy.name().to_string()), + boundary: boundary.as_str().to_string(), + subject: subject.to_string(), + rule: result.rule.clone(), + reason: result + .reason + .clone() + .unwrap_or_else(|| "the active policy denied this operation".to_string()), + action: effective_action.name().to_string(), + source: source.as_str().to_string(), + }, + }) + }); + + for (layer, (result, configured_action)) in policies.iter().zip(decisions) { + let observation = PolicyObservation { + kind: if result.allowed { + PolicyObservationKind::Checked + } else { + PolicyObservationKind::Violation + }, + policy: layer.policy.name().to_string(), + policy_digest: layer.policy.fingerprint().to_string(), + boundary, + subject: subject.to_string(), + subject_digest: subject_digest.clone(), + rule: result.rule, + label: None, + count: None, + // Journal what the boundary will actually do. A stricter denial in + // any active layer upgrades every violation observation to that + // effective action. + action: configured_action + .and(denied_action) + .map(|action| action.name().to_string()), + reason: result.reason, + source, + agent_id: agent_id.clone(), + }; + (layer.sink)(observation); + } + denial.map_or(PolicyGate::Allow, PolicyGate::Deny) +} + +fn policy_value_digest(value: &serde_json::Value) -> String { + let mut hasher = Sha256::new(); + hasher.update(serde_json::to_vec(value).unwrap_or_default()); + format!("sha256:{:x}", hasher.finalize()) +} + +fn policy_text_digest(text: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(text.as_bytes()); + format!("sha256:{:x}", hasher.finalize()) +} + +fn output_policy_active() -> bool { + ACTIVE_POLICIES.with(|policies| { + policies + .borrow() + .iter() + .any(|layer| layer.policy.has_output_policy()) + }) +} + +fn apply_text_policy( + text: &str, + boundary: PolicyBoundary, + subject: &str, + source: PolicySource, + output_stage: Option, +) -> Result { + let policies = ACTIVE_POLICIES.with(|policies| policies.borrow().clone()); + let policies: Vec<_> = policies + .into_iter() + .filter(|layer| match boundary { + PolicyBoundary::LlmInput => layer.policy.has_input_policy(), + PolicyBoundary::LlmOutput => layer.policy.has_output_policy(), + PolicyBoundary::Model | PolicyBoundary::Tool => false, + }) + .collect(); + if policies.is_empty() { + return Ok(text.to_string()); + } + if text.len() > sema_policy::content::INPUT_BYTE_CAP { + return Err(policy_denied(unnamed_policy_denial( + boundary, + subject, + "content.input-too-large", + format!( + "content exceeds the {}-byte policy limit", + sema_policy::content::INPUT_BYTE_CAP + ), + "block", + source, + ))); + } + let subject_digest = Some(policy_text_digest(text)); + let agent_id = POLICY_AGENT_ID.with(|agent| agent.borrow().clone()); + if let Some(reason) = POLICY_BYPASS.with(|bypass| bypass.borrow().last().cloned()) { + let observation = PolicyObservation { + kind: PolicyObservationKind::Bypassed, + policy: "effective-policy".to_string(), + policy_digest: effective_policy_fingerprint(), + boundary, + subject: subject.to_string(), + subject_digest, + rule: "policy.without".to_string(), + label: None, + count: None, + action: Some("bypass".to_string()), + reason: Some(reason), + source, + agent_id, + }; + (policies.last().expect("nonempty content policy stack").sink)(observation); + return Ok(text.to_string()); + } + + let outcomes: Vec<_> = policies + .iter() + .map(|layer| { + let outcome = match boundary { + PolicyBoundary::LlmInput => layer.policy.check_input(text), + PolicyBoundary::LlmOutput => layer.policy.check_output( + text, + output_stage.unwrap_or(sema_policy::OutputStage::Final), + ), + PolicyBoundary::Model | PolicyBoundary::Tool => { + unreachable!("content policy called for non-content boundary") + } + }; + (layer, outcome) + }) + .collect(); + let effective_action = outcomes + .iter() + .map(|(_, outcome)| outcome.action) + .max() + .unwrap_or(sema_policy::ContentAction::Allow); + + for (layer, outcome) in &outcomes { + if outcome.findings.is_empty() { + (layer.sink)(PolicyObservation { + kind: PolicyObservationKind::Checked, + policy: layer.policy.name().to_string(), + policy_digest: layer.policy.fingerprint().to_string(), + boundary, + subject: subject.to_string(), + subject_digest: subject_digest.clone(), + rule: format!("{}.checked", boundary.as_str()), + label: None, + count: None, + action: None, + reason: None, + source, + agent_id: agent_id.clone(), + }); + continue; + } + for finding in &outcome.findings { + let kind = match outcome.action { + sema_policy::ContentAction::Block => PolicyObservationKind::Violation, + sema_policy::ContentAction::Redact => PolicyObservationKind::Redacted, + sema_policy::ContentAction::Audit => PolicyObservationKind::Flagged, + sema_policy::ContentAction::Allow => PolicyObservationKind::Checked, + }; + (layer.sink)(PolicyObservation { + kind, + policy: layer.policy.name().to_string(), + policy_digest: layer.policy.fingerprint().to_string(), + boundary, + subject: subject.to_string(), + subject_digest: subject_digest.clone(), + rule: finding.rule_id.clone(), + label: Some(finding.label.clone()), + count: Some(finding.count), + action: Some(outcome.action.as_str().to_string()), + reason: (outcome.action == sema_policy::ContentAction::Block) + .then(|| "deterministic content policy matched".to_string()), + source, + agent_id: agent_id.clone(), + }); + } + } + + match effective_action { + sema_policy::ContentAction::Block => { + let denial = outcomes + .iter() + .filter(|(_, outcome)| outcome.action == sema_policy::ContentAction::Block) + .find_map(|(layer, outcome)| { + outcome.findings.first().map(|finding| PolicyDenial { + policy: Some(layer.policy.name().to_string()), + boundary: boundary.as_str().to_string(), + subject: subject.to_string(), + rule: finding.rule_id.clone(), + reason: format!( + "content matched {} ({} {})", + finding.label, + finding.count, + if finding.count == 1 { + "finding" + } else { + "findings" + } + ), + action: effective_action.as_str().to_string(), + source: source.as_str().to_string(), + }) + }) + .unwrap_or_else(|| { + unnamed_policy_denial( + boundary, + subject, + "content.denied", + "content policy blocked this value", + effective_action.as_str(), + source, + ) + }); + Err(policy_denied(denial)) + } + sema_policy::ContentAction::Redact => { + let redactions = outcomes + .iter() + .flat_map(|(_, outcome)| outcome.redactions.iter().cloned()) + .collect::>(); + Ok(sema_policy::content::redact(text, &redactions)) + } + sema_policy::ContentAction::Allow | sema_policy::ContentAction::Audit => { + Ok(text.to_string()) + } + } +} + +fn apply_input_policy_to_request(request: &mut ChatRequest) -> Result<(), SemaError> { + let mut system = request.system.clone(); + if let Some(value) = &mut system { + *value = apply_text_policy( + value, + PolicyBoundary::LlmInput, + "system", + PolicySource::Request, + None, + )?; + } + let mut messages = request.messages.clone(); + for (message_index, message) in messages.iter_mut().enumerate() { + let subject = format!("message.{message_index}.{}", message.role); + match &mut message.content { + MessageContent::Text(text) => { + *text = apply_text_policy( + text, + PolicyBoundary::LlmInput, + &subject, + PolicySource::Request, + None, + )?; + } + MessageContent::Blocks(blocks) => { + for (block_index, block) in blocks.iter_mut().enumerate() { + if let ContentBlock::Text { text } = block { + *text = apply_text_policy( + text, + PolicyBoundary::LlmInput, + &format!("{subject}.block.{block_index}"), + PolicySource::Request, + None, + )?; + } + } + } + } + } + request.system = system; + request.messages = messages; + Ok(()) +} + +fn apply_output_policy_to_response( + response: &mut ChatResponse, + source: PolicySource, +) -> Result<(), SemaError> { + let stage = if response.tool_calls.is_empty() { + sema_policy::OutputStage::Final + } else { + sema_policy::OutputStage::Round + }; + let subject = match stage { + sema_policy::OutputStage::Round => "assistant.round", + sema_policy::OutputStage::Final => "assistant.final", + }; + response.content = apply_text_policy( + &response.content, + PolicyBoundary::LlmOutput, + subject, + source, + Some(stage), + )?; + Ok(()) +} + +fn apply_input_policy_to_texts( + texts: &mut [String], + subject_prefix: &str, +) -> Result<(), SemaError> { + let transformed = texts + .iter() + .enumerate() + .map(|(index, text)| { + apply_text_policy( + text, + PolicyBoundary::LlmInput, + &format!("{subject_prefix}[{index}]"), + PolicySource::Request, + None, + ) + }) + .collect::, _>>()?; + for (text, safe) in texts.iter_mut().zip(transformed) { + *text = safe; + } + Ok(()) +} + /// Clear the per-thread last-usage slot. The workflow runtime calls this at the START /// of each agent leaf so that [`last_usage_snapshot`] read afterwards reflects ONLY a /// completion this leaf made — a leaf whose call fails (or makes none) reports `None` @@ -141,16 +936,8 @@ pub struct LeafUsage { fn accumulate_into(slot: &Rc>, usage: &Usage, cost: Option) { let input = usage.prompt_tokens as u64; let output = usage.completion_tokens as u64; - // Cache-hit-zero-usage invariant: an all-zero completion is a cache hit; - // don't count it as a call (no phantom zero Budget event for a cached leaf). - // - // Cost is deliberately NOT part of this test. The only caller prices the - // usage first, and pricing a zero-token usage against a model that IS in the - // snapshot yields `Some(0.0)`, not `None` — so requiring `cost.is_none()` - // let every cache hit on a priced model (gpt-5.5, claude-*, …) fall through - // and book a call at $0.00, flipping a purely-cached leaf's cost from - // "unknown" to "free". Only fakes and unpriced models took the intended - // path, which is why nothing caught it. + // A cache hit reports no tokens and no cost. Priced models can report the + // cost as `Some(0.0)`, so `cost.is_none()` cannot identify cache hits. if input == 0 && output == 0 && cost.unwrap_or(0.0) == 0.0 { return; } @@ -344,6 +1131,9 @@ struct LlmDynScope { /// The cassette selected by this scope. Spawned siblings share one tape so /// replay and recording remain coherent across quantum boundaries. cassette: Option, + policies: Vec, + policy_bypass: Vec, + policy_agent_id: Option, } impl Default for LlmDynScope { @@ -364,6 +1154,9 @@ impl Default for LlmDynScope { budget_stack: Vec::new(), custom_pricing: std::collections::HashMap::new(), cassette: None, + policies: Vec::new(), + policy_bypass: Vec::new(), + policy_agent_id: None, } } } @@ -386,6 +1179,9 @@ fn read_llm_scope() -> LlmDynScope { budget_stack: BUDGET_STACK.with(|s| s.borrow().clone()), custom_pricing: pricing::snapshot_custom_pricing(), cassette: CASSETTE.with(|c| c.borrow().clone()), + policies: ACTIVE_POLICIES.with(|policies| policies.borrow().clone()), + policy_bypass: POLICY_BYPASS.with(|bypass| bypass.borrow().clone()), + policy_agent_id: POLICY_AGENT_ID.with(|agent| agent.borrow().clone()), } } @@ -407,6 +1203,9 @@ fn write_llm_scope(s: LlmDynScope) -> LlmDynScope { BUDGET_STACK.with(|stack| *stack.borrow_mut() = s.budget_stack); pricing::restore_custom_pricing(s.custom_pricing); CASSETTE.with(|c| *c.borrow_mut() = s.cassette); + ACTIVE_POLICIES.with(|policies| *policies.borrow_mut() = s.policies); + POLICY_BYPASS.with(|bypass| *bypass.borrow_mut() = s.policy_bypass); + POLICY_AGENT_ID.with(|agent| *agent.borrow_mut() = s.policy_agent_id); prev } @@ -464,6 +1263,9 @@ fn llm_scope_ambient_is_empty() -> bool { && RETRY_BASE_MS.with(|base| base.get() == 500) && NETWORK_MAX_RETRIES.with(|retries| retries.get() == 3) && CASSETTE.with(|c| c.borrow().is_none()) + && ACTIVE_POLICIES.with(|policies| policies.borrow().is_empty()) + && POLICY_BYPASS.with(|bypass| bypass.borrow().is_empty()) + && POLICY_AGENT_ID.with(|agent| agent.borrow().is_none()) } /// Shared field-by-field default check for [`LlmDynScope`] (avoids requiring @@ -485,6 +1287,9 @@ fn llm_dyn_scope_is_default(s: &LlmDynScope) -> bool { && s.rate_limit_rps.is_none() && s.retry_base_ms == 500 && s.network_max_retries == 3 + && s.policies.is_empty() + && s.policy_bypass.is_empty() + && s.policy_agent_id.is_none() } /// Register the per-task LLM dynamic-scope callbacks with sema-core. Called once at startup. @@ -507,19 +1312,15 @@ struct BudgetFrame { #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] struct CachedResponse { content: String, + /// Provider that served this response. An empty value marks a legacy cache + /// entry, which an active policy rejects. + #[serde(default)] + provider: String, model: String, prompt_tokens: u32, completion_tokens: u32, cached_at: i64, - /// The assistant turn's tool calls, if any. - /// - /// Omitting these made caching silently break agents: a tool-call turn has - /// empty `content` and N tool calls, so it was stored as an empty string - /// and replayed as a *final* answer. `run_tool_loop` sees no tool calls, - /// stops, and returns "" — no tool ever runs, no error is raised. Since - /// entries persist to disk, that poisoned every later run within the TTL, - /// which is exactly the "re-run the script cheaply" workflow `with-cache` - /// exists for. Defaulted so entries written before this field still load. + /// Assistant tool calls retained for agent-loop cache replay. #[serde(default)] tool_calls: Vec, } @@ -875,6 +1676,9 @@ pub fn reset_runtime_state() { RATE_LIMIT_RPS.with(|r| r.set(None)); RATE_LIMIT_LAST.with(|r| *r.borrow_mut() = None); install_cassette_scope(None); + ACTIVE_POLICIES.with(|policies| policies.borrow_mut().clear()); + POLICY_BYPASS.with(|bypass| bypass.borrow_mut().clear()); + POLICY_AGENT_ID.with(|agent| *agent.borrow_mut() = None); LAST_SERVING_PROVIDER.with(|p| *p.borrow_mut() = None); RETRY_BASE_MS.with(|c| c.set(500)); NETWORK_MAX_RETRIES.with(|c| c.set(3)); @@ -4138,21 +4942,19 @@ pub fn register_llm_builtins(env: &Env, sandbox: &sema_core::Sandbox) { .collect::>(); let responses = with_provider(|provider| { - let requests = requests - .into_iter() - .map(|mut request| { - if request.model.is_empty() { - request.model = provider.default_model().to_string(); - } - request - }) - .collect(); + let requests = resolve_batch_models(provider, requests)?; Ok(provider.batch_complete(requests)) })?; responses .into_iter() .map(|response| { - let response = response.map_err(|error| SemaError::Llm(error.to_string()))?; + let mut response = response.map_err(|error| SemaError::Llm(error.to_string()))?; + if let Err(error) = + apply_output_policy_to_response(&mut response, PolicySource::Request) + { + track_usage(&response.usage)?; + return Err(error); + } track_usage(&response.usage)?; Ok(Value::string(&response.content)) }) @@ -4236,12 +5038,13 @@ pub fn register_llm_builtins(env: &Env, sandbox: &sema_core::Sandbox) { .to_string(), )); }; + let resolved_requests = resolve_batch_models(&*provider, requests.iter().cloned())?; if provider.runs_on_vm_thread() { return Box::new(SemaBatchDriver { provider: provider.name().to_string(), default_model: provider.default_model().to_string(), - requests, + requests: resolved_requests, next_request: 0, active_model: None, responses: Vec::new(), @@ -4251,17 +5054,6 @@ pub fn register_llm_builtins(env: &Env, sandbox: &sema_core::Sandbox) { .advance(); } - let reqs: Vec = requests - .iter() - .cloned() - .map(|mut r| { - if r.model.is_empty() { - r.model = provider.default_model().to_string(); - } - r - }) - .collect(); - // Capture the dispatch-time budget + leaf-usage frames (ASYNC-1), so the // decoder charges the frames active now, not whatever scope is installed // when the future lands. Mirrors the completion and embedding paths. @@ -4290,7 +5082,7 @@ pub fn register_llm_builtins(env: &Env, sandbox: &sema_core::Sandbox) { kind, decoder, resource, - move || Ok(Box::new(p_job.batch_complete(reqs)) as SendPayload), + move || Ok(Box::new(p_job.batch_complete(resolved_requests)) as SendPayload), ); return Ok(NativeOutcome::Suspend(NativeSuspend { wait: WaitKind::External(Box::new(prepared)), @@ -4299,23 +5091,18 @@ pub fn register_llm_builtins(env: &Env, sandbox: &sema_core::Sandbox) { } } - // ── SYNC path: inline provider call (byte-identical to before) ───── let responses = with_provider(|p| { - let reqs: Vec = requests - .into_iter() - .map(|mut r| { - if r.model.is_empty() { - r.model = p.default_model().to_string(); - } - r - }) - .collect(); + let reqs = resolve_batch_models(p, requests)?; Ok(p.batch_complete(reqs)) })?; let mut results = Vec::with_capacity(responses.len()); for resp_result in responses { - let resp = resp_result.map_err(|e| SemaError::Llm(e.to_string()))?; + let mut resp = resp_result.map_err(|e| SemaError::Llm(e.to_string()))?; + if let Err(error) = apply_output_policy_to_response(&mut resp, PolicySource::Request) { + track_usage(&resp.usage)?; + return Err(error); + } track_usage(&resp.usage)?; results.push(Value::string(&resp.content)); } @@ -4474,7 +5261,8 @@ pub fn register_llm_builtins(env: &Env, sandbox: &sema_core::Sandbox) { None }; - let request = EmbedRequest { texts, model }; + let mut request = EmbedRequest { texts, model }; + apply_input_policy_to_texts(&mut request.texts, "embedding.input")?; let req_model = request.model.clone().unwrap_or_default(); let cassette_key = compute_embed_key(&request); @@ -4497,6 +5285,11 @@ pub fn register_llm_builtins(env: &Env, sandbox: &sema_core::Sandbox) { .map(|scope| scope.borrow().decide(&cassette_key)); match decision { Some(crate::cassette::Decision::Replay(entry)) => { + enforce_stored_model_policy( + &entry.provider, + &entry.model, + PolicySource::Cassette, + )?; // Replay made no provider call: finalize the span inline, // account, and return without suspending. let resp = EmbedResponse { @@ -4526,8 +5319,6 @@ pub fn register_llm_builtins(env: &Env, sandbox: &sema_core::Sandbox) { } let recording = matches!(decision, Some(crate::cassette::Decision::Record)); - // Clone an Arc off the thread-local registry on THIS thread, - // release the borrow, and move it into the offloaded future. let provider = PROVIDER_REGISTRY.with(|reg| { let reg = reg.borrow(); reg.embedding_provider().or_else(|| reg.default_provider()) @@ -4538,6 +5329,11 @@ pub fn register_llm_builtins(env: &Env, sandbox: &sema_core::Sandbox) { .to_string(), )); }; + let model = request + .model + .as_deref() + .unwrap_or_else(|| provider.default_model()); + model_target_allowed(provider.name(), model, PolicySource::Request, false)?; // The provider name + canonical price are needed on the VM thread in // the decoder; capture them before the Arc is moved into the worker. @@ -4608,15 +5404,13 @@ pub fn register_llm_builtins(env: &Env, sandbox: &sema_core::Sandbox) { } } - // ── SYNC path: inline provider call (byte-identical to before) ───── - // CLIENT embeddings span (bypasses do_complete). Input tokens only. + // Synchronous embedding calls bypass do_complete. let span = sema_otel::llm_span("embeddings"); - // Advertise the input texts (content-gated; OpenInference embedding.* keys). span.set_embedding_input(&request.texts); - // Cassette interception (mirrors run_completion, for the embeddings seam). let decision = cassette_decide(&cassette_key); let response = match decision { Some(crate::cassette::Decision::Replay(entry)) => { + enforce_stored_model_policy(&entry.provider, &entry.model, PolicySource::Cassette)?; let resp = EmbedResponse { embeddings: entry.embeddings, model: entry.model.clone(), @@ -4638,7 +5432,12 @@ pub fn register_llm_builtins(env: &Env, sandbox: &sema_core::Sandbox) { Some(crate::cassette::Decision::Miss(k)) => return Err(cassette_miss_error(&k)), other => { let recording = matches!(other, Some(crate::cassette::Decision::Record)); - let resp = with_embedding_provider(|p| { + let (resp, provider_name) = with_embedding_provider(|p| { + let model = request + .model + .as_deref() + .unwrap_or_else(|| p.default_model()); + model_target_allowed(p.name(), model, PolicySource::Request, false)?; let resp = match p.embed(request) { Ok(r) => r, Err(e) => { @@ -4654,11 +5453,12 @@ pub fn register_llm_builtins(env: &Env, sandbox: &sema_core::Sandbox) { cost_usd: pricing::calculate_cost_for(p.name(), &resp.usage), ..Default::default() }); - Ok(resp) + Ok((resp, p.name().to_string())) })?; if recording { cassette_record(crate::cassette::TapeEntry::from_embed( &cassette_key, + &provider_name, &resp.model, &resp.embeddings, resp.usage.prompt_tokens, @@ -4683,33 +5483,85 @@ pub fn register_llm_builtins(env: &Env, sandbox: &sema_core::Sandbox) { if args.len() < 2 || args.len() > 3 { return Err(SemaError::arity("llm/rerank", "2-3", args.len())); } - let query = args[0] + let mut query = args[0] .as_str() - .ok_or_else(|| SemaError::type_error("string query", args[0].type_name()))? + .ok_or_else(|| SemaError::argument_type("llm/rerank", 1, "string query", &args[0]))? .to_string(); - let documents: Vec = args[1] + let mut documents: Vec = args[1] .as_seq() - .ok_or_else(|| SemaError::type_error("list of strings", args[1].type_name()))? + .ok_or_else(|| { + SemaError::argument_type("llm/rerank", 2, "list or vector of strings", &args[1]) + })? .iter() - .map(|d| { - d.as_str() - .map(|s| s.to_string()) - .ok_or_else(|| SemaError::type_error("string document", d.type_name())) + .enumerate() + .map(|(index, document)| { + document.as_str().map(str::to_string).ok_or_else(|| { + SemaError::eval(format!( + "llm/rerank argument 2 entry {} must be a string, got {}", + index + 1, + document.type_name() + )) + }) }) .collect::>()?; if documents.is_empty() { return Ok(NativeOutcome::Return(Value::list(vec![]))); } + query = apply_text_policy( + &query, + PolicyBoundary::LlmInput, + "rerank.query", + PolicySource::Request, + None, + )?; + apply_input_policy_to_texts(&mut documents, "rerank.document")?; let mut top_k = None; let mut model = None; let mut provider = None; - if let Some(opts) = args.get(2).and_then(|v| v.as_map_rc()) { - top_k = get_opt_u32(&opts, "top-k").map(|n| n as usize); - model = get_opt_string(&opts, "model"); + if let Some(options) = args.get(2) { + let opts = options + .as_map_rc() + .ok_or_else(|| SemaError::argument_type("llm/rerank", 3, "map", options))?; + top_k = opts + .get(&Value::keyword("top-k")) + .map(|value| { + value + .as_int() + .and_then(|value| usize::try_from(value).ok()) + .filter(|value| *value > 0) + .ok_or_else(|| { + SemaError::eval(format!( + "llm/rerank option :top-k must be a positive integer, got {value}" + )) + }) + }) + .transpose()?; + model = opts + .get(&Value::keyword("model")) + .map(|value| { + value.as_str().map(str::to_string).ok_or_else(|| { + SemaError::eval(format!( + "llm/rerank option :model must be a string, got {}", + value.type_name() + )) + }) + }) + .transpose()?; provider = opts .get(&Value::keyword("provider")) - .and_then(|p| p.as_keyword().or_else(|| p.as_str().map(|s| s.to_string()))); + .map(|value| { + value + .as_keyword() + .or_else(|| value.as_str().map(str::to_string)) + .ok_or_else(|| { + SemaError::eval(format!( + "llm/rerank option :provider must be a keyword or string, got {}", + value.type_name() + )) + }) + }) + .transpose()?; } let request = RerankRequest { @@ -4752,6 +5604,16 @@ pub fn register_llm_builtins(env: &Env, sandbox: &sema_core::Sandbox) { }), } })?; + let resolved_model = request + .model + .as_deref() + .unwrap_or_else(|| resolved_provider.default_model()); + model_target_allowed( + resolved_provider.name(), + resolved_model, + PolicySource::Request, + false, + )?; // Root-main and spawned tasks suspend on an External wait; the decoder // builds the reordered output on the VM thread when it lands. @@ -4806,12 +5668,16 @@ pub fn register_llm_builtins(env: &Env, sandbox: &sema_core::Sandbox) { } } - // ── SYNC path: inline provider call (byte-identical to before) ───── // OpenInference RERANKER span (no-op unless telemetry + compat are on). let span = sema_otel::reranker_span(&query, model.as_deref().unwrap_or(""), top_k); span.set_input(&documents); let resp = with_rerank_provider(provider.as_deref(), |p| { + let resolved_model = request + .model + .as_deref() + .unwrap_or_else(|| p.default_model()); + model_target_allowed(p.name(), resolved_model, PolicySource::Request, false)?; p.rerank(request).map_err(|e| { span.record_error(llm_error_kind(&e), &e.to_string()); SemaError::Llm(e.to_string()) @@ -5124,6 +5990,21 @@ pub fn register_llm_builtins(env: &Env, sandbox: &sema_core::Sandbox) { Ok(t.parameters.clone()) }); + register_fn(env, "tool/policy-subjects", |args| { + if args.len() != 1 { + return Err(SemaError::arity("tool/policy-subjects", "1", args.len())); + } + let tool = args[0] + .as_tool_def_rc() + .ok_or_else(|| SemaError::type_error("tool", args[0].type_name()))?; + Ok(Value::vector( + tool.policy_subjects + .iter() + .map(tool_policy_subject_to_value) + .collect(), + )) + }); + // (agent {:system "…" :tools […] :model "…" :max-turns N}) — build an anonymous, // reusable actor value (system prompt + tools + model + max-turns) without binding // it. The named form is `defagent`; this is the plain constructor used inline (e.g. @@ -6728,6 +7609,7 @@ pub fn register_llm_builtins(env: &Env, sandbox: &sema_core::Sandbox) { // JSON-coerce the arguments (lossily) so a direct invocation hands the // handler exactly what an agent-driven tool call would. let json_args = sema_core::value_to_json_lossy(&args[1]); + enforce_direct_tool_policy(&tool_def.name, &json_args, &tool_def.policy_subjects)?; let handler_args = json_args_to_sema(&tool_def.parameters, &json_args, &tool_def.handler); Box::new(ToolInvokeContinuation { tool_name: tool_def.name.clone(), @@ -7083,6 +7965,11 @@ fn compute_cache_key(request: &ChatRequest) -> String { hasher.update(schema.as_bytes()); } } + let policy_fingerprint = effective_policy_fingerprint(); + if !policy_fingerprint.is_empty() { + hasher.update(b"\x00policy\x00"); + hasher.update(policy_fingerprint.as_bytes()); + } format!("{:x}", hasher.finalize()) } @@ -7132,9 +8019,10 @@ fn read_cached_from_disk(path: &std::path::Path) -> Option { serde_json::from_str(&data).ok() } -fn store_cached(key: &str, response: &ChatResponse) { +fn store_cached(key: &str, response: &ChatResponse, provider: &str) { let cached = CachedResponse { content: response.content.clone(), + provider: provider.to_string(), model: response.model.clone(), prompt_tokens: response.usage.prompt_tokens, completion_tokens: response.usage.completion_tokens, @@ -7382,7 +8270,8 @@ fn apply_call_telemetry_agent(span: &sema_otel::AgentSpan) { }); } -fn do_complete(request: ChatRequest) -> Result { +fn do_complete(mut request: ChatRequest) -> Result { + apply_input_policy_to_request(&mut request)?; // Standalone completions get their own conversation id so every chat span carries // gen_ai.conversation.id; agent-nested completions inherit the agent's scope. let _conv = if sema_otel::current_conversation_id().is_none() { @@ -7428,11 +8317,7 @@ fn do_complete(request: ChatRequest) -> Result { if !cache_enabled { return run_completion(request, &span); } - // Compute the cache key from the model the request will *logically* use, but - // without mutating the request that flows into the fallback loop. Pre-filling - // `request.model` here would make it non-empty and defeat the per-provider - // default/override substitution in `do_complete_with_provider` — sending the - // wrong provider's model id down the chain (the original cache+fallback bug). + // Keep request.model unchanged so fallback entries can apply their own model. let key_model = if request.model.is_empty() { primary_model_for_cache()? } else { @@ -7443,11 +8328,13 @@ fn do_complete(request: ChatRequest) -> Result { let cache_key = compute_cache_key(&key_request); if let Some(cached) = load_cached(&cache_key) { if is_cache_valid(&cached) { + enforce_stored_model_policy(&cached.provider, &cached.model, PolicySource::Cache)?; CACHE_HITS.with(|c| c.set(c.get() + 1)); // A cache hit makes no provider call: no tokens are consumed and no money // is spent. Report ZERO usage so the caller's `track_usage` does not // re-charge session cost or burn the budget for a cached response. - let resp = cache_hit_response(cached, key_request.model.clone()); + let mut resp = cache_hit_response(cached, key_request.model.clone()); + apply_output_policy_to_response(&mut resp, PolicySource::Cache)?; // Cache-hit span: no provider served it; tag gen_ai.cache.hit=true with // zero usage (matches the zero-usage accounting invariant). span.set_dispatch("", &resp.model); @@ -7457,7 +8344,8 @@ fn do_complete(request: ChatRequest) -> Result { } CACHE_MISSES.with(|c| c.set(c.get() + 1)); let response = run_completion(request, &span)?; - store_cached(&cache_key, &response); + let serving_provider = LAST_SERVING_PROVIDER.with(|p| p.borrow().clone().unwrap_or_default()); + store_cached(&cache_key, &response, &serving_provider); Ok(response) } @@ -7465,8 +8353,8 @@ fn do_complete(request: ChatRequest) -> Result { /// option. Opens the same per-completion `chat` span/scope, but drives /// `stream_with_dispatch` and delivers each text delta to the Sema `on_text` /// callback. Returns the assembled [`ChatResponse`] so the loop's tool-call -/// handling and `track_usage` accounting are byte-identical to the non-streaming -/// path. Streaming bypasses the completion cache (like `llm/stream`). +/// handling and `track_usage` accounting match the non-streaming path. Streaming +/// bypasses the completion cache (like `llm/stream`). fn do_complete_streaming( ctx: &EvalContext, request: ChatRequest, @@ -7582,6 +8470,8 @@ enum CompletePrep { #[cfg(not(target_arch = "wasm32"))] struct CompleteOffloadPlan { chain: Vec, + /// Model `:skip` applies to every explicit fallback chain, including one entry. + explicit_fallback: bool, request: ChatRequest, max_retries: u32, retry_base_ms: u64, @@ -7600,7 +8490,8 @@ struct CompleteOffloadPlan { /// runtime completion paths use this stage to keep cache, cassette, and retry /// behavior aligned. #[cfg(not(target_arch = "wasm32"))] -fn complete_offload_prep(request: ChatRequest) -> Result { +fn complete_offload_prep(mut request: ChatRequest) -> Result { + apply_input_policy_to_request(&mut request)?; // Standalone completions get their own conversation scope (so the chat span // carries gen_ai.conversation.id); agent-nested ones inherit. The detached span // captures the conversation id at creation, so the guard need only live across @@ -7639,7 +8530,6 @@ fn complete_offload_prep(request: ChatRequest) -> Result Result Result { - let resp = entry.to_response(); - span.set_dispatch("cassette", &resp.model); - span.set_response(&response_facts("cassette", &resp)); + enforce_stored_model_policy(&entry.provider, &entry.model, PolicySource::Cassette)?; + let mut resp = entry.to_response(); + apply_output_policy_to_response(&mut resp, PolicySource::Cassette)?; + set_guarded_response_telemetry(&span, &request, "cassette", &resp); drop(span); return Ok(CompletePrep::Inline(resp)); } @@ -7704,9 +8596,10 @@ fn complete_offload_prep(request: ChatRequest) -> Result = PROVIDER_REGISTRY.with(|reg| { let reg = reg.borrow(); - let fallback = FALLBACK_CHAIN.with(|c| c.borrow().clone()); match fallback { Some(entries) if !entries.is_empty() => entries .iter() @@ -7743,6 +8636,7 @@ fn complete_offload_prep(request: ChatRequest) -> Result sema_core::runtime::NativeResult { let CompleteOutcome { - resp, + mut resp, serving_provider, serving_model, retry_events, @@ -7787,6 +8681,17 @@ fn finalize_complete_success( emit_retry_spans(&retry_events); }); span.set_dispatch(&serving_provider, &serving_model); + if let Err(error) = apply_output_policy_to_response(&mut resp, PolicySource::Request) { + span.record_error("policy", &error.to_string()); + drop(span); + account_complete_usage( + &serving_provider, + &resp.usage, + usage_accum_slot.as_ref(), + budget_slot, + )?; + return Err(error); + } span.set_response(&response_facts(&serving_provider, &resp)); span.set_messages( &messages_json(&request_for_messages.messages), @@ -7800,41 +8705,48 @@ fn finalize_complete_success( drop(span); // ends the span set_serving_provider(&serving_provider); if let Some(key) = &cache_key { - store_cached(key, &resp); + store_cached(key, &resp, &serving_provider); } if let Some(key) = &cassette_record_key { cassette_scope_record( &cassette_scope, - crate::cassette::TapeEntry::from_response(key, &resp), + crate::cassette::TapeEntry::from_response(key, &serving_provider, &resp), ); } + account_complete_usage( + &serving_provider, + &resp.usage, + usage_accum_slot.as_ref(), + budget_slot, + )?; + finalize.finish(resp) +} + +#[cfg(not(target_arch = "wasm32"))] +fn account_complete_usage( + serving_provider: &str, + usage: &Usage, + usage_accum_slot: Option<&Rc>>, + budget_slot: Option>>, +) -> Result<(), SemaError> { // Fold this completion into the LEAF'S OWN captured accumulator frame — the // `Rc` snapshotted at dispatch, not whatever scope is active when the offload - // lands (the finalize runs outside the per-task install boundary). Price it the - // same way `track_usage` does, then suppress `track_usage`'s own active-frame - // fold so this completion is counted exactly once. - if let Some(slot) = &usage_accum_slot { - let cost = pricing::calculate_cost_for(&serving_provider, &resp.usage); - accumulate_into(slot, &resp.usage, cost); - } - // Account on the VM thread, then shape the value. Install THIS completion's - // captured budget frame as active around `track_usage` so the charge + limit - // check land on the dispatch-time frame (shared by `Rc` across the fan-out), - // then restore whatever was active. - let track_result = { - let prev_budget = - ACTIVE_BUDGET.with(|b| std::mem::replace(&mut *b.borrow_mut(), budget_slot.clone())); - let r = USAGE_ACCUM_SUPPRESS.with(|s| { - s.set(true); - let r = track_usage(&resp.usage); - s.set(false); - r - }); - ACTIVE_BUDGET.with(|b| *b.borrow_mut() = prev_budget); - r - }; - track_result?; - finalize.finish(resp) + // lands. Suppress `track_usage`'s ambient accumulator fold so the completion + // is counted exactly once. + if let Some(slot) = usage_accum_slot { + let cost = pricing::calculate_cost_for(serving_provider, usage); + accumulate_into(slot, usage, cost); + } + let previous_budget = + ACTIVE_BUDGET.with(|active| std::mem::replace(&mut *active.borrow_mut(), budget_slot)); + let result = USAGE_ACCUM_SUPPRESS.with(|suppress| { + suppress.set(true); + let result = track_usage(usage); + suppress.set(false); + result + }); + ACTIVE_BUDGET.with(|active| *active.borrow_mut() = previous_budget); + result } /// Completion-kind tag for an agent/chat provider round offloaded through the @@ -8019,6 +8931,14 @@ impl RuntimeCompleteDriver { } else if request.model.is_empty() { request.model = provider.default_model().to_string(); } + if !model_target_allowed( + &provider_name, + &request.model, + PolicySource::Request, + self.plan.explicit_fallback, + )? { + continue; + } if provider.runs_on_vm_thread() { let callback = match lisp_provider_complete_callback(&provider_name) { @@ -8085,15 +9005,16 @@ impl RuntimeCompleteDriver { ) -> sema_core::runtime::NativeResult { if let Some(cached) = disk { if is_cache_valid(&cached) { + enforce_stored_model_policy(&cached.provider, &cached.model, PolicySource::Cache)?; CACHE_HITS.with(|c| c.set(c.get() + 1)); let Self { plan, finalize, .. } = *self; if let Some(key) = &plan.cache_key { CACHE_MEM.with(|c| c.borrow_mut().insert(key.clone(), cached.clone())); } let usage_model = cached.model.clone(); - let resp = cache_hit_response(cached, usage_model); - plan.span.set_dispatch("", &resp.model); - plan.span.set_response(&response_facts("", &resp)); + let mut resp = cache_hit_response(cached, usage_model); + apply_output_policy_to_response(&mut resp, PolicySource::Cache)?; + set_guarded_response_telemetry(&plan.span, &plan.request_for_messages, "", &resp); drop(plan.span); track_usage(&resp.usage)?; return finalize.finish(resp); @@ -8677,6 +9598,7 @@ impl sema_core::runtime::CompletionDecoder for EmbedDecoder { &self.cassette_scope, crate::cassette::TapeEntry::from_embed( &self.key, + &self.provider_name, &resp.model, &resp.embeddings, resp.usage.prompt_tokens, @@ -8889,7 +9811,8 @@ fn finalize_batch_responses( ) -> Result { let mut results = Vec::with_capacity(responses.len()); for resp_result in responses { - let resp = resp_result.map_err(|error| SemaError::Llm(error.to_string()))?; + let mut resp = resp_result.map_err(|error| SemaError::Llm(error.to_string()))?; + let policy_result = apply_output_policy_to_response(&mut resp, PolicySource::Request); // Priced with an empty provider, matching the sync path (which never // stamps a serving provider for `llm/batch`). if let Some(slot) = &usage_accum_slot { @@ -8906,6 +9829,7 @@ fn finalize_batch_responses( }); ACTIVE_BUDGET.with(|active| *active.borrow_mut() = prev_budget); track_result?; + policy_result?; results.push(Value::string(&resp.content)); } Ok(Value::list(results)) @@ -8997,7 +9921,13 @@ fn run_completion( span: &sema_otel::LlmSpan, ) -> Result { if current_cassette_scope().is_none() { - return do_complete_inner(request, span); + let mut response = do_complete_inner(request.clone(), span)?; + if let Err(error) = apply_output_policy_to_response(&mut response, PolicySource::Request) { + track_usage(&response.usage)?; + return Err(error); + } + set_guarded_response_telemetry(span, &request, "", &response); + return Ok(response); } // Key by the request as-is (no default-model resolution) so record and replay // produce the same key for an identical call, even with no provider configured @@ -9006,23 +9936,56 @@ fn run_completion( let decision = cassette_decide(&key).expect("cassette scope checked above"); match decision { crate::cassette::Decision::Replay(entry) => { + enforce_stored_model_policy(&entry.provider, &entry.model, PolicySource::Cassette)?; // A replayed call is a stand-in for a real one: emit the span with the // recorded facts and let the caller's usage/cost accounting run on the // recorded tokens (distinct from a cache hit, which reports zero usage). - let resp = entry.to_response(); - span.set_dispatch("cassette", &resp.model); - span.set_response(&response_facts("cassette", &resp)); - Ok(resp) + let mut response = entry.to_response(); + apply_output_policy_to_response(&mut response, PolicySource::Cassette)?; + set_guarded_response_telemetry(span, &request, "cassette", &response); + Ok(response) } crate::cassette::Decision::Miss(k) => Err(cassette_miss_error(&k)), crate::cassette::Decision::Record => { - let resp = do_complete_inner(request, span)?; - cassette_record(crate::cassette::TapeEntry::from_response(&key, &resp)); + let mut resp = do_complete_inner(request.clone(), span)?; + if let Err(error) = apply_output_policy_to_response(&mut resp, PolicySource::Request) { + track_usage(&resp.usage)?; + return Err(error); + } + set_guarded_response_telemetry(span, &request, "", &resp); + let provider = LAST_SERVING_PROVIDER.with(|p| p.borrow().clone().unwrap_or_default()); + cassette_record(crate::cassette::TapeEntry::from_response( + &key, &provider, &resp, + )); Ok(resp) } } } +fn set_guarded_response_telemetry( + span: &sema_otel::LlmSpan, + request: &ChatRequest, + provider_override: &str, + response: &ChatResponse, +) { + let provider = if provider_override.is_empty() { + LAST_SERVING_PROVIDER.with(|provider| provider.borrow().clone().unwrap_or_default()) + } else { + provider_override.to_string() + }; + span.set_dispatch(&provider, &response.model); + span.set_response(&response_facts(&provider, response)); + span.set_messages( + &messages_json(&request.messages), + &content_json("assistant", &response.content), + request + .system + .as_deref() + .map(|system| content_json("system", system)) + .as_deref(), + ); +} + /// The hard error raised on a `:replay`-mode cassette miss (no recorded interaction /// for this request). Shared by the complete, stream, and embed seams. fn cassette_miss_error(key: &str) -> SemaError { @@ -9032,13 +9995,53 @@ fn cassette_miss_error(key: &str) -> SemaError { )) } -/// Streaming counterpart to `run_completion`: replays the recorded chunk sequence -/// (feeding the caller's `on_chunk` so boundaries match) and final response, or -/// records a fresh stream by capturing chunks as they arrive. Transparent -/// passthrough with no active cassette. Sits below the otel span, above the provider. -fn stream_with_cassette( +enum StreamCassettePlan { + Replay(ChatResponse), + Live { record_key: Option }, +} + +fn prepare_stream_cassette( + request: &ChatRequest, + chunk_cb: &mut dyn FnMut(&str) -> Result<(), crate::types::LlmError>, + span: &sema_otel::LlmSpan, +) -> Result { + if current_cassette_scope().is_none() { + return Ok(StreamCassettePlan::Live { record_key: None }); + } + + let key = compute_cache_key(request); + match cassette_decide(&key).expect("cassette scope checked above") { + crate::cassette::Decision::Replay(entry) => { + enforce_stored_model_policy(&entry.provider, &entry.model, PolicySource::Cassette)?; + let provider = entry.provider.clone(); + let mut response = entry.to_response(); + if output_policy_active() { + apply_output_policy_to_response(&mut response, PolicySource::Cassette)?; + if !response.content.is_empty() { + chunk_cb(&response.content) + .map_err(|error| SemaError::Llm(error.to_string()))?; + } + } else { + for chunk in &entry.chunks { + chunk_cb(chunk).map_err(|error| SemaError::Llm(error.to_string()))?; + } + } + span.set_dispatch("cassette", &response.model); + span.set_response(&response_facts("cassette", &response)); + set_serving_provider(&provider); + Ok(StreamCassettePlan::Replay(response)) + } + crate::cassette::Decision::Miss(key) => Err(cassette_miss_error(&key)), + crate::cassette::Decision::Record => Ok(StreamCassettePlan::Live { + record_key: Some(key), + }), + } +} + +fn stream_live( p: &dyn LlmProvider, request: ChatRequest, + record_key: Option<&str>, chunk_cb: &mut dyn FnMut(&str) -> Result<(), crate::types::LlmError>, span: &sema_otel::LlmSpan, ) -> Result { @@ -9061,41 +10064,52 @@ fn stream_with_cassette( }) }; - if current_cassette_scope().is_none() { - let resp = stream_real(request.clone(), chunk_cb)?; - span.set_dispatch(p.name(), &request.model); - span.set_response(&response_facts(p.name(), &resp)); - return Ok(resp); - } - - let key = compute_cache_key(&request); - let decision = cassette_decide(&key).expect("cassette scope checked above"); - match decision { - crate::cassette::Decision::Replay(entry) => { - for ch in &entry.chunks { - chunk_cb(ch).map_err(|e| SemaError::Llm(e.to_string()))?; - } - let resp = entry.to_response(); - span.set_dispatch("cassette", &resp.model); - span.set_response(&response_facts("cassette", &resp)); - Ok(resp) + let defer_output = output_policy_active(); + let mut response = if defer_output { + let mut discarded = |_chunk: &str| -> Result<(), crate::types::LlmError> { Ok(()) }; + stream_real(request.clone(), &mut discarded)? + } else if let Some(key) = record_key { + let mut chunks = Vec::new(); + let mut collect = |chunk: &str| -> Result<(), crate::types::LlmError> { + chunks.push(chunk.to_string()); + chunk_cb(chunk) + }; + let response = stream_real(request.clone(), &mut collect)?; + cassette_record(crate::cassette::TapeEntry::from_stream( + key, + p.name(), + &chunks, + &response, + )); + response + } else { + stream_real(request.clone(), chunk_cb)? + }; + if defer_output { + if let Err(error) = apply_output_policy_to_response(&mut response, PolicySource::Request) { + track_usage(&response.usage)?; + return Err(error); } - crate::cassette::Decision::Miss(k) => Err(cassette_miss_error(&k)), - crate::cassette::Decision::Record => { - let mut collected: Vec = Vec::new(); - let mut wrap = |chunk: &str| -> Result<(), crate::types::LlmError> { - collected.push(chunk.to_string()); - chunk_cb(chunk) + if let Some(key) = record_key { + let chunks = if response.content.is_empty() { + Vec::new() + } else { + vec![response.content.clone()] }; - let resp = stream_real(request.clone(), &mut wrap)?; cassette_record(crate::cassette::TapeEntry::from_stream( - &key, &collected, &resp, + key, + p.name(), + &chunks, + &response, )); - span.set_dispatch(p.name(), &request.model); - span.set_response(&response_facts(p.name(), &resp)); - Ok(resp) + } + if !response.content.is_empty() { + chunk_cb(&response.content).map_err(|error| SemaError::Llm(error.to_string()))?; } } + span.set_dispatch(p.name(), &request.model); + span.set_response(&response_facts(p.name(), &response)); + Ok(response) } /// Cassette key for an embeddings request (model + the input texts). @@ -9109,12 +10123,16 @@ fn compute_embed_key(request: &EmbedRequest) -> String { hasher.update(t.as_bytes()); hasher.update(b"\0"); } + let policy_fingerprint = effective_policy_fingerprint(); + if !policy_fingerprint.is_empty() { + hasher.update(b"\0policy\0"); + hasher.update(policy_fingerprint.as_bytes()); + } format!("{:x}", hasher.finalize()) } -/// Encode an `EmbedResponse`'s vectors into the SAME `Value` the synchronous -/// `llm/embed` returns (single → bytevector; multi → list of bytevectors), so the -/// concurrent (async) and sync paths are byte-identical: both decode through here. +/// Encode an `EmbedResponse` for both synchronous and async calls. A single +/// vector becomes a bytevector; multiple vectors become a list of bytevectors. fn embed_value_from_response(resp: &EmbedResponse, single: bool) -> Value { if single { let embedding = resp.embeddings.first().cloned().unwrap_or_default(); @@ -9133,10 +10151,8 @@ fn embed_value_from_response(resp: &EmbedResponse, single: bool) -> Value { } } -/// Encode a `RerankResponse`'s reordered results into the SAME `Value` the -/// synchronous `llm/rerank` returns (a list of `{:index :score :document}`, highest -/// relevance first), so the concurrent (async) and sync paths are byte-identical: -/// both decode through here. +/// Encode a `RerankResponse` for both synchronous and async calls. Results are +/// ordered by relevance and contain `:index`, `:score`, and `:document`. fn rerank_value_from_response(resp: &RerankResponse, documents: &[String]) -> Value { Value::list( resp.results @@ -9527,7 +10543,8 @@ fn do_complete_inner( let mut last_error = None; for entry in &chain { match do_complete_with_provider(entry, request.clone(), span) { - Ok(resp) => return Ok(resp), + Ok(Some(resp)) => return Ok(resp), + Ok(None) => continue, Err(e) => { eprintln!( "Provider '{}' failed: {}, trying next...", @@ -9824,7 +10841,7 @@ fn do_complete_with_provider( entry: &FallbackEntry, mut request: ChatRequest, span: &sema_otel::LlmSpan, -) -> Result { +) -> Result, SemaError> { PROVIDER_REGISTRY.with(|reg| { let reg = reg.borrow(); let provider = reg.get(&entry.provider).ok_or_else(|| { @@ -9839,6 +10856,9 @@ fn do_complete_with_provider( } else if request.model.is_empty() { request.model = provider.default_model().to_string(); } + if !model_target_allowed(&entry.provider, &request.model, PolicySource::Request, true)? { + return Ok(None); + } let max_retries = NETWORK_MAX_RETRIES.with(|c| c.get()); let resp = complete_with_retry(&*provider, &request, max_retries) .map_err(|e| SemaError::Llm(e.to_string()))?; @@ -9846,17 +10866,7 @@ fn do_complete_with_provider( // Provider + model + response are all in scope here, before track_usage // consumes the serving-provider stamp. span.set_dispatch(&entry.provider, &request.model); - span.set_response(&response_facts(&entry.provider, &resp)); - span.set_messages( - &messages_json(&request.messages), - &content_json("assistant", &resp.content), - request - .system - .as_deref() - .map(|s| content_json("system", s)) - .as_deref(), - ); - Ok(resp) + Ok(Some(resp)) }) } @@ -9871,8 +10881,7 @@ type StreamArgs = ( /// Parse `llm/stream`-shaped args — prompt/messages, then an optional callback /// (any procedure) and an optional opts map in either order — into the /// `ChatRequest` plus the raw callback/opts. Shared by the blocking native -/// (`__llm-stream-blocking`) and the non-blocking `__stream-begin`, so both -/// paths accept byte-identical calls. +/// (`__llm-stream-blocking`) and the non-blocking `__stream-begin`. fn parse_stream_args(args: &[Value]) -> Result { if args.is_empty() || args.len() > 3 { return Err(SemaError::arity("llm/stream", "1-3", args.len())); @@ -9961,7 +10970,7 @@ fn stream_one_provider( mut request: ChatRequest, chunk_cb: &mut dyn FnMut(&str) -> Result<(), crate::types::LlmError>, span: &sema_otel::LlmSpan, -) -> Result { +) -> Result, SemaError> { PROVIDER_REGISTRY.with(|reg| { let reg = reg.borrow(); let provider = reg.get(&entry.provider).ok_or_else(|| { @@ -9972,9 +10981,16 @@ fn stream_one_provider( } else if request.model.is_empty() { request.model = provider.default_model().to_string(); } - let resp = stream_with_cassette(&*provider, request, chunk_cb, span)?; + let record_key = match prepare_stream_cassette(&request, chunk_cb, span)? { + StreamCassettePlan::Replay(response) => return Ok(Some(response)), + StreamCassettePlan::Live { record_key } => record_key, + }; + if !model_target_allowed(&entry.provider, &request.model, PolicySource::Request, true)? { + return Ok(None); + } + let resp = stream_live(&*provider, request, record_key.as_deref(), chunk_cb, span)?; set_serving_provider(&entry.provider); - Ok(resp) + Ok(Some(resp)) }) } @@ -9984,10 +11000,11 @@ fn stream_one_provider( /// surfaces (failing over would re-emit the already-delivered partial — see the spike test /// `spike_mid_stream_failure_behaviour`). fn stream_with_dispatch( - request: ChatRequest, + mut request: ChatRequest, chunk_cb: &mut dyn FnMut(&str) -> Result<(), crate::types::LlmError>, span: &sema_otel::LlmSpan, ) -> Result { + apply_input_policy_to_request(&mut request)?; stream_budget_pregate()?; enforce_rate_limit(); @@ -10005,7 +11022,8 @@ fn stream_with_dispatch( stream_one_provider(entry, request.clone(), &mut wrapped, span) }; match result { - Ok(resp) => return Ok(resp), + Ok(Some(resp)) => return Ok(resp), + Ok(None) => continue, Err(e) if emitted => { // Mid-stream failure: surface; do NOT fail over (would duplicate). span.record_error("provider_error", &e.to_string()); @@ -10029,12 +11047,16 @@ fn stream_with_dispatch( if req.model.is_empty() { req.model = p.default_model().to_string(); } - stream_with_cassette(p, req, chunk_cb, span) + let record_key = match prepare_stream_cassette(&req, chunk_cb, span)? { + StreamCassettePlan::Replay(response) => return Ok(response), + StreamCassettePlan::Live { record_key } => record_key, + }; + model_target_allowed(p.name(), &req.model, PolicySource::Request, false)?; + stream_live(p, req, record_key.as_deref(), chunk_cb, span) }), } } -/// Original do_complete logic (provider dispatch + rate-limit retry). fn do_complete_uncached( mut request: ChatRequest, span: &sema_otel::LlmSpan, @@ -10045,21 +11067,12 @@ fn do_complete_uncached( if request.model.is_empty() { request.model = p.default_model().to_string(); } + model_target_allowed(p.name(), &request.model, PolicySource::Request, false)?; let resp = complete_with_retry(p, &request, max_retries) .map_err(|e| SemaError::Llm(e.to_string()))?; set_serving_provider(p.name()); // Capture provider/model/response before track_usage consumes the stamp. span.set_dispatch(p.name(), &request.model); - span.set_response(&response_facts(p.name(), &resp)); - span.set_messages( - &messages_json(&request.messages), - &content_json("assistant", &resp.content), - request - .system - .as_deref() - .map(|s| content_json("system", s)) - .as_deref(), - ); Ok(resp) }) } @@ -10170,6 +11183,40 @@ fn build_tool_schemas(tools: &[Value]) -> Result, SemaError> { Ok(schemas) } +fn tool_policy_subject_to_value(subject: &sema_core::ToolPolicySubject) -> Value { + let mut map = BTreeMap::new(); + match subject { + sema_core::ToolPolicySubject::File { access, path_arg } => { + let kind = match access { + sema_core::FileAccess::Read => "file-read", + sema_core::FileAccess::Write => "file-write", + sema_core::FileAccess::Delete => "file-delete", + }; + map.insert(Value::keyword("kind"), Value::keyword(kind)); + map.insert(Value::keyword("path-arg"), Value::keyword(path_arg)); + } + sema_core::ToolPolicySubject::NetworkRequest { method, url_arg } => { + map.insert(Value::keyword("kind"), Value::keyword("network-request")); + map.insert(Value::keyword("url-arg"), Value::keyword(url_arg)); + if let Some(method) = method { + map.insert(Value::keyword("method"), Value::string(method)); + } + } + sema_core::ToolPolicySubject::Command { command_arg } => { + map.insert(Value::keyword("kind"), Value::keyword("command")); + map.insert(Value::keyword("command-arg"), Value::keyword(command_arg)); + } + sema_core::ToolPolicySubject::ExternalAction { action, target_arg } => { + map.insert(Value::keyword("kind"), Value::keyword("external-action")); + map.insert(Value::keyword("action"), Value::keyword(action)); + if let Some(target_arg) = target_arg { + map.insert(Value::keyword("target-arg"), Value::keyword(target_arg)); + } + } + } + Value::map(map) +} + /// Convert a Sema schema map into a JSON Schema object for the LLM API. fn sema_value_to_json_schema(val: &Value) -> serde_json::Value { if let Some(map) = val.as_map_rc() { @@ -10714,7 +11761,7 @@ fn agent_begin(args: &[Value]) -> Result { /// mirroring `run_tool_loop`'s own setup (a caller-id-or-fresh conversation scope + /// a nameless agent span) rather than `agent_begin`'s (which threads a defagent's /// identity + `:session`/`:memory` resolution through). The options parsing below -/// is intentionally byte-identical to `__llm-chat-blocking`'s. +/// matches `__llm-chat-blocking`. /// /// Returns nil when no tool loop is needed — the same `tools.is_empty() || /// tool_mode == "none"` condition `__llm-chat-blocking` checks — so the prelude @@ -10984,6 +12031,7 @@ fn agent_exec_tools(ctx: &EvalContext, token: u64) -> sema_core::runtime::Native let pending = std::mem::take(&mut st.pending_tool_calls); Ok::<_, SemaError>((pending, st.tools.clone(), st.on_tool_call.clone())) })?; + let denied = preflight_tool_calls(&pending, &tools)?; // Cooperative runtime path (Task 04/06): a tool handler may SUSPEND (e.g. // `mcp/call`'s runtime external wait, or an `async/await` inside the handler), @@ -10996,10 +12044,14 @@ fn agent_exec_tools(ctx: &EvalContext, token: u64) -> sema_core::runtime::Native // per-tool OTel span + `:on-tool-call` start/end events + correlated tool // results (with the same error-recovery) the synchronous `run_tool_loop` does. if sema_core::in_runtime_quantum() { - return exec_tools_cooperative_start(token, tools, on_tool_call, pending); + return exec_tools_cooperative_start(token, tools, on_tool_call, pending, denied); } for tc in &pending { + if let Some(error) = denied.get(&tc.id) { + record_tool_result(token, tc, error.clone(), true); + continue; + } let args_value = sema_core::json_to_value(&tc.arguments); if let Some(callback) = on_tool_call.as_ref() { @@ -11137,6 +12189,7 @@ struct ExecToolsContinuation { on_tool_call: Option, /// Tool calls not yet dispatched (front = next). remaining: std::collections::VecDeque, + denied: BTreeMap, /// The call currently in flight, plus which `Call` the next `resume` settles. active: Option, phase: ToolPhase, @@ -11327,6 +12380,10 @@ impl ExecToolsContinuation { let Some(tc) = self.remaining.pop_front() else { return Ok(NativeOutcome::Return(Value::nil())); }; + if let Some(error) = self.denied.remove(&tc.id) { + record_tool_result(self.token, &tc, error, true); + return self.advance(); + } let args_value = sema_core::json_to_value(&tc.arguments); let prepared = prepare_tool_call_cooperative(&self.tools, &tc.name, &tc.arguments); let (pending_handler, pending_error, validation_steps) = match prepared { @@ -11475,12 +12532,14 @@ fn exec_tools_cooperative_start( tools: Vec, on_tool_call: Option, pending: Vec, + denied: BTreeMap, ) -> sema_core::runtime::NativeResult { let continuation = Box::new(ExecToolsContinuation { token, tools, on_tool_call, remaining: pending.into(), + denied, active: None, phase: ToolPhase::Handler, }); @@ -11616,6 +12675,8 @@ struct StreamDone { #[cfg(not(target_arch = "wasm32"))] struct StreamDispatchState { chain: Vec, + /// Model `:skip` applies to every explicit fallback chain, including one entry. + explicit_fallback: bool, request: ChatRequest, next_provider: usize, last_error: Option<(LlmError, String)>, @@ -11652,6 +12713,9 @@ struct StreamRunState { cassette_scope: Option, /// Every delta drained so far (cassette recording preserves boundaries). collected: Vec, + /// Output policies require terminal buffering so an unsafe prefix can never + /// escape before the assembled response is checked. + defer_deltas: bool, first_token_seen: bool, /// The assembled response, set once `Done(Ok)` has been finalized. response: Option, @@ -11663,7 +12727,7 @@ struct StreamRunState { /// A failure that arrived in a batch that still carried deltas: stored so the /// driver delivers those deltas to the callback first, then raised (and the /// entry dropped) on the next `__stream-next`/`__stream-finish`. - pending_error: Option, + pending_error: Option, } impl Drop for StreamRunState { @@ -11777,11 +12841,13 @@ fn stream_wire_attempt( } /// Resolve the active fallback chain (or the default provider) into owned `Arc` -/// clones on the VM thread, so the offloaded wire walk touches no thread-locals. -fn resolve_stream_chain() -> Result, SemaError> { - PROVIDER_REGISTRY.with(|reg| { +/// clones on the VM thread. The boolean records whether the chain was explicit, +/// so the offloaded wire walk touches no thread-locals. +fn resolve_stream_chain() -> Result<(Vec, bool), SemaError> { + let fallback = FALLBACK_CHAIN.with(|c| c.borrow().clone()); + let explicit_fallback = fallback.as_ref().is_some_and(|entries| !entries.is_empty()); + let chain = PROVIDER_REGISTRY.with(|reg| { let reg = reg.borrow(); - let fallback = FALLBACK_CHAIN.with(|c| c.borrow().clone()); match fallback { Some(entries) if !entries.is_empty() => entries .iter() @@ -11813,22 +12879,19 @@ fn resolve_stream_chain() -> Result, SemaError> { }]) } } - }) + })?; + Ok((chain, explicit_fallback)) } -/// Start a non-blocking stream run: budget pre-gate and cassette decision happen -/// on the VM thread. Replay pre-fills the run; a real dispatch stores an owned -/// provider plan for `__stream-next` to drive one provider at a time. `span` is -/// the caller's detached chat span and is finalized when `Done` lands. -/// -/// The rate-limit gate sits AFTER the cassette decision (unlike the sync -/// `stream_with_dispatch`, which always calls `enforce_rate_limit` up front): -/// a replay makes no provider call, so it does not consume a pacing slot. -fn stream_run_begin(request: ChatRequest, span: sema_otel::LlmSpan) -> Result { +/// Start a non-blocking stream run. Cassette replay does not reserve a rate-limit slot. +fn stream_run_begin( + mut request: ChatRequest, + span: sema_otel::LlmSpan, +) -> Result { + apply_input_policy_to_request(&mut request)?; stream_budget_pregate()?; + let defer_deltas = output_policy_active(); - // Keyed by the request as-is (no default-model resolution), matching the - // synchronous `stream_with_cassette` so record/replay agree across paths. let cassette_scope = current_cassette_scope(); let cassette_decision = cassette_scope.as_ref().map(|scope| { let key = compute_cache_key(&request); @@ -11839,6 +12902,7 @@ fn stream_run_begin(request: ChatRequest, span: sema_otel::LlmSpan) -> Result { + enforce_stored_model_policy(&entry.provider, &entry.model, PolicySource::Cassette)?; for ch in &entry.chunks { buffered.push_back(StreamEvent::Delta(ch.clone())); } @@ -11857,8 +12921,10 @@ fn stream_run_begin(request: ChatRequest, span: sema_otel::LlmSpan) -> Result Result(); if rate_limit_wait_ms > 0 { sema_core::blocking_sleep_ms(rate_limit_wait_ms); @@ -11897,6 +12963,7 @@ fn stream_run_begin(request: ChatRequest, span: sema_otel::LlmSpan) -> Result continue, Action::Pace(wait_ms) => { self.phase = RuntimeStreamPhase::Pacing; return Ok(NativeOutcome::Suspend(NativeSuspend { @@ -12169,17 +13246,59 @@ fn stream_dispatch_ready(token: u64) -> Result { /// serving-provider stamp, cassette record, per-leaf usage fold, and /// budget-installed `track_usage` (exactly once per streamed completion). /// Returns the response, or the error message to surface. -fn stream_finalize( - done: StreamDone, +struct StreamFinalizeContext { span: Option, usage_accum_slot: Option>>, budget_slot: Option>>, cassette_record_key: Option, cassette_scope: Option, - collected: &[String], -) -> Result { + collected: Vec, + defer_deltas: bool, +} + +fn stream_finalize( + done: StreamDone, + context: StreamFinalizeContext, +) -> Result { + let StreamFinalizeContext { + span, + usage_accum_slot, + budget_slot, + cassette_record_key, + cassette_scope, + collected, + defer_deltas, + } = context; match done.result { - Ok(resp) => { + Ok(mut resp) => { + if defer_deltas { + let source = if done.provider == "cassette" { + PolicySource::Cassette + } else { + PolicySource::Request + }; + if let Err(error) = apply_output_policy_to_response(&mut resp, source) { + if let Some(span) = span { + span.set_dispatch(&done.provider, &resp.model); + span.record_error("policy", &error.to_string()); + } + if let Some(slot) = &usage_accum_slot { + let cost = pricing::calculate_cost_for(&done.provider, &resp.usage); + accumulate_into(slot, &resp.usage, cost); + } + let previous_budget = ACTIVE_BUDGET + .with(|active| std::mem::replace(&mut *active.borrow_mut(), budget_slot)); + let track_result = USAGE_ACCUM_SUPPRESS.with(|suppress| { + suppress.set(true); + let result = track_usage(&resp.usage); + suppress.set(false); + result + }); + ACTIVE_BUDGET.with(|active| *active.borrow_mut() = previous_budget); + track_result?; + return Err(error); + } + } if let Some(span) = span { span.set_dispatch(&done.provider, &resp.model); span.set_response(&response_facts(&done.provider, &resp)); @@ -12191,9 +13310,25 @@ fn stream_finalize( set_serving_provider(&done.provider); } if let Some(key) = &cassette_record_key { + let guarded_chunks; + let recorded_chunks = if defer_deltas { + guarded_chunks = if resp.content.is_empty() { + Vec::new() + } else { + vec![resp.content.clone()] + }; + guarded_chunks.as_slice() + } else { + &collected + }; cassette_scope_record( &cassette_scope, - crate::cassette::TapeEntry::from_stream(key, collected, &resp), + crate::cassette::TapeEntry::from_stream( + key, + &done.provider, + recorded_chunks, + &resp, + ), ); } // Fold into THIS run's captured accumulator frame, then suppress @@ -12215,16 +13350,13 @@ fn stream_finalize( ACTIVE_BUDGET.with(|b| *b.borrow_mut() = prev_budget); r }; - match track_result { - Ok(()) => Ok(resp), - Err(e) => Err(e.to_string()), - } + track_result.map(|()| resp) } Err(e) => { if let Some(span) = span { span.record_error(llm_error_kind(&e), &e.to_string()); } - Err(e.to_string()) + Err(SemaError::Llm(e.to_string())) } } } @@ -12292,7 +13424,9 @@ fn stream_poll_batch(token: u64, blocking: bool) -> Result, SemaEr span.mark_first_token(); } } - batch.push(Value::string(&s)); + if !st.defer_deltas { + batch.push(Value::string(&s)); + } st.collected.push(s); } Some(StreamEvent::Done(d)) => { @@ -12353,22 +13487,31 @@ fn stream_poll_batch(token: u64, blocking: bool) -> Result, SemaEr st.cassette_record_key.take(), st.cassette_scope.take(), std::mem::take(&mut st.collected), + st.defer_deltas, ) }) }); - let Some((span, usage_slot, budget_slot, record_key, cassette_scope, collected)) = ctx else { + let Some((span, usage_slot, budget_slot, record_key, cassette_scope, collected, defer_deltas)) = + ctx + else { return Err(SemaError::Llm("stream-run handle not found".to_string())); }; match stream_finalize( *done, - span, - usage_slot, - budget_slot, - record_key, - cassette_scope, - &collected, + StreamFinalizeContext { + span, + usage_accum_slot: usage_slot, + budget_slot, + cassette_record_key: record_key, + cassette_scope, + collected, + defer_deltas, + }, ) { Ok(resp) => { + if defer_deltas && !resp.content.is_empty() { + batch.push(Value::string(&resp.content)); + } STREAM_RUNS.with(|r| { if let Some(st) = r.borrow_mut().get_mut(&token) { st.response = Some(resp); @@ -12377,10 +13520,10 @@ fn stream_poll_batch(token: u64, blocking: bool) -> Result, SemaEr }); Ok(Some(stream_batch_map(batch, true))) } - Err(msg) => { + Err(error) => { STREAM_RUNS.with(|r| { if let Some(st) = r.borrow_mut().get_mut(&token) { - st.pending_error = Some(msg); + st.pending_error = Some(error); } }); Ok(Some(stream_batch_map(batch, false))) @@ -12509,7 +13652,7 @@ fn stream_next(token: u64) -> sema_core::runtime::NativeResult { use sema_core::runtime::NativeOutcome; enum Pre { - Err(String), + Err(SemaError), Done, Run { prefilled: bool }, } @@ -12518,11 +13661,11 @@ fn stream_next(token: u64) -> sema_core::runtime::NativeResult { let st = slab .get_mut(&token) .ok_or_else(|| SemaError::Llm("stream-run handle not found".to_string()))?; - if let Some(msg) = st.pending_error.take() { + if let Some(error) = st.pending_error.take() { // The deltas that preceded this failure were delivered last batch; // the run is over — drop the entry and surface. slab.remove(&token); - return Ok(Pre::Err(msg)); + return Ok(Pre::Err(error)); } if st.done { return Ok(Pre::Done); @@ -12535,7 +13678,7 @@ fn stream_next(token: u64) -> sema_core::runtime::NativeResult { })?; let prefilled = match pre { - Pre::Err(msg) => return Err(SemaError::Llm(msg)), + Pre::Err(error) => return Err(error), Pre::Done => return Ok(NativeOutcome::Return(stream_batch_map(Vec::new(), true))), Pre::Run { prefilled } => prefilled, }; @@ -12580,8 +13723,8 @@ fn stream_finish(token: u64) -> Result { let mut st = STREAM_RUNS .with(|r| r.borrow_mut().remove(&token)) .ok_or_else(|| SemaError::Llm("stream-run handle not found".to_string()))?; - if let Some(msg) = st.pending_error.take() { - return Err(SemaError::Llm(msg)); + if let Some(error) = st.pending_error.take() { + return Err(error); } let resp = st .response @@ -12599,8 +13742,8 @@ fn agent_stream_apply(agent_token: u64, stream_token: u64) -> Result= MAX_CONSECUTIVE_TOOL_ERRORS { + let msg = format!( + "aborting agent run after {consecutive_errors} consecutive tool errors" + ); + _agent_span.record_error("tool_error", &msg); + return Err(SemaError::Llm(msg)); + } + continue; + } // Build args map for callback let args_value = sema_core::json_to_value(&tc.arguments); @@ -13241,6 +14404,7 @@ mod tests { tools: vec![Value::int(1), Value::int(2)], on_tool_call: Some(Value::int(3)), remaining: std::collections::VecDeque::new(), + denied: BTreeMap::new(), active: None, phase: ToolPhase::Handler, }; @@ -13660,6 +14824,7 @@ mod tests { let driver = RuntimeCompleteDriver { plan: CompleteOffloadPlan { chain: Vec::new(), + explicit_fallback: false, request: ChatRequest::new(String::new(), Vec::new()), max_retries: 0, retry_base_ms: 0, @@ -14060,7 +15225,7 @@ mod tests { usage: Usage::default(), stop_reason: Some("tool_use".to_string()), }; - store_cached(key, &response); + store_cached(key, &response, "fake"); let cached = CACHE_MEM .with(|c| c.borrow().get(key).cloned()) diff --git a/crates/sema-llm/src/cassette.rs b/crates/sema-llm/src/cassette.rs index be8245685..ec9a40895 100644 --- a/crates/sema-llm/src/cassette.rs +++ b/crates/sema-llm/src/cassette.rs @@ -58,6 +58,10 @@ pub struct TapeEntry { /// Request hash (the matching key). pub key: String, pub content: String, + /// Provider that served the interaction. An empty value marks a legacy tape + /// entry, which an active policy rejects. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub provider: String, #[serde(default = "default_role")] pub role: String, pub model: String, @@ -91,12 +95,13 @@ fn default_role() -> String { impl TapeEntry { /// Build a tape entry from a live response under `key`. - pub fn from_response(key: &str, resp: &ChatResponse) -> TapeEntry { + pub fn from_response(key: &str, provider: &str, resp: &ChatResponse) -> TapeEntry { TapeEntry { v: 1, kind: "complete".to_string(), key: key.to_string(), content: resp.content.clone(), + provider: provider.to_string(), role: resp.role.clone(), model: resp.model.clone(), tool_calls: resp.tool_calls.clone(), @@ -119,6 +124,7 @@ impl TapeEntry { kind: "mcp-call".to_string(), key: key.to_string(), content: String::new(), + provider: String::new(), role: default_role(), model: String::new(), tool_calls: Vec::new(), @@ -134,8 +140,13 @@ impl TapeEntry { } /// Tape entry for a streamed completion: the chunk sequence plus the final response. - pub fn from_stream(key: &str, chunks: &[String], resp: &ChatResponse) -> TapeEntry { - let mut entry = TapeEntry::from_response(key, resp); + pub fn from_stream( + key: &str, + provider: &str, + chunks: &[String], + resp: &ChatResponse, + ) -> TapeEntry { + let mut entry = TapeEntry::from_response(key, provider, resp); entry.kind = "stream".to_string(); entry.chunks = chunks.to_vec(); entry @@ -144,6 +155,7 @@ impl TapeEntry { /// Tape entry for an embeddings call: the vectors plus the model and input tokens. pub fn from_embed( key: &str, + provider: &str, model: &str, embeddings: &[Vec], prompt_tokens: u32, @@ -153,6 +165,7 @@ impl TapeEntry { kind: "embed".to_string(), key: key.to_string(), content: String::new(), + provider: provider.to_string(), role: default_role(), model: model.to_string(), tool_calls: Vec::new(), @@ -445,7 +458,7 @@ mod tests { #[test] fn entry_round_trips_response_with_usage() { let r = resp("hello", 12, 34); - let e = TapeEntry::from_response("k1", &r); + let e = TapeEntry::from_response("k1", "fake", &r); let back = e.to_response(); assert_eq!(back.content, "hello"); assert_eq!(back.usage.prompt_tokens, 12); @@ -475,7 +488,11 @@ mod tests { persisted_entries: 0, }; assert!(matches!(cass.decide("k"), Decision::Record)); - cass.record_entry(TapeEntry::from_response("k", &resp("recorded", 5, 6))); + cass.record_entry(TapeEntry::from_response( + "k", + "fake", + &resp("recorded", 5, 6), + )); match cass.decide("k") { Decision::Replay(e) => { let r = e.to_response(); @@ -495,8 +512,8 @@ mod tests { )); let mut first = Cassette::load(path.clone(), CassetteMode::Record); let mut second = Cassette::load(path.clone(), CassetteMode::Record); - first.record_entry(TapeEntry::from_response("first", &resp("a", 1, 1))); - second.record_entry(TapeEntry::from_response("second", &resp("b", 1, 1))); + first.record_entry(TapeEntry::from_response("first", "fake", &resp("a", 1, 1))); + second.record_entry(TapeEntry::from_response("second", "fake", &resp("b", 1, 1))); first.save().expect("append first task's entry"); second.save().expect("append second task's entry"); @@ -517,6 +534,7 @@ mod tests { let mut cassette = Cassette::load(path.clone(), CassetteMode::Record); cassette.record_entry(TapeEntry::from_response( "survives-retry", + "fake", &resp("answer", 1, 1), )); @@ -540,7 +558,7 @@ mod tests { line!() )); let mut cass = Cassette::load(path.clone(), CassetteMode::Record); - cass.record_entry(TapeEntry::from_response("k", &resp("v", 1, 1))); + cass.record_entry(TapeEntry::from_response("k", "fake", &resp("v", 1, 1))); // The VM-thread half renders the pending NDJSON and marks it persisted. let (rendered_path, encoded) = cass.take_pending_append().expect("pending entry"); @@ -563,8 +581,8 @@ mod tests { let dir = std::env::temp_dir().join(format!("sema-cassette-test-{}", std::process::id())); let path = dir.join("tape.jsonl"); let mut tape = Tape::default(); - tape.record(TapeEntry::from_response("a", &resp("one", 1, 2))); - tape.record(TapeEntry::from_response("b", &resp("two", 3, 4))); + tape.record(TapeEntry::from_response("a", "fake", &resp("one", 1, 2))); + tape.record(TapeEntry::from_response("b", "fake", &resp("two", 3, 4))); tape.save(&path).unwrap(); let loaded = Tape::load(&path); diff --git a/crates/sema-llm/src/fake.rs b/crates/sema-llm/src/fake.rs index 1cb7a5d2f..844bf7afa 100644 --- a/crates/sema-llm/src/fake.rs +++ b/crates/sema-llm/src/fake.rs @@ -218,16 +218,31 @@ impl FakeProviderBuilder { /// Script an assistant turn that emits a single tool call (empty text content, /// `tool_use` stop reason) — mirrors how OpenAI/Anthropic return tool calls. pub fn tool_call(mut self, id: &str, name: &str, arguments: serde_json::Value) -> Self { + self.push_tool_calls(vec![ToolCall { + id: id.to_string(), + name: name.to_string(), + arguments, + thought_signature: None, + }]); + self + } + + /// Script one assistant turn containing a batch of tool calls. + /// + /// This is useful for testing batch preflight and sibling-call behavior that + /// cannot be represented by repeated [`Self::tool_call`] calls (those are + /// separate assistant turns). + pub fn tool_calls(mut self, calls: Vec) -> Self { + self.push_tool_calls(calls); + self + } + + fn push_tool_calls(&mut self, calls: Vec) { let resp = ChatResponse { content: String::new(), role: "assistant".to_string(), model: self.default_model.clone(), - tool_calls: vec![ToolCall { - id: id.to_string(), - name: name.to_string(), - arguments, - thought_signature: None, - }], + tool_calls: calls, usage: Usage { prompt_tokens: 10, completion_tokens: 5, @@ -237,7 +252,6 @@ impl FakeProviderBuilder { stop_reason: Some("tool_use".to_string()), }; self.script.push_back(FakeReply::Chat(resp)); - self } /// Script a streamed reply: `chunks` are delivered to `on_chunk`, then the diff --git a/crates/sema-lsp/src/handlers/semantic_tokens.rs b/crates/sema-lsp/src/handlers/semantic_tokens.rs index ffbc5df71..5c4e942fa 100644 --- a/crates/sema-lsp/src/handlers/semantic_tokens.rs +++ b/crates/sema-lsp/src/handlers/semantic_tokens.rs @@ -5,7 +5,7 @@ use std::collections::HashSet; use tower_lsp::lsp_types::*; use crate::helpers::char_col_to_utf16; -use crate::state::{token_modifiers, token_types, BackendState}; +use crate::state::{token_modifiers, token_types, BackendState, DEFINITION_HEADS}; impl BackendState { pub(crate) fn handle_semantic_tokens_full(&self, uri: &Url) -> Option { @@ -15,6 +15,7 @@ impl BackendState { // Single pass: collect user-defined function and macro names let mut user_fn_names = HashSet::new(); let mut user_macro_names = HashSet::new(); + let mut workflow_names = HashSet::new(); for expr in &cached.ast { if let Some(items) = expr.as_list() { if items.len() >= 2 { @@ -27,6 +28,9 @@ impl BackendState { "defmacro" => { user_macro_names.insert(name); } + "defworkflow" => { + workflow_names.insert(name); + } "define" | "def" => { // (define (f x) ...) shorthand // Already handled below @@ -54,10 +58,13 @@ impl BackendState { for (name, span) in &cached.symbol_spans { let (token_type, modifiers) = if sema_eval::SPECIAL_FORM_NAMES.contains(&name.as_str()) + || DEFINITION_HEADS.contains(&name.as_str()) { (token_types::KEYWORD, 0u32) } else if user_macro_names.contains(name.as_str()) { (token_types::MACRO, 0u32) + } else if workflow_names.contains(name.as_str()) { + (token_types::FUNCTION, 0u32) } else if self.builtin_names.contains(name.as_str()) { (token_types::FUNCTION, token_modifiers::DEFAULT_LIBRARY) } else { diff --git a/crates/sema-lsp/src/helpers.rs b/crates/sema-lsp/src/helpers.rs index 8b403eb0d..c5376517e 100644 --- a/crates/sema-lsp/src/helpers.rs +++ b/crates/sema-lsp/src/helpers.rs @@ -301,17 +301,7 @@ pub fn span_to_range(span: &Span, lines: &[&str]) -> Range { /// Build a diagnostic message from a `SemaError`, appending hint/note if present. pub(crate) fn format_error_message(err: &SemaError) -> String { - let mut message = match err.inner() { - SemaError::Reader { message, .. } => message.clone(), - other => other.to_string(), - }; - if let Some(hint) = err.hint() { - message.push_str(&format!("\nhint: {hint}")); - } - if let Some(note) = err.note() { - message.push_str(&format!("\nnote: {note}")); - } - message + err.format_diagnostic() } /// Convert a SemaError into a diagnostic with the given severity. @@ -408,7 +398,7 @@ pub fn extract_symbol_at(line: &str, byte_offset: usize) -> &str { } /// Collect user-defined names with their spans from a pre-parsed AST. -/// Returns (name, range) for each `define`/`defun`/`defn`/`defmacro`/`defagent`/`deftool`. +/// Returns (name, range) for each top-level form that creates a reusable binding. /// When `symbol_spans` is provided, returns the precise span of just the name symbol; /// otherwise falls back to the span of the entire definition form. pub fn user_definitions_from_ast( @@ -424,7 +414,7 @@ pub fn user_definitions_from_ast( if let Some(head) = items[0].as_symbol() { match head.as_str() { "define" | "def" | "defun" | "defn" | "defmacro" | "defagent" - | "deftool" => { + | "deftool" | "defpolicy" => { let form_span = expr_span(expr, span_map); // (define name ...) or (defun name (...) ...) if let Some(name) = items[1].as_symbol() { @@ -1121,6 +1111,26 @@ pub fn document_symbols_from_ast( continue; } } + "defworkflow" => { + if let Some(name) = items[1].as_symbol() { + let fs = expr_span(expr, span_map); + let nr = + fs.and_then(|s| find_name_span(&name, s, symbol_spans, lines)); + (name, SymbolKind::FUNCTION, nr) + } else { + continue; + } + } + "defpolicy" => { + if let Some(name) = items[1].as_symbol() { + let fs = expr_span(expr, span_map); + let nr = + fs.and_then(|s| find_name_span(&name, s, symbol_spans, lines)); + (name, SymbolKind::VARIABLE, nr) + } else { + continue; + } + } "define" | "def" => { if let Some(name) = items[1].as_symbol() { let fs = expr_span(expr, span_map); @@ -1174,7 +1184,7 @@ pub fn document_symbols_from_ast( symbols } -/// Collect user-defined names from top-level `define`/`defun`/`defn`/`defmacro`/`defagent`/`deftool` forms. +/// Collect reusable names from top-level definition forms. pub fn user_definitions(text: &str) -> Vec { user_definitions_with_spans(text) .into_iter() diff --git a/crates/sema-lsp/src/scope.rs b/crates/sema-lsp/src/scope.rs index 7b16eaef3..4b4e95166 100644 --- a/crates/sema-lsp/src/scope.rs +++ b/crates/sema-lsp/src/scope.rs @@ -124,7 +124,7 @@ impl ScopeTree { "define" | "def" => self.walk_define(items, expr, parent_scope, span_map, symbol_spans), "defun" | "defn" => self.walk_defun(items, expr, parent_scope, span_map, symbol_spans), "defmacro" => self.walk_defmacro(items, expr, parent_scope, span_map, symbol_spans), - "defagent" | "deftool" => { + "defagent" | "deftool" | "defpolicy" => { // These define a name at the parent scope level. if items.len() >= 2 { if let Some(name) = items[1].as_symbol() { @@ -1371,7 +1371,7 @@ mod tests { assert_ne!(a_resolved.scope_idx, b_resolved.scope_idx); } - // ── defagent/deftool scoping ───────────────────────────────── + // ── named host form scoping ────────────────────────────────── #[test] fn defagent_name_is_top_level() { @@ -1391,6 +1391,15 @@ mod tests { assert!(resolved.unwrap().is_top_level); } + #[test] + fn defpolicy_name_is_top_level() { + let src = "(defpolicy safe {:models {:default :deny}})"; + let (tree, _) = build_scope(src); + let resolved = tree.resolve_at("safe", 1, 12); + assert!(resolved.is_some()); + assert!(resolved.unwrap().is_top_level); + } + // ── fn (lambda alias) scoping ──────────────────────────────── #[test] diff --git a/crates/sema-lsp/src/state.rs b/crates/sema-lsp/src/state.rs index d6bfa2f6d..f3889e7f4 100644 --- a/crates/sema-lsp/src/state.rs +++ b/crates/sema-lsp/src/state.rs @@ -227,7 +227,15 @@ pub(crate) fn build_selection_range(mut ranges: Vec, pos: &Position) -> S /// Heads that introduce a top-level named definition. pub(crate) const DEFINITION_HEADS: &[&str] = &[ - "define", "def", "defun", "defn", "defmacro", "defagent", "deftool", + "define", + "def", + "defun", + "defn", + "defmacro", + "defagent", + "deftool", + "defworkflow", + "defpolicy", ]; /// If `expr` is a definition form, return its `(name, full-form range, name range)`. diff --git a/crates/sema-lsp/src/tests.rs b/crates/sema-lsp/src/tests.rs index 118b7ee83..8ce4f002a 100644 --- a/crates/sema-lsp/src/tests.rs +++ b/crates/sema-lsp/src/tests.rs @@ -1618,6 +1618,20 @@ fn doc_symbols_deftool() { assert_eq!(symbols[0].kind, SymbolKind::METHOD); } +#[test] +fn doc_symbols_workflow_and_policy() { + let src = + "(defworkflow release \"Release\" {} #t)\n(defpolicy safe {:models {:default :deny}})"; + let (ast, span_map, sym_spans) = sema_reader::read_many_with_symbol_spans(src).unwrap(); + let lines: Vec<&str> = src.lines().collect(); + let symbols = document_symbols_from_ast(&ast, &span_map, &sym_spans, &lines); + assert_eq!(symbols.len(), 2); + assert_eq!(symbols[0].name, "release"); + assert_eq!(symbols[0].kind, SymbolKind::FUNCTION); + assert_eq!(symbols[1].name, "safe"); + assert_eq!(symbols[1].kind, SymbolKind::VARIABLE); +} + // ── find_enclosing_call edge cases ─────────────────────────── #[test] @@ -1840,6 +1854,68 @@ fn semantic_token_start_column_is_utf16_after_astral_char() { ); } +#[test] +fn workflow_approval_and_policy_forms_are_semantic_tokens() { + let src = "(defworkflow release \"Release\" {} (approval :ship {}))\n\ + (defpolicy safe {})\n\ + (policy/without \"test\" #t)\n\ + (workflow/approval :ship {})\n\ + (workflow/policy-without \"test\" (fn () #t))\n\ + (workflow/tool-result \"publish\")\n\ + (tool/policy-subjects publish)"; + let (mut state, uri) = parsed_state("file:///workflow-semtok.sema", src); + state.builtin_names = BackendState::new().builtin_names; + + for name in [ + "approval", + "defpolicy", + "policy/without", + "tool/policy-subjects", + "workflow/approval", + "workflow/policy-without", + "workflow/tool-result", + ] { + assert!( + state.builtin_names.contains(name), + "missing LSP builtin {name}" + ); + } + + let result = state.handle_semantic_tokens_full(&uri).unwrap(); + let SemanticTokensResult::Tokens(tokens) = result else { + panic!("expected token data"); + }; + assert!( + tokens + .data + .iter() + .filter(|token| token.token_type == crate::state::token_types::KEYWORD) + .count() + >= 2, + "defworkflow and defpolicy must be keyword tokens" + ); + assert!( + tokens.data.iter().any(|token| { + token.token_type == crate::state::token_types::FUNCTION + && token.token_modifiers_bitset == 0 + }), + "the workflow name must be a function token" + ); + assert!( + tokens + .data + .iter() + .filter(|token| { + token.token_type == crate::state::token_types::FUNCTION + && token.token_modifiers_bitset + == crate::state::token_modifiers::DEFAULT_LIBRARY + }) + .count() + >= 6, + "workflow and policy builtins must be default-library function tokens" + ); +} + // Folding-range columns must be UTF-16 code units, not char indices. #[test] fn folding_range_columns_are_utf16_after_astral_char() { diff --git a/crates/sema-mcp/src/builtins.rs b/crates/sema-mcp/src/builtins.rs index f4801eb67..36226cd22 100644 --- a/crates/sema-mcp/src/builtins.rs +++ b/crates/sema-mcp/src/builtins.rs @@ -2493,6 +2493,7 @@ fn tool_defs_to_value( name: tool.name, description: tool.description, parameters, + policy_subjects: Vec::new(), handler, })); } diff --git a/crates/sema-mcp/src/tools.rs b/crates/sema-mcp/src/tools.rs index 0ce5b87ed..b3a673180 100644 --- a/crates/sema-mcp/src/tools.rs +++ b/crates/sema-mcp/src/tools.rs @@ -520,22 +520,7 @@ where let captured = buf.lock().map(|b| b.clone()).unwrap_or_default(); match result { - // Keep structured hint/note: Display on WithContext is "{inner}" only, - // so plain "{e}" silently drops the VM's did-you-mean suggestions that - // the CLI prints — exactly the guidance an MCP client needs most. - Ok(r) => ( - r.map_err(|e| { - let mut message = e.to_string(); - if let Some(hint) = e.hint() { - message.push_str(&format!("\nhint: {hint}")); - } - if let Some(note) = e.note() { - message.push_str(&format!("\nnote: {note}")); - } - message - }), - captured, - ), + Ok(r) => (r.map_err(|e| e.format_plain()), captured), Err(panic) => std::panic::resume_unwind(panic), } } @@ -977,12 +962,14 @@ fn call_mcp_tool_inner( Interpreter::new_with_sandbox(&sema_core::Sandbox::allow_all()); let result = match compile_interpreter.compile_to_bytecode(&source) { Ok(r) => r, - Err(e) => return error_result(format!("Compile error: {}", e.inner())), + Err(e) => return error_result(format!("compilation failed: {}", e.format_plain())), }; let bytes = match sema_vm::serialize_to_bytes(&result, source_hash) { Ok(b) => b, - Err(e) => return error_result(format!("Serialization error: {}", e.inner())), + Err(e) => { + return error_result(format!("serialization failed: {}", e.format_plain())) + } }; let out_path = match output_path { @@ -1126,7 +1113,12 @@ fn call_mcp_tool_inner( let compile_result = if sema_vm::is_bytecode_file(&bytes) { match sema_vm::deserialize_from_bytes(&bytes) { Ok(r) => r, - Err(e) => return error_result(format!("Deserialization error: {}", e.inner())), + Err(e) => { + return error_result(format!( + "deserialization failed: {}", + e.format_plain() + )) + } } } else { let source = match std::str::from_utf8(&bytes) { @@ -1137,7 +1129,9 @@ fn call_mcp_tool_inner( Interpreter::new_with_sandbox(&sema_core::Sandbox::allow_all()); match compile_interpreter.compile_to_bytecode(source) { Ok(r) => r, - Err(e) => return error_result(format!("Compile error: {}", e.inner())), + Err(e) => { + return error_result(format!("compilation failed: {}", e.format_plain())) + } } }; diff --git a/crates/sema-policy/Cargo.toml b/crates/sema-policy/Cargo.toml new file mode 100644 index 000000000..0b852a43f --- /dev/null +++ b/crates/sema-policy/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "sema-policy" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +description = "Deterministic policy compiler and matcher for Sema workflows" + +[dependencies] +sema-core.workspace = true +globset.workspace = true +regex.workspace = true +serde_json.workspace = true +sha2.workspace = true +thiserror.workspace = true +url.workspace = true diff --git a/crates/sema-policy/src/content.rs b/crates/sema-policy/src/content.rs new file mode 100644 index 000000000..90153307c --- /dev/null +++ b/crates/sema-policy/src/content.rs @@ -0,0 +1,306 @@ +//! Shared deterministic secret and personal-data scanners. + +use regex::Regex; +use std::collections::BTreeMap; +use std::sync::OnceLock; + +/// Maximum text size accepted by policy content scanners. +pub const INPUT_BYTE_CAP: usize = 16 * 1024 * 1024; + +/// A deterministic detector exposed by the policy DSL. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum DetectorKind { + Secret, + Email, + Phone, + Ipv4, + PaymentCard, +} + +impl DetectorKind { + pub fn as_str(self) -> &'static str { + match self { + Self::Secret => "secret", + Self::Email => "email", + Self::Phone => "phone", + Self::Ipv4 => "ipv4", + Self::PaymentCard => "payment-card", + } + } +} + +/// One private scanner finding. Callers must not serialize the matched span. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Finding { + pub label: &'static str, + pub start: usize, + pub end: usize, +} + +/// Scan one detector family and return non-overlapping byte spans. +pub fn scan(text: &str, detector: DetectorKind) -> Vec { + match detector { + DetectorKind::Secret => detect_secrets(text), + DetectorKind::Email => detect_regex(text, "email", email_re()), + DetectorKind::Phone => detect_regex(text, "phone", phone_re()), + DetectorKind::Ipv4 => detect_regex(text, "ipv4", ipv4_re()), + DetectorKind::PaymentCard => detect_payment_cards(text), + } +} + +/// Scan the public PII detector set used by `pii/detect`. +pub fn detect_pii(text: &str) -> Vec { + merge_findings( + [DetectorKind::Email, DetectorKind::Ipv4, DetectorKind::Phone] + .into_iter() + .flat_map(|detector| scan(text, detector)), + ) +} + +/// Run all deterministic secret matchers. +pub fn detect_secrets(text: &str) -> Vec { + let mut findings = Vec::new(); + for (regex, label) in [ + (aws_re(), "aws-access-key"), + (private_key_re(), "private-key"), + (jwt_re(), "jwt"), + (slack_re(), "slack-token"), + (github_re(), "github-token"), + ] { + for matched in regex.find_iter(text) { + push_if_free(&mut findings, label, matched.start(), matched.end()); + } + } + for captures in generic_re().captures_iter(text) { + let whole = captures + .get(0) + .expect("generic secret regex has whole match"); + let value = captures + .get(2) + .expect("generic secret regex has value capture"); + if shannon_entropy(value.as_str()) >= ENTROPY_THRESHOLD { + push_if_free(&mut findings, "api-key", whole.start(), whole.end()); + } + } + for matched in high_entropy_re().find_iter(text) { + if shannon_entropy(matched.as_str()) >= ENTROPY_THRESHOLD { + push_if_free( + &mut findings, + "high-entropy", + matched.start(), + matched.end(), + ); + } + } + findings.sort_by_key(|finding| finding.start); + findings +} + +/// Replace findings right-to-left with deterministic typed markers. +pub fn redact(text: &str, findings: &[Finding]) -> String { + let mut findings = findings.to_vec(); + findings.sort_by_key(|finding| finding.start); + let mut accepted = Vec::new(); + let mut last_end = 0; + for finding in findings { + if finding.start >= last_end { + last_end = finding.end; + accepted.push(finding); + } + } + let mut output = text.to_string(); + for finding in accepted.into_iter().rev() { + output.replace_range( + finding.start..finding.end, + &format!("«redacted:{}»", finding.label), + ); + } + output +} + +fn detect_regex(text: &str, label: &'static str, regex: &Regex) -> Vec { + regex + .find_iter(text) + .map(|matched| Finding { + label, + start: matched.start(), + end: matched.end(), + }) + .collect() +} + +fn detect_payment_cards(text: &str) -> Vec { + payment_card_re() + .find_iter(text) + .filter(|matched| { + let digits: String = matched + .as_str() + .chars() + .filter(char::is_ascii_digit) + .collect(); + (13..=19).contains(&digits.len()) && luhn_valid(&digits) + }) + .map(|matched| Finding { + label: "payment-card", + start: matched.start(), + end: matched.end(), + }) + .collect() +} + +fn luhn_valid(digits: &str) -> bool { + let sum: u32 = digits + .bytes() + .rev() + .enumerate() + .map(|(index, byte)| { + let mut digit = u32::from(byte - b'0'); + if index % 2 == 1 { + digit *= 2; + if digit > 9 { + digit -= 9; + } + } + digit + }) + .sum(); + sum.is_multiple_of(10) +} + +fn merge_findings(findings: impl IntoIterator) -> Vec { + let mut merged = Vec::new(); + for finding in findings { + push_if_free(&mut merged, finding.label, finding.start, finding.end); + } + merged.sort_by_key(|finding| finding.start); + merged +} + +fn push_if_free(findings: &mut Vec, label: &'static str, start: usize, end: usize) { + if findings + .iter() + .any(|finding| start < finding.end && finding.start < end) + { + return; + } + findings.push(Finding { label, start, end }); +} + +fn shannon_entropy(value: &str) -> f64 { + if value.is_empty() { + return 0.0; + } + let mut counts = BTreeMap::new(); + for character in value.chars() { + *counts.entry(character).or_insert(0usize) += 1; + } + let length = value.chars().count() as f64; + counts.values().fold(0.0, |entropy, count| { + let probability = *count as f64 / length; + entropy - probability * probability.log2() + }) +} + +const ENTROPY_THRESHOLD: f64 = 3.5; + +fn aws_re() -> &'static Regex { + static REGEX: OnceLock = OnceLock::new(); + REGEX.get_or_init(|| Regex::new(r"AKIA[0-9A-Z]{16}").expect("valid AWS regex")) +} + +fn generic_re() -> &'static Regex { + static REGEX: OnceLock = OnceLock::new(); + REGEX.get_or_init(|| { + Regex::new(r#"(?i)(api[_-]?key|secret|token|password)\s*[:=]\s*['"]?([A-Za-z0-9_\-]{16,})"#) + .expect("valid generic secret regex") + }) +} + +fn private_key_re() -> &'static Regex { + static REGEX: OnceLock = OnceLock::new(); + REGEX.get_or_init(|| { + Regex::new(r"-----BEGIN [A-Z ]*PRIVATE KEY-----").expect("valid private-key regex") + }) +} + +fn jwt_re() -> &'static Regex { + static REGEX: OnceLock = OnceLock::new(); + REGEX.get_or_init(|| { + Regex::new(r"eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+").expect("valid JWT regex") + }) +} + +fn slack_re() -> &'static Regex { + static REGEX: OnceLock = OnceLock::new(); + REGEX.get_or_init(|| Regex::new(r"xox[baprs]-[A-Za-z0-9-]+").expect("valid Slack regex")) +} + +fn github_re() -> &'static Regex { + static REGEX: OnceLock = OnceLock::new(); + REGEX.get_or_init(|| { + Regex::new(r"gh[pousr]_[A-Za-z0-9]{36,}").expect("valid GitHub token regex") + }) +} + +fn high_entropy_re() -> &'static Regex { + static REGEX: OnceLock = OnceLock::new(); + REGEX.get_or_init(|| Regex::new(r"[A-Za-z0-9+/=_\-]{32,}").expect("valid high-entropy regex")) +} + +fn email_re() -> &'static Regex { + static REGEX: OnceLock = OnceLock::new(); + REGEX.get_or_init(|| { + Regex::new(r"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}").expect("valid email regex") + }) +} + +fn ipv4_re() -> &'static Regex { + static REGEX: OnceLock = OnceLock::new(); + REGEX.get_or_init(|| { + Regex::new( + r"\b(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])\b", + ) + .expect("valid IPv4 regex") + }) +} + +fn phone_re() -> &'static Regex { + static REGEX: OnceLock = OnceLock::new(); + REGEX.get_or_init(|| { + Regex::new(r"(?:\+?1[\s.\-]?)?\(?\d{3}\)?[\s.\-]?\d{3}[\s.\-]?\d{4}") + .expect("valid phone regex") + }) +} + +fn payment_card_re() -> &'static Regex { + static REGEX: OnceLock = OnceLock::new(); + REGEX.get_or_init(|| { + Regex::new(r"\b(?:[0-9][ -]?){12,18}[0-9]\b").expect("valid payment-card regex") + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn payment_cards_require_luhn_validation() { + assert_eq!( + scan("card 4111 1111 1111 1111", DetectorKind::PaymentCard).len(), + 1 + ); + assert!(scan("card 4111 1111 1111 1112", DetectorKind::PaymentCard).is_empty()); + } + + #[test] + fn redaction_is_typed_and_idempotent() { + let text = "contact me@example.com"; + let findings = scan(text, DetectorKind::Email); + let redacted = redact(text, &findings); + assert_eq!(redacted, "contact «redacted:email»"); + assert_eq!( + redact(&redacted, &scan(&redacted, DetectorKind::Email)), + redacted + ); + } +} diff --git a/crates/sema-policy/src/lib.rs b/crates/sema-policy/src/lib.rs new file mode 100644 index 000000000..4eebd1254 --- /dev/null +++ b/crates/sema-policy/src/lib.rs @@ -0,0 +1,2788 @@ +//! Deterministic workflow policy compilation and boundary matching. +//! +//! This crate is deliberately runtime-agnostic. It compiles immutable Sema maps +//! into Rust-only policy data, evaluates resolved model identities and +//! model-supplied tool arguments, and returns decisions for the workflow/LLM +//! integration layers to enforce and journal. + +pub mod content; + +use globset::{GlobBuilder, GlobMatcher}; +use regex::Regex; +use sema_core::{suggest_similar, FileAccess, ToolPolicySubject, Value}; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Component, Path, PathBuf}; +use thiserror::Error; +use url::{Host, Url}; + +const POLICY_VERSION: i64 = 1; + +/// A policy definition or matcher compilation error. +#[derive(Debug, Error, Clone, PartialEq, Eq)] +#[error("{message}")] +pub struct PolicyError { + message: String, + hint: Option, +} + +impl PolicyError { + pub fn hint(&self) -> Option<&str> { + self.hint.as_deref() + } +} + +/// The default effect when no explicit rule matches. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DefaultEffect { + Allow, + Deny, +} + +/// What a model gate does with a denied fallback target. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum ModelDenyAction { + Skip, + Fail, +} + +/// What an agent loop does with a denied tool call. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum ToolDenyAction { + ToolError, + Fail, +} + +/// The result of checking one policy layer. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PolicyCheck { + pub allowed: bool, + pub rule: String, + pub reason: Option, +} + +impl PolicyCheck { + fn allow(rule: impl Into) -> Self { + Self { + allowed: true, + rule: rule.into(), + reason: None, + } + } + + fn deny(rule: impl Into, reason: impl Into) -> Self { + Self { + allowed: false, + rule: rule.into(), + reason: Some(reason.into()), + } + } +} + +/// Strictness lattice for deterministic content findings. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum ContentAction { + Allow, + Audit, + Redact, + Block, +} + +impl ContentAction { + pub fn as_str(self) -> &'static str { + match self { + Self::Allow => "allow", + Self::Audit => "audit", + Self::Redact => "redact", + Self::Block => "block", + } + } +} + +/// Whether output validation is running on an intermediate tool-call round or +/// the terminal assistant response. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OutputStage { + Round, + Final, +} + +/// Safe, aggregate content finding. It contains no matched text or byte offsets. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PolicyFinding { + pub rule_id: String, + pub label: String, + pub action: ContentAction, + pub count: usize, +} + +/// Result of evaluating one policy layer against one original text value. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ContentOutcome { + pub action: ContentAction, + pub findings: Vec, + /// Private transformation data for the enforcement layer; never journal it. + pub redactions: Vec, +} + +impl ContentOutcome { + fn allow() -> Self { + Self { + action: ContentAction::Allow, + findings: Vec::new(), + redactions: Vec::new(), + } + } +} + +/// One compiled, named policy layer. +#[derive(Debug)] +pub struct CompiledPolicy { + name: String, + fingerprint: String, + models: Option, + tools: Option, + subjects: Option, + input: Option, + output: Option, + metadata: Option, + completion: Option, +} + +impl CompiledPolicy { + /// Compile a `defpolicy` map or an inline policy map. + pub fn compile(value: &Value) -> Result { + let map = require_map(value, "policy")?; + reject_unknown_keys( + map, + &[ + "__policy-name", + "__policy-version", + "models", + "tools", + "subjects", + "input", + "output", + "metadata", + "completion", + ], + "policy", + )?; + + let name = get(map, "__policy-name") + .and_then(value_name) + .unwrap_or_else(|| "inline-policy".to_string()); + if name.trim().is_empty() { + return Err(invalid("policy name must not be empty")); + } + + if let Some(version) = get(map, "__policy-version") { + let version = version + .as_int() + .ok_or_else(|| invalid(":__policy-version must be an integer"))?; + if version != POLICY_VERSION { + return Err(invalid(format!( + "unsupported policy version {version}; expected {POLICY_VERSION}" + ))); + } + } + + let models = get(map, "models").map(ModelPolicy::compile).transpose()?; + let tools = get(map, "tools").map(ToolPolicy::compile).transpose()?; + let subjects = get(map, "subjects") + .map(SubjectPolicy::compile) + .transpose()?; + let input = get(map, "input") + .map(|value| DetectorPolicy::compile(value, ":input", true)) + .transpose()?; + let output = get(map, "output").map(OutputPolicy::compile).transpose()?; + let metadata = get(map, "metadata") + .map(MetadataPolicy::compile) + .transpose()?; + let completion = get(map, "completion") + .map(CompletionPolicy::compile) + .transpose()?; + let fingerprint = fingerprint(value, &name); + + Ok(Self { + name, + fingerprint, + models, + tools, + subjects, + input, + output, + metadata, + completion, + }) + } + + pub fn name(&self) -> &str { + &self.name + } + + /// Stable SHA-256 digest over the policy name, version, and canonical map. + pub fn fingerprint(&self) -> &str { + &self.fingerprint + } + + /// The configured deny action, or `None` when no models section is present. + pub fn model_action(&self) -> Option { + self.models.as_ref().map(|policy| policy.on_deny) + } + + /// The configured deny action, or `None` when no tools section is present. + pub fn tool_action(&self) -> Option { + self.tools.as_ref().map(|policy| policy.on_deny) + } + + /// Check a fully resolved provider/model pair. + pub fn check_model(&self, provider: &str, model: &str) -> PolicyCheck { + self.models.as_ref().map_or_else( + || PolicyCheck::allow("models.unrestricted"), + |policy| policy.check(provider, model), + ) + } + + /// Check a named tool and its model-supplied JSON arguments. + pub fn check_tool( + &self, + tool: &str, + arguments: &serde_json::Value, + policy_subjects: &[ToolPolicySubject], + workspace_root: &Path, + ) -> PolicyCheck { + let named = self.tools.as_ref().map_or_else( + || PolicyCheck::allow("tools.unrestricted"), + |policy| policy.check(tool, arguments, workspace_root), + ); + if !named.allowed { + return named; + } + self.subjects.as_ref().map_or_else( + || PolicyCheck::allow("subjects.unrestricted"), + |policy| policy.check(policy_subjects, arguments, workspace_root), + ) + } + + pub fn has_input_policy(&self) -> bool { + self.input.is_some() + } + + pub fn has_output_policy(&self) -> bool { + self.output.is_some() + } + + pub fn check_input(&self, text: &str) -> ContentOutcome { + self.input + .as_ref() + .map_or_else(ContentOutcome::allow, |policy| policy.check(text)) + } + + pub fn check_output(&self, text: &str, stage: OutputStage) -> ContentOutcome { + self.output + .as_ref() + .map_or_else(ContentOutcome::allow, |policy| policy.check(text, stage)) + } + + pub fn required_metadata(&self) -> impl Iterator { + self.metadata + .iter() + .flat_map(|policy| policy.required.iter().map(String::as_str)) + } + + pub fn required_completion_events(&self) -> impl Iterator { + self.completion + .iter() + .flat_map(|policy| policy.required_events.iter().map(String::as_str)) + } +} + +#[derive(Debug)] +struct MetadataPolicy { + required: BTreeSet, +} + +impl MetadataPolicy { + fn compile(value: &Value) -> Result { + let map = require_map(value, ":metadata")?; + reject_unknown_keys(map, &["require"], ":metadata")?; + let required = parse_name_set(get(map, "require"), ":metadata :require")?; + if required.is_empty() { + return Err(invalid(":metadata :require must not be empty")); + } + Ok(Self { required }) + } +} + +#[derive(Debug)] +struct CompletionPolicy { + required_events: BTreeSet, +} + +impl CompletionPolicy { + fn compile(value: &Value) -> Result { + let map = require_map(value, ":completion")?; + reject_unknown_keys(map, &["require-events"], ":completion")?; + let required_events = + parse_name_set(get(map, "require-events"), ":completion :require-events")?; + if required_events.is_empty() { + return Err(invalid(":completion :require-events must not be empty")); + } + const SUPPORTED_EVENTS: &[&str] = &[ + "run.started", + "phase.started", + "phase.ended", + "agent.started", + "agent.result", + "agent.tool_call", + "agent.tool_result", + "checkpoint", + "budget", + "auth.required", + "auth.granted", + "auth.failed", + "approval.requested", + "approval.granted", + "approval.rejected", + "approval.applied", + "policy.checked", + "policy.flagged", + "policy.redacted", + "policy.violation", + "policy.bypassed", + ]; + for event in &required_events { + if !SUPPORTED_EVENTS.contains(&event.as_str()) { + return Err(invalid_with_hint( + format!(":completion requires unsupported event {event:?}"), + format!("valid events are {}", SUPPORTED_EVENTS.join(", ")), + )); + } + } + Ok(Self { required_events }) + } +} + +#[derive(Debug)] +struct ModelPolicy { + default: DefaultEffect, + allow: Vec, + deny: Vec, + on_deny: ModelDenyAction, +} + +impl ModelPolicy { + fn compile(value: &Value) -> Result { + let map = require_map(value, ":models")?; + reject_unknown_keys(map, &["default", "allow", "deny", "on-deny"], ":models")?; + Ok(Self { + default: parse_default(get(map, "default"), ":models")?, + allow: parse_model_patterns(get(map, "allow"), ":models :allow")?, + deny: parse_model_patterns(get(map, "deny"), ":models :deny")?, + on_deny: match get(map, "on-deny") { + None => ModelDenyAction::Fail, + Some(value) => match value_name(value).as_deref() { + Some("fail") => ModelDenyAction::Fail, + Some("skip") => ModelDenyAction::Skip, + Some(other) => { + return Err(invalid_with_hint( + format!(":models :on-deny has unsupported value :{other}"), + "valid values are :fail and :skip", + )) + } + None => { + return Err(invalid(format!( + ":models :on-deny must be a keyword or string, got {}", + value.type_name() + ))) + } + }, + }, + }) + } + + fn check(&self, provider: &str, model: &str) -> PolicyCheck { + if self + .deny + .iter() + .any(|pattern| pattern.matches(provider, model)) + { + return PolicyCheck::deny( + "models.deny", + format!("model {provider}/{model} matches a deny rule"), + ); + } + if self + .allow + .iter() + .any(|pattern| pattern.matches(provider, model)) + { + return PolicyCheck::allow("models.allow"); + } + match self.default { + DefaultEffect::Allow => PolicyCheck::allow("models.default-allow"), + DefaultEffect::Deny => PolicyCheck::deny( + "models.default-deny", + format!("model {provider}/{model} is not allowlisted"), + ), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ModelPattern { + provider: String, + model: Option, +} + +impl ModelPattern { + fn parse(value: &str) -> Result { + let (provider, model) = value.split_once('/').ok_or_else(|| { + invalid(format!( + "model rule {value:?} must use provider/model syntax" + )) + })?; + if provider.is_empty() || model.is_empty() { + return Err(invalid(format!( + "model rule {value:?} must have a nonempty provider and model" + ))); + } + if provider.contains('*') { + return Err(invalid(format!( + "model rule {value:?} cannot wildcard the provider" + ))); + } + if model.contains('*') && model != "*" { + return Err(invalid(format!( + "model rule {value:?} only supports the provider/* wildcard" + ))); + } + Ok(Self { + provider: provider.to_string(), + model: (model != "*").then(|| model.to_string()), + }) + } + + fn matches(&self, provider: &str, model: &str) -> bool { + self.provider == provider && self.model.as_deref().is_none_or(|rule| rule == model) + } +} + +#[derive(Debug)] +struct DetectorPolicy { + rules: Vec<(content::DetectorKind, ContentAction)>, + rule_prefix: &'static str, +} + +impl DetectorPolicy { + fn compile(value: &Value, context: &'static str, input: bool) -> Result { + let map = require_map(value, context)?; + reject_unknown_keys(map, &["detect", "actions"], context)?; + Self::compile_from_map(map, context, input) + } + + fn compile_from_map( + map: &BTreeMap, + context: &'static str, + input: bool, + ) -> Result { + let detectors = parse_detectors(get(map, "detect"), &format!("{context} :detect"))?; + let actions = match get(map, "actions") { + None => BTreeMap::new(), + Some(value) => { + let action_map = require_map(value, &format!("{context} :actions"))?; + let mut actions = BTreeMap::new(); + for (key, value) in action_map { + let detector = parse_detector(key, &format!("{context} :actions"))?; + if !detectors.contains(&detector) { + return Err(invalid(format!( + "{context} :actions key :{} is not declared in :detect", + detector.as_str() + ))); + } + let action = parse_content_action(value, &format!("{context} :actions"))?; + if action == ContentAction::Allow { + return Err(invalid(format!( + "{context} detector actions must be :audit, :redact, or :block" + ))); + } + actions.insert(detector, action); + } + actions + } + }; + Ok(Self { + rules: detectors + .into_iter() + .map(|detector| { + let action = actions + .get(&detector) + .copied() + .unwrap_or(ContentAction::Block); + (detector, action) + }) + .collect(), + rule_prefix: if input { + "input.detect" + } else { + "output.detect" + }, + }) + } + + fn check(&self, text: &str) -> ContentOutcome { + let mut outcome = ContentOutcome::allow(); + for (detector, action) in &self.rules { + let findings = content::scan(text, *detector); + if findings.is_empty() { + continue; + } + outcome.action = outcome.action.max(*action); + let mut counts = BTreeMap::new(); + for finding in &findings { + *counts.entry(finding.label).or_insert(0usize) += 1; + } + outcome + .findings + .extend(counts.into_iter().map(|(label, count)| PolicyFinding { + rule_id: format!("{}.{}", self.rule_prefix, detector.as_str()), + label: label.to_string(), + action: *action, + count, + })); + if *action == ContentAction::Redact { + outcome.redactions.extend(findings); + } + } + outcome + } +} + +#[derive(Debug)] +struct OutputPolicy { + detectors: DetectorPolicy, + schema: Option, + required: BTreeSet, + max_length: Option, + forbid: Vec, + action: ContentAction, +} + +impl OutputPolicy { + fn compile(value: &Value) -> Result { + let map = require_map(value, ":output")?; + reject_unknown_keys( + map, + &[ + "detect", + "actions", + "schema", + "require", + "max-length", + "forbid", + "action", + ], + ":output", + )?; + let action = get(map, "action") + .map(|value| parse_content_action(value, ":output :action")) + .transpose()? + .unwrap_or(ContentAction::Block); + if !matches!(action, ContentAction::Audit | ContentAction::Block) { + return Err(invalid( + ":output :action must be :audit or :block; only detector spans may be redacted", + )); + } + let max_length = get(map, "max-length") + .map(|value| { + value + .as_int() + .and_then(|value| usize::try_from(value).ok()) + .filter(|value| *value > 0) + .ok_or_else(|| { + invalid(format!( + ":output :max-length must be a positive integer, got {value}" + )) + }) + }) + .transpose()?; + Ok(Self { + detectors: DetectorPolicy::compile_from_map(map, ":output", false)?, + schema: get(map, "schema").map(OutputSchema::compile).transpose()?, + required: parse_name_set(get(map, "require"), ":output :require")?, + max_length, + forbid: parse_forbid_rules(get(map, "forbid"))?, + action, + }) + } + + fn check(&self, text: &str, stage: OutputStage) -> ContentOutcome { + let mut outcome = self.detectors.check(text); + for rule in &self.forbid { + let count = rule.count(text); + if count == 0 { + continue; + } + outcome.action = outcome.action.max(self.action); + outcome.findings.push(PolicyFinding { + rule_id: format!("output.forbid.{}", rule.id), + label: rule.id.clone(), + action: self.action, + count, + }); + } + if stage == OutputStage::Round { + return outcome; + } + + if self + .max_length + .is_some_and(|maximum| text.chars().count() > maximum) + { + push_structural_finding(&mut outcome, "output.max-length", "max-length", self.action); + } + + if self.schema.is_none() && self.required.is_empty() { + return outcome; + } + let parsed = match serde_json::from_str::(text) { + Ok(parsed) => parsed, + Err(_) => { + push_structural_finding( + &mut outcome, + "output.schema.json", + "invalid-json", + self.action, + ); + return outcome; + } + }; + if let Some(schema) = &self.schema { + for finding in schema.validate(&parsed) { + push_structural_finding( + &mut outcome, + &format!("output.schema.{finding}"), + &finding, + self.action, + ); + } + } + let Some(object) = parsed.as_object() else { + if !self.required.is_empty() { + push_structural_finding( + &mut outcome, + "output.require.object", + "required-fields", + self.action, + ); + } + return outcome; + }; + for key in &self.required { + if object.get(key).is_none_or(is_empty_json) { + push_structural_finding( + &mut outcome, + &format!("output.require.{key}"), + key, + self.action, + ); + } + } + outcome + } +} + +fn push_structural_finding( + outcome: &mut ContentOutcome, + rule_id: &str, + label: &str, + action: ContentAction, +) { + outcome.action = outcome.action.max(action); + outcome.findings.push(PolicyFinding { + rule_id: rule_id.to_string(), + label: label.to_string(), + action, + count: 1, + }); +} + +#[derive(Debug)] +struct ForbidRule { + id: String, + matcher: ForbidMatcher, +} + +impl ForbidRule { + fn compile(value: &Value, index: usize) -> Result { + let context = format!(":output :forbid entry {}", index + 1); + let map = require_map(value, &context)?; + reject_unknown_keys(map, &["id", "contains", "regex"], &context)?; + let id = get(map, "id") + .and_then(value_name) + .filter(|id| !id.trim().is_empty()) + .ok_or_else(|| invalid(format!("{context} requires a nonempty :id")))?; + let contains = get(map, "contains").and_then(Value::as_str); + let regex = get(map, "regex").and_then(Value::as_str); + let matcher = match (contains, regex) { + (Some(literal), None) if !literal.is_empty() => { + ForbidMatcher::Contains(literal.to_string()) + } + (None, Some(pattern)) if !pattern.is_empty() => ForbidMatcher::Regex( + Regex::new(pattern) + .map_err(|error| invalid(format!("{context} invalid regex: {error}")))?, + ), + _ => { + return Err(invalid(format!( + "{context} requires exactly one nonempty :contains or :regex" + ))) + } + }; + Ok(Self { id, matcher }) + } + + fn count(&self, text: &str) -> usize { + match &self.matcher { + ForbidMatcher::Contains(literal) => text.match_indices(literal).count(), + ForbidMatcher::Regex(regex) => regex.find_iter(text).count(), + } + } +} + +#[derive(Debug)] +enum ForbidMatcher { + Contains(String), + Regex(Regex), +} + +fn parse_forbid_rules(value: Option<&Value>) -> Result, PolicyError> { + let Some(value) = value else { + return Ok(Vec::new()); + }; + let rules = require_seq(value, ":output :forbid")?; + let mut ids = BTreeSet::new(); + rules + .iter() + .enumerate() + .map(|(index, value)| { + let rule = ForbidRule::compile(value, index)?; + if !ids.insert(rule.id.clone()) { + return Err(invalid(format!( + ":output :forbid has duplicate :id {:?}", + rule.id + ))); + } + Ok(rule) + }) + .collect() +} + +#[derive(Debug)] +struct OutputSchema { + fields: BTreeMap, +} + +impl OutputSchema { + fn compile(value: &Value) -> Result { + let map = require_map(value, ":output :schema")?; + let mut fields = BTreeMap::new(); + for (key, value) in map { + let key = value_name(key) + .filter(|key| !key.is_empty()) + .ok_or_else(|| invalid(":output :schema field names must be names or strings"))?; + if fields + .insert(key.clone(), SchemaField::compile(value, &key)?) + .is_some() + { + return Err(invalid(format!( + ":output :schema has duplicate field {key:?}" + ))); + } + } + Ok(Self { fields }) + } + + fn validate(&self, value: &serde_json::Value) -> Vec { + let Some(object) = value.as_object() else { + return vec!["object".to_string()]; + }; + self.fields + .iter() + .filter_map(|(name, field)| match object.get(name) { + None if !field.optional => Some(format!("{name}.missing")), + Some(value) if !field.kind.matches(value) => Some(format!("{name}.type")), + _ => None, + }) + .collect() + } +} + +#[derive(Debug)] +struct SchemaField { + kind: SchemaKind, + optional: bool, +} + +impl SchemaField { + fn compile(value: &Value, name: &str) -> Result { + if let Some(kind) = value_name(value) { + return Ok(Self { + kind: SchemaKind::parse(&kind, name)?, + optional: false, + }); + } + let map = require_map(value, &format!(":output :schema field {name:?}"))?; + reject_unknown_keys( + map, + &["type", "optional"], + &format!(":output :schema field {name:?}"), + )?; + let kind = get(map, "type") + .and_then(value_name) + .ok_or_else(|| invalid(format!(":output :schema field {name:?} requires :type")))?; + let optional = get(map, "optional") + .map(|value| { + value.as_bool().ok_or_else(|| { + invalid(format!( + ":output :schema field {name:?} :optional must be boolean" + )) + }) + }) + .transpose()? + .unwrap_or(false); + Ok(Self { + kind: SchemaKind::parse(&kind, name)?, + optional, + }) + } +} + +#[derive(Debug)] +enum SchemaKind { + String, + Number, + Boolean, + List, +} + +impl SchemaKind { + fn parse(value: &str, name: &str) -> Result { + match value { + "string" => Ok(Self::String), + "number" => Ok(Self::Number), + "boolean" => Ok(Self::Boolean), + "list" | "array" => Ok(Self::List), + _ => Err(invalid(format!( + ":output :schema field {name:?} has unsupported type {value:?}" + ))), + } + } + + fn matches(&self, value: &serde_json::Value) -> bool { + match self { + Self::String => value.is_string(), + Self::Number => value.is_number(), + Self::Boolean => value.is_boolean(), + Self::List => value.is_array(), + } + } +} + +fn is_empty_json(value: &serde_json::Value) -> bool { + match value { + serde_json::Value::Null => true, + serde_json::Value::String(value) => value.trim().is_empty(), + serde_json::Value::Array(value) => value.is_empty(), + serde_json::Value::Object(value) => value.is_empty(), + serde_json::Value::Bool(_) | serde_json::Value::Number(_) => false, + } +} + +fn parse_detectors( + value: Option<&Value>, + context: &str, +) -> Result, PolicyError> { + let Some(value) = value else { + return Ok(BTreeSet::new()); + }; + require_seq(value, context)? + .iter() + .enumerate() + .map(|(index, value)| parse_detector(value, &format!("{context} entry {}", index + 1))) + .collect() +} + +fn parse_detector(value: &Value, context: &str) -> Result { + match value_name(value).as_deref() { + Some("secret") => Ok(content::DetectorKind::Secret), + Some("email") => Ok(content::DetectorKind::Email), + Some("phone") => Ok(content::DetectorKind::Phone), + Some("ipv4") => Ok(content::DetectorKind::Ipv4), + Some("payment-card") => Ok(content::DetectorKind::PaymentCard), + Some(other) => Err(invalid_with_hint( + format!("{context} has unsupported detector :{other}"), + "valid detectors are :secret, :email, :phone, :ipv4, and :payment-card", + )), + None => Err(invalid(format!( + "{context} must be a keyword or string, got {}", + value.type_name() + ))), + } +} + +fn parse_content_action(value: &Value, context: &str) -> Result { + match value_name(value).as_deref() { + Some("allow") => Ok(ContentAction::Allow), + Some("audit") => Ok(ContentAction::Audit), + Some("redact") => Ok(ContentAction::Redact), + Some("block") => Ok(ContentAction::Block), + Some(other) => Err(invalid_with_hint( + format!("{context} action has unsupported value :{other}"), + "valid actions are :allow, :audit, :redact, and :block", + )), + None => Err(invalid(format!( + "{context} action must be a keyword or string, got {}", + value.type_name() + ))), + } +} + +#[derive(Debug)] +struct ToolPolicy { + default: DefaultEffect, + allow: BTreeMap, + deny: BTreeSet, + on_deny: ToolDenyAction, +} + +impl ToolPolicy { + fn compile(value: &Value) -> Result { + let map = require_map(value, ":tools")?; + reject_unknown_keys(map, &["default", "allow", "deny", "on-deny"], ":tools")?; + let allow = match get(map, "allow") { + None => BTreeMap::new(), + Some(value) => { + let rules = require_map(value, ":tools :allow")?; + let mut compiled = BTreeMap::new(); + for (name, rule) in rules { + let name = value_name(name) + .ok_or_else(|| invalid(":tools :allow keys must be tool names"))?; + if compiled.contains_key(&name) { + return Err(invalid(format!( + ":tools :allow has duplicate tool name {name:?}" + ))); + } + compiled.insert(name.clone(), ToolRule::compile(&name, rule)?); + } + compiled + } + }; + let deny = parse_string_set(get(map, "deny"), ":tools :deny")?; + let on_deny = match get(map, "on-deny") { + None => ToolDenyAction::Fail, + Some(value) => match value_name(value).as_deref() { + Some("fail") => ToolDenyAction::Fail, + Some("tool-error") => ToolDenyAction::ToolError, + Some(other) => { + return Err(invalid_with_hint( + format!(":tools :on-deny has unsupported value :{other}"), + "valid values are :fail and :tool-error", + )) + } + None => { + return Err(invalid(format!( + ":tools :on-deny must be a keyword or string, got {}", + value.type_name() + ))) + } + }, + }; + Ok(Self { + default: parse_default(get(map, "default"), ":tools")?, + allow, + deny, + on_deny, + }) + } + + fn check( + &self, + tool: &str, + arguments: &serde_json::Value, + workspace_root: &Path, + ) -> PolicyCheck { + if self.deny.contains(tool) { + return PolicyCheck::deny( + format!("tools.{tool}.deny"), + format!("tool {tool} matches an explicit deny rule"), + ); + } + if let Some(rule) = self.allow.get(tool) { + return rule.check(tool, arguments, workspace_root); + } + match self.default { + DefaultEffect::Allow => PolicyCheck::allow("tools.default-allow"), + DefaultEffect::Deny => PolicyCheck::deny( + "tools.default-deny", + format!("tool {tool} is not allowlisted"), + ), + } + } +} + +#[derive(Debug)] +struct SubjectPolicy { + default: DefaultEffect, + allow: Vec, + deny: Vec, +} + +impl SubjectPolicy { + fn compile(value: &Value) -> Result { + let map = require_map(value, ":subjects")?; + reject_unknown_keys(map, &["default", "allow", "deny"], ":subjects")?; + Ok(Self { + default: parse_default(get(map, "default"), ":subjects")?, + allow: parse_subject_rules(get(map, "allow"), ":subjects :allow")?, + deny: parse_subject_rules(get(map, "deny"), ":subjects :deny")?, + }) + } + + fn check( + &self, + specs: &[ToolPolicySubject], + arguments: &serde_json::Value, + workspace_root: &Path, + ) -> PolicyCheck { + let Some(arguments) = arguments.as_object() else { + return PolicyCheck::deny("subjects.arguments", "tool arguments must be a JSON object"); + }; + if specs.is_empty() { + return match self.default { + DefaultEffect::Allow => PolicyCheck::allow("subjects.default-allow"), + DefaultEffect::Deny => { + PolicyCheck::deny("subjects.missing", "tool has no declared policy subjects") + } + }; + } + + for spec in specs { + let subject = match ResolvedSubject::resolve(spec, arguments) { + Ok(subject) => subject, + Err(reason) => { + return PolicyCheck::deny("subjects.arguments", reason); + } + }; + if self + .deny + .iter() + .any(|rule| rule.matches(&subject, workspace_root, true)) + { + return PolicyCheck::deny( + format!("subjects.{}.deny", subject.kind().as_str()), + format!( + "{} subject matches an explicit deny rule", + subject.kind().as_str() + ), + ); + } + if self + .allow + .iter() + .any(|rule| rule.matches(&subject, workspace_root, false)) + { + continue; + } + if self.default == DefaultEffect::Deny { + return PolicyCheck::deny( + format!("subjects.{}.default-deny", subject.kind().as_str()), + format!("{} subject is not allowlisted", subject.kind().as_str()), + ); + } + } + PolicyCheck::allow("subjects.allow") + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SubjectKind { + FileRead, + FileWrite, + FileDelete, + NetworkRequest, + Command, + ExternalAction, +} + +impl SubjectKind { + fn parse(value: &Value, context: &str) -> Result { + match value_name(value).as_deref() { + Some("file-read") => Ok(Self::FileRead), + Some("file-write") => Ok(Self::FileWrite), + Some("file-delete") => Ok(Self::FileDelete), + Some("network-request") => Ok(Self::NetworkRequest), + Some("command") => Ok(Self::Command), + Some("external-action") => Ok(Self::ExternalAction), + Some(other) => Err(invalid_with_hint( + format!("{context} has unsupported :kind :{other}"), + "valid kinds are :file-read, :file-write, :file-delete, :network-request, :command, and :external-action", + )), + None => Err(invalid(format!( + "{context} :kind must be a keyword or string, got {}", + value.type_name() + ))), + } + } + + fn as_str(self) -> &'static str { + match self { + Self::FileRead => "file-read", + Self::FileWrite => "file-write", + Self::FileDelete => "file-delete", + Self::NetworkRequest => "network-request", + Self::Command => "command", + Self::ExternalAction => "external-action", + } + } +} + +#[derive(Debug)] +struct SubjectRule { + kind: SubjectKind, + constraint: Option, + methods: BTreeSet, + actions: BTreeSet, +} + +impl SubjectRule { + fn compile(value: &Value, context: &str) -> Result { + let map = require_map(value, context)?; + reject_unknown_keys( + map, + &["kind", "paths", "domains", "commands", "methods", "actions"], + context, + )?; + let kind = SubjectKind::parse( + get(map, "kind").ok_or_else(|| invalid(format!("{context} requires :kind")))?, + context, + )?; + let constraint = match kind { + SubjectKind::FileRead | SubjectKind::FileWrite | SubjectKind::FileDelete => { + get(map, "paths") + .map(|value| compile_subject_constraint(value, ConstraintKind::Path, context)) + .transpose()? + } + SubjectKind::NetworkRequest => get(map, "domains") + .map(|value| compile_subject_constraint(value, ConstraintKind::Domain, context)) + .transpose()?, + SubjectKind::Command => get(map, "commands") + .map(|value| compile_subject_constraint(value, ConstraintKind::Command, context)) + .transpose()?, + SubjectKind::ExternalAction => None, + }; + if kind != SubjectKind::NetworkRequest && get(map, "methods").is_some() { + return Err(invalid(format!( + "{context} :methods is valid only for :network-request" + ))); + } + if kind != SubjectKind::ExternalAction && get(map, "actions").is_some() { + return Err(invalid(format!( + "{context} :actions is valid only for :external-action" + ))); + } + Ok(Self { + kind, + constraint, + methods: parse_name_set(get(map, "methods"), &format!("{context} :methods"))? + .into_iter() + .map(|method| method.to_ascii_uppercase()) + .collect(), + actions: parse_name_set(get(map, "actions"), &format!("{context} :actions"))?, + }) + } + + /// Decide whether this rule covers `subject`. + /// + /// `on_unevaluatable` is the answer for a subject the rule cannot inspect at all + /// (`ConstraintMiss::evaluated` false): an unparsable or non-allowlisted-scheme URL, + /// a path that escapes the workspace, or an absent request method. A deny rule + /// passes `true` so an argument it cannot read still denies; an allow rule passes + /// `false` so that argument is never allowlisted. Both directions fail closed. + /// + /// A value that *was* compared and simply is not covered stays a non-match for both + /// polarities, so a deny rule naming one host does not deny every other host. + /// + /// Treating an un-evaluatable subject as a plain non-match made deny rules fail + /// open: a `:domains` rule defaults `:schemes` to `["https"]`, so an `http://` URL + /// produced a scheme error, did not match, and was allowed by `:default :allow`. + fn matches( + &self, + subject: &ResolvedSubject, + workspace_root: &Path, + on_unevaluatable: bool, + ) -> bool { + if self.kind != subject.kind() { + return false; + } + let constraint_matches = |value: &serde_json::Value| { + self.constraint.as_ref().is_none_or(|constraint| { + constraint + .check(value, workspace_root) + .map_or_else(|miss| !miss.evaluated && on_unevaluatable, |()| true) + }) + }; + match subject { + ResolvedSubject::File { path, .. } => constraint_matches(path), + ResolvedSubject::NetworkRequest { method, url } => { + let method_matches = self.methods.is_empty() + || match method.as_deref() { + Some(method) => self.methods.contains(method), + None => on_unevaluatable, + }; + method_matches && constraint_matches(url) + } + ResolvedSubject::Command(command) => constraint_matches(command), + ResolvedSubject::ExternalAction { action, .. } => { + self.actions.is_empty() || self.actions.contains(action) + } + } + } +} + +#[derive(Debug)] +enum ResolvedSubject { + File { + kind: SubjectKind, + path: serde_json::Value, + }, + NetworkRequest { + method: Option, + url: serde_json::Value, + }, + Command(serde_json::Value), + ExternalAction { + action: String, + _target: Option, + }, +} + +impl ResolvedSubject { + fn resolve( + spec: &ToolPolicySubject, + arguments: &serde_json::Map, + ) -> Result { + let required = |name: &str| { + arguments + .get(name) + .cloned() + .ok_or_else(|| format!("required policy subject argument {name:?} is missing")) + }; + match spec { + ToolPolicySubject::File { access, path_arg } => Ok(Self::File { + kind: match access { + FileAccess::Read => SubjectKind::FileRead, + FileAccess::Write => SubjectKind::FileWrite, + FileAccess::Delete => SubjectKind::FileDelete, + }, + path: required(path_arg)?, + }), + ToolPolicySubject::NetworkRequest { method, url_arg } => Ok(Self::NetworkRequest { + method: method.as_ref().map(|method| method.to_ascii_uppercase()), + url: required(url_arg)?, + }), + ToolPolicySubject::Command { command_arg } => Ok(Self::Command(required(command_arg)?)), + ToolPolicySubject::ExternalAction { action, target_arg } => Ok(Self::ExternalAction { + action: action.clone(), + _target: target_arg.as_deref().map(required).transpose()?, + }), + } + } + + fn kind(&self) -> SubjectKind { + match self { + Self::File { kind, .. } => *kind, + Self::NetworkRequest { .. } => SubjectKind::NetworkRequest, + Self::Command(_) => SubjectKind::Command, + Self::ExternalAction { .. } => SubjectKind::ExternalAction, + } + } +} + +fn parse_subject_rules( + value: Option<&Value>, + context: &str, +) -> Result, PolicyError> { + let Some(value) = value else { + return Ok(Vec::new()); + }; + require_seq(value, context)? + .iter() + .enumerate() + .map(|(index, value)| SubjectRule::compile(value, &format!("{context}[{index}]"))) + .collect() +} + +fn compile_subject_constraint( + value: &Value, + kind: ConstraintKind, + context: &str, +) -> Result { + let selector = if let Some(map) = value.as_map_ref() { + map.clone() + } else { + let values = require_seq(value, context)?; + if !values.iter().all(|value| value.as_str().is_some()) { + return Err(invalid(format!( + "{context} constraint must be a selector map or string sequence" + ))); + } + shorthand_selector(&values) + }; + let allowed_keys: &[&str] = match kind { + ConstraintKind::Path | ConstraintKind::Command => &["allow", "deny"], + ConstraintKind::Domain => &["allow", "deny", "schemes", "ports"], + }; + reject_unknown_keys(&selector, allowed_keys, context)?; + match kind { + ConstraintKind::Path => PathConstraint::compile(&selector).map(CompiledConstraint::Path), + ConstraintKind::Domain => { + DomainConstraint::compile(&selector).map(CompiledConstraint::Domain) + } + ConstraintKind::Command => { + CommandConstraint::compile(&selector).map(CompiledConstraint::Command) + } + } +} + +#[derive(Debug, Default)] +struct ToolRule { + constraints: Vec, +} + +impl ToolRule { + fn compile(tool: &str, value: &Value) -> Result { + let map = require_map(value, &format!("tool rule {tool:?}"))?; + reject_unknown_keys( + map, + &["paths", "domains", "commands"], + &format!("tool rule {tool:?}"), + )?; + let mut constraints = Vec::new(); + if let Some(value) = get(map, "paths") { + constraints.extend(compile_constraints( + value, + "path", + ConstraintKind::Path, + tool, + )?); + } + if let Some(value) = get(map, "domains") { + constraints.extend(compile_constraints( + value, + "url", + ConstraintKind::Domain, + tool, + )?); + } + if let Some(value) = get(map, "commands") { + constraints.extend(compile_constraints( + value, + "command", + ConstraintKind::Command, + tool, + )?); + } + Ok(Self { constraints }) + } + + fn check( + &self, + tool: &str, + arguments: &serde_json::Value, + workspace_root: &Path, + ) -> PolicyCheck { + let Some(arguments) = arguments.as_object() else { + return PolicyCheck::deny( + format!("tools.{tool}.arguments"), + "tool arguments must be a JSON object", + ); + }; + for constraint in &self.constraints { + if let Err(reason) = constraint.check(arguments, workspace_root) { + return PolicyCheck::deny( + format!( + "tools.{tool}.{}.{}", + constraint.kind.label(), + constraint.argument + ), + reason, + ); + } + } + PolicyCheck::allow(format!("tools.{tool}.allow")) + } +} + +#[derive(Debug, Clone, Copy)] +enum ConstraintKind { + Path, + Domain, + Command, +} + +impl ConstraintKind { + fn label(self) -> &'static str { + match self { + Self::Path => "paths", + Self::Domain => "domains", + Self::Command => "commands", + } + } +} + +#[derive(Debug)] +struct ArgumentConstraint { + argument: String, + kind: CompiledConstraint, +} + +impl ArgumentConstraint { + fn check( + &self, + arguments: &serde_json::Map, + workspace_root: &Path, + ) -> Result<(), String> { + let value = arguments + .get(&self.argument) + .ok_or_else(|| format!("required argument {:?} is missing", self.argument))?; + // The tools section is an allowlist: every miss denies, so only the reason matters. + self.kind + .check(value, workspace_root) + .map_err(|miss| miss.reason) + } +} + +#[derive(Debug)] +enum CompiledConstraint { + Path(PathConstraint), + Domain(DomainConstraint), + Command(CommandConstraint), +} + +/// Why a constraint did not accept a value. +/// +/// `evaluated` is false when the value could not be compared against the rule's lists +/// at all: a non-string argument, an unparsable URL, a scheme or port outside the +/// selector, or a path that escapes the workspace. It is true when the comparison ran +/// and the value is simply not covered. +/// +/// Subject matching needs the distinction. An allow rule must not allowlist a value it +/// could not read, and a deny rule must still deny one. Collapsing both cases into +/// "does not match" let an `http://` URL evade a `:domains` deny rule, because +/// `:schemes` defaults to `["https"]` and the scheme error read as "no match". +#[derive(Debug)] +struct ConstraintMiss { + reason: String, + evaluated: bool, +} + +impl ConstraintMiss { + /// The value could not be compared against the rule's lists. + fn unevaluatable(reason: impl Into) -> Self { + Self { + reason: reason.into(), + evaluated: false, + } + } + + /// The comparison ran; the value is not covered by the rule. + fn no_match(reason: impl Into) -> Self { + Self { + reason: reason.into(), + evaluated: true, + } + } +} + +impl CompiledConstraint { + fn label(&self) -> &'static str { + match self { + Self::Path(_) => "paths", + Self::Domain(_) => "domains", + Self::Command(_) => "commands", + } + } + + fn check( + &self, + value: &serde_json::Value, + workspace_root: &Path, + ) -> Result<(), ConstraintMiss> { + match self { + Self::Path(rule) => rule.check(value, workspace_root), + Self::Domain(rule) => rule.check(value), + Self::Command(rule) => rule.check(value), + } + } +} + +#[derive(Debug)] +struct PathConstraint { + allow: Vec, + deny: Vec, +} + +impl PathConstraint { + fn compile(selector: &BTreeMap) -> Result { + let allow = parse_string_list(get(selector, "allow"), "path :allow")?; + let deny = parse_string_list(get(selector, "deny"), "path :deny")?; + if allow.is_empty() { + return Err(invalid("path constraint requires a nonempty :allow list")); + } + Ok(Self { + allow: compile_path_globs(&allow)?, + deny: compile_path_globs(&deny)?, + }) + } + + fn check( + &self, + value: &serde_json::Value, + workspace_root: &Path, + ) -> Result<(), ConstraintMiss> { + let path = value + .as_str() + .ok_or_else(|| ConstraintMiss::unevaluatable("path argument must be a string"))?; + let relative = + normalize_policy_path(workspace_root, path).map_err(ConstraintMiss::unevaluatable)?; + if self.deny.iter().any(|pattern| pattern.is_match(&relative)) { + return Err(ConstraintMiss::no_match("path matches a deny pattern")); + } + self.allow + .iter() + .any(|pattern| pattern.is_match(&relative)) + .then_some(()) + .ok_or_else(|| ConstraintMiss::no_match("path is not allowlisted")) + } +} + +#[derive(Debug)] +struct DomainConstraint { + allow: Vec, + deny: Vec, + schemes: BTreeSet, + ports: Option>, +} + +impl DomainConstraint { + fn compile(selector: &BTreeMap) -> Result { + let allow = parse_string_list(get(selector, "allow"), "domain :allow")? + .into_iter() + .map(|host| HostPattern::parse(&host)) + .collect::, _>>()?; + if allow.is_empty() { + return Err(invalid("domain constraint requires a nonempty :allow list")); + } + let deny = parse_string_list(get(selector, "deny"), "domain :deny")? + .into_iter() + .map(|host| HostPattern::parse(&host)) + .collect::, _>>()?; + let schemes = match get(selector, "schemes") { + Some(value) => parse_string_list(Some(value), "domain :schemes")? + .into_iter() + .map(|scheme| scheme.to_ascii_lowercase()) + .collect(), + None => BTreeSet::from(["https".to_string()]), + }; + if schemes.is_empty() { + return Err(invalid("domain :schemes must not be empty")); + } + if schemes + .iter() + .any(|scheme| scheme != "http" && scheme != "https") + { + return Err(invalid( + "domain :schemes supports only \"http\" and \"https\"", + )); + } + let ports = get(selector, "ports") + .map(|value| { + let values = require_seq(value, "domain :ports")?; + values + .iter() + .map(|value| { + value + .as_int() + .and_then(|port| u16::try_from(port).ok()) + .ok_or_else(|| { + invalid("domain :ports entries must be integers 0-65535") + }) + }) + .collect::, _>>() + }) + .transpose()?; + Ok(Self { + allow, + deny, + schemes, + ports, + }) + } + + fn check(&self, value: &serde_json::Value) -> Result<(), ConstraintMiss> { + let raw = value + .as_str() + .ok_or_else(|| ConstraintMiss::unevaluatable("URL argument must be a string"))?; + let url = Url::parse(raw) + .map_err(|_| ConstraintMiss::unevaluatable("URL argument is invalid"))?; + if !self.schemes.contains(url.scheme()) { + return Err(ConstraintMiss::unevaluatable( + "URL scheme is not allowlisted", + )); + } + if !url.username().is_empty() || url.password().is_some() { + return Err(ConstraintMiss::unevaluatable( + "URL credentials are not allowed", + )); + } + let host = match url.host() { + Some(Host::Domain(domain)) => domain.to_ascii_lowercase(), + Some(Host::Ipv4(ip)) => ip.to_string(), + Some(Host::Ipv6(ip)) => ip.to_string(), + None => return Err(ConstraintMiss::unevaluatable("URL must have a host")), + }; + if let Some(ports) = &self.ports { + let port = url + .port_or_known_default() + .ok_or_else(|| ConstraintMiss::unevaluatable("URL port cannot be resolved"))?; + if !ports.contains(&port) { + return Err(ConstraintMiss::unevaluatable("URL port is not allowlisted")); + } + } + if self.deny.iter().any(|pattern| pattern.matches(&host)) { + return Err(ConstraintMiss::no_match("URL host matches a deny rule")); + } + self.allow + .iter() + .any(|pattern| pattern.matches(&host)) + .then_some(()) + .ok_or_else(|| ConstraintMiss::no_match("URL host is not allowlisted")) + } +} + +#[derive(Debug)] +struct HostPattern { + host: String, + include_subdomains: bool, +} + +impl HostPattern { + fn parse(value: &str) -> Result { + if value.contains("://") || value.contains('/') || value.contains('@') { + return Err(invalid(format!( + "domain rule {value:?} must be a hostname, not a URL" + ))); + } + let (host, include_subdomains) = match value.strip_prefix("*.") { + Some(host) => (host, true), + None => (value, false), + }; + if host.is_empty() || host.contains('*') { + return Err(invalid(format!( + "domain rule {value:?} only supports a leading *. wildcard" + ))); + } + let host = match Host::parse(host) { + Ok(Host::Domain(domain)) => domain.to_ascii_lowercase(), + Ok(Host::Ipv4(ip)) => ip.to_string(), + Ok(Host::Ipv6(ip)) => ip.to_string(), + Err(_) => { + return Err(invalid(format!( + "domain rule {value:?} must contain only a valid hostname" + ))) + } + }; + Ok(Self { + host, + include_subdomains, + }) + } + + fn matches(&self, candidate: &str) -> bool { + if !self.include_subdomains { + return candidate == self.host; + } + candidate + .strip_suffix(&self.host) + .is_some_and(|prefix| prefix.ends_with('.') && prefix.len() > 1) + } +} + +#[derive(Debug)] +struct CommandConstraint { + allow: BTreeSet, + deny: BTreeSet, +} + +impl CommandConstraint { + fn compile(selector: &BTreeMap) -> Result { + let allow = parse_string_set(get(selector, "allow"), "command :allow")?; + let deny = parse_string_set(get(selector, "deny"), "command :deny")?; + if allow.is_empty() { + return Err(invalid( + "command constraint requires a nonempty :allow list", + )); + } + if allow + .iter() + .chain(deny.iter()) + .any(|command| command.contains('*') || command.contains('?') || command.contains('[')) + { + return Err(invalid( + "command rules are exact strings; wildcard syntax is not supported", + )); + } + Ok(Self { allow, deny }) + } + + fn check(&self, value: &serde_json::Value) -> Result<(), ConstraintMiss> { + let command = value + .as_str() + .ok_or_else(|| ConstraintMiss::unevaluatable("command argument must be a string"))?; + if self.deny.contains(command) { + return Err(ConstraintMiss::no_match( + "command matches an explicit deny rule", + )); + } + self.allow + .contains(command) + .then_some(()) + .ok_or_else(|| ConstraintMiss::no_match("command is not allowlisted")) + } +} + +fn compile_constraints( + value: &Value, + default_argument: &str, + kind: ConstraintKind, + tool: &str, +) -> Result, PolicyError> { + if let Some(map) = value.as_map_ref() { + return compile_selector(map, default_argument, kind, tool).map(|rule| vec![rule]); + } + let values = require_seq(value, &format!("tool {tool:?} {}", kind.label()))?; + if values.iter().all(|value| value.as_str().is_some()) { + let selector = shorthand_selector(&values); + return compile_selector(&selector, default_argument, kind, tool).map(|rule| vec![rule]); + } + values + .iter() + .map(|value| { + let map = require_map(value, &format!("tool {tool:?} {} selector", kind.label()))?; + compile_selector(map, default_argument, kind, tool) + }) + .collect() +} + +fn compile_selector( + selector: &BTreeMap, + default_argument: &str, + kind: ConstraintKind, + tool: &str, +) -> Result { + let allowed_keys: &[&str] = match kind { + ConstraintKind::Path | ConstraintKind::Command => &["arg", "allow", "deny"], + ConstraintKind::Domain => &["arg", "allow", "deny", "schemes", "ports"], + }; + reject_unknown_keys( + selector, + allowed_keys, + &format!("tool {tool:?} {} selector", kind.label()), + )?; + let argument = get(selector, "arg") + .map(|value| { + value_name(value).ok_or_else(|| { + invalid(format!( + "tool {tool:?} {} :arg must be a keyword or string", + kind.label() + )) + }) + }) + .transpose()? + .unwrap_or_else(|| default_argument.to_string()); + if argument.is_empty() { + return Err(invalid(format!( + "tool {tool:?} {} :arg must not be empty", + kind.label() + ))); + } + let kind = match kind { + ConstraintKind::Path => CompiledConstraint::Path(PathConstraint::compile(selector)?), + ConstraintKind::Domain => CompiledConstraint::Domain(DomainConstraint::compile(selector)?), + ConstraintKind::Command => { + CompiledConstraint::Command(CommandConstraint::compile(selector)?) + } + }; + Ok(ArgumentConstraint { argument, kind }) +} + +fn shorthand_selector(values: &[Value]) -> BTreeMap { + BTreeMap::from([(Value::keyword("allow"), Value::vector(values.to_vec()))]) +} + +fn compile_path_globs(patterns: &[String]) -> Result, PolicyError> { + patterns + .iter() + .map(|pattern| { + validate_path_pattern(pattern)?; + GlobBuilder::new(pattern) + .literal_separator(true) + .backslash_escape(false) + .build() + .map(|glob| glob.compile_matcher()) + .map_err(|error| invalid(format!("invalid path pattern {pattern:?}: {error}"))) + }) + .collect() +} + +fn validate_path_pattern(pattern: &str) -> Result<(), PolicyError> { + if pattern.is_empty() + || pattern.starts_with('/') + || pattern.contains('\\') + || pattern.contains(['[', ']', '{', '}', '?']) + { + return Err(invalid(format!( + "path pattern {pattern:?} must be a relative literal/*/** pattern" + ))); + } + for component in pattern.split('/') { + if component == ".." || (component.contains("**") && component != "**") { + return Err(invalid(format!( + "path pattern {pattern:?} has an invalid component {component:?}" + ))); + } + } + Ok(()) +} + +fn normalize_policy_path(workspace_root: &Path, input: &str) -> Result { + if input.is_empty() || input.contains('\0') { + return Err("path must be a nonempty string without NUL bytes".to_string()); + } + let input_path = Path::new(input); + if input_path.is_absolute() { + return Err("absolute paths are not allowed".to_string()); + } + + let root = absolute_lexical(workspace_root)?; + let joined = normalize_lexical(&root.join(input_path)); + if !joined.starts_with(&root) { + return Err("path escapes the workflow root".to_string()); + } + let canonical_root = canonical_or_lexical(&root); + let resolved = canonicalize_existing_prefix(&joined); + if !resolved.starts_with(&canonical_root) { + return Err("path resolves outside the workflow root".to_string()); + } + let relative = resolved + .strip_prefix(&canonical_root) + .map_err(|_| "path cannot be made root-relative".to_string())?; + Ok(relative + .components() + .filter_map(|component| match component { + Component::Normal(part) => Some(part.to_string_lossy().into_owned()), + _ => None, + }) + .collect::>() + .join("/")) +} + +fn absolute_lexical(path: &Path) -> Result { + if path.is_absolute() { + return Ok(normalize_lexical(path)); + } + // The workspace root should always be provided as an absolute path by the enforcement + // layer. Resolving a relative path with `current_dir()` introduces CWD-dependent + // non-determinism, so we canonicalize it instead of accepting process-global state. + std::fs::canonicalize(path).map_err(|error| format!("cannot resolve workspace root: {error}")) +} + +fn canonical_or_lexical(path: &Path) -> PathBuf { + std::fs::canonicalize(path).unwrap_or_else(|_| normalize_lexical(path)) +} + +fn canonicalize_existing_prefix(path: &Path) -> PathBuf { + let mut existing = path.to_path_buf(); + let mut suffix = Vec::new(); + while !existing.exists() { + let Some(name) = existing.file_name().map(|name| name.to_os_string()) else { + break; + }; + suffix.push(name); + if !existing.pop() { + break; + } + } + let mut resolved = canonical_or_lexical(&existing); + for component in suffix.into_iter().rev() { + resolved.push(component); + } + normalize_lexical(&resolved) +} + +fn normalize_lexical(path: &Path) -> PathBuf { + let mut result = PathBuf::new(); + for component in path.components() { + match component { + Component::ParentDir => { + result.pop(); + } + Component::CurDir => {} + other => result.push(other.as_os_str()), + } + } + result +} + +fn fingerprint(value: &Value, name: &str) -> String { + let json = sema_core::value_to_json_lossy(value); + let encoded = serde_json::to_vec(&json).unwrap_or_default(); + let mut hasher = Sha256::new(); + hasher.update(b"sema-policy-v1\0"); + hasher.update(name.as_bytes()); + hasher.update(b"\0"); + hasher.update(encoded); + format!("sha256:{:x}", hasher.finalize()) +} + +fn parse_default(value: Option<&Value>, context: &str) -> Result { + let Some(value) = value else { + return Ok(DefaultEffect::Deny); + }; + match value_name(value).as_deref() { + Some("deny") => Ok(DefaultEffect::Deny), + Some("allow") => Ok(DefaultEffect::Allow), + Some(other) => Err(invalid_with_hint( + format!("{context} :default has unsupported value :{other}"), + "valid values are :allow and :deny", + )), + None => Err(invalid(format!( + "{context} :default must be a keyword or string, got {}", + value.type_name() + ))), + } +} + +fn parse_model_patterns( + value: Option<&Value>, + context: &str, +) -> Result, PolicyError> { + parse_string_list(value, context)? + .into_iter() + .map(|value| ModelPattern::parse(&value)) + .collect() +} + +fn parse_string_set(value: Option<&Value>, context: &str) -> Result, PolicyError> { + parse_string_list(value, context).map(|values| values.into_iter().collect()) +} + +fn parse_name_set(value: Option<&Value>, context: &str) -> Result, PolicyError> { + let Some(value) = value else { + return Ok(BTreeSet::new()); + }; + require_seq(value, context)? + .iter() + .enumerate() + .map(|(index, value)| { + value_name(value).ok_or_else(|| { + invalid(format!( + "{context} entry {} must be a keyword or string, got {}", + index + 1, + value.type_name() + )) + }) + }) + .collect() +} + +fn parse_string_list(value: Option<&Value>, context: &str) -> Result, PolicyError> { + let Some(value) = value else { + return Ok(Vec::new()); + }; + require_seq(value, context)? + .iter() + .enumerate() + .map(|(index, value)| { + value.as_str().map(str::to_string).ok_or_else(|| { + invalid(format!( + "{context} entry {} must be a string, got {}", + index + 1, + value.type_name() + )) + }) + }) + .collect() +} + +fn require_seq(value: &Value, context: &str) -> Result, PolicyError> { + value.as_seq().map(|values| values.to_vec()).ok_or_else(|| { + invalid(format!( + "{context} must be a list or vector, got {}", + value.type_name() + )) + }) +} + +fn require_map<'a>( + value: &'a Value, + context: &str, +) -> Result<&'a BTreeMap, PolicyError> { + value.as_map_ref().ok_or_else(|| { + invalid(format!( + "{context} must be a map, got {}", + value.type_name() + )) + }) +} + +fn get<'a>(map: &'a BTreeMap, key: &str) -> Option<&'a Value> { + map.iter().find_map(|(candidate, value)| { + (value_name(candidate).as_deref() == Some(key)).then_some(value) + }) +} + +fn value_name(value: &Value) -> Option { + value + .as_str() + .map(str::to_string) + .or_else(|| value.as_keyword()) + .or_else(|| value.as_symbol()) +} + +fn reject_unknown_keys( + map: &BTreeMap, + allowed: &[&str], + context: &str, +) -> Result<(), PolicyError> { + let mut seen = BTreeSet::new(); + for key in map.keys() { + let key_name = value_name(key).ok_or_else(|| { + invalid(format!( + "{context} keys must be keywords, strings, or symbols, got {}", + key.type_name() + )) + })?; + if !allowed.contains(&key_name.as_str()) { + let hint = suggest_similar(&key_name, allowed) + .map(|candidate| format!("did you mean :{candidate}?")) + .unwrap_or_else(|| { + format!( + "valid keys are {}", + allowed + .iter() + .map(|key| format!(":{key}")) + .collect::>() + .join(", ") + ) + }); + return Err(invalid_with_hint( + format!("{context} has unknown key :{key_name}"), + hint, + )); + } + if !seen.insert(key_name.clone()) { + return Err(invalid(format!("{context} has duplicate key :{key_name}"))); + } + } + Ok(()) +} + +fn invalid(message: impl Into) -> PolicyError { + PolicyError { + message: message.into(), + hint: None, + } +} + +fn invalid_with_hint(message: impl Into, hint: impl Into) -> PolicyError { + PolicyError { + message: message.into(), + hint: Some(hint.into()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn map(entries: impl IntoIterator) -> Value { + Value::map( + entries + .into_iter() + .map(|(key, value)| (Value::keyword(key), value)) + .collect(), + ) + } + + fn strings(values: &[&str]) -> Value { + Value::vector(values.iter().map(|value| Value::string(value)).collect()) + } + + fn named_policy(entries: impl IntoIterator) -> Value { + let mut values: BTreeMap = entries + .into_iter() + .map(|(key, value)| (Value::keyword(key), value)) + .collect(); + values.insert(Value::keyword("__policy-name"), Value::symbol("safe")); + values.insert( + Value::keyword("__policy-version"), + Value::int(POLICY_VERSION), + ); + Value::map(values) + } + + #[test] + fn model_rules_match_resolved_pairs_and_provider_wildcard() { + let policy = CompiledPolicy::compile(&named_policy([( + "models", + map([ + ("allow", strings(&["openai/gpt-5", "ollama/*"])), + ("deny", strings(&["ollama/unsafe"])), + ("on-deny", Value::keyword("skip")), + ]), + )])) + .unwrap(); + + assert!(policy.check_model("openai", "gpt-5").allowed); + assert!(policy.check_model("ollama", "qwen").allowed); + assert!(!policy.check_model("ollama", "unsafe").allowed); + assert!(!policy.check_model("openai", "gpt-4").allowed); + assert_eq!(policy.model_action(), Some(ModelDenyAction::Skip)); + } + + #[test] + fn invalid_model_globs_are_rejected() { + let error = CompiledPolicy::compile(&named_policy([( + "models", + map([("allow", strings(&["*/gpt-5"]))]), + )])) + .unwrap_err(); + assert!(error.to_string().contains("cannot wildcard the provider")); + } + + #[test] + fn present_sections_default_to_deny_and_hard_fail() { + let policy = + CompiledPolicy::compile(&named_policy([("models", map([])), ("tools", map([]))])) + .unwrap(); + assert!(!policy.check_model("fake", "model").allowed); + assert_eq!(policy.model_action(), Some(ModelDenyAction::Fail)); + assert!( + !policy + .check_tool("read-file", &serde_json::json!({}), &[], Path::new(".")) + .allowed + ); + assert_eq!(policy.tool_action(), Some(ToolDenyAction::Fail)); + } + + #[test] + fn semantic_subject_rules_use_declared_argument_mappings() { + let root = std::env::temp_dir().join(format!("sema-policy-subject-{}", std::process::id())); + fs::create_dir_all(root.join("src")).unwrap(); + let policy = CompiledPolicy::compile(&named_policy([( + "subjects", + map([( + "allow", + Value::vector(vec![map([ + ("kind", Value::keyword("file-read")), + ("paths", strings(&["src/**"])), + ])]), + )]), + )])) + .unwrap(); + let subjects = [ToolPolicySubject::File { + access: FileAccess::Read, + path_arg: "location".to_string(), + }]; + + assert!( + policy + .check_tool( + "arbitrary-name", + &serde_json::json!({"location":"src/lib.rs"}), + &subjects, + &root, + ) + .allowed + ); + assert!( + !policy + .check_tool( + "arbitrary-name", + &serde_json::json!({"location":"Cargo.toml"}), + &subjects, + &root, + ) + .allowed + ); + assert!( + !policy + .check_tool( + "arbitrary-name", + &serde_json::json!({"location":"src/lib.rs"}), + &[], + &root, + ) + .allowed + ); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn input_detectors_compile_to_safe_aggregate_findings() { + let policy = CompiledPolicy::compile(&named_policy([( + "input", + map([ + ( + "detect", + Value::vector(vec![Value::keyword("secret"), Value::keyword("email")]), + ), + ( + "actions", + map([ + ("secret", Value::keyword("block")), + ("email", Value::keyword("redact")), + ]), + ), + ]), + )])) + .unwrap(); + let text = "send me@example.com and AKIAIOSFODNN7EXAMPLE"; + let outcome = policy.check_input(text); + + assert_eq!(outcome.action, ContentAction::Block); + assert!(outcome + .findings + .iter() + .all(|finding| !finding.label.contains('@'))); + assert_eq!( + content::redact(text, &outcome.redactions), + "send «redacted:email» and AKIAIOSFODNN7EXAMPLE" + ); + } + + #[test] + fn terminal_output_enforces_schema_required_length_and_patterns() { + let policy = CompiledPolicy::compile(&named_policy([( + "output", + map([ + ( + "schema", + map([ + ("answer", Value::keyword("string")), + ( + "citations", + map([ + ("type", Value::keyword("list")), + ("optional", Value::bool(true)), + ]), + ), + ]), + ), + ("require", Value::vector(vec![Value::keyword("citations")])), + ("max-length", Value::int(120)), + ( + "forbid", + Value::vector(vec![map([ + ("id", Value::keyword("absolute")), + ("contains", Value::string("guaranteed")), + ])]), + ), + ]), + )])) + .unwrap(); + + let round = policy.check_output("guaranteed", OutputStage::Round); + assert_eq!(round.action, ContentAction::Block); + let final_outcome = + policy.check_output(r#"{"answer":"ok","citations":[]}"#, OutputStage::Final); + assert_eq!(final_outcome.action, ContentAction::Block); + assert!(final_outcome + .findings + .iter() + .any(|finding| finding.rule_id == "output.require.citations")); + assert_eq!( + policy + .check_output( + r#"{"answer":"ok","citations":["source-1"]}"#, + OutputStage::Final, + ) + .action, + ContentAction::Allow + ); + } + + #[test] + fn metadata_and_completion_requirements_are_strict_and_stable() { + let policy = CompiledPolicy::compile(&named_policy([ + ( + "metadata", + map([("require", strings(&["owner", "risk-tier"]))]), + ), + ( + "completion", + map([( + "require-events", + strings(&["agent.tool_result", "approval.applied", "checkpoint"]), + )]), + ), + ])) + .unwrap(); + assert_eq!( + policy.required_metadata().collect::>(), + vec!["owner", "risk-tier"] + ); + assert_eq!( + policy.required_completion_events().collect::>(), + vec!["agent.tool_result", "approval.applied", "checkpoint"] + ); + + let error = CompiledPolicy::compile(&named_policy([( + "completion", + map([("require-events", strings(&["made.up"]))]), + )])) + .unwrap_err(); + assert!(error.to_string().contains("unsupported event")); + } + + #[test] + fn unknown_keys_are_rejected_instead_of_being_ignored() { + let error = CompiledPolicy::compile(&named_policy([( + "models", + map([("alow", strings(&["fake/*"]))]), + )])) + .unwrap_err(); + assert!(error.to_string().contains("unknown key :alow")); + assert_eq!(error.hint(), Some("did you mean :allow?")); + } + + #[test] + fn list_errors_include_one_based_index_and_actual_type() { + let error = CompiledPolicy::compile(&named_policy([( + "models", + map([( + "allow", + Value::vector(vec![Value::string("fake/*"), Value::int(42)]), + )]), + )])) + .unwrap_err(); + assert_eq!( + error.to_string(), + ":models :allow entry 2 must be a string, got int" + ); + } + + #[test] + fn enum_errors_use_keyword_notation_and_list_valid_values() { + let error = CompiledPolicy::compile(&named_policy([( + "tools", + map([("on-deny", Value::keyword("ignore"))]), + )])) + .unwrap_err(); + assert_eq!( + error.to_string(), + ":tools :on-deny has unsupported value :ignore" + ); + assert_eq!(error.hint(), Some("valid values are :fail and :tool-error")); + } + + #[test] + fn structural_string_and_symbol_keys_are_enforced() { + let models = Value::map(BTreeMap::from([ + (Value::string("default"), Value::keyword("deny")), + (Value::symbol("allow"), strings(&["openai/allowed-model"])), + ])); + let policy = Value::map(BTreeMap::from([(Value::string("models"), models)])); + let policy = CompiledPolicy::compile(&policy).unwrap(); + + assert!(policy.check_model("openai", "allowed-model").allowed); + assert!(!policy.check_model("openai", "blocked-model").allowed); + } + + #[test] + fn string_constraint_keys_do_not_create_unconstrained_tool_rules() { + let tool_rule = Value::map(BTreeMap::from([( + Value::string("paths"), + strings(&["safe/**"]), + )])); + let allow = Value::map(BTreeMap::from([(Value::string("read-file"), tool_rule)])); + let tools = Value::map(BTreeMap::from([ + (Value::string("default"), Value::keyword("deny")), + (Value::string("allow"), allow), + ])); + let policy = Value::map(BTreeMap::from([(Value::string("tools"), tools)])); + let policy = CompiledPolicy::compile(&policy).unwrap(); + + assert!( + !policy + .check_tool( + "read-file", + &serde_json::json!({"path":"../outside"}), + &[], + Path::new(".") + ) + .allowed + ); + } + + #[test] + fn subject_deny_rules_fail_closed_on_unevaluatable_arguments() { + // A deny rule must still deny an argument it cannot inspect. `:domains` defaults + // `:schemes` to ["https"], so an `http://` URL cannot be evaluated against the + // rule; treating that as "no match" let plain HTTP evade the deny list. + let subjects = Value::map(BTreeMap::from([ + (Value::string("default"), Value::keyword("allow")), + ( + Value::string("deny"), + Value::vector(vec![Value::map(BTreeMap::from([ + (Value::string("kind"), Value::keyword("network-request")), + (Value::string("domains"), strings(&["evil.example.com"])), + ]))]), + ), + ])); + let policy = CompiledPolicy::compile(&Value::map(BTreeMap::from([( + Value::string("subjects"), + subjects, + )]))) + .unwrap(); + let spec = [ToolPolicySubject::NetworkRequest { + method: None, + url_arg: "url".to_string(), + }]; + + for url in [ + "https://evil.example.com/x", + // Same host, scheme the rule cannot evaluate — must not become an allow. + "http://evil.example.com/x", + ] { + let check = policy.check_tool( + "fetch", + &serde_json::json!({ "url": url }), + &spec, + Path::new("."), + ); + assert!(!check.allowed, "{url} was allowed by a deny rule"); + } + + // A host the rule does not name is still allowed by `:default :allow`. + assert!( + policy + .check_tool( + "fetch", + &serde_json::json!({"url":"https://ok.example.com/x"}), + &spec, + Path::new(".") + ) + .allowed + ); + } + + #[test] + fn subject_deny_paths_fail_closed_outside_the_workspace() { + // `normalize_policy_path` errors for absolute and root-escaping paths, so a + // `:paths` deny rule could not evaluate them and let them through. + let subjects = Value::map(BTreeMap::from([ + (Value::string("default"), Value::keyword("allow")), + ( + Value::string("deny"), + Value::vector(vec![Value::map(BTreeMap::from([ + (Value::string("kind"), Value::keyword("file-write")), + (Value::string("paths"), strings(&["**"])), + ]))]), + ), + ])); + let policy = CompiledPolicy::compile(&Value::map(BTreeMap::from([( + Value::string("subjects"), + subjects, + )]))) + .unwrap(); + let spec = [ToolPolicySubject::File { + access: FileAccess::Write, + path_arg: "path".to_string(), + }]; + + for path in ["src/x.rs", "/tmp/evil.txt", "../evil.txt"] { + let check = policy.check_tool( + "write-file", + &serde_json::json!({ "path": path }), + &spec, + Path::new("."), + ); + assert!(!check.allowed, "{path} was allowed by a deny rule"); + } + } + + #[test] + fn duplicate_normalized_keys_and_tool_names_are_rejected() { + let duplicate_sections = Value::map(BTreeMap::from([ + (Value::keyword("models"), map([])), + (Value::string("models"), map([])), + ])); + assert!(CompiledPolicy::compile(&duplicate_sections) + .unwrap_err() + .to_string() + .contains("duplicate key")); + + let duplicate_tools = map([( + "tools", + map([( + "allow", + Value::map(BTreeMap::from([ + (Value::keyword("read-file"), map([])), + (Value::string("read-file"), map([])), + ])), + )]), + )]); + assert!(CompiledPolicy::compile(&duplicate_tools) + .unwrap_err() + .to_string() + .contains("duplicate tool name")); + } + + #[test] + fn tool_default_deny_and_explicit_deny_win() { + let policy = CompiledPolicy::compile(&named_policy([( + "tools", + map([ + ( + "allow", + Value::map(BTreeMap::from([(Value::string("read-file"), map([]))])), + ), + ("deny", strings(&["read-file"])), + ]), + )])) + .unwrap(); + assert!( + !policy + .check_tool("read-file", &serde_json::json!({}), &[], Path::new(".")) + .allowed + ); + assert!( + !policy + .check_tool("write-file", &serde_json::json!({}), &[], Path::new(".")) + .allowed + ); + } + + #[test] + fn shorthand_and_explicit_path_arguments_match() { + let root = std::env::temp_dir().join(format!("sema-policy-path-{}", std::process::id())); + fs::create_dir_all(root.join("src")).unwrap(); + let policy = CompiledPolicy::compile(&named_policy([( + "tools", + map([( + "allow", + Value::map(BTreeMap::from([ + ( + Value::string("read-file"), + map([("paths", strings(&["src/**"]))]), + ), + ( + Value::string("copy-file"), + map([( + "paths", + Value::vector(vec![ + map([ + ("arg", Value::keyword("source")), + ("allow", strings(&["src/**"])), + ]), + map([ + ("arg", Value::keyword("destination")), + ("allow", strings(&["tmp/**"])), + ]), + ]), + )]), + ), + ])), + )]), + )])) + .unwrap(); + + assert!( + policy + .check_tool( + "read-file", + &serde_json::json!({"path":"src/lib.rs"}), + &[], + &root + ) + .allowed + ); + assert!( + !policy + .check_tool( + "read-file", + &serde_json::json!({"path":"Cargo.toml"}), + &[], + &root + ) + .allowed + ); + assert!( + policy + .check_tool( + "copy-file", + &serde_json::json!({"source":"src/lib.rs","destination":"tmp/lib.rs"}), + &[], + &root + ) + .allowed + ); + assert!( + !policy + .check_tool( + "copy-file", + &serde_json::json!({"source":"src/lib.rs"}), + &[], + &root + ) + .allowed + ); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn path_traversal_and_absolute_paths_are_denied() { + let root = + std::env::temp_dir().join(format!("sema-policy-traversal-{}", std::process::id())); + fs::create_dir_all(root.join("src")).unwrap(); + let rule = PathConstraint { + allow: compile_path_globs(&["**".to_string()]).unwrap(), + deny: Vec::new(), + }; + assert!(rule.check(&serde_json::json!("../outside"), &root).is_err()); + assert!(rule + .check(&serde_json::json!("/tmp/outside"), &root) + .is_err()); + let _ = fs::remove_dir_all(root); + } + + #[cfg(unix)] + #[test] + fn unix_backslash_is_not_treated_as_a_path_separator() { + let root = + std::env::temp_dir().join(format!("sema-policy-backslash-{}", std::process::id())); + fs::create_dir_all(&root).unwrap(); + fs::write(root.join(r"safe\secret"), "not in the safe directory").unwrap(); + let rule = PathConstraint { + allow: compile_path_globs(&["safe/**".to_string()]).unwrap(), + deny: Vec::new(), + }; + + assert!(rule + .check(&serde_json::json!(r"safe\secret"), &root) + .is_err()); + let _ = fs::remove_dir_all(root); + } + + #[cfg(unix)] + #[test] + fn symlink_escape_is_denied() { + use std::os::unix::fs::symlink; + + let root = std::env::temp_dir().join(format!("sema-policy-symlink-{}", std::process::id())); + let outside = + std::env::temp_dir().join(format!("sema-policy-outside-{}", std::process::id())); + fs::create_dir_all(&root).unwrap(); + fs::create_dir_all(&outside).unwrap(); + symlink(&outside, root.join("escape")).unwrap(); + let rule = PathConstraint { + allow: compile_path_globs(&["**".to_string()]).unwrap(), + deny: Vec::new(), + }; + assert!(rule + .check(&serde_json::json!("escape/new.txt"), &root) + .is_err()); + let _ = fs::remove_dir_all(root); + let _ = fs::remove_dir_all(outside); + } + + #[test] + fn domains_normalize_idna_and_reject_credentials() { + let selector = BTreeMap::from([( + Value::keyword("allow"), + strings(&["münchen.de", "*.example.com"]), + )]); + let rule = DomainConstraint::compile(&selector).unwrap(); + assert!(rule + .check(&serde_json::json!("https://münchen.de/a/../b")) + .is_ok()); + assert!(rule + .check(&serde_json::json!("https://api.example.com/v1")) + .is_ok()); + assert!(rule + .check(&serde_json::json!("https://example.com/v1")) + .is_err()); + assert!(rule + .check(&serde_json::json!("http://api.example.com/v1")) + .is_err()); + assert!(rule + .check(&serde_json::json!("https://u:p@api.example.com/v1")) + .is_err()); + } + + #[test] + fn domain_rules_reject_url_components_that_would_be_discarded() { + for host in [ + "example.com:443", + "example.com?tenant=other", + "example.com#fragment", + ] { + let selector = BTreeMap::from([(Value::keyword("allow"), strings(&[host]))]); + let error = DomainConstraint::compile(&selector).unwrap_err(); + assert!( + error.to_string().contains("only a valid hostname"), + "unexpected error for {host:?}: {error}" + ); + } + } + + #[test] + fn commands_are_exact_and_wildcards_are_rejected() { + let selector = BTreeMap::from([( + Value::keyword("allow"), + strings(&["cargo test", "git diff"]), + )]); + let rule = CommandConstraint::compile(&selector).unwrap(); + assert!(rule.check(&serde_json::json!("cargo test")).is_ok()); + assert!(rule.check(&serde_json::json!("cargo test -p x")).is_err()); + + let wildcard = BTreeMap::from([(Value::keyword("allow"), strings(&["cargo test *"]))]); + assert!(CommandConstraint::compile(&wildcard).is_err()); + } + + #[test] + fn fingerprint_is_stable_and_includes_name() { + let first = CompiledPolicy::compile(&named_policy([( + "tools", + map([("default", Value::keyword("allow"))]), + )])) + .unwrap(); + let second = CompiledPolicy::compile(&named_policy([( + "tools", + map([("default", Value::keyword("allow"))]), + )])) + .unwrap(); + assert_eq!(first.fingerprint(), second.fingerprint()); + + let other = CompiledPolicy::compile(&map([( + "tools", + map([("default", Value::keyword("allow"))]), + )])) + .unwrap(); + assert_ne!(first.fingerprint(), other.fingerprint()); + } +} diff --git a/crates/sema-stdlib/Cargo.toml b/crates/sema-stdlib/Cargo.toml index 981066db1..07241b033 100644 --- a/crates/sema-stdlib/Cargo.toml +++ b/crates/sema-stdlib/Cargo.toml @@ -12,6 +12,7 @@ readme = "README.md" sema-core.workspace = true sema-reader.workspace = true sema-otel.workspace = true +sema-policy.workspace = true num-bigint = "0.4" num-integer = "0.1" regex.workspace = true diff --git a/crates/sema-stdlib/src/io.rs b/crates/sema-stdlib/src/io.rs index 39e0ef215..aadc41182 100644 --- a/crates/sema-stdlib/src/io.rs +++ b/crates/sema-stdlib/src/io.rs @@ -2635,7 +2635,7 @@ pub fn register(env: &sema_core::Env, sandbox: &sema_core::Sandbox) { check_arity!(args, "file/read", 1); let path = args[0] .as_str() - .ok_or_else(|| SemaError::type_error("string", args[0].type_name()))?; + .ok_or_else(|| SemaError::argument_type("file/read", 1, "string", &args[0]))?; if let Some(data) = sema_core::vfs::vfs_read(path) { return String::from_utf8(data) .map_err(|e| SemaError::Io(format!("file/read {path}: invalid UTF-8 in VFS: {e}"))) diff --git a/crates/sema-stdlib/src/json.rs b/crates/sema-stdlib/src/json.rs index 5058dc35d..1cf4e2681 100644 --- a/crates/sema-stdlib/src/json.rs +++ b/crates/sema-stdlib/src/json.rs @@ -26,7 +26,7 @@ pub fn register(env: &sema_core::Env) { register_fn(env, "json/decode", |args| { check_arity!(args, "json/decode", 1); let s = args[0].as_str().ok_or_else(|| { - SemaError::type_error("string", args[0].type_name()) + SemaError::argument_type("json/decode", 1, "string", &args[0]) .with_hint("json/decode: argument 1 must be a JSON-encoded string") })?; let json: serde_json::Value = serde_json::from_str(s).map_err(|e| { diff --git a/crates/sema-stdlib/src/list.rs b/crates/sema-stdlib/src/list.rs index 265883ddf..c40cafe14 100644 --- a/crates/sema-stdlib/src/list.rs +++ b/crates/sema-stdlib/src/list.rs @@ -2010,7 +2010,7 @@ pub fn register(env: &sema_core::Env) { } else { "nth: argument order is (nth collection index); the index must be an integer" }; - SemaError::type_error("int", args[1].type_name()).with_hint(hint) + SemaError::argument_type("nth", 2, "int", &args[1]).with_hint(hint) })?; if idx_i < 0 { return Err( @@ -2036,8 +2036,10 @@ pub fn register(env: &sema_core::Env) { )) }) } else { - Err(SemaError::type_error("list or vector", args[0].type_name()) - .with_hint("nth: argument 1 must be a list, vector, or mutable-array")) + Err( + SemaError::argument_type("nth", 1, "list, vector, or mutable-array", &args[0]) + .with_hint("nth: argument 1 must be a list, vector, or mutable-array"), + ) } }); diff --git a/crates/sema-stdlib/src/reflect.rs b/crates/sema-stdlib/src/reflect.rs index a639a48c2..7cad4d3d0 100644 --- a/crates/sema-stdlib/src/reflect.rs +++ b/crates/sema-stdlib/src/reflect.rs @@ -38,6 +38,7 @@ fn diagnostic(e: &SemaError) -> Value { SemaError::Unbound(_) => "unbound-symbol", SemaError::Arity { .. } => "arity", SemaError::Type { .. } => "type", + SemaError::Internal(_) => "internal", _ => "error", }; m.insert(kw("code"), Value::string(code)); diff --git a/crates/sema-stdlib/src/secret.rs b/crates/sema-stdlib/src/secret.rs index cf89252ba..e795c5d2c 100644 --- a/crates/sema-stdlib/src/secret.rs +++ b/crates/sema-stdlib/src/secret.rs @@ -25,10 +25,11 @@ //! shape. use std::collections::BTreeMap; -use std::sync::OnceLock; -use regex::Regex; use sema_core::{check_arity, SemaError, Value}; +#[cfg(not(target_arch = "wasm32"))] +use sema_policy::content::INPUT_BYTE_CAP; +use sema_policy::content::{detect_pii, detect_secrets, redact as redact_findings, Finding}; use sha2::{Digest, Sha256}; use crate::register_fn; @@ -42,7 +43,7 @@ use {crate::register_runtime_fn, sema_core::runtime::NativeOutcome}; /// ceiling (16 MiB) is tighter than `diff`'s — still far above any realistic /// credential-scan input. #[cfg(not(target_arch = "wasm32"))] -const SECRET_INPUT_BYTE_CAP: u64 = 16 * 1024 * 1024; +const SECRET_INPUT_BYTE_CAP: u64 = INPUT_BYTE_CAP as u64; #[cfg(not(target_arch = "wasm32"))] thread_local! { @@ -103,175 +104,10 @@ fn detect_pair_to_value(pair: (String, Vec)) -> Value { findings_to_list(&text, &findings) } -/// A single detected secret/PII finding. Offsets plus a `&'static str` kind, so -/// it is `Send` and can cross the offload thread boundary (no `Value`/`Env`). -struct Finding { - kind: &'static str, - start: usize, - end: usize, -} - -/// Shannon entropy in bits per character. Used to suppress low-entropy -/// (and therefore probably-not-secret) candidates for the generic and -/// high-entropy matchers. -fn shannon_entropy(s: &str) -> f64 { - if s.is_empty() { - return 0.0; - } - let mut counts: BTreeMap = BTreeMap::new(); - for c in s.chars() { - *counts.entry(c).or_insert(0) += 1; - } - let len = s.chars().count() as f64; - let mut entropy = 0.0; - for &count in counts.values() { - let p = count as f64 / len; - entropy -= p * p.log2(); - } - entropy -} - -/// Minimum bits/char of entropy for a generic or high-entropy candidate to be -/// treated as a real secret. -const ENTROPY_THRESHOLD: f64 = 3.5; - -fn aws_re() -> &'static Regex { - static RE: OnceLock = OnceLock::new(); - RE.get_or_init(|| Regex::new(r"AKIA[0-9A-Z]{16}").unwrap()) -} - -fn generic_re() -> &'static Regex { - static RE: OnceLock = OnceLock::new(); - // Capture group 2 is the secret value; the whole match (incl. the key and - // operator) is what we report/redact. - RE.get_or_init(|| { - Regex::new(r#"(?i)(api[_-]?key|secret|token|password)\s*[:=]\s*['"]?([A-Za-z0-9_\-]{16,})"#) - .unwrap() - }) -} - -fn private_key_re() -> &'static Regex { - static RE: OnceLock = OnceLock::new(); - RE.get_or_init(|| Regex::new(r"-----BEGIN [A-Z ]*PRIVATE KEY-----").unwrap()) -} - -fn jwt_re() -> &'static Regex { - static RE: OnceLock = OnceLock::new(); - RE.get_or_init(|| Regex::new(r"eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+").unwrap()) -} - -fn slack_re() -> &'static Regex { - static RE: OnceLock = OnceLock::new(); - RE.get_or_init(|| Regex::new(r"xox[baprs]-[A-Za-z0-9-]+").unwrap()) -} - -fn github_re() -> &'static Regex { - static RE: OnceLock = OnceLock::new(); - RE.get_or_init(|| Regex::new(r"gh[pousr]_[A-Za-z0-9]{36,}").unwrap()) -} - -fn high_entropy_re() -> &'static Regex { - static RE: OnceLock = OnceLock::new(); - // Long hex or base64-ish runs (>= 32 chars). Entropy gate applied after. - RE.get_or_init(|| Regex::new(r"[A-Za-z0-9+/=_\-]{32,}").unwrap()) -} - -fn email_re() -> &'static Regex { - static RE: OnceLock = OnceLock::new(); - RE.get_or_init(|| Regex::new(r"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}").unwrap()) -} - -fn ipv4_re() -> &'static Regex { - static RE: OnceLock = OnceLock::new(); - // Octet 0-255 with word boundaries so we don't grab digits mid-number. - RE.get_or_init(|| { - Regex::new( - r"\b(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])\b", - ) - .unwrap() - }) -} - -fn phone_re() -> &'static Regex { - static RE: OnceLock = OnceLock::new(); - // US-style: optional +1, optional separators, 3-3-4 grouping. - RE.get_or_init(|| { - Regex::new(r"(?:\+?1[\s.\-]?)?\(?\d{3}\)?[\s.\-]?\d{3}[\s.\-]?\d{4}").unwrap() - }) -} - -/// Push a finding only if it does not overlap an already-recorded one. Earlier -/// matchers (more specific patterns) win over the broad high-entropy matcher. -fn push_if_free(findings: &mut Vec, kind: &'static str, start: usize, end: usize) { - let overlaps = findings.iter().any(|f| start < f.end && f.start < end); - if !overlaps { - findings.push(Finding { kind, start, end }); - } -} - -/// Run all secret matchers over `text`, returning non-overlapping findings -/// sorted by start offset. Order of matchers matters: specific patterns are -/// recorded first so the generic / high-entropy matchers can't shadow them. -fn detect_secrets(text: &str) -> Vec { - let mut findings: Vec = Vec::new(); - - for m in aws_re().find_iter(text) { - push_if_free(&mut findings, "aws-access-key", m.start(), m.end()); - } - for m in private_key_re().find_iter(text) { - push_if_free(&mut findings, "private-key", m.start(), m.end()); - } - for m in jwt_re().find_iter(text) { - push_if_free(&mut findings, "jwt", m.start(), m.end()); - } - for m in slack_re().find_iter(text) { - push_if_free(&mut findings, "slack-token", m.start(), m.end()); - } - for m in github_re().find_iter(text) { - push_if_free(&mut findings, "github-token", m.start(), m.end()); - } - // Generic `key = value` assignments — gate the captured value on entropy. - for caps in generic_re().captures_iter(text) { - let whole = caps.get(0).unwrap(); - let value = caps.get(2).unwrap(); - if shannon_entropy(value.as_str()) >= ENTROPY_THRESHOLD { - push_if_free(&mut findings, "api-key", whole.start(), whole.end()); - } - } - // Bare high-entropy blobs (hex/base64) that none of the above caught. - for m in high_entropy_re().find_iter(text) { - if shannon_entropy(m.as_str()) >= ENTROPY_THRESHOLD { - push_if_free(&mut findings, "high-entropy", m.start(), m.end()); - } - } - - findings.sort_by_key(|f| f.start); - findings -} - -/// Run all PII matchers over `text`, returning non-overlapping findings sorted -/// by start offset. -fn detect_pii(text: &str) -> Vec { - let mut findings: Vec = Vec::new(); - - for m in email_re().find_iter(text) { - push_if_free(&mut findings, "email", m.start(), m.end()); - } - for m in ipv4_re().find_iter(text) { - push_if_free(&mut findings, "ipv4", m.start(), m.end()); - } - for m in phone_re().find_iter(text) { - push_if_free(&mut findings, "phone", m.start(), m.end()); - } - - findings.sort_by_key(|f| f.start); - findings -} - /// Build the `{:type :match :start :end}` result map for a finding. fn finding_to_map(text: &str, f: &Finding) -> Value { let mut m = BTreeMap::new(); - m.insert(Value::keyword("type"), Value::string(f.kind)); + m.insert(Value::keyword("type"), Value::string(f.label)); m.insert( Value::keyword("match"), Value::string(&text[f.start..f.end]), @@ -281,17 +117,6 @@ fn finding_to_map(text: &str, f: &Finding) -> Value { Value::map(m) } -/// Replace each finding's span with `«redacted:»`, working right-to-left -/// so byte offsets of not-yet-applied edits stay valid. -fn redact_findings(text: &str, findings: &[Finding]) -> String { - let mut out = text.to_string(); - for f in findings.iter().rev() { - let replacement = format!("\u{ab}redacted:{}\u{bb}", f.kind); - out.replace_range(f.start..f.end, &replacement); - } - out -} - /// Turn a scan's `(text, findings)` pair into the `[{:type :match :start /// :end} ...]` list `Value`. Shared by the sync and offloaded-async paths of /// `secret/detect` and `pii/detect` so both build the identical result. @@ -709,12 +534,6 @@ mod tests { ); } - #[test] - fn entropy_low_for_repetitive() { - assert!(shannon_entropy("aaaaaaaa") < 1.0); - assert!(shannon_entropy("a8Fk3Lm9Zq2Wx7Bv1Nc4Pd6") >= ENTROPY_THRESHOLD); - } - #[cfg(not(target_arch = "wasm32"))] #[test] fn secret_limit_accepts_boundary_and_rejects_one_over() { diff --git a/crates/sema-stdlib/src/string.rs b/crates/sema-stdlib/src/string.rs index 280552389..bbf40d3db 100644 --- a/crates/sema-stdlib/src/string.rs +++ b/crates/sema-stdlib/src/string.rs @@ -245,7 +245,7 @@ pub fn register(env: &sema_core::Env) { check_arity!(args, "string-length", 1); let s = args[0] .as_str() - .ok_or_else(|| SemaError::type_error("string", args[0].type_name()))?; + .ok_or_else(|| SemaError::argument_type("string-length", 1, "string", &args[0]))?; Ok(Value::int(s.chars().count() as i64)) }); @@ -253,10 +253,10 @@ pub fn register(env: &sema_core::Env) { check_arity!(args, "string-ref", 2); let s = args[0] .as_str() - .ok_or_else(|| SemaError::type_error("string", args[0].type_name()))?; + .ok_or_else(|| SemaError::argument_type("string-ref", 1, "string", &args[0]))?; let idx_signed = args[1] .as_int() - .ok_or_else(|| SemaError::type_error("int", args[1].type_name()))?; + .ok_or_else(|| SemaError::argument_type("string-ref", 2, "int", &args[1]))?; if idx_signed < 0 { return Err(SemaError::eval(format!( "string-ref: index {idx_signed} must be non-negative" @@ -276,10 +276,10 @@ pub fn register(env: &sema_core::Env) { check_arity!(args, "substring", 2..=3); let s = args[0] .as_str() - .ok_or_else(|| SemaError::type_error("string", args[0].type_name()))?; + .ok_or_else(|| SemaError::argument_type("substring", 1, "string", &args[0]))?; let start_signed = args[1] .as_int() - .ok_or_else(|| SemaError::type_error("int", args[1].type_name()))?; + .ok_or_else(|| SemaError::argument_type("substring", 2, "int", &args[1]))?; if start_signed < 0 { return Err(SemaError::eval(format!( "substring: start index {start_signed} must be non-negative" @@ -290,7 +290,7 @@ pub fn register(env: &sema_core::Env) { let end = if args.len() == 3 { let end_signed = args[2] .as_int() - .ok_or_else(|| SemaError::type_error("int", args[2].type_name()))?; + .ok_or_else(|| SemaError::argument_type("substring", 3, "int", &args[2]))?; if end_signed < 0 { return Err(SemaError::eval(format!( "substring: end index {end_signed} must be non-negative" @@ -320,10 +320,10 @@ pub fn register(env: &sema_core::Env) { check_arity!(args, "string/split", 2); let s = args[0] .as_str() - .ok_or_else(|| SemaError::type_error("string", args[0].type_name()))?; + .ok_or_else(|| SemaError::argument_type("string/split", 1, "string", &args[0]))?; let sep = args[1] .as_str() - .ok_or_else(|| SemaError::type_error("string", args[1].type_name()))?; + .ok_or_else(|| SemaError::argument_type("string/split", 2, "string", &args[1]))?; let parts: Vec = s.split(sep).map(Value::string).collect(); Ok(Value::list(parts)) }); @@ -334,7 +334,7 @@ pub fn register(env: &sema_core::Env) { check_arity!(args, "string/lines", 1); let s = args[0] .as_str() - .ok_or_else(|| SemaError::type_error("string", args[0].type_name()))?; + .ok_or_else(|| SemaError::argument_type("string/lines", 1, "string", &args[0]))?; Ok(Value::list(s.lines().map(Value::string).collect())) }); @@ -342,7 +342,7 @@ pub fn register(env: &sema_core::Env) { check_arity!(args, "string/trim", 1); let s = args[0] .as_str() - .ok_or_else(|| SemaError::type_error("string", args[0].type_name()))?; + .ok_or_else(|| SemaError::argument_type("string/trim", 1, "string", &args[0]))?; Ok(Value::string(s.trim())) }); @@ -350,21 +350,21 @@ pub fn register(env: &sema_core::Env) { check_arity!(args, "string/contains?", 2); let s = args[0] .as_str() - .ok_or_else(|| SemaError::type_error("string", args[0].type_name()))?; + .ok_or_else(|| SemaError::argument_type("string/contains?", 1, "string", &args[0]))?; let sub = args[1] .as_str() - .ok_or_else(|| SemaError::type_error("string", args[1].type_name()))?; + .ok_or_else(|| SemaError::argument_type("string/contains?", 2, "string", &args[1]))?; Ok(Value::bool(s.contains(sub))) }); register_fn(env, "string/starts-with?", |args| { check_arity!(args, "string/starts-with?", 2); - let s = args[0] - .as_str() - .ok_or_else(|| SemaError::type_error("string", args[0].type_name()))?; - let prefix = args[1] - .as_str() - .ok_or_else(|| SemaError::type_error("string", args[1].type_name()))?; + let s = args[0].as_str().ok_or_else(|| { + SemaError::argument_type("string/starts-with?", 1, "string", &args[0]) + })?; + let prefix = args[1].as_str().ok_or_else(|| { + SemaError::argument_type("string/starts-with?", 2, "string", &args[1]) + })?; Ok(Value::bool(s.starts_with(prefix))) }); @@ -372,10 +372,10 @@ pub fn register(env: &sema_core::Env) { check_arity!(args, "string/ends-with?", 2); let s = args[0] .as_str() - .ok_or_else(|| SemaError::type_error("string", args[0].type_name()))?; + .ok_or_else(|| SemaError::argument_type("string/ends-with?", 1, "string", &args[0]))?; let suffix = args[1] .as_str() - .ok_or_else(|| SemaError::type_error("string", args[1].type_name()))?; + .ok_or_else(|| SemaError::argument_type("string/ends-with?", 2, "string", &args[1]))?; Ok(Value::bool(s.ends_with(suffix))) }); @@ -383,7 +383,7 @@ pub fn register(env: &sema_core::Env) { check_arity!(args, "string/upper", 1); let s = args[0] .as_str() - .ok_or_else(|| SemaError::type_error("string", args[0].type_name()))?; + .ok_or_else(|| SemaError::argument_type("string/upper", 1, "string", &args[0]))?; Ok(Value::string_owned(s.to_uppercase())) }); @@ -391,7 +391,7 @@ pub fn register(env: &sema_core::Env) { check_arity!(args, "string/lower", 1); let s = args[0] .as_str() - .ok_or_else(|| SemaError::type_error("string", args[0].type_name()))?; + .ok_or_else(|| SemaError::argument_type("string/lower", 1, "string", &args[0]))?; Ok(Value::string_owned(s.to_lowercase())) }); diff --git a/crates/sema-stdlib/src/workflow.rs b/crates/sema-stdlib/src/workflow.rs index e436bb307..9fe64b9e2 100644 --- a/crates/sema-stdlib/src/workflow.rs +++ b/crates/sema-stdlib/src/workflow.rs @@ -22,15 +22,22 @@ use sema_core::cycle::GcEdge; use sema_core::runtime::{ - CancelDisposition, CancelHook, CancelHookError, CompletionDecoder, CompletionKind, - DecodedCompletion, ExternalFailure, InterruptibleResource, NativeCall, NativeCallContext, - NativeContinuation, NativeOutcome, NativeResult, NativeSuspend, PreparedExternalOperation, - ResumeInput, SendPayload, TaskContextHandle, Trace, WaitKind, + downcast_send_payload, CancelDisposition, CancelHook, CancelHookError, CompletionDecoder, + CompletionKind, DecodedCompletion, ExternalFailure, InterruptibleResource, NativeCall, + NativeCallContext, NativeContinuation, NativeOutcome, NativeResult, NativeSuspend, + PreparedExternalOperation, QuarantineBound, ResumeInput, SendPayload, TaskContextHandle, Trace, + WaitKind, }; -use sema_core::{SemaError, Value}; +use sema_core::{PolicyDenial, SemaError, Value, ValueViewRef}; +use sema_llm::builtins::{ + PolicyAttributionScope, PolicyBypassScope, PolicyDecisionSink, PolicyObservation, + PolicyObservationKind, PolicyScope, +}; +use sema_workflow::approval::{ApprovalRequest, ApprovalResolution, NewApprovalRequest}; use sema_workflow::context; use sema_workflow::event::WorkflowEvent; use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; use std::rc::Rc; use std::sync::mpsc::Receiver; use std::time::{Duration, Instant}; @@ -54,6 +61,776 @@ fn opt_str(v: &Value, key: &str) -> String { .unwrap_or_default() } +fn opt_value(v: &Value, key: &str) -> Option { + v.as_map_rc() + .and_then(|m| m.get(&Value::keyword(key)).cloned()) +} + +const APPROVAL_TEXT_MAX_CHARS: usize = 1024; +const APPROVAL_SUBJECT_MAX_BYTES: usize = 1 << 20; + +fn approval_request( + task_context: Option<&TaskContextHandle>, + args: &[Value], +) -> Result<(std::path::PathBuf, ApprovalRequest), SemaError> { + approval_request_inner(task_context, args).map_err(|error| fail_approval(task_context, error)) +} + +fn fail_approval(task_context: Option<&TaskContextHandle>, error: SemaError) -> SemaError { + let should_latch = !matches!( + error.inner(), + SemaError::WorkflowApprovalRequired { .. } | SemaError::WorkflowApprovalRejected { .. } + ); + let error = match error.inner() { + SemaError::WorkflowApprovalRequired { .. } + | SemaError::WorkflowApprovalRejected { .. } + | SemaError::WorkflowApprovalFailed { .. } => error, + _ => SemaError::WorkflowApprovalFailed { + message: error.to_string(), + }, + }; + if should_latch { + let message = match error.inner() { + SemaError::WorkflowApprovalFailed { message } => message.clone(), + _ => error.to_string(), + }; + if let Some(ctx) = context::current_for(task_context) { + ctx.fail_approval(message); + } + } + error +} + +fn approval_request_inner( + task_context: Option<&TaskContextHandle>, + args: &[Value], +) -> Result<(std::path::PathBuf, ApprovalRequest), SemaError> { + if args.len() != 2 { + return Err(SemaError::arity("workflow/approval", "2", args.len())); + } + let key = as_name(&args[0]).ok_or_else(|| { + SemaError::argument_type_with_value("workflow/approval", 1, "keyword or string", &args[0]) + })?; + let opts = args[1].as_map_rc().ok_or_else(|| { + SemaError::argument_type_with_value("workflow/approval", 2, "map", &args[1]) + })?; + let reason = opts + .get(&Value::keyword("reason")) + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| SemaError::eval("approval :reason must be a nonempty string"))? + .to_string(); + if reason.chars().count() > APPROVAL_TEXT_MAX_CHARS { + return Err(SemaError::eval(format!( + "approval :reason must not exceed {APPROVAL_TEXT_MAX_CHARS} characters" + ))); + } + let subject = opts + .get(&Value::keyword("subject")) + .ok_or_else(|| SemaError::eval("approval requires a :subject value"))?; + let subject_digest = canonical_approval_subject_digest(subject)?; + let preview = opts + .get(&Value::keyword("preview")) + .map(|value| { + value + .as_str() + .ok_or_else(|| SemaError::eval("approval :preview must be a string")) + .map(str::trim) + .map(str::to_string) + }) + .transpose()?; + if preview + .as_ref() + .is_some_and(|value| value.chars().count() > APPROVAL_TEXT_MAX_CHARS) + { + return Err(SemaError::eval(format!( + "approval :preview must not exceed {APPROVAL_TEXT_MAX_CHARS} characters" + ))); + } + let ctx = context::current_for(task_context) + .ok_or_else(|| SemaError::eval("approval outside a workflow/run"))?; + if !context::approval_scope_is_root_owner(task_context) { + return Err(SemaError::eval( + "approval must be a sequential gate in the owning workflow task; nested, spawned, and concurrent approval gates are not supported", + )); + } + let code_version = ctx.approval_code_version(); + if code_version.trim().is_empty() { + return Err(SemaError::eval( + "approval requires a stable workflow code version; run the file with `sema workflow run` or set SEMA_WORKFLOW_APPROVAL_CODE_VERSION", + )); + } + let authority_public_key = ctx.approval_public_key(); + if authority_public_key.trim().is_empty() { + return Err(SemaError::eval( + "approval requires a host-selected Ed25519 public key; use --approval-public-key-file for headless runs", + )); + } + let phase = ctx.cur_phase_label(); + let occurrence = ctx.approval_occurrence(&key, &subject_digest, &phase); + let request = ApprovalRequest::new(NewApprovalRequest { + run_id: ctx.run_id(), + workflow: ctx.workflow_name(), + code_version, + args_digest: ctx.approval_args_digest(), + phase, + key, + occurrence, + subject_digest, + reason, + preview, + requested_at: ctx.ts(), + authority_public_key, + }); + Ok((ctx.run_dir(), request)) +} + +fn canonical_approval_subject_digest(subject: &Value) -> Result { + let mut bytes = Vec::new(); + encode_approval_subject(subject, &mut bytes, 0)?; + Ok(sema_workflow::approval::sha256_bytes(&bytes)) +} + +fn append_subject_bytes(out: &mut Vec, bytes: &[u8]) -> Result<(), SemaError> { + let needed = 8usize.saturating_add(bytes.len()); + if out.len().saturating_add(needed) > APPROVAL_SUBJECT_MAX_BYTES { + return Err(SemaError::eval(format!( + "approval :subject canonical encoding must not exceed {APPROVAL_SUBJECT_MAX_BYTES} bytes" + ))); + } + out.extend_from_slice(&(bytes.len() as u64).to_le_bytes()); + out.extend_from_slice(bytes); + Ok(()) +} + +fn encode_approval_subject( + value: &Value, + out: &mut Vec, + depth: usize, +) -> Result<(), SemaError> { + if depth > 128 { + return Err(SemaError::eval( + "approval :subject nesting exceeds 128 levels", + )); + } + let tag = match value.view_ref() { + ValueViewRef::Nil => b"nil".as_slice(), + ValueViewRef::Bool(v) => { + append_subject_bytes(out, b"bool")?; + return append_subject_bytes(out, if v { b"1" } else { b"0" }); + } + ValueViewRef::Int(v) => { + append_subject_bytes(out, b"int")?; + return append_subject_bytes(out, v.to_string().as_bytes()); + } + ValueViewRef::BigInt(v) => { + append_subject_bytes(out, b"int")?; + return append_subject_bytes(out, v.to_string().as_bytes()); + } + ValueViewRef::Rational(v) => { + append_subject_bytes(out, b"rational")?; + return append_subject_bytes(out, v.to_string().as_bytes()); + } + ValueViewRef::Float(v) => { + append_subject_bytes(out, b"float")?; + return append_subject_bytes(out, &v.to_bits().to_le_bytes()); + } + ValueViewRef::String(v) => { + append_subject_bytes(out, b"string")?; + return append_subject_bytes(out, v.as_bytes()); + } + ValueViewRef::Symbol(_) => { + append_subject_bytes(out, b"symbol")?; + return append_subject_bytes(out, value.as_symbol().unwrap_or_default().as_bytes()); + } + ValueViewRef::Keyword(_) => { + append_subject_bytes(out, b"keyword")?; + return append_subject_bytes(out, value.as_keyword().unwrap_or_default().as_bytes()); + } + ValueViewRef::Char(v) => { + append_subject_bytes(out, b"char")?; + return append_subject_bytes(out, &(v as u32).to_le_bytes()); + } + ValueViewRef::List(items) | ValueViewRef::Vector(items) => { + let tag: &[u8] = if value.is_list() { b"list" } else { b"vector" }; + append_subject_bytes(out, tag)?; + append_subject_bytes(out, &(items.len() as u64).to_le_bytes())?; + for item in items { + encode_approval_subject(item, out, depth + 1)?; + } + return Ok(()); + } + ValueViewRef::Map(map) => { + let mut entries = Vec::with_capacity(map.len()); + let mut encoded_bytes = 0usize; + for (key, item) in map { + let mut encoded = Vec::new(); + encode_approval_subject(key, &mut encoded, depth + 1)?; + encode_approval_subject(item, &mut encoded, depth + 1)?; + encoded_bytes = encoded_bytes + .checked_add(encoded.len()) + .filter(|total| *total <= APPROVAL_SUBJECT_MAX_BYTES) + .ok_or_else(|| { + SemaError::eval(format!( + "approval :subject canonical encoding must not exceed {APPROVAL_SUBJECT_MAX_BYTES} bytes" + )) + })?; + entries.push(encoded); + } + entries.sort(); + append_subject_bytes(out, b"map")?; + append_subject_bytes(out, &(entries.len() as u64).to_le_bytes())?; + for entry in entries { + append_subject_bytes(out, &entry)?; + } + return Ok(()); + } + ValueViewRef::HashMap(map) => { + let mut entries = Vec::with_capacity(map.len()); + let mut encoded_bytes = 0usize; + for (key, item) in map { + let mut encoded = Vec::new(); + encode_approval_subject(key, &mut encoded, depth + 1)?; + encode_approval_subject(item, &mut encoded, depth + 1)?; + encoded_bytes = encoded_bytes + .checked_add(encoded.len()) + .filter(|total| *total <= APPROVAL_SUBJECT_MAX_BYTES) + .ok_or_else(|| { + SemaError::eval(format!( + "approval :subject canonical encoding must not exceed {APPROVAL_SUBJECT_MAX_BYTES} bytes" + )) + })?; + entries.push(encoded); + } + entries.sort(); + append_subject_bytes(out, b"hashmap")?; + append_subject_bytes(out, &(entries.len() as u64).to_le_bytes())?; + for entry in entries { + append_subject_bytes(out, &entry)?; + } + return Ok(()); + } + ValueViewRef::Bytevector(bytes) => { + append_subject_bytes(out, b"bytevector")?; + return append_subject_bytes(out, bytes); + } + ValueViewRef::F64Array(values) => { + append_subject_bytes(out, b"f64-array")?; + append_subject_bytes(out, &(values.len() as u64).to_le_bytes())?; + for value in values { + append_subject_bytes(out, &value.to_bits().to_le_bytes())?; + } + return Ok(()); + } + ValueViewRef::I64Array(values) => { + append_subject_bytes(out, b"i64-array")?; + append_subject_bytes(out, &(values.len() as u64).to_le_bytes())?; + for value in values { + append_subject_bytes(out, &value.to_le_bytes())?; + } + return Ok(()); + } + _ => { + return Err(SemaError::eval(format!( + "approval :subject must be immutable canonical data, got {}", + value.type_name() + ))); + } + }; + append_subject_bytes(out, tag) +} + +fn approval_resolution_value(resolution: ApprovalResolution) -> Value { + let mut map = BTreeMap::new(); + match resolution { + ApprovalResolution::Pending(request) => { + map.insert(Value::keyword("status"), Value::keyword("pending")); + insert_approval_request_fields(&mut map, &request); + } + ApprovalResolution::Approved(request, decision) => { + map.insert(Value::keyword("status"), Value::keyword("approved")); + insert_approval_request_fields(&mut map, &request); + insert_approval_decision_fields(&mut map, &decision); + } + ApprovalResolution::Rejected(request, decision) => { + map.insert(Value::keyword("status"), Value::keyword("rejected")); + insert_approval_request_fields(&mut map, &request); + insert_approval_decision_fields(&mut map, &decision); + } + } + Value::map(map) +} + +fn insert_approval_request_fields(map: &mut BTreeMap, request: &ApprovalRequest) { + map.insert( + Value::keyword("approval-id"), + Value::string(&request.approval_id), + ); + map.insert( + Value::keyword("request-digest"), + Value::string(&request.request_digest), + ); + map.insert(Value::keyword("key"), Value::string(&request.key)); + map.insert(Value::keyword("reason"), Value::string(&request.reason)); + map.insert( + Value::keyword("subject-digest"), + Value::string(&request.subject_digest), + ); + if let Some(preview) = &request.preview { + map.insert(Value::keyword("preview"), Value::string(preview)); + } +} + +fn insert_approval_decision_fields( + map: &mut BTreeMap, + decision: &sema_workflow::approval::ApprovalDecision, +) { + map.insert( + Value::keyword("decision-id"), + Value::string(&decision.decision_id), + ); + map.insert(Value::keyword("actor"), Value::string(&decision.actor)); + map.insert( + Value::keyword("provenance"), + Value::string(&decision.provenance), + ); + if let Some(reason) = &decision.reason { + map.insert(Value::keyword("decision-reason"), Value::string(reason)); + } +} + +fn approval_field(map: &BTreeMap, key: &str) -> String { + map.get(&Value::keyword(key)) + .and_then(|value| value.as_str()) + .unwrap_or_default() + .to_string() +} + +fn apply_approval_resolution( + task_context: Option<&TaskContextHandle>, + value: Value, +) -> Result { + let map = value + .as_map_rc() + .ok_or_else(|| SemaError::internal("approval worker returned a non-map result"))?; + let status = map + .get(&Value::keyword("status")) + .and_then(|value| value.as_keyword()) + .unwrap_or_default(); + let approval_id = approval_field(&map, "approval-id"); + let ctx = context::current_for(task_context) + .ok_or_else(|| SemaError::eval("approval outside a workflow/run"))?; + match status.as_str() { + "pending" => { + ctx.emit(WorkflowEvent::ApprovalRequested { + seq: ctx.next_seq(), + ts: ctx.ts(), + phase_seq: ctx.phase_seq(), + approval_id: approval_id.clone(), + request_digest: approval_field(&map, "request-digest"), + key: approval_field(&map, "key"), + reason: approval_field(&map, "reason"), + subject_digest: approval_field(&map, "subject-digest"), + preview: map + .get(&Value::keyword("preview")) + .and_then(|value| value.as_str()) + .map(str::to_string), + }); + Err(SemaError::WorkflowApprovalRequired { approval_id }) + } + "approved" => { + let decision_id = approval_field(&map, "decision-id"); + ctx.emit(WorkflowEvent::ApprovalGranted { + seq: ctx.next_seq(), + ts: ctx.ts(), + phase_seq: ctx.phase_seq(), + approval_id: approval_id.clone(), + decision_id: decision_id.clone(), + actor: approval_field(&map, "actor"), + provenance: approval_field(&map, "provenance"), + }); + ctx.emit(WorkflowEvent::ApprovalApplied { + seq: ctx.next_seq(), + ts: ctx.ts(), + phase_seq: ctx.phase_seq(), + approval_id, + decision_id, + }); + Ok(Value::bool(true)) + } + "rejected" => { + let reason = map + .get(&Value::keyword("decision-reason")) + .and_then(|value| value.as_str()) + .map(str::to_string); + ctx.emit(WorkflowEvent::ApprovalRejected { + seq: ctx.next_seq(), + ts: ctx.ts(), + phase_seq: ctx.phase_seq(), + approval_id: approval_id.clone(), + decision_id: approval_field(&map, "decision-id"), + actor: approval_field(&map, "actor"), + provenance: approval_field(&map, "provenance"), + reason: reason.clone(), + }); + Err(SemaError::WorkflowApprovalRejected { + approval_id, + reason, + }) + } + _ => Err(SemaError::internal(format!( + "approval worker returned unknown status {status:?}" + ))), + } +} + +const APPROVAL_COMPLETION_KIND: u64 = 0x7761_7070; // "wapp" + +struct ApprovalDecoder; + +impl Trace for ApprovalDecoder { + fn trace(&self, _sink: &mut dyn FnMut(GcEdge<'_>)) -> bool { + true + } +} + +impl CompletionDecoder for ApprovalDecoder { + fn decode( + self: Box, + _context: &mut NativeCallContext<'_>, + result: Result, + ) -> DecodedCompletion { + match result { + Ok(payload) => match downcast_send_payload::>( + payload, + "workflow/approval", + ) { + Ok(Ok(resolution)) => Ok(approval_resolution_value(resolution)), + Ok(Err(message)) => Err(SemaError::WorkflowApprovalFailed { message }), + Err(failure) => Err(SemaError::WorkflowApprovalFailed { + message: failure.message().to_string(), + }), + }, + Err(failure) => Err(SemaError::WorkflowApprovalFailed { + message: format!("workflow/approval: {}", failure.message()), + }), + } + } +} + +struct ApprovalContinuation; + +impl Trace for ApprovalContinuation { + fn trace(&self, _sink: &mut dyn FnMut(GcEdge<'_>)) -> bool { + true + } +} + +impl NativeContinuation for ApprovalContinuation { + fn resume( + self: Box, + context: &mut NativeCallContext<'_>, + input: ResumeInput, + ) -> NativeResult { + match input { + ResumeInput::Returned(value) => { + let task_context = context.task_context.clone(); + apply_approval_resolution(Some(&task_context), value) + .map_err(|error| fail_approval(Some(&task_context), error)) + .map(NativeOutcome::Return) + } + ResumeInput::Failed(error) => Err(fail_approval(Some(&context.task_context), error)), + ResumeInput::Cancelled(reason) => Err(fail_approval( + Some(&context.task_context), + SemaError::eval(format!("workflow/approval was cancelled ({reason:?})")), + )), + ResumeInput::Runtime(_) => Err(fail_approval( + Some(&context.task_context), + SemaError::internal("workflow/approval received an unexpected runtime response"), + )), + } + } +} + +fn approval_suspend(run_dir: PathBuf, request: ApprovalRequest) -> NativeResult { + let kind = CompletionKind::try_from_raw(APPROVAL_COMPLETION_KIND) + .expect("approval completion kind is nonzero"); + let bound = QuarantineBound::hard_deadline(Duration::from_secs(5)) + .expect("approval store cleanup deadline is nonzero"); + let prepared = PreparedExternalOperation::quarantined_blocking( + kind, + Box::new(ApprovalDecoder), + bound, + move || { + let result = sema_workflow::approval::ensure_request(&run_dir, &request) + .map_err(|error| error.to_string()); + Ok(Box::new(result) as SendPayload) + }, + ); + Ok(NativeOutcome::Suspend(NativeSuspend { + wait: WaitKind::External(Box::new(prepared)), + continuation: Box::new(ApprovalContinuation), + })) +} + +fn compile_policy( + container: &Value, +) -> Result>>, SemaError> { + opt_value(container, "policy") + .map(|policy| { + let values = if policy.as_map_rc().is_some() { + vec![policy] + } else { + let policies = policy.as_seq().ok_or_else(|| { + SemaError::eval(format!( + "invalid workflow policy: :policy must be a map or nonempty sequence of maps, got {}", + policy.type_name() + )) + })?; + if policies.is_empty() { + return Err(SemaError::eval( + "invalid workflow policy: :policy sequence must not be empty", + )); + } + policies.to_vec() + }; + + values + .iter() + .enumerate() + .map(|(index, value)| { + sema_policy::CompiledPolicy::compile(value) + .map(Rc::new) + .map_err(|error| { + let hint = error.hint().map(str::to_string); + let error = SemaError::eval(format!( + "invalid workflow policy layer {}: {error}", + index + 1 + )); + if let Some(hint) = hint { + error.with_hint(hint) + } else { + error + } + }) + }) + .collect() + }) + .transpose() +} + +fn value_is_present(value: &Value) -> bool { + if value.is_nil() { + return false; + } + if let Some(text) = value.as_str() { + return !text.trim().is_empty(); + } + if let Some(items) = value.as_seq() { + return !items.is_empty(); + } + if let Some(map) = value.as_map_rc() { + return !map.is_empty(); + } + true +} + +fn validate_required_metadata( + policies: Option<&[Rc]>, + meta: &Value, +) -> Result<(), SemaError> { + let map = meta.as_map_rc(); + for policy in policies.into_iter().flatten() { + let missing = policy + .required_metadata() + .filter(|key| { + map.as_ref() + .and_then(|metadata| metadata.get(&Value::keyword(key))) + .is_none_or(|value| !value_is_present(value)) + }) + .collect::>(); + if missing.is_empty() { + continue; + } + let missing = missing.into_iter().collect::>(); + return Err(SemaError::policy_denied(PolicyDenial { + policy: Some(policy.name().to_string()), + boundary: "workflow.metadata".to_string(), + subject: "workflow".to_string(), + rule: format!("metadata.missing.{}", missing.join(",")), + reason: format!( + "required workflow metadata is missing: {}", + missing + .iter() + .map(|key| format!(":{key}")) + .collect::>() + .join(", ") + ), + action: "fail".to_string(), + source: "request".to_string(), + })); + } + Ok(()) +} + +fn required_completion_events(policies: Option<&[Rc]>) -> Vec { + policies + .into_iter() + .flatten() + .flat_map(|policy| policy.required_completion_events()) + .map(str::to_string) + .collect::>() + .into_iter() + .collect() +} + +fn validate_step_policy_sections( + policies: Option<&[Rc]>, +) -> Result<(), SemaError> { + for policy in policies.into_iter().flatten() { + let has_metadata = policy.required_metadata().next().is_some(); + let has_completion = policy.required_completion_events().next().is_some(); + if has_metadata || has_completion { + let sections = match (has_metadata, has_completion) { + (true, true) => ":metadata and :completion", + (true, false) => ":metadata", + (false, true) => ":completion", + (false, false) => unreachable!("checked above"), + }; + return Err(SemaError::eval(format!( + "workflow/step: policy {:?} uses {sections}; evidence requirements must be attached to the enclosing workflow", + policy.name() + ))); + } + } + Ok(()) +} + +fn policy_sink(ctx: &Rc) -> PolicyDecisionSink { + let weak = Rc::downgrade(ctx); + Rc::new(move |observation| { + let Some(ctx) = weak.upgrade() else { + return; + }; + emit_policy_observation(&ctx, observation); + }) +} + +fn emit_policy_observation(ctx: &context::WorkflowCtx, observation: PolicyObservation) { + let PolicyObservation { + kind, + policy, + policy_digest, + boundary, + subject, + subject_digest, + rule, + label, + count, + action, + reason, + source, + agent_id, + } = observation; + let seq = ctx.next_seq(); + let ts = ctx.ts(); + let phase_seq = ctx.phase_seq(); + let boundary = boundary.as_str().to_string(); + let source = source.as_str().to_string(); + + let event = match kind { + PolicyObservationKind::Checked => WorkflowEvent::PolicyChecked { + seq, + ts, + phase_seq, + agent_id, + policy, + policy_digest, + boundary, + subject, + subject_digest, + rule, + source, + }, + PolicyObservationKind::Flagged => WorkflowEvent::PolicyFlagged { + seq, + ts, + phase_seq, + agent_id, + policy, + policy_digest, + boundary, + subject, + subject_digest, + rule, + label: label.unwrap_or_else(|| "finding".to_string()), + count: count.unwrap_or(1), + action: action.unwrap_or_else(|| "audit".to_string()), + source, + }, + PolicyObservationKind::Redacted => WorkflowEvent::PolicyRedacted { + seq, + ts, + phase_seq, + agent_id, + policy, + policy_digest, + boundary, + subject, + subject_digest, + rule, + label: label.unwrap_or_else(|| "finding".to_string()), + count: count.unwrap_or(1), + source, + }, + PolicyObservationKind::Violation => WorkflowEvent::PolicyViolation { + seq, + ts, + phase_seq, + agent_id, + policy, + policy_digest, + boundary, + subject, + subject_digest, + rule, + action: action.unwrap_or_else(|| "fail".to_string()), + reason: reason.unwrap_or_else(|| "denied".to_string()), + source, + }, + PolicyObservationKind::Bypassed => WorkflowEvent::PolicyBypassed { + seq, + ts, + phase_seq, + agent_id, + policy, + policy_digest, + boundary, + subject, + subject_digest, + rule, + reason: reason.unwrap_or_else(|| "unspecified".to_string()), + source, + }, + }; + ctx.emit(event); +} + +fn open_compiled_policy( + policies: Option>>, + ctx: &Rc, + workspace_root: &Path, +) -> Option { + policies.map(|policies| { + sema_llm::builtins::open_policy_scopes( + policies, + workspace_root.to_path_buf(), + policy_sink(ctx), + ) + }) +} + /// The workflow's declared phase plan from `defworkflow` meta `:phases` (a list or /// vector of names — keyword OR string items, via `as_name`). Empty when absent. Lets /// the dashboard show ALL phases up front instead of only those that have started. @@ -93,18 +870,13 @@ fn cap_text(s: &str) -> String { } } -/// Max bytes of a value's compact form the journal renders inline before truncating. -/// Golden values are tiny (far below this), so [`capped_render`] returns `pretty_print` -/// verbatim for them and the goldens stay byte-identical; only a pathologically large -/// value is truncated — and it is NEVER materialized in full (the compact form is -/// bounded-checked via `context::compact_capped`, which aborts at the cap). +/// Maximum size of a compact journal value before truncation. The bounded +/// renderer does not materialize an over-limit value in full. const RENDERED_VALUE_MAX_BYTES: usize = 8192; /// Render a value for the journal so the dashboard can show the real data, byte-budgeted -/// so one huge value can't materialize a multi-MB string on the VM thread. A value that -/// fits renders exactly as before (`pretty_print(v, 100)`) — keeping goldens -/// byte-identical; an over-cap value is rendered from its bounded compact prefix + a -/// truncation marker. +/// so one huge value cannot materialize a multi-MB string on the VM thread. Small values +/// use `pretty_print`; larger values use a bounded compact prefix and a truncation marker. fn capped_render(v: &Value) -> String { let (compact, truncated) = sema_workflow::context::compact_capped(v, RENDERED_VALUE_MAX_BYTES); if truncated { @@ -133,6 +905,13 @@ fn success_envelope(value: Value) -> Value { Value::map(m) } +fn envelope_status(envelope: &Value) -> Option { + envelope + .as_map_rc()? + .get(&Value::keyword("status")) + .and_then(as_name) +} + /// Close the currently-open marker phase, if any, emitting its `phase.ended` with the /// given status. No-op when no phase is open (a workflow with no `(phase …)` markers, /// or after the last phase already closed). Called both by the `(phase …)` marker (to @@ -156,6 +935,24 @@ fn failed_envelope(msg: &str) -> Value { Value::map(m) } +fn needs_approval_envelope(approval_id: &str) -> Value { + Value::map(BTreeMap::from([ + (Value::keyword("status"), Value::keyword("needs-approval")), + (Value::keyword("approval-id"), Value::string(approval_id)), + ])) +} + +fn rejected_approval_envelope(approval_id: &str, reason: Option<&str>) -> Value { + let mut map = BTreeMap::from([ + (Value::keyword("status"), Value::keyword("rejected")), + (Value::keyword("approval-id"), Value::string(approval_id)), + ]); + if let Some(reason) = reason { + map.insert(Value::keyword("reason"), Value::string(reason)); + } + Value::map(map) +} + /// The envelope for a run that a budget cap stopped. Distinct `:reason` (not `:error`) /// because the body itself did not error — the runtime aborted it. fn budget_failed_envelope() -> Value { @@ -413,6 +1210,8 @@ struct StepTeardown { content_key: String, start: Instant, usage_scope: sema_llm::builtins::UsageScope, + _policy_scope: Option, + _attribution_scope: PolicyAttributionScope, } /// Journal a `workflow/step` leaf's result: emit `agent.result`, attribute usage via a @@ -475,8 +1274,49 @@ fn finish_step( result.map(NativeOutcome::Return) } -/// Pre-thunk work for `workflow/step` — see the original inline documentation preserved -/// in `finish_step` and the event emissions below. +struct PolicyBypassTeardown { + _bypass_scope: PolicyBypassScope, +} + +fn policy_bypass_plan( + task_context: Option<&TaskContextHandle>, + args: &[Value], +) -> Result, SemaError> { + if args.len() != 2 { + return Err(SemaError::arity("workflow/policy-without", "2", args.len())); + } + if context::current_for(task_context).is_none() || !sema_llm::builtins::policy_active() { + return Err(SemaError::eval( + "policy/without requires an active workflow policy", + )); + } + let reason = args[0] + .as_str() + .ok_or_else(|| SemaError::argument_type("policy/without", 1, "string", &args[0]))? + .trim() + .to_string(); + if reason.is_empty() || reason.chars().count() > 256 { + return Err(SemaError::eval( + "policy/without reason must contain 1 to 256 characters", + )); + } + Ok(ThunkPlan::Run { + thunk: args[1].clone(), + teardown: PolicyBypassTeardown { + _bypass_scope: sema_llm::builtins::open_policy_bypass(reason), + }, + }) +} + +fn finish_policy_bypass( + _task_context: Option<&TaskContextHandle>, + _teardown: PolicyBypassTeardown, + result: Result, + _durable: bool, +) -> NativeResult { + result.map(NativeOutcome::Return) +} + fn step_plan( task_context: Option<&TaskContextHandle>, args: &[Value], @@ -489,6 +1329,11 @@ fn step_plan( let label = agent_role(&args[0]); let thunk = args[1].clone(); let Some(ctx) = context::current_for(task_context) else { + if opt_value(&args[0], "policy").is_some() { + return Err(SemaError::eval( + "workflow/step: :policy requires an enclosing workflow/run", + )); + } // Outside a run: transparent — just call the thunk (still cooperatively, so an // async op inside it works), with no journaling teardown. return Ok(ThunkPlan::Run { @@ -496,6 +1341,11 @@ fn step_plan( teardown: None, }); }; + let step_policy = compile_policy(&args[0])?; + validate_step_policy_sections(step_policy.as_deref())?; + let workspace_root = std::env::current_dir() + .map_err(|error| SemaError::eval(format!("workflow/step: current directory: {error}")))?; + let policy_scope = open_compiled_policy(step_policy, &ctx, &workspace_root); // Resume short-circuit FIRST (before the budget latch): a memoized leaf replays for // FREE. This MUST precede the budget check: a replay makes no provider call, so a // tripped cap must not refuse it. The key is computed on EVERY leaf so its occurrence @@ -513,6 +1363,7 @@ fn step_plan( &opt_str(&args[0], "__schema-repr"), &label, &ctx.cur_phase_label(), + &sema_llm::builtins::effective_policy_fingerprint(), ); if ctx.resuming() { if let Some(v) = ctx.memo_lookup(&content_key) { @@ -525,6 +1376,7 @@ fn step_plan( } // Unique per-invocation id (the dashboard correlates started→result→budget by it). let agent_id = ctx.next_agent_id(&label); + let attribution_scope = sema_llm::builtins::open_policy_attribution(agent_id.clone()); ctx.emit(WorkflowEvent::AgentStarted { seq: ctx.next_seq(), ts: ctx.ts(), @@ -550,6 +1402,8 @@ fn step_plan( content_key, start, usage_scope, + _policy_scope: policy_scope, + _attribution_scope: attribution_scope, }), }) } @@ -621,16 +1475,10 @@ fn finish_checkpoint( Ok(NativeOutcome::Return(value)) } -/// Post-thunk teardown state for `workflow/run`. Holds the scope guard (whose Drop -/// removes the exact scope token, LAST — after `run.ended` + `result.json`) and, until -/// closed exactly once, the resolver + open MCP handles (the handles are `Value`s — -/// traced). struct RunTeardown { - // A pure RAII drop guard: never read by name (a type with a manual `Drop` cannot be - // destructured), it exists solely so its own `Drop` removes the exact scope token - // whenever the `RunTeardown` is dropped — on `finish_run`, or via the backstop. - #[allow(dead_code)] - guard: context::WorkflowGuard, + _guard: context::WorkflowGuard, + _policy_scope: Option, + required_events: Vec, mcp: Option, } @@ -673,32 +1521,90 @@ impl Drop for RunTeardown { fn finish_run( task_context: Option<&TaskContextHandle>, mut teardown: RunTeardown, - result: Result, + mut result: Result, durable: bool, ) -> NativeResult { + if result.is_ok() { + if let Some(message) = + context::current_for(task_context).and_then(|ctx| ctx.approval_failure()) + { + result = Err(SemaError::WorkflowApprovalFailed { message }); + } + } + let approval_control = result + .as_ref() + .err() + .filter(|error| error.is_uncatchable()) + .cloned(); + let propagate_to_outer = + approval_control.is_some() && context::scope_depth_for(task_context) > 1; let (mut status, mut envelope, mut reason) = match &result { - Ok(v) => ("success", success_envelope(v.clone()), None), - Err(e) => ( - "failed", - failed_envelope(&e.to_string()), - Some("workflow body returned an error".to_string()), - ), + Ok(v) => { + let envelope = success_envelope(v.clone()); + let status = envelope_status(&envelope).unwrap_or_else(|| "success".to_string()); + (status, envelope, None) + } + Err(e) => match e.inner() { + SemaError::WorkflowApprovalRequired { approval_id } => ( + "needs-approval".to_string(), + needs_approval_envelope(approval_id), + Some("human approval required".to_string()), + ), + SemaError::WorkflowApprovalRejected { + approval_id, + reason, + } => ( + "rejected".to_string(), + rejected_approval_envelope(approval_id, reason.as_deref()), + Some("human approval rejected".to_string()), + ), + _ => ( + "failed".to_string(), + failed_envelope(&e.to_string()), + Some("workflow body returned an error".to_string()), + ), + }, }; // Close any resolved MCP handles exactly once, regardless of how the body exited. teardown.close_mcp(); let ctx = context::current_for(task_context); let ack = if let Some(ctx) = &ctx { + if matches!(status.as_str(), "needs-approval" | "rejected") { + let mut map = envelope + .as_map_rc() + .map(|map| (*map).clone()) + .unwrap_or_default(); + map.insert(Value::keyword("run-id"), Value::string(&ctx.run_id())); + envelope = Value::map(map); + } // A tripped budget cap fails the run regardless of the body's own outcome. if ctx.over_budget() { - status = "failed"; + status = "failed".to_string(); envelope = budget_failed_envelope(); reason = Some("budget exceeded".to_string()); } - close_open_phase(ctx, status); + if status == "success" { + let missing = teardown + .required_events + .iter() + .filter(|event| !ctx.has_event(event)) + .cloned() + .collect::>(); + if !missing.is_empty() { + status = "failed".to_string(); + let message = format!( + "completion policy missing required events: {}", + missing.join(", ") + ); + envelope = failed_envelope(&message); + reason = Some(message); + } + } + close_open_phase(ctx, &status); ctx.emit(WorkflowEvent::RunEnded { seq: ctx.next_seq(), ts: ctx.ts(), - status: status.into(), + status, reason, dur_ms: ctx.dur_ms(), }); @@ -712,6 +1618,9 @@ fn finish_run( // Dropping the teardown removes the exact scope token (its `guard`) and is a no-op // second MCP close. drop(teardown); + if propagate_to_outer { + return Err(approval_control.expect("checked above")); + } match ack { // Runtime (quantum) normal completion: park on the External flush-ack so the task // resumes — returning the envelope — only once the writer has flushed to disk. @@ -866,10 +1775,7 @@ impl CancelHook for FlushCancelHook { } } -/// Pre-thunk work for `workflow/run`: open the run scope, journal `run.started`, resolve -/// any declared `:mcp` servers (a pre-body gate that can end the run before the body ever -/// runs), and hand back the body thunk plus the teardown state. Mirrors the original -/// inline builtin; the post-body work moved to `finish_run`. +/// Open the run scope, journal `run.started`, and resolve declared MCP servers. fn run_plan( task_context: Option<&TaskContextHandle>, args: &[Value], @@ -886,6 +1792,14 @@ fn run_plan( let doc = args[1].as_str().unwrap_or("").to_string(); let meta = args[2].clone(); let thunk = args[3].clone(); + let policy = compile_policy(&meta)?; + validate_required_metadata(policy.as_deref(), &meta)?; + let workspace_root = match context::host_workspace_root() { + Some(root) => root, + None => std::env::current_dir().map_err(|error| { + SemaError::eval(format!("workflow/run: current directory: {error}")) + })?, + }; // Open the run scope: sets up the journal sink under ./.sema/runs//, installs // the thread-local WorkflowCtx, and returns a panic-safe Drop guard that reaps the @@ -909,10 +1823,6 @@ fn run_plan( }); } - // ── Implicit :mcp auth-resolution step, before the body thunk ───────── - // A workflow with no :mcp meta key parses to an empty Vec here (O(1) on the absent - // key), so every branch below is skipped and the body runs exactly as it did before - // this feature — byte-identical for the no-:mcp case. let decls = match workflow_mcp::declared_mcp(&meta) { Ok(d) => d, Err(e) => { @@ -930,12 +1840,19 @@ fn run_plan( } }; - // A workflow with no `:mcp` runs the body straight away — byte-identical to the - // pre-feature path. if decls.is_empty() { + let ctx = context::current_for(task_context) + .ok_or_else(|| SemaError::eval("workflow/run: scope not established"))?; + let required_events = required_completion_events(policy.as_deref()); + let policy_scope = open_compiled_policy(policy, &ctx, &workspace_root); return Ok(ThunkPlan::Run { thunk, - teardown: RunTeardown { guard, mcp: None }, + teardown: RunTeardown { + _guard: guard, + _policy_scope: policy_scope, + required_events, + mcp: None, + }, }); } @@ -974,13 +1891,23 @@ fn run_plan( guard, thunk, resolver, + policy, + workspace_root, }), })); } // Host arm (outside a runtime quantum): `io_block_on` is legal — resolve inline. let resolutions = resolver.resolve(&decls, &name, &run_id); - match apply_resolutions(task_context, guard, thunk, resolver, resolutions)? { + match apply_resolutions( + task_context, + guard, + thunk, + resolver, + resolutions, + policy, + workspace_root, + )? { ResolveGate::Exit { envelope, ack } => Ok(terminal_plan(task_context, envelope, ack)), ResolveGate::Proceed { thunk, teardown } => Ok(ThunkPlan::Run { thunk, teardown }), } @@ -1006,6 +1933,8 @@ fn apply_resolutions( thunk: Value, resolver: Rc, resolutions: Vec, + policy: Option>>, + workspace_root: PathBuf, ) -> Result { let ctx = context::current_for(task_context) .ok_or_else(|| SemaError::eval("workflow/run: scope not established"))?; @@ -1101,10 +2030,14 @@ fn apply_resolutions( // Every declared server connected: publish handles for workflow/mcp-handle, and // remember (resolver, handles) so `finish_run` closes them EXACTLY once. ctx.set_mcp_handles(connected); + let required_events = required_completion_events(policy.as_deref()); + let policy_scope = open_compiled_policy(policy, &ctx, &workspace_root); Ok(ResolveGate::Proceed { thunk, teardown: RunTeardown { - guard, + _guard: guard, + _policy_scope: policy_scope, + required_events, mcp: Some(McpClose { resolver, handles: connected_handles, @@ -1137,6 +2070,8 @@ struct ResolveContinuation { guard: context::WorkflowGuard, thunk: Value, resolver: Rc, + policy: Option>>, + workspace_root: PathBuf, } impl Trace for ResolveContinuation { @@ -1157,11 +2092,21 @@ impl NativeContinuation for ResolveContinuation { guard, thunk, resolver, + policy, + workspace_root, } = *self; match input { ResumeInput::Returned(value) => { let resolutions = workflow_mcp::decode_resolutions(&value); - match apply_resolutions(Some(&task_context), guard, thunk, resolver, resolutions)? { + match apply_resolutions( + Some(&task_context), + guard, + thunk, + resolver, + resolutions, + policy, + workspace_root, + )? { ResolveGate::Exit { envelope, ack } => Ok(NativeOutcome::Suspend( build_flush_ack_suspend(envelope, ack), )), @@ -1228,6 +2173,28 @@ pub fn register(env: &sema_core::Env) { trace_run_teardown, ); + // (workflow/approval key opts) — atomically create/read the host-owned request and + // decision sidecars. The runtime arm offloads filesystem I/O; pending/rejected + // outcomes raise uncatchable workflow control transfers consumed by workflow/run. + env.set( + sema_core::intern("workflow/approval"), + Value::native_fn(sema_core::NativeFn::simple_with_runtime( + "workflow/approval", + |args| { + let (run_dir, request) = approval_request(None, args)?; + let resolution = sema_workflow::approval::ensure_request(&run_dir, &request) + .map_err(|error| fail_approval(None, SemaError::Io(error.to_string())))?; + apply_approval_resolution(None, approval_resolution_value(resolution)) + .map_err(|error| fail_approval(None, error)) + }, + |context, args| { + let task_context = context.task_context.clone(); + let (run_dir, request) = approval_request(Some(&task_context), args)?; + approval_suspend(run_dir, request) + }, + )), + ); + // (workflow/phase label) — a MARKER (workflow.js semantics), not a wrapper. Closes // the previously-open phase (emitting its phase.ended) then opens `label`. The // checkpoints/agents that follow attribute to this phase until the next marker or @@ -1275,6 +2242,14 @@ pub fn register(env: &sema_core::Env) { }, ); + register_thunk_fn( + env, + "workflow/policy-without", + policy_bypass_plan, + finish_policy_bypass, + |_teardown, _sink| {}, + ); + // (workflow/tool-call tool-name [args]) — journal a tool call by the current // agent (the dashboard renders these as tool twigs in the agent's drill-in). // No-op (returns nil) outside a workflow/step. `args` is an opaque/gated @@ -1309,6 +2284,28 @@ pub fn register(env: &sema_core::Env) { Ok(Value::nil()) }); + // Successful tool completion evidence. The event deliberately carries only + // a gated sentinel, never the callback's result preview. + register_scoped_fn(env, "workflow/tool-result", |task_context, args| { + if args.len() != 1 { + return Err(SemaError::arity("workflow/tool-result", "1", args.len())); + } + let tool_name = as_name(&args[0]) + .ok_or_else(|| SemaError::type_error("keyword or string", args[0].type_name()))?; + if let Some(ctx) = context::current_for(task_context) { + if let Some(agent_id) = context::cur_agent_for(task_context) { + ctx.emit(WorkflowEvent::AgentToolResult { + seq: ctx.next_seq(), + ts: ctx.ts(), + agent_id, + tool_name, + result_digest: "gated".to_string(), + }); + } + } + Ok(Value::nil()) + }); + // (workflow/checkpoint :k thunk) records+returns (thunk) and emits a checkpoint // event; (workflow/checkpoint :k) reads the stored value (nil if unset). The // public (checkpoint :k v) macro delays v into the thunk, so a resume memo hit can @@ -1469,4 +2466,43 @@ mod continuation_tests { assert!(checkpoint.trace(&mut |_| edges += 1)); assert_eq!(edges, 0, "checkpoint teardown must expose no Value edges"); } + + #[test] + fn approval_subject_encoding_is_type_preserving_and_rejects_mutability() { + let string = canonical_approval_subject_digest(&Value::string("x")).unwrap(); + let keyword = canonical_approval_subject_digest(&Value::keyword("x")).unwrap(); + let symbol = canonical_approval_subject_digest(&Value::symbol("x")).unwrap(); + assert_ne!(string, keyword); + assert_ne!(keyword, symbol); + + let mutable = Value::mutable_cell(Value::int(1)); + let error = canonical_approval_subject_digest(&mutable).unwrap_err(); + assert!(error.to_string().contains("immutable canonical data")); + } + + #[test] + fn approval_subject_hashmap_digest_is_insertion_order_independent() { + let first = Value::hashmap(vec![ + (Value::string("a"), Value::int(1)), + (Value::string("b"), Value::int(2)), + ]); + let second = Value::hashmap(vec![ + (Value::string("b"), Value::int(2)), + (Value::string("a"), Value::int(1)), + ]); + assert_eq!( + canonical_approval_subject_digest(&first).unwrap(), + canonical_approval_subject_digest(&second).unwrap() + ); + } + + #[test] + fn approval_infrastructure_failures_are_uncatchable() { + let error = fail_approval(None, SemaError::eval("approval store unavailable")); + assert!(error.is_uncatchable()); + assert!(matches!( + error.inner(), + SemaError::WorkflowApprovalFailed { .. } + )); + } } diff --git a/crates/sema-stdlib/src/workflow_check.rs b/crates/sema-stdlib/src/workflow_check.rs index f5bc20572..5adfeab0a 100644 --- a/crates/sema-stdlib/src/workflow_check.rs +++ b/crates/sema-stdlib/src/workflow_check.rs @@ -5,7 +5,7 @@ //! it is instant, side-effect-free, and safe to run on untrusted source. It exists to give //! a workflow author (often a coding agent) a fast feedback loop that catches the traps the //! runtime only surfaces at eval time — chiefly the `(phase "x" body…)` arity trap, since -//! `phase` is a one-argument marker. +//! `phase` is a one-argument marker, plus malformed literal workflow policies. //! //! Design (kept deliberately simple): one recursive visitor carries an `in_workflow` flag. //! Marker checks (`phase`/`checkpoint`/`step`/`parallel`/`pipeline`) fire ONLY inside a @@ -108,7 +108,108 @@ pub fn check_source(src: &str) -> Vec { diags.push(Diag::error(span, "E-PARSE", message)); } for form in &forms { - find_workflows(form, &spans, &mut diags); + check_first_class_approval(form, &spans, &mut diags); + find_workflows_and_policies(form, &spans, &mut diags); + } + diags +} + +/// `workflow/approval` is a control-flow operation, not a value. A direct call +/// is valid; aliasing, storing, or passing the native function would allow a +/// detached task to reach the gate after its owner has continued. +fn check_first_class_approval(form: &Value, spans: &SpanMap, out: &mut Vec) { + if matches!( + form.as_symbol().as_deref(), + Some("workflow/approval" | "approval") + ) { + out.push( + Diag::error( + span_of(form, spans), + "E-APPROVAL-VALUE", + format!( + "{} cannot be used as a value", + form.as_symbol().as_deref().unwrap_or("approval") + ), + ) + .with_hint("call (approval key opts) directly in the owning workflow task"), + ); + return; + } + if let Some(items) = form.as_seq() { + let head = items.first().and_then(name_of_symbol); + if matches!(head.as_deref(), Some("quote" | "quasiquote")) { + return; + } + let is_approval_head = matches!(head.as_deref(), Some("approval" | "workflow/approval")); + let children = if is_approval_head { &items[1..] } else { items }; + for child in children { + if matches!( + child.as_symbol().as_deref(), + Some("workflow/approval" | "approval") + ) { + out.push( + Diag::error( + span_of(child, spans), + "E-APPROVAL-VALUE", + format!( + "{} cannot be used as a value", + child.as_symbol().as_deref().unwrap_or("approval") + ), + ) + .with_hint("call (approval key opts) directly in the owning workflow task"), + ); + } else { + check_first_class_approval(child, spans, out); + } + } + return; + } + if let Some(map) = form.as_map_ref() { + for (key, value) in map { + check_first_class_approval(key, spans, out); + check_first_class_approval(value, spans, out); + } + } +} + +/// Workflow-run contract: exactly one top-level `defworkflow`, and it must be the +/// final executable form. This makes the host-observed envelope authoritative; later +/// top-level forms cannot replace `:needs-approval` with an unrelated value. +pub fn check_run_source(src: &str) -> Vec { + let mut diags = check_source(src); + let (forms, spans, _symbol_spans, parse_errors) = + sema_reader::read_many_with_spans_recover(src); + if !parse_errors.is_empty() { + return diags; + } + let workflows = forms + .iter() + .filter(|form| list_head(form, "defworkflow").is_some()) + .collect::>(); + if workflows.len() != 1 { + diags.push(Diag::error( + forms.last().and_then(|form| span_of(form, &spans)), + "E-WF-ENTRY", + format!( + "workflow run requires exactly one top-level defworkflow, found {}", + workflows.len() + ), + )); + } + if forms + .last() + .is_none_or(|form| list_head(form, "defworkflow").is_none()) + { + diags.push( + Diag::error( + forms.last().and_then(|form| span_of(form, &spans)), + "E-WF-FINAL", + "defworkflow must be the final top-level form", + ) + .with_hint( + "move helper definitions/imports before defworkflow and remove forms after it", + ), + ); } diags } @@ -167,16 +268,60 @@ fn permission_spec_string(key: &str, value: &Value) -> Result { .ok_or_else(|| format!("defworkflow {key} must be a sandbox string")) } -/// Walk the top-level forms looking for `(defworkflow …)` (which may be nested inside a -/// `(do …)` or similar), and check each one. Non-workflow code is left untouched. -fn find_workflows(form: &Value, spans: &SpanMap, out: &mut Vec) { +/// Walk executable forms looking for `defworkflow`/`defpolicy` declarations (which may +/// be nested inside a `(do …)` or similar). Quoted data is left untouched. +fn find_workflows_and_policies(form: &Value, spans: &SpanMap, out: &mut Vec) { + if head_symbol(form).is_some_and(|(head, _)| head == "quote" || head == "quasiquote") { + return; + } if let Some(items) = list_head(form, "defworkflow") { check_workflow(&items, form, spans, out); return; } + if let Some(items) = list_head(form, "defpolicy") { + let span = span_of(form, spans); + if items.len() != 3 || items[1].as_symbol().is_none() { + out.push( + Diag::error( + span, + "E-POLICY-SHAPE", + "defpolicy needs a bare name and one literal policy map", + ) + .with_hint("(defpolicy safe {:models {...} :tools {...}})"), + ); + } else if let Some(policy) = items[2].as_map_ref() { + let _ = check_literal_policy(&Value::map(policy.clone()), span, out); + } else { + out.push(Diag::error( + span, + "E-POLICY-SHAPE", + "defpolicy rules must be a literal map", + )); + } + return; + } if let Some(seq) = form.as_seq() { for sub in seq { - find_workflows(sub, spans, out); + find_workflows_and_policies(sub, spans, out); + } + } +} + +fn check_literal_policy( + policy: &Value, + span: Option, + out: &mut Vec, +) -> Option { + match sema_policy::CompiledPolicy::compile(policy) { + Ok(policy) => Some(policy), + Err(error) => { + let hint = error.hint().map(str::to_string); + let diagnostic = Diag::error(span, "E-POLICY", format!("invalid policy: {error}")); + out.push(match hint { + Some(hint) => diagnostic.with_hint(hint), + None => diagnostic, + }); + None } } } @@ -293,11 +438,110 @@ fn check_workflow(items: &[Value], form: &Value, spans: &SpanMap, out: &mut Vec< // tolerance as (b)/(c) above; a computed :mcp value is left to the runtime). if let Some(meta) = &meta { check_mcp_decls(meta, wf_span, out); + if let Some(policy) = meta + .get(&Value::keyword("policy")) + .filter(|value| value.as_map_ref().is_some()) + { + let _ = check_literal_policy(policy, wf_span, out); + } } // Marker arity/opts checks across the whole body (including nested forms). for f in body { walk_markers(f, spans, out); + walk_approval_placement(f, spans, None, out); + } +} + +/// A gate may be nested in ordinary sequential control flow, but not in a construct +/// that owns cleanup, retries, a child task, or a nested workflow. Those constructs can +/// either skip cleanup on an uncatchable control transfer or let an outer task continue. +fn walk_approval_placement( + form: &Value, + spans: &SpanMap, + restricted_by: Option<&str>, + out: &mut Vec, +) { + if let Some((head, items)) = head_symbol(form) { + if head == "quote" || head == "quasiquote" { + return; + } + if head == "approval" || head == "workflow/approval" { + if let Some(owner) = restricted_by { + out.push( + Diag::error( + span_of(form, spans), + "E-APPROVAL-PLACEMENT", + format!("approval cannot be nested inside {owner}"), + ) + .with_hint("place the approval as a sequential gate before this construct"), + ); + } + return; + } + if head == "apply" + && items + .get(1) + .and_then(|v| v.as_symbol()) + .is_some_and(|s| s.as_str() == "approval" || s.as_str() == "workflow/approval") + { + if let Some(owner) = restricted_by { + out.push( + Diag::error( + span_of(form, spans), + "E-APPROVAL-PLACEMENT", + format!("approval cannot be nested inside {owner}"), + ) + .with_hint( + "place the approval as a sequential gate before this construct; do not apply it inside a restricted context", + ), + ); + } + return; + } + const RESTRICTED: &[&str] = &[ + "async", + "async/spawn", + "async/spawn-all", + "async/map", + "async/pool-map", + "async/race", + "async/race-owned", + "async/with-timeout", + "parallel", + "parallel-settled", + "pipeline", + "pipeline-settled", + "try", + "guard", + "retry", + "with-retry", + "with-timeout", + "with-open", + "with-stream", + "with-file", + "step", + "agent", + "workflow/run", + ]; + let restricted = + restricted_by.or_else(|| RESTRICTED.contains(&head.as_str()).then_some(head.as_str())); + for sub in items.iter().skip(1) { + walk_approval_placement(sub, spans, restricted, out); + } + return; + } + if let Some(seq) = form.as_vector_rc() { + for sub in seq.iter() { + walk_approval_placement(sub, spans, restricted_by, out); + } + return; + } + if let Some(map) = form.as_map_ref() { + for (key, value) in map { + walk_approval_placement(key, spans, restricted_by, out); + walk_approval_placement(value, spans, restricted_by, out); + } } } @@ -401,6 +645,9 @@ fn check_mcp_decls(meta: &BTreeMap, span: Option, out: &mut /// Recursively check marker arities/opts. Only reached from within a workflow body. fn walk_markers(form: &Value, spans: &SpanMap, out: &mut Vec) { if let Some((head, items)) = head_symbol(form) { + if head == "quote" || head == "quasiquote" { + return; + } let span = span_of(form, spans); match head.as_str() { // phase is a ONE-arg marker — the #1 trap. (phase "x" body) is an arity error. @@ -438,6 +685,63 @@ fn walk_markers(form: &Value, spans: &SpanMap, out: &mut Vec) { )); } } + "approval" | "workflow/approval" => { + if items.len() != 3 { + out.push(Diag::error( + span, + "E-APPROVAL-ARITY", + format!( + "approval takes a key and an options map, got {} arguments", + items.len() - 1 + ), + )); + } else { + if items[1].as_keyword().is_none() && items[1].as_str().is_none() { + out.push(Diag::warn( + span, + "W-APPROVAL-KEY", + "approval key should be a keyword or string", + )); + } + match items[2].as_map_ref() { + Some(opts) => { + if opts + .get(&Value::keyword("reason")) + .and_then(|value| value.as_str()) + .is_none_or(|reason| reason.trim().is_empty()) + { + out.push(Diag::error( + span, + "E-APPROVAL-REASON", + "approval :reason must be a nonempty string", + )); + } + if !opts.contains_key(&Value::keyword("subject")) { + out.push(Diag::error( + span, + "E-APPROVAL-SUBJECT", + "approval requires a :subject value", + )); + } + if opts + .get(&Value::keyword("preview")) + .is_some_and(|value| value.as_str().is_none()) + { + out.push(Diag::error( + span, + "E-APPROVAL-PREVIEW", + "approval :preview must be a string", + )); + } + } + None => out.push(Diag::error( + span, + "E-APPROVAL-OPTS", + "approval options must be a literal map", + )), + } + } + } // step needs at least a prompt; if opts are given, validate the map. "step" => { if items.len() < 2 { @@ -465,6 +769,28 @@ fn walk_markers(form: &Value, spans: &SpanMap, out: &mut Vec) { )); } } + if let Some(policy) = opts + .get(&Value::keyword("policy")) + .filter(|value| value.as_map_ref().is_some()) + { + if let Some(policy) = check_literal_policy(policy, span, out) { + let has_metadata = policy.required_metadata().next().is_some(); + let has_completion = + policy.required_completion_events().next().is_some(); + if has_metadata || has_completion { + out.push( + Diag::error( + span, + "E-STEP-POLICY-SCOPE", + "step policies cannot contain :metadata or :completion requirements", + ) + .with_hint( + "attach workflow evidence requirements to defworkflow :policy", + ), + ); + } + } + } // :agent runs a configured defagent and owns its own tools/model; the // step must not also declare inline :tools/:model (they'd be ignored — // the routing takes the :agent branch). Warn so the author picks one. @@ -487,6 +813,24 @@ fn walk_markers(form: &Value, spans: &SpanMap, out: &mut Vec) { } } } + "policy/without" => { + if items.len() < 3 { + out.push(Diag::error( + span, + "E-POLICY-BYPASS", + "policy/without needs a reason string and at least one body form", + )); + } else if items[1] + .as_str() + .is_none_or(|reason| reason.trim().is_empty() || reason.chars().count() > 256) + { + out.push(Diag::error( + span, + "E-POLICY-BYPASS", + "policy/without reason must be a non-empty string of at most 256 characters", + )); + } + } // parallel/pipeline are structural — at least one argument beyond the head. "parallel" | "pipeline" if items.len() < 2 => { out.push(Diag::warn( @@ -697,6 +1041,120 @@ mod tests { ); } + #[test] + fn approval_shape_is_checked() { + let c = codes( + r#"(defworkflow d "d" {} + (approval 42 {:reason "" :preview 1}) + {:status :ok})"#, + ); + assert!(c.contains(&"W-APPROVAL-KEY")); + assert!(c.contains(&"E-APPROVAL-REASON")); + assert!(c.contains(&"E-APPROVAL-SUBJECT")); + assert!(c.contains(&"E-APPROVAL-PREVIEW")); + } + + #[test] + fn workflow_run_requires_one_final_top_level_entrypoint() { + let trailing = + check_run_source(r#"(defworkflow d "d" {} {:status :success}) (file/write "x" "y")"#); + assert!(trailing.iter().any(|diag| diag.code == "E-WF-FINAL")); + + let multiple = check_run_source( + r#"(defworkflow a "a" {} {:status :success}) + (defworkflow b "b" {} {:status :success})"#, + ); + assert!(multiple.iter().any(|diag| diag.code == "E-WF-ENTRY")); + } + + #[test] + fn approval_rejects_concurrent_cleanup_and_nested_workflow_placement() { + let src = r#" + (defworkflow d "d" {} + (parallel (list (fn () + (approval :a {:reason "r" :subject 1})))) + (try (workflow/approval :b {:reason "r" :subject 2}) (catch e e)) + (workflow/run "nested" "n" {} (fn () + (approval :c {:reason "r" :subject 3}))) + (async (approval :d {:reason "r" :subject 4})) + (async/race-owned (list (fn () + (approval :e {:reason "r" :subject 5})))) + (async/with-timeout 10 (fn () + (approval :f {:reason "r" :subject 6}))) + {:status :success}) + "#; + assert_eq!( + codes(src) + .into_iter() + .filter(|code| *code == "E-APPROVAL-PLACEMENT") + .count(), + 6 + ); + } + + #[test] + fn approval_in_vectors_and_let_bindings_is_caught() { + let src = r#" + (defworkflow d "d" {} + (parallel [(approval :a {:reason "r" :subject {:kind :external-action :secret "x"}})]) + {:status :success}) + "#; + assert!(codes(src).contains(&"E-APPROVAL-PLACEMENT")); + + let let_src = r#" + (defworkflow d "d" {} + (parallel (let [x (approval :b {:reason "r" :subject {:kind :external-action :secret "y"}})] x)) + {:status :success}) + "#; + assert!(codes(let_src).contains(&"E-APPROVAL-PLACEMENT")); + + let apply_src = r#" + (defworkflow d "d" {} + (parallel (apply approval :a {:reason "r" :subject {:kind :external-action :secret "x"}})) + {:status :success}) + "#; + assert!(codes(apply_src).contains(&"E-APPROVAL-PLACEMENT")); + } + + #[test] + fn approval_short_name_alias_is_caught() { + let src = r#" + (define gate approval) + (defworkflow d "d" {} + {:status :success}) + "#; + assert!(codes(src).contains(&"E-APPROVAL-VALUE")); + } + + #[test] + fn approval_native_cannot_be_aliased_or_stored() { + let src = r#" + (define gate workflow/approval) + (defworkflow d "d" {} + (list workflow/approval) + {:status :success}) + "#; + assert_eq!( + codes(src) + .into_iter() + .filter(|code| *code == "E-APPROVAL-VALUE") + .count(), + 2 + ); + } + + #[test] + fn step_policy_rejects_workflow_evidence_sections() { + let src = r#" + (defworkflow d "d" {} + (step "inspect" + {:policy {:metadata {:require [:owner]} + :completion {:require-events [:checkpoint]}}}) + {:status :success}) + "#; + assert!(codes(src).contains(&"E-STEP-POLICY-SCOPE")); + } + #[test] fn bare_top_level_markers_outside_a_workflow_are_ignored() { // (phase …) with wrong arity in a plain library file is NOT a workflow — no diag. @@ -790,6 +1248,81 @@ mod tests { assert!(c.contains(&"W-STEP-AGENT-MODEL"), "got {c:?}"); } + #[test] + fn valid_policy_declarations_and_inline_policies_are_checked() { + let src = r#" + (defpolicy safe + {:models {:default :deny + :allow ["fake/fake-model"]} + :tools {:default :deny + :allow {"read-file" {:paths ["src/**"]}}}}) + (defworkflow d "d" + {:policy {:models {:default :deny :allow ["fake/*"]}}} + (phase "P") + (step "go" + {:policy {:tools {:default :deny + :allow {"read-file" {:paths ["src/**"]}}}}}) + {:status :ok}) + "#; + let c = codes(src); + assert!( + !c.iter().any(|code| code.starts_with("E-POLICY")), + "got {c:?}" + ); + } + + #[test] + fn malformed_policy_declarations_and_rules_error() { + let bad_shape = codes("(defpolicy \"safe\" [])"); + assert!(bad_shape.contains(&"E-POLICY-SHAPE"), "got {bad_shape:?}"); + + let bad_rule = codes( + r#"(defpolicy bad + {:models {:allow ["*/model"]}}) + (defworkflow d "d" {} {:status :ok})"#, + ); + assert!(bad_rule.contains(&"E-POLICY"), "got {bad_rule:?}"); + } + + #[test] + fn quoted_policy_forms_are_not_checked_as_declarations_or_bypasses() { + let c = codes( + r#" + '(defpolicy "not-a-name" {:models {:allow ["*/bad"]}}) + (defworkflow d "d" {} + '(policy/without "") + {:status :ok}) + "#, + ); + assert!( + !c.iter().any(|code| code.starts_with("E-POLICY")), + "quoted data is not executable syntax: {c:?}" + ); + } + + #[test] + fn policy_without_requires_a_bounded_literal_reason_and_body() { + let missing_body = codes( + r#"(defworkflow d "d" {} + (policy/without "maintenance") + {:status :ok})"#, + ); + assert!( + missing_body.contains(&"E-POLICY-BYPASS"), + "got {missing_body:?}" + ); + + let computed_reason = codes( + r#"(defworkflow d "d" {} + (policy/without reason (step "go")) + {:status :ok})"#, + ); + assert!( + computed_reason.contains(&"E-POLICY-BYPASS"), + "got {computed_reason:?}" + ); + } + #[test] fn empty_fanout_warns() { let c = codes(r#"(defworkflow d "d" {} (phase "P") (pipeline) {:status :ok})"#); diff --git a/crates/sema-vm/src/compiler.rs b/crates/sema-vm/src/compiler.rs index 9ab0d3c63..f9b8ed5f4 100644 --- a/crates/sema-vm/src/compiler.rs +++ b/crates/sema-vm/src/compiler.rs @@ -228,11 +228,13 @@ fn scan_global_rebinds(expr: &ResolvedExpr, f: &mut impl FnMut(Spur, bool)) { E::Deftool { description, parameters, + options, handler, .. } => { scan_global_rebinds(description, f); scan_global_rebinds(parameters, f); + scan_global_rebinds(options, f); scan_global_rebinds(handler, f); } E::Defagent { options, .. } => scan_global_rebinds(options, f), @@ -546,8 +548,9 @@ impl Compiler { name, description, parameters, + options, handler, - } => self.compile_deftool(*name, description, parameters, handler), + } => self.compile_deftool(*name, description, parameters, options, handler), ResolvedExpr::Defagent { name, options } => self.compile_defagent(*name, options), ResolvedExpr::Delay(expr) => self.compile_delay(expr), ResolvedExpr::Force(expr) => self.compile_force(expr), @@ -1491,15 +1494,17 @@ impl Compiler { name: Spur, description: &ResolvedExpr, parameters: &ResolvedExpr, + options: &ResolvedExpr, handler: &ResolvedExpr, ) -> Result<(), SemaError> { self.emit_load_global(intern("__vm-deftool"))?; self.emit.emit_const(Value::symbol_from_spur(name))?; self.compile_expr(description)?; self.compile_expr(parameters)?; + self.compile_expr(options)?; self.compile_expr(handler)?; self.emit.emit_op(Op::Call); - self.emit.emit_u16(4); + self.emit.emit_u16(5); Ok(()) } diff --git a/crates/sema-vm/src/core_expr.rs b/crates/sema-vm/src/core_expr.rs index e83a3b5ff..d0e5f2156 100644 --- a/crates/sema-vm/src/core_expr.rs +++ b/crates/sema-vm/src/core_expr.rs @@ -138,6 +138,7 @@ pub enum Expr { name: Spur, description: Box>, parameters: Box>, + options: Box>, handler: Box>, }, /// Agent definition (LLM) diff --git a/crates/sema-vm/src/lower.rs b/crates/sema-vm/src/lower.rs index 30e583340..4f2791b5b 100644 --- a/crates/sema-vm/src/lower.rs +++ b/crates/sema-vm/src/lower.rs @@ -112,7 +112,14 @@ fn lower_expr(expr: &Value, tail: bool) -> Result { fn lower_expr_inner(expr: &Value, tail: bool) -> Result { match expr.view() { - ValueView::Symbol(spur) => Ok(CoreExpr::Var(spur)), + ValueView::Symbol(spur) => { + if spur == intern("workflow/approval") { + return Err(SemaError::eval( + "workflow/approval is a control-flow gate and cannot be used as a value; call (approval key opts) directly", + )); + } + Ok(CoreExpr::Var(spur)) + } ValueView::Vector(items) => { let exprs = items @@ -156,6 +163,20 @@ fn lower_list(items: &[Value], tail: bool) -> Result { let args = &items[1..]; if let Some(spur) = head.as_symbol_spur() { + // `workflow/approval` transfers control to the host when no decision + // exists. It must stay in direct call position so it cannot run later + // in a detached task after the owning workflow has continued. + if spur == intern("workflow/approval") { + let call_args = args + .iter() + .map(|arg| lower_expr(arg, false)) + .collect::>()?; + return Ok(CoreExpr::Call { + func: Box::new(CoreExpr::Var(spur)), + args: call_args, + tail, + }); + } if let Some(form) = special_form_for(spur) { return match form { SpecialForm::Quote => lower_quote(args), @@ -1968,17 +1989,22 @@ fn lower_message(args: &[Value]) -> Result { } fn lower_deftool(args: &[Value]) -> Result { - if args.len() < 4 { - return Err(SemaError::arity("deftool", "4", args.len())); + if !matches!(args.len(), 4 | 5) { + return Err(SemaError::arity("deftool", "4 or 5", args.len())); } let name = require_symbol(&args[0], "deftool")?; let description = lower_expr(&args[1], false)?; let parameters = lower_expr(&args[2], false)?; - let handler = lower_expr(&args[3], false)?; + let (options, handler) = if args.len() == 5 { + (lower_expr(&args[3], false)?, lower_expr(&args[4], false)?) + } else { + (CoreExpr::Const(Value::nil()), lower_expr(&args[3], false)?) + }; Ok(CoreExpr::Deftool { name, description: Box::new(description), parameters: Box::new(parameters), + options: Box::new(options), handler: Box::new(handler), }) } @@ -2648,6 +2674,28 @@ mod tests { assert!(matches!(lower_str("(force p)"), CoreExpr::Call { .. })); } + #[test] + fn workflow_approval_is_only_valid_in_direct_call_position() { + assert!(matches!( + lower_str("(workflow/approval :ship {:reason \"r\" :subject 1})"), + CoreExpr::Call { .. } + )); + + for input in [ + "(define gate workflow/approval)", + "(list workflow/approval)", + "[workflow/approval]", + "{:gate workflow/approval}", + ] { + let value = parse(input); + let error = lower(&value, None).expect_err(input); + assert!( + error.to_string().contains("cannot be used as a value"), + "{error}" + ); + } + } + #[test] fn test_lower_defmacro() { // defmacro now lowers to a Call to __vm-defmacro-form with the full form as a constant @@ -2798,7 +2846,9 @@ mod tests { let err = lower(&vals[0], Some(&span_map)).expect_err("define with 3 args should fail"); // Message is unchanged (WithTrace displays its inner error). - assert!(err.to_string().contains("define expects 2 args, got 3")); + assert!(err + .to_string() + .contains("define expects 2 arguments, got 3")); let trace = err.stack_trace().expect("error should carry a stack trace"); let frame = trace.0.first().expect("trace should have a frame"); @@ -2814,7 +2864,9 @@ mod tests { let (vals, _span_map) = sema_reader::read_many_with_spans(input).unwrap(); let err = lower(&vals[0], None).expect_err("define with 3 args should fail"); assert!(err.stack_trace().is_none()); - assert!(err.to_string().contains("define expects 2 args, got 3")); + assert!(err + .to_string() + .contains("define expects 2 arguments, got 3")); } #[test] diff --git a/crates/sema-vm/src/optimize.rs b/crates/sema-vm/src/optimize.rs index 083fce861..7ffd73c46 100644 --- a/crates/sema-vm/src/optimize.rs +++ b/crates/sema-vm/src/optimize.rs @@ -320,11 +320,13 @@ fn optimize_inner(expr: CoreExpr, shadowed: &[String]) -> CoreExpr { name, description, parameters, + options, handler, } => CoreExpr::Deftool { name, description: Box::new(optimize_inner(*description, shadowed)), parameters: Box::new(optimize_inner(*parameters, shadowed)), + options: Box::new(optimize_inner(*options, shadowed)), handler: Box::new(optimize_inner(*handler, shadowed)), }, CoreExpr::Defagent { name, options } => CoreExpr::Defagent { diff --git a/crates/sema-vm/src/resolve.rs b/crates/sema-vm/src/resolve.rs index d63002e3e..011f53d0f 100644 --- a/crates/sema-vm/src/resolve.rs +++ b/crates/sema-vm/src/resolve.rs @@ -407,11 +407,13 @@ fn resolve_expr_inner(expr: &CoreExpr, r: &mut Resolver) -> Result Ok(ResolvedExpr::Deftool { name: *name, description: Box::new(resolve_expr(description, r)?), parameters: Box::new(resolve_expr(parameters, r)?), + options: Box::new(resolve_expr(options, r)?), handler: Box::new(resolve_expr(handler, r)?), }), @@ -580,11 +582,13 @@ fn collect_rebinds( CoreExpr::Deftool { description, parameters, + options, handler, .. } => { collect_rebinds(description, false, rebound, defined); collect_rebinds(parameters, false, rebound, defined); + collect_rebinds(options, false, rebound, defined); collect_rebinds(handler, false, rebound, defined); } CoreExpr::Defagent { options, .. } => collect_rebinds(options, false, rebound, defined), @@ -895,11 +899,13 @@ fn scan_self_tail(e: &ResolvedExpr, self_uv: u16) -> bool { E::Deftool { description, parameters, + options, handler, .. } => { scan_self_tail(description, self_uv) && scan_self_tail(parameters, self_uv) + && scan_self_tail(options, self_uv) && scan_self_tail(handler, self_uv) } E::Defagent { options, .. } => scan_self_tail(options, self_uv), @@ -1037,11 +1043,13 @@ fn rewrite_self_refs(e: &mut ResolvedExpr, self_uv: u16) { E::Deftool { description, parameters, + options, handler, .. } => { rewrite_self_refs(description, self_uv); rewrite_self_refs(parameters, self_uv); + rewrite_self_refs(options, self_uv); rewrite_self_refs(handler, self_uv); } E::Defagent { options, .. } => rewrite_self_refs(options, self_uv), diff --git a/crates/sema-vm/src/runtime/state.rs b/crates/sema-vm/src/runtime/state.rs index 19aa04b80..e991e30c4 100644 --- a/crates/sema-vm/src/runtime/state.rs +++ b/crates/sema-vm/src/runtime/state.rs @@ -2440,6 +2440,9 @@ impl Runtime { // the same total instruction count a single quantum always was. let mut remaining_budget = instruction_limit; let action = loop { + if let Some(error) = task.context.preflight_error() { + vm.resume_with_error(error); + } let cancellation = { let cancel = task.record.cancellation(); CancellationView::new(cancel.is_some(), cancel.map(|request| request.reason)) @@ -4258,6 +4261,9 @@ impl Runtime { ); } } + if let Some(error) = task.context.preflight_error() { + vm.resume_with_error(error); + } if let Err(error) = vm.setup_for_call_owned(next_closure, &mut current_call.args) { let mut native_context = NativeCallContext { hof_host: None, diff --git a/crates/sema-vm/src/vm.rs b/crates/sema-vm/src/vm.rs index f8058808d..a61225abd 100644 --- a/crates/sema-vm/src/vm.rs +++ b/crates/sema-vm/src/vm.rs @@ -4988,7 +4988,10 @@ impl VM { } } - if let Some(entry) = found { + // Workflow approval is a host-owned control transfer, not a language + // exception. It must unwind through user `try`/`catch` blocks so workflow + // code cannot continue past a pending or rejected gate. + if let Some(entry) = found.filter(|_| !err.is_uncatchable()) { // Close open upvalues above the handler's stack depth let base = frame.base; if let Some(ref mut open) = self.frames.last_mut().unwrap().open_upvalues { @@ -5786,14 +5789,28 @@ fn error_to_value(err: &SemaError) -> Value { map.insert(Value::keyword("type"), Value::keyword("eval")); map.insert(Value::keyword("message"), Value::string(msg)); } - SemaError::Type { expected, got, .. } => { + SemaError::Type { + context, + expected, + got, + got_value, + } => { map.insert(Value::keyword("type"), Value::keyword("type-error")); map.insert( Value::keyword("message"), - Value::string(&format!("expected {expected}, got {got}")), + Value::string(&inner.user_message()), ); map.insert(Value::keyword("expected"), Value::string(expected)); map.insert(Value::keyword("got"), Value::string(got)); + if let Some(context) = context { + map.insert(Value::keyword("function"), Value::string(&context.function)); + if let Some(argument) = context.argument { + map.insert(Value::keyword("argument"), Value::int(argument as i64)); + } + } + if let Some(got_value) = got_value { + map.insert(Value::keyword("value"), Value::string(got_value)); + } } SemaError::Arity { name, @@ -5803,8 +5820,11 @@ fn error_to_value(err: &SemaError) -> Value { map.insert(Value::keyword("type"), Value::keyword("arity")); map.insert( Value::keyword("message"), - Value::string(&format!("{name} expects {expected} args, got {got}")), + Value::string(&inner.user_message()), ); + map.insert(Value::keyword("function"), Value::string(name)); + map.insert(Value::keyword("expected"), Value::string(expected)); + map.insert(Value::keyword("got"), Value::int(*got as i64)); } SemaError::Unbound(name) => { map.insert(Value::keyword("type"), Value::keyword("unbound")); @@ -5859,6 +5879,64 @@ fn error_to_value(err: &SemaError) -> Value { map.insert(Value::keyword("function"), Value::string(function)); map.insert(Value::keyword("path"), Value::string(path)); } + SemaError::PolicyDenied(denial) => { + map.insert(Value::keyword("type"), Value::keyword("policy-denied")); + map.insert( + Value::keyword("message"), + Value::string(&denial.to_string()), + ); + if let Some(policy) = &denial.policy { + map.insert(Value::keyword("policy"), Value::string(policy)); + } + map.insert(Value::keyword("boundary"), Value::string(&denial.boundary)); + map.insert(Value::keyword("subject"), Value::string(&denial.subject)); + map.insert(Value::keyword("rule"), Value::string(&denial.rule)); + map.insert(Value::keyword("reason"), Value::string(&denial.reason)); + map.insert(Value::keyword("action"), Value::keyword(&denial.action)); + map.insert(Value::keyword("source"), Value::keyword(&denial.source)); + } + SemaError::WorkflowApprovalRequired { approval_id } => { + map.insert( + Value::keyword("type"), + Value::keyword("workflow-approval-required"), + ); + map.insert( + Value::keyword("message"), + Value::string(&inner.user_message()), + ); + map.insert(Value::keyword("approval-id"), Value::string(approval_id)); + } + SemaError::WorkflowApprovalRejected { + approval_id, + reason, + } => { + map.insert( + Value::keyword("type"), + Value::keyword("workflow-approval-rejected"), + ); + map.insert( + Value::keyword("message"), + Value::string(&inner.user_message()), + ); + map.insert(Value::keyword("approval-id"), Value::string(approval_id)); + if let Some(reason) = reason { + map.insert(Value::keyword("reason"), Value::string(reason)); + } + } + SemaError::WorkflowApprovalFailed { message } => { + map.insert( + Value::keyword("type"), + Value::keyword("workflow-approval-failed"), + ); + map.insert(Value::keyword("message"), Value::string(message)); + } + SemaError::Internal(message) => { + map.insert(Value::keyword("type"), Value::keyword("internal")); + map.insert( + Value::keyword("message"), + Value::string(&format!("Internal error: {message}")), + ); + } SemaError::WithTrace { .. } | SemaError::WithContext { .. } => { unreachable!("inner() already unwraps these") } diff --git a/crates/sema-wasm/src/driver.rs b/crates/sema-wasm/src/driver.rs index b03c13090..b1fe16a16 100644 --- a/crates/sema-wasm/src/driver.rs +++ b/crates/sema-wasm/src/driver.rs @@ -1281,17 +1281,7 @@ fn resolve_debug_immediately(resolve: &Function, result: JsValue) { } fn format_debug_error(error: &SemaError) -> String { - let mut message = format!("{}", error.inner()); - if let Some(trace) = error.stack_trace() { - message.push_str(&format!("\n{trace}")); - } - if let Some(hint) = error.hint() { - message.push_str(&format!("\n hint: {hint}")); - } - if let Some(note) = error.note() { - message.push_str(&format!("\n note: {note}")); - } - message + error.format_plain() } fn resolve_with_value(resolve: &Function, value: &Value) { @@ -1311,17 +1301,7 @@ fn resolve_with_value(resolve: &Function, value: &Value) { /// wrapper can recover full fidelity from a plain `JsFuture` rejection /// without a second, parallel error-detail channel. fn reject_with_error(reject: &Function, error: &SemaError) { - let mut message = format!("{}", error.inner()); - if let Some(trace) = error.stack_trace() { - message.push_str(&format!("\n{trace}")); - } - if let Some(hint) = error.hint() { - message.push_str(&format!("\n hint: {hint}")); - } - if let Some(note) = error.note() { - message.push_str(&format!("\n note: {note}")); - } - reject_with_message(reject, &message); + reject_with_message(reject, &error.format_plain()); } fn reject_with_message(reject: &Function, message: &str) { diff --git a/crates/sema-wasm/src/lib.rs b/crates/sema-wasm/src/lib.rs index dfc5aff38..b3645a3ec 100644 --- a/crates/sema-wasm/src/lib.rs +++ b/crates/sema-wasm/src/lib.rs @@ -1901,16 +1901,7 @@ impl WasmInterpreter { } Err(e) => { let output = take_output(); - let mut err_str = format!("{}", e.inner()); - if let Some(trace) = e.stack_trace() { - err_str.push_str(&format!("\n{trace}")); - } - if let Some(hint) = e.hint() { - err_str.push_str(&format!("\n hint: {hint}")); - } - if let Some(note) = e.note() { - err_str.push_str(&format!("\n note: {note}")); - } + let err_str = e.format_plain(); format!( "{{\"value\":null,\"output\":[{}],\"error\":\"{}\"}}", output @@ -2072,16 +2063,7 @@ impl WasmInterpreter { } Err(e) => { let output = take_output(); - let mut err_str = format!("{}", e.inner()); - if let Some(trace) = e.stack_trace() { - err_str.push_str(&format!("\n{trace}")); - } - if let Some(hint) = e.hint() { - err_str.push_str(&format!("\n hint: {hint}")); - } - if let Some(note) = e.note() { - err_str.push_str(&format!("\n note: {note}")); - } + let err_str = e.format_plain(); format!( "{{\"value\":null,\"output\":[{}],\"error\":\"{}\"}}", output @@ -2122,16 +2104,7 @@ impl WasmInterpreter { } Err(e) => { let output = take_output(); - let mut err_str = format!("{}", e.inner()); - if let Some(trace) = e.stack_trace() { - err_str.push_str(&format!("\n{trace}")); - } - if let Some(hint) = e.hint() { - err_str.push_str(&format!("\n hint: {hint}")); - } - if let Some(note) = e.note() { - err_str.push_str(&format!("\n note: {note}")); - } + let err_str = e.format_plain(); format!( "{{\"value\":null,\"output\":[{}],\"error\":\"{}\"}}", output @@ -2955,7 +2928,7 @@ impl WasmInterpreter { &self.callback_ids_by_value, &self.next_callback_id, )), - Err(e) => Err(JsValue::from_str(&format!("{}", e.inner()))), + Err(e) => Err(JsValue::from_str(&e.format_plain())), } } @@ -2988,7 +2961,7 @@ impl WasmInterpreter { &self.callback_ids_by_value, &self.next_callback_id, )), - Err(e) => Err(JsValue::from_str(&format!("{}", e.inner()))), + Err(e) => Err(JsValue::from_str(&e.format_plain())), } } @@ -3054,7 +3027,7 @@ impl WasmInterpreter { Ok(()) => r#"{"ok":true,"error":null}"#.to_string(), Err(e) => format!( r#"{{"ok":false,"error":"{}"}}"#, - escape_json(&format!("{}", e.inner())) + escape_json(&e.format_plain()) ), }; js_sys::JSON::parse(&json_str).unwrap_or(JsValue::NULL) @@ -3109,7 +3082,7 @@ impl WasmInterpreter { ), Err(e) => format!( "{{\"ok\":false,\"entryPoint\":null,\"fileCount\":0,\"semaVersion\":null,\"buildTarget\":null,\"buildTimestamp\":null,\"error\":\"{}\"}}", - escape_json(&format!("{}", e.inner())) + escape_json(&e.format_plain()) ), }; js_sys::JSON::parse(&json_str).unwrap_or(JsValue::NULL) @@ -3246,7 +3219,7 @@ impl WasmInterpreter { pub fn write_file(&self, path: &str, content: &str) -> JsValue { let path = match normalize_path(path) { Ok(p) => p, - Err(e) => return JsValue::from_str(&format!("{}", e.inner())), + Err(e) => return JsValue::from_str(&e.format_plain()), }; match vfs_check_quota(&path, content.len()) { Ok(()) => { @@ -3264,10 +3237,7 @@ impl WasmInterpreter { }); JsValue::NULL } - Err(e) => { - let msg = format!("{}", e.inner()); - JsValue::from_str(&msg) - } + Err(e) => JsValue::from_str(&e.format_plain()), } } @@ -3493,16 +3463,7 @@ impl WasmInterpreter { fn eval_error_result(&self, e: &sema_core::SemaError) -> JsValue { let output = take_output(); - let mut err_str = format!("{}", e.inner()); - if let Some(trace) = e.stack_trace() { - err_str.push_str(&format!("\n{trace}")); - } - if let Some(hint) = e.hint() { - err_str.push_str(&format!("\n hint: {hint}")); - } - if let Some(note) = e.note() { - err_str.push_str(&format!("\n note: {note}")); - } + let err_str = e.format_plain(); let json_str = format!( "{{\"value\":null,\"output\":[{}],\"error\":\"{}\"}}", output @@ -3641,13 +3602,7 @@ impl WasmInterpreter { fn debug_error_result(&self, e: &sema_core::SemaError) -> JsValue { let output = take_output(); - let mut err_str = format!("{}", e.inner()); - if let Some(trace) = e.stack_trace() { - err_str.push_str(&format!("\n{trace}")); - } - if let Some(hint) = e.hint() { - err_str.push_str(&format!("\n hint: {hint}")); - } + let err_str = e.format_plain(); let output_json = output .iter() .map(|s| format!("\"{}\"", escape_json(s))) @@ -3831,7 +3786,7 @@ pub fn format_code(code: &str, width: usize, indent: usize, align: bool) -> JsVa Err(e) => { let json_str = format!( "{{\"formatted\":null,\"error\":\"{}\"}}", - escape_json(&format!("{}", e.inner())) + escape_json(&e.format_plain()) ); js_sys::JSON::parse(&json_str).unwrap_or(JsValue::NULL) } diff --git a/crates/sema-workflow/Cargo.toml b/crates/sema-workflow/Cargo.toml index 210e248f8..2c4416f7b 100644 --- a/crates/sema-workflow/Cargo.toml +++ b/crates/sema-workflow/Cargo.toml @@ -16,3 +16,16 @@ sema-core = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } md5 = { workspace = true } +sha2 = { workspace = true } +base64 = { workspace = true } +ring = { workspace = true } +libc = { workspace = true } + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61.2", features = [ + "Win32_Foundation", + "Win32_Security", + "Win32_Security_Authorization", + "Win32_Storage_FileSystem", + "Win32_System_Threading", +] } diff --git a/crates/sema-workflow/src/approval.rs b/crates/sema-workflow/src/approval.rs new file mode 100644 index 000000000..27f922ce3 --- /dev/null +++ b/crates/sema-workflow/src/approval.rs @@ -0,0 +1,1707 @@ +//! Durable, host-controlled approval request and decision sidecars. +//! +//! The journal reports approval events, but these atomically-published private files are +//! the authority consulted before a workflow crosses a gate. A decision is immutable and +//! bound to the exact request digest and revision. + +use std::fmt; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Read, Write}; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use base64::Engine as _; +use ring::rand::SystemRandom; +use ring::signature::{self, KeyPair as _}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +pub const APPROVAL_SCHEMA_VERSION: u32 = 2; +const APPROVAL_SIDECAR_MAX_BYTES: u64 = 128 * 1024; +const APPROVAL_TEXT_MAX_CHARS: usize = 1024; + +/// Whether this build can enforce the private-directory guarantees required +/// before publishing durable approval sidecars. +pub const fn durable_writes_supported() -> bool { + cfg!(any(unix, windows)) +} + +/// Host-held Ed25519 authority used to sign immutable approval decisions. The private +/// PKCS#8 bytes are deliberately opaque to Sema values and request sidecars. +#[derive(Clone)] +pub struct ApprovalSigningKey { + pkcs8: Vec, +} + +impl fmt::Debug for ApprovalSigningKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("ApprovalSigningKey()") + } +} + +impl ApprovalSigningKey { + pub fn generate() -> io::Result { + let bytes = signature::Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()) + .map_err(|_| invalid_data("cannot generate Ed25519 approval signing key"))?; + Ok(Self { + pkcs8: bytes.as_ref().to_vec(), + }) + } + + pub fn from_base64(encoded: &str) -> io::Result { + let pkcs8 = base64::engine::general_purpose::STANDARD + .decode(encoded.trim()) + .map_err(|_| invalid_data("approval signing key is not valid base64"))?; + signature::Ed25519KeyPair::from_pkcs8(&pkcs8) + .map_err(|_| invalid_data("approval signing key is not valid Ed25519 PKCS#8"))?; + Ok(Self { pkcs8 }) + } + + pub fn to_base64(&self) -> String { + base64::engine::general_purpose::STANDARD.encode(&self.pkcs8) + } + + pub fn public_key_base64(&self) -> io::Result { + let pair = self.key_pair()?; + Ok(base64::engine::general_purpose::STANDARD.encode(pair.public_key().as_ref())) + } + + fn key_pair(&self) -> io::Result { + signature::Ed25519KeyPair::from_pkcs8(&self.pkcs8) + .map_err(|_| invalid_data("approval signing key is not valid Ed25519 PKCS#8")) + } + + fn sign(&self, message: &[u8]) -> io::Result { + Ok(base64::engine::general_purpose::STANDARD + .encode(self.key_pair()?.sign(message).as_ref())) + } +} + +fn validate_public_key(encoded: &str) -> io::Result> { + let bytes = base64::engine::general_purpose::STANDARD + .decode(encoded.trim()) + .map_err(|_| invalid_data("approval public key is not valid base64"))?; + if bytes.len() != 32 { + return Err(invalid_data( + "approval public key must be a 32-byte Ed25519 key", + )); + } + Ok(bytes) +} + +pub fn normalize_public_key_base64(encoded: &str) -> io::Result { + let bytes = validate_public_key(encoded)?; + Ok(base64::engine::general_purpose::STANDARD.encode(bytes)) +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct ApprovalRequest { + pub schema_version: u32, + pub approval_id: String, + pub identity_digest: String, + pub request_digest: String, + pub revision: u64, + pub run_id: String, + pub workflow: String, + pub code_version: String, + pub args_digest: String, + pub phase: String, + pub key: String, + pub occurrence: u32, + pub subject_digest: String, + pub reason: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub preview: Option, + pub requested_at: String, + pub authority_public_key: String, +} + +#[derive(Debug, Clone)] +pub struct NewApprovalRequest { + pub run_id: String, + pub workflow: String, + pub code_version: String, + pub args_digest: String, + pub phase: String, + pub key: String, + pub occurrence: u32, + pub subject_digest: String, + pub reason: String, + pub preview: Option, + pub requested_at: String, + pub authority_public_key: String, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum ApprovalDecisionKind { + Approve, + Reject, +} + +impl fmt::Display for ApprovalDecisionKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::Approve => "approve", + Self::Reject => "reject", + }) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct ApprovalDecision { + pub schema_version: u32, + pub decision_id: String, + pub approval_id: String, + pub request_digest: String, + pub request_revision: u64, + pub decision: ApprovalDecisionKind, + pub actor: String, + pub provenance: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub comment: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, + pub decided_at: String, + pub signature: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ApprovalResolution { + Pending(ApprovalRequest), + Approved(ApprovalRequest, ApprovalDecision), + Rejected(ApprovalRequest, ApprovalDecision), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DecisionWrite { + Created(ApprovalDecision), + AlreadyExists(ApprovalDecision), +} + +impl ApprovalRequest { + pub fn new(input: NewApprovalRequest) -> Self { + let identity = RequestIdentityBinding::from(&input); + let identity_digest = sha256_binding(&identity); + let approval_id = format!("apr_{}", &identity_digest[..24]); + let request_digest = sha256_binding(&RequestBinding { + identity_digest: &identity_digest, + requested_at: &input.requested_at, + authority_public_key: &input.authority_public_key, + }); + Self { + schema_version: APPROVAL_SCHEMA_VERSION, + approval_id, + identity_digest, + request_digest, + revision: 1, + run_id: input.run_id, + workflow: input.workflow, + code_version: input.code_version, + args_digest: input.args_digest, + phase: input.phase, + key: input.key, + occurrence: input.occurrence, + subject_digest: input.subject_digest, + reason: input.reason, + preview: input.preview, + requested_at: input.requested_at, + authority_public_key: input.authority_public_key, + } + } + + pub fn validate(&self, expected_run_id: &str) -> io::Result<()> { + if self.schema_version != APPROVAL_SCHEMA_VERSION { + return Err(invalid_data("unsupported approval request schema version")); + } + if self.revision != 1 { + return Err(invalid_data("unsupported approval request revision")); + } + crate::context::validate_explicit_run_id(&self.run_id)?; + if self.run_id != expected_run_id { + return Err(invalid_data("approval request belongs to a different run")); + } + for (value, label) in [ + (&self.run_id, "approval run id"), + (&self.workflow, "approval workflow"), + (&self.code_version, "approval code version"), + (&self.args_digest, "approval args digest"), + (&self.key, "approval key"), + (&self.subject_digest, "approval subject digest"), + (&self.reason, "approval reason"), + (&self.requested_at, "approval request timestamp"), + (&self.authority_public_key, "approval authority public key"), + ] { + validate_text(value, label, false)?; + } + validate_text(&self.phase, "approval phase", true)?; + if let Some(preview) = &self.preview { + validate_text(preview, "approval preview", true)?; + } + validate_public_key(&self.authority_public_key)?; + let rebuilt = Self::new(NewApprovalRequest { + run_id: self.run_id.clone(), + workflow: self.workflow.clone(), + code_version: self.code_version.clone(), + args_digest: self.args_digest.clone(), + phase: self.phase.clone(), + key: self.key.clone(), + occurrence: self.occurrence, + subject_digest: self.subject_digest.clone(), + reason: self.reason.clone(), + preview: self.preview.clone(), + requested_at: self.requested_at.clone(), + authority_public_key: self.authority_public_key.clone(), + }); + if rebuilt.identity_digest != self.identity_digest + || rebuilt.request_digest != self.request_digest + || rebuilt.approval_id != self.approval_id + { + return Err(invalid_data( + "approval request digest does not match its contents", + )); + } + Ok(()) + } +} + +impl ApprovalDecision { + #[allow(clippy::too_many_arguments)] + pub fn new( + request: &ApprovalRequest, + signing_key: &ApprovalSigningKey, + decision: ApprovalDecisionKind, + actor: String, + provenance: String, + comment: Option, + reason: Option, + decided_at: String, + ) -> io::Result { + if signing_key.public_key_base64()? != request.authority_public_key { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "approval signing key does not match the request authority", + )); + } + let binding = DecisionBinding { + schema_version: APPROVAL_SCHEMA_VERSION, + approval_id: &request.approval_id, + request_digest: &request.request_digest, + request_revision: request.revision, + decision, + actor: &actor, + provenance: &provenance, + comment: comment.as_deref(), + reason: reason.as_deref(), + decided_at: &decided_at, + }; + let binding_bytes = binding_bytes(&binding); + let signature = signing_key.sign(&binding_bytes)?; + let decision_id = format!( + "dec_{}", + &sha256_fields(&[&sha256_bytes(&binding_bytes), &signature])[..24] + ); + Ok(Self { + schema_version: APPROVAL_SCHEMA_VERSION, + decision_id, + approval_id: request.approval_id.clone(), + request_digest: request.request_digest.clone(), + request_revision: request.revision, + decision, + actor, + provenance, + comment, + reason, + decided_at, + signature, + }) + } + + fn validate_for(&self, request: &ApprovalRequest) -> io::Result<()> { + if self.schema_version != APPROVAL_SCHEMA_VERSION + || self.approval_id != request.approval_id + || self.request_digest != request.request_digest + || self.request_revision != request.revision + { + return Err(invalid_data( + "approval decision is not bound to this request and revision", + )); + } + for (value, label) in [ + (&self.actor, "approval actor"), + (&self.provenance, "approval provenance"), + (&self.decided_at, "approval decision timestamp"), + ] { + validate_text(value, label, false)?; + } + if let Some(comment) = &self.comment { + validate_text(comment, "approval comment", true)?; + } + if let Some(reason) = &self.reason { + validate_text(reason, "approval reason", false)?; + } + if self.decision == ApprovalDecisionKind::Reject + && self + .reason + .as_deref() + .is_none_or(|reason| reason.trim().is_empty()) + { + return Err(invalid_data("a rejection decision requires a reason")); + } + let binding = DecisionBinding::from(self); + let binding_bytes = binding_bytes(&binding); + let public_key = validate_public_key(&request.authority_public_key)?; + let signature = base64::engine::general_purpose::STANDARD + .decode(&self.signature) + .map_err(|_| invalid_data("approval decision signature is not valid base64"))?; + signature::UnparsedPublicKey::new(&signature::ED25519, public_key) + .verify(&binding_bytes, &signature) + .map_err(|_| invalid_data("approval decision signature is invalid"))?; + let expected_id = format!( + "dec_{}", + &sha256_fields(&[&sha256_bytes(&binding_bytes), &self.signature])[..24] + ); + if expected_id != self.decision_id { + return Err(invalid_data( + "approval decision digest does not match its contents", + )); + } + Ok(()) + } +} + +#[derive(Serialize)] +struct RequestIdentityBinding<'a> { + schema_version: u32, + revision: u64, + run_id: &'a str, + workflow: &'a str, + code_version: &'a str, + args_digest: &'a str, + phase: &'a str, + key: &'a str, + occurrence: u32, + subject_digest: &'a str, + reason: &'a str, + preview: Option<&'a str>, +} + +impl<'a> From<&'a NewApprovalRequest> for RequestIdentityBinding<'a> { + fn from(value: &'a NewApprovalRequest) -> Self { + Self { + schema_version: APPROVAL_SCHEMA_VERSION, + revision: 1, + run_id: &value.run_id, + workflow: &value.workflow, + code_version: &value.code_version, + args_digest: &value.args_digest, + phase: &value.phase, + key: &value.key, + occurrence: value.occurrence, + subject_digest: &value.subject_digest, + reason: &value.reason, + preview: value.preview.as_deref(), + } + } +} + +#[derive(Serialize)] +struct RequestBinding<'a> { + identity_digest: &'a str, + requested_at: &'a str, + authority_public_key: &'a str, +} + +#[derive(Serialize)] +struct DecisionBinding<'a> { + schema_version: u32, + approval_id: &'a str, + request_digest: &'a str, + request_revision: u64, + decision: ApprovalDecisionKind, + actor: &'a str, + provenance: &'a str, + comment: Option<&'a str>, + reason: Option<&'a str>, + decided_at: &'a str, +} + +impl<'a> From<&'a ApprovalDecision> for DecisionBinding<'a> { + fn from(value: &'a ApprovalDecision) -> Self { + Self { + schema_version: value.schema_version, + approval_id: &value.approval_id, + request_digest: &value.request_digest, + request_revision: value.request_revision, + decision: value.decision, + actor: &value.actor, + provenance: &value.provenance, + comment: value.comment.as_deref(), + reason: value.reason.as_deref(), + decided_at: &value.decided_at, + } + } +} + +pub fn sha256_bytes(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +/// SHA-256 over an ordered list of length-prefixed UTF-8 fields. +/// +/// Length prefixes keep field boundaries unambiguous even when a field contains a +/// separator character. This is used for approval identity inputs, where values can +/// originate in workflow source and must not be concatenated ambiguously. +pub fn sha256_fields(fields: &[&str]) -> String { + let mut digest = Sha256::new(); + for field in fields { + digest.update((field.len() as u64).to_le_bytes()); + digest.update(field.as_bytes()); + } + format!("{:x}", digest.finalize()) +} + +pub fn now_timestamp() -> String { + crate::context::rfc3339_now() +} + +pub fn ensure_request( + run_dir: &Path, + candidate: &ApprovalRequest, +) -> io::Result { + candidate.validate(&candidate.run_id)?; + validate_component(&candidate.approval_id, "approval id")?; + let dir = approval_dir(run_dir); + create_private_dir(&dir)?; + let request_path = request_path(run_dir, &candidate.approval_id); + let request = if path_entry_exists(&request_path)? { + let existing = match read_json::(&request_path) { + Ok(existing) => existing, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + return Err(invalid_data( + "approval request was removed before it could be read", + )); + } + Err(error) => return Err(error), + }; + existing.validate(&candidate.run_id)?; + if existing.identity_digest != candidate.identity_digest + || existing.authority_public_key != candidate.authority_public_key + { + return Err(invalid_data( + "approval id collision: existing request has a different identity or authority", + )); + } + existing + } else { + match publish_json_once(&request_path, candidate) { + Ok(true) => candidate.clone(), + Ok(false) => { + let existing: ApprovalRequest = read_json(&request_path)?; + existing.validate(&candidate.run_id)?; + if existing.identity_digest != candidate.identity_digest + || existing.authority_public_key != candidate.authority_public_key + { + return Err(invalid_data( + "approval request identity or authority changed while it was being created", + )); + } + existing + } + Err(error) => return Err(error), + } + }; + + let decision_path = decision_path(run_dir, &request.approval_id); + if !path_entry_exists(&decision_path)? { + return Ok(ApprovalResolution::Pending(request)); + } + let decision: ApprovalDecision = match read_json(&decision_path) { + Ok(decision) => decision, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + return Ok(ApprovalResolution::Pending(request)); + } + Err(error) => return Err(error), + }; + decision.validate_for(&request)?; + Ok(match decision.decision { + ApprovalDecisionKind::Approve => ApprovalResolution::Approved(request, decision), + ApprovalDecisionKind::Reject => ApprovalResolution::Rejected(request, decision), + }) +} + +#[allow(clippy::too_many_arguments)] +pub fn decide( + runs_root: &Path, + run_id: &str, + approval_id: &str, + signing_key: &ApprovalSigningKey, + kind: ApprovalDecisionKind, + actor: String, + provenance: String, + comment: Option, + reason: Option, +) -> io::Result { + crate::context::validate_explicit_run_id(run_id)?; + validate_component(approval_id, "approval id")?; + validate_text(&actor, "approval actor", false)?; + validate_text(&provenance, "approval provenance", false)?; + if let Some(comment) = &comment { + validate_text(comment, "approval comment", true)?; + } + if let Some(reason) = &reason { + validate_text(reason, "approval reason", false)?; + } + let run_dir = runs_root.join(run_id); + let request: ApprovalRequest = read_json(&request_path(&run_dir, approval_id))?; + request.validate(run_id)?; + if request.approval_id != approval_id { + return Err(invalid_data("approval id does not match request filename")); + } + if kind == ApprovalDecisionKind::Reject + && reason + .as_deref() + .is_none_or(|value| value.trim().is_empty()) + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "rejecting an approval requires a reason", + )); + } + let decision = ApprovalDecision::new( + &request, + signing_key, + kind, + actor, + provenance, + comment, + reason, + now_timestamp(), + )?; + let path = decision_path(&run_dir, approval_id); + if publish_json_once(&path, &decision)? { + return Ok(DecisionWrite::Created(decision)); + } + let existing: ApprovalDecision = read_json(&path)?; + existing.validate_for(&request)?; + if existing.decision == decision.decision { + Ok(DecisionWrite::AlreadyExists(existing)) + } else { + Err(io::Error::new( + io::ErrorKind::AlreadyExists, + format!( + "approval already has a conflicting {} decision by {}", + existing.decision, existing.actor + ), + )) + } +} + +pub fn list_requests(runs_root: &Path, run_id: &str) -> io::Result> { + crate::context::validate_explicit_run_id(run_id)?; + let run_dir = runs_root.join(run_id); + let dir = approval_dir(&run_dir); + let mut paths = match fs::read_dir(&dir) { + Ok(entries) => { + let mut paths = Vec::new(); + for entry in entries { + let path = entry?.path(); + if path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.ends_with(".request.json")) + { + paths.push(path); + } + } + paths + } + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => return Err(error), + }; + paths.sort(); + paths + .into_iter() + .map(|path| { + let request: ApprovalRequest = read_json(&path)?; + request.validate(run_id)?; + let expected_name = format!("{}.request.json", request.approval_id); + if path.file_name().and_then(|name| name.to_str()) != Some(expected_name.as_str()) { + return Err(invalid_data(format!( + "approval request filename does not match {}", + request.approval_id + ))); + } + read_resolution(&run_dir, request) + }) + .collect() +} + +fn read_resolution(run_dir: &Path, request: ApprovalRequest) -> io::Result { + let path = decision_path(run_dir, &request.approval_id); + if !path_entry_exists(&path)? { + return Ok(ApprovalResolution::Pending(request)); + } + let decision: ApprovalDecision = match read_json(&path) { + Ok(decision) => decision, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + return Ok(ApprovalResolution::Pending(request)); + } + Err(error) => return Err(error), + }; + decision.validate_for(&request)?; + Ok(match decision.decision { + ApprovalDecisionKind::Approve => ApprovalResolution::Approved(request, decision), + ApprovalDecisionKind::Reject => ApprovalResolution::Rejected(request, decision), + }) +} + +pub fn approval_dir(run_dir: &Path) -> PathBuf { + run_dir.join("approvals") +} + +pub fn request_path(run_dir: &Path, approval_id: &str) -> PathBuf { + approval_dir(run_dir).join(format!("{approval_id}.request.json")) +} + +pub fn decision_path(run_dir: &Path, approval_id: &str) -> PathBuf { + approval_dir(run_dir).join(format!("{approval_id}.decision.json")) +} + +fn binding_bytes(value: &impl Serialize) -> Vec { + // All protocol bindings are fixed Rust structs. Their declared field order — not a + // serde_json map's feature-dependent ordering — is the wire format hashed/signed. + serde_json::to_vec(value).expect("approval binding is JSON serializable") +} + +fn sha256_binding(value: &impl Serialize) -> String { + sha256_bytes(&binding_bytes(value)) +} + +fn read_json Deserialize<'de>>(path: &Path) -> io::Result { + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt as _; + options.custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK); + } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt as _; + use windows_sys::Win32::Storage::FileSystem::FILE_FLAG_OPEN_REPARSE_POINT; + options.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT); + } + let file = options.open(path)?; + let metadata = file.metadata()?; + if !metadata.is_file() { + return Err(invalid_data(format!( + "approval sidecar {} is not a regular file", + path.display() + ))); + } + if metadata.len() > APPROVAL_SIDECAR_MAX_BYTES { + return Err(invalid_data(format!( + "approval sidecar {} exceeds {} bytes", + path.display(), + APPROVAL_SIDECAR_MAX_BYTES + ))); + } + let mut bytes = Vec::with_capacity(metadata.len() as usize); + file.take(APPROVAL_SIDECAR_MAX_BYTES + 1) + .read_to_end(&mut bytes)?; + if bytes.len() as u64 > APPROVAL_SIDECAR_MAX_BYTES { + return Err(invalid_data(format!( + "approval sidecar {} exceeds {} bytes", + path.display(), + APPROVAL_SIDECAR_MAX_BYTES + ))); + } + serde_json::from_slice(&bytes).map_err(|error| { + invalid_data(format!( + "cannot parse approval sidecar {}: {error}", + path.display() + )) + }) +} + +fn publish_json_once(path: &Path, value: &impl Serialize) -> io::Result { + let bytes = serde_json::to_vec_pretty(value) + .map_err(|error| invalid_data(format!("cannot serialize approval sidecar: {error}")))?; + if bytes.len() as u64 + 1 > APPROVAL_SIDECAR_MAX_BYTES { + return Err(invalid_data(format!( + "approval sidecar exceeds {APPROVAL_SIDECAR_MAX_BYTES} bytes" + ))); + } + let parent = path + .parent() + .ok_or_else(|| invalid_data("approval sidecar has no parent directory"))?; + create_private_dir(parent)?; + let tmp = parent.join(format!( + ".approval-{}-{}-{}-{}.tmp", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(), + next_publish_nonce(), + path.file_name() + .and_then(|name| name.to_str()) + .unwrap_or("sidecar") + )); + let mut file = private_create_new(&tmp)?; + let result = (|| { + file.write_all(&bytes)?; + file.write_all(b"\n")?; + file.sync_all()?; + match fs::hard_link(&tmp, path) { + Ok(()) => { + sync_dir(parent)?; + Ok(true) + } + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => Ok(false), + Err(error) => Err(error), + } + })(); + drop(file); + let _ = fs::remove_file(&tmp); + result +} + +#[cfg(unix)] +fn create_private_dir(path: &Path) -> io::Result<()> { + use std::os::unix::fs::{DirBuilderExt as _, PermissionsExt as _}; + + if let Ok(metadata) = fs::symlink_metadata(path) { + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(invalid_data(format!( + "approval path {} is not a regular directory", + path.display() + ))); + } + } + if !path_entry_exists(path)? { + let parent = path + .parent() + .ok_or_else(|| invalid_data("approval directory has no parent"))?; + let mut builder = fs::DirBuilder::new(); + builder.mode(0o700).create(path)?; + sync_dir(parent)?; + } + let metadata = fs::symlink_metadata(path)?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(invalid_data(format!( + "approval path {} is not a regular directory", + path.display() + ))); + } + fs::set_permissions(path, fs::Permissions::from_mode(0o700))?; + Ok(()) +} + +#[cfg(windows)] +mod windows_private { + use super::{invalid_data, path_entry_exists}; + use std::ffi::c_void; + use std::fs::{self, File}; + use std::io; + use std::mem::size_of; + use std::os::windows::ffi::OsStrExt as _; + use std::os::windows::fs::MetadataExt as _; + use std::os::windows::io::FromRawHandle as _; + use std::path::Path; + use std::ptr::{null, null_mut}; + + use windows_sys::Win32::Foundation::{ + CloseHandle, LocalFree, GENERIC_READ, GENERIC_WRITE, HANDLE, INVALID_HANDLE_VALUE, + }; + use windows_sys::Win32::Security::Authorization::{ + ConvertSidToStringSidW, ConvertStringSecurityDescriptorToSecurityDescriptorW, + SetSecurityInfo, SDDL_REVISION_1, SE_FILE_OBJECT, + }; + use windows_sys::Win32::Security::{ + GetSecurityDescriptorDacl, GetTokenInformation, SetFileSecurityW, TokenUser, ACL, + DACL_SECURITY_INFORMATION, PROTECTED_DACL_SECURITY_INFORMATION, SECURITY_ATTRIBUTES, + TOKEN_QUERY, TOKEN_USER, + }; + use windows_sys::Win32::Storage::FileSystem::{ + CreateDirectoryW, CreateFileW, CREATE_NEW, FILE_ATTRIBUTE_NORMAL, + FILE_ATTRIBUTE_REPARSE_POINT, FILE_FLAG_OPEN_REPARSE_POINT, FILE_FLAG_WRITE_THROUGH, + OPEN_EXISTING, WRITE_DAC, + }; + use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; + + struct OwnedHandle(HANDLE); + + impl Drop for OwnedHandle { + fn drop(&mut self) { + // SAFETY: this guard owns the real token handle returned by + // OpenProcessToken. Pseudo handles are not stored here. + unsafe { + CloseHandle(self.0); + } + } + } + + struct LocalMemory(*mut c_void); + + impl Drop for LocalMemory { + fn drop(&mut self) { + if !self.0.is_null() { + // SAFETY: the pointer was allocated by an API documented to + // transfer ownership to LocalFree. + unsafe { + LocalFree(self.0); + } + } + } + } + + fn wide_path(path: &Path) -> io::Result> { + let mut wide = path.as_os_str().encode_wide().collect::>(); + if wide.contains(&0) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "approval path contains a NUL code unit", + )); + } + wide.push(0); + Ok(wide) + } + + fn wide_text(text: &str) -> io::Result> { + let mut wide = text.encode_utf16().collect::>(); + if wide.contains(&0) { + return Err(invalid_data("security descriptor contains a NUL code unit")); + } + wide.push(0); + Ok(wide) + } + + fn current_user_sid_string() -> io::Result { + let mut token = null_mut(); + // SAFETY: token points to writable storage and GetCurrentProcess + // returns the documented process pseudo handle. + if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) } == 0 { + return Err(io::Error::last_os_error()); + } + let _token = OwnedHandle(token); + + let mut required = 0; + // SAFETY: a null buffer with length zero is the documented size query. + unsafe { + GetTokenInformation(token, TokenUser, null_mut(), 0, &mut required); + } + if required == 0 { + return Err(io::Error::last_os_error()); + } + let words = (required as usize).div_ceil(size_of::()); + let mut buffer = vec![0usize; words]; + // SAFETY: the aligned buffer has at least `required` writable bytes and + // remains alive while TOKEN_USER and its embedded SID are inspected. + if unsafe { + GetTokenInformation( + token, + TokenUser, + buffer.as_mut_ptr().cast(), + required, + &mut required, + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + // SAFETY: GetTokenInformation(TokenUser) initialized the buffer as a + // TOKEN_USER and keeps its SID pointer within the same live buffer. + let token_user = unsafe { &*buffer.as_ptr().cast::() }; + let mut string_sid = null_mut(); + // SAFETY: the SID comes from the current token and string_sid points to + // writable output storage. The returned string is owned by LocalFree. + if unsafe { ConvertSidToStringSidW(token_user.User.Sid, &mut string_sid) } == 0 { + return Err(io::Error::last_os_error()); + } + let _string_sid = LocalMemory(string_sid.cast()); + let mut len = 0usize; + // A textual SID is bounded by the Windows SID component limits. The + // API guarantees a NUL terminator; the explicit cap rejects corruption. + while len < 256 { + // SAFETY: ConvertSidToStringSidW returned a NUL-terminated SID + // string whose documented maximum is below this bound. + if unsafe { *string_sid.add(len) } == 0 { + // SAFETY: the preceding loop verified that these code units are + // within the returned NUL-terminated allocation. + let units = unsafe { std::slice::from_raw_parts(string_sid, len) }; + return String::from_utf16(units) + .map_err(|_| invalid_data("current user SID is not valid UTF-16")); + } + len += 1; + } + Err(invalid_data("current user SID string is too long")) + } + + fn security_descriptor(directory: bool) -> io::Result { + let user_sid = current_user_sid_string()?; + let inheritance = if directory { "OICI" } else { "" }; + let sddl = format!("D:P(A;{inheritance};FA;;;{user_sid})(A;{inheritance};FA;;;SY)"); + let wide = wide_text(&sddl)?; + let mut descriptor = null_mut(); + // SAFETY: wide is NUL-terminated and descriptor points to writable + // output storage. The returned descriptor is owned by LocalFree. + if unsafe { + ConvertStringSecurityDescriptorToSecurityDescriptorW( + wide.as_ptr(), + SDDL_REVISION_1, + &mut descriptor, + null_mut(), + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + Ok(LocalMemory(descriptor)) + } + + fn security_attributes(descriptor: &LocalMemory) -> SECURITY_ATTRIBUTES { + SECURITY_ATTRIBUTES { + nLength: size_of::() as u32, + lpSecurityDescriptor: descriptor.0, + bInheritHandle: 0, + } + } + + fn validate_directory(path: &Path) -> io::Result<()> { + let metadata = fs::symlink_metadata(path)?; + if !metadata.is_dir() || metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return Err(invalid_data(format!( + "approval path {} is not a regular directory", + path.display() + ))); + } + Ok(()) + } + + fn apply_private_directory_acl(path: &Path) -> io::Result<()> { + let wide = wide_path(path)?; + let descriptor = security_descriptor(true)?; + // SAFETY: wide and descriptor remain alive for this call. The security + // information flags request only the DACL contained in the descriptor. + if unsafe { + SetFileSecurityW( + wide.as_ptr(), + DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, + descriptor.0, + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + Ok(()) + } + + pub(super) fn create_private_dir(path: &Path) -> io::Result<()> { + let mut created = false; + if !path_entry_exists(path)? { + let parent = path + .parent() + .ok_or_else(|| invalid_data("approval directory has no parent"))?; + let wide = wide_path(path)?; + let descriptor = security_descriptor(true)?; + let attributes = security_attributes(&descriptor); + // SAFETY: the path and security descriptor remain alive for the + // call. The descriptor creates a protected current-user/SYSTEM DACL. + if unsafe { CreateDirectoryW(wide.as_ptr(), &attributes) } == 0 { + let error = io::Error::last_os_error(); + if error.kind() != io::ErrorKind::AlreadyExists { + return Err(error); + } + } else { + created = true; + super::sync_dir(parent)?; + } + } + validate_directory(path)?; + if !created { + apply_private_directory_acl(path)?; + } + Ok(()) + } + + pub(super) fn create_private_file(path: &Path) -> io::Result { + let wide = wide_path(path)?; + let descriptor = security_descriptor(false)?; + let attributes = security_attributes(&descriptor); + // SAFETY: all pointers remain alive for the call. CREATE_NEW prevents + // replacement, and the protected DACL is applied at object creation. + let handle = unsafe { + CreateFileW( + wide.as_ptr(), + GENERIC_WRITE, + 0, + &attributes, + CREATE_NEW, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_WRITE_THROUGH, + null_mut(), + ) + }; + if handle == INVALID_HANDLE_VALUE { + return Err(io::Error::last_os_error()); + } + // SAFETY: CreateFileW returned a new owned handle. File assumes sole + // ownership and closes it on drop. + Ok(unsafe { File::from_raw_handle(handle) }) + } + + pub(super) fn open_private_file(path: &Path) -> io::Result { + let wide = wide_path(path)?; + // SAFETY: wide is a live NUL-terminated path. Denying all sharing keeps + // the opened object from being replaced while it is inspected and read. + let handle = unsafe { + CreateFileW( + wide.as_ptr(), + GENERIC_READ | WRITE_DAC, + 0, + null(), + OPEN_EXISTING, + FILE_FLAG_OPEN_REPARSE_POINT, + null_mut(), + ) + }; + if handle == INVALID_HANDLE_VALUE { + return Err(io::Error::last_os_error()); + } + // SAFETY: CreateFileW returned a new owned handle. File assumes sole + // ownership immediately so every later error closes it. + let file = unsafe { File::from_raw_handle(handle) }; + let metadata = file.metadata()?; + if !metadata.is_file() || metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return Err(invalid_data(format!( + "private approval key {} is not a regular file", + path.display() + ))); + } + + let descriptor = security_descriptor(false)?; + let mut dacl_present = 0; + let mut dacl_defaulted = 0; + let mut dacl: *mut ACL = null_mut(); + // SAFETY: descriptor is a valid self-relative descriptor and all output + // pointers refer to live writable storage. + if unsafe { + GetSecurityDescriptorDacl( + descriptor.0, + &mut dacl_present, + &mut dacl, + &mut dacl_defaulted, + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + if dacl_present == 0 || dacl.is_null() { + return Err(invalid_data( + "private approval key security descriptor has no DACL", + )); + } + // SAFETY: file owns the live handle and dacl points into descriptor, + // which remains alive for the call. The new protected DACL grants only + // the current user and SYSTEM access. + let status = unsafe { + SetSecurityInfo( + handle, + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, + null_mut(), + null_mut(), + dacl, + null(), + ) + }; + if status != 0 { + return Err(io::Error::from_raw_os_error(status as i32)); + } + Ok(file) + } +} + +#[cfg(windows)] +fn create_private_dir(path: &Path) -> io::Result<()> { + windows_private::create_private_dir(path) +} + +#[cfg(not(any(unix, windows)))] +fn create_private_dir(_path: &Path) -> io::Result<()> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "durable approvals require a platform implementation that can enforce private ACLs", + )) +} + +#[cfg(unix)] +fn private_create_new(path: &Path) -> io::Result { + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + options.open(path) +} + +#[cfg(windows)] +fn private_create_new(path: &Path) -> io::Result { + windows_private::create_private_file(path) +} + +#[cfg(not(any(unix, windows)))] +fn private_create_new(_path: &Path) -> io::Result { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "private approval files are unavailable on this platform", + )) +} + +/// Create a new file whose contents are readable and writable only by the +/// current user and the operating system account. The call fails if the path +/// already exists. +pub fn create_private_file_new(path: &Path) -> io::Result { + private_create_new(path) +} + +/// Open an existing private key without following links and verify or enforce +/// that only the current user (and the operating system account on Windows) +/// can access it. +pub fn open_private_file(path: &Path) -> io::Result { + #[cfg(unix)] + { + use std::os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _}; + + let mut options = OpenOptions::new(); + options + .read(true) + .custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK); + let file = options.open(path)?; + let metadata = file.metadata()?; + if !metadata.is_file() { + return Err(invalid_data(format!( + "private approval key {} is not a regular file", + path.display() + ))); + } + if metadata.permissions().mode() & 0o077 != 0 { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + format!( + "private approval key {} must not be accessible by group or other users (chmod 600)", + path.display() + ), + )); + } + Ok(file) + } + #[cfg(windows)] + { + windows_private::open_private_file(path) + } + #[cfg(not(any(unix, windows)))] + { + let _ = path; + Err(io::Error::new( + io::ErrorKind::Unsupported, + "private approval files are unavailable on this platform", + )) + } +} + +#[cfg(unix)] +fn sync_dir(path: &Path) -> io::Result<()> { + File::open(path)?.sync_all() +} + +#[cfg(windows)] +fn sync_dir(_path: &Path) -> io::Result<()> { + // Windows does not expose a stable directory-fsync operation. Each sidecar + // file is flushed before its create-if-absent hard link is published. + Ok(()) +} + +#[cfg(not(any(unix, windows)))] +fn sync_dir(_path: &Path) -> io::Result<()> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "durable approvals are unavailable on this platform", + )) +} + +fn validate_component(value: &str, label: &str) -> io::Result<()> { + if value.is_empty() + || value.contains('/') + || value.contains('\\') + || value.contains("..") + || value.chars().any(char::is_control) + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("{label} is not a safe path component"), + )); + } + Ok(()) +} + +fn validate_text(value: &str, label: &str, allow_empty: bool) -> io::Result<()> { + if (!allow_empty && value.trim().is_empty()) || value.chars().count() > APPROVAL_TEXT_MAX_CHARS + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "{label} must {}and contain at most {APPROVAL_TEXT_MAX_CHARS} characters", + if allow_empty { "" } else { "be nonempty " } + ), + )); + } + Ok(()) +} + +fn path_entry_exists(path: &Path) -> io::Result { + match fs::symlink_metadata(path) { + Ok(_) => Ok(true), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(error), + } +} + +fn invalid_data(message: impl Into) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, message.into()) +} + +fn next_publish_nonce() -> u64 { + use std::sync::atomic::{AtomicU64, Ordering}; + static NONCE: AtomicU64 = AtomicU64::new(0); + NONCE.fetch_add(1, Ordering::Relaxed) +} + +#[cfg(test)] +#[cfg(any(unix, windows))] +mod tests { + use super::*; + use std::sync::{Arc, Barrier, OnceLock}; + + fn test_key() -> ApprovalSigningKey { + static KEY: OnceLock = OnceLock::new(); + KEY.get_or_init(|| ApprovalSigningKey::generate().unwrap()) + .clone() + } + + fn temp_root(label: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "sema-approval-{label}-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + )) + } + + fn request_input() -> NewApprovalRequest { + NewApprovalRequest { + run_id: "run-1".into(), + workflow: "release".into(), + code_version: "code-a".into(), + args_digest: "args-a".into(), + phase: "publish".into(), + key: "release-signoff".into(), + occurrence: 0, + subject_digest: "subject-a".into(), + reason: "Publish release".into(), + preview: Some("Publish package@1.0.0".into()), + requested_at: "0".into(), + authority_public_key: test_key().public_key_base64().unwrap(), + } + } + + fn request() -> ApprovalRequest { + ApprovalRequest::new(request_input()) + } + + fn run_dir(root: &Path) -> PathBuf { + let path = root.join("run-1"); + fs::create_dir_all(&path).unwrap(); + path + } + + #[test] + fn field_hash_is_boundary_safe() { + assert_ne!(sha256_fields(&["a", "bc"]), sha256_fields(&["ab", "c"])); + assert_ne!(sha256_fields(&["a\0b", "c"]), sha256_fields(&["a", "b\0c"])); + } + + #[test] + fn request_identity_tracks_execution_bindings() { + let original = request(); + let changed_code = ApprovalRequest::new(NewApprovalRequest { + code_version: "code-b".into(), + ..request_input() + }); + let changed_subject = ApprovalRequest::new(NewApprovalRequest { + subject_digest: "subject-b".into(), + ..request_input() + }); + + assert_ne!(original.approval_id, changed_code.approval_id); + assert_ne!(original.approval_id, changed_subject.approval_id); + } + + #[test] + fn request_timestamp_changes_full_digest_but_not_lookup_identity() { + let original = request(); + let later = ApprovalRequest::new(NewApprovalRequest { + requested_at: "later".into(), + ..request_input() + }); + assert_eq!(original.approval_id, later.approval_id); + assert_eq!(original.identity_digest, later.identity_digest); + assert_ne!(original.request_digest, later.request_digest); + } + + #[test] + fn a_different_private_key_cannot_decide_the_request() { + let root = temp_root("wrong-key"); + let request = request(); + ensure_request(&run_dir(&root), &request).unwrap(); + let wrong = ApprovalSigningKey::generate().unwrap(); + let error = decide( + &root, + "run-1", + &request.approval_id, + &wrong, + ApprovalDecisionKind::Approve, + "mallory".into(), + "test".into(), + None, + None, + ) + .unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::PermissionDenied); + assert!(matches!( + ensure_request(&root.join("run-1"), &request).unwrap(), + ApprovalResolution::Pending(_) + )); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn listing_is_read_only_and_rejects_a_misnamed_request() { + let root = temp_root("misnamed"); + let directory = run_dir(&root); + let request = request(); + ensure_request(&directory, &request).unwrap(); + let original = request_path(&directory, &request.approval_id); + let misnamed = approval_dir(&directory).join("apr_wrong.request.json"); + fs::rename(&original, &misnamed).unwrap(); + + assert_eq!( + list_requests(&root, "run-1").unwrap_err().kind(), + io::ErrorKind::InvalidData + ); + assert!( + !original.exists(), + "listing must not recreate request files" + ); + assert!(misnamed.exists()); + let _ = fs::remove_dir_all(root); + } + + #[cfg(unix)] + #[test] + fn sidecar_symlinks_are_never_followed() { + use std::os::unix::fs::symlink; + + let root = temp_root("sidecar-symlink"); + let directory = run_dir(&root); + let request = request(); + ensure_request(&directory, &request).unwrap(); + let original = request_path(&directory, &request.approval_id); + let moved = approval_dir(&directory).join("saved-request.json"); + fs::rename(&original, &moved).unwrap(); + symlink(&moved, &original).unwrap(); + + assert!(ensure_request(&directory, &request).is_err()); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn request_is_idempotent_and_decision_is_bound() { + let root = temp_root("roundtrip"); + let run_dir = run_dir(&root); + let request = request(); + assert!(matches!( + ensure_request(&run_dir, &request).unwrap(), + ApprovalResolution::Pending(_) + )); + assert!(matches!( + ensure_request(&run_dir, &request).unwrap(), + ApprovalResolution::Pending(_) + )); + decide( + &root, + "run-1", + &request.approval_id, + &test_key(), + ApprovalDecisionKind::Approve, + "alice".into(), + "cli".into(), + Some("looks good".into()), + None, + ) + .unwrap(); + assert!(matches!( + ensure_request(&run_dir, &request).unwrap(), + ApprovalResolution::Approved(_, _) + )); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn conflicting_decision_cannot_overwrite_the_winner() { + let root = temp_root("conflict"); + let run_dir = run_dir(&root); + let request = request(); + ensure_request(&run_dir, &request).unwrap(); + decide( + &root, + "run-1", + &request.approval_id, + &test_key(), + ApprovalDecisionKind::Approve, + "alice".into(), + "cli".into(), + None, + None, + ) + .unwrap(); + let error = decide( + &root, + "run-1", + &request.approval_id, + &test_key(), + ApprovalDecisionKind::Reject, + "bob".into(), + "web".into(), + None, + Some("no".into()), + ) + .unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::AlreadyExists); + assert!(matches!( + ensure_request(&run_dir, &request).unwrap(), + ApprovalResolution::Approved(_, _) + )); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn racing_opposite_decisions_have_one_winner() { + let root = temp_root("race"); + let request = request(); + ensure_request(&run_dir(&root), &request).unwrap(); + let barrier = Arc::new(Barrier::new(3)); + let handles = [ApprovalDecisionKind::Approve, ApprovalDecisionKind::Reject] + .into_iter() + .map(|kind| { + let root = root.clone(); + let approval_id = request.approval_id.clone(); + let barrier = Arc::clone(&barrier); + let signing_key = test_key(); + std::thread::spawn(move || { + barrier.wait(); + decide( + &root, + "run-1", + &approval_id, + &signing_key, + kind, + kind.to_string(), + "test".into(), + None, + (kind == ApprovalDecisionKind::Reject).then(|| "no".into()), + ) + }) + }) + .collect::>(); + barrier.wait(); + let results = handles + .into_iter() + .map(|handle| handle.join().unwrap()) + .collect::>(); + assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1); + let decisions = fs::read_dir(approval_dir(&root.join("run-1"))) + .unwrap() + .filter_map(Result::ok) + .filter(|entry| { + entry + .file_name() + .to_string_lossy() + .ends_with(".decision.json") + }) + .count(); + assert_eq!(decisions, 1); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn tampered_request_is_rejected() { + let root = temp_root("tamper"); + let run_dir = run_dir(&root); + let request = request(); + ensure_request(&run_dir, &request).unwrap(); + let path = request_path(&run_dir, &request.approval_id); + let mut json: serde_json::Value = + serde_json::from_slice(&fs::read(&path).unwrap()).unwrap(); + json["reason"] = serde_json::json!("changed"); + fs::write(&path, serde_json::to_vec_pretty(&json).unwrap()).unwrap(); + assert_eq!( + ensure_request(&run_dir, &request).unwrap_err().kind(), + io::ErrorKind::InvalidData + ); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn unsigned_unknown_sidecar_fields_are_rejected() { + let root = temp_root("unknown-fields"); + let run_dir = run_dir(&root); + let request = request(); + ensure_request(&run_dir, &request).unwrap(); + + let request_path = request_path(&run_dir, &request.approval_id); + let mut request_json: serde_json::Value = + serde_json::from_slice(&fs::read(&request_path).unwrap()).unwrap(); + request_json["unsigned_annotation"] = serde_json::json!("misleading"); + fs::write( + &request_path, + serde_json::to_vec_pretty(&request_json).unwrap(), + ) + .unwrap(); + assert_eq!( + ensure_request(&run_dir, &request).unwrap_err().kind(), + io::ErrorKind::InvalidData + ); + + fs::write(&request_path, serde_json::to_vec_pretty(&request).unwrap()).unwrap(); + decide( + &root, + "run-1", + &request.approval_id, + &test_key(), + ApprovalDecisionKind::Approve, + "alice".into(), + "cli".into(), + None, + None, + ) + .unwrap(); + let decision_path = decision_path(&run_dir, &request.approval_id); + let mut decision_json: serde_json::Value = + serde_json::from_slice(&fs::read(&decision_path).unwrap()).unwrap(); + decision_json["unsigned_annotation"] = serde_json::json!("misleading"); + fs::write( + decision_path, + serde_json::to_vec_pretty(&decision_json).unwrap(), + ) + .unwrap(); + assert_eq!( + ensure_request(&run_dir, &request).unwrap_err().kind(), + io::ErrorKind::InvalidData + ); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn tampered_revision_and_copied_decision_are_rejected() { + let root = temp_root("binding-tamper"); + let run_dir = run_dir(&root); + let first = request(); + let second = ApprovalRequest::new(NewApprovalRequest { + key: "second-signoff".into(), + ..request_input() + }); + ensure_request(&run_dir, &first).unwrap(); + ensure_request(&run_dir, &second).unwrap(); + + let first_request_path = request_path(&run_dir, &first.approval_id); + let mut request_json: serde_json::Value = + serde_json::from_slice(&fs::read(&first_request_path).unwrap()).unwrap(); + request_json["revision"] = serde_json::json!(2); + fs::write( + &first_request_path, + serde_json::to_vec_pretty(&request_json).unwrap(), + ) + .unwrap(); + assert_eq!( + ensure_request(&run_dir, &first).unwrap_err().kind(), + io::ErrorKind::InvalidData + ); + + fs::write( + &first_request_path, + serde_json::to_vec_pretty(&first).unwrap(), + ) + .unwrap(); + decide( + &root, + "run-1", + &first.approval_id, + &test_key(), + ApprovalDecisionKind::Approve, + "alice".into(), + "cli".into(), + None, + None, + ) + .unwrap(); + fs::copy( + decision_path(&run_dir, &first.approval_id), + decision_path(&run_dir, &second.approval_id), + ) + .unwrap(); + assert_eq!( + ensure_request(&run_dir, &second).unwrap_err().kind(), + io::ErrorKind::InvalidData + ); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn tampered_decision_is_rejected() { + let root = temp_root("tampered-decision"); + let run_dir = run_dir(&root); + let request = request(); + ensure_request(&run_dir, &request).unwrap(); + decide( + &root, + "run-1", + &request.approval_id, + &test_key(), + ApprovalDecisionKind::Approve, + "alice".into(), + "cli".into(), + None, + None, + ) + .unwrap(); + let path = decision_path(&run_dir, &request.approval_id); + let mut json: serde_json::Value = + serde_json::from_slice(&fs::read(&path).unwrap()).unwrap(); + json["actor"] = serde_json::json!("mallory"); + fs::write(&path, serde_json::to_vec_pretty(&json).unwrap()).unwrap(); + assert_eq!( + ensure_request(&run_dir, &request).unwrap_err().kind(), + io::ErrorKind::InvalidData + ); + let _ = fs::remove_dir_all(root); + } +} diff --git a/crates/sema-workflow/src/context.rs b/crates/sema-workflow/src/context.rs index a02cc0f79..c70c11ef1 100644 --- a/crates/sema-workflow/src/context.rs +++ b/crates/sema-workflow/src/context.rs @@ -118,6 +118,47 @@ thread_local! { /// the owning task's [`TaskContextHandle`] instead. Read only when /// `!sema_core::in_runtime_quantum()`. static WORKFLOW: Rc = Rc::new(WorkflowTaskState::default()); + /// Immutable host-owned run configuration. The CLI installs this around evaluation; + /// Sema code can mutate process environment variables but cannot reach this slot. + static HOST_CONFIG: RefCell> = const { RefCell::new(None) }; +} + +#[derive(Debug, Clone)] +pub struct WorkflowHostConfig { + pub runs_root: String, + pub explicit_run_id: Option, + pub resuming: bool, + pub code_version: String, + pub approval_code_version: String, + pub args_json: String, + pub approval_public_key: String, + pub entry_file: String, + pub workspace_root: String, +} + +pub struct WorkflowHostConfigGuard { + previous: Option, +} + +impl Drop for WorkflowHostConfigGuard { + fn drop(&mut self) { + HOST_CONFIG.with(|slot| { + *slot.borrow_mut() = self.previous.take(); + }); + } +} + +pub fn install_host_config(config: WorkflowHostConfig) -> WorkflowHostConfigGuard { + let previous = HOST_CONFIG.with(|slot| slot.borrow_mut().replace(config)); + WorkflowHostConfigGuard { previous } +} + +fn host_config() -> Option { + HOST_CONFIG.with(|slot| slot.borrow().clone()) +} + +pub fn host_workspace_root() -> Option { + host_config().map(|config| config.workspace_root.into()) } /// Run-scoped dynamic context. Cheap to clone-share via `Rc`; all interior state is @@ -126,6 +167,9 @@ thread_local! { pub struct WorkflowCtx { /// Stable identifier for this run; names the run dir (`./.sema/runs//`). pub run_id: String, + /// Declared `defworkflow` name. Stored separately from the run id so approval + /// requests can bind decisions to the workflow definition that produced them. + workflow_name: RefCell, /// Append-only JSONL journal sink. `RefCell` because `emit` needs `&mut` access /// to the underlying writer while the ctx itself is shared `Rc`. journal: Rc>, @@ -134,6 +178,8 @@ pub struct WorkflowCtx { state: Rc>>, /// Monotonic event sequence counter (0-based; first `next_seq()` returns 0). seq: Cell, + /// Bounded completion ledger keyed only by the frozen event vocabulary. + event_counts: RefCell>, /// Wall-clock origin for `dur_ms`. Ignored when the fixed-ts seam is active. start: Instant, /// Parsed spend caps (absent ⇒ that dimension is unenforced). `usd` is best-effort @@ -152,6 +198,10 @@ pub struct WorkflowCtx { /// `Err` propagation — because the `__fanout-tagged` engine swallows a leaf `Err` /// into `nil`, so an exception can't stop a concurrent batch. over_budget: Cell, + /// Sticky fail-closed latch for an approval attempted from an invalid child/nested + /// position. Shared with inherited tasks so the owning run cannot report success + /// after a detached child tried to create a gate. + approval_failure: RefCell>, /// `start_seq` of the currently-open phase (the phase.started event's seq), so /// checkpoints/agents/budget events can be attributed to their phase. cur_phase_seq: Cell>, @@ -172,7 +222,14 @@ pub struct WorkflowCtx { /// mints a per-base occurrence ordinal so identical-prompt repeats in source order /// line up across runs. resuming: Cell, + /// Existing short resume fingerprint. Kept stable for memo compatibility. code_version: RefCell, + /// Collision-resistant source fingerprint used to bind human decisions. The CLI + /// supplies SHA-256; library callers fall back to `code_version`. + approval_code_version: RefCell, + /// Ed25519 public key selected by the host before evaluation. Decisions must verify + /// against this authority; the matching private key is never exposed to Sema code. + approval_public_key: RefCell, resume_memos: RefCell>, key_seen: RefCell>, /// Number of memos stored this run, capped at [`MEMO_MAX_COUNT`] so an unbounded fan-out @@ -235,20 +292,25 @@ impl WorkflowCtx { .map(|i| i as u64); Rc::new(WorkflowCtx { run_id, + workflow_name: RefCell::new(String::new()), journal: Rc::new(RefCell::new(journal)), state: Rc::new(RefCell::new(BTreeMap::new())), seq: Cell::new(0), + event_counts: RefCell::new(BTreeMap::new()), start: Instant::now(), cost_limit, token_limit, cost_spent: Cell::new(0.0), tokens_spent: Cell::new(0), over_budget: Cell::new(false), + approval_failure: RefCell::new(None), cur_phase_seq: Cell::new(None), cur_phase_label: RefCell::new(None), agent_n: RefCell::new(BTreeMap::new()), resuming: Cell::new(false), code_version: RefCell::new(String::new()), + approval_code_version: RefCell::new(String::new()), + approval_public_key: RefCell::new(String::new()), resume_memos: RefCell::new(HashMap::new()), key_seen: RefCell::new(HashMap::new()), memo_count: Cell::new(0), @@ -265,6 +327,44 @@ impl WorkflowCtx { &self.args_json } + /// Bind this context to its declared workflow name. Called once while opening the + /// scope, before the body can evaluate an approval gate. + pub fn set_workflow_name(&self, name: impl Into) { + *self.workflow_name.borrow_mut() = name.into(); + } + + pub fn workflow_name(&self) -> String { + self.workflow_name.borrow().clone() + } + + /// Collision-resistant workflow revision used by durable approval requests. + pub fn approval_code_version(&self) -> String { + self.approval_code_version.borrow().clone() + } + + pub fn approval_public_key(&self) -> String { + self.approval_public_key.borrow().clone() + } + + /// Full SHA-256 of canonicalized workflow arguments for approval bindings. Resume + /// keeps its historical short content-key fingerprint; approvals use the full digest. + pub fn approval_args_digest(&self) -> String { + let normalized = if self.args_json.trim().is_empty() { + String::new() + } else { + serde_json::from_str::(&self.args_json) + .ok() + .and_then(|json| serde_json::to_string(&json).ok()) + .unwrap_or_else(|| self.args_json.clone()) + }; + crate::approval::sha256_bytes(normalized.as_bytes()) + } + + /// Run directory containing the approval authority sidecars. + pub fn run_dir(&self) -> std::path::PathBuf { + self.journal.borrow().dir().to_path_buf() + } + /// Open a marker-style phase: record its `phase.started` seq AND label so the next /// marker (or the run end) can emit the matching `phase.ended`. Subsequent /// checkpoints / agents / budget events attribute to `start_seq`. @@ -334,9 +434,20 @@ impl WorkflowCtx { /// Append one event to the journal. Write errors are swallowed by the journal /// (same trust model as the OTel file exporter); journaling never aborts the run. pub fn emit(&self, event: WorkflowEvent) { + let kind = event.kind(); + let mut counts = self.event_counts.borrow_mut(); + *counts.entry(kind).or_insert(0) += 1; + drop(counts); self.journal.borrow().write(&event); } + pub fn has_event(&self, kind: &str) -> bool { + self.event_counts + .borrow() + .get(kind) + .is_some_and(|count| *count > 0) + } + /// True under the fixed-timestamp test seam (`SEMA_WORKFLOW_FIXED_TS`). Callers /// that measure their own per-leaf durations force them to 0 in this mode so /// goldens stay byte-identical. @@ -422,6 +533,17 @@ impl WorkflowCtx { self.over_budget.get() } + pub fn fail_approval(&self, message: impl Into) { + let mut failure = self.approval_failure.borrow_mut(); + if failure.is_none() { + *failure = Some(message.into()); + } + } + + pub fn approval_failure(&self) -> Option { + self.approval_failure.borrow().clone() + } + // ── Resume / content-key memoization ────────────────────────────────────── /// Set the workflow's code version (folded into every content-key alongside args). @@ -431,6 +553,14 @@ impl WorkflowCtx { *self.code_version.borrow_mut() = v; } + pub fn set_approval_code_version(&self, v: String) { + *self.approval_code_version.borrow_mut() = v; + } + + pub fn set_approval_public_key(&self, v: String) { + *self.approval_public_key.borrow_mut() = v; + } + /// Enter resume mode with the prior run's memos (content-key → value). pub fn enter_resume(&self, memos: HashMap) { self.resuming.set(true); @@ -460,14 +590,15 @@ impl WorkflowCtx { } /// Content-key for an agent leaf: a stable hash over (kind, code-version, args, - /// phase, name, prompt, schema-repr) plus an occurrence ordinal. Length-prefixed - /// so `("a","bc")` and `("ab","c")` never collide. + /// phase, name, prompt, schema-repr, effective-policy) plus an occurrence ordinal. + /// Length-prefixed so `("a","bc")` and `("ab","c")` never collide. pub fn agent_content_key( &self, prompt: &str, schema_repr: &str, name: &str, phase: &str, + policy_fingerprint: &str, ) -> String { let cv = self.code_version.borrow().clone(); let base = hash_fields(&[ @@ -478,6 +609,7 @@ impl WorkflowCtx { name, prompt, schema_repr, + policy_fingerprint, ]); format!("{base}_{}", self.next_occurrence(&base)) } @@ -490,6 +622,22 @@ impl WorkflowCtx { format!("{base}_{}", self.next_occurrence(&base)) } + /// Next occurrence for an explicit approval gate. The base binds the same inputs as + /// the durable request, so repeated identical gates in deterministic body order get + /// distinct request ids that line up on resume. + pub fn approval_occurrence(&self, key: &str, subject_digest: &str, phase: &str) -> u32 { + let cv = self.approval_code_version.borrow().clone(); + let base = crate::approval::sha256_fields(&[ + "approval", + &cv, + &self.args_fingerprint, + phase, + key, + subject_digest, + ]); + self.next_occurrence(&base) + } + /// Look up a memoized value by content-key (only meaningful while `resuming`). pub fn memo_lookup(&self, content_key: &str) -> Option { self.resume_memos.borrow().get(content_key).cloned() @@ -668,6 +816,18 @@ impl WorkflowTaskState { self.inner.borrow().scopes.last().map(|s| Rc::clone(&s.ctx)) } + fn scope_depth(&self) -> usize { + self.inner.borrow().scopes.len() + } + + fn current_scope_is_owned(&self) -> bool { + self.inner + .borrow() + .scopes + .last() + .is_some_and(|scope| scope.token.is_some()) + } + fn cur_agent(&self) -> Option { self.inner.borrow().cur_agent.clone() } @@ -718,6 +878,12 @@ impl TaskLocalValue for WorkflowTaskState { fn as_any(&self) -> &dyn Any { self } + + fn preflight_error(&self) -> Option { + self.current_ctx() + .and_then(|ctx| ctx.approval_failure()) + .map(|message| sema_core::SemaError::WorkflowApprovalFailed { message }) + } } /// Panic-safe RAII guard for one installed workflow scope. Drop removes the EXACT token @@ -791,6 +957,32 @@ pub fn current_for(task_context: Option<&TaskContextHandle>) -> Option) -> bool { + let state = if let Some(handle) = task_context { + handle.get_rc::() + } else if !sema_core::in_runtime_quantum() { + Some(host_state()) + } else { + None + }; + state.is_some_and(|state| state.scope_depth() == 1 && state.current_scope_is_owned()) +} + +pub fn scope_depth_for(task_context: Option<&TaskContextHandle>) -> usize { + if let Some(handle) = task_context { + return handle + .get_rc::() + .map_or(0, |state| state.scope_depth()); + } + if !sema_core::in_runtime_quantum() { + return host_state().scope_depth(); + } + 0 +} + /// The `agent_id` of the step currently executing on `task_context` (TASK-PRIVATE /// attribution), for `workflow/tool-call`. pub fn cur_agent_for(task_context: Option<&TaskContextHandle>) -> Option { @@ -856,15 +1048,45 @@ pub fn set_workflow_scope( meta: &Value, task_context: Option<&TaskContextHandle>, ) -> io::Result { - let runs_root = resolve_runs_root(); - let code_version = std::env::var(CODE_VERSION_ENV).unwrap_or_default(); - let resuming = std::env::var(RESUME_ENV).map(|v| v == "1").unwrap_or(false); + let host = host_config(); + let outermost = scope_depth_for(task_context) == 0; + let runs_root = host + .as_ref() + .map(|config| config.runs_root.clone()) + .unwrap_or_else(resolve_runs_root_from_env); + let code_version = host + .as_ref() + .map(|config| config.code_version.clone()) + .unwrap_or_else(|| std::env::var(CODE_VERSION_ENV).unwrap_or_default()); + let approval_code_version = host + .as_ref() + .map(|config| config.approval_code_version.clone()) + .unwrap_or_else(|| { + std::env::var(APPROVAL_CODE_VERSION_ENV).unwrap_or_else(|_| code_version.clone()) + }); + let approval_public_key = host + .as_ref() + .map(|config| config.approval_public_key.clone()) + .unwrap_or_default(); + let resuming = outermost + && host.as_ref().map_or_else( + || std::env::var(RESUME_ENV).map(|v| v == "1").unwrap_or(false), + |config| config.resuming, + ); // An explicit run id (the `SEMA_WORKFLOW_RUN_ID` seam, or a future library caller) is // validated HERE as exactly one safe path component before it is ever joined into a // filesystem path — the library is the authoritative gate, not just the CLI. - let explicit_id = match std::env::var(RUN_ID_ENV) { - Ok(id) if !id.is_empty() => { + let configured_id = if outermost { + host.as_ref().map_or_else( + || std::env::var(RUN_ID_ENV).ok(), + |config| config.explicit_run_id.clone(), + ) + } else { + None + }; + let explicit_id = match configured_id { + Some(id) if !id.is_empty() => { validate_explicit_run_id(&id)?; Some(id) } @@ -911,14 +1133,23 @@ pub fn set_workflow_scope( "doc": doc, "run_id": run_id, "code_version": code_version, + "approval_code_version": approval_code_version, + "approval_authority_public_key": approval_public_key, + "entry_file": host.as_ref().map(|config| config.entry_file.as_str()).unwrap_or(""), "meta": redact_meta_secrets(sema_core::json::value_to_json_lossy(meta)), }); journal.write_metadata(&metadata); // The `:budget` submap of meta becomes the run's enforced spend caps. // The CLI sets SEMA_WORKFLOW_ARGS_JSON to the verbatim `--args` string. - let args_json = std::env::var("SEMA_WORKFLOW_ARGS_JSON").unwrap_or_default(); + let args_json = host + .as_ref() + .map(|config| config.args_json.clone()) + .unwrap_or_else(|| std::env::var("SEMA_WORKFLOW_ARGS_JSON").unwrap_or_default()); let ctx = WorkflowCtx::new_with_args(run_id.clone(), journal, parse_budget(meta), args_json); + ctx.set_workflow_name(name); ctx.set_code_version(code_version); + ctx.set_approval_code_version(approval_code_version); + ctx.set_approval_public_key(approval_public_key); if resuming { let memos: HashMap = crate::journal::load_memos(&runs_root, &run_id) .into_iter() @@ -950,6 +1181,12 @@ pub fn parse_budget(meta: &Value) -> BTreeMap { /// Resolve the run-directory base: the `SEMA_WORKFLOW_RUN_DIR` seam (set by the CLI /// `--run-dir`) if present, else the project-local [`RUNS_ROOT`]. pub fn resolve_runs_root() -> String { + host_config() + .map(|config| config.runs_root) + .unwrap_or_else(resolve_runs_root_from_env) +} + +fn resolve_runs_root_from_env() -> String { std::env::var(RUN_DIR_ENV).unwrap_or_else(|_| RUNS_ROOT.to_string()) } @@ -982,6 +1219,8 @@ fn canonical_args_fingerprint(args_json: &str) -> String { const RESUME_ENV: &str = "SEMA_WORKFLOW_RESUME"; /// Env seam: a stable hash of the workflow source, folded into every content-key. const CODE_VERSION_ENV: &str = "SEMA_WORKFLOW_CODE_VERSION"; +/// Env seam: collision-resistant source fingerprint for durable approval binding. +const APPROVAL_CODE_VERSION_ENV: &str = "SEMA_WORKFLOW_APPROVAL_CODE_VERSION"; /// Process-wide monotonic nonce folded into every generated run id, so two runs started /// in one process — even within the same nanosecond — never collide on a run directory. @@ -1104,7 +1343,7 @@ fn annotate_fresh_open(err: io::Error, run_id: &str) -> io::Error { /// Format `SystemTime::now()` as an RFC3339 / ISO-8601 UTC string (`YYYY-MM-DDTHH:MM:SSZ`) /// without pulling in `chrono`. Civil-date conversion via the standard /// days-since-epoch algorithm (Howard Hinnant's `civil_from_days`). -fn rfc3339_now() -> String { +pub(crate) fn rfc3339_now() -> String { let dur = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default(); @@ -1210,12 +1449,12 @@ mod tests { let ctx = WorkflowCtx::new("wf_t".into(), Journal::null(), BTreeMap::new()); ctx.set_code_version("v1".into()); // First occurrence of each distinct input is stable; differing inputs differ. - let k_a = ctx.agent_content_key("audit a.php", "[:list :string]", "auditor", "Audit"); - let k_b = ctx.agent_content_key("audit b.php", "[:list :string]", "auditor", "Audit"); + let k_a = ctx.agent_content_key("audit a.php", "[:list :string]", "auditor", "Audit", ""); + let k_b = ctx.agent_content_key("audit b.php", "[:list :string]", "auditor", "Audit", ""); assert_ne!(k_a, k_b, "different prompts ⇒ different keys"); // Length-prefixing: ('a','bc') must not collide with ('ab','c'). - let k1 = ctx.agent_content_key("a", "bc", "n", "p"); - let k2 = ctx.agent_content_key("ab", "c", "n", "p"); + let k1 = ctx.agent_content_key("a", "bc", "n", "p", ""); + let k2 = ctx.agent_content_key("ab", "c", "n", "p", ""); assert_ne!( k1, k2, "length-prefixed fields can't collide via concatenation" @@ -1236,8 +1475,8 @@ mod tests { let ctx2 = WorkflowCtx::new("b".into(), Journal::null(), BTreeMap::new()); ctx2.set_code_version("v2".into()); assert_ne!( - ctx1.agent_content_key("p", "s", "n", "ph"), - ctx2.agent_content_key("p", "s", "n", "ph"), + ctx1.agent_content_key("p", "s", "n", "ph", ""), + ctx2.agent_content_key("p", "s", "n", "ph", ""), "a changed code-version produces different content-keys (auto-invalidation)" ); } diff --git a/crates/sema-workflow/src/event.rs b/crates/sema-workflow/src/event.rs index e4778553f..0f5d23c38 100644 --- a/crates/sema-workflow/src/event.rs +++ b/crates/sema-workflow/src/event.rs @@ -14,8 +14,9 @@ //! Field ordering convention: `seq` then `ts` lead every variant (so a human or //! `jq` scan sees ordering+time first), followed by the variant-specific payload. //! -//! This vocabulary is FROZEN. Add fields to existing variants (append-only, all -//! `Option`/skippable to keep old goldens valid) rather than inventing new variants. +//! Existing variants are FROZEN. Additive variants are allowed. Fields added to an +//! existing variant must be append-only and optional/skippable so old goldens remain +//! valid. use serde::Serialize; @@ -116,6 +117,17 @@ pub enum WorkflowEvent { args_json: String, }, + /// A tool handler returned successfully. The result is an opaque digest or + /// gated sentinel; raw tool output never enters the workflow journal. + #[serde(rename = "agent.tool_result")] + AgentToolResult { + seq: u64, + ts: String, + agent_id: String, + tool_name: String, + result_digest: String, + }, + /// A `checkpoint` recorded a keyed step value. The value itself is NOT stored in /// the event stream — only a (lossy) digest — and a `content_key` resume hash. #[serde(rename = "checkpoint")] @@ -159,8 +171,8 @@ pub enum WorkflowEvent { }, /// Last line of every run. `status` mirrors the `{:status …}` envelope's status - /// (`"success"` / `"failed"` / `"needs-auth"`); `reason` carries the failure - /// reason when failed. + /// (`"success"`, `"failed"`, `"needs-auth"`, `"needs-approval"`, or + /// `"rejected"`); `reason` carries the terminal reason when present. #[serde(rename = "run.ended")] RunEnded { seq: u64, @@ -232,6 +244,193 @@ pub enum WorkflowEvent { /// material. reason: String, }, + + /// A policy layer allowed one protected model or tool boundary. + #[serde(rename = "policy.checked")] + PolicyChecked { + seq: u64, + ts: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + phase_seq: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + agent_id: Option, + policy: String, + policy_digest: String, + boundary: String, + subject: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + subject_digest: Option, + rule: String, + source: String, + }, + + /// A deterministic policy rule observed content in audit-only mode. + #[serde(rename = "policy.flagged")] + PolicyFlagged { + seq: u64, + ts: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + phase_seq: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + agent_id: Option, + policy: String, + policy_digest: String, + boundary: String, + subject: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + subject_digest: Option, + rule: String, + label: String, + count: usize, + action: String, + source: String, + }, + + /// A deterministic policy rule mechanically redacted one or more spans. + #[serde(rename = "policy.redacted")] + PolicyRedacted { + seq: u64, + ts: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + phase_seq: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + agent_id: Option, + policy: String, + policy_digest: String, + boundary: String, + subject: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + subject_digest: Option, + rule: String, + label: String, + count: usize, + source: String, + }, + + /// A policy layer denied one protected model or tool boundary. + #[serde(rename = "policy.violation")] + PolicyViolation { + seq: u64, + ts: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + phase_seq: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + agent_id: Option, + policy: String, + policy_digest: String, + boundary: String, + subject: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + subject_digest: Option, + rule: String, + action: String, + reason: String, + source: String, + }, + + /// A trusted lexical `policy/without` scope bypassed the effective policy stack. + #[serde(rename = "policy.bypassed")] + PolicyBypassed { + seq: u64, + ts: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + phase_seq: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + agent_id: Option, + policy: String, + policy_digest: String, + boundary: String, + subject: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + subject_digest: Option, + rule: String, + reason: String, + source: String, + }, + + /// A durable explicit approval request stopped workflow evaluation. + #[serde(rename = "approval.requested")] + ApprovalRequested { + seq: u64, + ts: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + phase_seq: Option, + approval_id: String, + request_digest: String, + key: String, + reason: String, + subject_digest: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + preview: Option, + }, + + /// An approved durable decision was observed while replaying the gate. + #[serde(rename = "approval.granted")] + ApprovalGranted { + seq: u64, + ts: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + phase_seq: Option, + approval_id: String, + decision_id: String, + actor: String, + provenance: String, + }, + + /// A rejected durable decision was observed while replaying the gate. + #[serde(rename = "approval.rejected")] + ApprovalRejected { + seq: u64, + ts: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + phase_seq: Option, + approval_id: String, + decision_id: String, + actor: String, + provenance: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + reason: Option, + }, + + /// Execution crossed an explicit gate after observing an approved decision. + #[serde(rename = "approval.applied")] + ApprovalApplied { + seq: u64, + ts: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + phase_seq: Option, + approval_id: String, + decision_id: String, + }, +} + +impl WorkflowEvent { + pub fn kind(&self) -> &'static str { + match self { + Self::RunStarted { .. } => "run.started", + Self::PhaseStarted { .. } => "phase.started", + Self::PhaseEnded { .. } => "phase.ended", + Self::AgentStarted { .. } => "agent.started", + Self::AgentResult { .. } => "agent.result", + Self::AgentToolCall { .. } => "agent.tool_call", + Self::AgentToolResult { .. } => "agent.tool_result", + Self::Checkpoint { .. } => "checkpoint", + Self::Budget { .. } => "budget", + Self::RunEnded { .. } => "run.ended", + Self::AuthRequired { .. } => "auth.required", + Self::AuthGranted { .. } => "auth.granted", + Self::AuthFailed { .. } => "auth.failed", + Self::PolicyChecked { .. } => "policy.checked", + Self::PolicyFlagged { .. } => "policy.flagged", + Self::PolicyRedacted { .. } => "policy.redacted", + Self::PolicyViolation { .. } => "policy.violation", + Self::PolicyBypassed { .. } => "policy.bypassed", + Self::ApprovalRequested { .. } => "approval.requested", + Self::ApprovalGranted { .. } => "approval.granted", + Self::ApprovalRejected { .. } => "approval.rejected", + Self::ApprovalApplied { .. } => "approval.applied", + } + } } #[cfg(test)] @@ -406,4 +605,50 @@ mod tests { r#"{"event":"auth.failed","seq":7,"ts":"0","server":"asana","reason":"consent_denied"}"# ); } + + #[test] + fn policy_events_are_additive_and_keep_tool_arguments_opaque() { + let checked = WorkflowEvent::PolicyChecked { + seq: 8, + ts: "0".into(), + phase_seq: Some(2), + agent_id: Some("coder_1".into()), + policy: "safe".into(), + policy_digest: "policy-sha".into(), + boundary: "tool".into(), + subject: "read-file".into(), + subject_digest: Some("args-sha".into()), + rule: "tools.read-file.allow".into(), + source: "live".into(), + }; + assert_eq!( + serde_json::to_string(&checked).unwrap(), + r#"{"event":"policy.checked","seq":8,"ts":"0","phase_seq":2,"agent_id":"coder_1","policy":"safe","policy_digest":"policy-sha","boundary":"tool","subject":"read-file","subject_digest":"args-sha","rule":"tools.read-file.allow","source":"live"}"# + ); + + let violation = WorkflowEvent::PolicyViolation { + seq: 9, + ts: "0".into(), + phase_seq: None, + agent_id: None, + policy: "safe".into(), + policy_digest: "policy-sha".into(), + boundary: "model".into(), + subject: "openai/gpt-5".into(), + subject_digest: None, + rule: "models.default-deny".into(), + action: "fail".into(), + reason: "not allowlisted".into(), + source: "cache".into(), + }; + let line = serde_json::to_string(&violation).unwrap(); + assert_eq!( + line, + r#"{"event":"policy.violation","seq":9,"ts":"0","policy":"safe","policy_digest":"policy-sha","boundary":"model","subject":"openai/gpt-5","rule":"models.default-deny","action":"fail","reason":"not allowlisted","source":"cache"}"# + ); + assert!( + !line.contains("args_json"), + "policy events must never expose raw tool arguments" + ); + } } diff --git a/crates/sema-workflow/src/lib.rs b/crates/sema-workflow/src/lib.rs index a17ac5efe..597ce852b 100644 --- a/crates/sema-workflow/src/lib.rs +++ b/crates/sema-workflow/src/lib.rs @@ -11,6 +11,7 @@ //! The builtins that invoke Sema thunks (`workflow/run`, `workflow/phase`, //! `checkpoint`, `workflow/agent`) live in `sema-stdlib`, which depends on this crate. +pub mod approval; pub mod context; pub mod event; mod journal; diff --git a/crates/sema/Cargo.toml b/crates/sema/Cargo.toml index c74fa6521..2f8def0f4 100644 --- a/crates/sema/Cargo.toml +++ b/crates/sema/Cargo.toml @@ -63,6 +63,7 @@ toml.workspace = true toml_edit.workspace = true serde.workspace = true reqwest.workspace = true +url.workspace = true directories.workspace = true sha2.workspace = true semver.workspace = true diff --git a/crates/sema/src/import_tracer.rs b/crates/sema/src/import_tracer.rs index 5ea51e9ce..b89dedd67 100644 --- a/crates/sema/src/import_tracer.rs +++ b/crates/sema/src/import_tracer.rs @@ -18,6 +18,25 @@ use sema_core::Value; /// dependency. The root file itself is **not** included (it is compiled to /// bytecode separately). pub fn trace_imports(root_file: &Path) -> Result>, String> { + trace_imports_with_mode(root_file, false).map(|snapshot| snapshot.files) +} + +/// Exact dependency bytes and their filesystem identities for an approval run. +#[derive(Debug)] +pub struct StrictImportSnapshot { + /// Portable VFS keys used to hash and embed the dependency closure. + pub files: HashMap>, + /// Filesystem spellings used to make absolute imports hit the snapshot. + pub filesystem_files: HashMap>, +} + +/// Approval binding variant: every import must be literal, readable, and inside the +/// project/package roots so the returned map is a complete executable dependency set. +pub fn trace_imports_strict(root_file: &Path) -> Result { + trace_imports_with_mode(root_file, true) +} + +fn trace_imports_with_mode(root_file: &Path, strict: bool) -> Result { let root_file = root_file .canonicalize() .map_err(|e| format!("cannot canonicalize root file {}: {e}", root_file.display()))?; @@ -29,6 +48,7 @@ pub fn trace_imports(root_file: &Path) -> Result>, Strin let mut visited: HashSet = HashSet::new(); let mut result: HashMap> = HashMap::new(); + let mut filesystem_files: HashMap> = HashMap::new(); // Mark the root file as visited so we never add it to the result map. visited.insert(root_file.clone()); @@ -37,12 +57,28 @@ pub fn trace_imports(root_file: &Path) -> Result>, Strin let source = std::fs::read_to_string(&root_file) .map_err(|e| format!("cannot read root file {}: {e}", root_file.display()))?; - let exprs = sema_reader::read_many(&source) - .map_err(|e| format!("parse error in {}: {}", root_file.display(), e.inner()))?; - - trace_file_imports(&exprs, &root_file, &root_dir, &mut visited, &mut result)?; + let exprs = sema_reader::read_many(&source).map_err(|e| { + format!( + "parse failed in {}: {}", + root_file.display(), + e.format_plain() + ) + })?; - Ok(result) + trace_file_imports( + &exprs, + &root_file, + &root_dir, + &mut visited, + &mut result, + &mut filesystem_files, + strict, + )?; + + Ok(StrictImportSnapshot { + files: result, + filesystem_files, + }) } /// Parse the expressions from a single file and extract all import/load paths, @@ -53,9 +89,19 @@ fn trace_file_imports( root_dir: &Path, visited: &mut HashSet, result: &mut HashMap>, + filesystem_files: &mut HashMap>, + strict: bool, ) -> Result<(), String> { for expr in exprs { - extract_imports(expr, current_file, root_dir, visited, result)?; + extract_imports( + expr, + current_file, + root_dir, + visited, + result, + filesystem_files, + strict, + )?; } Ok(()) } @@ -69,6 +115,8 @@ fn extract_imports( root_dir: &Path, visited: &mut HashSet, result: &mut HashMap>, + filesystem_files: &mut HashMap>, + strict: bool, ) -> Result<(), String> { let items = match expr.as_list() { Some(items) if !items.is_empty() => items, @@ -83,15 +131,29 @@ fn extract_imports( "import" | "load" => { if items.len() >= 2 { if let Some(path_str) = items[1].as_str() { - process_import(path_str, current_file, root_dir, visited, result)?; + process_import( + path_str, + current_file, + root_dir, + visited, + result, + filesystem_files, + strict, + )?; } else { + if strict { + return Err(format!( + "dynamic {head} in {} cannot be bound to an approval revision", + current_file.display() + )); + } // Dynamic import -- cannot resolve statically. - eprintln!( - "warning: dynamic {} in {} cannot be resolved statically; \ + crate::print_cli_warning(format!( + "dynamic {} in {} cannot be resolved statically; \ use --include to add it manually", head, current_file.display() - ); + )); } } // Don't recurse further into import/load forms. @@ -103,7 +165,15 @@ fn extract_imports( // may be an (export ...) form in there too -- just recurse // into everything after the name. for item in items.iter().skip(2) { - extract_imports(item, current_file, root_dir, visited, result)?; + extract_imports( + item, + current_file, + root_dir, + visited, + result, + filesystem_files, + strict, + )?; } return Ok(()); } @@ -113,7 +183,15 @@ fn extract_imports( // For any other list, recurse into all children. for item in items.iter() { - extract_imports(item, current_file, root_dir, visited, result)?; + extract_imports( + item, + current_file, + root_dir, + visited, + result, + filesystem_files, + strict, + )?; } Ok(()) @@ -127,9 +205,18 @@ fn process_import( root_dir: &Path, visited: &mut HashSet, result: &mut HashMap>, + filesystem_files: &mut HashMap>, + strict: bool, ) -> Result<(), String> { if is_package_import(import_path) { - return process_package_import(import_path, root_dir, visited, result); + return process_package_import( + import_path, + root_dir, + visited, + result, + filesystem_files, + strict, + ); } // Resolve relative to the directory of the importing file. @@ -148,18 +235,27 @@ fn process_import( let canonical = match resolved.canonicalize() { Ok(c) => c, Err(_) => { - eprintln!( - " warning: import \"{}\" (from {}) couldn't be resolved at build time; \ + if strict { + return Err(format!( + "import {import_path:?} from {} cannot be resolved for approval binding", + current_file.display() + )); + } + crate::print_cli_warning(format!( + "import \"{}\" (from {}) could not be resolved at build time; \ not bundled — it will be resolved at runtime (filesystem/VFS)", import_path, current_file.display() - ); + )); return Ok(()); } }; // Circular import protection. if visited.contains(&canonical) { + if let Some(contents) = filesystem_files.get(&canonical).cloned() { + filesystem_files.insert(resolved, contents); + } return Ok(()); } visited.insert(canonical.clone()); @@ -168,14 +264,22 @@ fn process_import( let contents = match std::fs::read(&canonical) { Ok(c) => c, Err(_) => { - eprintln!( - " warning: import \"{}\" couldn't be read at build time; not bundled \ + if strict { + return Err(format!( + "import {} cannot be read for approval binding", + canonical.display() + )); + } + crate::print_cli_warning(format!( + "import \"{}\" could not be read at build time; not bundled \ (resolved at runtime)", canonical.display() - ); + )); return Ok(()); } }; + filesystem_files.insert(resolved, contents.clone()); + filesystem_files.insert(canonical.clone(), contents.clone()); // Compute relative path for the VFS key. // Check packages_dir FIRST — package files must get package-relative keys @@ -190,21 +294,33 @@ fn process_import( } else if let Ok(rel) = canonical.strip_prefix(root_dir) { rel.to_string_lossy().replace('\\', "/") } else { - eprintln!( - " warning: imported file {} is outside the project and packages \ + if strict { + return Err(format!( + "imported file {} is outside the project and packages directories", + canonical.display() + )); + } + crate::print_cli_warning(format!( + "imported file {} is outside the project and packages \ directories; not bundled (resolved at runtime)", canonical.display() - ); + )); return Ok(()); } } else if let Ok(rel) = canonical.strip_prefix(root_dir) { rel.to_string_lossy().replace('\\', "/") } else { - eprintln!( - " warning: imported file {} is outside the project directory; not \ + if strict { + return Err(format!( + "imported file {} is outside the project directory", + canonical.display() + )); + } + crate::print_cli_warning(format!( + "imported file {} is outside the project directory; not \ bundled (resolved at runtime)", canonical.display() - ); + )); return Ok(()); } }; @@ -229,8 +345,26 @@ fn process_import( // Recursively trace the imported file's own imports. // Only parse if it looks like a text file (sema source). if let Ok(source) = std::str::from_utf8(&contents) { - if let Ok(exprs) = sema_reader::read_many(source) { - trace_file_imports(&exprs, &canonical, root_dir, visited, result)?; + match sema_reader::read_many(source) { + Ok(exprs) => { + trace_file_imports( + &exprs, + &canonical, + root_dir, + visited, + result, + filesystem_files, + strict, + )?; + } + Err(error) if strict => { + return Err(format!( + "cannot parse imported file {} for approval binding: {}", + canonical.display(), + error.format_plain() + )); + } + Err(_) => {} } // If parsing fails, we still included the file -- just don't trace deeper. } @@ -245,6 +379,8 @@ fn process_package_import( root_dir: &Path, visited: &mut HashSet, result: &mut HashMap>, + filesystem_files: &mut HashMap>, + strict: bool, ) -> Result<(), String> { let resolved = match resolve_package_import(import_path) { Ok(p) => p, @@ -270,6 +406,7 @@ fn process_package_import( let contents = std::fs::read(&canonical) .map_err(|e| format!("cannot read {}: {e}", canonical.display()))?; + filesystem_files.insert(canonical.clone(), contents.clone()); // Use the package path as the VFS key for portability. // Validate and check for collisions. @@ -289,8 +426,25 @@ fn process_package_import( // Recursively trace the package file's own imports. if let Ok(source) = std::str::from_utf8(&contents) { - if let Ok(exprs) = sema_reader::read_many(source) { - trace_file_imports(&exprs, &canonical, root_dir, visited, result)?; + match sema_reader::read_many(source) { + Ok(exprs) => { + trace_file_imports( + &exprs, + &canonical, + root_dir, + visited, + result, + filesystem_files, + strict, + )?; + } + Err(error) if strict => { + return Err(format!( + "cannot parse package import {import_path:?} for approval binding: {}", + error.format_plain() + )); + } + Err(_) => {} } } @@ -389,6 +543,22 @@ mod tests { let _ = fs::remove_dir_all(&dir); } + #[test] + fn strict_trace_rejects_dynamic_and_unresolved_imports() { + let dir = tmpdir("strict-incomplete"); + let main = dir.join("main.sema"); + fs::write(&main, "(import some-var)").unwrap(); + assert!(trace_imports_strict(&main) + .unwrap_err() + .contains("cannot be bound to an approval revision")); + + fs::write(&main, r#"(import "missing.sema")"#).unwrap(); + assert!(trace_imports_strict(&main) + .unwrap_err() + .contains("cannot be resolved for approval binding")); + let _ = fs::remove_dir_all(&dir); + } + #[test] fn test_trace_missing_import_warns_not_errors() { // A literal import the tracer can't resolve at build time is NOT a build diff --git a/crates/sema/src/lib.rs b/crates/sema/src/lib.rs index ae7d710e0..fe98f6122 100644 --- a/crates/sema/src/lib.rs +++ b/crates/sema/src/lib.rs @@ -14,6 +14,7 @@ use std::rc::Rc; +pub mod workflow_evidence; pub mod workflow_mcp; // `sema workflow view` — the dashboard server. Lives in the library (not just // `main.rs`) so `crates/sema/tests/*.rs` integration tests can drive it diff --git a/crates/sema/src/main.rs b/crates/sema/src/main.rs index 6e3ed3edd..c6847b55c 100644 --- a/crates/sema/src/main.rs +++ b/crates/sema/src/main.rs @@ -3,7 +3,7 @@ use std::io::IsTerminal; use std::path::{Path, PathBuf}; use std::rc::Rc; -use clap::{CommandFactory, Parser, Subcommand}; +use clap::{CommandFactory, Parser, Subcommand, ValueEnum}; use clap_complete::Shell; use sema_core::{archive, pretty_print, SemaError, Value, ValueView}; @@ -558,16 +558,55 @@ enum PkgCommands { }, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +enum ApprovalMode { + /// Prompt on a real terminal unless a durable authority is configured; + /// otherwise pause with exit code 3. + Auto, + /// Require an interactive terminal prompt. + Prompt, + /// Leave the request pending and exit 3. + Pause, + /// Do not prompt; fail the command when a gate is reached. + Deny, +} + +fn resolve_approval_mode( + requested: ApprovalMode, + interactive: bool, + has_durable_authority: bool, +) -> ApprovalMode { + match requested { + ApprovalMode::Auto if has_durable_authority => ApprovalMode::Pause, + ApprovalMode::Auto if interactive => ApprovalMode::Prompt, + ApprovalMode::Auto => ApprovalMode::Pause, + mode => mode, + } +} + +fn validate_interactive_approval_authority( + mode: ApprovalMode, + interactive: bool, + has_public_key: bool, + has_signing_key: bool, +) -> Result<(), &'static str> { + if mode == ApprovalMode::Prompt && interactive && has_public_key && !has_signing_key { + Err( + "--approval-mode prompt cannot sign with --approval-public-key-file; omit the public key for an ephemeral terminal authority, or use --view with the matching --approval-signing-key-file", + ) + } else { + Ok(()) + } +} + #[derive(Subcommand)] enum WorkflowCommands { /// Run a workflow file (a `.sema` program that `defworkflow`s and runs it), /// journaling a frozen run-directory and writing `result.json`. /// - /// Exit codes: 0 success, 1 failed, 2 the run needs MCP authentication (see - /// stderr for which server(s) and the `sema mcp login` command to run, then - /// re-run this workflow). On an interactive terminal, a needs-auth gate logs - /// in inline (the browser/loopback flow) instead of exiting 2; `--no-auth-prompt` - /// forces the headless exit-2 behavior even on a TTY. + /// Exit codes: 0 success, 1 failed/rejected, 2 needs MCP authentication, 3 + /// needs human approval. On an interactive terminal the default mode handles + /// auth and approval inline; the flags below select headless behavior. Run { /// Path to the `.sema` workflow file. file: String, @@ -603,6 +642,88 @@ enum WorkflowCommands { /// gets the exit-2 behavior. #[arg(long)] no_auth_prompt: bool, + + /// How a pending human approval is handled. + #[arg(long, value_enum, default_value = "auto")] + approval_mode: ApprovalMode, + + /// File containing the base64 Ed25519 public key authorized to decide headless + /// approval requests. Interactive prompt mode generates an in-memory key when + /// this is omitted. + #[arg(long)] + approval_public_key_file: Option, + + /// Private approval authority exposed only to the loopback web viewer. Its + /// public key becomes the run authority; the private key never enters Sema. + #[arg(long, requires = "view")] + approval_signing_key_file: Option, + + /// Default actor recorded for decisions made in the web viewer. + #[arg(long, requires = "approval_signing_key_file")] + approval_actor: Option, + }, + /// List durable approval requests and decisions for one run. + Approvals { + /// Run id (the single directory name under --run-dir). + run_id: String, + + /// Base directory holding workflow run directories. + #[arg(long, default_value = ".sema/runs")] + run_dir: String, + + /// Emit a JSON array instead of a human-readable list. + #[arg(long)] + json: bool, + }, + /// Approve one pending workflow request. + Approve { + run_id: String, + approval_id: String, + + #[arg(long, default_value = ".sema/runs")] + run_dir: String, + + /// Optional audit comment. + #[arg(long)] + comment: Option, + + /// Actor recorded in the decision (defaults to SEMA_APPROVAL_ACTOR/USER). + #[arg(long)] + actor: Option, + + /// Private Ed25519 PKCS#8 key created by `workflow approval-keygen`. + #[arg(long)] + signing_key_file: String, + }, + /// Reject one pending workflow request. + Reject { + run_id: String, + approval_id: String, + + #[arg(long, default_value = ".sema/runs")] + run_dir: String, + + /// Required rejection reason recorded in the decision. + #[arg(long)] + reason: String, + + /// Actor recorded in the decision (defaults to SEMA_APPROVAL_ACTOR/USER). + #[arg(long)] + actor: Option, + + /// Private Ed25519 PKCS#8 key created by `workflow approval-keygen`. + #[arg(long)] + signing_key_file: String, + }, + /// Generate an Ed25519 approval authority key pair. + ApprovalKeygen { + /// New private-key file (created with mode 0600 on Unix). + #[arg(long)] + private_key_file: String, + + /// New public-key file safe to pass to workflow runs. + #[arg(long)] + public_key_file: String, }, /// Backfill the cross-run SQLite index (`/index.db`) from every run's /// journal — for offline/CI use; the viewer also syncs lazily on request. @@ -611,6 +732,19 @@ enum WorkflowCommands { #[arg(long, default_value = ".sema/runs")] run_dir: String, }, + /// Export a deterministic evidence bundle for one completed workflow run. + Export { + /// Run id (the single directory name under `--run-dir`). + run_id: String, + + /// Base directory holding `/events.jsonl` run journals. + #[arg(long, default_value = ".sema/runs")] + run_dir: String, + + /// Output directory. Defaults to `//evidence`. + #[arg(long)] + out_dir: Option, + }, /// Open the web viewer for a run directory's workflow journals View { /// Base directory holding `/events.jsonl` run journals. @@ -625,6 +759,15 @@ enum WorkflowCommands { /// Port to listen on. #[arg(short, long, default_value = "8899")] port: u16, + + /// Private Ed25519 key enabling approve/reject controls. Approval-enabled + /// viewers are restricted to loopback hosts. + #[arg(long)] + approval_signing_key_file: Option, + + /// Default actor recorded for decisions made in the web viewer. + #[arg(long, requires = "approval_signing_key_file")] + approval_actor: Option, }, /// Statically validate a workflow `.sema` file WITHOUT evaluating it or calling any LLM /// — catches arity traps, bad step opts, and layout issues before a run. @@ -743,27 +886,85 @@ fn build_interpreter(sandbox: &sema_core::Sandbox) -> Interpreter { /// Ctrl-C keypress at the REPL prompt itself never reaches this handler as a /// real `SIGINT` — it is delivered to reedline as a raw key event and handled /// entirely by the REPL's own `Signal::CtrlC` branch, unchanged. +struct CliCtrlCState { + handle: std::sync::Mutex>, + prompt_active: std::sync::atomic::AtomicBool, + started: std::time::Instant, + last_sigint_ms: std::sync::atomic::AtomicU64, +} + +static CLI_CTRLC_STATE: std::sync::OnceLock = std::sync::OnceLock::new(); +static CLI_CTRLC_HANDLER: std::sync::OnceLock> = std::sync::OnceLock::new(); + fn install_ctrlc_handler(interpreter: &Interpreter) { - let handle = interpreter.command_handle(); - let start = std::time::Instant::now(); - let last_sigint_ms = std::sync::atomic::AtomicU64::new(0); - // A second interpreter in the same process would hit MultipleHandlers here, - // leaving Ctrl-C pinned to the FIRST interpreter's (possibly dead) handle — - // single-press would then no-op (double-press still hard-exits). Every - // current build_interpreter call site is a mutually exclusive subcommand - // path, so the install runs once per process. - let _ = ctrlc::set_handler(move || { - let now_ms = start.elapsed().as_millis() as u64; - let previous_ms = last_sigint_ms.swap(now_ms, std::sync::atomic::Ordering::SeqCst); - if is_double_interrupt(previous_ms, now_ms) { - // Exit code 130 = 128 + SIGINT(2), the shell convention for - // "killed by Ctrl-C" (matches what the OS default SIGINT - // disposition would have produced had we never installed a - // handler). - std::process::exit(130); - } - handle.cancel_all(); + let state = CLI_CTRLC_STATE.get_or_init(|| CliCtrlCState { + handle: std::sync::Mutex::new(None), + prompt_active: std::sync::atomic::AtomicBool::new(false), + started: std::time::Instant::now(), + last_sigint_ms: std::sync::atomic::AtomicU64::new(0), + }); + *state + .handle + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(interpreter.command_handle()); + let result = CLI_CTRLC_HANDLER.get_or_init(|| { + ctrlc::set_handler(|| { + let Some(state) = CLI_CTRLC_STATE.get() else { + return; + }; + // `read_line` may be restarted by the OS. Exiting is the only portable way + // to make a prompt Ctrl-C immediate; the request was already durably + // published, so this deliberately leaves it pending. + if state + .prompt_active + .load(std::sync::atomic::Ordering::SeqCst) + { + std::process::exit(130); + } + let now_ms = state.started.elapsed().as_millis() as u64; + let previous_ms = state + .last_sigint_ms + .swap(now_ms, std::sync::atomic::Ordering::SeqCst); + if is_double_interrupt(previous_ms, now_ms) { + std::process::exit(130); + } + if let Some(handle) = state + .handle + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .as_ref() + { + handle.cancel_all(); + } + }) + .map_err(|error| error.to_string()) }); + if let Err(error) = result { + print_cli_warning(format!("cannot install Ctrl-C handler: {error}")); + } +} + +struct ApprovalPromptCtrlCGuard; + +impl ApprovalPromptCtrlCGuard { + fn enter() -> Self { + if let Some(state) = CLI_CTRLC_STATE.get() { + state + .prompt_active + .store(true, std::sync::atomic::Ordering::SeqCst); + } + Self + } +} + +impl Drop for ApprovalPromptCtrlCGuard { + fn drop(&mut self) { + if let Some(state) = CLI_CTRLC_STATE.get() { + state + .prompt_active + .store(false, std::sync::atomic::Ordering::SeqCst); + } + } } /// The double-interrupt decision `install_ctrlc_handler` applies on every @@ -784,7 +985,11 @@ fn is_double_interrupt(previous_ms: u64, now_ms: u64) -> bool { #[cfg(test)] mod ctrlc_tests { - use super::is_double_interrupt; + use super::{ + format_needs_approval_guidance, is_double_interrupt, resolve_approval_mode, shell_quote, + terminal_safe, validate_interactive_approval_authority, ApprovalMode, Value, + }; + use std::collections::BTreeMap; #[test] fn first_signal_never_hard_exits() { @@ -805,6 +1010,74 @@ mod ctrlc_tests { assert!(!is_double_interrupt(1_000, 3_000)); assert!(!is_double_interrupt(1_000, 60_000)); } + + #[test] + fn auto_approval_preserves_a_configured_durable_authority() { + assert_eq!( + resolve_approval_mode(ApprovalMode::Auto, true, true), + ApprovalMode::Pause + ); + assert_eq!( + resolve_approval_mode(ApprovalMode::Auto, true, false), + ApprovalMode::Prompt + ); + assert_eq!( + resolve_approval_mode(ApprovalMode::Auto, false, false), + ApprovalMode::Pause + ); + } + + #[test] + fn interactive_prompt_rejects_a_public_only_authority() { + assert!( + validate_interactive_approval_authority(ApprovalMode::Prompt, true, true, false) + .is_err() + ); + assert!( + validate_interactive_approval_authority(ApprovalMode::Prompt, true, true, true).is_ok() + ); + assert!( + validate_interactive_approval_authority(ApprovalMode::Prompt, false, true, false) + .is_ok() + ); + } + + #[test] + fn approval_terminal_text_escapes_control_and_bidi_characters() { + let rendered = terminal_safe("ok\n\u{1b}[31mspoof\u{061c}\u{202e}"); + assert_eq!(rendered, "ok\\u{a}\\u{1b}[31mspoof\\u{61c}\\u{202e}"); + assert!(!rendered.contains('\u{1b}')); + assert!(!rendered.contains('\n')); + + #[cfg(not(windows))] + { + let quoted = shell_quote("line\n\u{202e}tail"); + assert_eq!(quoted, "$'line\\x0a\\u202etail'"); + assert!(!quoted.contains('\n')); + assert!(!quoted.contains('\u{202e}')); + } + } + + #[cfg(not(windows))] + #[test] + fn approval_guidance_is_copy_safe_and_preserves_exact_resume_inputs() { + let mut envelope = BTreeMap::new(); + envelope.insert(Value::keyword("run-id"), Value::string("run one")); + envelope.insert(Value::keyword("approval-id"), Value::string("apr one")); + let rendered = format_needs_approval_guidance( + &Value::map(envelope), + "/tmp/run dir", + "/tmp/work flow.sema", + r#"{"target":"a b"}"#, + Some("/tmp/key file.public"), + None, + ); + assert!(rendered.contains("--signing-key-file \"$SEMA_APPROVAL_PRIVATE_KEY\"")); + assert!(!rendered.contains("")); + assert!(rendered.contains( + r#"sema workflow run $'/tmp/work flow.sema' --args $'{"target":"a b"}' --resume $'run one' --run-dir $'/tmp/run dir' --approval-public-key-file $'/tmp/key file.public'"# + )); + } } fn main() { @@ -843,7 +1116,7 @@ fn main() { let sandbox = match &cli.sandbox { Some(value) => sema_core::Sandbox::parse_cli(value).unwrap_or_else(|e| { - eprintln!("Error: {e}"); + print_cli_error(e); std::process::exit(1); }), None => sema_core::Sandbox::allow_all(), @@ -897,7 +1170,7 @@ fn main() { docs::PagerMode::Auto }; if let Err(msg) = run_doc(command, symbol, pager) { - eprintln!("Error: {msg}"); + print_cli_error(msg); std::process::exit(1); } } @@ -928,7 +1201,7 @@ fn main() { } }; if let Err(e) = result { - eprintln!("Error: {e}"); + print_cli_error(e); std::process::exit(1); } } @@ -957,7 +1230,7 @@ fn main() { no_cache, BuildOutputOpts { verbose, json }, ) { - eprintln!("Error: {e}"); + print_cli_error(e); std::process::exit(1); } } @@ -1021,7 +1294,7 @@ fn main() { McpAuthCommands::List => sema_mcp::mcp_list(), }; if let Err(e) = result { - eprintln!("mcp: {e}"); + print_cli_error(format!("MCP command failed: {e}")); std::process::exit(1); } return; @@ -1043,7 +1316,7 @@ fn main() { // exactly that behavior. let sandbox = match mcp_sandbox.as_deref() { Some(value) => sema_core::Sandbox::parse_cli(value).unwrap_or_else(|e| { - eprintln!("mcp: invalid --sandbox: {e}"); + print_cli_error(format!("invalid MCP --sandbox value: {e}")); std::process::exit(1); }), None => sandbox, @@ -1056,12 +1329,12 @@ fn main() { match read_source_file(&file) { Ok(content) => { if let Err(e) = interpreter.eval_str_compiled(&content) { - eprintln!("Error loading tool file {file}: {e}"); + print_cli_error(format!("could not load tool file {file}: {e}")); std::process::exit(1); } } Err(e) => { - eprintln!("Error reading tool file {file}: {e}"); + print_cli_error(format!("could not read tool file {file}: {e}")); std::process::exit(1); } } @@ -1071,7 +1344,7 @@ fn main() { // runtime, llm/* builtins hit io_block_on's runtime-in-runtime // panic and killed the server on the first LLM tool call. if let Err(e) = sema_mcp::run_mcp_server_sync(interpreter, inc_tools, exc_tools) { - eprintln!("MCP server error: {e}"); + print_cli_error(format!("MCP server failed: {e}")); std::process::exit(1); } } @@ -1086,7 +1359,7 @@ fn main() { no_llm, } => { if let Err(e) = web::run(&file, &host, port, !no_open, !no_llm) { - eprintln!("sema web: {e}"); + print_cli_error(format!("sema web failed: {e}")); std::process::exit(1); } } @@ -1115,7 +1388,7 @@ fn main() { yes, }; if let Err(e) = update::run(opts) { - eprintln!("Error: {e}"); + print_cli_error(e); std::process::exit(1); } } @@ -1173,7 +1446,7 @@ fn main() { } } Err(msg) => { - eprintln!("error: {msg}"); + print_cli_error(msg); std::process::exit(1); } } @@ -1264,11 +1537,13 @@ fn main() { } } Err(msg) if msg.starts_with("file not found:") => { - eprintln!("error: file not found: '{file}' (not a file or command)\n\nRun 'sema --help' for available commands."); + print_cli_error(format!( + "file not found: '{file}' (not a file or command)\n\nRun 'sema --help' for available commands." + )); std::process::exit(1); } Err(msg) => { - eprintln!("error: {msg}"); + print_cli_error(msg); std::process::exit(1); } } @@ -1282,11 +1557,573 @@ fn main() { repl::run(interpreter, cli.quiet, cli.sandbox.as_deref()); } +fn default_approval_actor() -> String { + std::env::var("SEMA_APPROVAL_ACTOR") + .or_else(|_| std::env::var("USER")) + .or_else(|_| std::env::var("USERNAME")) + .unwrap_or_else(|_| "local-user".to_string()) +} + +fn read_approval_key_file(path: &Path, private: bool) -> Result { + use std::io::Read as _; + + const MAX_KEY_BYTES: u64 = 16 * 1024; + let file = if private { + sema_workflow::approval::open_private_file(path) + } else { + let mut options = std::fs::OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt as _; + options.custom_flags(libc::O_NOFOLLOW); + } + options.open(path) + } + .map_err(|error| format!("cannot open approval key {}: {error}", path.display()))?; + let metadata = file + .metadata() + .map_err(|error| format!("cannot inspect approval key {}: {error}", path.display()))?; + if !metadata.is_file() { + return Err(format!("{} is not a regular file", path.display())); + } + if metadata.len() > MAX_KEY_BYTES { + return Err(format!( + "{} is too large to be an approval key", + path.display() + )); + } + let mut bytes = Vec::with_capacity(metadata.len() as usize); + file.take(MAX_KEY_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|error| format!("cannot read approval key {}: {error}", path.display()))?; + if bytes.len() as u64 > MAX_KEY_BYTES { + return Err(format!( + "{} is too large to be an approval key", + path.display() + )); + } + let value = String::from_utf8(bytes) + .map_err(|_| format!("approval key {} is not UTF-8", path.display()))?; + if private && value.trim().is_empty() { + return Err("approval signing key file is empty".to_string()); + } + Ok(value.trim().to_string()) +} + +fn load_approval_signing_key( + path: &Path, +) -> Result { + let encoded = read_approval_key_file(path, true)?; + sema_workflow::approval::ApprovalSigningKey::from_base64(&encoded) + .map_err(|error| error.to_string()) +} + +fn create_approval_key_pair(private_path: &Path, public_path: &Path) -> Result<(), String> { + use std::io::Write as _; + + if private_path == public_path { + return Err("private and public approval key paths must differ".to_string()); + } + let key = sema_workflow::approval::ApprovalSigningKey::generate() + .map_err(|error| error.to_string())?; + let mut private = sema_workflow::approval::create_private_file_new(private_path) + .map_err(|error| format!("cannot create {}: {error}", private_path.display()))?; + let mut public_file = match std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(public_path) + { + Ok(file) => file, + Err(error) => { + drop(private); + let _ = std::fs::remove_file(private_path); + return Err(format!("cannot create {}: {error}", public_path.display())); + } + }; + let write_result = private + .write_all(format!("{}\n", key.to_base64()).as_bytes()) + .and_then(|()| private.sync_all()) + .map_err(|error| format!("cannot write {}: {error}", private_path.display())) + .and_then(|()| { + let public = key.public_key_base64().map_err(|error| error.to_string())?; + public_file + .write_all(format!("{public}\n").as_bytes()) + .and_then(|()| public_file.sync_all()) + .map_err(|error| format!("cannot write {}: {error}", public_path.display())) + }); + drop(private); + drop(public_file); + if let Err(error) = write_result { + let _ = std::fs::remove_file(public_path); + let _ = std::fs::remove_file(private_path); + return Err(error); + } + Ok(()) +} + +struct WorkflowApprovalRevision { + digest: String, + embedded_dependencies: Vec<(PathBuf, Vec)>, +} + +fn workflow_approval_revision( + file: &Path, + content: &[u8], +) -> Result { + use sha2::{Digest as _, Sha256}; + + const MAX_FILES: usize = 4096; + const MAX_BYTES: usize = 64 * 1024 * 1024; + + let snapshot = import_tracer::trace_imports_strict(file)?; + let mut dependencies = snapshot.files.into_iter().collect::>(); + dependencies.sort_by(|left, right| left.0.cmp(&right.0)); + if dependencies.len() > MAX_FILES { + return Err(format!( + "workflow dependency closure exceeds {MAX_FILES} files" + )); + } + + let mut manifests = Vec::with_capacity(2); + + let mut ancestor = file + .canonicalize() + .map_err(|error| format!("cannot canonicalize {}: {error}", file.display()))? + .parent() + .map(Path::to_path_buf); + while let Some(directory) = ancestor { + for name in ["sema.toml", "sema.lock"] { + let path = directory.join(name); + if path.is_file() + && !dependencies.iter().any(|(key, _)| key == name) + && !manifests.iter().any(|(key, _)| key == name) + { + let bytes = std::fs::read(&path) + .map_err(|error| format!("cannot read {}: {error}", path.display()))?; + manifests.push((name.to_string(), bytes)); + } + } + ancestor = directory.parent().map(Path::to_path_buf); + } + + let mut inputs = Vec::with_capacity(dependencies.len() + manifests.len() + 1); + inputs.push(("", content)); + inputs.extend( + dependencies + .iter() + .map(|(name, bytes)| (name.as_str(), bytes.as_slice())), + ); + inputs.extend( + manifests + .iter() + .map(|(name, bytes)| (name.as_str(), bytes.as_slice())), + ); + inputs.sort_by(|left, right| left.0.cmp(right.0)); + + let total_bytes = inputs.iter().try_fold(0usize, |total, (_, bytes)| { + total + .checked_add(bytes.len()) + .ok_or_else(|| "workflow dependency closure byte count overflowed".to_string()) + })?; + if total_bytes > MAX_BYTES { + return Err(format!( + "workflow dependency closure exceeds {MAX_BYTES} bytes" + )); + } + + let mut digest = Sha256::new(); + digest.update(b"sema-workflow-approval-revision-v1"); + for (name, bytes) in inputs { + digest.update((name.len() as u64).to_le_bytes()); + digest.update(name.as_bytes()); + digest.update((bytes.len() as u64).to_le_bytes()); + digest.update(bytes); + } + + let mut embedded_dependencies = std::collections::BTreeMap::new(); + for (name, bytes) in dependencies { + embedded_dependencies.insert(PathBuf::from(name), bytes); + } + for (filesystem_path, bytes) in snapshot.filesystem_files { + match embedded_dependencies.entry(filesystem_path.clone()) { + std::collections::btree_map::Entry::Vacant(entry) => { + entry.insert(bytes.clone()); + } + std::collections::btree_map::Entry::Occupied(entry) if entry.get() == &bytes => {} + std::collections::btree_map::Entry::Occupied(entry) => { + return Err(format!( + "dependency snapshot key collision at {}", + entry.key().display() + )); + } + } + let alias = sema_core::vfs::normalize_path(&filesystem_path).ok_or_else(|| { + format!( + "cannot normalize imported file identity {}", + filesystem_path.display() + ) + })?; + match embedded_dependencies.entry(PathBuf::from(alias)) { + std::collections::btree_map::Entry::Vacant(entry) => { + entry.insert(bytes); + } + std::collections::btree_map::Entry::Occupied(entry) if entry.get() == &bytes => {} + std::collections::btree_map::Entry::Occupied(entry) => { + return Err(format!( + "dependency snapshot key collision at {}", + entry.key().display() + )); + } + } + } + + Ok(WorkflowApprovalRevision { + digest: format!("{:x}", digest.finalize()), + embedded_dependencies: embedded_dependencies.into_iter().collect(), + }) +} + +struct WorkflowApprovalInput<'a> { + runs_root: &'a Path, + run_id: &'a str, + approval_id: &'a str, + signing_key: &'a sema_workflow::approval::ApprovalSigningKey, + kind: sema_workflow::approval::ApprovalDecisionKind, + actor: String, + comment: Option, + reason: Option, +} + +fn write_workflow_approval(input: WorkflowApprovalInput<'_>) { + match sema_workflow::approval::decide( + input.runs_root, + input.run_id, + input.approval_id, + input.signing_key, + input.kind, + input.actor, + "cli".to_string(), + input.comment, + input.reason, + ) { + Ok(sema_workflow::approval::DecisionWrite::Created(decision)) => println!( + "{} {} ({})", + approval_decision_past_tense(decision.decision), + decision.approval_id, + decision.decision_id + ), + Ok(sema_workflow::approval::DecisionWrite::AlreadyExists(decision)) => println!( + "already {} {} ({})", + approval_decision_past_tense(decision.decision), + decision.approval_id, + decision.decision_id + ), + Err(error) => { + print_cli_error(format!( + "cannot {} approval {}: {error}", + input.kind, input.approval_id + )); + std::process::exit(1); + } + } +} + +fn approval_decision_past_tense( + decision: sema_workflow::approval::ApprovalDecisionKind, +) -> &'static str { + match decision { + sema_workflow::approval::ApprovalDecisionKind::Approve => "approved", + sema_workflow::approval::ApprovalDecisionKind::Reject => "rejected", + } +} + +fn approval_resolution_json( + resolution: &sema_workflow::approval::ApprovalResolution, +) -> serde_json::Value { + use sema_workflow::approval::ApprovalResolution; + let (status, request, decision) = match resolution { + ApprovalResolution::Pending(request) => ("pending", request, None), + ApprovalResolution::Approved(request, decision) => ("approved", request, Some(decision)), + ApprovalResolution::Rejected(request, decision) => ("rejected", request, Some(decision)), + }; + serde_json::json!({ + "status": status, + "request": request, + "decision": decision, + }) +} + +fn list_workflow_approvals(runs_root: &Path, run_id: &str, json: bool) { + let resolutions = match sema_workflow::approval::list_requests(runs_root, run_id) { + Ok(resolutions) => resolutions, + Err(error) => { + print_cli_error(format!("cannot list approvals for {run_id}: {error}")); + std::process::exit(1); + } + }; + if json { + println!( + "{}", + serde_json::to_string_pretty( + &resolutions + .iter() + .map(approval_resolution_json) + .collect::>() + ) + .expect("approval list is JSON serializable") + ); + return; + } + if resolutions.is_empty() { + println!("no approvals for run {run_id}"); + return; + } + for resolution in &resolutions { + let json = approval_resolution_json(resolution); + let request = &json["request"]; + println!( + "{} {:<8} {}", + request["approval_id"].as_str().unwrap_or(""), + json["status"].as_str().unwrap_or("unknown"), + terminal_safe(request["reason"].as_str().unwrap_or("")) + ); + if let Some(preview) = request["preview"].as_str() { + println!(" {}", terminal_safe(preview)); + } + } +} + +enum ApprovalPromptAction { + Approve, + Reject(String), + AlreadyDecided, + LeavePending, +} + +fn is_terminal_control(ch: char) -> bool { + ch.is_control() + || matches!( + ch, + '\u{061c}' + | '\u{200e}' + | '\u{200f}' + | '\u{202a}'..='\u{202e}' + | '\u{2066}'..='\u{206f}' + ) +} + +fn terminal_safe(value: &str) -> String { + let mut safe = String::with_capacity(value.len()); + for ch in value.chars() { + if is_terminal_control(ch) { + use std::fmt::Write as _; + let _ = write!(safe, "\\u{{{:x}}}", ch as u32); + } else { + safe.push(ch); + } + } + safe +} + +fn prompt_for_workflow_approval( + runs_root: &Path, + run_id: &str, + approval_id: &str, +) -> Result { + use std::io::Write; + + let resolution = sema_workflow::approval::list_requests(runs_root, run_id) + .map_err(|error| error.to_string())? + .into_iter() + .find(|resolution| { + let request = match resolution { + sema_workflow::approval::ApprovalResolution::Pending(request) + | sema_workflow::approval::ApprovalResolution::Approved(request, _) + | sema_workflow::approval::ApprovalResolution::Rejected(request, _) => request, + }; + request.approval_id == approval_id + }) + .ok_or_else(|| format!("approval {approval_id} was not found"))?; + let request = match resolution { + sema_workflow::approval::ApprovalResolution::Pending(request) => request, + sema_workflow::approval::ApprovalResolution::Approved(_, decision) + | sema_workflow::approval::ApprovalResolution::Rejected(_, decision) => { + eprintln!( + "approval {approval_id} was already {} by {}", + decision.decision, + terminal_safe(&decision.actor) + ); + return Ok(ApprovalPromptAction::AlreadyDecided); + } + }; + + let _ctrlc_guard = ApprovalPromptCtrlCGuard::enter(); + eprintln!("\nHuman approval required"); + eprintln!(" id: {}", request.approval_id); + eprintln!(" reason: {}", terminal_safe(&request.reason)); + if let Some(preview) = request.preview { + eprintln!(" preview: {}", terminal_safe(&preview)); + } + loop { + eprint!("Approve? [y]es / [n]o / [q]uit pending: "); + std::io::stderr() + .flush() + .map_err(|error| error.to_string())?; + let mut answer = String::new(); + let read = std::io::stdin() + .read_line(&mut answer) + .map_err(|error| error.to_string())?; + if read == 0 { + return Ok(ApprovalPromptAction::LeavePending); + } + match answer.trim().to_ascii_lowercase().as_str() { + "y" | "yes" => return Ok(ApprovalPromptAction::Approve), + "n" | "no" => { + eprint!("Rejection reason: "); + std::io::stderr() + .flush() + .map_err(|error| error.to_string())?; + let mut reason = String::new(); + if std::io::stdin() + .read_line(&mut reason) + .map_err(|error| error.to_string())? + == 0 + { + return Ok(ApprovalPromptAction::LeavePending); + } + if reason.trim().is_empty() { + eprintln!("A rejection reason is required."); + continue; + } + return Ok(ApprovalPromptAction::Reject(reason.trim().to_string())); + } + "q" | "quit" => return Ok(ApprovalPromptAction::LeavePending), + _ => eprintln!("Enter y, n, or q."), + } + } +} + +fn approval_was_decided(runs_root: &Path, run_id: &str, approval_id: &str) -> bool { + sema_workflow::approval::list_requests(runs_root, run_id) + .ok() + .is_some_and(|resolutions| { + resolutions.into_iter().any(|resolution| match resolution { + sema_workflow::approval::ApprovalResolution::Pending(_) => false, + sema_workflow::approval::ApprovalResolution::Approved(request, _) + | sema_workflow::approval::ApprovalResolution::Rejected(request, _) => { + request.approval_id == approval_id + } + }) + }) +} + +fn approval_envelope_field(envelope: &Value, key: &str) -> Option { + envelope + .as_map_rc()? + .get(&Value::keyword(key))? + .as_str() + .map(str::to_string) +} + +fn shell_quote(value: &str) -> String { + #[cfg(windows)] + { + return format!("\"{}\"", value.replace('"', "\\\"")); + } + #[cfg(not(windows))] + { + use std::fmt::Write as _; + + // ANSI-C quotes preserve exact control/bidi characters without rendering them + // into the terminal. Sema's supported Unix shells (bash/zsh) understand this + // form, including \xHH/\uHHHH/\UHHHHHHHH escapes. + let mut quoted = String::with_capacity(value.len() + 3); + quoted.push_str("$'"); + for ch in value.chars() { + match ch { + '\\' => quoted.push_str("\\\\"), + '\'' => quoted.push_str("\\'"), + _ if is_terminal_control(ch) => { + let scalar = ch as u32; + if scalar <= 0xff { + let _ = write!(quoted, "\\x{scalar:02x}"); + } else if scalar <= 0xffff { + let _ = write!(quoted, "\\u{scalar:04x}"); + } else { + let _ = write!(quoted, "\\U{scalar:08x}"); + } + } + _ => quoted.push(ch), + } + } + quoted.push('\''); + quoted + } +} + +fn format_needs_approval_guidance( + envelope: &Value, + run_dir: &str, + file: &str, + args: &str, + public_key_file: Option<&str>, + signing_key_file: Option<&str>, +) -> String { + let run_id = approval_envelope_field(envelope, "run-id").unwrap_or_default(); + let approval_id = approval_envelope_field(envelope, "approval-id").unwrap_or_default(); + let mut out = format!( + "run needs human approval {approval_id}:\n sema workflow approve {} {} --run-dir {} --signing-key-file \"$SEMA_APPROVAL_PRIVATE_KEY\"\n sema workflow reject {} {} --run-dir {} --reason 'explain why' --signing-key-file \"$SEMA_APPROVAL_PRIVATE_KEY\"\n", + shell_quote(&run_id), + shell_quote(&approval_id), + shell_quote(run_dir), + shell_quote(&run_id), + shell_quote(&approval_id), + shell_quote(run_dir), + ); + if let Some(signing_key_file) = signing_key_file { + out.push_str(&format!( + "or decide in the loopback viewer, then resume exactly:\n sema workflow run {} --args {} --resume {} --run-dir {} --view --approval-signing-key-file {}\n", + shell_quote(file), + shell_quote(args), + shell_quote(&run_id), + shell_quote(run_dir), + shell_quote(signing_key_file), + )); + } else if let Some(public_key_file) = public_key_file { + out.push_str(&format!( + "then resume exactly:\n sema workflow run {} --args {} --resume {} --run-dir {} --approval-public-key-file {}\n", + shell_quote(file), + shell_quote(args), + shell_quote(&run_id), + shell_quote(run_dir), + shell_quote(public_key_file), + )); + } else { + out.push_str( + "this interactive request used an ephemeral authority; if left pending, start a fresh run instead of resuming it.\n", + ); + } + out +} + /// `sema workflow run ` — evaluate a workflow `.sema` file (which /// `defworkflow`s and runs it) with the run-directory + args seams wired, then /// exit non-zero if the run's `{:status …}` envelope reports failure. fn run_workflow_command(command: WorkflowCommands, sandbox: &sema_core::Sandbox) { - let (file, args, run_dir, view, view_port, resume, no_auth_prompt) = match command { + let ( + file, + args, + run_dir, + view, + view_port, + resume, + no_auth_prompt, + approval_mode, + approval_public_key_file, + approval_signing_key_file, + approval_actor, + ) = match command { WorkflowCommands::Run { file, args, @@ -1295,17 +2132,127 @@ fn run_workflow_command(command: WorkflowCommands, sandbox: &sema_core::Sandbox) port, resume, no_auth_prompt, - } => (file, args, run_dir, view, port, resume, no_auth_prompt), + approval_mode, + approval_public_key_file, + approval_signing_key_file, + approval_actor, + } => ( + file, + args, + run_dir, + view, + port, + resume, + no_auth_prompt, + approval_mode, + approval_public_key_file, + approval_signing_key_file, + approval_actor, + ), + WorkflowCommands::Approvals { + run_id, + run_dir, + json, + } => { + list_workflow_approvals(Path::new(&run_dir), &run_id, json); + return; + } + WorkflowCommands::Approve { + run_id, + approval_id, + run_dir, + comment, + actor, + signing_key_file, + } => { + let signing_key = load_approval_signing_key(Path::new(&signing_key_file)) + .unwrap_or_else(|error| { + print_cli_error(error); + std::process::exit(1); + }); + write_workflow_approval(WorkflowApprovalInput { + runs_root: Path::new(&run_dir), + run_id: &run_id, + approval_id: &approval_id, + signing_key: &signing_key, + kind: sema_workflow::approval::ApprovalDecisionKind::Approve, + actor: actor.unwrap_or_else(default_approval_actor), + comment, + reason: None, + }); + return; + } + WorkflowCommands::Reject { + run_id, + approval_id, + run_dir, + reason, + actor, + signing_key_file, + } => { + let signing_key = load_approval_signing_key(Path::new(&signing_key_file)) + .unwrap_or_else(|error| { + print_cli_error(error); + std::process::exit(1); + }); + write_workflow_approval(WorkflowApprovalInput { + runs_root: Path::new(&run_dir), + run_id: &run_id, + approval_id: &approval_id, + signing_key: &signing_key, + kind: sema_workflow::approval::ApprovalDecisionKind::Reject, + actor: actor.unwrap_or_else(default_approval_actor), + comment: None, + reason: Some(reason), + }); + return; + } + WorkflowCommands::ApprovalKeygen { + private_key_file, + public_key_file, + } => { + if let Err(error) = + create_approval_key_pair(Path::new(&private_key_file), Path::new(&public_key_file)) + { + print_cli_error(error); + std::process::exit(1); + } + println!("created private approval key {private_key_file}"); + println!("created public approval key {public_key_file}"); + return; + } WorkflowCommands::View { run_dir, host, port, + approval_signing_key_file, + approval_actor, } => { + let approval_authority = approval_signing_key_file + .as_deref() + .map(|path| { + let signing_key = load_approval_signing_key(Path::new(path))?; + workflow_view::ApprovalAuthority::new( + signing_key, + approval_actor.unwrap_or_else(default_approval_actor), + ) + .map_err(|error| error.to_string()) + }) + .transpose() + .unwrap_or_else(|error| { + print_cli_error(format!("cannot enable viewer approval controls: {error}")); + std::process::exit(1); + }); tokio::runtime::Builder::new_multi_thread() .enable_all() .build() .expect("Failed to create tokio runtime") - .block_on(workflow_view::serve(PathBuf::from(run_dir), &host, port)); + .block_on(workflow_view::serve_with_approval( + PathBuf::from(run_dir), + &host, + port, + approval_authority, + )); return; } WorkflowCommands::Index { run_dir } => { @@ -1319,11 +2266,37 @@ fn run_workflow_command(command: WorkflowCommands, sandbox: &sema_core::Sandbox) rows.len(), root.join(sema_workflow::INDEX_DB).display() ), - Err(e) => eprintln!("warning: index summary: {e}"), + Err(e) => print_cli_warning(format!("could not summarize index: {e}")), } } Err(e) => { - eprintln!("error: cannot open index db: {e}"); + print_cli_error(format!("cannot open index database: {e}")); + std::process::exit(1); + } + } + return; + } + WorkflowCommands::Export { + run_id, + run_dir, + out_dir, + } => { + match sema::workflow_evidence::export( + &PathBuf::from(run_dir), + &run_id, + out_dir.as_deref().map(std::path::Path::new), + ) { + Ok(bundle) => { + println!( + "exported workflow evidence → {}", + bundle.directory.display() + ); + println!(" {}", bundle.evidence_json.display()); + println!(" {}", bundle.evidence_markdown.display()); + println!(" {}", bundle.manifest_json.display()); + } + Err(error) => { + print_cli_error(format!("cannot export workflow evidence: {error}")); std::process::exit(1); } } @@ -1333,15 +2306,52 @@ fn run_workflow_command(command: WorkflowCommands, sandbox: &sema_core::Sandbox) let src = match read_source_file(&file) { Ok(s) => s, Err(msg) => { - eprintln!("error: {msg}"); + print_cli_error(msg); std::process::exit(2); } }; - let diags = workflow_check::check_source(&src); + let diags = workflow_check::check_run_source(&src); std::process::exit(workflow_check::report(&file, &diags, strict, json)); } }; + let workspace_root = std::env::current_dir().unwrap_or_else(|error| { + print_cli_error(format!("cannot resolve workflow workspace: {error}")); + std::process::exit(1); + }); + let run_dir = { + let path = PathBuf::from(&run_dir); + let absolute = if path.is_absolute() { + path + } else { + workspace_root.join(path) + }; + absolute.to_string_lossy().to_string() + }; + let file = std::fs::canonicalize(&file).unwrap_or_else(|error| { + print_cli_error(format!("cannot resolve workflow file {file}: {error}")); + std::process::exit(1); + }); + let file = file.to_string_lossy().to_string(); + let approval_public_key_file = approval_public_key_file.map(|value| { + let path = PathBuf::from(value); + let absolute = if path.is_absolute() { + path + } else { + workspace_root.join(path) + }; + absolute.to_string_lossy().to_string() + }); + let approval_signing_key_file = approval_signing_key_file.map(|value| { + let path = PathBuf::from(value); + let absolute = if path.is_absolute() { + path + } else { + workspace_root.join(path) + }; + absolute.to_string_lossy().to_string() + }); + // Interactive MCP auth (docs/plans/2026-06-24-workflow-mcp-auth.md §3): on a // real terminal, a needs-auth gate logs in inline instead of exiting 2. See // `should_enable_interactive_auth` for the exact decision and @@ -1353,78 +2363,228 @@ fn run_workflow_command(command: WorkflowCommands, sandbox: &sema_core::Sandbox) no_auth_prompt, )); - // The workflow runtime (sema-workflow) reads this seam to choose the run-dir - // base; the run lands in `//`. - std::env::set_var("SEMA_WORKFLOW_RUN_DIR", &run_dir); + // Backward-compatible host seam for deterministic tests/embedders. Read exactly + // once before evaluation and copy into host-owned state; a workflow's later + // `sys/set-env` calls cannot alter it. + let fresh_run_id = std::env::var("SEMA_WORKFLOW_RUN_ID") + .ok() + .filter(|value| !value.is_empty()); + if let Some(run_id) = &fresh_run_id { + if run_id.contains('/') || run_id.contains('\\') || run_id.contains("..") { + print_cli_error( + "SEMA_WORKFLOW_RUN_ID must be a bare directory name without path separators", + ); + std::process::exit(1); + } + } // `--resume `: reuse that run's dir + memo cache. Sanitize the operator- - // supplied id against path traversal (it joins into a filesystem path), require the - // prior run's events.jsonl to exist, then set the seams the runtime reads. + // supplied id against path traversal (it joins into a filesystem path) and require + // the prior run's events.jsonl to exist. The values are installed below in immutable + // host state rather than mutable process environment variables. if let Some(run_id) = &resume { if run_id.is_empty() || run_id.contains('/') || run_id.contains('\\') || run_id.contains("..") { - eprintln!("error: --resume run-id must be a bare directory name (no path separators)"); + print_cli_error( + "--resume run-id must be a bare directory name without path separators", + ); std::process::exit(1); } let prior = PathBuf::from(&run_dir).join(run_id).join("events.jsonl"); if !prior.exists() { - eprintln!("error: no prior run to resume at {}", prior.display()); + print_cli_error(format!("no prior run to resume at {}", prior.display())); std::process::exit(1); } - std::env::set_var("SEMA_WORKFLOW_RUN_ID", run_id); - std::env::set_var("SEMA_WORKFLOW_RESUME", "1"); } - // Recorded verbatim on the run.started event (shown in the viewer's stream/meta). - std::env::set_var("SEMA_WORKFLOW_ARGS_JSON", &args); let content = match read_source_file(&file) { Ok(c) => c, Err(msg) => { - eprintln!("error: {msg}"); + print_cli_error(msg); std::process::exit(1); } }; + let run_diags = workflow_check::check_run_source(&content); + if run_diags + .iter() + .any(|diag| diag.severity == workflow_check::Severity::Error) + { + let code = workflow_check::report(&file, &run_diags, false, false); + std::process::exit(code.max(1)); + } + + let approval_revision = workflow_approval_revision(Path::new(&file), content.as_bytes()) + .unwrap_or_else(|error| { + print_cli_error(format!( + "cannot bind workflow approval dependency closure: {error}" + )); + std::process::exit(1); + }); + let approval_code_version = approval_revision.digest.clone(); let mut effective_sandbox = sandbox.clone(); let permission_specs = match workflow_check::declared_permission_specs(&content) { Ok(specs) => specs, Err(e) => { - eprintln!("error: invalid workflow permissions: {e}"); + print_cli_error(format!("invalid workflow permissions: {e}")); std::process::exit(1); } }; for spec in permission_specs { let declared = sema_core::Sandbox::parse_cli(&spec).unwrap_or_else(|e| { - eprintln!("error: invalid defworkflow :permissions {spec:?}: {e}"); + print_cli_error(format!("invalid defworkflow :permissions {spec:?}: {e}")); std::process::exit(1); }); effective_sandbox = effective_sandbox.with_more_denied(declared.denied); } - // `--view`: start the live viewer on a background thread BEFORE the run, so the - // journal (written flush-per-event) is watchable in real time, and keep it up - // afterwards for inspection. A bind failure degrades to a warning (the run still - // proceeds). Best-effort open the browser. + // Bind the parsed --args JSON object to the global `*workflow-args*` so the + // workflow body can read its inputs. + let args_value = match serde_json::from_str::(&args) { + Ok(json) => sema_core::json::json_to_value(&json), + Err(e) => { + print_cli_error(format!("--args is not valid JSON: {e}")); + std::process::exit(1); + } + }; + // Preserve the established short source hash for memo compatibility. Approval + // decisions use a separate collision-resistant source binding below. + let code_version = { + use std::hash::{Hash, Hasher}; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + content.hash(&mut hasher); + format!("{:016x}", hasher.finish()) + }; + + // The file's last form is the `defworkflow` (which expands to `workflow/run`), + // so eval returns the `{:status …}` envelope; journaling is its side effect. A TTY + // approval decision starts a fresh interpreter and resumes the same run. Completed + // leaves replay from their memos; no interpreter-local continuation is trusted. + let stdin_tty = std::io::stdin().is_terminal(); + let stderr_tty = std::io::stderr().is_terminal(); + let interactive_approval = stdin_tty + && stderr_tty + && std::env::var("CI") + .ok() + .as_deref() + .is_none_or(str::is_empty); + let viewer_signing_key = approval_signing_key_file + .as_deref() + .map(|path| load_approval_signing_key(Path::new(path))) + .transpose() + .unwrap_or_else(|error| { + print_cli_error(format!("cannot enable viewer approval controls: {error}")); + std::process::exit(1); + }); + let viewer_public_key = viewer_signing_key.as_ref().map(|key| { + key.public_key_base64().unwrap_or_else(|error| { + print_cli_error(format!("cannot derive viewer approval key: {error}")); + std::process::exit(1); + }) + }); + let configured_public_key = approval_public_key_file.as_deref().map(|path| { + let encoded = read_approval_key_file(Path::new(path), false).unwrap_or_else(|error| { + print_cli_error(error); + std::process::exit(1); + }); + sema_workflow::approval::normalize_public_key_base64(&encoded).unwrap_or_else(|error| { + print_cli_error(format!("invalid approval public key: {error}")); + std::process::exit(1); + }) + }); + if viewer_public_key + .as_ref() + .zip(configured_public_key.as_ref()) + .is_some_and(|(viewer, configured)| viewer != configured) + { + print_cli_error("--approval-signing-key-file does not match --approval-public-key-file"); + std::process::exit(1); + } + let has_durable_authority = viewer_signing_key.is_some() || configured_public_key.is_some(); + let effective_approval_mode = + resolve_approval_mode(approval_mode, interactive_approval, has_durable_authority); + if let Err(error) = validate_interactive_approval_authority( + effective_approval_mode, + interactive_approval, + configured_public_key.is_some(), + viewer_signing_key.is_some(), + ) { + print_cli_error(error); + std::process::exit(1); + } + let needs_ephemeral_authority = (effective_approval_mode == ApprovalMode::Prompt + && interactive_approval) + || (effective_approval_mode == ApprovalMode::Deny && !has_durable_authority); + let inline_signing_key = if needs_ephemeral_authority { + Some(viewer_signing_key.clone().unwrap_or_else(|| { + sema_workflow::approval::ApprovalSigningKey::generate().unwrap_or_else(|error| { + print_cli_error(format!("cannot generate interactive approval key: {error}")); + std::process::exit(1); + }) + })) + } else { + None + }; + let approval_public_key = if let Some(key) = &viewer_public_key { + key.clone() + } else if let Some(key) = &configured_public_key { + key.clone() + } else if let Some(key) = &inline_signing_key { + key.public_key_base64().unwrap_or_else(|error| { + print_cli_error(format!("cannot derive interactive approval key: {error}")); + std::process::exit(1); + }) + } else { + String::new() + }; + // An in-memory prompt authority is ephemeral unless it came from the explicit + // viewer key file. Guidance only promises a resumable authority when the operator + // supplied one of those durable files. + let resumable_public_key_file = viewer_signing_key + .is_none() + .then_some(approval_public_key_file.as_deref()) + .flatten(); + let resumable_signing_key_file = viewer_signing_key + .is_some() + .then_some(approval_signing_key_file.as_deref()) + .flatten(); + + // Start the live viewer after loading the host-only signing authority but before + // evaluation, so the browser can observe the flush-per-event journal immediately. + // The key remains in server state and is never installed in the interpreter. if view { let vd = run_dir.clone(); + let viewer_authority = viewer_signing_key.clone().map(|signing_key| { + workflow_view::ApprovalAuthority::new( + signing_key, + approval_actor + .clone() + .unwrap_or_else(default_approval_actor), + ) + .unwrap_or_else(|error| { + print_cli_error(format!("cannot enable viewer approval controls: {error}")); + std::process::exit(1); + }) + }); std::thread::spawn(move || { tokio::runtime::Builder::new_multi_thread() .enable_all() .build() .expect("Failed to create tokio runtime") .block_on(async { - if let Err(e) = workflow_view::serve_result( + if let Err(error) = workflow_view::serve_result_with_approval( PathBuf::from(vd), "127.0.0.1", view_port, false, + viewer_authority, ) .await { - eprintln!("warning: --view could not start the viewer: {e}"); + print_cli_warning(format!("--view could not start the viewer: {error}")); } }); }); @@ -1434,67 +2594,202 @@ fn run_workflow_command(command: WorkflowCommands, sandbox: &sema_core::Sandbox) // Give the listener a moment to bind before the run starts producing events. std::thread::sleep(std::time::Duration::from_millis(250)); } - - let interpreter = build_interpreter(&effective_sandbox); - - // Auto-configure an LLM provider from the environment (mirrors the default run - // path), so a workflow whose leaves call `llm/*` works without self-configuring. - // Best-effort: a workflow with no LLM leaves needs no provider, so ignore errors. - let _ = interpreter.eval_str("(llm/auto-configure)"); - - // Bind the parsed --args JSON object to the global `*workflow-args*` so the - // workflow body can read its inputs. - let args_value = match serde_json::from_str::(&args) { - Ok(json) => sema_core::json::json_to_value(&json), - Err(e) => { - eprintln!("error: --args is not valid JSON: {e}"); - std::process::exit(1); + // Snapshot audit identity before evaluating untrusted workflow code. A workflow may + // have env-write permission, but cannot rewrite the actor attached to a later host + // terminal decision. + let terminal_approval_actor = default_approval_actor(); + let mut active_resume = resume.clone(); + let exit_code = loop { + let interpreter = build_interpreter(&effective_sandbox); + for (path, bytes) in &approval_revision.embedded_dependencies { + interpreter + .ctx + .set_embedded_file(path.clone(), bytes.clone()); } - }; - interpreter - .global_env - .set(sema_core::intern("*workflow-args*"), args_value); + let _host_config = sema_workflow::context::install_host_config( + sema_workflow::context::WorkflowHostConfig { + runs_root: run_dir.clone(), + explicit_run_id: active_resume.clone().or_else(|| fresh_run_id.clone()), + resuming: active_resume.is_some(), + code_version: code_version.clone(), + approval_code_version: approval_code_version.clone(), + args_json: args.clone(), + approval_public_key: approval_public_key.clone(), + entry_file: file.clone(), + workspace_root: workspace_root.to_string_lossy().to_string(), + }, + ); - // Code version: a deterministic hash of the source, folded into every resume - // content-key. Editing the workflow changes this ⇒ memos no longer match ⇒ a - // resumed run re-executes from scratch (correct invalidation). DefaultHasher uses - // fixed keys, so the value is stable across separate invocations of this binary. - { - use std::hash::{Hash, Hasher}; - let mut h = std::collections::hash_map::DefaultHasher::new(); - content.hash(&mut h); - std::env::set_var("SEMA_WORKFLOW_CODE_VERSION", format!("{:016x}", h.finish())); - } + // Auto-configure an LLM provider from the environment (mirrors the default run + // path), so a workflow whose leaves call `llm/*` works without self-configuring. + // Best-effort: a workflow with no LLM leaves needs no provider, so ignore errors. + let _ = interpreter.eval_str("(llm/auto-configure)"); + // Evaluate against the same complete static dependency snapshot that was + // hashed into the approval revision. Macro-generated or runtime-selected + // imports outside that closure fail closed instead of escaping the binding. + interpreter.ctx.set_embedded_files_only(true); + interpreter + .global_env + .set(sema_core::intern("*workflow-args*"), args_value.clone()); - // The file's last form is the `defworkflow` (which expands to `workflow/run`), - // so eval returns the `{:status …}` envelope; journaling is its side effect. - let exit_code = match interpreter.eval_str_compiled(&content) { - Ok(envelope) => { - drain_async_scheduler(&interpreter); - let status = envelope - .as_map_rc() - .and_then(|m| m.get(&Value::keyword("status")).cloned()) - .and_then(|s| s.as_keyword()); - match status.as_deref() { - Some("failed") => { - eprintln!("workflow failed: {}", pretty_print(&envelope, 80)); - 1 - } - // The headless-precursor gate (docs/plans/2026-06-24-workflow-mcp-auth.md - // §3/§5): a declared `:mcp` server had no usable session. Distinct exit - // code so a CI/orchestrator script can branch on "needs a human to log - // in" vs. a genuine failure. - Some("needs-auth") => { - eprint!("{}", format_needs_auth_guidance(&envelope)); - 2 + let envelope = match interpreter.eval_str_compiled(&content) { + Ok(envelope) => { + drain_async_scheduler(&interpreter); + envelope + } + Err(e) => { + eprint!("Error running workflow {file}: "); + print_error(&e); + break 1; + } + }; + // Tear down the evaluator (including detached descendants) before any private + // inline signing key is used at the terminal prompt. + drop(interpreter); + let status = envelope + .as_map_rc() + .and_then(|m| m.get(&Value::keyword("status")).cloned()) + .and_then(|s| s.as_keyword()); + match status.as_deref() { + Some("failed") => { + print_cli_error(format!("workflow failed: {}", pretty_print(&envelope, 80))); + break 1; + } + Some("rejected") => { + print_cli_error(format!( + "workflow approval rejected: {}", + pretty_print(&envelope, 80) + )); + break 1; + } + // The headless-precursor gate (docs/plans/2026-06-24-workflow-mcp-auth.md + // §3/§5): a declared `:mcp` server had no usable session. Distinct exit + // code so a CI/orchestrator script can branch on "needs a human to log + // in" vs. a genuine failure. + Some("needs-auth") => { + eprint!("{}", format_needs_auth_guidance(&envelope)); + break 2; + } + Some("needs-approval") => { + let Some(run_id) = approval_envelope_field(&envelope, "run-id") else { + print_cli_error("needs-approval envelope is missing :run-id"); + break 1; + }; + let Some(approval_id) = approval_envelope_field(&envelope, "approval-id") else { + print_cli_error("needs-approval envelope is missing :approval-id"); + break 1; + }; + match effective_approval_mode { + ApprovalMode::Pause | ApprovalMode::Auto => { + eprint!( + "{}", + format_needs_approval_guidance( + &envelope, + &run_dir, + &file, + &args, + resumable_public_key_file, + resumable_signing_key_file, + ) + ); + break 3; + } + ApprovalMode::Deny => { + print_cli_error(format!( + "workflow reached approval {approval_id}; --approval-mode deny refuses interactive approval" + )); + break 1; + } + ApprovalMode::Prompt if !interactive_approval => { + print_cli_error( + "--approval-mode prompt requires terminal stdin/stderr and is disabled in CI", + ); + eprint!( + "{}", + format_needs_approval_guidance( + &envelope, + &run_dir, + &file, + &args, + resumable_public_key_file, + resumable_signing_key_file, + ) + ); + break 3; + } + ApprovalMode::Prompt => { + let action = match prompt_for_workflow_approval( + Path::new(&run_dir), + &run_id, + &approval_id, + ) { + Ok(action) => action, + Err(error) => { + print_cli_error(format!("cannot prompt for approval: {error}")); + break 3; + } + }; + let decision = match action { + ApprovalPromptAction::Approve => sema_workflow::approval::decide( + Path::new(&run_dir), + &run_id, + &approval_id, + inline_signing_key.as_ref().expect("interactive key exists"), + sema_workflow::approval::ApprovalDecisionKind::Approve, + terminal_approval_actor.clone(), + "terminal-prompt".to_string(), + None, + None, + ), + ApprovalPromptAction::Reject(reason) => { + sema_workflow::approval::decide( + Path::new(&run_dir), + &run_id, + &approval_id, + inline_signing_key.as_ref().expect("interactive key exists"), + sema_workflow::approval::ApprovalDecisionKind::Reject, + terminal_approval_actor.clone(), + "terminal-prompt".to_string(), + None, + Some(reason), + ) + } + ApprovalPromptAction::AlreadyDecided => { + active_resume = Some(run_id); + continue; + } + ApprovalPromptAction::LeavePending => { + eprint!( + "{}", + format_needs_approval_guidance( + &envelope, + &run_dir, + &file, + &args, + resumable_public_key_file, + resumable_signing_key_file, + ) + ); + break 3; + } + }; + if let Err(error) = decision { + if approval_was_decided(Path::new(&run_dir), &run_id, &approval_id) { + eprintln!( + "approval {approval_id} was decided concurrently; resuming with the recorded decision" + ); + active_resume = Some(run_id); + continue; + } + print_cli_error(format!("cannot record approval decision: {error}")); + break 1; + } + active_resume = Some(run_id); + continue; + } } - _ => 0, } - } - Err(e) => { - eprint!("Error running workflow {file}: "); - print_error(&e); - 1 + _ => break 0, } }; @@ -1614,7 +2909,7 @@ fn run_notebook_command(command: NotebookCommands) { let mut engine = match sema_notebook::Engine::from_file(path) { Ok(e) => e, Err(e) => { - eprintln!("Error: {e}"); + print_cli_error(e); std::process::exit(1); } }; @@ -1668,7 +2963,7 @@ fn run_notebook_command(command: NotebookCommands) { } } Err(e) => { - eprintln!("[{}/{}] Error: {e}", i + 1, total); + print_cli_error(format!("[{}/{}] {e}", i + 1, total)); had_error = true; } } @@ -1676,7 +2971,7 @@ fn run_notebook_command(command: NotebookCommands) { // Save updated outputs back to the file if let Err(e) = engine.notebook.save(path) { - eprintln!("Warning: failed to save: {e}"); + print_cli_warning(format!("could not save: {e}")); } if had_error { @@ -1692,7 +2987,7 @@ fn run_notebook_command(command: NotebookCommands) { let notebook = match sema_notebook::Notebook::load(path) { Ok(nb) => nb, Err(e) => { - eprintln!("Error: {e}"); + print_cli_error(e); std::process::exit(1); } }; @@ -1700,7 +2995,9 @@ fn run_notebook_command(command: NotebookCommands) { let content = match format.as_str() { "md" | "markdown" => sema_notebook::render::export_markdown(¬ebook), other => { - eprintln!("Unknown export format: {other}. Supported: md"); + print_cli_error(format!( + "unknown export format: {other}; supported format: md" + )); std::process::exit(1); } }; @@ -1708,7 +3005,7 @@ fn run_notebook_command(command: NotebookCommands) { match output { Some(out_path) => { if let Err(e) = std::fs::write(&out_path, &content) { - eprintln!("Error writing {out_path}: {e}"); + print_cli_error(format!("could not write {out_path}: {e}")); std::process::exit(1); } eprintln!("Exported to {out_path}"); @@ -1727,7 +3024,7 @@ fn run_notebook_command(command: NotebookCommands) { // Add a starter code cell notebook.add_code_cell("; Welcome to your Sema notebook!\n(+ 1 2)"); if let Err(e) = notebook.save(path) { - eprintln!("Error: {e}"); + print_cli_error(e); std::process::exit(1); } eprintln!("Created notebook: {file}"); @@ -1764,7 +3061,7 @@ fn run_eval( elapsed_ms: 0, }); } else { - eprintln!("Error reading stdin: {e}"); + print_cli_error(format!("could not read stdin: {e}")); } std::process::exit(1); }); @@ -1785,7 +3082,7 @@ fn run_eval( elapsed_ms: 0, }); } else { - eprintln!("Error: either --stdin or --expr is required"); + print_cli_error("either --stdin or --expr is required"); } std::process::exit(1); }; @@ -1806,7 +3103,7 @@ fn run_eval( elapsed_ms: 0, }); } else { - eprintln!("Error: {e}"); + print_cli_error(e); } std::process::exit(1); }), @@ -1885,7 +3182,7 @@ fn run_eval( } Err(e) => { let inner = e.inner(); - let msg = inner.to_string(); + let msg = e.user_message(); let hint = e.hint().map(|s| s.to_string()); // Extract line+col from Reader span or first stack trace frame let (line, col) = match inner { @@ -2023,7 +3320,7 @@ fn run_compile(file: &str, output: Option<&str>) { let source = match read_source_file(path) { Ok(s) => s, Err(msg) => { - eprintln!("error: {msg}"); + print_cli_error(msg); std::process::exit(1); } }; @@ -2038,7 +3335,7 @@ fn run_compile(file: &str, output: Option<&str>) { let result = match interpreter.compile_to_bytecode(&source) { Ok(r) => r, Err(e) => { - eprintln!("Compile error: {}", e.inner()); + print_cli_error(format!("compilation failed: {}", e.format_plain())); std::process::exit(1); } }; @@ -2047,7 +3344,7 @@ fn run_compile(file: &str, output: Option<&str>) { let bytes = match sema_vm::serialize_to_bytes(&result, source_hash) { Ok(b) => b, Err(e) => { - eprintln!("Serialization error: {}", e.inner()); + print_cli_error(format!("serialization failed: {}", e.format_plain())); std::process::exit(1); } }; @@ -2058,7 +3355,7 @@ fn run_compile(file: &str, output: Option<&str>) { None => path.with_extension("semac"), }; if let Err(e) = std::fs::write(&out_path, &bytes) { - eprintln!("Error writing {}: {e}", out_path.display()); + print_cli_error(format!("could not write {}: {e}", out_path.display())); std::process::exit(1); } } @@ -2087,7 +3384,7 @@ fn try_run_embedded() -> Option { let arch = match archive::deserialize_archive_from_bytes(&archive_data) { Ok(a) => a, Err(e) => { - eprintln!("Error: failed to load embedded archive: {e}"); + print_cli_error(format!("could not load embedded archive: {e}")); return Some(1); } }; @@ -2102,7 +3399,9 @@ fn try_run_embedded() -> Option { let bytecode = match arch.files.get(&entry_point) { Some(b) => b.clone(), None => { - eprintln!("Error: entry point '{entry_point}' not found in embedded archive"); + print_cli_error(format!( + "entry point '{entry_point}' was not found in the embedded archive" + )); return Some(1); } }; @@ -2155,7 +3454,7 @@ fn try_run_embedded() -> Option { // Same no-ambient-runtime rule as the CLI mcp arm (llm/* + io_block_on). if let Err(e) = sema_mcp::run_mcp_server_sync(interpreter, inc_tools, exc_tools) { - eprintln!("MCP server error: {e}"); + print_cli_error(format!("MCP server failed: {e}")); std::process::exit(1); } Some(0) @@ -2392,9 +3691,9 @@ fn build_archive( let result = interpreter .compile_to_bytecode(&source) - .map_err(|e| format!("compile error: {}", e.inner()))?; + .map_err(|e| format!("compile failed: {}", e.format_plain()))?; let bytecode = sema_vm::serialize_to_bytes(&result, source_hash) - .map_err(|e| format!("serialization error: {}", e.inner()))?; + .map_err(|e| format!("serialization failed: {}", e.format_plain()))?; if opts.verbose { eprintln!("[2/4] Tracing imports..."); @@ -2410,7 +3709,7 @@ fn build_archive( for (rel_path, contents) in &imports { if let Err(e) = sema_core::vfs::validate_vfs_path(rel_path) { - eprintln!("Warning: skipping import with invalid VFS path: {e}"); + print_cli_warning(format!("skipping import with invalid VFS path: {e}")); continue; } files.insert(rel_path.clone(), contents.clone()); @@ -2432,7 +3731,7 @@ fn build_archive( .to_string_lossy() .to_string(); if let Err(e) = sema_core::vfs::validate_vfs_path(&rel) { - eprintln!("Warning: skipping {include}: {e}"); + print_cli_warning(format!("skipping {include}: {e}")); continue; } match std::fs::read(inc_path) { @@ -2440,11 +3739,11 @@ fn build_archive( files.insert(rel, data); } Err(e) => { - eprintln!("Warning: cannot read {include}: {e}"); + print_cli_warning(format!("cannot read {include}: {e}")); } } } else { - eprintln!("Warning: --include path not found: {include}"); + print_cli_warning(format!("--include path not found: {include}")); } } @@ -2802,12 +4101,12 @@ fn compile_source_to_bytecode(source: &str) -> Result, String> { let interpreter = Interpreter::new_with_sandbox(&sandbox); interpreter .eval_str_in_global(include_str!("web_prelude.sema")) - .map_err(|e| format!("web prelude error: {}", e.inner()))?; + .map_err(|e| format!("web prelude failed: {}", e.format_plain()))?; let result = interpreter .compile_to_bytecode(source) - .map_err(|e| format!("compile error: {}", e.inner()))?; + .map_err(|e| format!("compile failed: {}", e.format_plain()))?; sema_vm::serialize_to_bytes(&result, source_hash) - .map_err(|e| format!("serialization error: {}", e.inner())) + .map_err(|e| format!("serialization failed: {}", e.format_plain())) } fn should_compile_traced_import(rel_path: &str) -> bool { @@ -2865,7 +4164,7 @@ pub(crate) fn build_web_archive( for (rel_path, contents) in &imports { if let Err(e) = sema_core::vfs::validate_vfs_path(rel_path) { - eprintln!("Warning: skipping import with invalid VFS path: {e}"); + print_cli_warning(format!("skipping import with invalid VFS path: {e}")); continue; } @@ -2900,7 +4199,7 @@ pub(crate) fn build_web_archive( .to_string_lossy() .to_string(); if let Err(e) = sema_core::vfs::validate_vfs_path(&rel) { - eprintln!("Warning: skipping {include}: {e}"); + print_cli_warning(format!("skipping {include}: {e}")); continue; } match std::fs::read(inc_path) { @@ -2908,11 +4207,11 @@ pub(crate) fn build_web_archive( files.insert(rel, data); } Err(e) => { - eprintln!("Warning: cannot read {include}: {e}"); + print_cli_warning(format!("cannot read {include}: {e}")); } } } else { - eprintln!("Warning: --include path not found: {include}"); + print_cli_warning(format!("--include path not found: {include}")); } } @@ -3233,7 +4532,7 @@ fn collect_directory_files( let entries = match std::fs::read_dir(dir) { Ok(e) => e, Err(e) => { - eprintln!("Warning: cannot read directory {}: {e}", dir.display()); + print_cli_warning(format!("cannot read directory {}: {e}", dir.display())); return; } }; @@ -3251,7 +4550,7 @@ fn collect_directory_files( collect_directory_files(&entry_path, &vfs_path, files); } else if entry_path.is_file() { if let Err(e) = sema_core::vfs::validate_vfs_path(&vfs_path) { - eprintln!("Warning: skipping {}: {e}", entry_path.display()); + print_cli_warning(format!("skipping {}: {e}", entry_path.display())); continue; } match std::fs::read(&entry_path) { @@ -3259,7 +4558,7 @@ fn collect_directory_files( files.insert(vfs_path, data); } Err(e) => { - eprintln!("Warning: cannot read {}: {e}", entry_path.display()); + print_cli_warning(format!("cannot read {}: {e}", entry_path.display())); } } } @@ -3278,13 +4577,13 @@ fn run_check(file: &str) { let bytes = match std::fs::read(file) { Ok(b) => b, Err(e) => { - eprintln!("✗ {file}: {e}"); + print_cli_error(format!("could not read {file}: {e}")); std::process::exit(1); } }; if !sema_vm::is_bytecode_file(&bytes) { - eprintln!("✗ {file}: not a valid .semac bytecode file"); + print_cli_error(format!("{file} is not a valid .semac bytecode file")); std::process::exit(1); } @@ -3304,7 +4603,7 @@ fn run_check(file: &str) { ); } Err(e) => { - eprintln!("✗ {file}: {}", e.inner()); + print_cli_error(format!("{file} is invalid: {}", e.format_plain())); std::process::exit(1); } } @@ -3319,20 +4618,20 @@ fn run_disasm(file: &str, json: bool) { std::io::ErrorKind::PermissionDenied => format!("permission denied: {file}"), _ => format!("reading {file}: {e}"), }; - eprintln!("error: {msg}"); + print_cli_error(msg); std::process::exit(1); } }; if !sema_vm::is_bytecode_file(&bytes) { - eprintln!("Error: {file} is not a valid .semac bytecode file"); + print_cli_error(format!("{file} is not a valid .semac bytecode file")); std::process::exit(1); } let result = match sema_vm::deserialize_from_bytes(&bytes) { Ok(r) => r, Err(e) => { - eprintln!("Deserialization error: {}", e.inner()); + print_cli_error(format!("deserialization failed: {}", e.format_plain())); std::process::exit(1); } }; @@ -3652,7 +4951,7 @@ fn run_fmt( }) ); } else { - eprintln!("Error reading stdin: {e}"); + print_cli_error(format!("could not read stdin: {e}")); } std::process::exit(1); } @@ -3680,7 +4979,7 @@ fn run_fmt( }) ); } else { - eprintln!("Error formatting stdin: {e}"); + print_cli_error(format!("could not format stdin: {e}")); } std::process::exit(1); } @@ -3698,7 +4997,7 @@ fn run_fmt( .filter(|p| !is_ignored(p)) .collect::>(), Err(e) => { - eprintln!("Error: invalid glob pattern: {e}"); + print_cli_error(format!("invalid glob pattern: {e}")); std::process::exit(1); } } @@ -3718,7 +5017,7 @@ fn run_fmt( } } Err(e) => { - eprintln!("Error: invalid glob pattern '{pattern}': {e}"); + print_cli_error(format!("invalid glob pattern '{pattern}': {e}")); std::process::exit(1); } } @@ -3755,7 +5054,7 @@ fn run_fmt( }) ); } else { - eprintln!("error: {msg}"); + print_cli_error(msg); } errors += 1; continue; @@ -3775,7 +5074,7 @@ fn run_fmt( }) ); } else { - eprintln!("Error formatting {file}: {e}"); + print_cli_error(format!("could not format {file}: {e}")); } errors += 1; continue; @@ -3810,7 +5109,7 @@ fn run_fmt( } else { // Write formatted output back if let Err(e) = std::fs::write(file, &formatted) { - eprintln!("Error writing {file}: {e}"); + print_cli_error(format!("could not write {file}: {e}")); errors += 1; continue; } @@ -3841,7 +5140,7 @@ fn run_fmt( } if errors > 0 { - eprintln!("{errors} error(s)"); + print_cli_error(format!("{errors} file(s) could not be formatted")); std::process::exit(1); } @@ -3890,17 +5189,17 @@ fn run_ast(file: Option, eval: Option, json: bool) { (Some(path), None) => match read_source_file(path) { Ok(content) => content, Err(msg) => { - eprintln!("error: {msg}"); + print_cli_error(msg); std::process::exit(1); } }, (None, Some(expr)) => expr.clone(), (Some(_), Some(_)) => { - eprintln!("Error: cannot specify both a file and --eval"); + print_cli_error("cannot specify both a file and --eval"); std::process::exit(1); } (None, None) => { - eprintln!("Error: provide a file or --eval expression"); + print_cli_error("provide a file or --eval expression"); std::process::exit(1); } }; @@ -3908,7 +5207,7 @@ fn run_ast(file: Option, eval: Option, json: bool) { let exprs = match sema_reader::read_many(&source) { Ok(exprs) => exprs, Err(e) => { - eprintln!("Parse error: {}", e.inner()); + print_cli_error(format!("parsing failed: {}", e.format_plain())); std::process::exit(1); } }; @@ -4150,9 +5449,17 @@ pub(crate) fn format_source_snippet( Some(out) } +pub(crate) fn print_cli_error(message: impl std::fmt::Display) { + eprintln!("{} {message}", colors::red_bold("Error:")); +} + +pub(crate) fn print_cli_warning(message: impl std::fmt::Display) { + eprintln!("{} {message}", colors::yellow("Warning:")); +} + pub(crate) fn print_error(e: &SemaError) { let inner = e.inner(); - eprintln!("{} {}", colors::red_bold("Error:"), inner); + print_cli_error(e.user_message()); // Show source snippet for reader errors if let SemaError::Reader { span, .. } = inner { @@ -4370,7 +5677,7 @@ fn install_completions(shell: Shell) { let home = match std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")) { Ok(h) => PathBuf::from(h), Err(_) => { - eprintln!("Error: could not determine home directory"); + print_cli_error("could not determine the home directory"); std::process::exit(1); } }; @@ -4381,28 +5688,31 @@ fn install_completions(shell: Shell) { Shell::Fish => home.join(".config/fish/completions/sema.fish"), Shell::Elvish => home.join(".config/elvish/lib/sema.elv"), Shell::PowerShell => { - eprintln!( + print_cli_error( "Auto-install is not supported for PowerShell.\n\ - Run manually: sema completions powershell >> $PROFILE" + Run manually: sema completions powershell >> $PROFILE", ); std::process::exit(1); } _ => { - eprintln!("Auto-install is not supported for this shell."); + print_cli_error("auto-install is not supported for this shell"); std::process::exit(1); } }; if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).unwrap_or_else(|e| { - eprintln!("Error creating directory {}: {e}", parent.display()); + print_cli_error(format!( + "could not create directory {}: {e}", + parent.display() + )); std::process::exit(1); }); } let completions = generate_completions(shell); std::fs::write(&path, completions).unwrap_or_else(|e| { - eprintln!("Error writing {}: {e}", path.display()); + print_cli_error(format!("could not write {}: {e}", path.display())); std::process::exit(1); }); @@ -4416,6 +5726,34 @@ fn install_completions(shell: Shell) { mod tests { use super::*; + #[cfg(unix)] + #[test] + fn approval_revision_embeds_absolute_import_identity() { + let root = std::env::temp_dir().join(format!( + "sema-approval-revision-test-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + let helper = root.join("helper.sema"); + let workflow = root.join("workflow.sema"); + let helper_bytes = b"(define target \"v1\")\n"; + std::fs::write(&helper, helper_bytes).unwrap(); + let source = format!( + "(import \"helper.sema\")\n(import {})", + serde_json::to_string(&helper).unwrap() + ); + std::fs::write(&workflow, &source).unwrap(); + + let revision = workflow_approval_revision(&workflow, source.as_bytes()).unwrap(); + assert!(revision + .embedded_dependencies + .iter() + .any(|(path, bytes)| path == &helper && bytes == helper_bytes)); + + let _ = std::fs::remove_dir_all(root); + } + #[test] fn build_summary_helpers() { assert_eq!( diff --git a/crates/sema/src/pkg.rs b/crates/sema/src/pkg.rs index 45ddfaa0a..aa0187c00 100644 --- a/crates/sema/src/pkg.rs +++ b/crates/sema/src/pkg.rs @@ -68,6 +68,98 @@ fn find_all_packages(pkg_dir: &Path) -> Vec { packages } +fn validate_package_manifest_sema(dir: &Path, package: &str) -> Result<(), String> { + let manifest = dir.join("sema.toml"); + if !manifest.is_file() { + return Ok(()); + } + let content = std::fs::read_to_string(&manifest) + .map_err(|error| format!("Failed to read {}: {error}", manifest.display()))?; + validate_package_manifest_sema_content(&content, &manifest.display().to_string(), package) +} + +fn validate_package_manifest_sema_content( + content: &str, + source: &str, + package: &str, +) -> Result<(), String> { + let document: toml::Value = + toml::from_str(content).map_err(|error| format!("Failed to parse {source}: {error}"))?; + let requirement = document + .get("package") + .and_then(|package| package.get("sema_version_req")) + .map(|value| { + value + .as_str() + .ok_or_else(|| format!("{source} [package].sema_version_req must be a string")) + }) + .transpose()? + .map(str::trim) + .filter(|requirement| !requirement.is_empty()); + let Some(requirement) = requirement else { + return Ok(()); + }; + if requirement.len() > 128 { + return Err(format!( + "{package} has invalid sema_version_req: must be at most 128 characters" + )); + } + let parsed = semver::VersionReq::parse(requirement).map_err(|error| { + format!("{package} has invalid sema_version_req {requirement:?}: {error}") + })?; + let current = current_sema_version(); + if parsed.matches(¤t) { + Ok(()) + } else { + Err(format!( + "{package} requires Sema {requirement}, but this is Sema {current}" + )) + } +} + +fn resolve_git_ref_commit(dir: &Path, git_ref: &str) -> Result { + let candidates = [ + format!("refs/remotes/origin/{git_ref}"), + format!("refs/tags/{git_ref}"), + git_ref.to_string(), + ]; + for candidate in candidates { + let commit = format!("{candidate}^{{commit}}"); + if let Ok(resolved) = run_git(Some(dir), &["rev-parse", "--verify", &commit]) { + return Ok(resolved); + } + } + Err(format!("git ref {git_ref:?} does not resolve to a commit")) +} + +fn validate_git_commit_manifest( + dir: &Path, + commit: &str, + display_ref: &str, + package: &str, +) -> Result<(), String> { + let object = format!("{commit}:sema.toml"); + if run_git(Some(dir), &["cat-file", "-e", &object]).is_err() { + return Ok(()); + } + let content = run_git(Some(dir), &["show", &object])?; + validate_package_manifest_sema_content( + &content, + &format!("{package}@{display_ref}:sema.toml"), + package, + ) +} + +fn resolve_and_validate_git_ref( + dir: &Path, + git_ref: &str, + package: &str, +) -> Result { + let commit = resolve_git_ref_commit(dir, git_ref)?; + validate_git_commit_manifest(dir, &commit, git_ref, package)?; + Ok(commit) +} + fn collect_packages(dir: &Path, packages: &mut Vec) { let entries = match std::fs::read_dir(dir) { Ok(e) => e, @@ -111,25 +203,30 @@ pub fn cmd_add(spec: &str, registry: Option<&str>) -> Result<(), String> { fn install_git(spec: &sema_core::resolve::PackageSpec) -> Result<(String, String), String> { let pkg_dir = packages_dir(); let dest = spec.dest_dir(&pkg_dir); + let existed = dest.exists(); - if dest.exists() { + if existed { run_git(Some(&dest), &["fetch", "origin"])?; run_git(Some(&dest), &["fetch", "--tags"])?; - run_git(Some(&dest), &["checkout", &spec.git_ref])?; - let current = current_git_ref(&dest); - println!("✓ Updated {} → {current}", spec.path); } else { if let Some(parent) = dest.parent() { std::fs::create_dir_all(parent) .map_err(|e| format!("Failed to create directory: {e}"))?; } run_git(None, &["clone", &spec.clone_url(), &dest.to_string_lossy()])?; - run_git(Some(&dest), &["checkout", &spec.git_ref])?; - let current = current_git_ref(&dest); - println!("✓ Installed {} → {current}", spec.path); } - let commit = run_git(Some(&dest), &["rev-parse", "HEAD"])?; + let commit = match resolve_and_validate_git_ref(&dest, &spec.git_ref, spec.path.as_str()) { + Ok(commit) => commit, + Err(error) => { + if !existed { + let _ = std::fs::remove_dir_all(&dest); + } + return Err(error); + } + }; + run_git(Some(&dest), &["checkout", "--detach", &commit])?; + println!("✓ Installed {} → {} ({commit})", spec.path, spec.git_ref); let git_ref = spec.git_ref.clone(); Ok((git_ref, commit)) } @@ -141,8 +238,9 @@ fn install_git_locked( ) -> Result<(), String> { let pkg_dir = packages_dir(); let dest = spec.dest_dir(&pkg_dir); + let existed = dest.exists(); - if dest.exists() { + if existed { run_git(Some(&dest), &["fetch", "origin"])?; run_git(Some(&dest), &["fetch", "--tags"])?; } else { @@ -153,7 +251,16 @@ fn install_git_locked( run_git(None, &["clone", &spec.clone_url(), &dest.to_string_lossy()])?; } - run_git(Some(&dest), &["checkout", "--detach", expected_commit])?; + let commit = match resolve_and_validate_git_ref(&dest, expected_commit, spec.path.as_str()) { + Ok(commit) => commit, + Err(error) => { + if !existed { + let _ = std::fs::remove_dir_all(&dest); + } + return Err(error); + } + }; + run_git(Some(&dest), &["checkout", "--detach", &commit])?; let actual = run_git(Some(&dest), &["rev-parse", "HEAD"])?; if actual != expected_commit { return Err(format!( @@ -174,7 +281,7 @@ fn cmd_add_git(spec: &str) -> Result<(), String> { match add_dep_to_toml(toml_path, spec.path.as_str(), &git_ref) { Ok(true) => println!("✓ Added {} = \"{}\" to sema.toml", spec.path, git_ref), Ok(false) => {} - Err(e) => eprintln!("Warning: could not update sema.toml: {e}"), + Err(e) => crate::print_cli_warning(format!("could not update sema.toml: {e}")), } match update_lock_entry( @@ -186,7 +293,7 @@ fn cmd_add_git(spec: &str) -> Result<(), String> { }, ) { Ok(()) => println!("✓ Updated sema.lock"), - Err(e) => eprintln!("Warning: could not update sema.lock: {e}"), + Err(e) => crate::print_cli_warning(format!("could not update sema.lock: {e}")), } // Pull in this package's own dependencies, if any (transitive resolution). @@ -207,8 +314,9 @@ fn cmd_add_registry(spec: &str, registry: Option<&str>) -> Result<(), String> { Some(v) => v, None => { let info = registry_package_info(&name, ®istry_url)?; - latest_version(&info) - .ok_or_else(|| format!("No published versions found for '{name}'"))? + latest_compatible_version(&name, &info)?.ok_or_else(|| { + format!("No non-yanked version of '{name}' supports this Sema release") + })? } }; @@ -221,7 +329,7 @@ fn cmd_add_registry(spec: &str, registry: Option<&str>) -> Result<(), String> { match add_dep_to_toml(toml_path, &name, &version) { Ok(true) => println!("✓ Added {name} = \"{version}\" to sema.toml"), Ok(false) => {} - Err(e) => eprintln!("Warning: could not update sema.toml: {e}"), + Err(e) => crate::print_cli_warning(format!("could not update sema.toml: {e}")), } match update_lock_entry( @@ -234,7 +342,7 @@ fn cmd_add_registry(spec: &str, registry: Option<&str>) -> Result<(), String> { }, ) { Ok(()) => println!("✓ Updated sema.lock"), - Err(e) => eprintln!("Warning: could not update sema.lock: {e}"), + Err(e) => crate::print_cli_warning(format!("could not update sema.lock: {e}")), } // Pull in this package's own dependencies, if any (transitive resolution). @@ -682,7 +790,9 @@ pub fn cmd_install(locked: bool) -> Result<(), String> { )?; for name in &pruned { - eprintln!("Warning: '{name}' is no longer required, removing from sema.lock"); + crate::print_cli_warning(format!( + "'{name}' is no longer required; removing it from sema.lock" + )); } for note in ¬es { print_resolution_note(note); @@ -737,7 +847,7 @@ pub fn cmd_update(name: Option<&str>) -> Result<(), String> { continue; } if let Err(e) = update_single_package(&pkg_dir, dir) { - eprintln!("✗ Failed to update {}: {e}", rel.display()); + crate::print_cli_error(format!("could not update {}: {e}", rel.display())); } } } @@ -777,8 +887,9 @@ fn update_single_package(pkg_dir: &Path, dir: &Path) -> Result<(), String> { .unwrap_or(DEFAULT_REGISTRY); let info = registry_package_info(&name, registry)?; - let latest = - latest_version(&info).ok_or_else(|| format!("No versions found for '{name}'"))?; + let latest = latest_compatible_version(&name, &info)?.ok_or_else(|| { + format!("No non-yanked version of '{name}' supports this Sema release") + })?; if latest == current_ver { println!(" {} already at latest ({current_ver})", rel.display()); @@ -807,18 +918,18 @@ fn update_single_package(pkg_dir: &Path, dir: &Path) -> Result<(), String> { } else if dir.join(".git").is_dir() { // Git package — fetch and update to latest on the tracking ref run_git(Some(dir), &["fetch", "origin"])?; + run_git(Some(dir), &["fetch", "--tags"])?; // Read the tracking ref from sema.toml (needed if HEAD is detached after --locked install) let tracking_ref = read_dep_ref_from_toml(&rel_str); - if let Some(ref git_ref) = tracking_ref { - // Checkout the branch/tag first so pull works - let _ = run_git(Some(dir), &["checkout", git_ref]); - } - - run_git(Some(dir), &["pull"])?; let current_ref = tracking_ref.unwrap_or_else(|| current_git_ref(dir)); - let commit = - run_git(Some(dir), &["rev-parse", "HEAD"]).unwrap_or_else(|_| "unknown".to_string()); + let resolve_ref = if current_ref == "HEAD" { + "FETCH_HEAD" + } else { + current_ref.as_str() + }; + let commit = resolve_and_validate_git_ref(dir, resolve_ref, &rel_str)?; + run_git(Some(dir), &["checkout", "--detach", &commit])?; println!("✓ Updated {} → {current_ref}", rel.display()); let _ = update_lock_entry( @@ -858,7 +969,7 @@ pub fn cmd_remove(name: &str) -> Result<(), String> { match remove_dep_from_toml(toml_path, &rel_path) { Ok(true) => removed_from_toml = true, Ok(false) => {} - Err(e) => eprintln!("Warning: could not update sema.toml: {e}"), + Err(e) => crate::print_cli_warning(format!("could not update sema.toml: {e}")), } } @@ -916,7 +1027,7 @@ pub fn cmd_remove(name: &str) -> Result<(), String> { match remove_lock_entry(&rel_path) { Ok(true) => println!("✓ Removed {rel_path} from sema.lock"), Ok(false) => {} - Err(e) => eprintln!("Warning: could not update sema.lock: {e}"), + Err(e) => crate::print_cli_warning(format!("could not update sema.lock: {e}")), } Ok(()) @@ -1421,6 +1532,7 @@ fn install_tarball_atomic( // a broken tarball never leaves a corrupt tree behind. let build = || -> Result<(), String> { extract_tarball(tarball, &temp_dir)?; + validate_package_manifest_sema(&temp_dir, &format!("{name}@{version}"))?; write_pkg_meta(&temp_dir, name, version, registry_url, checksum)?; Ok(()) }; @@ -1452,6 +1564,7 @@ fn registry_install(name: &str, version: &str, registry_url: &str) -> Result Result<(), String> { validate_package_spec(name).map_err(|e| e.to_string())?; + validate_registry_version(name, version, registry_url)?; let (tarball, checksum) = registry_download(name, version, registry_url)?; if checksum != expected_checksum { @@ -1661,15 +1775,108 @@ fn registry_package_info(name: &str, registry_url: &str) -> Result Option { - info.get("versions")? - .as_array()? - .iter() - .filter(|v| !v.get("yanked").and_then(|y| y.as_bool()).unwrap_or(false)) - .filter_map(|v| v.get("version").and_then(|s| s.as_str())) - .next() - .map(|s| s.to_string()) +fn current_sema_version() -> semver::Version { + semver::Version::parse(env!("CARGO_PKG_VERSION")) + .expect("the Sema crate version is valid semver") +} + +fn version_metadata<'a>( + package: &str, + version: &str, + info: &'a serde_json::Value, +) -> Result<&'a serde_json::Value, String> { + info.get("versions") + .and_then(serde_json::Value::as_array) + .and_then(|versions| { + versions.iter().find(|candidate| { + candidate.get("version").and_then(serde_json::Value::as_str) == Some(version) + }) + }) + .ok_or_else(|| format!("Registry metadata has no version '{package}@{version}'")) +} + +fn ensure_version_supports_sema( + package: &str, + version: &str, + metadata: &serde_json::Value, +) -> Result, String> { + let requirement = match metadata.get("sema_version_req") { + None | Some(serde_json::Value::Null) => None, + Some(serde_json::Value::String(requirement)) => { + let requirement = requirement.trim(); + (!requirement.is_empty()).then_some(requirement) + } + Some(_) => { + return Err(format!( + "Registry metadata for {package}@{version} has invalid sema_version_req: \ + expected a string" + )) + } + }; + let Some(requirement) = requirement else { + return Ok(None); + }; + if requirement.len() > 128 { + return Err(format!( + "Registry metadata for {package}@{version} has invalid sema_version_req: \ + must be at most 128 characters" + )); + } + let parsed = semver::VersionReq::parse(requirement).map_err(|error| { + format!( + "Registry metadata for {package}@{version} has invalid sema_version_req \ + {requirement:?}: {error}" + ) + })?; + let current = current_sema_version(); + if !parsed.matches(¤t) { + return Err(format!( + "{package}@{version} requires Sema {requirement}, but this is Sema {current}" + )); + } + Ok(Some(requirement.to_string())) +} + +fn validate_registry_version( + package: &str, + version: &str, + registry_url: &str, +) -> Result, String> { + let info = registry_package_info(package, registry_url)?; + let metadata = version_metadata(package, version, &info)?; + ensure_version_supports_sema(package, version, metadata) +} + +/// Select the latest non-yanked version compatible with this Sema release. +fn latest_compatible_version( + package: &str, + info: &serde_json::Value, +) -> Result, String> { + let Some(versions) = info.get("versions").and_then(serde_json::Value::as_array) else { + return Ok(None); + }; + let mut invalid_requirement = None; + for metadata in versions.iter().filter(|version| { + !version + .get("yanked") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) + }) { + let Some(version) = metadata.get("version").and_then(serde_json::Value::as_str) else { + continue; + }; + match ensure_version_supports_sema(package, version, metadata) { + Ok(_) => return Ok(Some(version.to_string())), + Err(error) if error.contains("invalid sema_version_req") => { + invalid_requirement.get_or_insert(error); + } + Err(_) => {} + } + } + if let Some(error) = invalid_requirement { + return Err(error); + } + Ok(None) } fn validate_version(version: &str) -> Result { @@ -1702,6 +1909,26 @@ pub fn cmd_publish(registry: Option<&str>) -> Result<(), String> { .ok_or("sema.toml [package] missing 'version'")?; validate_version(version)?; + let sema_version_req = pkg + .get("sema_version_req") + .map(|value| { + value + .as_str() + .ok_or("sema.toml [package].sema_version_req must be a string") + }) + .transpose()? + .map(str::trim) + .filter(|requirement| !requirement.is_empty()); + if let Some(requirement) = sema_version_req { + if requirement.len() > 128 { + return Err( + "Invalid sema_version_req in sema.toml: must be at most 128 characters".to_string(), + ); + } + semver::VersionReq::parse(requirement).map_err(|error| { + format!("Invalid sema_version_req {requirement:?} in sema.toml: {error}") + })?; + } let token = read_token().ok_or("Not logged in. Run `sema pkg login --token ` first.")?; let registry_url = effective_registry(registry); @@ -1716,7 +1943,7 @@ pub fn cmd_publish(registry: Option<&str>) -> Result<(), String> { let metadata = serde_json::json!({ "description": pkg.get("description").and_then(|v| v.as_str()).unwrap_or(""), "repository_url": pkg.get("repository").and_then(|v| v.as_str()), - "sema_version_req": pkg.get("sema_version_req").and_then(|v| v.as_str()), + "sema_version_req": sema_version_req, }); // Upload. The multipart Form is single-use, so rebuild it (from owned bytes) @@ -2157,6 +2384,199 @@ mod tests { use std::fs; use std::io::Write; + #[test] + fn registry_version_selection_skips_incompatible_releases() { + let current = current_sema_version(); + let incompatible = format!(">{}.0.0", current.major + 1); + let compatible = format!(">={}.0.0", current.major); + let info = serde_json::json!({ + "versions": [ + { + "version": "2.0.0", + "yanked": false, + "sema_version_req": incompatible + }, + { + "version": "1.0.0", + "yanked": false, + "sema_version_req": compatible + } + ] + }); + assert_eq!( + latest_compatible_version("policies", &info).unwrap(), + Some("1.0.0".to_string()) + ); + } + + #[test] + fn explicit_registry_versions_fail_closed_on_invalid_requirements() { + let info = serde_json::json!({ + "versions": [{ + "version": "1.0.0", + "yanked": false, + "sema_version_req": "definitely not semver" + }] + }); + let metadata = version_metadata("policies", "1.0.0", &info).unwrap(); + let error = ensure_version_supports_sema("policies", "1.0.0", metadata).unwrap_err(); + assert!(error.contains("invalid sema_version_req")); + } + + #[test] + fn explicit_registry_versions_fail_closed_on_non_string_requirements() { + let info = serde_json::json!({ + "versions": [{ + "version": "1.0.0", + "yanked": false, + "sema_version_req": 34 + }] + }); + let metadata = version_metadata("policies", "1.0.0", &info).unwrap(); + let error = ensure_version_supports_sema("policies", "1.0.0", metadata).unwrap_err(); + assert!(error.contains("invalid sema_version_req")); + assert!(error.contains("expected a string")); + } + + #[test] + fn explicit_registry_versions_reject_incompatible_requirements() { + let current = current_sema_version(); + let incompatible = format!(">{}.0.0", current.major + 1); + let info = serde_json::json!({ + "versions": [{ + "version": "1.0.0", + "yanked": false, + "sema_version_req": incompatible + }] + }); + let metadata = version_metadata("policies", "1.0.0", &info).unwrap(); + let error = ensure_version_supports_sema("policies", "1.0.0", metadata).unwrap_err(); + assert!(error.contains("requires Sema")); + assert!(error.contains(¤t.to_string())); + } + + #[test] + fn installed_manifests_fail_closed_on_invalid_or_incompatible_requirements() { + let dir = tmpdir("manifest-sema-version"); + let current = current_sema_version(); + let incompatible = format!(">{}.0.0", current.major + 1); + + fs::write( + dir.join("sema.toml"), + "[package]\nname = \"policies\"\nsema_version_req = \"not semver\"\n", + ) + .unwrap(); + let invalid = validate_package_manifest_sema(&dir, "policies").unwrap_err(); + assert!(invalid.contains("invalid sema_version_req")); + + fs::write( + dir.join("sema.toml"), + "[package]\nname = \"policies\"\nsema_version_req = 34\n", + ) + .unwrap(); + let wrong_type = validate_package_manifest_sema(&dir, "policies").unwrap_err(); + assert!(wrong_type.contains("must be a string")); + + fs::write( + dir.join("sema.toml"), + format!("[package]\nname = \"policies\"\nsema_version_req = \"{incompatible}\"\n"), + ) + .unwrap(); + let incompatible = validate_package_manifest_sema(&dir, "policies").unwrap_err(); + assert!(incompatible.contains("requires Sema")); + assert!(incompatible.contains(¤t.to_string())); + + fs::write( + dir.join("sema.toml"), + format!( + "[package]\nname = \"policies\"\nsema_version_req = \">={}.0.0\"\n", + current.major + ), + ) + .unwrap(); + validate_package_manifest_sema(&dir, "policies").unwrap(); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn git_refs_are_validated_before_checkout() { + let dir = tmpdir("git-sema-version"); + run_git(Some(&dir), &["init"]).unwrap(); + run_git(Some(&dir), &["config", "user.email", "test@test.com"]).unwrap(); + run_git(Some(&dir), &["config", "user.name", "Test"]).unwrap(); + run_git(Some(&dir), &["checkout", "-b", "main"]).unwrap(); + fs::write( + dir.join("sema.toml"), + "[package]\nname = \"policies\"\nsema_version_req = \"*\"\n", + ) + .unwrap(); + run_git(Some(&dir), &["add", "sema.toml"]).unwrap(); + run_git(Some(&dir), &["commit", "-m", "compatible"]).unwrap(); + run_git(Some(&dir), &["checkout", "-b", "incompatible"]).unwrap(); + let current = current_sema_version(); + fs::write( + dir.join("sema.toml"), + format!( + "[package]\nname = \"policies\"\nsema_version_req = \">{}.0.0\"\n", + current.major + 1 + ), + ) + .unwrap(); + run_git(Some(&dir), &["add", "sema.toml"]).unwrap(); + run_git(Some(&dir), &["commit", "-m", "incompatible"]).unwrap(); + run_git(Some(&dir), &["checkout", "main"]).unwrap(); + + let error = resolve_and_validate_git_ref(&dir, "incompatible", "policies").unwrap_err(); + assert!(error.contains("requires Sema")); + assert_eq!(current_git_ref(&dir), "main"); + + fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn git_ref_validation_and_checkout_use_the_same_remote_commit() { + let dir = tmpdir("git-remote-ref-snapshot"); + run_git(Some(&dir), &["init"]).unwrap(); + run_git(Some(&dir), &["config", "user.email", "test@test.com"]).unwrap(); + run_git(Some(&dir), &["config", "user.name", "Test"]).unwrap(); + run_git(Some(&dir), &["checkout", "-b", "main"]).unwrap(); + fs::write( + dir.join("sema.toml"), + "[package]\nname = \"policies\"\nsema_version_req = \"*\"\n", + ) + .unwrap(); + fs::write(dir.join("package.sema"), "old").unwrap(); + run_git(Some(&dir), &["add", "."]).unwrap(); + run_git(Some(&dir), &["commit", "-m", "old local main"]).unwrap(); + + run_git(Some(&dir), &["checkout", "-b", "remote-main"]).unwrap(); + fs::write(dir.join("package.sema"), "new remote main").unwrap(); + run_git(Some(&dir), &["add", "package.sema"]).unwrap(); + run_git(Some(&dir), &["commit", "-m", "new remote main"]).unwrap(); + let remote_commit = run_git(Some(&dir), &["rev-parse", "HEAD"]).unwrap(); + run_git( + Some(&dir), + &["update-ref", "refs/remotes/origin/main", &remote_commit], + ) + .unwrap(); + run_git(Some(&dir), &["checkout", "main"]).unwrap(); + assert_eq!(fs::read_to_string(dir.join("package.sema")).unwrap(), "old"); + + let validated = resolve_and_validate_git_ref(&dir, "main", "policies").unwrap(); + assert_eq!(validated, remote_commit); + run_git(Some(&dir), &["checkout", "--detach", &validated]).unwrap(); + assert_eq!( + fs::read_to_string(dir.join("package.sema")).unwrap(), + "new remote main" + ); + assert_eq!( + run_git(Some(&dir), &["rev-parse", "HEAD"]).unwrap(), + validated + ); + + fs::remove_dir_all(&dir).unwrap(); + } + #[test] fn registry_install_rejects_path_traversal_name() { // Must fail at validation, before any network/filesystem work, so the diff --git a/crates/sema/src/repl/commands.rs b/crates/sema/src/repl/commands.rs index 3641dc95c..b8cb2d984 100644 --- a/crates/sema/src/repl/commands.rs +++ b/crates/sema/src/repl/commands.rs @@ -91,7 +91,7 @@ pub fn dispatch( match interpreter.eval_str_in_global(rest) { Ok(val) => { if let Err(e) = super::inspector::run(val, rest) { - eprintln!("inspector error: {e}"); + crate::print_cli_error(format!("inspector failed: {e}")); } } Err(e) => print_error(&e), diff --git a/crates/sema/src/repl/mod.rs b/crates/sema/src/repl/mod.rs index 5fc916551..5f1190c37 100644 --- a/crates/sema/src/repl/mod.rs +++ b/crates/sema/src/repl/mod.rs @@ -70,7 +70,7 @@ pub fn run(interpreter: Interpreter, quiet: bool, sandbox_mode: Option<&str>) { println!("Goodbye!"); } Err(msg) => { - eprintln!("error: {msg}"); + crate::print_cli_error(msg); std::process::exit(1); } } @@ -131,7 +131,7 @@ pub fn run(interpreter: Interpreter, quiet: bool, sandbox_mode: Option<&str>) { continue; } Err(e) => { - eprintln!("Error: {e}"); + crate::print_cli_error(e); break; } } diff --git a/crates/sema/src/web/assets/sema_wasm.js b/crates/sema/src/web/assets/sema_wasm.js index 2dacf40bf..a9073bd12 100644 --- a/crates/sema/src/web/assets/sema_wasm.js +++ b/crates/sema/src/web/assets/sema_wasm.js @@ -798,7 +798,7 @@ function __wbg_get_imports() { const a = state0.a; state0.a = 0; try { - return wasm_bindgen_99a98757d426b094___convert__closures_____invoke___js_sys_82c2e4c9bb939c97___Function_fn_wasm_bindgen_99a98757d426b094___JsValue_____wasm_bindgen_99a98757d426b094___sys__Undefined___js_sys_82c2e4c9bb939c97___Function_fn_wasm_bindgen_99a98757d426b094___JsValue_____wasm_bindgen_99a98757d426b094___sys__Undefined_______true_(a, state0.b, arg0, arg1); + return wasm_bindgen_ebd8a114a503ff65___convert__closures_____invoke___js_sys_767414036c389fc4___Function_fn_wasm_bindgen_ebd8a114a503ff65___JsValue_____wasm_bindgen_ebd8a114a503ff65___sys__Undefined___js_sys_767414036c389fc4___Function_fn_wasm_bindgen_ebd8a114a503ff65___JsValue_____wasm_bindgen_ebd8a114a503ff65___sys__Undefined_______true_(a, state0.b, arg0, arg1); } finally { state0.a = a; } @@ -824,7 +824,7 @@ function __wbg_get_imports() { const a = state0.a; state0.a = 0; try { - return wasm_bindgen_99a98757d426b094___convert__closures_____invoke___js_sys_82c2e4c9bb939c97___Function_fn_wasm_bindgen_99a98757d426b094___JsValue_____wasm_bindgen_99a98757d426b094___sys__Undefined___js_sys_82c2e4c9bb939c97___Function_fn_wasm_bindgen_99a98757d426b094___JsValue_____wasm_bindgen_99a98757d426b094___sys__Undefined_______true_(a, state0.b, arg0, arg1); + return wasm_bindgen_ebd8a114a503ff65___convert__closures_____invoke___js_sys_767414036c389fc4___Function_fn_wasm_bindgen_ebd8a114a503ff65___JsValue_____wasm_bindgen_ebd8a114a503ff65___sys__Undefined___js_sys_767414036c389fc4___Function_fn_wasm_bindgen_ebd8a114a503ff65___JsValue_____wasm_bindgen_ebd8a114a503ff65___sys__Undefined_______true_(a, state0.b, arg0, arg1); } finally { state0.a = a; } @@ -974,23 +974,23 @@ function __wbg_get_imports() { return ret; }, __wbindgen_cast_0000000000000001: function(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { dtor_idx: 9, function: Function { arguments: [Externref], shim_idx: 67, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`. - const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_99a98757d426b094___closure__destroy___dyn_core_7d5f0a2ba6a62c33___ops__function__FnMut__wasm_bindgen_99a98757d426b094___JsValue____Output___core_7d5f0a2ba6a62c33___result__Result_____wasm_bindgen_99a98757d426b094___JsError___, wasm_bindgen_99a98757d426b094___convert__closures_____invoke___wasm_bindgen_99a98757d426b094___JsValue__core_7d5f0a2ba6a62c33___result__Result_____wasm_bindgen_99a98757d426b094___JsError___true_); + // Cast intrinsic for `Closure(Closure { dtor_idx: 9, function: Function { arguments: [Externref], shim_idx: 66, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_ebd8a114a503ff65___closure__destroy___dyn_core_7d5f0a2ba6a62c33___ops__function__FnMut__wasm_bindgen_ebd8a114a503ff65___JsValue____Output___core_7d5f0a2ba6a62c33___result__Result_____wasm_bindgen_ebd8a114a503ff65___JsError___, wasm_bindgen_ebd8a114a503ff65___convert__closures_____invoke___wasm_bindgen_ebd8a114a503ff65___JsValue__core_7d5f0a2ba6a62c33___result__Result_____wasm_bindgen_ebd8a114a503ff65___JsError___true_); return ret; }, __wbindgen_cast_0000000000000002: function(arg0, arg1) { // Cast intrinsic for `Closure(Closure { dtor_idx: 9, function: Function { arguments: [F64, Externref, Externref], shim_idx: 12, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. - const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_99a98757d426b094___closure__destroy___dyn_core_7d5f0a2ba6a62c33___ops__function__FnMut__wasm_bindgen_99a98757d426b094___JsValue____Output___core_7d5f0a2ba6a62c33___result__Result_____wasm_bindgen_99a98757d426b094___JsError___, wasm_bindgen_99a98757d426b094___convert__closures_____invoke___f64__wasm_bindgen_99a98757d426b094___JsValue__wasm_bindgen_99a98757d426b094___JsValue______true_); + const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_ebd8a114a503ff65___closure__destroy___dyn_core_7d5f0a2ba6a62c33___ops__function__FnMut__wasm_bindgen_ebd8a114a503ff65___JsValue____Output___core_7d5f0a2ba6a62c33___result__Result_____wasm_bindgen_ebd8a114a503ff65___JsError___, wasm_bindgen_ebd8a114a503ff65___convert__closures_____invoke___f64__wasm_bindgen_ebd8a114a503ff65___JsValue__wasm_bindgen_ebd8a114a503ff65___JsValue______true_); return ret; }, __wbindgen_cast_0000000000000003: function(arg0, arg1) { // Cast intrinsic for `Closure(Closure { dtor_idx: 9, function: Function { arguments: [F64], shim_idx: 10, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. - const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_99a98757d426b094___closure__destroy___dyn_core_7d5f0a2ba6a62c33___ops__function__FnMut__wasm_bindgen_99a98757d426b094___JsValue____Output___core_7d5f0a2ba6a62c33___result__Result_____wasm_bindgen_99a98757d426b094___JsError___, wasm_bindgen_99a98757d426b094___convert__closures_____invoke___f64______true_); + const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_ebd8a114a503ff65___closure__destroy___dyn_core_7d5f0a2ba6a62c33___ops__function__FnMut__wasm_bindgen_ebd8a114a503ff65___JsValue____Output___core_7d5f0a2ba6a62c33___result__Result_____wasm_bindgen_ebd8a114a503ff65___JsError___, wasm_bindgen_ebd8a114a503ff65___convert__closures_____invoke___f64______true_); return ret; }, __wbindgen_cast_0000000000000004: function(arg0, arg1) { // Cast intrinsic for `Closure(Closure { dtor_idx: 9, function: Function { arguments: [], shim_idx: 14, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. - const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_99a98757d426b094___closure__destroy___dyn_core_7d5f0a2ba6a62c33___ops__function__FnMut__wasm_bindgen_99a98757d426b094___JsValue____Output___core_7d5f0a2ba6a62c33___result__Result_____wasm_bindgen_99a98757d426b094___JsError___, wasm_bindgen_99a98757d426b094___convert__closures_____invoke_______true_); + const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_ebd8a114a503ff65___closure__destroy___dyn_core_7d5f0a2ba6a62c33___ops__function__FnMut__wasm_bindgen_ebd8a114a503ff65___JsValue____Output___core_7d5f0a2ba6a62c33___result__Result_____wasm_bindgen_ebd8a114a503ff65___JsError___, wasm_bindgen_ebd8a114a503ff65___convert__closures_____invoke_______true_); return ret; }, __wbindgen_cast_0000000000000005: function(arg0) { @@ -1019,27 +1019,27 @@ function __wbg_get_imports() { }; } -function wasm_bindgen_99a98757d426b094___convert__closures_____invoke_______true_(arg0, arg1) { - wasm.wasm_bindgen_99a98757d426b094___convert__closures_____invoke_______true_(arg0, arg1); +function wasm_bindgen_ebd8a114a503ff65___convert__closures_____invoke_______true_(arg0, arg1) { + wasm.wasm_bindgen_ebd8a114a503ff65___convert__closures_____invoke_______true_(arg0, arg1); } -function wasm_bindgen_99a98757d426b094___convert__closures_____invoke___wasm_bindgen_99a98757d426b094___JsValue__core_7d5f0a2ba6a62c33___result__Result_____wasm_bindgen_99a98757d426b094___JsError___true_(arg0, arg1, arg2) { - const ret = wasm.wasm_bindgen_99a98757d426b094___convert__closures_____invoke___wasm_bindgen_99a98757d426b094___JsValue__core_7d5f0a2ba6a62c33___result__Result_____wasm_bindgen_99a98757d426b094___JsError___true_(arg0, arg1, arg2); +function wasm_bindgen_ebd8a114a503ff65___convert__closures_____invoke___wasm_bindgen_ebd8a114a503ff65___JsValue__core_7d5f0a2ba6a62c33___result__Result_____wasm_bindgen_ebd8a114a503ff65___JsError___true_(arg0, arg1, arg2) { + const ret = wasm.wasm_bindgen_ebd8a114a503ff65___convert__closures_____invoke___wasm_bindgen_ebd8a114a503ff65___JsValue__core_7d5f0a2ba6a62c33___result__Result_____wasm_bindgen_ebd8a114a503ff65___JsError___true_(arg0, arg1, arg2); if (ret[1]) { throw takeFromExternrefTable0(ret[0]); } } -function wasm_bindgen_99a98757d426b094___convert__closures_____invoke___js_sys_82c2e4c9bb939c97___Function_fn_wasm_bindgen_99a98757d426b094___JsValue_____wasm_bindgen_99a98757d426b094___sys__Undefined___js_sys_82c2e4c9bb939c97___Function_fn_wasm_bindgen_99a98757d426b094___JsValue_____wasm_bindgen_99a98757d426b094___sys__Undefined_______true_(arg0, arg1, arg2, arg3) { - wasm.wasm_bindgen_99a98757d426b094___convert__closures_____invoke___js_sys_82c2e4c9bb939c97___Function_fn_wasm_bindgen_99a98757d426b094___JsValue_____wasm_bindgen_99a98757d426b094___sys__Undefined___js_sys_82c2e4c9bb939c97___Function_fn_wasm_bindgen_99a98757d426b094___JsValue_____wasm_bindgen_99a98757d426b094___sys__Undefined_______true_(arg0, arg1, arg2, arg3); +function wasm_bindgen_ebd8a114a503ff65___convert__closures_____invoke___js_sys_767414036c389fc4___Function_fn_wasm_bindgen_ebd8a114a503ff65___JsValue_____wasm_bindgen_ebd8a114a503ff65___sys__Undefined___js_sys_767414036c389fc4___Function_fn_wasm_bindgen_ebd8a114a503ff65___JsValue_____wasm_bindgen_ebd8a114a503ff65___sys__Undefined_______true_(arg0, arg1, arg2, arg3) { + wasm.wasm_bindgen_ebd8a114a503ff65___convert__closures_____invoke___js_sys_767414036c389fc4___Function_fn_wasm_bindgen_ebd8a114a503ff65___JsValue_____wasm_bindgen_ebd8a114a503ff65___sys__Undefined___js_sys_767414036c389fc4___Function_fn_wasm_bindgen_ebd8a114a503ff65___JsValue_____wasm_bindgen_ebd8a114a503ff65___sys__Undefined_______true_(arg0, arg1, arg2, arg3); } -function wasm_bindgen_99a98757d426b094___convert__closures_____invoke___f64______true_(arg0, arg1, arg2) { - wasm.wasm_bindgen_99a98757d426b094___convert__closures_____invoke___f64______true_(arg0, arg1, arg2); +function wasm_bindgen_ebd8a114a503ff65___convert__closures_____invoke___f64______true_(arg0, arg1, arg2) { + wasm.wasm_bindgen_ebd8a114a503ff65___convert__closures_____invoke___f64______true_(arg0, arg1, arg2); } -function wasm_bindgen_99a98757d426b094___convert__closures_____invoke___f64__wasm_bindgen_99a98757d426b094___JsValue__wasm_bindgen_99a98757d426b094___JsValue______true_(arg0, arg1, arg2, arg3, arg4) { - wasm.wasm_bindgen_99a98757d426b094___convert__closures_____invoke___f64__wasm_bindgen_99a98757d426b094___JsValue__wasm_bindgen_99a98757d426b094___JsValue______true_(arg0, arg1, arg2, arg3, arg4); +function wasm_bindgen_ebd8a114a503ff65___convert__closures_____invoke___f64__wasm_bindgen_ebd8a114a503ff65___JsValue__wasm_bindgen_ebd8a114a503ff65___JsValue______true_(arg0, arg1, arg2, arg3, arg4) { + wasm.wasm_bindgen_ebd8a114a503ff65___convert__closures_____invoke___f64__wasm_bindgen_ebd8a114a503ff65___JsValue__wasm_bindgen_ebd8a114a503ff65___JsValue______true_(arg0, arg1, arg2, arg3, arg4); } diff --git a/crates/sema/src/web/assets/sema_wasm_bg.wasm b/crates/sema/src/web/assets/sema_wasm_bg.wasm index 73b61b355..02fc468d8 100644 Binary files a/crates/sema/src/web/assets/sema_wasm_bg.wasm and b/crates/sema/src/web/assets/sema_wasm_bg.wasm differ diff --git a/crates/sema/src/web/mod.rs b/crates/sema/src/web/mod.rs index cdba2a1a1..3aa6617f4 100644 --- a/crates/sema/src/web/mod.rs +++ b/crates/sema/src/web/mod.rs @@ -76,7 +76,7 @@ pub fn run(entry: &str, host: &str, port: u16, open: bool, llm: bool) -> Result< let interp = Interpreter::new_with_sandbox(&sandbox); interp .eval_str_in_global(&format!("(define __web-config-json {config_literal})")) - .map_err(|e| format!("web config injection failed: {}", e.inner()))?; + .map_err(|e| format!("web config injection failed: {}", e.format_plain()))?; // Configure LLM providers from env keys (as the CLI does) so the proxy can // reach real providers. Harmless when no keys are set. if llm { @@ -103,7 +103,7 @@ pub fn run(entry: &str, host: &str, port: u16, open: bool, llm: bool) -> Result< interp .eval_str_in_global(include_str!("dev_server.sema")) - .map_err(|e| format!("dev server error: {}", e.inner()))?; + .map_err(|e| format!("dev server failed: {}", e.format_plain()))?; Ok(()) } diff --git a/crates/sema/src/workflow_check.rs b/crates/sema/src/workflow_check.rs index 6649e4c2d..6a9208410 100644 --- a/crates/sema/src/workflow_check.rs +++ b/crates/sema/src/workflow_check.rs @@ -1,6 +1,6 @@ //! Re-export the workflow checker from sema-stdlib so `main.rs` compiles //! against a single implementation. -pub use sema_stdlib::workflow_check::{check_source, declared_permission_specs, report}; +pub use sema_stdlib::workflow_check::{check_run_source, declared_permission_specs, report}; // Diag and Severity are exported for callers that inspect diagnostics directly. #[allow(unused_imports)] pub use sema_stdlib::workflow_check::{Diag, Severity}; diff --git a/crates/sema/src/workflow_evidence.rs b/crates/sema/src/workflow_evidence.rs new file mode 100644 index 000000000..26479bee7 --- /dev/null +++ b/crates/sema/src/workflow_evidence.rs @@ -0,0 +1,515 @@ +//! Deterministic, provider-neutral workflow evidence bundles. + +use sema_workflow::approval::{ + self, ApprovalDecision, ApprovalDecisionKind, ApprovalRequest, ApprovalResolution, +}; +use serde::Serialize; +use sha2::{Digest, Sha256}; +use std::collections::BTreeMap; +use std::fs; +use std::io; +use std::path::{Component, Path, PathBuf}; + +#[derive(Debug, Serialize)] +struct Evidence { + schema_version: u32, + run_id: String, + status: Option, + metadata: serde_json::Value, + result: serde_json::Value, + event_counts: BTreeMap, + events: Vec, + approvals: Vec, +} + +#[derive(Debug, Serialize)] +struct ApprovalEvidence { + approval_id: String, + request_digest: String, + revision: u64, + phase: String, + key: String, + occurrence: u32, + subject_digest: String, + reason: String, + preview: Option, + requested_at: String, + status: &'static str, + decision: Option, +} + +#[derive(Debug, Serialize)] +struct ApprovalDecisionEvidence { + decision_id: String, + decision: ApprovalDecisionKind, + actor: String, + provenance: String, + comment: Option, + reason: Option, + decided_at: String, +} + +#[derive(Debug, Serialize)] +struct Manifest { + schema_version: u32, + run_id: String, + files: Vec, +} + +#[derive(Debug, Serialize)] +struct ManifestFile { + path: String, + bytes: usize, + sha256: String, +} + +pub struct ExportedEvidence { + pub directory: PathBuf, + pub evidence_json: PathBuf, + pub evidence_markdown: PathBuf, + pub manifest_json: PathBuf, +} + +pub fn export( + runs_root: &Path, + run_id: &str, + output_directory: Option<&Path>, +) -> io::Result { + validate_run_id(run_id)?; + let run_directory = runs_root.join(run_id); + if !run_directory.is_dir() { + return Err(io::Error::new( + io::ErrorKind::NotFound, + format!("workflow run not found: {}", run_directory.display()), + )); + } + let output_directory = output_directory + .map(Path::to_path_buf) + .unwrap_or_else(|| run_directory.join("evidence")); + fs::create_dir_all(&output_directory)?; + + let metadata = read_json_or_null(&run_directory.join("metadata.json"))?; + let result = read_json_or_null(&run_directory.join("result.json"))?; + let approval_resolutions = approval::list_requests(runs_root, run_id)?; + let approvals = approval_resolutions + .iter() + .map(ApprovalEvidence::from) + .collect(); + let journal_paths = journal_paths(&run_directory)?; + let mut events = Vec::new(); + let mut event_counts = BTreeMap::new(); + for path in &journal_paths { + for line in fs::read_to_string(path)?.lines() { + if line.trim().is_empty() { + continue; + } + let event: serde_json::Value = serde_json::from_str(line).map_err(|error| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("{}: {error}", path.display()), + ) + })?; + if let Some(kind) = event.get("event").and_then(serde_json::Value::as_str) { + *event_counts.entry(kind.to_string()).or_insert(0) += 1; + } + events.push(event); + } + } + let status = result + .get("status") + .and_then(serde_json::Value::as_str) + .map(str::to_string); + let evidence = Evidence { + schema_version: 1, + run_id: run_id.to_string(), + status, + metadata, + result, + event_counts, + events, + approvals, + }; + + let evidence_json = output_directory.join("evidence.json"); + let evidence_markdown = output_directory.join("evidence.md"); + let manifest_json = output_directory.join("manifest.json"); + let json_bytes = serde_json::to_vec_pretty(&evidence).map_err(io::Error::other)?; + fs::write(&evidence_json, &json_bytes)?; + let markdown = render_markdown(&evidence); + fs::write(&evidence_markdown, markdown.as_bytes())?; + + let mut manifest_sources = vec![ + run_directory.join("metadata.json"), + run_directory.join("result.json"), + ]; + manifest_sources.extend(journal_paths); + let mut files = manifest_sources + .into_iter() + .filter(|path| path.is_file()) + .map(|path| manifest_file(&path, &run_directory, &output_directory)) + .collect::>>()?; + for path in approval_sidecar_paths(&run_directory, &approval_resolutions) + .into_iter() + .chain([evidence_json.clone(), evidence_markdown.clone()]) + { + // These files were either signature-validated above or generated by this + // export. Do not silently omit one if it disappears before hashing. + files.push(manifest_file(&path, &run_directory, &output_directory)?); + } + let manifest = Manifest { + schema_version: 1, + run_id: run_id.to_string(), + files, + }; + fs::write( + &manifest_json, + serde_json::to_vec_pretty(&manifest).map_err(io::Error::other)?, + )?; + + Ok(ExportedEvidence { + directory: output_directory, + evidence_json, + evidence_markdown, + manifest_json, + }) +} + +impl From<&ApprovalResolution> for ApprovalEvidence { + fn from(resolution: &ApprovalResolution) -> Self { + let (request, status, decision) = match resolution { + ApprovalResolution::Pending(request) => (request, "pending", None), + ApprovalResolution::Approved(request, decision) => { + (request, "approved", Some(decision)) + } + ApprovalResolution::Rejected(request, decision) => { + (request, "rejected", Some(decision)) + } + }; + Self::new(request, status, decision) + } +} + +impl ApprovalEvidence { + fn new( + request: &ApprovalRequest, + status: &'static str, + decision: Option<&ApprovalDecision>, + ) -> Self { + Self { + approval_id: request.approval_id.clone(), + request_digest: request.request_digest.clone(), + revision: request.revision, + phase: request.phase.clone(), + key: request.key.clone(), + occurrence: request.occurrence, + subject_digest: request.subject_digest.clone(), + reason: request.reason.clone(), + preview: request.preview.clone(), + requested_at: request.requested_at.clone(), + status, + decision: decision.map(ApprovalDecisionEvidence::from), + } + } +} + +impl From<&ApprovalDecision> for ApprovalDecisionEvidence { + fn from(decision: &ApprovalDecision) -> Self { + Self { + decision_id: decision.decision_id.clone(), + decision: decision.decision, + actor: decision.actor.clone(), + provenance: decision.provenance.clone(), + comment: decision.comment.clone(), + reason: decision.reason.clone(), + decided_at: decision.decided_at.clone(), + } + } +} + +fn approval_sidecar_paths( + run_directory: &Path, + resolutions: &[ApprovalResolution], +) -> Vec { + resolutions + .iter() + .flat_map(|resolution| { + let (request, has_decision) = match resolution { + ApprovalResolution::Pending(request) => (request, false), + ApprovalResolution::Approved(request, _) + | ApprovalResolution::Rejected(request, _) => (request, true), + }; + let request_path = approval::request_path(run_directory, &request.approval_id); + let decision_path = + has_decision.then(|| approval::decision_path(run_directory, &request.approval_id)); + std::iter::once(request_path).chain(decision_path) + }) + .collect() +} + +fn validate_run_id(run_id: &str) -> io::Result<()> { + let mut components = Path::new(run_id).components(); + let valid = matches!(components.next(), Some(Component::Normal(_))) + && components.next().is_none() + && run_id != "." + && run_id != ".."; + if valid { + Ok(()) + } else { + Err(io::Error::new( + io::ErrorKind::InvalidInput, + "run id must be exactly one safe path component", + )) + } +} + +fn read_json_or_null(path: &Path) -> io::Result { + match fs::read(path) { + Ok(bytes) => serde_json::from_slice(&bytes).map_err(|error| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("{}: {error}", path.display()), + ) + }), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(serde_json::Value::Null), + Err(error) => Err(error), + } +} + +fn journal_paths(run_directory: &Path) -> io::Result> { + let mut paths = Vec::new(); + let primary = run_directory.join("events.jsonl"); + if primary.is_file() { + paths.push(primary); + } + let mut resumes = fs::read_dir(run_directory)? + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with("events.resume-") && name.ends_with(".jsonl")) + }) + .collect::>(); + resumes.sort_by_key(|path| { + path.file_stem() + .and_then(|stem| stem.to_str()) + .and_then(|stem| stem.strip_prefix("events.resume-")) + .and_then(|number| number.parse::().ok()) + .unwrap_or(u64::MAX) + }); + paths.extend(resumes); + Ok(paths) +} + +fn render_markdown(evidence: &Evidence) -> String { + let mut markdown = format!( + "# Workflow evidence\n\n- Run: `{}`\n- Status: `{}`\n- Events: {}\n\n## Event counts\n\n| Event | Count |\n| --- | ---: |\n", + evidence.run_id, + evidence.status.as_deref().unwrap_or("unknown"), + evidence.events.len() + ); + for (event, count) in &evidence.event_counts { + markdown.push_str(&format!("| `{event}` | {count} |\n")); + } + if !evidence.approvals.is_empty() { + markdown.push_str( + "\n## Human approvals\n\n| Approval | Key | Phase | Status | Actor |\n| --- | --- | --- | --- | --- |\n", + ); + for approval in &evidence.approvals { + let actor = approval + .decision + .as_ref() + .map_or("—", |decision| decision.actor.as_str()); + markdown.push_str(&format!( + "| {} | {} | {} | {} | {} |\n", + markdown_cell(&approval.approval_id), + markdown_cell(&approval.key), + markdown_cell(&approval.phase), + approval.status, + markdown_cell(actor), + )); + } + } + markdown.push_str( + "\nThe machine-readable bundle is `evidence.json`; file integrity, including validated approval sidecars, is recorded in `manifest.json`.\n", + ); + markdown +} + +fn markdown_cell(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('|', "\\|") + .replace(['\r', '\n'], " ") +} + +fn manifest_file( + path: &Path, + run_directory: &Path, + output_directory: &Path, +) -> io::Result { + let bytes = fs::read(path)?; + let display = path + .strip_prefix(run_directory) + .or_else(|_| path.strip_prefix(output_directory)) + .unwrap_or(path) + .to_string_lossy() + .replace('\\', "/"); + Ok(ManifestFile { + path: display, + bytes: bytes.len(), + sha256: format!("{:x}", Sha256::digest(&bytes)), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_directory(name: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "sema-workflow-evidence-{name}-{}", + std::process::id() + )) + } + + #[test] + fn rejects_path_like_run_ids() { + assert!(validate_run_id("../run").is_err()); + assert!(validate_run_id("nested/run").is_err()); + assert!(validate_run_id("run-1").is_ok()); + } + + #[test] + fn exports_ordered_events_and_integrity_manifest() { + let root = test_directory("bundle"); + let _ = fs::remove_dir_all(&root); + let run = root.join("run-1"); + fs::create_dir_all(&run).unwrap(); + fs::write(run.join("metadata.json"), r#"{"name":"demo"}"#).unwrap(); + fs::write(run.join("result.json"), r#"{"status":"success"}"#).unwrap(); + fs::write( + run.join("events.jsonl"), + "{\"event\":\"run.started\",\"seq\":1}\n", + ) + .unwrap(); + fs::write( + run.join("events.resume-1.jsonl"), + "{\"event\":\"checkpoint\",\"seq\":2}\n", + ) + .unwrap(); + + let bundle = export(&root, "run-1", None).unwrap(); + let evidence: serde_json::Value = + serde_json::from_slice(&fs::read(&bundle.evidence_json).unwrap()).unwrap(); + assert_eq!(evidence["status"], "success"); + assert_eq!(evidence["event_counts"]["run.started"], 1); + assert_eq!(evidence["event_counts"]["checkpoint"], 1); + assert_eq!(evidence["events"][0]["seq"], 1); + assert_eq!(evidence["events"][1]["seq"], 2); + assert_eq!(evidence["approvals"], serde_json::json!([])); + + let manifest: serde_json::Value = + serde_json::from_slice(&fs::read(&bundle.manifest_json).unwrap()).unwrap(); + let files = manifest["files"].as_array().unwrap(); + let evidence_entry = files + .iter() + .find(|entry| entry["path"] == "evidence/evidence.json") + .unwrap(); + let evidence_bytes = fs::read(&bundle.evidence_json).unwrap(); + assert_eq!( + evidence_entry["sha256"], + format!("{:x}", Sha256::digest(&evidence_bytes)) + ); + + fs::remove_dir_all(&root).unwrap(); + } + + #[test] + fn exports_validated_approval_summaries_and_sidecars() { + let root = test_directory("approvals"); + let _ = fs::remove_dir_all(&root); + let run = root.join("run-1"); + fs::create_dir_all(&run).unwrap(); + fs::write(run.join("metadata.json"), r#"{"name":"release"}"#).unwrap(); + fs::write(run.join("result.json"), r#"{"status":"success"}"#).unwrap(); + fs::write( + run.join("events.jsonl"), + "{\"event\":\"approval.applied\",\"seq\":1}\n", + ) + .unwrap(); + + let signing_key = approval::ApprovalSigningKey::generate().unwrap(); + let request = ApprovalRequest::new(approval::NewApprovalRequest { + run_id: "run-1".to_string(), + workflow: "release".to_string(), + code_version: "sha256:code".to_string(), + args_digest: "sha256:args".to_string(), + phase: "Review | sign-off".to_string(), + key: "release-signoff".to_string(), + occurrence: 1, + subject_digest: "sha256:subject".to_string(), + reason: "Publish the release".to_string(), + preview: Some("release@1.0.0".to_string()), + requested_at: "2026-08-01T00:00:00Z".to_string(), + authority_public_key: signing_key.public_key_base64().unwrap(), + }); + let approval_id = request.approval_id.clone(); + approval::ensure_request(&run, &request).unwrap(); + approval::decide( + &root, + "run-1", + &approval_id, + &signing_key, + ApprovalDecisionKind::Approve, + "release-manager".to_string(), + "test".to_string(), + Some("verified".to_string()), + None, + ) + .unwrap(); + + let bundle = export(&root, "run-1", None).unwrap(); + let evidence: serde_json::Value = + serde_json::from_slice(&fs::read(&bundle.evidence_json).unwrap()).unwrap(); + assert_eq!(evidence["approvals"][0]["approval_id"], approval_id); + assert_eq!(evidence["approvals"][0]["status"], "approved"); + assert_eq!( + evidence["approvals"][0]["decision"]["actor"], + "release-manager" + ); + + let markdown = fs::read_to_string(&bundle.evidence_markdown).unwrap(); + assert!(markdown.contains("Review \\| sign-off")); + assert!(markdown.contains("release-manager")); + + let manifest: serde_json::Value = + serde_json::from_slice(&fs::read(&bundle.manifest_json).unwrap()).unwrap(); + let paths = manifest["files"] + .as_array() + .unwrap() + .iter() + .filter_map(|entry| entry["path"].as_str()) + .collect::>(); + assert!(paths.contains(&format!("approvals/{approval_id}.request.json").as_str())); + assert!(paths.contains(&format!("approvals/{approval_id}.decision.json").as_str())); + + let decision_path = approval::decision_path(&run, &approval_id); + let mut tampered_decision: serde_json::Value = + serde_json::from_slice(&fs::read(&decision_path).unwrap()).unwrap(); + tampered_decision["actor"] = serde_json::json!("intruder"); + fs::write( + decision_path, + serde_json::to_vec_pretty(&tampered_decision).unwrap(), + ) + .unwrap(); + let error = export(&root, "run-1", Some(&run.join("tampered-evidence"))) + .err() + .expect("tampered decision must fail export"); + assert_eq!(error.kind(), io::ErrorKind::InvalidData); + + fs::remove_dir_all(&root).unwrap(); + } +} diff --git a/crates/sema/src/workflow_view.rs b/crates/sema/src/workflow_view.rs index 154c96529..bed73c1f3 100644 --- a/crates/sema/src/workflow_view.rs +++ b/crates/sema/src/workflow_view.rs @@ -1,4 +1,4 @@ -//! `sema workflow view` — a tiny read-only web viewer over workflow run journals. +//! `sema workflow view` — a tiny web viewer over workflow run journals. //! //! Spike (scope doc `docs/plans/archive/2026-06-23-workflow-dashboard-scope.md`, Option A): //! a self-contained AlpineJS tree viewer that `fetch()`es a run's `events.jsonl` and @@ -9,34 +9,41 @@ //! //! Security: loopback-only by default. GET routes are unauthenticated — the same //! trusted-local-developer tool model the notebook server documents. Binding a -//! non-loopback host exposes the run directory's contents (and, per below, the -//! write endpoints) to the network; that is the operator's responsibility. +//! non-loopback host exposes the run directory's contents and MCP auth write +//! endpoints to the network; that is the operator's responsibility. Approval +//! signing is stricter: a viewer configured with a private approval authority +//! refuses to bind a non-loopback host. //! -//! **Write-route hardening (plan §8):** `connect`/`forget` (`connect` module) -//! can trigger a real OAuth consent screen, so "loopback + no auth" alone is not -//! enough for those two routes — a malicious local page could otherwise POST to -//! them blind. At startup this server mints a random 32-hex session token +//! **Write-route hardening (plan §8):** MCP `connect`/`forget` and approval +//! decisions can cause external or durable changes, so "loopback + no auth" +//! alone is not enough for those routes. A malicious local page could otherwise +//! POST to them blind. At startup this server mints a random 32-hex session token //! (`sema_mcp::random_hex_token`) and substitutes it into the served HTML in //! place of the `__SEMA_VIEW_TOKEN__` placeholder (see `route`'s `"/"` case). -//! Every write route requires header `X-Sema-View-Token: ` matching -//! exactly; missing or wrong is a `403` with no side effects. This is -//! deliberately cheap, not a rewrite: the custom header ALSO forces a CORS -//! preflight on any cross-origin request, which this server never answers (no -//! `Access-Control-Allow-Origin` handling at all), so a third-party page's -//! browser-issued `fetch` can't even reach the route; the token defeats a -//! same-origin/drive-by guess. GET routes stay unauthenticated (read-only, -//! same trust model as before). +//! Every request must also carry a `Host` header for the configured loopback +//! listener. This blocks DNS rebinding through an attacker-controlled hostname. +//! Browser writes must be same-origin when `Origin` or Fetch Metadata is present. +//! Every write route requires `X-Sema-View-Token: ` matching exactly; +//! approval forms may carry the same token in a hidden form field so they still +//! work without JavaScript. Missing or wrong tokens get a `403` with no side +//! effects. The custom header forces a CORS preflight on cross-origin `fetch` +//! requests, which this server never answers (there is no +//! `Access-Control-Allow-Origin` handling); the unguessable hidden field gives +//! native forms equivalent protection. GET routes stay unauthenticated +//! (read-only, same trust model as before). //! //! No new crate dependency: a ~hand-rolled HTTP/1.1 handler over the `tokio` net/io //! the binary already pulls in (the notebook uses axum; a handful of routes does //! not need it). +use std::net::{IpAddr, SocketAddr}; use std::path::{Path, PathBuf}; use std::sync::Arc; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::{TcpListener, TcpStream}; +mod approval; pub mod auth; mod connect; pub mod ingest; @@ -50,15 +57,53 @@ pub(crate) type JsonResponse = (&'static str, &'static str, Vec); const INDEX_HTML: &str = include_str!("workflow_view/index.html"); const ALPINE_JS: &str = include_str!("workflow_view/alpine.min.js"); +const FAVICON_SVG: &[u8] = include_bytes!("workflow_view/favicon.svg"); /// Substituted for the real per-process session token at response time (see /// the module doc's §8 note). Must match the placeholder literal embedded in /// `workflow_view/index.html`'s `