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/Cargo.lock b/Cargo.lock index e968cf089..55147f589 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" @@ -3571,6 +3594,7 @@ dependencies = [ "sema-core", "sema-io", "sema-otel", + "sema-policy", "serde", "serde_json", "sha2 0.10.9", @@ -3655,6 +3679,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 +3739,7 @@ dependencies = [ "sema-io", "sema-llm", "sema-otel", + "sema-policy", "sema-reader", "sema-vm", "sema-workflow", diff --git a/Cargo.toml b/Cargo.toml index fc323dfdf..29bc64908 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" @@ -109,6 +112,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/error.rs b/crates/sema-core/src/error.rs index 51276fac8..5b367b12b 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,12 @@ pub enum SemaError { #[error("Permission denied: {function} — path '{path}' is outside allowed directories")] PathDenied { function: String, path: String }, + #[error("{0}")] + PolicyDenied(Box), + + #[error("Internal error: {0}")] + Internal(String), + #[error("User exception: {0}")] UserException(Value), @@ -213,6 +259,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 +420,8 @@ const CONDITION_TYPES: &[&str] = &[ "llm", "reader", "permission-denied", + "policy-denied", + "internal", "cancelled", "timeout", ]; @@ -389,6 +481,16 @@ 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}")) + } + + 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 +559,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 +571,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 +735,79 @@ 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::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 +893,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 +916,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 +1156,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 +1183,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/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..26c761b5b 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", @@ -13847,15 +13863,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\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 +13897,37 @@ "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/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 +13960,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 +14011,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/defpolicy.md b/crates/sema-docs/entries/stdlib/workflow/defpolicy.md new file mode 100644 index 000000000..2f6efa4fc --- /dev/null +++ b/crates/sema-docs/entries/stdlib/workflow/defpolicy.md @@ -0,0 +1,50 @@ +--- +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. + +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-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..5cdbe8a68 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. @@ -357,7 +374,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 +461,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..4fcae1a1c 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 { 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..6bbf290ed 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(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(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) -> T, +) -> 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)); + (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/helpers.rs b/crates/sema-lsp/src/helpers.rs index 8b403eb0d..126e1e127 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. 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..27524ed0d --- /dev/null +++ b/crates/sema-policy/src/lib.rs @@ -0,0 +1,2626 @@ +//! 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 + } + + pub fn model_action(&self) -> ModelDenyAction { + self.models + .as_ref() + .map_or(ModelDenyAction::Fail, |policy| policy.on_deny) + } + + pub fn tool_action(&self) -> ToolDenyAction { + self.tools + .as_ref() + .map_or(ToolDenyAction::Fail, |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", + "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)) + { + 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)) + { + 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"))?, + }) + } + + fn matches(&self, subject: &ResolvedSubject, workspace_root: &Path) -> bool { + if self.kind != subject.kind() { + return false; + } + match subject { + ResolvedSubject::File { path, .. } => self + .constraint + .as_ref() + .is_none_or(|constraint| constraint.check(path, workspace_root).is_ok()), + ResolvedSubject::NetworkRequest { method, url } => { + (self.methods.is_empty() + || method + .as_deref() + .is_some_and(|method| self.methods.contains(method))) + && self + .constraint + .as_ref() + .is_none_or(|constraint| constraint.check(url, workspace_root).is_ok()) + } + ResolvedSubject::Command(command) => self + .constraint + .as_ref() + .is_none_or(|constraint| constraint.check(command, workspace_root).is_ok()), + 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))?; + self.kind.check(value, workspace_root) + } +} + +#[derive(Debug)] +enum CompiledConstraint { + Path(PathConstraint), + Domain(DomainConstraint), + Command(CommandConstraint), +} + +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<(), String> { + 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<(), String> { + let path = value + .as_str() + .ok_or_else(|| "path argument must be a string".to_string())?; + let relative = normalize_policy_path(workspace_root, path)?; + if self.deny.iter().any(|pattern| pattern.is_match(&relative)) { + return Err("path matches a deny pattern".to_string()); + } + self.allow + .iter() + .any(|pattern| pattern.is_match(&relative)) + .then_some(()) + .ok_or_else(|| "path is not allowlisted".to_string()) + } +} + +#[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<(), String> { + let raw = value + .as_str() + .ok_or_else(|| "URL argument must be a string".to_string())?; + let url = Url::parse(raw).map_err(|_| "URL argument is invalid".to_string())?; + if !self.schemes.contains(url.scheme()) { + return Err("URL scheme is not allowlisted".to_string()); + } + if !url.username().is_empty() || url.password().is_some() { + return Err("URL credentials are not allowed".to_string()); + } + 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("URL must have a host".to_string()), + }; + if let Some(ports) = &self.ports { + let port = url + .port_or_known_default() + .ok_or_else(|| "URL port cannot be resolved".to_string())?; + if !ports.contains(&port) { + return Err("URL port is not allowlisted".to_string()); + } + } + if self.deny.iter().any(|pattern| pattern.matches(&host)) { + return Err("URL host matches a deny rule".to_string()); + } + self.allow + .iter() + .any(|pattern| pattern.matches(&host)) + .then_some(()) + .ok_or_else(|| "URL host is not allowlisted".to_string()) + } +} + +#[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<(), String> { + let command = value + .as_str() + .ok_or_else(|| "command argument must be a string".to_string())?; + if self.deny.contains(command) { + return Err("command matches an explicit deny rule".to_string()); + } + self.allow + .contains(command) + .then_some(()) + .ok_or_else(|| "command is not allowlisted".to_string()) + } +} + +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)); + } + std::env::current_dir() + .map(|cwd| normalize_lexical(&cwd.join(path))) + .map_err(|error| format!("cannot resolve workflow 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(), 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(), ModelDenyAction::Fail); + assert!( + !policy + .check_tool("read-file", &serde_json::json!({}), &[], Path::new(".")) + .allowed + ); + assert_eq!(policy.tool_action(), 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", "checkpoint"]), + )]), + ), + ])) + .unwrap(); + assert_eq!( + policy.required_metadata().collect::>(), + vec!["owner", "risk-tier"] + ); + assert_eq!( + policy.required_completion_events().collect::>(), + vec!["agent.tool_result", "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 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..1d7d6dbf7 100644 --- a/crates/sema-stdlib/src/workflow.rs +++ b/crates/sema-stdlib/src/workflow.rs @@ -27,10 +27,15 @@ use sema_core::runtime::{ NativeContinuation, NativeOutcome, NativeResult, NativeSuspend, PreparedExternalOperation, ResumeInput, SendPayload, TaskContextHandle, Trace, WaitKind, }; -use sema_core::{SemaError, Value}; +use sema_core::{PolicyDenial, SemaError, Value}; +use sema_llm::builtins::{ + PolicyAttributionScope, PolicyBypassScope, PolicyDecisionSink, PolicyObservation, + PolicyObservationKind, PolicyScope, +}; 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 +59,246 @@ 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()) +} + +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 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 +338,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 +373,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 @@ -413,6 +660,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 +724,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 +779,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 +791,10 @@ fn step_plan( teardown: None, }); }; + let step_policy = compile_policy(&args[0])?; + 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 +812,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 +825,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 +851,8 @@ fn step_plan( content_key, start, usage_scope, + _policy_scope: policy_scope, + _attribution_scope: attribution_scope, }), }) } @@ -621,16 +924,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, } @@ -677,9 +974,13 @@ fn finish_run( durable: bool, ) -> NativeResult { let (mut status, mut envelope, mut reason) = match &result { - Ok(v) => ("success", success_envelope(v.clone()), None), + Ok(v) => { + let envelope = success_envelope(v.clone()); + let status = envelope_status(&envelope).unwrap_or_else(|| "success".to_string()); + (status, envelope, None) + } Err(e) => ( - "failed", + "failed".to_string(), failed_envelope(&e.to_string()), Some("workflow body returned an error".to_string()), ), @@ -690,15 +991,32 @@ fn finish_run( let ack = if let Some(ctx) = &ctx { // 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(), }); @@ -866,10 +1184,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 +1201,10 @@ 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 = 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 +1228,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 +1245,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 +1296,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 +1338,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 +1435,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 +1475,8 @@ struct ResolveContinuation { guard: context::WorkflowGuard, thunk: Value, resolver: Rc, + policy: Option>>, + workspace_root: PathBuf, } impl Trace for ResolveContinuation { @@ -1157,11 +1497,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), )), @@ -1275,6 +1625,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 +1667,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 diff --git a/crates/sema-stdlib/src/workflow_check.rs b/crates/sema-stdlib/src/workflow_check.rs index f5bc20572..2d328fc7c 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,7 @@ 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); + find_workflows_and_policies(form, &spans, &mut diags); } diags } @@ -167,20 +167,56 @@ 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() { + 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) { + if let Err(error) = sema_policy::CompiledPolicy::compile(policy) { + 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, + }); + } +} + /// Check one `(defworkflow name doc meta . body)` form. fn check_workflow(items: &[Value], form: &Value, spans: &SpanMap, out: &mut Vec) { let wf_span = span_of(form, spans); @@ -293,6 +329,12 @@ 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()) + { + check_literal_policy(policy, wf_span, out); + } } // Marker arity/opts checks across the whole body (including nested forms). @@ -401,6 +443,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. @@ -465,6 +510,12 @@ 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()) + { + check_literal_policy(policy, span, out); + } // :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 +538,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( @@ -790,6 +859,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..cb5e462c7 100644 --- a/crates/sema-vm/src/lower.rs +++ b/crates/sema-vm/src/lower.rs @@ -1968,17 +1968,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), }) } @@ -2798,7 +2803,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 +2821,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/vm.rs b/crates/sema-vm/src/vm.rs index f8058808d..35f54163b 100644 --- a/crates/sema-vm/src/vm.rs +++ b/crates/sema-vm/src/vm.rs @@ -5786,14 +5786,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 +5817,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 +5876,29 @@ 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::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/src/context.rs b/crates/sema-workflow/src/context.rs index a02cc0f79..af2a57399 100644 --- a/crates/sema-workflow/src/context.rs +++ b/crates/sema-workflow/src/context.rs @@ -134,6 +134,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 @@ -238,6 +240,7 @@ impl WorkflowCtx { 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, @@ -334,9 +337,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. @@ -460,14 +474,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 +493,7 @@ impl WorkflowCtx { name, prompt, schema_repr, + policy_fingerprint, ]); format!("{base}_{}", self.next_occurrence(&base)) } @@ -1210,12 +1226,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 +1252,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..a09e7125e 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")] @@ -232,6 +244,134 @@ 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, + }, +} + +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", + } + } } #[cfg(test)] @@ -406,4 +546,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/src/import_tracer.rs b/crates/sema/src/import_tracer.rs index 5ea51e9ce..6477d1929 100644 --- a/crates/sema/src/import_tracer.rs +++ b/crates/sema/src/import_tracer.rs @@ -37,8 +37,13 @@ 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()))?; + let exprs = sema_reader::read_many(&source).map_err(|e| { + format!( + "parse failed in {}: {}", + root_file.display(), + e.format_plain() + ) + })?; trace_file_imports(&exprs, &root_file, &root_dir, &mut visited, &mut result)?; @@ -86,12 +91,12 @@ fn extract_imports( process_import(path_str, current_file, root_dir, visited, result)?; } else { // 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. @@ -148,12 +153,12 @@ fn process_import( let canonical = match resolved.canonicalize() { Ok(c) => c, Err(_) => { - eprintln!( - " warning: import \"{}\" (from {}) couldn't be resolved at build time; \ + 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(()); } }; @@ -168,11 +173,11 @@ 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 \ + crate::print_cli_warning(format!( + "import \"{}\" could not be read at build time; not bundled \ (resolved at runtime)", canonical.display() - ); + )); return Ok(()); } }; @@ -190,21 +195,21 @@ 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 \ + 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 \ + crate::print_cli_warning(format!( + "imported file {} is outside the project directory; not \ bundled (resolved at runtime)", canonical.display() - ); + )); return Ok(()); } }; 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..786ed680e 100644 --- a/crates/sema/src/main.rs +++ b/crates/sema/src/main.rs @@ -611,6 +611,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. @@ -843,7 +856,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 +910,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 +941,7 @@ fn main() { } }; if let Err(e) = result { - eprintln!("Error: {e}"); + print_cli_error(e); std::process::exit(1); } } @@ -957,7 +970,7 @@ fn main() { no_cache, BuildOutputOpts { verbose, json }, ) { - eprintln!("Error: {e}"); + print_cli_error(e); std::process::exit(1); } } @@ -1021,7 +1034,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 +1056,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 +1069,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 +1084,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 +1099,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 +1128,7 @@ fn main() { yes, }; if let Err(e) = update::run(opts) { - eprintln!("Error: {e}"); + print_cli_error(e); std::process::exit(1); } } @@ -1173,7 +1186,7 @@ fn main() { } } Err(msg) => { - eprintln!("error: {msg}"); + print_cli_error(msg); std::process::exit(1); } } @@ -1264,11 +1277,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); } } @@ -1319,11 +1334,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,7 +1374,7 @@ 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); } }; @@ -1366,12 +1407,14 @@ fn run_workflow_command(command: WorkflowCommands, sandbox: &sema_core::Sandbox) || 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); @@ -1383,7 +1426,7 @@ fn run_workflow_command(command: WorkflowCommands, sandbox: &sema_core::Sandbox) let content = match read_source_file(&file) { Ok(c) => c, Err(msg) => { - eprintln!("error: {msg}"); + print_cli_error(msg); std::process::exit(1); } }; @@ -1392,13 +1435,13 @@ fn run_workflow_command(command: WorkflowCommands, sandbox: &sema_core::Sandbox) 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); @@ -1424,7 +1467,7 @@ fn run_workflow_command(command: WorkflowCommands, sandbox: &sema_core::Sandbox) ) .await { - eprintln!("warning: --view could not start the viewer: {e}"); + print_cli_warning(format!("--view could not start the viewer: {e}")); } }); }); @@ -1447,7 +1490,7 @@ fn run_workflow_command(command: WorkflowCommands, sandbox: &sema_core::Sandbox) 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}"); + print_cli_error(format!("--args is not valid JSON: {e}")); std::process::exit(1); } }; @@ -1477,7 +1520,7 @@ fn run_workflow_command(command: WorkflowCommands, sandbox: &sema_core::Sandbox) .and_then(|s| s.as_keyword()); match status.as_deref() { Some("failed") => { - eprintln!("workflow failed: {}", pretty_print(&envelope, 80)); + print_cli_error(format!("workflow failed: {}", pretty_print(&envelope, 80))); 1 } // The headless-precursor gate (docs/plans/2026-06-24-workflow-mcp-auth.md @@ -1614,7 +1657,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 +1711,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 +1719,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 +1735,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 +1743,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 +1753,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 +1772,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 +1809,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 +1830,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 +1851,7 @@ fn run_eval( elapsed_ms: 0, }); } else { - eprintln!("Error: {e}"); + print_cli_error(e); } std::process::exit(1); }), @@ -1885,7 +1930,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 +2068,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 +2083,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 +2092,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 +2103,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 +2132,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 +2147,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 +2202,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 +2439,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 +2457,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 +2479,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 +2487,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 +2849,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 +2912,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 +2947,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 +2955,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 +3280,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 +3298,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 +3306,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 +3325,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 +3351,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 +3366,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 +3699,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 +3727,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 +3745,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 +3765,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 +3802,7 @@ fn run_fmt( }) ); } else { - eprintln!("error: {msg}"); + print_cli_error(msg); } errors += 1; continue; @@ -3775,7 +3822,7 @@ fn run_fmt( }) ); } else { - eprintln!("Error formatting {file}: {e}"); + print_cli_error(format!("could not format {file}: {e}")); } errors += 1; continue; @@ -3810,7 +3857,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 +3888,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 +3937,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 +3955,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 +4197,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 +4425,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 +4436,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); }); diff --git a/crates/sema/src/pkg.rs b/crates/sema/src/pkg.rs index 45ddfaa0a..ba8f8c4a4 100644 --- a/crates/sema/src/pkg.rs +++ b/crates/sema/src/pkg.rs @@ -68,6 +68,81 @@ 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 validate_git_ref_manifest(dir: &Path, git_ref: &str, package: &str) -> Result<(), String> { + let candidates = [ + format!("refs/remotes/origin/{git_ref}"), + format!("refs/tags/{git_ref}"), + git_ref.to_string(), + ]; + let Some(resolved) = candidates + .iter() + .find(|candidate| run_git(Some(dir), &["rev-parse", "--verify", candidate]).is_ok()) + .cloned() + else { + // Checkout reports a missing ref with the normal git diagnostic. + return Ok(()); + }; + let object = format!("{resolved}: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}@{git_ref}:sema.toml"), + package, + ) +} + fn collect_packages(dir: &Path, packages: &mut Vec) { let entries = match std::fs::read_dir(dir) { Ok(e) => e, @@ -111,24 +186,28 @@ 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); } + if let Err(error) = validate_git_ref_manifest(&dest, &spec.git_ref, spec.path.as_str()) { + if !existed { + let _ = std::fs::remove_dir_all(&dest); + } + return Err(error); + } + 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 git_ref = spec.git_ref.clone(); Ok((git_ref, commit)) @@ -141,8 +220,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,6 +233,12 @@ fn install_git_locked( run_git(None, &["clone", &spec.clone_url(), &dest.to_string_lossy()])?; } + if let Err(error) = validate_git_ref_manifest(&dest, expected_commit, spec.path.as_str()) { + if !existed { + let _ = std::fs::remove_dir_all(&dest); + } + return Err(error); + } run_git(Some(&dest), &["checkout", "--detach", expected_commit])?; let actual = run_git(Some(&dest), &["rev-parse", "HEAD"])?; if actual != expected_commit { @@ -174,7 +260,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 +272,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 +293,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 +308,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 +321,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 +769,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 +826,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 +866,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,9 +897,15 @@ 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); + validate_git_ref_manifest( + dir, + tracking_ref.as_deref().unwrap_or("FETCH_HEAD"), + &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]); @@ -858,7 +954,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 +1012,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 +1517,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 +1549,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 +1760,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 +1894,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 +1928,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 +2369,155 @@ 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 = validate_git_ref_manifest(&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 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..2561b56f5 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_evidence.rs b/crates/sema/src/workflow_evidence.rs new file mode 100644 index 000000000..6bf7ac2d8 --- /dev/null +++ b/crates/sema/src/workflow_evidence.rs @@ -0,0 +1,285 @@ +//! Deterministic, provider-neutral workflow evidence bundles. + +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, +} + +#[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 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, + }; + + 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); + manifest_sources.push(evidence_json.clone()); + manifest_sources.push(evidence_markdown.clone()); + let files = manifest_sources + .into_iter() + .filter(|path| path.is_file()) + .map(|path| manifest_file(&path, &run_directory, &output_directory)) + .collect::>>()?; + 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, + }) +} + +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")); + } + markdown.push_str( + "\nThe machine-readable bundle is `evidence.json`; file integrity is recorded in `manifest.json`.\n", + ); + markdown +} + +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); + + 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(); + } +} diff --git a/crates/sema/src/workflow_view/index.html b/crates/sema/src/workflow_view/index.html index eff3f9d81..dffb2f453 100644 --- a/crates/sema/src/workflow_view/index.html +++ b/crates/sema/src/workflow_view/index.html @@ -333,8 +333,10 @@ item_count/, no per-agent usage inside agent.result; budget events carry an OPTIONAL agent_id (the only way per-agent tokens become attributable). - Frozen vocab (scoping §3.5): run.started, phase.started, phase.ended, - agent.started, agent.result, agent.tool_call, checkpoint, budget, run.ended. + Existing event shapes are frozen; additive kinds are supported. Core vocab: + run.started, phase.started, phase.ended, agent.started, agent.result, + agent.tool_call, checkpoint, budget, policy.checked, policy.violation, + policy.bypassed, run.ended. ========================================================================= */ // Live data — fetched from the viewer server in load(); see init() at the bottom. let EVENTS = []; @@ -713,6 +715,9 @@ case "agent.tool_call": return `${G.twig}`; case "checkpoint": return `${G.cp}`; case "budget": return `${G.bud}`; + case "policy.checked": return `${G.ok}`; + case "policy.violation": return `${G.fail}`; + case "policy.bypassed": return `${G.skip}`; default: return `${G.bud}`; } } @@ -727,6 +732,9 @@ case "agent.tool_call": return `${esc(e.agent_id)} ${esc(e.tool_name)} args ${e.args_json==="gated"?"(gated)":esc(e.args_json)}`; case "checkpoint": return `${esc(e.key)} content_key ${esc(e.content_key)} digest ${esc(e.value_digest)}`; case "budget": return `${e.agent_id?esc(e.agent_id)+" ":""}in ${e.input_tokens} out ${e.output_tokens} cost ${e.cost_usd==null?"—":"$"+e.cost_usd.toFixed(4)}`; + case "policy.checked": return `${esc(e.policy)} allowed ${esc(e.boundary)} ${esc(e.subject)} · ${esc(e.rule)} · ${esc(e.source)}`; + case "policy.violation": return `${esc(e.policy)} denied ${esc(e.boundary)} ${esc(e.subject)} · ${esc(e.action)} · ${esc(e.reason)}`; + case "policy.bypassed": return `${esc(e.policy)} bypassed ${esc(e.boundary)} ${esc(e.subject)} · ${esc(e.reason)}`; default: return ""; } } diff --git a/crates/sema/src/workflow_view/ingest.rs b/crates/sema/src/workflow_view/ingest.rs index 401112d0c..e2234e665 100644 --- a/crates/sema/src/workflow_view/ingest.rs +++ b/crates/sema/src/workflow_view/ingest.rs @@ -82,6 +82,13 @@ pub fn init_schema(conn: &Connection) -> rusqlite::Result<()> { input_tokens INTEGER, output_tokens INTEGER, cost_usd REAL, -- nullable! PRIMARY KEY (run_id, seg, seq) ); + CREATE TABLE IF NOT EXISTS policy_events ( + run_id TEXT, seg INTEGER, seq INTEGER, event TEXT, phase_seq INTEGER, + agent_id TEXT, policy TEXT, policy_digest TEXT, boundary TEXT, + subject TEXT, subject_digest TEXT, rule TEXT, action TEXT, reason TEXT, + source TEXT, + PRIMARY KEY (run_id, seg, seq) + ); -- One cursor per journal FILE (key = run_id for the primary, run_id::resume-n -- for a segment), so each file resumes from its own byte offset. CREATE TABLE IF NOT EXISTS ingest_cursor ( @@ -194,6 +201,7 @@ fn wipe_run(conn: &Connection, run_id: &str) -> rusqlite::Result<()> { "tool_calls", "checkpoints", "usage", + "policy_events", "ingest_cursor", ] { conn.execute(&format!("DELETE FROM {t} WHERE run_id=?1"), params![run_id])?; @@ -334,6 +342,31 @@ fn project_event( ], )?; } + event @ ("policy.checked" | "policy.violation" | "policy.bypassed") => { + conn.execute( + "INSERT OR IGNORE INTO policy_events( + run_id,seg,seq,event,phase_seq,agent_id,policy,policy_digest,boundary, + subject,subject_digest,rule,action,reason,source + ) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15)", + params![ + run_id, + seg, + seq, + event, + i(e, "phase_seq"), + s(e, "agent_id"), + s(e, "policy"), + s(e, "policy_digest"), + s(e, "boundary"), + s(e, "subject"), + s(e, "subject_digest"), + s(e, "rule"), + s(e, "action"), + s(e, "reason"), + s(e, "source"), + ], + )?; + } _ => {} } Ok(()) @@ -554,4 +587,51 @@ mod tests { assert_eq!(first, third, "re-ingest must not change row counts"); } + + #[test] + fn ingests_policy_events_without_requiring_raw_arguments() { + let mut dir = std::env::temp_dir(); + dir.push(format!("sema-wf-policy-ingest-{}", std::process::id())); + let run = dir.join("policy-run"); + std::fs::create_dir_all(&run).unwrap(); + std::fs::write( + run.join("events.jsonl"), + concat!( + r#"{"event":"run.started","seq":0,"ts":"0","workflow":"wf","run_id":"embedded"}"#, + "\n", + r#"{"event":"policy.checked","seq":1,"ts":"0","policy":"safe","policy_digest":"p","boundary":"tool","subject":"read-file","subject_digest":"args-sha","rule":"tools.read-file.allow","source":"request"}"#, + "\n", + r#"{"event":"policy.violation","seq":2,"ts":"0","policy":"safe","policy_digest":"p","boundary":"model","subject":"other/model","rule":"models.default-deny","action":"fail","reason":"not allowlisted","source":"cache"}"#, + "\n", + r#"{"event":"policy.bypassed","seq":3,"ts":"0","policy":"effective-policy","policy_digest":"p","boundary":"tool","subject":"legacy-tool","subject_digest":"legacy-sha","rule":"policy.without","reason":"migration","source":"request"}"#, + "\n", + ), + ) + .unwrap(); + + let conn = Connection::open_in_memory().unwrap(); + init_schema(&conn).unwrap(); + sync_run(&conn, &dir, "policy-run").unwrap(); + + assert_eq!(count(&conn, "policy_events"), 3); + let checked: (String, String, String, Option) = conn + .query_row( + "SELECT event,subject,rule,subject_digest + FROM policy_events WHERE seq=1", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), + ) + .unwrap(); + assert_eq!( + checked, + ( + "policy.checked".into(), + "read-file".into(), + "tools.read-file.allow".into(), + Some("args-sha".into()) + ) + ); + + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/sema/tests/integration_test.rs b/crates/sema/tests/integration_test.rs index caa586166..6af5f5952 100644 --- a/crates/sema/tests/integration_test.rs +++ b/crates/sema/tests/integration_test.rs @@ -5323,7 +5323,7 @@ fn test_ast_parse_error() { assert!(!output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); assert!( - stderr.contains("Parse error"), + stderr.contains("parsing failed") && stderr.contains("unterminated list"), "expected parse error: {stderr}" ); } diff --git a/crates/sema/tests/pmap_async_test.rs b/crates/sema/tests/pmap_async_test.rs index 622196f9d..f0382ba4b 100644 --- a/crates/sema/tests/pmap_async_test.rs +++ b/crates/sema/tests/pmap_async_test.rs @@ -407,7 +407,7 @@ fn pmap_preserves_exact_arity_errors() { assert!(zero_args .expect_err("zero-argument pmap must fail") .to_string() - .contains("llm/pmap expects 2-3 args, got 0")); + .contains("llm/pmap expects 2 to 3 arguments, got 0")); assert_eq!(recorder.call_count(), 0); let fake = FakeProvider::builder("fake").model("fake-chat").build(); @@ -415,7 +415,7 @@ fn pmap_preserves_exact_arity_errors() { assert!(four_args .expect_err("four-argument pmap must fail") .to_string() - .contains("llm/pmap expects 2-3 args, got 4")); + .contains("llm/pmap expects 2 to 3 arguments, got 4")); assert_eq!(recorder.call_count(), 0); } diff --git a/crates/sema/tests/suites/eval_stdlib_test.rs b/crates/sema/tests/suites/eval_stdlib_test.rs index cdca36ec1..090b800df 100644 --- a/crates/sema/tests/suites/eval_stdlib_test.rs +++ b/crates/sema/tests/suites/eval_stdlib_test.rs @@ -214,11 +214,52 @@ eval_tests! { // A handler that suspends parks on the runtime and resumes; the raw value // (a keyword, which has no JSON form) passes through unstringified. tool_invoke_suspending_handler: r#"(begin (deftool slow "Sleep then return" {} (lambda () (async/sleep 1) :done)) (tool/invoke slow {}))"# => Value::keyword("done"), + string_split_type_condition_fields: + r#"(try (string/split 42 ",") (catch error (list (:type error) (:function error) (:argument error) (:expected error) (:got error))))"# + => Value::list(vec![ + Value::keyword("type-error"), + Value::string("string/split"), + Value::int(1), + Value::string("string"), + Value::string("int"), + ]), + string_split_arity_condition_fields: + r#"(try (string/split "x") (catch error (list (:type error) (:function error) (:expected error) (:got error))))"# + => Value::list(vec![ + Value::keyword("arity"), + Value::string("string/split"), + Value::string("2"), + Value::int(1), + ]), } eval_error_tests! { tool_invoke_invalid_args: r#"(begin (deftool calc "Add" {:x {:type :number}} (lambda (x) x)) (tool/invoke calc {}))"# => "invalid arguments for tool 'calc': missing key: x", tool_invoke_validate_fail: r#"(begin (deftool check "Check" {:x {:type :number :validate (lambda (v) (> v 0)) :message "must be positive"}} (lambda (x) x)) (tool/invoke check {:x -1}))"# => "invalid arguments for tool 'check': key x: must be positive", + deftool_options_type_has_context: + r#"(deftool bad "Bad" {} 42 (lambda () nil))"# + => "deftool: options must be a map, got int", + deftool_subjects_type_has_context: + r#"(deftool bad "Bad" {} {:policy-subjects 42} (lambda () nil))"# + => "deftool: :policy-subjects must be a list or vector, got int", + deftool_subject_missing_kind: + r#"(deftool bad "Bad" {} {:policy-subjects [{}]} (lambda () nil))"# + => "deftool: policy subject 1 is missing :kind", + deftool_subject_field_type_and_index: + r#"(deftool bad "Bad" {} {:policy-subjects [{:kind :command :command-arg :cmd} {:kind :file-read :path-arg 42}]} (lambda () nil))"# + => "deftool: policy subject 2 :path-arg must be a keyword or string, got int", + deftool_subject_kind_lists_valid_values: + r#"(deftool bad "Bad" {} {:policy-subjects [{:kind :database :target-arg :db}]} (lambda () nil))"# + => "deftool: policy subject 1 has unsupported :kind :database", + deftool_subject_unknown_key: + r#"(deftool bad "Bad" {} {:policy-subjects [{:kind :file-read :path-ag :path}]} (lambda () nil))"# + => "deftool: policy subject 1 has unknown key :path-ag", + string_split_type_names_function_and_argument: + r#"(string/split 42 ",")"# + => "string/split argument 1 expected string, got int", + string_split_arity_uses_readable_grammar: + r#"(string/split "x")"# + => "string/split expects 2 arguments, got 1", } // ============================================================ diff --git a/crates/sema/tests/suites/eval_test.rs b/crates/sema/tests/suites/eval_test.rs index 7e1cbd943..fb5897f5e 100644 --- a/crates/sema/tests/suites/eval_test.rs +++ b/crates/sema/tests/suites/eval_test.rs @@ -2985,7 +2985,7 @@ eval_tests! { eval_error_tests! { // Arity is still checked on the self-call frame path. - call_self_arity_error: "(define (f n) (if (= n 0) 0 (+ 1 (f)))) (f 1)" => "expects 1 args, got 0", + call_self_arity_error: "(define (f n) (if (= n 0) 0 (+ 1 (f)))) (f 1)" => "expects 1 argument, got 0", } // ============================================================ diff --git a/crates/sema/tests/suites/stream_async_test.rs b/crates/sema/tests/suites/stream_async_test.rs index 401f12c01..8e57bc4dc 100644 --- a/crates/sema/tests/suites/stream_async_test.rs +++ b/crates/sema/tests/suites/stream_async_test.rs @@ -796,7 +796,7 @@ fn blocking_stream_compatibility_native_preserves_public_arity_error() { .expect("arity error message") .to_string(); assert!( - message.contains("llm/stream expects 1-3 args, got 0"), + message.contains("llm/stream expects 1 to 3 arguments, got 0"), "unexpected zero-arity error: {message}" ); assert_eq!( diff --git a/crates/sema/tests/suites/workflow_policy_test.rs b/crates/sema/tests/suites/workflow_policy_test.rs new file mode 100644 index 000000000..830408b17 --- /dev/null +++ b/crates/sema/tests/suites/workflow_policy_test.rs @@ -0,0 +1,1045 @@ +//! End-to-end workflow policy tests. +//! +//! These use the real workflow, LLM, agent, tool, journal, and resume paths +//! against a deterministic `FakeProvider`. No network access or API key is +//! required. + +use crate::workflow_common as wc; +use crate::workflow_common::{run_workflow, temp_run_dir, RunOpts}; + +use sema_eval::Interpreter; +use sema_llm::fake::FakeProvider; +use sema_llm::types::ToolCall; + +fn fake_with_reply(text: &str) -> FakeProvider { + FakeProvider::builder("fake") + .model("fake-model") + .reply(text) + .build() +} + +#[test] +fn workflow_policy_allows_the_resolved_model_and_journals_the_check() { + let src = r#" + (defpolicy safe + {:models {:default :deny :allow ["fake/fake-model"]}}) + (defworkflow guarded "model allowlist" {:policy safe} + (phase "Run") + (def result (step "say hello" {:name "writer"})) + {:status :success :result result}) + "#; + + let out = wc::run_once(src, fake_with_reply("hello"), "wf_policy_model_allow"); + assert_eq!(out.result["status"], "success"); + assert_eq!(out.recorder.call_count(), 1); + + let checks = wc::events_of(&out.events, "policy.checked"); + assert_eq!( + checks.len(), + 1, + "one provider boundary must produce one policy check" + ); + assert!( + checks.iter().any(|event| { + event["policy"] == "safe" + && event["boundary"] == "model" + && event["subject"] == "fake/fake-model" + && event["rule"] == "models.allow" + }), + "expected the resolved provider/model check, got {checks:?}" + ); +} + +#[test] +fn model_skip_still_fails_a_non_fallback_call_before_provider_access() { + let src = r#" + (defpolicy safe + {:models {:default :deny + :allow ["other/*"] + :on-deny :skip}}) + (defworkflow guarded "model deny" {:policy safe} + (phase "Run") + (step "must not run" {:name "writer"}) + {:status :success}) + "#; + + let out = wc::run_once(src, fake_with_reply("unexpected"), "wf_policy_model_deny"); + assert_eq!( + out.recorder.call_count(), + 0, + "a denied model must not reach the provider" + ); + let violations = wc::events_of(&out.events, "policy.violation"); + assert!( + violations.iter().any(|event| { + event["boundary"] == "model" + && event["subject"] == "fake/fake-model" + && event["action"] == "fail" + }), + "expected a model violation, got {violations:?}" + ); + assert_eq!( + wc::events_of(&out.events, "run.ended")[0]["status"], + "failed" + ); +} + +#[test] +fn caught_model_denial_preserves_structured_policy_details() { + let src = r#" + (defpolicy safe + {:models {:default :deny}}) + (defworkflow guarded "structured model denial" {:policy safe} + (phase "Run") + (def denial + (try + (llm/complete "must not run") + (catch error error))) + {:status :success :denial denial}) + "#; + + let out = wc::run_once( + src, + fake_with_reply("unexpected"), + "wf_policy_structured_denial", + ); + assert_eq!(out.result["status"], "success"); + assert_eq!( + out.recorder.call_count(), + 0, + "a denied model must not reach the provider" + ); + + let denial = &out.result["denial"]; + assert_eq!(denial["type"], "policy-denied"); + assert_eq!(denial["policy"], "safe"); + assert_eq!(denial["boundary"], "model"); + assert_eq!(denial["subject"], "fake/fake-model"); + assert_eq!(denial["rule"], "models.default-deny"); + assert_eq!(denial["reason"], "model fake/fake-model is not allowlisted"); + assert_eq!(denial["action"], "fail"); + assert_eq!(denial["source"], "request"); + assert_eq!( + denial["message"], + "Policy 'safe' denied model 'fake/fake-model': model fake/fake-model is not allowlisted" + ); +} + +#[test] +fn model_skip_skips_only_denied_fallback_targets() { + let src = r#" + (llm/define-provider :blocked + {:default-model "model" + :complete (lambda (_request) "blocked")}) + (llm/define-provider :allowed + {:default-model "model" + :complete (lambda (_request) "allowed")}) + (defpolicy fallback-safe + {:models {:default :deny + :allow ["allowed/model"] + :on-deny :skip}}) + (defworkflow guarded "fallback skip" {:policy fallback-safe} + (phase "Run") + (def result + (llm/with-fallback [:blocked :allowed] + (lambda () (llm/complete "choose")))) + {:status :success :result result}) + "#; + + let fake = FakeProvider::builder("fake").model("fake-model").build(); + let out = wc::run_once(src, fake, "wf_policy_fallback_skip"); + assert_eq!(out.result["status"], "success"); + assert_eq!(out.result["result"], "allowed"); + assert!( + wc::events_of(&out.events, "policy.violation") + .iter() + .any(|event| { event["subject"] == "blocked/model" && event["action"] == "skip" }), + "the denied fallback should be skipped and audited" + ); + assert!(wc::events_of(&out.events, "policy.checked") + .iter() + .any(|event| event["subject"] == "allowed/model")); +} + +#[test] +fn single_entry_fallback_preserves_skip_for_completion_and_stream() { + let src = r#" + (defpolicy fallback-safe + {:models {:default :deny :on-deny :skip}}) + (defworkflow guarded "single fallback skip" {:policy fallback-safe} + (phase "Run") + (def completion-type + (try + (llm/with-fallback [:fake] + (lambda () (llm/complete "complete"))) + (catch error (:type error)))) + (def stream-type + (try + (llm/with-fallback [:fake] + (lambda () + (llm/stream "stream" (lambda (_chunk) nil)))) + (catch error (:type error)))) + {:status :success + :types (list completion-type stream-type)}) + "#; + + let fake = FakeProvider::builder("fake").model("fake-model").build(); + let out = wc::run_once(src, fake, "wf_policy_single_fallback_skip"); + assert_eq!(out.result["status"], "success"); + assert_eq!(out.result["types"], serde_json::json!(["llm", "llm"])); + assert_eq!( + out.recorder.call_count(), + 0, + "a denied fallback target must not reach the provider" + ); + + let violations = wc::events_of(&out.events, "policy.violation"); + assert_eq!( + violations.len(), + 2, + "completion and stream should each audit one denial" + ); + assert!( + violations + .iter() + .all(|event| { event["subject"] == "fake/fake-model" && event["action"] == "skip" }), + "a one-entry fallback must retain fallback skip behavior: {violations:?}" + ); +} + +#[test] +fn batch_and_pmap_check_model_policy_before_provider_access() { + let src = r#" + (defpolicy locked {:models {:default :deny}}) + (defworkflow guarded "batch model policy" {:policy locked} + (phase "Run") + (def batch-result + (try + (llm/batch (list "batch")) + (catch _ :denied))) + (def pmap-result + (try + (llm/pmap str (list "pmap")) + (catch _ :denied))) + {:status :success + :results (list batch-result pmap-result)}) + "#; + + let out = wc::run_once( + src, + FakeProvider::builder("fake") + .model("fake-model") + .reply("unexpected") + .reply("unexpected") + .build(), + "wf_policy_batch_pmap", + ); + assert_eq!(out.result["status"], "success"); + assert_eq!( + out.result["results"], + serde_json::json!(["denied", "denied"]) + ); + assert_eq!( + out.recorder.call_count(), + 0, + "denied batch requests must not reach the provider" + ); + + let violations = wc::events_of(&out.events, "policy.violation"); + assert_eq!( + violations.len(), + 2, + "llm/batch and llm/pmap should each audit one denial" + ); + assert!( + violations + .iter() + .all(|event| { event["subject"] == "fake/fake-model" && event["action"] == "fail" }), + "batch model denials should be hard failures: {violations:?}" + ); +} + +#[test] +fn stream_embed_and_rerank_are_denied_before_provider_or_callback() { + let base = temp_run_dir("policy-model-surfaces"); + let marker = base.join("stream-callback"); + let path = marker + .to_string_lossy() + .replace('\\', "\\\\") + .replace('"', "\\\""); + let src = format!( + r#" + (defpolicy locked {{:models {{:default :deny}}}}) + (defworkflow guarded "all model surfaces" {{:policy locked}} + (phase "Run") + (def embed-result + (try (llm/embed "secret") (catch _ :denied))) + (def rerank-result + (try (llm/rerank "query" (list "document")) + (catch _ :denied))) + (def stream-result + (try + (llm/stream "secret" + (lambda (chunk) (file/write "{path}" chunk))) + (catch _ :denied))) + {{:status :success + :results (list embed-result rerank-result stream-result)}}) + "# + ); + let fake = FakeProvider::builder("fake") + .model("fake-model") + .embed(vec![vec![0.1, 0.2]]) + .rerank(&[(0, 0.9)]) + .stream(&["must", "not", "stream"]) + .build(); + let out = run_workflow( + &src, + fake, + RunOpts::fresh("wf_policy_model_surfaces", &base), + ); + + assert_eq!(out.result["status"], "success"); + assert_eq!(out.recorder.call_count(), 0, "stream must not open"); + assert!(out.recorder.embeds().is_empty(), "embed must not dispatch"); + assert!( + out.recorder.reranks().is_empty(), + "rerank must not dispatch" + ); + assert!( + !marker.exists(), + "stream callbacks must not run for a denied model" + ); + assert!( + wc::events_of(&out.events, "policy.violation").len() >= 3, + "each denied model surface should be audited" + ); + + let _ = std::fs::remove_dir_all(&base); +} + +#[test] +fn input_policy_redacts_before_provider_dispatch() { + let src = r#" + (defpolicy private + {:input {:detect [:email] + :actions {:email :redact}}}) + (defworkflow guarded "input redaction" {:policy private} + (phase "Run") + (def result (step "Email alice@example.com" {:name "writer"})) + {:status :success :result result}) + "#; + + let out = wc::run_once( + src, + fake_with_reply("accepted"), + "wf_policy_input_redaction", + ); + assert_eq!(out.result["status"], "success"); + let requests = out.recorder.requests(); + assert_eq!(requests.len(), 1); + let provider_input = requests[0].messages[0].content.to_text(); + assert!(!provider_input.contains("alice@example.com")); + assert!(provider_input.contains("«redacted:email»")); + assert!( + wc::events_of(&out.events, "policy.redacted") + .iter() + .any(|event| { + event["boundary"] == "llm.input" && event["label"] == "email" && event["count"] == 1 + }), + "the safe aggregate finding should be journaled" + ); +} + +#[test] +fn output_policy_buffers_streams_and_blocks_before_callbacks() { + let base = temp_run_dir("policy-output-block"); + let marker = base.join("stream-callback"); + let path = marker + .to_string_lossy() + .replace('\\', "\\\\") + .replace('"', "\\\""); + let src = format!( + r#" + (defpolicy private + {{:output {{:detect [:email] + :actions {{:email :block}}}}}}) + (defworkflow guarded "output block" {{:policy private}} + (phase "Run") + (def completion + (try (llm/complete "complete") (catch error (:type error)))) + (def streaming + (try + (llm/stream "stream" + (lambda (chunk) (file/write "{path}" chunk))) + (catch error (:type error)))) + {{:status :success :types (list completion streaming)}}) + "# + ); + let fake = FakeProvider::builder("fake") + .model("fake-model") + .reply("alice@example.com") + .stream(&["alice@", "example.com"]) + .build(); + let out = run_workflow(&src, fake, RunOpts::fresh("wf_policy_output_block", &base)); + + assert_eq!(out.result["status"], "success"); + assert_eq!( + out.result["types"], + serde_json::json!(["policy-denied", "policy-denied"]) + ); + assert!( + !marker.exists(), + "a governed stream must not deliver unsafe prefixes" + ); + assert_eq!( + wc::events_of(&out.events, "policy.violation") + .iter() + .filter(|event| event["boundary"] == "llm.output") + .count(), + 2 + ); + + let _ = std::fs::remove_dir_all(&base); +} + +#[test] +fn output_policy_returns_only_redacted_content() { + let src = r#" + (defpolicy private + {:output {:detect [:email] + :actions {:email :redact}}}) + (defworkflow guarded "output redaction" {:policy private} + (phase "Run") + (def result (llm/complete "complete")) + {:status :success :result result}) + "#; + let out = wc::run_once( + src, + fake_with_reply("Contact alice@example.com"), + "wf_policy_output_redaction", + ); + + assert_eq!(out.result["status"], "success"); + assert_eq!(out.result["result"], "Contact «redacted:email»"); + assert!( + !serde_json::to_string(&out.events) + .expect("events serialize") + .contains("alice@example.com"), + "journals must contain only safe finding metadata and digests" + ); +} + +#[test] +fn composed_output_policies_report_each_layer_action() { + let src = r#" + (defpolicy audit-email + {:output {:detect [:email] + :actions {:email :audit}}}) + (defpolicy redact-phone + {:output {:detect [:phone] + :actions {:phone :redact}}}) + (defworkflow guarded "composed output actions" + {:policy [audit-email redact-phone]} + (phase "Run") + (def result (step "answer")) + {:status :success :result result}) + "#; + let out = wc::run_once( + src, + fake_with_reply("alice@example.com +1 212-555-0199"), + "wf_policy_composed_output_actions", + ); + + assert_eq!(out.result["result"], "alice@example.com «redacted:phone»"); + assert!(wc::events_of(&out.events, "policy.flagged") + .iter() + .any(|event| event["policy"] == "audit-email" && event["label"] == "email")); + assert!(wc::events_of(&out.events, "policy.redacted") + .iter() + .any(|event| event["policy"] == "redact-phone" && event["label"] == "phone")); + assert!(!wc::events_of(&out.events, "policy.redacted") + .iter() + .any(|event| event["policy"] == "audit-email" && event["label"] == "email")); +} + +#[test] +fn completion_policy_fails_a_nominal_success_when_evidence_is_missing() { + let src = r#" + (defpolicy evidenced + {:metadata {:require [:owner]} + :completion {:require-events [:checkpoint]}}) + (defworkflow guarded "completion evidence" + {:policy evidenced :owner "risk-team"} + (phase "Run") + {:status :success}) + "#; + let out = wc::run_once( + src, + fake_with_reply("unused"), + "wf_policy_completion_missing", + ); + + assert_eq!(out.result["status"], "failed"); + assert!(out.result["error"] + .as_str() + .is_some_and(|message| message.contains("checkpoint"))); + let ended = &wc::events_of(&out.events, "run.ended")[0]; + assert_eq!(ended["status"], "failed"); + assert!(ended["reason"] + .as_str() + .is_some_and(|message| message.contains("checkpoint"))); +} + +#[test] +fn successful_tool_results_satisfy_completion_evidence() { + let src = r#" + (deftool ping "Return pong" {} (lambda () "pong")) + (defpolicy evidenced + {:completion {:require-events [:agent.tool_result]}}) + (defworkflow guarded "tool evidence" {:policy evidenced} + (phase "Run") + (def result (step "Call ping" {:tools [ping]})) + {:status :success :result result}) + "#; + let fake = FakeProvider::builder("fake") + .model("fake-model") + .tool_call("call_1", "ping", serde_json::json!({})) + .reply("done") + .build(); + let out = wc::run_once(src, fake, "wf_policy_tool_result_evidence"); + + assert_eq!(out.result["status"], "success"); + let results = wc::events_of(&out.events, "agent.tool_result"); + assert_eq!(results.len(), 1); + assert_eq!(results[0]["tool_name"], "ping"); + assert_eq!(results[0]["result_digest"], "gated"); +} + +#[test] +fn run_ended_status_mirrors_an_explicit_failed_envelope() { + let src = r#" + (defworkflow guarded "explicit failure" {} + (phase "Run") + {:status :failed :reason "review failed"}) + "#; + let out = wc::run_once(src, fake_with_reply("unused"), "wf_policy_explicit_failure"); + + assert_eq!(out.result["status"], "failed"); + assert_eq!( + wc::events_of(&out.events, "run.ended")[0]["status"], + "failed" + ); +} + +const BATCH_TOOLS: &str = r#" + (deftool allowed-tool + "Write a marker" + {:path {:type :string}} + (lambda (path) (file/write path "allowed"))) + (deftool blocked-tool + "Write a marker that must never execute" + {:path {:type :string}} + (lambda (path) (file/write path "blocked"))) +"#; + +fn batched_tool_provider(path: &str) -> FakeProvider { + FakeProvider::builder("fake") + .model("fake-model") + .tool_calls(vec![ + ToolCall { + id: "allowed_1".into(), + name: "allowed-tool".into(), + arguments: serde_json::json!({"path": path}), + thought_signature: None, + }, + ToolCall { + id: "blocked_1".into(), + name: "blocked-tool".into(), + arguments: serde_json::json!({"path": path}), + thought_signature: None, + }, + ]) + .reply("finished") + .build() +} + +fn batch_workflow(action: &str) -> String { + format!( + r#" + {BATCH_TOOLS} + (defpolicy safe + {{:models {{:default :deny :allow ["fake/fake-model"]}} + :tools {{:default :deny + :allow {{"allowed-tool" {{}}}} + :deny ["blocked-tool"] + :on-deny :{action}}}}}) + (defworkflow guarded "tool batch" {{:policy safe}} + (phase "Run") + (def result + (step "use both tools" + {{:name "coder" :tools [allowed-tool blocked-tool]}})) + {{:status :success :result result}}) + "# + ) +} + +#[test] +fn hard_tool_denial_preflights_the_batch_and_runs_no_sibling() { + let base = temp_run_dir("policy-tool-fail"); + let marker = base.join("allowed-marker"); + let src = batch_workflow("fail"); + let out = run_workflow( + &src, + batched_tool_provider(marker.to_string_lossy().as_ref()), + RunOpts::fresh("wf_policy_tool_fail", &base), + ); + + assert!( + !marker.exists(), + "hard denial must prevent an allowed sibling handler from running" + ); + assert_eq!( + wc::events_of(&out.events, "agent.tool_call").len(), + 0, + "preflight failure happens before tool callbacks" + ); + let violations = wc::events_of(&out.events, "policy.violation"); + assert!( + violations + .iter() + .any(|event| event["subject"] == "blocked-tool" && event["action"] == "fail"), + "expected blocked-tool violation, got {violations:?}" + ); + + let _ = std::fs::remove_dir_all(&base); +} + +#[test] +fn tool_error_denial_runs_allowed_siblings_without_journaling_denied_call() { + let base = temp_run_dir("policy-tool-error"); + let marker = base.join("allowed-marker"); + let src = batch_workflow("tool-error"); + let out = run_workflow( + &src, + batched_tool_provider(marker.to_string_lossy().as_ref()), + RunOpts::fresh("wf_policy_tool_error", &base), + ); + + assert!( + marker.exists(), + "the allowed sibling should run under :tool-error" + ); + assert_eq!(out.result["status"], "success"); + let tool_calls = wc::events_of(&out.events, "agent.tool_call"); + assert_eq!( + tool_calls.len(), + 1, + "only the allowed call reaches the observer" + ); + assert_eq!(tool_calls[0]["tool_name"], "allowed-tool"); + assert!(wc::events_of(&out.events, "policy.violation") + .iter() + .any(|event| { event["subject"] == "blocked-tool" && event["action"] == "tool-error" })); + let requests = out.recorder.requests(); + let denied_result = requests + .iter() + .flat_map(|request| request.messages.iter()) + .find(|message| message.tool_call_id.as_deref() == Some("blocked_1")) + .expect("the denied call must return a correlated tool result"); + assert_eq!( + denied_result.content.as_text(), + Some("tool 'blocked-tool' was blocked: tool blocked-tool matches an explicit deny rule") + ); + assert!( + !denied_result.content.to_text().contains("Error:"), + "tool results must not embed a second presentation prefix" + ); + + let _ = std::fs::remove_dir_all(&base); +} + +#[test] +fn direct_tool_invoke_is_gated_before_the_handler() { + let base = temp_run_dir("policy-direct-tool"); + let marker = base.join("direct-marker"); + let path = marker + .to_string_lossy() + .replace('\\', "\\\\") + .replace('"', "\\\""); + let src = format!( + r#" + (deftool direct-tool + "Write a marker" + {{:path {{:type :string}}}} + (lambda (path) (file/write path "ran"))) + (defpolicy safe + {{:tools {{:default :deny :on-deny :tool-error}}}}) + (defworkflow guarded "direct tool" {{:policy safe}} + (phase "Run") + (tool/invoke direct-tool {{:path "{path}"}}) + {{:status :success}}) + "# + ); + let fake = FakeProvider::builder("fake").model("fake-model").build(); + let out = run_workflow(&src, fake, RunOpts::fresh("wf_policy_direct_tool", &base)); + + assert!( + !marker.exists(), + "direct tool policy denial must happen before its handler" + ); + assert_eq!( + wc::events_of(&out.events, "run.ended")[0]["status"], + "failed" + ); + assert!(wc::events_of(&out.events, "policy.violation") + .iter() + .any(|event| { + event["subject"] == "direct-tool" + && event["boundary"] == "tool" + && event["action"] == "fail" + })); + + let _ = std::fs::remove_dir_all(&base); +} + +#[test] +fn semantic_tool_subjects_are_name_and_argument_independent() { + let src = r#" + (deftool arbitrary-reader + "read through a deployment-specific argument" + {:location {:type :string}} + {:policy-subjects [{:kind :file-read :path-arg :location}]} + (fn (_location) "read-ok")) + (deftool arbitrary-writer + "write through a deployment-specific argument" + {:destination {:type :string}} + {:policy-subjects [{:kind :file-write :path-arg :destination}]} + (fn (_destination) "write-ran")) + (defpolicy readonly + {:subjects + {:default :deny + :allow [{:kind :file-read :paths ["src/**"]}]}}) + (defworkflow guarded "semantic subjects" {:policy readonly} + (def read-result + (tool/invoke arbitrary-reader {:location "src/lib.rs"})) + (def write-result + (try + (tool/invoke arbitrary-writer {:destination "src/lib.rs"}) + (catch error (:type error)))) + {:status :success + :read read-result + :write write-result + :subjects (tool/policy-subjects arbitrary-reader)}) + "#; + + let out = wc::run_once( + src, + fake_with_reply("unused"), + "wf_policy_semantic_subjects", + ); + assert_eq!(out.result["status"], "success"); + assert_eq!(out.result["read"], "read-ok"); + assert_eq!(out.result["write"], "policy-denied"); + assert_eq!(out.result["subjects"][0]["kind"], "file-read"); + assert_eq!(out.result["subjects"][0]["path-arg"], "location"); +} + +#[test] +fn step_policy_can_tighten_but_not_loosen_the_workflow_policy() { + let src = r#" + (defpolicy workflow-safe + {:models {:default :deny :allow ["fake/*"]}}) + (defworkflow guarded "logical AND" + {:policy workflow-safe} + (phase "Run") + (step "must not run" + {:name "writer" + :policy {:models {:default :deny :allow ["other/*"]}}}) + {:status :success}) + "#; + + let out = wc::run_once(src, fake_with_reply("unexpected"), "wf_policy_step_tighten"); + assert_eq!(out.recorder.call_count(), 0); + assert!( + wc::events_of(&out.events, "policy.checked") + .iter() + .any(|event| event["policy"] == "workflow-safe"), + "the outer layer should allow before the inner layer denies" + ); + assert!( + wc::events_of(&out.events, "policy.violation") + .iter() + .any(|event| event["policy"] == "inline-policy"), + "the step layer should deny the same boundary" + ); +} + +#[test] +fn workflow_policy_sequence_composes_without_loosening() { + let src = r#" + (defpolicy models + {:models {:default :deny :allow ["fake/fake-model"]}}) + (defpolicy tools + {:tools {:default :deny}}) + (defworkflow guarded "composed policy" {:policy [models tools]} + (phase "Run") + (llm/complete "hello")) + "#; + + let out = wc::run_once(src, fake_with_reply("ok"), "wf_policy_sequence_composes"); + assert_eq!(out.result["status"], "success"); + assert!(out + .events + .iter() + .any(|event| event["event"] == "policy.checked" && event["policy"] == "models")); +} + +#[test] +fn workflow_policy_sequence_rejects_empty_and_nonmap_layers() { + for (suffix, policy) in [("empty", "[]"), ("nonmap", "[{:models {}} 42]")] { + let src = format!( + r#" + (defworkflow guarded "invalid composed policy" {{:policy {policy}}} + :unreachable) + "# + ); + let error = Interpreter::new() + .eval_str_compiled(&src) + .expect_err("invalid policy sequence"); + assert!( + error.to_string().contains("invalid workflow policy"), + "{suffix}: {error}" + ); + } +} + +#[test] +fn stricter_layer_action_is_the_effective_journaled_action() { + let src = r#" + (defpolicy outer + {:models {:default :deny :on-deny :skip}}) + (defworkflow guarded "strictest action wins" {:policy outer} + (phase "Run") + (step "must not run" + {:name "writer" + :policy {:models {:default :deny :on-deny :fail}}}) + {:status :success}) + "#; + + let out = wc::run_once(src, fake_with_reply("unexpected"), "wf_policy_strictest"); + assert_eq!(out.recorder.call_count(), 0); + let violations = wc::events_of(&out.events, "policy.violation"); + assert_eq!(violations.len(), 2, "both denying layers are journaled"); + assert!( + violations.iter().all(|event| event["action"] == "fail"), + "the effective hard-fail action should be visible on every violation: {violations:?}" + ); +} + +#[test] +fn lexical_bypass_is_audited_and_does_not_call_the_denied_gate() { + let src = r#" + (defpolicy locked + {:models {:default :deny}}) + (defworkflow guarded "trusted exception" {:policy locked} + (phase "Run") + (def result + (policy/without "legacy migration fixture" + (step "read the fixture" {:name "migrator"}))) + {:status :success :result result}) + "#; + + let out = wc::run_once(src, fake_with_reply("legacy"), "wf_policy_bypass"); + assert_eq!(out.result["status"], "success"); + assert_eq!(out.recorder.call_count(), 1); + let bypassed = wc::events_of(&out.events, "policy.bypassed"); + assert!( + bypassed.iter().any(|event| { + event["boundary"] == "model" + && event["reason"] == "legacy migration fixture" + && event["rule"] == "policy.without" + }), + "expected an audited model bypass, got {bypassed:?}" + ); +} + +#[test] +fn changing_only_policy_invalidates_a_resumed_step_memo() { + let base = temp_run_dir("policy-resume"); + let first = r#" + (defpolicy safe + {:models {:default :deny :allow ["fake/fake-model"]}}) + (defworkflow guarded "resume policy" {:policy safe} + (phase "Run") + (def result (step "summarize" {:name "writer"})) + {:status :success :result result}) + "#; + let second = r#" + (defpolicy safe + {:models {:default :deny :allow ["fake/*"]}}) + (defworkflow guarded "resume policy" {:policy safe} + (phase "Run") + (def result (step "summarize" {:name "writer"})) + {:status :success :result result}) + "#; + + let first_run = run_workflow( + first, + fake_with_reply("same answer"), + RunOpts::fresh("wf_policy_resume", &base), + ); + assert_eq!(first_run.recorder.call_count(), 1); + + let resumed = run_workflow( + second, + fake_with_reply("same answer"), + RunOpts { + run_id: "wf_policy_resume", + run_dir: &base, + resume: true, + code_version: "", + args_json: "{}", + }, + ); + assert_eq!( + resumed.recorder.call_count(), + 1, + "a changed effective policy must miss the prior step memo" + ); + + let _ = std::fs::remove_dir_all(&base); +} + +#[test] +fn cassette_replay_rechecks_the_recorded_provider_identity() { + let base = temp_run_dir("policy-cassette"); + let tape = base.join("policy-tape.jsonl"); + let tape_path = tape + .to_string_lossy() + .replace('\\', "\\\\") + .replace('"', "\\\""); + let src = format!( + r#" + (defpolicy safe + {{:models {{:default :deny :allow ["fake/m"]}}}}) + (defworkflow guarded "cassette policy" {{:policy safe}} + (phase "Run") + (def result + (llm/with-cassette "{tape_path}" {{:mode :record}} + (lambda () (llm/complete "prompt" {{:model "m"}})))) + {{:status :success :result result}}) + "# + ); + let recorded = run_workflow( + &src, + FakeProvider::builder("fake") + .model("m") + .reply("recorded") + .build(), + RunOpts::fresh("wf_policy_cassette_record", &base), + ); + assert_eq!(recorded.result["status"], "success"); + + let tape_body = std::fs::read_to_string(&tape).expect("recorded policy tape"); + assert!( + tape_body.contains(r#""provider":"fake""#), + "policy-safe replay requires the serving provider on tape: {tape_body}" + ); + std::fs::write( + &tape, + tape_body.replace(r#""provider":"fake""#, r#""provider":"other""#), + ) + .unwrap(); + + let replay_src = src.replace(":mode :record", ":mode :replay"); + let replay = run_workflow( + &replay_src, + FakeProvider::builder("fake") + .model("m") + .error(sema_llm::types::LlmError::Api { + status: 500, + message: "provider must not be called".into(), + }) + .build(), + RunOpts::fresh("wf_policy_cassette_replay", &base), + ); + assert_eq!(replay.recorder.call_count(), 0); + assert_eq!( + wc::events_of(&replay.events, "run.ended")[0]["status"], + "failed" + ); + assert!( + wc::events_of(&replay.events, "policy.violation") + .iter() + .any(|event| { + event["subject"] == "other/m" + && event["source"] == "cassette" + && event["action"] == "fail" + }), + "the tampered recorded provider must be rejected before replay" + ); + + let _ = std::fs::remove_dir_all(&base); +} + +#[test] +fn cassette_replay_uses_the_recorded_provider_not_the_current_route() { + let base = temp_run_dir("policy-cassette-route"); + let tape = base.join("policy-route-tape.jsonl"); + let tape_path = tape + .to_string_lossy() + .replace('\\', "\\\\") + .replace('"', "\\\""); + let src = format!( + r#" + (llm/define-provider :allowed + {{:default-model "model" + :complete (lambda (_request) "recorded")}}) + (llm/define-provider :blocked + {{:default-model "model" + :complete (lambda (_request) "must not run")}}) + (defpolicy replay-safe + {{:models {{:default :deny :allow ["allowed/model"]}}}}) + (defworkflow guarded "cassette route" {{:policy replay-safe}} + (phase "Run") + (def recorded + (llm/with-fallback [:allowed] + (lambda () + (llm/with-cassette "{tape_path}" {{:mode :record}} + (lambda () (llm/complete "prompt" {{:model "model"}})))))) + (def replayed + (llm/with-fallback [:blocked] + (lambda () + (llm/with-cassette "{tape_path}" {{:mode :replay}} + (lambda () (llm/complete "prompt" {{:model "model"}})))))) + {{:status :success :recorded recorded :replayed replayed}}) + "# + ); + let fake = FakeProvider::builder("fake").model("fake-model").build(); + let out = run_workflow( + &src, + fake, + RunOpts::fresh("wf_policy_cassette_route", &base), + ); + + assert_eq!(out.result["status"], "success"); + assert_eq!(out.result["recorded"], "recorded"); + assert_eq!(out.result["replayed"], "recorded"); + let checks = wc::events_of(&out.events, "policy.checked"); + assert_eq!( + checks + .iter() + .filter(|event| event["subject"] == "allowed/model") + .count(), + 2, + "record and replay should each check the provider they use" + ); + assert!( + !checks + .iter() + .any(|event| event["subject"] == "blocked/model"), + "the replay route does not serve the recorded response" + ); + + let _ = std::fs::remove_dir_all(&base); +} diff --git a/crates/sema/tests/workflow_suite.rs b/crates/sema/tests/workflow_suite.rs index 1b45af95a..748a6d724 100644 --- a/crates/sema/tests/workflow_suite.rs +++ b/crates/sema/tests/workflow_suite.rs @@ -14,6 +14,8 @@ mod workflow_budget_test; mod workflow_cookbook_test; #[path = "suites/workflow_mcp_seam_test.rs"] mod workflow_mcp_seam_test; +#[path = "suites/workflow_policy_test.rs"] +mod workflow_policy_test; #[path = "suites/workflow_resume_test.rs"] mod workflow_resume_test; #[path = "suites/workflow_selfrewrite_test.rs"] diff --git a/crates/sema/web-runtime.lock b/crates/sema/web-runtime.lock index 6588a658d..66f70aa2a 100644 --- a/crates/sema/web-runtime.lock +++ b/crates/sema/web-runtime.lock @@ -1,6 +1,6 @@ { - "inputs": "f4f097b312b8bc5f6e680928efa81dd475d1220f7d1cbfcf5315a386ddfcd3dc", - "assets": "ca7c625644c4234a81384e024bd90d4663ba94c0c0c23dda171457f28e43e19a", - "input_count": 200, + "inputs": "c51538bd2f0bd3efdddd745de53e115e45bc5a9a3bb3dee874da8c1260914065", + "assets": "69172cc58d01f9327f6eb15dcf5c7b7484837183809cca4678ab1b49c952bf34", + "input_count": 203, "asset_count": 12 } diff --git a/docs/plans/evidence/unified-cooperative-runtime/runtime-match-map.tsv b/docs/plans/evidence/unified-cooperative-runtime/runtime-match-map.tsv index c9fc11d31..75f91929d 100644 --- a/docs/plans/evidence/unified-cooperative-runtime/runtime-match-map.tsv +++ b/docs/plans/evidence/unified-cooperative-runtime/runtime-match-map.tsv @@ -70,12 +70,12 @@ F22 crates/sema-core/src/output_hook.rs:71:thread_local! { F24 crates/sema-core/src/runtime/native.rs:393: /// `Value::async_promise_id(id)` — `async/spawn`'s own default. The spawn F18 crates/sema-core/src/value.rs:173: /// "requires runtime invocation" hard-error stub (async/spawn, channel/*, F18 crates/sema-core/src/value.rs:175: /// `with_ctx_runtime`, e.g. `async/sleep`, `__llm-chat-blocking`) has a real -C17 crates/sema-core/src/value.rs:2868:thread_local! { C17 crates/sema-core/src/value.rs:28:thread_local! { +C17 crates/sema-core/src/value.rs:2900:thread_local! { F18 crates/sema-core/src/value.rs:492: /// (`async/spawn`, `channel/*`, `async/resolved`, …). Callback-driving F15 crates/sema-core/src/value.rs:573:/// the user explicitly cancels via `async/cancel` is *not* a normal rejection F15 crates/sema-core/src/value.rs:575:/// `async/cancelled?` be precise without string-matching, and lets -F32 crates/sema-core/src/value.rs:767: let dispatch_val = crate::call_callback(ctx, &mm.dispatch_fn, args)?; +F32 crates/sema-core/src/value.rs:799: let dispatch_val = crate::call_callback(ctx, &mm.dispatch_fn, args)?; C17 crates/sema-core/src/value.rs:94:thread_local! { H04 crates/sema-dap/src/server.rs:1059: while let Ok(cmd) = ds.command_rx.recv() { H04 crates/sema-dap/src/server.rs:263: reply_rx.recv().await.unwrap_or_default() @@ -86,318 +86,318 @@ H04 crates/sema-dap/src/server.rs:903: while let Ok(evt) = db H04 crates/sema-dap/src/server.rs:94: Some(event) = event_bridge_rx.recv() => { H04 crates/sema-dap/src/server.rs:995: // with no `RuntimeState` borrow held. Async ops (async/await, F33 crates/sema-eval/src/debug_session.rs:16:thread_local! { -F31 crates/sema-eval/src/eval.rs:150: // `Rc` and `Value` edges (a still-parked/detached `async/spawn` -F32 crates/sema-eval/src/eval.rs:220: sema_core::set_eval_callback(&ctx, eval_value_vm); -F32 crates/sema-eval/src/eval.rs:221: sema_core::set_call_callback(&ctx, call_value); -F32 crates/sema-eval/src/eval.rs:222: sema_core::set_call_owned_callback(&ctx, call_value_owned); -F32 crates/sema-eval/src/eval.rs:241: sema_core::set_eval_callback(&ctx, eval_value_vm); -F32 crates/sema-eval/src/eval.rs:242: sema_core::set_call_callback(&ctx, call_value); -F32 crates/sema-eval/src/eval.rs:243: sema_core::set_call_owned_callback(&ctx, call_value_owned); -F32 crates/sema-eval/src/eval.rs:3450: sema_core::call_callback(context, &thunk.body, &[])? -F31 crates/sema-eval/src/eval.rs:4520: .eval_str("(defmacro sleeping-transformer () (async/sleep 1) '42)") -F31 crates/sema-eval/src/eval.rs:4564: b"(defmacro loaded-sleeper () (async/sleep 1) '42)\ -F31 crates/sema-eval/src/eval.rs:4636: .eval_str("(defmacro late-force-macro () (async/sleep 1) '42)") -F31 crates/sema-eval/src/eval.rs:4655: .eval_str("(defmacro macroexpand-sleeper () (async/sleep 1) '42)") -F31 crates/sema-eval/src/eval.rs:4678: "(defmacro spawn-transformer () (async/spawn (fn () 42)))", -F31 crates/sema-eval/src/eval.rs:5043: (async/spawn (fn () (async/sleep 1) (channel/send ch 42))) \ -F31 crates/sema-eval/src/eval.rs:5044: (force (delay (channel/recv ch))))", -F31 crates/sema-eval/src/eval.rs:5060: (async/sleep 1) \ -F31 crates/sema-eval/src/eval.rs:5079: (async/sleep 10) \ -F31 crates/sema-eval/src/eval.rs:5083: (async/await \ -F31 crates/sema-eval/src/eval.rs:5084: (async/all (list (async (force p)) (async (force p))))) \ -F31 crates/sema-eval/src/eval.rs:5105: (async/sleep 20) -F31 crates/sema-eval/src/eval.rs:5127: std::thread::sleep(std::time::Duration::from_millis(25)); -F31 crates/sema-eval/src/eval.rs:5147: (async/sleep 1) \ -F31 crates/sema-eval/src/eval.rs:5165: (define p (delay (begin (async/sleep 1) (mutable-array/new)))) \ -F31 crates/sema-eval/src/eval.rs:5182: (define force-promise (delay (channel/recv force-gate))))", -F31 crates/sema-eval/src/eval.rs:5218: (channel/send entered :entered) \ -F31 crates/sema-eval/src/eval.rs:5219: (channel/recv body-gate) \ -F31 crates/sema-eval/src/eval.rs:5223: (channel/recv entered) \ -F31 crates/sema-eval/src/eval.rs:5225: (async/sleep 1) \ -F31 crates/sema-eval/src/eval.rs:5226: (async/cancel first) \ -F31 crates/sema-eval/src/eval.rs:5227: (channel/send body-gate :continue) \ -F31 crates/sema-eval/src/eval.rs:5250: (channel/send entered :q) -F31 crates/sema-eval/src/eval.rs:5251: (channel/recv hold) -F31 crates/sema-eval/src/eval.rs:5254: (channel/recv entered) -F31 crates/sema-eval/src/eval.rs:5257: (async/sleep 5) -F31 crates/sema-eval/src/eval.rs:5258: (async/cancel p-owner) -F31 crates/sema-eval/src/eval.rs:5259: (channel/send hold :go) -F31 crates/sema-eval/src/eval.rs:5315: (delay (channel/recv force-deadlock-gate))))", -F31 crates/sema-eval/src/eval.rs:5327: (async (channel/send force-deadlock-gate 9)) \ -F31 crates/sema-eval/src/eval.rs:5556: // `async/sleep` returns a structural timer suspension. The runtime parks its -F31 crates/sema-eval/src/eval.rs:5564: .eval_str_via_runtime("(async/sleep 2)") -F31 crates/sema-eval/src/eval.rs:5565: .expect("async/sleep settles through the runtime"); -F31 crates/sema-eval/src/eval.rs:5575: .eval_str_via_runtime("(begin (async/sleep 2) (+ 40 2))") -F31 crates/sema-eval/src/eval.rs:5576: .expect("program continues past async/sleep"); -F31 crates/sema-eval/src/eval.rs:5580: // `async/spawn` + `async/await` round-trip through the runtime: spawn a -F31 crates/sema-eval/src/eval.rs:5588: .eval_str_via_runtime("(await (async/spawn (fn () (+ 40 2))))") -F31 crates/sema-eval/src/eval.rs:5602: (define a (async/spawn (fn () (+ 1 2)))) \ -F31 crates/sema-eval/src/eval.rs:5603: (define b (async/spawn (fn () (* 4 5)))) \ -F31 crates/sema-eval/src/eval.rs:5610: // A spawned task that itself parks on a timer (`async/sleep`) and resumes: -F31 crates/sema-eval/src/eval.rs:5617: .eval_str_via_runtime("(await (async/spawn (fn () (async/sleep 2) 7)))") -F31 crates/sema-eval/src/eval.rs:5630: // detached task (parked on its `async/sleep` timer at the end of call one) -F31 crates/sema-eval/src/eval.rs:5639: .eval_str_via_runtime("(define p (async/spawn (fn () (async/sleep 2) 42)))") -F31 crates/sema-eval/src/eval.rs:5696: r#"(await (async/spawn (fn () (load "./dep.sema"))))"#, -F31 crates/sema-eval/src/eval.rs:5712: b"(channel/recv runtime-load-gate) (define runtime-loaded 42) runtime-loaded".to_vec(), -F31 crates/sema-eval/src/eval.rs:5728: .eval_str_via_runtime("(channel/send runtime-load-gate :continue)") -F31 crates/sema-eval/src/eval.rs:5747: b"(module blocking (export answer) (set! runtime-import-count (+ runtime-import-count 1)) (channel/recv runtime-import-gate) (define answer 42))".to_vec(), -F31 crates/sema-eval/src/eval.rs:5783: .eval_str_via_runtime("(channel/send runtime-import-gate :continue)") -F31 crates/sema-eval/src/eval.rs:5811: b"(module sync-runtime-overlap (export overlap-answer) (set! overlap-count (+ overlap-count 1)) (channel/recv overlap-gate) (define overlap-answer 42))".to_vec(), -F31 crates/sema-eval/src/eval.rs:5850: .eval_str_via_runtime("(channel/send overlap-gate :continue)") -F31 crates/sema-eval/src/eval.rs:5871: b"(module foreign-runtime-import (export foreign-answer) (set! foreign-import-count (+ foreign-import-count 1)) (channel/recv foreign-import-gate) (define foreign-answer 42))".to_vec(), -F31 crates/sema-eval/src/eval.rs:5906: .eval_str_via_runtime("(channel/send foreign-import-gate :continue)") -F31 crates/sema-eval/src/eval.rs:5991: b"(channel/recv gate-cancel-channel)".to_vec(), -F31 crates/sema-eval/src/eval.rs:6027: b"(module single-flight-source (export source-answer) (set! source-import-count (+ source-import-count 1)) (channel/recv source-import-gate) (define source-answer 42))".to_vec(), -F31 crates/sema-eval/src/eval.rs:6063: .eval_str_via_runtime("(channel/send source-import-gate :continue)") -F31 crates/sema-eval/src/eval.rs:6091: "(module single-flight-bytecode (export bytecode-answer) (set! bytecode-import-count (+ bytecode-import-count 1)) (channel/recv bytecode-import-gate) (define bytecode-answer 42))", -F31 crates/sema-eval/src/eval.rs:60: /// context would route the VM's `call_callback` through unregistered -F31 crates/sema-eval/src/eval.rs:6128: .eval_str_via_runtime("(channel/send bytecode-import-gate :continue)") -F31 crates/sema-eval/src/eval.rs:6151: b"(module cancelled-owner-import (export cancelled-owner-answer) (set! cancelled-owner-count (+ cancelled-owner-count 1)) (if (= cancelled-owner-count 1) (channel/recv cancelled-owner-gate) nil) (define cancelled-owner-answer 42))".to_vec(), -F31 crates/sema-eval/src/eval.rs:6202: b"(module failed-owner-import (export failed-owner-answer) (set! failed-owner-count (+ failed-owner-count 1)) (if (= failed-owner-count 1) (begin (channel/recv failed-owner-gate) (error \"expected first-owner failure\")) nil) (define failed-owner-answer 42))".to_vec(), -F31 crates/sema-eval/src/eval.rs:6232: .eval_str_via_runtime("(channel/send failed-owner-gate :continue)") -F31 crates/sema-eval/src/eval.rs:6255: b"(channel/recv cancelled-load-gate) :loaded".to_vec(), -F31 crates/sema-eval/src/eval.rs:6276: .eval_str_via_runtime("(channel/send cancelled-load-gate :retry)") -F31 crates/sema-eval/src/eval.rs:6294: b"(module cancelled-import (export cancelled-import-answer) (channel/recv cancelled-import-gate) (define cancelled-import-answer 42))".to_vec(), -F31 crates/sema-eval/src/eval.rs:6319: .eval_str_via_runtime("(channel/send cancelled-import-gate :retry)") -F31 crates/sema-eval/src/eval.rs:6368: b"(channel/recv compile-failure-import-gate) (if #t)".to_vec(), -F31 crates/sema-eval/src/eval.rs:6385: .eval_str_via_runtime("(channel/send compile-failure-import-gate :continue)") -F31 crates/sema-eval/src/eval.rs:6412: b"(channel/recv callback-failure-import-gate) (error \"expected module callback failure\")".to_vec(), -F31 crates/sema-eval/src/eval.rs:6429: .eval_str_via_runtime("(channel/send callback-failure-import-gate :continue)") -F31 crates/sema-eval/src/eval.rs:6487: b"(module outer (export answer) (async/sleep 2) (import \"./inner.sema\" inner-value) (define answer (+ inner-value 2)))".to_vec(), -F31 crates/sema-eval/src/eval.rs:6504: "(module bytecode (export answer) (async/sleep 2) (define answer 42))", -F31 crates/sema-eval/src/eval.rs:6614: (channel/recv root-context-gate) -F31 crates/sema-eval/src/eval.rs:6665: "(channel/send root-context-gate :go)", -F31 crates/sema-eval/src/eval.rs:6704: (channel/send ready :ready) -F31 crates/sema-eval/src/eval.rs:6705: (channel/recv gate) -F31 crates/sema-eval/src/eval.rs:6710: (channel/recv ready) -F31 crates/sema-eval/src/eval.rs:6718: (channel/send gate :go) -F31 crates/sema-eval/src/eval.rs:67: /// `async/spawn` tasks, timers, promises and channels survive *between* -F31 crates/sema-eval/src/eval.rs:6824: (async/sleep 2) -F31 crates/sema-eval/src/eval.rs:6851: (channel/send ready :ready) -F31 crates/sema-eval/src/eval.rs:6852: (channel/recv go) -F31 crates/sema-eval/src/eval.rs:6854: (channel/recv ready) -F31 crates/sema-eval/src/eval.rs:6858: (channel/send go :go) -F31 crates/sema-eval/src/eval.rs:6859: (async/sleep 2) -F31 crates/sema-eval/src/eval.rs:6878: (async/sleep 2) -F31 crates/sema-eval/src/eval.rs:6897: (fn () (async/sleep 2) (context/clear) 42))"#, -F31 crates/sema-eval/src/eval.rs:6915: (fn () (async/sleep 2) (error "expected failure")))"#, -F31 crates/sema-eval/src/eval.rs:6936: (fn () (channel/recv (channel/new 1))))"#, -F31 crates/sema-eval/src/eval.rs:6974: "(context/with {:scoped :inner-a} (fn () (channel/recv context-gate) (context/get :scoped)))", -F31 crates/sema-eval/src/eval.rs:6980: "(context/with {:scoped :inner-b} (fn () (channel/recv context-gate) (context/get :scoped)))", -F31 crates/sema-eval/src/eval.rs:7002: .eval_str_via_runtime("(channel/send context-gate :go)") -F31 crates/sema-eval/src/eval.rs:7075: (channel/recv (channel/new 1)))"#, -F31 crates/sema-eval/src/eval.rs:7166: .eval_str_via_runtime("(async/spawn (fn () (async/sleep 100000) 1)) 7") -F31 crates/sema-eval/src/eval.rs:7185: let result = interp.eval_str_via_runtime("(await (async/spawn (fn () (error \"boom\"))))"); -F31 crates/sema-eval/src/eval.rs:7197: .eval_str_via_runtime("(let ((p (async/spawn (fn () 5)))) (async/sleep 5) (await p))") -F31 crates/sema-eval/src/eval.rs:7210: (define p (async/spawn (fn () 99))) \ -F31 crates/sema-eval/src/eval.rs:7211: (define a (async/spawn (fn () (await p)))) \ -F31 crates/sema-eval/src/eval.rs:7212: (define b (async/spawn (fn () (await p)))) \ -F31 crates/sema-eval/src/eval.rs:7224: .eval_str_via_runtime("(await (async/spawn (fn () (await (async/spawn (fn () 42))))))") -F31 crates/sema-eval/src/eval.rs:7235: .eval_str_via_runtime("(begin (async/spawn (fn () 1)) 99)") -F31 crates/sema-eval/src/eval.rs:7249: (set! p (async/spawn (fn () (async/sleep 1) (await p)))) \ -F31 crates/sema-eval/src/eval.rs:7283: match rx.recv_timeout(std::time::Duration::from_secs(30)) { -F31 crates/sema-eval/src/eval.rs:7309: // GATE — top-level `channel/recv` on an empty channel with no sender parks -F31 crates/sema-eval/src/eval.rs:7311: // it with the legacy "channel/recv: channel is empty". -F31 crates/sema-eval/src/eval.rs:7314: assert_runtime_deadlock_matches_oracle("(channel/recv (channel/new 1))"); -F31 crates/sema-eval/src/eval.rs:7317: // GATE — top-level `channel/send` on a full channel with no receiver parks -F31 crates/sema-eval/src/eval.rs:7318: // the root; the drive loop settles it with the legacy "channel/send: channel -F31 crates/sema-eval/src/eval.rs:7324: "(begin (define ch (channel/new 1)) (channel/send ch 1) (channel/send ch 2))", -F31 crates/sema-eval/src/eval.rs:7337: (set! pa (async/spawn (fn () (async/sleep 1) (await pb)))) \ -F31 crates/sema-eval/src/eval.rs:7338: (set! pb (async/spawn (fn () (async/sleep 1) (await pa)))) \ -F31 crates/sema-eval/src/eval.rs:7356: .eval_str_via_runtime("(async/spawn (fn () (async/sleep 5000) 1))") -F31 crates/sema-eval/src/eval.rs:7367: // awaited promise is still Pending at the moment `async/await` runs, the -F31 crates/sema-eval/src/eval.rs:7382: "(try (await (async/spawn (fn () (async/sleep 2) (error \"x\")))) \ -F31 crates/sema-eval/src/eval.rs:7395: .eval_str_via_runtime("(await (async/spawn (fn () (async/sleep 2) (error \"boom\"))))"); -F31 crates/sema-eval/src/eval.rs:7417: "(async/all (list (async/spawn (fn () 1)) \ -F31 crates/sema-eval/src/eval.rs:7418: (async/spawn (fn () 2)) \ -F31 crates/sema-eval/src/eval.rs:7419: (async/spawn (fn () 3))))", -F31 crates/sema-eval/src/eval.rs:7421: .expect("async/all resolves through the runtime"); -F31 crates/sema-eval/src/eval.rs:7429: .eval_str_via_runtime("(async/all (list))") -F31 crates/sema-eval/src/eval.rs:7430: .expect("async/all of empty input"); -F31 crates/sema-eval/src/eval.rs:7443: (define sib (async/spawn (fn () (async/sleep 4) (channel/send ch 77) 1))) \ -F31 crates/sema-eval/src/eval.rs:7444: (define bad (async/spawn (fn () (error \"boom\")))) \ -F31 crates/sema-eval/src/eval.rs:7445: (define outcome (try (async/all (list bad sib)) (catch e :caught))) \ -F31 crates/sema-eval/src/eval.rs:7446: (list outcome (await sib) (channel/recv ch)))", -F31 crates/sema-eval/src/eval.rs:7459: (define fast (async/spawn (fn () 10))) \ -F31 crates/sema-eval/src/eval.rs:7460: (define slow (async/spawn (fn () (async/sleep 5) (channel/send ch 88) 20))) \ -F31 crates/sema-eval/src/eval.rs:7461: (define winner (async/race (list fast slow))) \ -F31 crates/sema-eval/src/eval.rs:7462: (list winner (await slow) (channel/recv ch)))", -F31 crates/sema-eval/src/eval.rs:7472: .eval_str_via_runtime("(async/timeout 10000 (async/spawn (fn () (async/sleep 1) 5)))") -F31 crates/sema-eval/src/eval.rs:747: if !runtime.block_on_inbox(next_deadline) && next_deadline.is_none() { -F31 crates/sema-eval/src/eval.rs:7484: (define slow (async/spawn (fn () (async/sleep 20) (channel/send ch 55) 9))) \ -F31 crates/sema-eval/src/eval.rs:7485: (define outcome (try (async/timeout 1 slow) (catch e :timeout))) \ -F31 crates/sema-eval/src/eval.rs:7486: (list outcome (await slow) (channel/recv ch)))", -F31 crates/sema-eval/src/eval.rs:7494: // yield seam. Unlike the earlier async/all|race|timeout channel uses (which -F31 crates/sema-eval/src/eval.rs:7507: (async/spawn (fn () (channel/send ch 42))) \ -F31 crates/sema-eval/src/eval.rs:7508: (channel/recv ch))", -F31 crates/sema-eval/src/eval.rs:7523: (channel/send ch 1) (channel/send ch 2) (channel/send ch 3) \ -F31 crates/sema-eval/src/eval.rs:7524: (list (channel/recv ch) (channel/recv ch) (channel/recv ch)))", -F31 crates/sema-eval/src/eval.rs:7541: (define p (async/spawn (fn () \ -F31 crates/sema-eval/src/eval.rs:7542: (channel/send ch 1) (channel/send ch 2) (channel/send ch 3) :done))) \ -F31 crates/sema-eval/src/eval.rs:7543: (define out (list (channel/recv ch) (channel/recv ch) (channel/recv ch))) \ -F31 crates/sema-eval/src/eval.rs:7553: fn runtime_channel_blocking_recv_parks_until_sent() { -F31 crates/sema-eval/src/eval.rs:7559: (async/spawn (fn () (async/sleep 2) (channel/send ch 77))) \ -F31 crates/sema-eval/src/eval.rs:755: // The root is parked purely on a timer (`async/sleep`): the only -F31 crates/sema-eval/src/eval.rs:7560: (channel/recv ch))", -F31 crates/sema-eval/src/eval.rs:7573: (channel/send ch 1) \ -F31 crates/sema-eval/src/eval.rs:7575: (list (channel/recv ch) (channel/recv ch)))", -F31 crates/sema-eval/src/eval.rs:7588: (try (channel/send ch 9) (catch e :send-failed)))", -F08A crates/sema-eval/src/eval.rs:758: // than a raw `thread::sleep` — no external op is registered, so -F31 crates/sema-eval/src/eval.rs:7623: (channel/send ch 10) \ -F31 crates/sema-eval/src/eval.rs:7624: (channel/send ch 20) \ -F31 crates/sema-eval/src/eval.rs:7636: (channel/send ch 10) \ -F31 crates/sema-eval/src/eval.rs:7637: (channel/send ch 20) \ -F31 crates/sema-eval/src/eval.rs:7648: (channel/send ch 1) \ -F31 crates/sema-eval/src/eval.rs:7649: (channel/send ch 2) \ -F31 crates/sema-eval/src/eval.rs:7659: (channel/send ch 7) \ -F31 crates/sema-eval/src/eval.rs:7667: // GATE 1: `async/cancel` returns `#t` ONLY for the FIRST cancellation request -F31 crates/sema-eval/src/eval.rs:7675: "(let ((p (async/spawn (fn () (async/sleep 100000) 42)))) \ -F31 crates/sema-eval/src/eval.rs:7676: (list (async/cancel p) (async/cancel p)))", -F31 crates/sema-eval/src/eval.rs:7678: .expect("async/cancel of a sleeping spawned task drives through the runtime"); -F31 crates/sema-eval/src/eval.rs:7686: // GATE 1b: `async/cancel` returns `#f` for a synthetic promise (no backing -F31 crates/sema-eval/src/eval.rs:7692: .eval_str_via_runtime("(async/cancel (async/resolved 5))") -F31 crates/sema-eval/src/eval.rs:7705: "(let ((p (async/spawn (fn () (async/sleep 100000) 42)))) \ -F31 crates/sema-eval/src/eval.rs:7706: (async/cancel p) \ -F31 crates/sema-eval/src/eval.rs:7723: "(let ((p (async/spawn (fn () (async/sleep 100000) 42)))) \ -F31 crates/sema-eval/src/eval.rs:7724: (async/cancel p) \ -F31 crates/sema-eval/src/eval.rs:7735: // GATE 3: a task blocked on a LONG `async/sleep`, when cancelled, actually -F31 crates/sema-eval/src/eval.rs:7737: // NOT after the full (100s) sleep. The promise reports `async/cancelled?` -F31 crates/sema-eval/src/eval.rs:7745: "(let ((p (async/spawn (fn () (async/sleep 100000) 42)))) \ -F31 crates/sema-eval/src/eval.rs:7746: (async/cancel p) \ -F31 crates/sema-eval/src/eval.rs:7748: (async/cancelled? p))", -F31 crates/sema-eval/src/eval.rs:7765: // `channel/recv` is tracked ONLY in `channel_waits`, with a wait key that is -F31 crates/sema-eval/src/eval.rs:7774: // PROMPTLY; `async/cancelled?` is #t and `await` raises a catchable -F31 crates/sema-eval/src/eval.rs:7782: "(let ((p (async/spawn (fn () (channel/recv (channel/new 1)))))) \ -F31 crates/sema-eval/src/eval.rs:7783: (async/cancel p) \ -F31 crates/sema-eval/src/eval.rs:7784: (list (try (await p) (catch e (:type e))) (async/cancelled? p)))", -F31 crates/sema-eval/src/eval.rs:778: runtime.block_on_inbox(Some(deadline)); -F31 crates/sema-eval/src/eval.rs:7799: // GATE B: THE HANG PROOF. A detached task left parked on `channel/recv` when -F31 crates/sema-eval/src/eval.rs:7812: .eval_str_via_runtime("(async/spawn (fn () (channel/recv (channel/new 1)))) 42") -F31 crates/sema-eval/src/eval.rs:7832: "(async/spawn (fn () (let ((c (channel/new 1))) (channel/send c 1) (channel/send c 99)))) 7", -F31 crates/sema-eval/src/eval.rs:7843: // GATE D: owned fail-fast that cancels a worker parked on `channel/recv` -F31 crates/sema-eval/src/eval.rs:7854: (try (async/spawn-all \ -F31 crates/sema-eval/src/eval.rs:7856: (fn () (channel/recv ch)))) \ -F31 crates/sema-eval/src/eval.rs:785: // legacy-parity error (`channel/recv: channel is empty` / -F31 crates/sema-eval/src/eval.rs:786: // `channel/send: channel is full` for a top-level channel op, or -F31 crates/sema-eval/src/eval.rs:7874: // observational `async/all`/`race`/`timeout` gates above, where the supplied -F31 crates/sema-eval/src/eval.rs:7877: // async/spawn-all GATE 1 — happy path: values in INPUT order. -F31 crates/sema-eval/src/eval.rs:7882: .eval_str_via_runtime("(async/spawn-all (list (fn () 1) (fn () 2) (fn () 3)))") -F31 crates/sema-eval/src/eval.rs:7887: // async/spawn-all GATE 1b — empty input → empty list. -F31 crates/sema-eval/src/eval.rs:7892: .eval_str_via_runtime("(async/spawn-all (list))") -F31 crates/sema-eval/src/eval.rs:7897: // async/spawn-all GATE 2 — fail-fast OWNERSHIP: one child errors immediately; -F31 crates/sema-eval/src/eval.rs:7909: (try (async/spawn-all \ -F31 crates/sema-eval/src/eval.rs:7910: (list (fn () (async/sleep 60) (set! flag 77) 1) \ -F31 crates/sema-eval/src/eval.rs:7913: (async/sleep 200) \ -F31 crates/sema-eval/src/eval.rs:7945: (begin (async/sleep 60) (set! flag x) x))) \ -F31 crates/sema-eval/src/eval.rs:7948: (async/sleep 200) \ -F31 crates/sema-eval/src/eval.rs:7969: (async/sleep 15) \ -F31 crates/sema-eval/src/eval.rs:8015: (if (= x 1) (begin (async/sleep 5) (error \"boom\")) \ -F31 crates/sema-eval/src/eval.rs:8016: (begin (async/sleep 60) (set! flag x) x))) \ -F31 crates/sema-eval/src/eval.rs:8019: (async/sleep 200) \ -F31 crates/sema-eval/src/eval.rs:8026: // async/race-owned GATE 1 — first settlement wins AND the loser is CANCELLED -F31 crates/sema-eval/src/eval.rs:8037: (async/race-owned \ -F31 crates/sema-eval/src/eval.rs:8039: (fn () (async/sleep 60) (set! flag 99) 20)))) \ -F31 crates/sema-eval/src/eval.rs:8040: (async/sleep 200) \ -F31 crates/sema-eval/src/eval.rs:8051: // async/race-owned GATE 2 — empty input is an argument error. -F31 crates/sema-eval/src/eval.rs:8057: .eval_str_via_runtime("(try (async/race-owned (list)) (catch e :empty))",) -F31 crates/sema-eval/src/eval.rs:8063: // async/race-owned GATE 3 — the first settlement being an error re-raises it. -F31 crates/sema-eval/src/eval.rs:8069: "(try (async/race-owned (list (fn () (error \"boom\")) \ -F31 crates/sema-eval/src/eval.rs:8070: (fn () (async/sleep 100) 2))) \ -F31 crates/sema-eval/src/eval.rs:8088: (fn () (async/sleep 200) (set! flag 5) :done)) \ -F31 crates/sema-eval/src/eval.rs:8090: (async/sleep 300) \ -F31 crates/sema-eval/src/eval.rs:8106: .eval_str_via_runtime("(async/with-timeout 10000 (fn () (async/sleep 1) 42))") -F31 crates/sema-eval/src/eval.rs:8127: // at bytecode level via `__spawn-apply` rather than `(map async/spawn …)`. -F31 crates/sema-eval/src/eval.rs:8129: // `(map async/spawn …)` shape would raise "async yield outside of scheduler -F31 crates/sema-eval/src/eval.rs:8184: "(map (fn (x) (async/await (async/spawn (fn () (* x x))))) (list 1 2 3))", -F31 crates/sema-eval/src/eval.rs:8193: // The embedding-style shape: each `async` desugars to `(async/spawn (fn () -F31 crates/sema-eval/src/eval.rs:8194: // …))`, so `map` produces a list of promises which `async/all` then awaits. -F31 crates/sema-eval/src/eval.rs:8195: // The callback's `async/spawn` yield must be serviced cooperatively. -F31 crates/sema-eval/src/eval.rs:8200: .eval_str_via_runtime("(async/all (map (fn (x) (async (* x x))) (list 1 2 3)))") -F31 crates/sema-eval/src/eval.rs:8201: .expect("async/all over a mapped list of spawns resolves"); -F31 crates/sema-eval/src/eval.rs:8220: "(filter (fn (x) (async/await (async/spawn (fn () (> x 1))))) (list 1 2 3))", -F31 crates/sema-eval/src/eval.rs:8236: "(foldl (fn (acc x) (async/await (async/spawn (fn () (+ acc x))))) 0 \ -F31 crates/sema-eval/src/eval.rs:8253: "(foldl (fn (acc x) (async/await (async/spawn (fn () (+ acc x))))) 99 (list))", -F31 crates/sema-eval/src/eval.rs:8264: "(reduce (fn (acc x) (async/await (async/spawn (fn () (+ acc x))))) \ -F31 crates/sema-eval/src/eval.rs:8281: "(reduce (fn (acc x) (async/await (async/spawn (fn () (+ acc x))))) (list 42))", -F31 crates/sema-eval/src/eval.rs:8293: "(sort-by (fn (x) (async/await (async/spawn (fn () (- x))))) (list 3 1 2))", -F31 crates/sema-eval/src/eval.rs:8315: (for-each (fn (x) (async/await (async/spawn (fn () (channel/send ch (* x 10)))))) \ -F31 crates/sema-eval/src/eval.rs:8318: (list (channel/recv ch) (channel/recv ch) (channel/recv ch))", -F31 crates/sema-eval/src/eval.rs:8346: // still-live `Tracked` cell, mirroring `async/spawn`) every escaping open -F31 crates/sema-eval/src/eval.rs:8521: .eval_str_via_runtime("(async/spawn (fn () (sleep 100000) 1)) 7") -F31 crates/sema-eval/src/eval.rs:8556: // observable synchronously via `async/cancelled?`. -F31 crates/sema-eval/src/eval.rs:8563: (let ((p (async (channel/recv ch)))) \ -F31 crates/sema-eval/src/eval.rs:8564: (async/cancel p) \ -F31 crates/sema-eval/src/eval.rs:8565: (async/cancelled? p)))", -F31 crates/sema-eval/src/eval.rs:8577: "(let ((p (async (async/sleep 100)))) \ -F31 crates/sema-eval/src/eval.rs:8578: (async/cancel p) \ -F31 crates/sema-eval/src/eval.rs:8579: (list (async/cancelled? p) (async/rejected? p) \ -F31 crates/sema-eval/src/eval.rs:8594: // A yielding native (`channel/recv`) passed DIRECTLY as a HOF callback now -F31 crates/sema-eval/src/eval.rs:8596: // continuation ABI (the tool-loop/HOF migration): `(map channel/recv …)` -F31 crates/sema-eval/src/eval.rs:8604: (let ((producer (async (channel/send ch 1) (channel/close ch))) \ -F31 crates/sema-eval/src/eval.rs:8605: (consumer (async (map channel/recv (list ch))))) \ -F31 crates/sema-eval/src/eval.rs:8612: // `async/run` inside an async task suspends cooperatively and preserves the -F31 crates/sema-eval/src/eval.rs:8620: (await (async (async/run) (channel/send ch 42) (channel/recv ch))))", -F31 crates/sema-eval/src/eval.rs:8628: // A 0 ms `async/timeout` must still let synchronously-ready work finish: the -F31 crates/sema-eval/src/eval.rs:8635: .eval_str_via_runtime("(async/timeout 0 (async 42))") -F31 crates/sema-eval/src/eval.rs:8640: // `retry` backoff in a runtime quantum yields cooperatively (via `async/sleep`) -F31 crates/sema-eval/src/eval.rs:8649: (async/all \ -F31 crates/sema-eval/src/eval.rs:8650: (list (async/spawn (fn () \ -F31 crates/sema-eval/src/eval.rs:8654: (channel/send out :slow))) \ -F31 crates/sema-eval/src/eval.rs:8655: (async/spawn (fn () (async/sleep 10) (channel/send out :fast))))) \ -F31 crates/sema-eval/src/eval.rs:8656: (list (channel/recv out) (channel/recv out)))", -F31 crates/sema-eval/src/eval.rs:8674: (begin (async (channel/send ch :ran)) :end)", -F31 crates/sema-eval/src/eval.rs:8680: .eval_str_via_runtime("(channel/recv ch)") -F34A crates/sema-eval/src/prelude.rs:1016:;; connection, spawned via `async/spawn`'s runtime ABI, which requires a -F34A crates/sema-eval/src/prelude.rs:1020:;; being `async/spawn`'s trivial promise-handle mapping — -F34A crates/sema-eval/src/prelude.rs:1047: (let ((factory (fn (h req responder) (async/spawn (fn () (responder (h req))))))) +F31 crates/sema-eval/src/eval.rs:154: // `Rc` and `Value` edges (a still-parked/detached `async/spawn` +F32 crates/sema-eval/src/eval.rs:224: sema_core::set_eval_callback(&ctx, eval_value_vm); +F32 crates/sema-eval/src/eval.rs:225: sema_core::set_call_callback(&ctx, call_value); +F32 crates/sema-eval/src/eval.rs:226: sema_core::set_call_owned_callback(&ctx, call_value_owned); +F32 crates/sema-eval/src/eval.rs:245: sema_core::set_eval_callback(&ctx, eval_value_vm); +F32 crates/sema-eval/src/eval.rs:246: sema_core::set_call_callback(&ctx, call_value); +F32 crates/sema-eval/src/eval.rs:247: sema_core::set_call_owned_callback(&ctx, call_value_owned); +F32 crates/sema-eval/src/eval.rs:3475: sema_core::call_callback(context, &thunk.body, &[])? +F31 crates/sema-eval/src/eval.rs:4551: .eval_str("(defmacro sleeping-transformer () (async/sleep 1) '42)") +F31 crates/sema-eval/src/eval.rs:4595: b"(defmacro loaded-sleeper () (async/sleep 1) '42)\ +F31 crates/sema-eval/src/eval.rs:4667: .eval_str("(defmacro late-force-macro () (async/sleep 1) '42)") +F31 crates/sema-eval/src/eval.rs:4686: .eval_str("(defmacro macroexpand-sleeper () (async/sleep 1) '42)") +F31 crates/sema-eval/src/eval.rs:4709: "(defmacro spawn-transformer () (async/spawn (fn () 42)))", +F31 crates/sema-eval/src/eval.rs:5074: (async/spawn (fn () (async/sleep 1) (channel/send ch 42))) \ +F31 crates/sema-eval/src/eval.rs:5075: (force (delay (channel/recv ch))))", +F31 crates/sema-eval/src/eval.rs:5091: (async/sleep 1) \ +F31 crates/sema-eval/src/eval.rs:5110: (async/sleep 10) \ +F31 crates/sema-eval/src/eval.rs:5114: (async/await \ +F31 crates/sema-eval/src/eval.rs:5115: (async/all (list (async (force p)) (async (force p))))) \ +F31 crates/sema-eval/src/eval.rs:5136: (async/sleep 20) +F31 crates/sema-eval/src/eval.rs:5158: std::thread::sleep(std::time::Duration::from_millis(25)); +F31 crates/sema-eval/src/eval.rs:5178: (async/sleep 1) \ +F31 crates/sema-eval/src/eval.rs:5196: (define p (delay (begin (async/sleep 1) (mutable-array/new)))) \ +F31 crates/sema-eval/src/eval.rs:5213: (define force-promise (delay (channel/recv force-gate))))", +F31 crates/sema-eval/src/eval.rs:5249: (channel/send entered :entered) \ +F31 crates/sema-eval/src/eval.rs:5250: (channel/recv body-gate) \ +F31 crates/sema-eval/src/eval.rs:5254: (channel/recv entered) \ +F31 crates/sema-eval/src/eval.rs:5256: (async/sleep 1) \ +F31 crates/sema-eval/src/eval.rs:5257: (async/cancel first) \ +F31 crates/sema-eval/src/eval.rs:5258: (channel/send body-gate :continue) \ +F31 crates/sema-eval/src/eval.rs:5281: (channel/send entered :q) +F31 crates/sema-eval/src/eval.rs:5282: (channel/recv hold) +F31 crates/sema-eval/src/eval.rs:5285: (channel/recv entered) +F31 crates/sema-eval/src/eval.rs:5288: (async/sleep 5) +F31 crates/sema-eval/src/eval.rs:5289: (async/cancel p-owner) +F31 crates/sema-eval/src/eval.rs:5290: (channel/send hold :go) +F31 crates/sema-eval/src/eval.rs:5346: (delay (channel/recv force-deadlock-gate))))", +F31 crates/sema-eval/src/eval.rs:5358: (async (channel/send force-deadlock-gate 9)) \ +F31 crates/sema-eval/src/eval.rs:5587: // `async/sleep` returns a structural timer suspension. The runtime parks its +F31 crates/sema-eval/src/eval.rs:5595: .eval_str_via_runtime("(async/sleep 2)") +F31 crates/sema-eval/src/eval.rs:5596: .expect("async/sleep settles through the runtime"); +F31 crates/sema-eval/src/eval.rs:5606: .eval_str_via_runtime("(begin (async/sleep 2) (+ 40 2))") +F31 crates/sema-eval/src/eval.rs:5607: .expect("program continues past async/sleep"); +F31 crates/sema-eval/src/eval.rs:5611: // `async/spawn` + `async/await` round-trip through the runtime: spawn a +F31 crates/sema-eval/src/eval.rs:5619: .eval_str_via_runtime("(await (async/spawn (fn () (+ 40 2))))") +F31 crates/sema-eval/src/eval.rs:5633: (define a (async/spawn (fn () (+ 1 2)))) \ +F31 crates/sema-eval/src/eval.rs:5634: (define b (async/spawn (fn () (* 4 5)))) \ +F31 crates/sema-eval/src/eval.rs:5641: // A spawned task that itself parks on a timer (`async/sleep`) and resumes: +F31 crates/sema-eval/src/eval.rs:5648: .eval_str_via_runtime("(await (async/spawn (fn () (async/sleep 2) 7)))") +F31 crates/sema-eval/src/eval.rs:5661: // detached task (parked on its `async/sleep` timer at the end of call one) +F31 crates/sema-eval/src/eval.rs:5670: .eval_str_via_runtime("(define p (async/spawn (fn () (async/sleep 2) 42)))") +F31 crates/sema-eval/src/eval.rs:5727: r#"(await (async/spawn (fn () (load "./dep.sema"))))"#, +F31 crates/sema-eval/src/eval.rs:5743: b"(channel/recv runtime-load-gate) (define runtime-loaded 42) runtime-loaded".to_vec(), +F31 crates/sema-eval/src/eval.rs:5759: .eval_str_via_runtime("(channel/send runtime-load-gate :continue)") +F31 crates/sema-eval/src/eval.rs:5778: b"(module blocking (export answer) (set! runtime-import-count (+ runtime-import-count 1)) (channel/recv runtime-import-gate) (define answer 42))".to_vec(), +F31 crates/sema-eval/src/eval.rs:5814: .eval_str_via_runtime("(channel/send runtime-import-gate :continue)") +F31 crates/sema-eval/src/eval.rs:5842: b"(module sync-runtime-overlap (export overlap-answer) (set! overlap-count (+ overlap-count 1)) (channel/recv overlap-gate) (define overlap-answer 42))".to_vec(), +F31 crates/sema-eval/src/eval.rs:5881: .eval_str_via_runtime("(channel/send overlap-gate :continue)") +F31 crates/sema-eval/src/eval.rs:5902: b"(module foreign-runtime-import (export foreign-answer) (set! foreign-import-count (+ foreign-import-count 1)) (channel/recv foreign-import-gate) (define foreign-answer 42))".to_vec(), +F31 crates/sema-eval/src/eval.rs:5937: .eval_str_via_runtime("(channel/send foreign-import-gate :continue)") +F31 crates/sema-eval/src/eval.rs:6022: b"(channel/recv gate-cancel-channel)".to_vec(), +F31 crates/sema-eval/src/eval.rs:6058: b"(module single-flight-source (export source-answer) (set! source-import-count (+ source-import-count 1)) (channel/recv source-import-gate) (define source-answer 42))".to_vec(), +F31 crates/sema-eval/src/eval.rs:6094: .eval_str_via_runtime("(channel/send source-import-gate :continue)") +F31 crates/sema-eval/src/eval.rs:6122: "(module single-flight-bytecode (export bytecode-answer) (set! bytecode-import-count (+ bytecode-import-count 1)) (channel/recv bytecode-import-gate) (define bytecode-answer 42))", +F31 crates/sema-eval/src/eval.rs:6159: .eval_str_via_runtime("(channel/send bytecode-import-gate :continue)") +F31 crates/sema-eval/src/eval.rs:6182: b"(module cancelled-owner-import (export cancelled-owner-answer) (set! cancelled-owner-count (+ cancelled-owner-count 1)) (if (= cancelled-owner-count 1) (channel/recv cancelled-owner-gate) nil) (define cancelled-owner-answer 42))".to_vec(), +F31 crates/sema-eval/src/eval.rs:6233: b"(module failed-owner-import (export failed-owner-answer) (set! failed-owner-count (+ failed-owner-count 1)) (if (= failed-owner-count 1) (begin (channel/recv failed-owner-gate) (error \"expected first-owner failure\")) nil) (define failed-owner-answer 42))".to_vec(), +F31 crates/sema-eval/src/eval.rs:6263: .eval_str_via_runtime("(channel/send failed-owner-gate :continue)") +F31 crates/sema-eval/src/eval.rs:6286: b"(channel/recv cancelled-load-gate) :loaded".to_vec(), +F31 crates/sema-eval/src/eval.rs:6307: .eval_str_via_runtime("(channel/send cancelled-load-gate :retry)") +F31 crates/sema-eval/src/eval.rs:6325: b"(module cancelled-import (export cancelled-import-answer) (channel/recv cancelled-import-gate) (define cancelled-import-answer 42))".to_vec(), +F31 crates/sema-eval/src/eval.rs:6350: .eval_str_via_runtime("(channel/send cancelled-import-gate :retry)") +F31 crates/sema-eval/src/eval.rs:6399: b"(channel/recv compile-failure-import-gate) (if #t)".to_vec(), +F31 crates/sema-eval/src/eval.rs:6416: .eval_str_via_runtime("(channel/send compile-failure-import-gate :continue)") +F31 crates/sema-eval/src/eval.rs:6443: b"(channel/recv callback-failure-import-gate) (error \"expected module callback failure\")".to_vec(), +F31 crates/sema-eval/src/eval.rs:6460: .eval_str_via_runtime("(channel/send callback-failure-import-gate :continue)") +F31 crates/sema-eval/src/eval.rs:64: /// context would route the VM's `call_callback` through unregistered +F31 crates/sema-eval/src/eval.rs:6518: b"(module outer (export answer) (async/sleep 2) (import \"./inner.sema\" inner-value) (define answer (+ inner-value 2)))".to_vec(), +F31 crates/sema-eval/src/eval.rs:6535: "(module bytecode (export answer) (async/sleep 2) (define answer 42))", +F31 crates/sema-eval/src/eval.rs:6645: (channel/recv root-context-gate) +F31 crates/sema-eval/src/eval.rs:6696: "(channel/send root-context-gate :go)", +F31 crates/sema-eval/src/eval.rs:6735: (channel/send ready :ready) +F31 crates/sema-eval/src/eval.rs:6736: (channel/recv gate) +F31 crates/sema-eval/src/eval.rs:6741: (channel/recv ready) +F31 crates/sema-eval/src/eval.rs:6749: (channel/send gate :go) +F31 crates/sema-eval/src/eval.rs:6855: (async/sleep 2) +F31 crates/sema-eval/src/eval.rs:6882: (channel/send ready :ready) +F31 crates/sema-eval/src/eval.rs:6883: (channel/recv go) +F31 crates/sema-eval/src/eval.rs:6885: (channel/recv ready) +F31 crates/sema-eval/src/eval.rs:6889: (channel/send go :go) +F31 crates/sema-eval/src/eval.rs:6890: (async/sleep 2) +F31 crates/sema-eval/src/eval.rs:6909: (async/sleep 2) +F31 crates/sema-eval/src/eval.rs:6928: (fn () (async/sleep 2) (context/clear) 42))"#, +F31 crates/sema-eval/src/eval.rs:6946: (fn () (async/sleep 2) (error "expected failure")))"#, +F31 crates/sema-eval/src/eval.rs:6967: (fn () (channel/recv (channel/new 1))))"#, +F31 crates/sema-eval/src/eval.rs:7005: "(context/with {:scoped :inner-a} (fn () (channel/recv context-gate) (context/get :scoped)))", +F31 crates/sema-eval/src/eval.rs:7011: "(context/with {:scoped :inner-b} (fn () (channel/recv context-gate) (context/get :scoped)))", +F31 crates/sema-eval/src/eval.rs:7033: .eval_str_via_runtime("(channel/send context-gate :go)") +F31 crates/sema-eval/src/eval.rs:7106: (channel/recv (channel/new 1)))"#, +F31 crates/sema-eval/src/eval.rs:7197: .eval_str_via_runtime("(async/spawn (fn () (async/sleep 100000) 1)) 7") +F31 crates/sema-eval/src/eval.rs:71: /// `async/spawn` tasks, timers, promises and channels survive *between* +F31 crates/sema-eval/src/eval.rs:7216: let result = interp.eval_str_via_runtime("(await (async/spawn (fn () (error \"boom\"))))"); +F31 crates/sema-eval/src/eval.rs:7228: .eval_str_via_runtime("(let ((p (async/spawn (fn () 5)))) (async/sleep 5) (await p))") +F31 crates/sema-eval/src/eval.rs:7241: (define p (async/spawn (fn () 99))) \ +F31 crates/sema-eval/src/eval.rs:7242: (define a (async/spawn (fn () (await p)))) \ +F31 crates/sema-eval/src/eval.rs:7243: (define b (async/spawn (fn () (await p)))) \ +F31 crates/sema-eval/src/eval.rs:7255: .eval_str_via_runtime("(await (async/spawn (fn () (await (async/spawn (fn () 42))))))") +F31 crates/sema-eval/src/eval.rs:7266: .eval_str_via_runtime("(begin (async/spawn (fn () 1)) 99)") +F31 crates/sema-eval/src/eval.rs:7280: (set! p (async/spawn (fn () (async/sleep 1) (await p)))) \ +F31 crates/sema-eval/src/eval.rs:7314: match rx.recv_timeout(std::time::Duration::from_secs(30)) { +F31 crates/sema-eval/src/eval.rs:7340: // GATE — top-level `channel/recv` on an empty channel with no sender parks +F31 crates/sema-eval/src/eval.rs:7342: // it with the legacy "channel/recv: channel is empty". +F31 crates/sema-eval/src/eval.rs:7345: assert_runtime_deadlock_matches_oracle("(channel/recv (channel/new 1))"); +F31 crates/sema-eval/src/eval.rs:7348: // GATE — top-level `channel/send` on a full channel with no receiver parks +F31 crates/sema-eval/src/eval.rs:7349: // the root; the drive loop settles it with the legacy "channel/send: channel +F31 crates/sema-eval/src/eval.rs:7355: "(begin (define ch (channel/new 1)) (channel/send ch 1) (channel/send ch 2))", +F31 crates/sema-eval/src/eval.rs:7368: (set! pa (async/spawn (fn () (async/sleep 1) (await pb)))) \ +F31 crates/sema-eval/src/eval.rs:7369: (set! pb (async/spawn (fn () (async/sleep 1) (await pa)))) \ +F31 crates/sema-eval/src/eval.rs:7387: .eval_str_via_runtime("(async/spawn (fn () (async/sleep 5000) 1))") +F31 crates/sema-eval/src/eval.rs:7398: // awaited promise is still Pending at the moment `async/await` runs, the +F31 crates/sema-eval/src/eval.rs:7413: "(try (await (async/spawn (fn () (async/sleep 2) (error \"x\")))) \ +F31 crates/sema-eval/src/eval.rs:7426: .eval_str_via_runtime("(await (async/spawn (fn () (async/sleep 2) (error \"boom\"))))"); +F31 crates/sema-eval/src/eval.rs:7448: "(async/all (list (async/spawn (fn () 1)) \ +F31 crates/sema-eval/src/eval.rs:7449: (async/spawn (fn () 2)) \ +F31 crates/sema-eval/src/eval.rs:7450: (async/spawn (fn () 3))))", +F31 crates/sema-eval/src/eval.rs:7452: .expect("async/all resolves through the runtime"); +F31 crates/sema-eval/src/eval.rs:7460: .eval_str_via_runtime("(async/all (list))") +F31 crates/sema-eval/src/eval.rs:7461: .expect("async/all of empty input"); +F31 crates/sema-eval/src/eval.rs:7474: (define sib (async/spawn (fn () (async/sleep 4) (channel/send ch 77) 1))) \ +F31 crates/sema-eval/src/eval.rs:7475: (define bad (async/spawn (fn () (error \"boom\")))) \ +F31 crates/sema-eval/src/eval.rs:7476: (define outcome (try (async/all (list bad sib)) (catch e :caught))) \ +F31 crates/sema-eval/src/eval.rs:7477: (list outcome (await sib) (channel/recv ch)))", +F31 crates/sema-eval/src/eval.rs:7490: (define fast (async/spawn (fn () 10))) \ +F31 crates/sema-eval/src/eval.rs:7491: (define slow (async/spawn (fn () (async/sleep 5) (channel/send ch 88) 20))) \ +F31 crates/sema-eval/src/eval.rs:7492: (define winner (async/race (list fast slow))) \ +F31 crates/sema-eval/src/eval.rs:7493: (list winner (await slow) (channel/recv ch)))", +F31 crates/sema-eval/src/eval.rs:7503: .eval_str_via_runtime("(async/timeout 10000 (async/spawn (fn () (async/sleep 1) 5)))") +F31 crates/sema-eval/src/eval.rs:7515: (define slow (async/spawn (fn () (async/sleep 20) (channel/send ch 55) 9))) \ +F31 crates/sema-eval/src/eval.rs:7516: (define outcome (try (async/timeout 1 slow) (catch e :timeout))) \ +F31 crates/sema-eval/src/eval.rs:7517: (list outcome (await slow) (channel/recv ch)))", +F31 crates/sema-eval/src/eval.rs:7525: // yield seam. Unlike the earlier async/all|race|timeout channel uses (which +F31 crates/sema-eval/src/eval.rs:7538: (async/spawn (fn () (channel/send ch 42))) \ +F31 crates/sema-eval/src/eval.rs:7539: (channel/recv ch))", +F31 crates/sema-eval/src/eval.rs:7554: (channel/send ch 1) (channel/send ch 2) (channel/send ch 3) \ +F31 crates/sema-eval/src/eval.rs:7555: (list (channel/recv ch) (channel/recv ch) (channel/recv ch)))", +F31 crates/sema-eval/src/eval.rs:7572: (define p (async/spawn (fn () \ +F31 crates/sema-eval/src/eval.rs:7573: (channel/send ch 1) (channel/send ch 2) (channel/send ch 3) :done))) \ +F31 crates/sema-eval/src/eval.rs:7574: (define out (list (channel/recv ch) (channel/recv ch) (channel/recv ch))) \ +F31 crates/sema-eval/src/eval.rs:7584: fn runtime_channel_blocking_recv_parks_until_sent() { +F31 crates/sema-eval/src/eval.rs:7590: (async/spawn (fn () (async/sleep 2) (channel/send ch 77))) \ +F31 crates/sema-eval/src/eval.rs:7591: (channel/recv ch))", +F31 crates/sema-eval/src/eval.rs:7604: (channel/send ch 1) \ +F31 crates/sema-eval/src/eval.rs:7606: (list (channel/recv ch) (channel/recv ch)))", +F31 crates/sema-eval/src/eval.rs:7619: (try (channel/send ch 9) (catch e :send-failed)))", +F31 crates/sema-eval/src/eval.rs:764: if !runtime.block_on_inbox(next_deadline) && next_deadline.is_none() { +F31 crates/sema-eval/src/eval.rs:7654: (channel/send ch 10) \ +F31 crates/sema-eval/src/eval.rs:7655: (channel/send ch 20) \ +F31 crates/sema-eval/src/eval.rs:7667: (channel/send ch 10) \ +F31 crates/sema-eval/src/eval.rs:7668: (channel/send ch 20) \ +F31 crates/sema-eval/src/eval.rs:7679: (channel/send ch 1) \ +F31 crates/sema-eval/src/eval.rs:7680: (channel/send ch 2) \ +F31 crates/sema-eval/src/eval.rs:7690: (channel/send ch 7) \ +F31 crates/sema-eval/src/eval.rs:7698: // GATE 1: `async/cancel` returns `#t` ONLY for the FIRST cancellation request +F31 crates/sema-eval/src/eval.rs:7706: "(let ((p (async/spawn (fn () (async/sleep 100000) 42)))) \ +F31 crates/sema-eval/src/eval.rs:7707: (list (async/cancel p) (async/cancel p)))", +F31 crates/sema-eval/src/eval.rs:7709: .expect("async/cancel of a sleeping spawned task drives through the runtime"); +F31 crates/sema-eval/src/eval.rs:7717: // GATE 1b: `async/cancel` returns `#f` for a synthetic promise (no backing +F31 crates/sema-eval/src/eval.rs:7723: .eval_str_via_runtime("(async/cancel (async/resolved 5))") +F31 crates/sema-eval/src/eval.rs:7736: "(let ((p (async/spawn (fn () (async/sleep 100000) 42)))) \ +F31 crates/sema-eval/src/eval.rs:7737: (async/cancel p) \ +F31 crates/sema-eval/src/eval.rs:773: // The root is parked purely on a timer (`async/sleep`): the only +F31 crates/sema-eval/src/eval.rs:7754: "(let ((p (async/spawn (fn () (async/sleep 100000) 42)))) \ +F31 crates/sema-eval/src/eval.rs:7755: (async/cancel p) \ +F31 crates/sema-eval/src/eval.rs:7766: // GATE 3: a task blocked on a LONG `async/sleep`, when cancelled, actually +F31 crates/sema-eval/src/eval.rs:7768: // NOT after the full (100s) sleep. The promise reports `async/cancelled?` +F08A crates/sema-eval/src/eval.rs:776: // than a raw `thread::sleep` — no external op is registered, so +F31 crates/sema-eval/src/eval.rs:7776: "(let ((p (async/spawn (fn () (async/sleep 100000) 42)))) \ +F31 crates/sema-eval/src/eval.rs:7777: (async/cancel p) \ +F31 crates/sema-eval/src/eval.rs:7779: (async/cancelled? p))", +F31 crates/sema-eval/src/eval.rs:7796: // `channel/recv` is tracked ONLY in `channel_waits`, with a wait key that is +F31 crates/sema-eval/src/eval.rs:7805: // PROMPTLY; `async/cancelled?` is #t and `await` raises a catchable +F31 crates/sema-eval/src/eval.rs:7813: "(let ((p (async/spawn (fn () (channel/recv (channel/new 1)))))) \ +F31 crates/sema-eval/src/eval.rs:7814: (async/cancel p) \ +F31 crates/sema-eval/src/eval.rs:7815: (list (try (await p) (catch e (:type e))) (async/cancelled? p)))", +F31 crates/sema-eval/src/eval.rs:7830: // GATE B: THE HANG PROOF. A detached task left parked on `channel/recv` when +F31 crates/sema-eval/src/eval.rs:7843: .eval_str_via_runtime("(async/spawn (fn () (channel/recv (channel/new 1)))) 42") +F31 crates/sema-eval/src/eval.rs:7863: "(async/spawn (fn () (let ((c (channel/new 1))) (channel/send c 1) (channel/send c 99)))) 7", +F31 crates/sema-eval/src/eval.rs:7874: // GATE D: owned fail-fast that cancels a worker parked on `channel/recv` +F31 crates/sema-eval/src/eval.rs:7885: (try (async/spawn-all \ +F31 crates/sema-eval/src/eval.rs:7887: (fn () (channel/recv ch)))) \ +F31 crates/sema-eval/src/eval.rs:7905: // observational `async/all`/`race`/`timeout` gates above, where the supplied +F31 crates/sema-eval/src/eval.rs:7908: // async/spawn-all GATE 1 — happy path: values in INPUT order. +F31 crates/sema-eval/src/eval.rs:7913: .eval_str_via_runtime("(async/spawn-all (list (fn () 1) (fn () 2) (fn () 3)))") +F31 crates/sema-eval/src/eval.rs:7918: // async/spawn-all GATE 1b — empty input → empty list. +F31 crates/sema-eval/src/eval.rs:7923: .eval_str_via_runtime("(async/spawn-all (list))") +F31 crates/sema-eval/src/eval.rs:7928: // async/spawn-all GATE 2 — fail-fast OWNERSHIP: one child errors immediately; +F31 crates/sema-eval/src/eval.rs:7940: (try (async/spawn-all \ +F31 crates/sema-eval/src/eval.rs:7941: (list (fn () (async/sleep 60) (set! flag 77) 1) \ +F31 crates/sema-eval/src/eval.rs:7944: (async/sleep 200) \ +F31 crates/sema-eval/src/eval.rs:796: runtime.block_on_inbox(Some(deadline)); +F31 crates/sema-eval/src/eval.rs:7976: (begin (async/sleep 60) (set! flag x) x))) \ +F31 crates/sema-eval/src/eval.rs:7979: (async/sleep 200) \ +F31 crates/sema-eval/src/eval.rs:8000: (async/sleep 15) \ +F31 crates/sema-eval/src/eval.rs:803: // legacy-parity error (`channel/recv: channel is empty` / +F31 crates/sema-eval/src/eval.rs:8046: (if (= x 1) (begin (async/sleep 5) (error \"boom\")) \ +F31 crates/sema-eval/src/eval.rs:8047: (begin (async/sleep 60) (set! flag x) x))) \ +F31 crates/sema-eval/src/eval.rs:804: // `channel/send: channel is full` for a top-level channel op, or +F31 crates/sema-eval/src/eval.rs:8050: (async/sleep 200) \ +F31 crates/sema-eval/src/eval.rs:8057: // async/race-owned GATE 1 — first settlement wins AND the loser is CANCELLED +F31 crates/sema-eval/src/eval.rs:8068: (async/race-owned \ +F31 crates/sema-eval/src/eval.rs:8070: (fn () (async/sleep 60) (set! flag 99) 20)))) \ +F31 crates/sema-eval/src/eval.rs:8071: (async/sleep 200) \ +F31 crates/sema-eval/src/eval.rs:8082: // async/race-owned GATE 2 — empty input is an argument error. +F31 crates/sema-eval/src/eval.rs:8088: .eval_str_via_runtime("(try (async/race-owned (list)) (catch e :empty))",) +F31 crates/sema-eval/src/eval.rs:8094: // async/race-owned GATE 3 — the first settlement being an error re-raises it. +F31 crates/sema-eval/src/eval.rs:8100: "(try (async/race-owned (list (fn () (error \"boom\")) \ +F31 crates/sema-eval/src/eval.rs:8101: (fn () (async/sleep 100) 2))) \ +F31 crates/sema-eval/src/eval.rs:8119: (fn () (async/sleep 200) (set! flag 5) :done)) \ +F31 crates/sema-eval/src/eval.rs:8121: (async/sleep 300) \ +F31 crates/sema-eval/src/eval.rs:8137: .eval_str_via_runtime("(async/with-timeout 10000 (fn () (async/sleep 1) 42))") +F31 crates/sema-eval/src/eval.rs:8158: // at bytecode level via `__spawn-apply` rather than `(map async/spawn …)`. +F31 crates/sema-eval/src/eval.rs:8160: // `(map async/spawn …)` shape would raise "async yield outside of scheduler +F31 crates/sema-eval/src/eval.rs:8215: "(map (fn (x) (async/await (async/spawn (fn () (* x x))))) (list 1 2 3))", +F31 crates/sema-eval/src/eval.rs:8224: // The embedding-style shape: each `async` desugars to `(async/spawn (fn () +F31 crates/sema-eval/src/eval.rs:8225: // …))`, so `map` produces a list of promises which `async/all` then awaits. +F31 crates/sema-eval/src/eval.rs:8226: // The callback's `async/spawn` yield must be serviced cooperatively. +F31 crates/sema-eval/src/eval.rs:8231: .eval_str_via_runtime("(async/all (map (fn (x) (async (* x x))) (list 1 2 3)))") +F31 crates/sema-eval/src/eval.rs:8232: .expect("async/all over a mapped list of spawns resolves"); +F31 crates/sema-eval/src/eval.rs:8251: "(filter (fn (x) (async/await (async/spawn (fn () (> x 1))))) (list 1 2 3))", +F31 crates/sema-eval/src/eval.rs:8267: "(foldl (fn (acc x) (async/await (async/spawn (fn () (+ acc x))))) 0 \ +F31 crates/sema-eval/src/eval.rs:8284: "(foldl (fn (acc x) (async/await (async/spawn (fn () (+ acc x))))) 99 (list))", +F31 crates/sema-eval/src/eval.rs:8295: "(reduce (fn (acc x) (async/await (async/spawn (fn () (+ acc x))))) \ +F31 crates/sema-eval/src/eval.rs:8312: "(reduce (fn (acc x) (async/await (async/spawn (fn () (+ acc x))))) (list 42))", +F31 crates/sema-eval/src/eval.rs:8324: "(sort-by (fn (x) (async/await (async/spawn (fn () (- x))))) (list 3 1 2))", +F31 crates/sema-eval/src/eval.rs:8346: (for-each (fn (x) (async/await (async/spawn (fn () (channel/send ch (* x 10)))))) \ +F31 crates/sema-eval/src/eval.rs:8349: (list (channel/recv ch) (channel/recv ch) (channel/recv ch))", +F31 crates/sema-eval/src/eval.rs:8377: // still-live `Tracked` cell, mirroring `async/spawn`) every escaping open +F31 crates/sema-eval/src/eval.rs:8552: .eval_str_via_runtime("(async/spawn (fn () (sleep 100000) 1)) 7") +F31 crates/sema-eval/src/eval.rs:8587: // observable synchronously via `async/cancelled?`. +F31 crates/sema-eval/src/eval.rs:8594: (let ((p (async (channel/recv ch)))) \ +F31 crates/sema-eval/src/eval.rs:8595: (async/cancel p) \ +F31 crates/sema-eval/src/eval.rs:8596: (async/cancelled? p)))", +F31 crates/sema-eval/src/eval.rs:8608: "(let ((p (async (async/sleep 100)))) \ +F31 crates/sema-eval/src/eval.rs:8609: (async/cancel p) \ +F31 crates/sema-eval/src/eval.rs:8610: (list (async/cancelled? p) (async/rejected? p) \ +F31 crates/sema-eval/src/eval.rs:8625: // A yielding native (`channel/recv`) passed DIRECTLY as a HOF callback now +F31 crates/sema-eval/src/eval.rs:8627: // continuation ABI (the tool-loop/HOF migration): `(map channel/recv …)` +F31 crates/sema-eval/src/eval.rs:8635: (let ((producer (async (channel/send ch 1) (channel/close ch))) \ +F31 crates/sema-eval/src/eval.rs:8636: (consumer (async (map channel/recv (list ch))))) \ +F31 crates/sema-eval/src/eval.rs:8643: // `async/run` inside an async task suspends cooperatively and preserves the +F31 crates/sema-eval/src/eval.rs:8651: (await (async (async/run) (channel/send ch 42) (channel/recv ch))))", +F31 crates/sema-eval/src/eval.rs:8659: // A 0 ms `async/timeout` must still let synchronously-ready work finish: the +F31 crates/sema-eval/src/eval.rs:8666: .eval_str_via_runtime("(async/timeout 0 (async 42))") +F31 crates/sema-eval/src/eval.rs:8671: // `retry` backoff in a runtime quantum yields cooperatively (via `async/sleep`) +F31 crates/sema-eval/src/eval.rs:8680: (async/all \ +F31 crates/sema-eval/src/eval.rs:8681: (list (async/spawn (fn () \ +F31 crates/sema-eval/src/eval.rs:8685: (channel/send out :slow))) \ +F31 crates/sema-eval/src/eval.rs:8686: (async/spawn (fn () (async/sleep 10) (channel/send out :fast))))) \ +F31 crates/sema-eval/src/eval.rs:8687: (list (channel/recv out) (channel/recv out)))", +F31 crates/sema-eval/src/eval.rs:8705: (begin (async (channel/send ch :ran)) :end)", +F31 crates/sema-eval/src/eval.rs:8711: .eval_str_via_runtime("(channel/recv ch)") +F34C crates/sema-eval/src/prelude.rs:1006:;; CONTEXT — a callback that itself suspends (async/sleep, channel ops, await) is +F34A crates/sema-eval/src/prelude.rs:1039:;; connection, spawned via `async/spawn`'s runtime ABI, which requires a +F34A crates/sema-eval/src/prelude.rs:1043:;; being `async/spawn`'s trivial promise-handle mapping — +F34A crates/sema-eval/src/prelude.rs:1070: (let ((factory (fn (h req responder) (async/spawn (fn () (responder (h req))))))) F34D crates/sema-eval/src/prelude.rs:137:;; (async/await (ws/listen sock {:on-message (fn (c m) (println m))})) F34A crates/sema-eval/src/prelude.rs:146: (async/spawn -F34A crates/sema-eval/src/prelude.rs:505:;; The OWNED combinators (`async/spawn-all`, `async/map`, `async/pool-map`, -F34B crates/sema-eval/src/prelude.rs:506:;; `async/race-owned`, `async/with-timeout`) OWN the tasks they create: on a -F34A crates/sema-eval/src/prelude.rs:509:;; observational `async/all`/`async/race`/`async/timeout`, which never cancel the -F34A crates/sema-eval/src/prelude.rs:514:;; A hard constraint drives the helper shape: `async/spawn`/`async/cancel` issue -F34A crates/sema-eval/src/prelude.rs:518:;; bytecode level via explicit recursion, never `(map async/spawn …)`. Pure list -F34A crates/sema-eval/src/prelude.rs:526: (let ((p (async/spawn (car thunks)))) -F34A crates/sema-eval/src/prelude.rs:538: (let ((p (async/spawn (fn () (wf item))))) -F34A crates/sema-eval/src/prelude.rs:547: (begin (async/cancel (car promises)) -F34A crates/sema-eval/src/prelude.rs:551:;; order. Fail-fast + OWNED: `async/all` raises on the FIRST child failure/ -F34A crates/sema-eval/src/prelude.rs:554:;; before its side effects — the ownership property the observational `async/all` -F34A crates/sema-eval/src/prelude.rs:560: (try (async/all children) -F34A crates/sema-eval/src/prelude.rs:566:;; (bytecode-level channel/send — capacity is exactly k so none block). -F34A crates/sema-eval/src/prelude.rs:570: (begin (channel/send sem #t) -F34A crates/sema-eval/src/prelude.rs:586:;; pre-filled with `n` tokens: each spawned task first `(channel/recv sem)` -F34A crates/sema-eval/src/prelude.rs:592:;; semaphore or mid-`f`) before re-raising — unlike the observational `async/all`. -F34A crates/sema-eval/src/prelude.rs:607: (channel/recv pool-sem#) ; acquire a slot (parks if full) -F34A crates/sema-eval/src/prelude.rs:610: (channel/send pool-sem# #t) ; release on BOTH paths -F34A crates/sema-eval/src/prelude.rs:624:;; async/spawn …)`): `async/spawn` issues a structural runtime request that a -F34A crates/sema-eval/src/prelude.rs:633: (for-range (i# 0 ,n) (channel/send fo-sem# #t)) ; n concurrency tokens -F34A crates/sema-eval/src/prelude.rs:636: (channel/recv fo-sem#) ; acquire (parks when full) -F34A crates/sema-eval/src/prelude.rs:639: (channel/send fo-sem# #t) ; release on BOTH paths -F34A crates/sema-eval/src/prelude.rs:641: (async/all (__spawn-apply fo-worker# fo-items#))))) -F34B crates/sema-eval/src/prelude.rs:710:;; On throw: sleeps base-ms * factor^(n-1) via (async/sleep …) — cooperative, parks -F34B crates/sema-eval/src/prelude.rs:730: (async/sleep delay#) ; cooperative backoff -F34B crates/sema-eval/src/prelude.rs:739:;; so in async context the loop lives here and backs off via `async/sleep` while -F34A crates/sema-eval/src/prelude.rs:741:;; blocking native. At top level the blocking native uses real `thread::sleep`. -F34B crates/sema-eval/src/prelude.rs:751: (when (> __retry-delay 0) (async/sleep __retry-delay)) -F34A crates/sema-eval/src/prelude.rs:805:;; async/spawn-all: spawn a list of zero-arg thunks as concurrent tasks and await -F34A crates/sema-eval/src/prelude.rs:809:;; before propagating (fail-fast), unlike the observational `async/all`. Empty -F34A crates/sema-eval/src/prelude.rs:812:;; (async/spawn-all (list (fn () (http/get a)) (fn () (http/get b)))) ; both at once -F34A crates/sema-eval/src/prelude.rs:813:(defmacro async/spawn-all (thunks) -F34A crates/sema-eval/src/prelude.rs:825:;; async/race-owned: run a list of zero-arg thunks concurrently and settle on the -F34A crates/sema-eval/src/prelude.rs:828:;; the observational `async/race` (which leaves losers running). Requires ≥1 thunk. -F34A crates/sema-eval/src/prelude.rs:830:;; (async/race-owned (list (fn () (http/get mirror-a)) (fn () (http/get mirror-b)))) -F34A crates/sema-eval/src/prelude.rs:831:(define (async/race-owned thunks) -F34A crates/sema-eval/src/prelude.rs:833: (error "async/race-owned: requires at least one thunk") -F34A crates/sema-eval/src/prelude.rs:835: ;; `async/race` settles on the FIRST child (value or error); either way we -F34A crates/sema-eval/src/prelude.rs:838: (try (let ((winner (async/race children))) -F34A crates/sema-eval/src/prelude.rs:852: (let ((child (async/spawn thunk)) -F34A crates/sema-eval/src/prelude.rs:854: ;; child tells the two apart without relying on `async/timeout` (which -F34B crates/sema-eval/src/prelude.rs:856: (timer (async/spawn (fn () (async/sleep ms) :__with-timeout-elapsed)))) -F34B crates/sema-eval/src/prelude.rs:857: (let ((outcome (try {:v (async/race (list child timer))} -F34A crates/sema-eval/src/prelude.rs:860: (async/cancel child) -F34B crates/sema-eval/src/prelude.rs:861: (async/cancel timer) -F34A crates/sema-eval/src/prelude.rs:871:;; sibling tasks overlap and `async/timeout`/`async/cancel` can cut the loop at -F34C crates/sema-eval/src/prelude.rs:983:;; CONTEXT — a callback that itself suspends (async/sleep, channel ops, await) is +F34A crates/sema-eval/src/prelude.rs:528:;; The OWNED combinators (`async/spawn-all`, `async/map`, `async/pool-map`, +F34B crates/sema-eval/src/prelude.rs:529:;; `async/race-owned`, `async/with-timeout`) OWN the tasks they create: on a +F34A crates/sema-eval/src/prelude.rs:532:;; observational `async/all`/`async/race`/`async/timeout`, which never cancel the +F34A crates/sema-eval/src/prelude.rs:537:;; A hard constraint drives the helper shape: `async/spawn`/`async/cancel` issue +F34A crates/sema-eval/src/prelude.rs:541:;; bytecode level via explicit recursion, never `(map async/spawn …)`. Pure list +F34A crates/sema-eval/src/prelude.rs:549: (let ((p (async/spawn (car thunks)))) +F34A crates/sema-eval/src/prelude.rs:561: (let ((p (async/spawn (fn () (wf item))))) +F34A crates/sema-eval/src/prelude.rs:570: (begin (async/cancel (car promises)) +F34A crates/sema-eval/src/prelude.rs:574:;; order. Fail-fast + OWNED: `async/all` raises on the FIRST child failure/ +F34A crates/sema-eval/src/prelude.rs:577:;; before its side effects — the ownership property the observational `async/all` +F34A crates/sema-eval/src/prelude.rs:583: (try (async/all children) +F34A crates/sema-eval/src/prelude.rs:589:;; (bytecode-level channel/send — capacity is exactly k so none block). +F34A crates/sema-eval/src/prelude.rs:593: (begin (channel/send sem #t) +F34A crates/sema-eval/src/prelude.rs:609:;; pre-filled with `n` tokens: each spawned task first `(channel/recv sem)` +F34A crates/sema-eval/src/prelude.rs:615:;; semaphore or mid-`f`) before re-raising — unlike the observational `async/all`. +F34A crates/sema-eval/src/prelude.rs:630: (channel/recv pool-sem#) ; acquire a slot (parks if full) +F34A crates/sema-eval/src/prelude.rs:633: (channel/send pool-sem# #t) ; release on BOTH paths +F34A crates/sema-eval/src/prelude.rs:647:;; async/spawn …)`): `async/spawn` issues a structural runtime request that a +F34A crates/sema-eval/src/prelude.rs:656: (for-range (i# 0 ,n) (channel/send fo-sem# #t)) ; n concurrency tokens +F34A crates/sema-eval/src/prelude.rs:659: (channel/recv fo-sem#) ; acquire (parks when full) +F34A crates/sema-eval/src/prelude.rs:662: (channel/send fo-sem# #t) ; release on BOTH paths +F34A crates/sema-eval/src/prelude.rs:664: (async/all (__spawn-apply fo-worker# fo-items#))))) +F34B crates/sema-eval/src/prelude.rs:733:;; On throw: sleeps base-ms * factor^(n-1) via (async/sleep …) — cooperative, parks +F34B crates/sema-eval/src/prelude.rs:753: (async/sleep delay#) ; cooperative backoff +F34B crates/sema-eval/src/prelude.rs:762:;; so in async context the loop lives here and backs off via `async/sleep` while +F34A crates/sema-eval/src/prelude.rs:764:;; blocking native. At top level the blocking native uses real `thread::sleep`. +F34B crates/sema-eval/src/prelude.rs:774: (when (> __retry-delay 0) (async/sleep __retry-delay)) +F34A crates/sema-eval/src/prelude.rs:828:;; async/spawn-all: spawn a list of zero-arg thunks as concurrent tasks and await +F34A crates/sema-eval/src/prelude.rs:832:;; before propagating (fail-fast), unlike the observational `async/all`. Empty +F34A crates/sema-eval/src/prelude.rs:835:;; (async/spawn-all (list (fn () (http/get a)) (fn () (http/get b)))) ; both at once +F34A crates/sema-eval/src/prelude.rs:836:(defmacro async/spawn-all (thunks) +F34A crates/sema-eval/src/prelude.rs:848:;; async/race-owned: run a list of zero-arg thunks concurrently and settle on the +F34A crates/sema-eval/src/prelude.rs:851:;; the observational `async/race` (which leaves losers running). Requires ≥1 thunk. +F34A crates/sema-eval/src/prelude.rs:853:;; (async/race-owned (list (fn () (http/get mirror-a)) (fn () (http/get mirror-b)))) +F34A crates/sema-eval/src/prelude.rs:854:(define (async/race-owned thunks) +F34A crates/sema-eval/src/prelude.rs:856: (error "async/race-owned: requires at least one thunk") +F34A crates/sema-eval/src/prelude.rs:858: ;; `async/race` settles on the FIRST child (value or error); either way we +F34A crates/sema-eval/src/prelude.rs:861: (try (let ((winner (async/race children))) +F34A crates/sema-eval/src/prelude.rs:875: (let ((child (async/spawn thunk)) +F34A crates/sema-eval/src/prelude.rs:877: ;; child tells the two apart without relying on `async/timeout` (which +F34B crates/sema-eval/src/prelude.rs:879: (timer (async/spawn (fn () (async/sleep ms) :__with-timeout-elapsed)))) +F34B crates/sema-eval/src/prelude.rs:880: (let ((outcome (try {:v (async/race (list child timer))} +F34A crates/sema-eval/src/prelude.rs:883: (async/cancel child) +F34B crates/sema-eval/src/prelude.rs:884: (async/cancel timer) +F34A crates/sema-eval/src/prelude.rs:894:;; sibling tasks overlap and `async/timeout`/`async/cancel` can cut the loop at F35B crates/sema-io/src/executor.rs:11://! burning one worker apiece — the ceiling the old blocking-tier `io_block_on` F35A crates/sema-io/src/executor.rs:379: std::thread::sleep(Duration::from_millis(ms)); F35A crates/sema-io/src/executor.rs:409: rx.recv_timeout(Duration::from_secs(2)) @@ -421,56 +421,56 @@ C07D crates/sema-llm/src/anthropic.rs:540: sema_io::io_block_on(self.comp C07D crates/sema-llm/src/anthropic.rs:556: // io_block_on drives ON THIS thread: `on_chunk` may touch non-Send Sema C07D crates/sema-llm/src/anthropic.rs:558: sema_io::io_block_on(self.stream_complete_async(request, on_chunk)) C07D crates/sema-llm/src/anthropic.rs:562: sema_io::io_block_on(async { -C07D crates/sema-llm/src/builtins.rs:10079: // synchronous caller thread (the provider's own block_on has already -C10 crates/sema-llm/src/builtins.rs:10084: std::thread::sleep(std::time::Duration::from_millis(sleep_ms)); -C10 crates/sema-llm/src/builtins.rs:10100:/// offloaded future, or a `std::thread::sleep` on a pool worker) so sibling -C11 crates/sema-llm/src/builtins.rs:10436:thread_local! { -C07B crates/sema-llm/src/builtins.rs:10932: // `async/cancel` cuts the loop at an inter-round park. -C07B crates/sema-llm/src/builtins.rs:10989: // `mcp/call`'s runtime external wait, or an `async/await` inside the handler), -C07C crates/sema-llm/src/builtins.rs:11010: let _ = sema_core::call_callback(ctx, callback, &[Value::map(event_map)]); -C07C crates/sema-llm/src/builtins.rs:11046: let _ = sema_core::call_callback(ctx, callback, &[Value::map(event_map)]); -C07C crates/sema-llm/src/builtins.rs:1124: let result = sema_core::call_callback( -C07C crates/sema-llm/src/builtins.rs:1138: results.push(sema_core::call_callback( -C07C crates/sema-llm/src/builtins.rs:1150: let result = sema_core::call_callback( -C11 crates/sema-llm/src/builtins.rs:11682:thread_local! { -C07C crates/sema-llm/src/builtins.rs:1168: if sema_core::call_callback(ctx, &plan.callback, std::slice::from_ref(&argument))? -C11 crates/sema-llm/src/builtins.rs:12267: match rx.recv() { -C10 crates/sema-llm/src/builtins.rs:12497: std::thread::sleep(std::time::Duration::from_millis(STREAM_POLL_INTERVAL_MS)); -C07C crates/sema-llm/src/builtins.rs:12715: let _ = sema_core::call_callback(ctx, callback, &[Value::map(event_map)]); -C07C crates/sema-llm/src/builtins.rs:12768: let _ = sema_core::call_callback(ctx, callback, &[Value::map(event_map)]); -C07C crates/sema-llm/src/builtins.rs:12841: let result = sema_core::call_callback(ctx, &tool_def.handler, &sema_args)?; -C07B crates/sema-llm/src/builtins.rs:1392:/// `NativeOutcome::Call`, so an async op inside the thunk (`async/spawn`, -C07C crates/sema-llm/src/builtins.rs:1394:/// `call_callback` re-entry would suspend the runtime quantum and hit the -C07C crates/sema-llm/src/builtins.rs:1396:/// synchronous `call_callback` extent): a thunk that only builds a promise tears down -C07C crates/sema-llm/src/builtins.rs:1416: let result = sema_core::call_callback(ctx, &body_fn, &[]); -C07C crates/sema-llm/src/builtins.rs:1927: sema_core::call_callback(ctx, &complete_fn, &[request_map]) -C08 crates/sema-llm/src/builtins.rs:266:// `async/spawn` and swaps it around every task step. Read-only flags ride as value -C11 crates/sema-llm/src/builtins.rs:27:thread_local! { -C07C crates/sema-llm/src/builtins.rs:3184: sema_core::call_callback(ctx, cb, &[Value::string(chunk)]) -C07B crates/sema-llm/src/builtins.rs:3976: // is not `async/cancel`-addressable). The prelude `agent/run` / `llm/chat` -C07B crates/sema-llm/src/builtins.rs:3981: // `async/await` inside it) parks on the active task and resumes through the -C07C crates/sema-llm/src/builtins.rs:4121: let result = sema_core::call_callback(ctx, func, std::slice::from_ref(item))?; -C11 crates/sema-llm/src/builtins.rs:539:thread_local! { -C07C crates/sema-llm/src/builtins.rs:6126: let result = sema_core::call_callback(ctx, &body_fn, &[]); -C10 crates/sema-llm/src/builtins.rs:6707: std::thread::sleep(std::time::Duration::from_millis(ms)); -C11 crates/sema-llm/src/builtins.rs:68:/// so they satisfy the `'static` bound required by `thread_local!`. -C07C crates/sema-llm/src/builtins.rs:6922: sema_core::call_callback(ctx, &callable, std::slice::from_ref(&argument)) -C11 crates/sema-llm/src/builtins.rs:7313:thread_local! { -C07B crates/sema-llm/src/builtins.rs:747:// `async/timeout` or a pool error-path) still runs to completion and decrements the -C07C crates/sema-llm/src/builtins.rs:7493: sema_core::call_callback(ctx, on_text, &[Value::string(chunk)]) -C07D crates/sema-llm/src/builtins.rs:8535:/// async tier drives futures with `block_on` (no task-abort). Lives on the runtime -C11 crates/sema-llm/src/builtins.rs:9554:thread_local! { -C07D crates/sema-llm/src/builtins.rs:9634:/// Synchronous-path loop (the VM thread; provider `block_on` already returned -C10 crates/sema-llm/src/builtins.rs:9635:/// before the backoff `thread::sleep`); the async wire stage uses the twin -C10 crates/sema-llm/src/builtins.rs:9658: std::thread::sleep(std::time::Duration::from_millis(wait)); +C08 crates/sema-llm/src/builtins.rs:1053:// `async/spawn` and swaps it around every task step. Read-only flags ride as value +C11 crates/sema-llm/src/builtins.rs:10571:thread_local! { +C07D crates/sema-llm/src/builtins.rs:10651:/// Synchronous-path loop (the VM thread; provider `block_on` already returned +C10 crates/sema-llm/src/builtins.rs:10652:/// before the backoff `thread::sleep`); the async wire stage uses the twin +C10 crates/sema-llm/src/builtins.rs:10675: std::thread::sleep(std::time::Duration::from_millis(wait)); +C07D crates/sema-llm/src/builtins.rs:11092: // synchronous caller thread (the provider's own block_on has already +C10 crates/sema-llm/src/builtins.rs:11097: std::thread::sleep(std::time::Duration::from_millis(sleep_ms)); +C10 crates/sema-llm/src/builtins.rs:11113:/// offloaded future, or a `std::thread::sleep` on a pool worker) so sibling +C11 crates/sema-llm/src/builtins.rs:11483:thread_local! { +C07B crates/sema-llm/src/builtins.rs:11979: // `async/cancel` cuts the loop at an inter-round park. +C07B crates/sema-llm/src/builtins.rs:12037: // `mcp/call`'s runtime external wait, or an `async/await` inside the handler), +C07C crates/sema-llm/src/builtins.rs:12062: let _ = sema_core::call_callback(ctx, callback, &[Value::map(event_map)]); +C07C crates/sema-llm/src/builtins.rs:12098: let _ = sema_core::call_callback(ctx, callback, &[Value::map(event_map)]); +C11 crates/sema-llm/src/builtins.rs:12746:thread_local! { +C11 crates/sema-llm/src/builtins.rs:13399: match rx.recv() { +C11 crates/sema-llm/src/builtins.rs:1340:thread_local! { +C10 crates/sema-llm/src/builtins.rs:13640: std::thread::sleep(std::time::Duration::from_millis(STREAM_POLL_INTERVAL_MS)); +C07C crates/sema-llm/src/builtins.rs:13878: let _ = sema_core::call_callback(ctx, callback, &[Value::map(event_map)]); +C07C crates/sema-llm/src/builtins.rs:13931: let _ = sema_core::call_callback(ctx, callback, &[Value::map(event_map)]); +C07C crates/sema-llm/src/builtins.rs:14004: let result = sema_core::call_callback(ctx, &tool_def.handler, &sema_args)?; +C07B crates/sema-llm/src/builtins.rs:1548:// `async/timeout` or a pool error-path) still runs to completion and decrements the +C07C crates/sema-llm/src/builtins.rs:1928: let result = sema_core::call_callback( +C07C crates/sema-llm/src/builtins.rs:1942: results.push(sema_core::call_callback( +C07C crates/sema-llm/src/builtins.rs:1954: let result = sema_core::call_callback( +C07C crates/sema-llm/src/builtins.rs:1972: if sema_core::call_callback(ctx, &plan.callback, std::slice::from_ref(&argument))? +C07B crates/sema-llm/src/builtins.rs:2196:/// `NativeOutcome::Call`, so an async op inside the thunk (`async/spawn`, +C07C crates/sema-llm/src/builtins.rs:2198:/// `call_callback` re-entry would suspend the runtime quantum and hit the +C07C crates/sema-llm/src/builtins.rs:2200:/// synchronous `call_callback` extent): a thunk that only builds a promise tears down +C07C crates/sema-llm/src/builtins.rs:2220: let result = sema_core::call_callback(ctx, &body_fn, &[]); +C07C crates/sema-llm/src/builtins.rs:2731: sema_core::call_callback(ctx, &complete_fn, &[request_map]) +C11 crates/sema-llm/src/builtins.rs:28:thread_local! { +C07C crates/sema-llm/src/builtins.rs:3988: sema_core::call_callback(ctx, cb, &[Value::string(chunk)]) +C07B crates/sema-llm/src/builtins.rs:4780: // is not `async/cancel`-addressable). The prelude `agent/run` / `llm/chat` +C07B crates/sema-llm/src/builtins.rs:4785: // `async/await` inside it) parks on the active task and resumes through the +C07C crates/sema-llm/src/builtins.rs:4925: let result = sema_core::call_callback(ctx, func, std::slice::from_ref(item))?; +C07C crates/sema-llm/src/builtins.rs:7007: let result = sema_core::call_callback(ctx, &body_fn, &[]); +C10 crates/sema-llm/src/builtins.rs:7588: std::thread::sleep(std::time::Duration::from_millis(ms)); +C07C crates/sema-llm/src/builtins.rs:7804: sema_core::call_callback(ctx, &callable, std::slice::from_ref(&argument)) +C11 crates/sema-llm/src/builtins.rs:79:/// so they satisfy the `'static` bound required by `thread_local!`. +C11 crates/sema-llm/src/builtins.rs:8201:thread_local! { +C07C crates/sema-llm/src/builtins.rs:8381: sema_core::call_callback(ctx, on_text, &[Value::string(chunk)]) +C07D crates/sema-llm/src/builtins.rs:9456:/// async tier drives futures with `block_on` (no task-abort). Lives on the runtime C07D crates/sema-llm/src/embeddings.rs:237: sema_io::io_block_on(self.embed_async(request)) C07D crates/sema-llm/src/embeddings.rs:247: Some(dialect) => sema_io::io_block_on(self.rerank_async(request, dialect)), C07D crates/sema-llm/src/embeddings.rs:392: sema_io::io_block_on(self.embed_async(request)) C07D crates/sema-llm/src/embeddings.rs:401: sema_io::io_block_on(self.rerank_async(request)) -C18 crates/sema-llm/src/fake.rs:504: std::thread::sleep(std::time::Duration::from_millis(self.chat_delay_ms)); -C18 crates/sema-llm/src/fake.rs:537: std::thread::sleep(std::time::Duration::from_millis(self.stream_chunk_delay_ms)); -C18 crates/sema-llm/src/fake.rs:580: std::thread::sleep(std::time::Duration::from_millis(self.embed_delay_ms)); -C18 crates/sema-llm/src/fake.rs:597: std::thread::sleep(std::time::Duration::from_millis(self.rerank_delay_ms)); +C18 crates/sema-llm/src/fake.rs:518: std::thread::sleep(std::time::Duration::from_millis(self.chat_delay_ms)); +C18 crates/sema-llm/src/fake.rs:551: std::thread::sleep(std::time::Duration::from_millis(self.stream_chunk_delay_ms)); +C18 crates/sema-llm/src/fake.rs:594: std::thread::sleep(std::time::Duration::from_millis(self.embed_delay_ms)); +C18 crates/sema-llm/src/fake.rs:611: std::thread::sleep(std::time::Duration::from_millis(self.rerank_delay_ms)); C07D crates/sema-llm/src/gemini.rs:476: sema_io::io_block_on(self.complete_async(request)) C07D crates/sema-llm/src/gemini.rs:492: // io_block_on drives ON THIS thread: `on_chunk` may touch non-Send Sema C07D crates/sema-llm/src/gemini.rs:494: sema_io::io_block_on(self.stream_complete_async(request, on_chunk)) @@ -504,15 +504,15 @@ C13 crates/sema-mcp/src/builtins.rs:1811: let payload = sema_io::io_b C13 crates/sema-mcp/src/builtins.rs:2292: sema_io::io_block_on(run_transport_task(async move { C13 crates/sema-mcp/src/builtins.rs:2308: sema_io::io_block_on(run_transport_task(async move { C13 crates/sema-mcp/src/builtins.rs:24://! the same transport futures through [`block_on`]. -C13 crates/sema-mcp/src/builtins.rs:2522: let result = block_on(list_tools_async(&mut conn)); -C13 crates/sema-mcp/src/builtins.rs:2644: let payload = sema_io::io_block_on(run_transport_task(async move { -C13 crates/sema-mcp/src/builtins.rs:2709:/// `io_block_on` would hit its active-quantum guard AND block the cooperative -C13 crates/sema-mcp/src/builtins.rs:2718: let _ = block_on(close_async(&mut conn)); -C13 crates/sema-mcp/src/builtins.rs:2723:/// where `io_block_on` is legal (no active runtime quantum on that thread). Kept -C13 crates/sema-mcp/src/builtins.rs:2725:/// the `io_block_on` lives on the worker, not the VM thread, so it must not sit -C13 crates/sema-mcp/src/builtins.rs:2731: let _ = sema_io::io_block_on(close_async(&mut conn)); -C13 crates/sema-mcp/src/builtins.rs:2764: let result = block_on(close_async(&mut conn)); -C13 crates/sema-mcp/src/builtins.rs:2888: let error = sema_io::io_block_on(crate::oauth::login::login( +C13 crates/sema-mcp/src/builtins.rs:2523: let result = block_on(list_tools_async(&mut conn)); +C13 crates/sema-mcp/src/builtins.rs:2645: let payload = sema_io::io_block_on(run_transport_task(async move { +C13 crates/sema-mcp/src/builtins.rs:2710:/// `io_block_on` would hit its active-quantum guard AND block the cooperative +C13 crates/sema-mcp/src/builtins.rs:2719: let _ = block_on(close_async(&mut conn)); +C13 crates/sema-mcp/src/builtins.rs:2724:/// where `io_block_on` is legal (no active runtime quantum on that thread). Kept +C13 crates/sema-mcp/src/builtins.rs:2726:/// the `io_block_on` lives on the worker, not the VM thread, so it must not sit +C13 crates/sema-mcp/src/builtins.rs:2732: let _ = sema_io::io_block_on(close_async(&mut conn)); +C13 crates/sema-mcp/src/builtins.rs:2765: let result = block_on(close_async(&mut conn)); +C13 crates/sema-mcp/src/builtins.rs:2889: let error = sema_io::io_block_on(crate::oauth::login::login( C13 crates/sema-mcp/src/builtins.rs:321:/// non-runtime) path. Routes through `sema_io::io_block_on`, the ADR C13 crates/sema-mcp/src/builtins.rs:326:/// offered both: "keep TOKIO_RT/block_on... or route through the sanctioned C13 crates/sema-mcp/src/builtins.rs:334:/// from inside `async/spawn` could not be driven on a private runtime). Routing every @@ -534,7 +534,7 @@ C13 crates/sema-mcp/src/client_auth.rs:96: rt.block_on(async { C13 crates/sema-mcp/src/oauth/flow.rs:276: let request = server.recv().expect("receive token request"); H07 crates/sema-mcp/src/oauth/loopback.rs:98: .recv_timeout(remaining.min(OPENER_ERROR_POLL)) C13 crates/sema-mcp/src/server.rs:27:/// synchronously and LLM builtins reach `sema_io::io_block_on`, which panics -H07 crates/sema-mcp/src/tools.rs:581: // runtime, the sole async engine, so async/await, channels, and timers work +H07 crates/sema-mcp/src/tools.rs:566: // runtime, the sole async engine, so async/await, channels, and timers work H06 crates/sema-notebook/src/bridge.rs:138: while let Some(req) = rt.block_on(rx.recv()) { H06 crates/sema-notebook/src/engine.rs:144: /// whichever cell this engine is currently driving (`async/sleep 60000` H06 crates/sema-notebook/src/engine.rs:627: (channel/send ch (list ch)) @@ -651,10 +651,10 @@ R22A crates/sema-stdlib/src/io.rs:3275: acc = sema_core::call R22A crates/sema-stdlib/src/io.rs:3333: acc = sema_core::call_callback_owned(ctx, &func, &mut cb_args)?; R08C crates/sema-stdlib/src/io.rs:85:thread_local! { R09A crates/sema-stdlib/src/kv.rs:127:thread_local! { -R22A crates/sema-stdlib/src/list.rs:3596:/// A runtime-only native (`async/spawn`, `channel/*`, `async/resolved`, …) can -R22A crates/sema-stdlib/src/list.rs:3637: sema_core::call_callback(ctx, func, args) -R22A crates/sema-stdlib/src/list.rs:3641: sema_core::with_stdlib_ctx(|ctx| sema_core::call_callback(ctx, func, args)) -R22A crates/sema-stdlib/src/list.rs:3650: sema_core::with_stdlib_ctx(|ctx| sema_core::call_callback_owned(ctx, func, args)) +R22A crates/sema-stdlib/src/list.rs:3598:/// A runtime-only native (`async/spawn`, `channel/*`, `async/resolved`, …) can +R22A crates/sema-stdlib/src/list.rs:3639: sema_core::call_callback(ctx, func, args) +R22A crates/sema-stdlib/src/list.rs:3643: sema_core::with_stdlib_ctx(|ctx| sema_core::call_callback(ctx, func, args)) +R22A crates/sema-stdlib/src/list.rs:3652: sema_core::with_stdlib_ctx(|ctx| sema_core::call_callback_owned(ctx, func, args)) R21A crates/sema-stdlib/src/markup.rs:42:thread_local! { R09C crates/sema-stdlib/src/memory.rs:158:thread_local! { R22A crates/sema-stdlib/src/meta.rs:116: // nonblocking-agent-run.md`) — so this real `thread::sleep` loop is @@ -680,7 +680,7 @@ F09B crates/sema-stdlib/src/runtime_offload.rs:363:/// running its future via [` F09B crates/sema-stdlib/src/runtime_offload.rs:371:/// async reactor (a synchronous library call under `io_block_on`) pick this; F09B crates/sema-stdlib/src/runtime_offload.rs:473: let out = sema_io::io_block_on(make_future()); F09B crates/sema-stdlib/src/runtime_offload.rs:666:// and cannot be reclaimed — best-effort, matching the retired `IoHandle` -R13A crates/sema-stdlib/src/secret.rs:48:thread_local! { +R13A crates/sema-stdlib/src/secret.rs:49:thread_local! { R14B crates/sema-stdlib/src/serial.rs:163:thread_local! { R14B crates/sema-stdlib/src/serial.rs:8://! VM thread for the operation's whole duration. Inside an `async/spawn`'d R15B crates/sema-stdlib/src/server.rs:1123: use sema_core::{call_callback, EvalContext, NativeFn}; @@ -744,14 +744,14 @@ R18C crates/sema-stdlib/src/system.rs:930: // VM thread — two `async/spawn` R18C crates/sema-stdlib/src/system.rs:931: // on the VM thread (unlike `async/sleep`, which is a virtual timer). Outside R18C crates/sema-stdlib/src/system.rs:944: std::thread::sleep(std::time::Duration::from_millis(ms as u64)); R19 crates/sema-stdlib/src/terminal.rs:84: /// bare `thread::sleep` frame loop, whose sleeping thread could only be -R23B crates/sema-stdlib/src/workflow.rs:249:/// `async/spawn`, an offloaded `llm/chat` tool loop, `channel/*`) parks on the active -R23B crates/sema-stdlib/src/workflow.rs:283: // The host arm resolves `:mcp` synchronously (its `io_block_on` is legal -R23B crates/sema-stdlib/src/workflow.rs:723: let _ = ack_rx.recv_timeout(HOST_FLUSH_ACK_TIMEOUT); -R23B crates/sema-stdlib/src/workflow.rs:737:// awaits the writer's ack on a blocking-tier worker (no `io_block_on`, no fs on the VM -R23B crates/sema-stdlib/src/workflow.rs:765: let _ = ack_rx.recv(); -R23B crates/sema-stdlib/src/workflow.rs:786: let _ = ack.recv_timeout(HOST_FLUSH_ACK_TIMEOUT); -R23B crates/sema-stdlib/src/workflow.rs:967: // `io_block_on`'s active-quantum guard, so offload it structurally — the blocking -R23B crates/sema-stdlib/src/workflow.rs:981: // Host arm (outside a runtime quantum): `io_block_on` is legal — resolve inline. +R23B crates/sema-stdlib/src/workflow.rs:1041: let _ = ack_rx.recv_timeout(HOST_FLUSH_ACK_TIMEOUT); +R23B crates/sema-stdlib/src/workflow.rs:1055:// awaits the writer's ack on a blocking-tier worker (no `io_block_on`, no fs on the VM +R23B crates/sema-stdlib/src/workflow.rs:1083: let _ = ack_rx.recv(); +R23B crates/sema-stdlib/src/workflow.rs:1104: let _ = ack.recv_timeout(HOST_FLUSH_ACK_TIMEOUT); +R23B crates/sema-stdlib/src/workflow.rs:1289: // `io_block_on`'s active-quantum guard, so offload it structurally — the blocking +R23B crates/sema-stdlib/src/workflow.rs:1305: // Host arm (outside a runtime quantum): `io_block_on` is legal — resolve inline. +R23B crates/sema-stdlib/src/workflow.rs:496:/// `async/spawn`, an offloaded `llm/chat` tool loop, `channel/*`) parks on the active +R23B crates/sema-stdlib/src/workflow.rs:530: // The host arm resolves `:mcp` synchronously (its `io_block_on` is legal R23B crates/sema-stdlib/src/workflow_mcp.rs:473: /// I/O through `io_block_on`, which rejects an active runtime quantum. Inside R23B crates/sema-stdlib/src/workflow_mcp.rs:480: /// synchronous [`resolve`](Self::resolve) would hit `io_block_on`'s R23B crates/sema-stdlib/src/workflow_mcp.rs:482: /// resolve off the VM thread (a plain worker where `io_block_on` is legal); @@ -888,32 +888,32 @@ H10A crates/sema-wasm/src/driver.rs:775:/// honor a pending `WaitKind::Timer` de H09 crates/sema-wasm/src/driver.rs:82:thread_local! { H09 crates/sema-wasm/src/lib.rs:132:thread_local! { H09 crates/sema-wasm/src/lib.rs:16:thread_local! { -H09 crates/sema-wasm/src/lib.rs:1930: /// with an `Error` on failure. The body runs once; `async/sleep` and -H09 crates/sema-wasm/src/lib.rs:1977: /// `http/get`/`async/sleep` inside it get the real, single-execution -H09 crates/sema-wasm/src/lib.rs:2767: sema_core::set_eval_callback(&ctx, sema_eval::eval_value_vm); -H09 crates/sema-wasm/src/lib.rs:2768: sema_core::set_call_callback(&ctx, sema_eval::call_value); -H09 crates/sema-wasm/src/lib.rs:2769: sema_core::set_call_owned_callback(&ctx, sema_eval::call_value_owned); -H10A crates/sema-wasm/src/lib.rs:3184: /// replays, and `http/get`/`async/sleep` resume the original root in place. +H09 crates/sema-wasm/src/lib.rs:1921: /// with an `Error` on failure. The body runs once; `async/sleep` and +H09 crates/sema-wasm/src/lib.rs:1968: /// `http/get`/`async/sleep` inside it get the real, single-execution +H09 crates/sema-wasm/src/lib.rs:2740: sema_core::set_eval_callback(&ctx, sema_eval::eval_value_vm); +H09 crates/sema-wasm/src/lib.rs:2741: sema_core::set_call_callback(&ctx, sema_eval::call_value); +H09 crates/sema-wasm/src/lib.rs:2742: sema_core::set_call_owned_callback(&ctx, sema_eval::call_value_owned); +H10A crates/sema-wasm/src/lib.rs:3157: /// replays, and `http/get`/`async/sleep` resume the original root in place. C12 crates/sema-workflow/src/context.rs:114:thread_local! { C12 crates/sema-workflow/src/journal.rs:150: let _ = ack.recv_timeout(HOST_FLUSH_TIMEOUT); C12 crates/sema-workflow/src/writer.rs:152: while let Ok(msg) = rx.recv() { C12 crates/sema-workflow/src/writer.rs:211:// `Condvar`, NEVER `thread::sleep`) before draining each message. Tests use it to hold a -H01 crates/sema/src/lib.rs:133: sema_core::set_eval_callback(&ctx, sema_eval::eval_value_vm); -H01 crates/sema/src/lib.rs:134: sema_core::set_call_callback(&ctx, sema_eval::call_value); -H01 crates/sema/src/lib.rs:135: sema_core::set_call_owned_callback(&ctx, sema_eval::call_value_owned); -H02 crates/sema/src/main.rs:1071: // runtime, llm/* builtins hit io_block_on's runtime-in-runtime +H01 crates/sema/src/lib.rs:134: sema_core::set_eval_callback(&ctx, sema_eval::eval_value_vm); +H01 crates/sema/src/lib.rs:135: sema_core::set_call_callback(&ctx, sema_eval::call_value); +H01 crates/sema/src/lib.rs:136: sema_core::set_call_owned_callback(&ctx, sema_eval::call_value_owned); +H02 crates/sema/src/main.rs:1002: .block_on(sema_lsp::run_server()); +H02 crates/sema/src/main.rs:1009: .block_on(sema_dap::run_server()); H02 crates/sema/src/main.rs:107:thread_local! { -H02 crates/sema/src/main.rs:1308: .block_on(workflow_view::serve(PathBuf::from(run_dir), &host, port)); -H02 crates/sema/src/main.rs:1418: .block_on(async { -H02 crates/sema/src/main.rs:1435: std::thread::sleep(std::time::Duration::from_millis(250)); -H02 crates/sema/src/main.rs:1505: std::thread::sleep(std::time::Duration::from_secs(3600)); -H02 crates/sema/src/main.rs:1610: .block_on(sema_notebook::serve(path, &host, port)); -H02 crates/sema/src/main.rs:2156: // Same no-ambient-runtime rule as the CLI mcp arm (llm/* + io_block_on). -H02 crates/sema/src/main.rs:3596: // runtime, the sole async engine, so async/await, channels, and timers work -H02 crates/sema/src/main.rs:989: .block_on(sema_lsp::run_server()); -H02 crates/sema/src/main.rs:996: .block_on(sema_dap::run_server()); -H02 crates/sema/src/pkg.rs:1539: std::thread::sleep(jittered(wait)); -H02 crates/sema/src/pkg.rs:1552: std::thread::sleep(jittered(backoff)); +H02 crates/sema/src/main.rs:1084: // runtime, llm/* builtins hit io_block_on's runtime-in-runtime +H02 crates/sema/src/main.rs:1323: .block_on(workflow_view::serve(PathBuf::from(run_dir), &host, port)); +H02 crates/sema/src/main.rs:1461: .block_on(async { +H02 crates/sema/src/main.rs:1478: std::thread::sleep(std::time::Duration::from_millis(250)); +H02 crates/sema/src/main.rs:1548: std::thread::sleep(std::time::Duration::from_secs(3600)); +H02 crates/sema/src/main.rs:1653: .block_on(sema_notebook::serve(path, &host, port)); +H02 crates/sema/src/main.rs:2203: // Same no-ambient-runtime rule as the CLI mcp arm (llm/* + io_block_on). +H02 crates/sema/src/main.rs:3643: // runtime, the sole async engine, so async/await, channels, and timers work +H02 crates/sema/src/pkg.rs:1637: std::thread::sleep(jittered(wait)); +H02 crates/sema/src/pkg.rs:1650: std::thread::sleep(jittered(backoff)); H03 crates/sema/src/repl/completer.rs:16:thread_local! { H08 crates/sema/src/web/mod.rs:154: std::thread::sleep(std::time::Duration::from_millis(100)); H13 crates/sema/src/web/runtime.rs:414: "installAtomicsSleep", diff --git a/examples/workflows/policy.sema b/examples/workflows/policy.sema new file mode 100644 index 000000000..e57b34ae9 --- /dev/null +++ b/examples/workflows/policy.sema @@ -0,0 +1,60 @@ +;; Least-privilege workflow policy example. +;; +;; The names in :tools :allow must match ToolDefinitions passed to an agent. +;; Replace the example model identities with providers/models configured in your +;; environment before running this workflow. + +(deftool read-file + "Read one repository file." + {:path {:type :string :description "Workspace-relative path"}} + (lambda (path) (file/read path))) + +(deftool fetch-url + "Fetch one HTTPS URL." + {:url {:type :string :description "Absolute HTTPS URL"}} + (lambda (url) (http/get url))) + +(deftool run-command + "Run one exact allowlisted command." + {:command {:type :string :description "Exact command string"}} + (lambda (command) (shell command))) + +(defpolicy repository-auditor + {:models + {:default :deny + :allow ["openai/gpt-5" "anthropic/*"] + :on-deny :fail} + + :tools + {:default :deny + :allow + {"read-file" + {:paths ["Cargo.toml" "src/**" "crates/**"]} + + "fetch-url" + {:domains {:allow ["api.example.com" "*.example.com"] + :schemes ["https"] + :ports [443]}} + + "run-command" + {:commands ["cargo check" "cargo test"]}} + :deny ["delete-file"] + :on-deny :tool-error}}) + +(defworkflow policy-demo + "Demonstrate model/tool policy composition." + {:phases ["Audit"] + :permissions "no-fs-write" + :policy repository-auditor} + + (phase "Audit") + (def report + (step "Inspect this repository without modifying it." + {:name "auditor" + :tools [read-file fetch-url run-command] + ;; A step policy can only tighten the workflow policy. + :policy {:tools + {:default :deny + :allow + {"read-file" {:paths ["src/**" "crates/**"]}}}}})) + {:status :success :report report}) diff --git a/packages/sema-web/tests/resource-sema.test.ts b/packages/sema-web/tests/resource-sema.test.ts index a5857ef0c..a88c76120 100644 --- a/packages/sema-web/tests/resource-sema.test.ts +++ b/packages/sema-web/tests/resource-sema.test.ts @@ -234,6 +234,6 @@ describeWithWasm("resource bindings under a real interpreter", () => { const screen = await boot('(def user (resource "user" (fn (previous) "/api/user")))'); await screen.flush(); - expect(screen.run("(:error @user)")).toMatch(/expects 1 args, got 0/); + expect(screen.run("(:error @user)")).toMatch(/expects 1 argument, got 0/); }); }); diff --git a/website/docs/llm/workflows.md b/website/docs/llm/workflows.md index fef3d6839..571213b11 100644 --- a/website/docs/llm/workflows.md +++ b/website/docs/llm/workflows.md @@ -47,6 +47,7 @@ The `meta-map` supports: | `:phases` | `[:string …]` | Declared phase plan — the dashboard shows all phases up front | | `:budget` | `{:tokens N :usd M}` | Spend caps (see [Budget Enforcement](#budget-enforcement)) | | `:permissions` | string | Sandbox restrictions for `sema workflow run`, using the same syntax as `--sandbox` | +| `:policy` | policy | Model/tool allowlist for this workflow (see [Model and tool policies](#model-and-tool-policies)) | | `:args` | map | Argument schema (informational; the actual args come from `--args`) | The body is ordinary Sema code. `phase` markers interleave with `def`, @@ -109,6 +110,7 @@ The opts map supports: | `:schema` | schema spec | Typed extraction — the step returns a validated map, not text | | `:tools` | `[tool …]` | Tool-calling loop — the step runs `llm/chat` with tool dispatch | | `:agent` | `defagent` | Run a configured `defagent` as this step via `agent/run` | +| `:policy` | policy | Additional restrictions for this step; it cannot loosen the workflow policy | When `:agent` is present, the defagent owns its own tools and model — inline `:tools`/`:model` are ignored (the static checker warns if both are given). @@ -184,9 +186,9 @@ Every `sema workflow run` creates a run directory under `.sema/runs//`: ### Event vocabulary -The event vocabulary is **frozen** — add fields (append-only, all -`Option`/skippable) but never change existing ones. Old runs stay readable -forever. +Existing event shapes are **frozen** — add fields only as append-only, +optional/skippable fields, and add new event kinds without changing old ones. +Old runs stay readable forever. | Event | Key fields | Description | |-------|-----------|-------------| @@ -196,6 +198,9 @@ forever. | `agent.started` | `agent_id`, `agent_name`, `model` | An agent leaf began | | `agent.result` | `agent_id`, `status`, `output`, `dur_ms`, `model` | An agent leaf produced a result | | `agent.tool_call` | `agent_id`, `tool_name`, `args_json` | An agent invoked a tool | +| `policy.checked` | `policy`, `boundary`, `subject`, `rule`, `source` | A policy layer allowed a protected boundary | +| `policy.violation` | `policy`, `boundary`, `subject`, `rule`, `action`, `source` | A policy layer denied a protected boundary | +| `policy.bypassed` | `policy`, `boundary`, `subject`, `reason`, `source` | A lexical `policy/without` scope bypassed a protected boundary | | `checkpoint` | `key`, `content_key`, `value_digest`, `value` | A checkpoint was recorded | | `budget` | `agent_id`, `input_tokens`, `output_tokens`, `cost_usd`, `budget_limit` | A per-leaf budget observation | | `run.ended` | `status`, `reason`, `dur_ms` | Last line of every run | @@ -213,9 +218,11 @@ memoized leaves — they replay for free. ### How content keys work Each step leaf's content key is a hash of `(kind, code-version, args, phase, -step-name, prompt, schema)`. Checkpoints use `(kind, code-version, args, -phase, key)`. Same inputs → same key → memo hit → no re-call. An occurrence -ordinal distinguishes identical repeats in source order. +step-name, prompt, schema, effective-policy)`. Checkpoints use `(kind, +code-version, args, phase, key)`. Same inputs → same key → memo hit → no +re-call. An occurrence ordinal distinguishes identical repeats in source +order. Tightening or otherwise changing the effective step policy invalidates +that step's memo. ### Automatic invalidation @@ -311,11 +318,153 @@ denial list. Workflow permissions can only remove capabilities from the caller's sandbox; they cannot loosen a stricter `--sandbox` or `--allowed-paths` setting. +## Model and tool policies + +Policies constrain the resolved model and model-requested tool calls inside a +workflow. Define one with `defpolicy`, then attach it to a workflow: + +```sema +(defpolicy safe-agent + {:models + {:default :deny + :allow ["openai/gpt-5" "anthropic/*"] + :deny ["anthropic/deprecated-model"] + :on-deny :fail} + + :tools + {:default :deny + :allow + {"read-file" {:paths ["src/**" "Cargo.toml"]} + "fetch-url" {:domains {:allow ["api.example.com" "*.example.com"] + :schemes ["https"] + :ports [443]}} + "run-command" {:commands ["cargo test" "cargo check"]}} + :deny ["delete-file"] + :on-deny :tool-error}}) + +(defworkflow guarded-audit + "Audit with a least-privilege model and tool envelope." + {:phases ["Audit"] + :permissions "no-fs-write" + :policy safe-agent} + + (phase "Audit") + (def result + (step "Inspect the Rust sources." + {:name "auditor" + :tools [read-file fetch-url run-command]})) + {:status :success :result result}) +``` + +Model rules use an exact `provider/model` identity. The only wildcard form is +`provider/*`; provider wildcards and partial model globs are rejected. Deny +rules win over allow rules. When `:models` or `:tools` is present, +`:default` defaults to `:deny`. + +Tool allow entries may be unconstrained (`{}`) or constrain named JSON +arguments: + +| Constraint | Shorthand argument | Match | +|------------|--------------------|-------| +| `:paths` | `"path"` | Workspace-relative literal, `*`, and `**` patterns; absolute paths and root/symlink escapes are denied | +| `:domains` | `"url"` | Parsed HTTP(S) URLs matched by normalized hostname, scheme, and optional effective port | +| `:commands` | `"command"` | Exact command strings only; no wildcard or shell-prefix matching | + +A leading `*.` matches subdomains only, so list both `"example.com"` and +`"*.example.com"` when both the apex and its subdomains are allowed. URLs +containing credentials are always denied. + +Use explicit selectors when a tool uses different argument names or has +multiple path-like arguments: + +```sema +{:tools + {:allow + {"copy-file" + {:paths [{:arg :source :allow ["src/**"]} + {:arg :destination + :allow ["generated/**"] + :deny ["generated/private/**"]}]}}}} +``` + +### Composition and denial behavior + +A step policy is combined with the workflow policy using logical AND: every +active layer must allow the boundary. A step may tighten its workflow but +cannot loosen it. The strictest denial action across active layers wins. + +| Boundary | `:on-deny` | Behavior | +|----------|------------|----------| +| Model | `:fail` (default) | Fail before cache, cassette, callback, or provider access | +| Model | `:skip` | Skip a denied fallback target; a non-fallback call still fails | +| Tool | `:fail` (default) | Preflight the whole requested batch and run none when any call is denied | +| Tool | `:tool-error` | In an agent loop, return a correlated tool error for the denied call while allowed siblings run | + +`:fail` raises a `:policy-denied` condition. Its message names the policy, +boundary, and denied subject. Catch the condition when code needs the exact +decision: + +```sema +(try + (llm/complete "Review this change.") + (catch denial + {:type (:type denial) ; :policy-denied + :policy (:policy denial) ; "safe-agent" + :boundary (:boundary denial) ; "model" + :subject (:subject denial) + :rule (:rule denial) + :reason (:reason denial) + :action (:action denial) ; :fail + :source (:source denial)})) ; :request, :cache, or :cassette +``` + +For example, a denied model reports: +`Policy 'safe-agent' denied model 'openai/unlisted': model openai/unlisted is not allowlisted`. +Tool errors returned to a model contain only the tool name and safe denial +reason. They do not include an extra CLI `Error:` prefix. + +The model gate covers completion, chat, extraction, classification, streaming, +fallbacks, embeddings, and reranking. The tool gate covers `ToolDefinition` +dispatch through agents and direct `tool/invoke`, including tools discovered +through MCP. Checks happen before cache/cassette replay and before user +callbacks, schema predicates, or tool handlers. Cache/cassette keys and resume +keys include the effective policy fingerprint; replay also rechecks the stored +provider identity. + +`policy.checked`, `policy.violation`, and `policy.bypassed` events identify the +policy, boundary, matched rule, enforcement action, and whether the source was +a request, cache, or cassette. Tool arguments are represented only by a digest in +policy events; raw paths, URLs, and commands are not recorded there. + +### Trusted lexical bypass + +Trusted workflow code can bypass model/tool policy for a narrow lexical scope: + +```sema +(policy/without "read the legacy migration fixture" + (step "Inspect the fixture." {:tools [read-file]})) +``` + +The reason must be a non-empty literal string of at most 256 characters. The +bypass is task-local, applies only to its body, and emits `policy.bypassed` for +each protected boundary. It never bypasses the outer sandbox. + +Policies govern LLM/model boundaries and model-invoked tools. Ordinary author +code such as direct filesystem, shell, HTTP, or raw MCP calls remains governed +by `:permissions`, the CLI sandbox, and allowed-path settings. Keep both: +policy controls what the model may choose; the sandbox remains the hard outer +capability ceiling. + ## `sema workflow check` Statically validate a workflow file **without evaluating it or calling any -LLM**. Catches arity traps, bad options, and layout issues before you spend a -token. +LLM**. Catches arity traps, bad options, invalid literal policy maps, +`defpolicy` shape errors, unsafe matcher syntax, and invalid +`policy/without` reasons before you spend a token. + +Policy diagnostics identify the invalid field or one-based list entry. Unknown +keys include a suggested replacement when one is close, or list the valid keys. +Invalid enum values list the accepted Sema keywords. ```bash $ sema workflow check audit.sema