diff --git a/Justfile b/Justfile index a90eb3c..3eb5287 100644 --- a/Justfile +++ b/Justfile @@ -32,6 +32,11 @@ test: build test-rust: bun run test:rust +# Mint a new λδ plugin package skeleton (issue #33 — the minter). Extra minter +# flags (--tier, --caps, --description): bun scripts/ld-mint.js --help +ld-new name: + bun scripts/ld-mint.js {{name}} + # Run the development server (http://localhost:5173) run: bun run dev diff --git a/core/src/lambdadelta/capability.rs b/core/src/lambdadelta/capability.rs new file mode 100644 index 0000000..fae4c87 --- /dev/null +++ b/core/src/lambdadelta/capability.rs @@ -0,0 +1,232 @@ +// SPDX-License-Identifier: MPL-2.0 +//! The λδ **capability model** — issue #33, foundation 1/2 (spec §7.1). +//! +//! A capability is an unforgeable-by-sandboxing permission token: a plugin +//! declares the capabilities it *requests* in its manifest +//! ([`crate::lambdadelta::package`]), the provisioner computes the grants the +//! user actually *allowed* ([`crate::lambdadelta::provisioner`]), and the host +//! *enforces* them by gating every notebook builtin at registration time +//! ([`crate::lambdadelta_host::register_gated`]). Because enforcement happens +//! in native code before a builtin executes, no λδ code can escape it — the +//! sandbox contract (spec §6) extends from bounded computation to bounded +//! *effect*: nothing runs with capabilities the user hasn't granted. +//! +//! The catalogue is deliberately small at the foundation — whole-notebook +//! scopes — but is named so it can refine later (path/attribute patterns like +//! `:notes/read {:titles "Journal *"}`) without breaking existing manifests. + +use std::collections::BTreeSet; +use std::fmt; +use std::rc::Rc; + +use super::error::{LdError, LdResult}; + +/// A permission over host-provided effects. Kept `Copy`-small: enforcement is +/// on the hot path of every host builtin call. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Capability { + /// Read the notebook: `notes`, `note`, `title`, `content`, `attrs`, + /// `links`, `backlinks`, `position`, `attr`, `search`, `resolve-title`, + /// `agents`, and every future pure reader a host registers. + /// Manifest keyword: `:notes/read`. + NotesRead, + /// Mutate the notebook: `create-note!`, `set-title!`, `set-content!`, + /// `set-attr!`, `remove-attr!`, `move-note!`, `resize-note!`, `link!`, + /// `unlink!`, `delete-note!`, and every future `!`-suffixed mutator. + /// Manifest keyword: `:notes/write`. + NotesWrite, + /// Run stored agents: `run-agent`. Since an agent's predicate is read code + /// evaluated over the notebook, holding this capability implies + /// [`Capability::NotesRead`] (see [`CapabilitySet::allows`]). + /// Manifest keyword: `:agents/run`. + AgentsRun, +} + +impl Capability { + /// Every capability the current catalogue defines. A manifest requesting + /// anything outside this list is rejected — the host cannot grant what it + /// does not know how to enforce. + pub const ALL: [Capability; 3] = [ + Capability::NotesRead, + Capability::NotesWrite, + Capability::AgentsRun, + ]; + + /// The canonical manifest keyword (`:notes/read`, …). + pub fn keyword(self) -> &'static str { + match self { + Capability::NotesRead => ":notes/read", + Capability::NotesWrite => ":notes/write", + Capability::AgentsRun => ":agents/run", + } + } + + /// Parse a capability keyword, with or without the leading colon + /// (`:notes/read` ≡ `notes/read`). Returns `None` for unknown keywords. + pub fn from_keyword(kw: &str) -> Option { + let kw = kw.strip_prefix(':').unwrap_or(kw); + match kw { + "notes/read" => Some(Capability::NotesRead), + "notes/write" => Some(Capability::NotesWrite), + "agents/run" => Some(Capability::AgentsRun), + _ => None, + } + } +} + +impl fmt::Display for Capability { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.keyword()) + } +} + +/// A set of granted capabilities. Cloning is cheap when shared as +/// `Rc` — every gated host builtin holds one `Rc`. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct CapabilitySet { + granted: BTreeSet, +} + +impl CapabilitySet { + /// No grants at all: the plugin gets pure λδ and nothing else. + pub fn none() -> Self { + CapabilitySet::default() + } + + /// Every capability in the catalogue — the grant a fully-trusted (e.g. + /// first-party, `teranga`-tier) package receives. + pub fn all() -> Self { + CapabilitySet { + granted: Capability::ALL.into_iter().collect(), + } + } + + /// Grants built from manifest keywords. Unknown keywords yield + /// [`LdError::User`] with a hint — surfaced to the provisioner as a + /// manifest defect, never silently dropped (silently dropping a requested + /// capability would make a plugin under-powered *and* under-honest about + /// it: the manifest must reflect what the code needs). + pub fn from_keywords(kws: I) -> LdResult + where + I: IntoIterator, + S: AsRef, + { + let mut set = CapabilitySet::none(); + for kw in kws { + let kw = kw.as_ref(); + match Capability::from_keyword(kw) { + Some(cap) => { + set.grant(cap); + } + None => { + return Err(LdError::User(format!( + "unknown capability {kw:?} — catalogue: {}", + Capability::ALL + .iter() + .map(|c| c.keyword()) + .collect::>() + .join(" ") + ))); + } + } + } + Ok(set) + } + + /// Add a grant. + pub fn grant(&mut self, cap: Capability) { + self.granted.insert(cap); + } + + /// Is `cap` covered by these grants? `AgentsRun` implies `NotesRead` + /// (running an agent evaluates read code over the notebook), so a grant of + /// `:agents/run` alone satisfies a `:notes/read` requirement. + pub fn allows(&self, cap: &Capability) -> bool { + if self.granted.contains(cap) { + return true; + } + match cap { + Capability::NotesRead => self.granted.contains(&Capability::AgentsRun), + _ => false, + } + } + + /// Everything in `other` that this set does *not* allow — the list a + /// provisioner presents to the user as "this package asks for more than + /// you granted". + pub fn missing(&self, other: &CapabilitySet) -> Vec { + other + .granted + .iter() + .filter(|cap| !self.allows(cap)) + .copied() + .collect() + } + + /// Enforce: succeed iff `cap` is allowed, else an [`LdError::Capability`] + /// naming the required grant. This is the single choke point every gated + /// host builtin calls before touching the notebook. + pub fn require(&self, cap: Capability) -> LdResult<()> { + if self.allows(&cap) { + Ok(()) + } else { + Err(LdError::Capability(format!( + "this package was not granted {cap}; the manifest must request it and the user must allow it" + ))) + } + } + + /// Iterate the granted capabilities (sorted — deterministic reporting). + pub fn iter(&self) -> impl Iterator { + self.granted.iter() + } +} + +/// Share a grant set across every gated builtin of one sandbox. +pub fn shared(set: CapabilitySet) -> Rc { + Rc::new(set) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn keyword_roundtrip() { + for cap in Capability::ALL { + assert_eq!(Capability::from_keyword(cap.keyword()), Some(cap)); + } + assert_eq!( + Capability::from_keyword("notes/read"), + Some(Capability::NotesRead) + ); + assert_eq!(Capability::from_keyword(":bogus/read"), None); + } + + #[test] + fn none_allows_nothing() { + let set = CapabilitySet::none(); + assert!(!set.allows(&Capability::NotesRead)); + assert!(set.require(Capability::NotesRead).is_err()); + } + + #[test] + fn agents_run_implies_notes_read() { + let set = CapabilitySet::from_keywords([":agents/run"]).unwrap(); + assert!(set.allows(&Capability::NotesRead)); + assert!(!set.allows(&Capability::NotesWrite)); + } + + #[test] + fn missing_reports_exactly_the_delta() { + let granted = CapabilitySet::from_keywords([":notes/read"]).unwrap(); + let wanted = CapabilitySet::from_keywords([":notes/read", ":notes/write"]).unwrap(); + assert_eq!(granted.missing(&wanted), vec![Capability::NotesWrite]); + } + + #[test] + fn unknown_keyword_is_a_manifest_error_not_a_silent_drop() { + let err = CapabilitySet::from_keywords([":disk/write"]).unwrap_err(); + assert!(format!("{err}").contains("unknown capability")); + } +} diff --git a/core/src/lambdadelta/error.rs b/core/src/lambdadelta/error.rs index bcfd7fc..ffd27e8 100644 --- a/core/src/lambdadelta/error.rs +++ b/core/src/lambdadelta/error.rs @@ -53,6 +53,12 @@ pub enum LdError { #[error("budget exceeded: {0}")] Budget(String), + /// Sandbox code attempted an effect its granted capabilities do not cover + /// (issue #33, spec §7.1: nothing runs with capabilities the user hasn't + /// granted). + #[error("capability denied: {0}")] + Capability(String), + /// An error raised deliberately from λδ code. #[error("{0}")] User(String), diff --git a/core/src/lambdadelta/harness.rs b/core/src/lambdadelta/harness.rs new file mode 100644 index 0000000..e8942e1 --- /dev/null +++ b/core/src/lambdadelta/harness.rs @@ -0,0 +1,177 @@ +// SPDX-License-Identifier: MPL-2.0 +//! The λδ **harness** — issue #33. The sandboxed develop/test environment a +//! plugin author iterates in: run the package against a fixture notebook, with +//! the evaluator's budget *and* capability enforcement active, so "works on my +//! machine" means "works inside the sandbox it will ship in". CI verifies +//! plugins with the same harness (no second implementation). +//! +//! Design notes +//! ──────────── +//! * The harness is registered by *closure*: it builds an [`Interp`] and hands +//! it to a caller-supplied registrar, so the kernel never depends on any +//! host. A notebook host passes +//! `|i| lambdadelta_host::register_gated(i, nb, grants)`; a pure-language +//! package passes nothing ([`Harness::pure`]). +//! * Assertions are **recorded, not thrown**: `assert-eq` inside a `.ld` test +//! file appends to a report instead of aborting at the first failure, so an +//! author sees the whole damage, and a reader/evaluator error in the test +//! file becomes a failed assertion rather than a panic (sandbox contract, +//! spec §6: failures are structured values, never panics). + +use std::cell::RefCell; +use std::rc::Rc; + +use super::error::LdResult; +use super::value::Value; +use super::{Budget, Interp}; + +/// One recorded assertion. Strings (not values) keep the report easy to print, +/// diff, and serialise for CI. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Assertion { + pub ok: bool, + pub want: String, + pub got: String, +} + +/// A full test run: every assertion, in order, plus tallies. +#[derive(Clone, Debug)] +pub struct HarnessReport { + pub assertions: Vec, + pub passed: usize, + pub failed: usize, +} + +impl HarnessReport { + /// Every assertion passed (including vacuously — a test file with no + /// assertions is trivially green; the minter's skeleton always ships one). + pub fn is_green(&self) -> bool { + self.failed == 0 + } +} + +/// A sandboxed λδ test environment for one package. +pub struct Harness { + interp: Interp, + budget: Budget, + assertions: Rc>>, +} + +impl Harness { + /// Build a sandbox whose host surface is whatever `register` installs + /// (typically [`crate::lambdadelta_host::register_gated`] with the granted + /// capabilities from the install plan). The assertion builtins + /// (`assert-eq`, `assert`) are always installed on top; they record into + /// the shared buffer that [`Harness::report`] reads. + pub fn new(register: impl FnOnce(&mut Interp)) -> Self { + let mut interp = Interp::new(); + register(&mut interp); + let assertions: Rc>> = Rc::new(RefCell::new(Vec::new())); + + let rec = assertions.clone(); + interp.register_builtin("assert-eq", 2, Some(2), move |_i, a| { + let want = a[0].to_string(); + let got = a[1].to_string(); + let ok = want == got; + rec.borrow_mut().push(Assertion { ok, want, got }); + Ok(Value::Bool(ok)) + }); + + let rec = assertions.clone(); + interp.register_builtin("assert", 1, Some(1), move |_i, a| { + let ok = a[0].is_truthy(); + rec.borrow_mut().push(Assertion { + ok, + want: "truthy".to_string(), + got: a[0].to_string(), + }); + Ok(Value::Bool(ok)) + }); + + Harness { + interp, + budget: Budget::new(), + assertions, + } + } + + /// A kernel-only harness: pure λδ, no host builtins at all. Right for + /// packages that compute (no notebook effects) and for testing the + /// kernel-facing parts of effectful packages. + pub fn pure() -> Self { + Harness::new(|_| {}) + } + + /// Override the default budget (1M steps / depth 512), e.g. to give a + /// community-tier package a deliberately tight leash in CI. + pub fn with_budget(mut self, budget: Budget) -> Self { + self.budget = budget; + self + } + + /// Direct access to the sandbox (e.g. for the wizard's REPL later). + pub fn interp(&mut self) -> &mut Interp { + &mut self.interp + } + + /// Load package *source* (definitions). Errors are returned: code that + /// cannot even be read/defined is not a test failure, it is a broken + /// package — the author should fix the file, not read a red assertion. + pub fn load_source(&mut self, _label: &str, src: &str) -> LdResult<()> { + self.interp.eval_str(src, self.budget).map(|_| ()) + } + + /// Run a `.ld` *test* file. Never returns `Err`: assertions recorded by + /// `assert-eq`/`assert`, and a read/eval error mid-file becomes one failed + /// assertion (reporting the error value) so the report stays complete and + /// nothing panics across the WASM boundary. + pub fn run_tests(&mut self, label: &str, src: &str) { + match self.interp.eval_str(src, self.budget) { + Ok(_) => {} + Err(e) => self.assertions.borrow_mut().push(Assertion { + ok: false, + want: format!("{label} evaluates to completion"), + got: format!("{e}"), + }), + } + } + + /// Tally everything recorded so far. + pub fn report(&self) -> HarnessReport { + let assertions = self.assertions.borrow(); + let passed = assertions.iter().filter(|a| a.ok).count(); + HarnessReport { + failed: assertions.len() - passed, + passed, + assertions: assertions.clone(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pure_harness_records_assertions() { + let mut h = Harness::pure(); + h.load_source("defs", "(def double (fn [x] (* 2 x)))") + .unwrap(); + h.run_tests( + "tests", + "(assert-eq 4 (double 2)) (assert-eq 5 (double 2)) (assert true)", + ); + let r = h.report(); + assert_eq!((r.passed, r.failed), (2, 1)); + assert!(!r.is_green()); + } + + #[test] + fn evaluation_errors_become_failures_not_panics() { + let mut h = Harness::pure(); + h.run_tests("bad", "(this-symbol/is-not-bound 1)"); + let r = h.report(); + assert_eq!(r.failed, 1); + assert!(r.assertions[0].got.contains("unbound")); + } +} diff --git a/core/src/lambdadelta/mod.rs b/core/src/lambdadelta/mod.rs index 5047340..211e43b 100644 --- a/core/src/lambdadelta/mod.rs +++ b/core/src/lambdadelta/mod.rs @@ -22,15 +22,32 @@ //! for the core special forms + budget + pure builtins. Hygienic macros, //! multimethods, and the notebook host bindings layer on top without changing //! the seam. +//! +//! # Plugin ecosystem (issue #33) +//! +//! Four sibling modules carry the plugin/SDK foundation on top of the seam: +//! [`capability`] (what a package may do — declared, granted, enforced), +//! [`package`] (the homoiconic manifest format), [`provisioner`] (pure +//! install-plan validation: nothing runs with un-granted capabilities), and +//! [`harness`] (the sandboxed author/test environment). The minter lives in +//! `scripts/ld-mint.js`; the design is `docs/design/lambdadelta-plugin-system.adoc`. mod builtins; +pub mod capability; mod error; mod eval; +pub mod harness; +pub mod package; mod prelude; +pub mod provisioner; mod reader; mod value; +pub use capability::{Capability, CapabilitySet}; pub use error::{LdError, LdResult}; +pub use harness::{Assertion, Harness, HarnessReport}; +pub use package::{ManifestError, PackageManifest, Tier}; +pub use provisioner::{plan_install, InstallPlan, ProvisionError}; pub use reader::{read_all, read_one}; pub use value::{Builtin, BuiltinImpl, Closure, Env, Scope, Value}; diff --git a/core/src/lambdadelta/package.rs b/core/src/lambdadelta/package.rs new file mode 100644 index 0000000..a37eea9 --- /dev/null +++ b/core/src/lambdadelta/package.rs @@ -0,0 +1,580 @@ +// SPDX-License-Identifier: MPL-2.0 +//! The λδ **package format** — issue #33, foundation 2/2. +//! +//! A λδ package (conventionally a `/` directory under `plugins/`, shared +//! as a `.ldpkg` bundle) is: +//! +//! ```text +//! word-count/ +//! ├── manifest.ld ; this file's format — a single λδ map (code is data) +//! ├── src/main.ld ; the entry point named by :entry-point +//! ├── test/main.test.ld ; harness tests named by :tests +//! └── README.adoc ; human docs (Asciidoc, estate standard) +//! ``` +//! +//! The manifest is *homoiconic*: it is a λδ map literal parsed by the ordinary +//! λδ reader — no second parser, no schema language, no dependency. Field +//! names deliberately mirror the BoJ `cartridge.json` conventions +//! (`name`/`version`/`spdx`/`tier`/`description`) and the PanLL +//! minter/provisioner contracts, so mapping between ecosystems is mechanical +//! (see `docs/design/lambdadelta-plugin-system.adoc`). +//! +//! ```clojure +//! {:name "word-count" +//! :version "0.1.0" +//! :spdx "MPL-2.0" +//! :tier :ayo ; :teranga core | :shield elevated-trust | :ayo community +//! :description "Counts words per note and writes the :word-count attribute" +//! :entry-point "src/main.ld" +//! :capabilities [:notes/read :notes/write] ; requested grants — see capability.rs +//! :config {:min-words {:type :int :default 0 :doc "ignore shorter notes"}} +//! :tests ["test/main.test.ld"]} +//! ``` + +use std::fmt; + +use thiserror::Error; + +use super::capability::{Capability, CapabilitySet}; +use super::reader::read_one; +use super::value::Value; + +/// Trust tier, mirroring `panll/src/abi/cartridge-schema.json` so ecosystem +/// tooling understands λδ packages without new vocabulary: +/// `Teranga` = core, always available · `Shield` = security-critical, elevated +/// trust · `Ayo` = community-contributed (the default for minted packages). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)] +pub enum Tier { + Teranga, + Shield, + #[default] + Ayo, +} + +impl Tier { + pub fn as_str(self) -> &'static str { + match self { + Tier::Teranga => "teranga", + Tier::Shield => "shield", + Tier::Ayo => "ayo", + } + } + + fn from_name(name: &str) -> Result { + match name { + "teranga" => Ok(Tier::Teranga), + "shield" => Ok(Tier::Shield), + "ayo" => Ok(Tier::Ayo), + other => Err(ManifestError::Field { + field: ":tier".into(), + msg: format!("unknown tier {other:?} — one of :teranga :shield :ayo"), + }), + } + } +} + +impl fmt::Display for Tier { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// The declared type of one configuration setting (the configurator's schema). +#[derive(Clone, Debug, PartialEq)] +pub enum ConfigType { + String, + Int, + Float, + Bool, + Keyword, + /// A keyword from a fixed set of allowed names: `{:type :enum :values [...]}`. + Enum(Vec), +} + +impl ConfigType { + fn name(&self) -> String { + match self { + ConfigType::String => ":string".into(), + ConfigType::Int => ":int".into(), + ConfigType::Float => ":float".into(), + ConfigType::Bool => ":bool".into(), + ConfigType::Keyword => ":keyword".into(), + ConfigType::Enum(allowed) => format!("(:enum {})", allowed.join(" ")), + } + } + + fn check(&self, key: &str, v: &Value) -> Result<(), ManifestError> { + let ok = match (self, v) { + (ConfigType::String, Value::Str(_)) => true, + (ConfigType::Int, Value::Int(_)) => true, + (ConfigType::Float, Value::Float(_)) => true, + (ConfigType::Bool, Value::Bool(_)) => true, + (ConfigType::Keyword, Value::Keyword(_)) => true, + (ConfigType::Enum(allowed), Value::Keyword(k)) => { + allowed.iter().any(|a| a == k.as_ref()) + } + _ => false, + }; + if ok { + Ok(()) + } else { + Err(ManifestError::Field { + field: format!(":config :{key}"), + msg: format!("value {v} does not match declared type {}", self.name()), + }) + } + } +} + +/// One declared configuration setting: type, optional default, optional doc. +#[derive(Clone, Debug)] +pub struct ConfigSpec { + pub name: String, + pub ty: ConfigType, + pub default: Option, + pub doc: Option, +} + +/// A validated package manifest. +#[derive(Clone, Debug)] +pub struct PackageManifest { + pub name: String, + pub version: String, + pub spdx: Option, + pub tier: Tier, + pub description: Option, + /// Entry-point source file, package-relative (e.g. `src/main.ld`). + pub entry_point: String, + /// Capabilities the package *requests*. The provisioner intersects these + /// with the user's grants; enforcement is in the host. + pub requested: CapabilitySet, + /// Declared configuration schema (the configurator's UI surface). + pub config: Vec, + /// Test files (harness inputs), package-relative. + pub tests: Vec, +} + +/// Everything that can be wrong with a manifest. Deliberately field-shaped so +/// provisioner/configurator UIs can point the author at the exact key. +#[derive(Clone, Debug, PartialEq, Error)] +pub enum ManifestError { + #[error("manifest read error: {0}")] + Read(String), + #[error("manifest must be a single λδ map literal, got: {0}")] + NotAMap(String), + #[error("manifest field {field}: {msg}")] + Field { field: String, msg: String }, +} + +fn field_err(field: &str, msg: impl Into) -> Result { + Err(ManifestError::Field { + field: field.into(), + msg: msg.into(), + }) +} + +/// Look up `key` (a bare name like `"name"`) in a λδ keyword-keyed map. +fn map_get<'m>(map: &'m [(Value, Value)], key: &str) -> Option<&'m Value> { + map.iter().find_map(|(k, v)| match k { + Value::Keyword(kw) if kw.as_ref() == key => Some(v), + _ => None, + }) +} + +fn want_string<'m>(map: &'m [(Value, Value)], key: &str) -> Result<&'m str, ManifestError> { + match map_get(map, key) { + Some(Value::Str(s)) => Ok(s.as_ref()), + Some(other) => field_err( + &format!(":{key}"), + format!("expected a string, got {other}"), + ), + None => field_err(&format!(":{key}"), "required field is missing"), + } +} + +fn opt_string(map: &[(Value, Value)], key: &str) -> Result, ManifestError> { + match map_get(map, key) { + None | Some(Value::Nil) => Ok(None), + Some(Value::Str(s)) => Ok(Some(s.to_string())), + Some(other) => field_err( + &format!(":{key}"), + format!("expected a string, got {other}"), + ), + } +} + +/// kebab-case: the minter, BoJ cartridges, and npm-adjacent tooling all agree. +fn valid_package_name(name: &str) -> bool { + !name.is_empty() + && name + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') + && !name.starts_with('-') + && !name.ends_with('-') + && !name.contains("--") +} + +/// `x.y.z` (loosely — numeric components, no build metadata requirements). +fn valid_version(v: &str) -> bool { + let parts: Vec<&str> = v.split('.').collect(); + parts.len() == 3 + && parts + .iter() + .all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit())) +} + +impl PackageManifest { + /// Parse and validate a manifest from its source text. + pub fn from_source(src: &str) -> Result { + let form = read_one(src).map_err(|e| ManifestError::Read(format!("{e}")))?; + PackageManifest::from_value(&form) + } + + /// Validate a manifest given as an already-read λδ value (the homoiconic + /// path: a host may obtain the map from anywhere — a `.ld` file, an + /// in-notebook attachment, a registry response). + pub fn from_value(v: &Value) -> Result { + let map = match v { + Value::Map(pairs) => pairs.as_ref(), + other => return Err(ManifestError::NotAMap(format!("{other}"))), + }; + + let name = want_string(map, "name")?.to_string(); + if !valid_package_name(&name) { + return field_err( + ":name", + format!("{name:?} is not kebab-case (a-z, 0-9, single dashes)"), + ); + } + + let version = want_string(map, "version")?.to_string(); + if !valid_version(&version) { + return field_err(":version", format!("{version:?} — expected x.y.z")); + } + + let spdx = opt_string(map, "spdx")?; + let description = opt_string(map, "description")?; + + let tier = match map_get(map, "tier") { + None | Some(Value::Nil) => Tier::default(), + Some(Value::Keyword(k)) => Tier::from_name(k.as_ref())?, + Some(Value::Str(s)) => Tier::from_name(s.as_ref())?, + Some(other) => { + return field_err(":tier", format!("expected a keyword, got {other}")); + } + }; + + let entry_point = want_string(map, "entry-point")?.to_string(); + if entry_point.contains("..") || entry_point.starts_with('/') { + return field_err( + ":entry-point", + "must be a package-relative path (no `..`, not absolute)", + ); + } + + let requested = match map_get(map, "capabilities") { + None | Some(Value::Nil) => CapabilitySet::none(), + Some(Value::Vector(items)) | Some(Value::List(items)) => { + let mut set = CapabilitySet::none(); + for item in items.iter() { + let kw = match item { + Value::Keyword(k) => k.to_string(), + Value::Str(s) => s.to_string(), + other => { + return field_err( + ":capabilities", + format!("expected capability keywords, got {other}"), + ); + } + }; + match Capability::from_keyword(&kw) { + Some(cap) => set.grant(cap), + None => { + return field_err( + ":capabilities", + format!("unknown capability {kw}; the host cannot grant what it cannot enforce"), + ); + } + } + } + set + } + Some(other) => { + return field_err(":capabilities", format!("expected a vector, got {other}")); + } + }; + + let config = match map_get(map, "config") { + None | Some(Value::Nil) => Vec::new(), + Some(Value::Map(entries)) => parse_config(entries.as_ref())?, + Some(other) => { + return field_err(":config", format!("expected a map, got {other}")); + } + }; + + let tests = match map_get(map, "tests") { + None | Some(Value::Nil) => Vec::new(), + Some(Value::Vector(items)) | Some(Value::List(items)) => { + let mut out = Vec::new(); + for item in items.iter() { + match item { + Value::Str(s) => out.push(s.to_string()), + other => { + return field_err(":tests", format!("expected strings, got {other}")); + } + } + } + out + } + Some(other) => return field_err(":tests", format!("expected a vector, got {other}")), + }; + + Ok(PackageManifest { + name, + version, + spdx, + tier, + description, + entry_point, + requested, + config, + tests, + }) + } + + /// Resolve configuration: apply `overrides` on top of defaults, type-check + /// every resulting value against the declared schema, reject unknown keys. + /// Returns the fully-resolved settings the configuration step would + /// persist. This is the configurator's enforcement half — a declared + /// schema the UI is generated from, and the guarantee that no unvalidated + /// value reaches the plugin. + pub fn resolve_config( + &self, + overrides: &[(String, Value)], + ) -> Result, ManifestError> { + for (key, _) in overrides { + if !self.config.iter().any(|spec| &spec.name == key) { + return field_err( + &format!(":config :{key}"), + "no such declared setting — the manifest's :config schema is the boundary", + ); + } + } + let mut resolved = Vec::new(); + for spec in &self.config { + let value = overrides + .iter() + .find_map(|(k, v)| { + if k == &spec.name { + Some(v.clone()) + } else { + None + } + }) + .or_else(|| spec.default.clone()); + match value { + Some(v) => { + spec.ty.check(&spec.name, &v)?; + resolved.push((spec.name.clone(), v)); + } + None => { + return field_err( + &format!(":config :{}", spec.name), + "no default and no override — this setting is required", + ); + } + } + } + Ok(resolved) + } +} + +fn parse_config(entries: &[(Value, Value)]) -> Result, ManifestError> { + let mut out = Vec::new(); + for (k, v) in entries { + let name = match k { + Value::Keyword(kw) => kw.to_string(), + other => return field_err(":config", format!("keys must be keywords, got {other}")), + }; + let spec_map = match v { + Value::Map(m) => m.as_ref(), + other => { + return field_err( + &format!(":config :{name}"), + format!("expected a spec map, got {other}"), + ); + } + }; + let ty = match map_get(spec_map, "type") { + Some(Value::Keyword(t)) => match t.as_ref() { + "string" => ConfigType::String, + "int" => ConfigType::Int, + "float" => ConfigType::Float, + "bool" => ConfigType::Bool, + "keyword" => ConfigType::Keyword, + "enum" => match map_get(spec_map, "values") { + Some(Value::Vector(vals)) | Some(Value::List(vals)) => { + let mut allowed = Vec::new(); + for v in vals.iter() { + match v { + Value::Keyword(k) => allowed.push(k.to_string()), + Value::Str(s) => allowed.push(s.to_string()), + other => { + return field_err( + &format!(":config :{name} :values"), + format!("expected keywords, got {other}"), + ); + } + } + } + ConfigType::Enum(allowed) + } + _ => { + return field_err( + &format!(":config :{name}"), + ":enum requires :values [...]", + ); + } + }, + other => { + return field_err( + &format!(":config :{name} :type"), + format!("unknown type :{other}"), + ); + } + }, + Some(other) => { + return field_err( + &format!(":config :{name} :type"), + format!("expected a keyword, got {other}"), + ); + } + None => return field_err(&format!(":config :{name}"), "missing :type"), + }; + let default = map_get(spec_map, "default").cloned(); + if let Some(d) = &default { + ty.check(&name, d)?; + } + let doc = opt_string(spec_map, "doc")?; + out.push(ConfigSpec { + name, + ty, + default, + doc, + }); + } + Ok(out) +} + +/// The reserved niceties a well-formed `manifest.ld` starts with: an SPDX +/// comment and a docstring comment are *comments* — the reader skips them — +/// but this helper extracts the SPDX identifier for tooling that wants it +/// without reading the whole file. +pub fn spdx_from_header_comments(src: &str) -> Option { + src.lines() + .take(8) + .filter_map(|line| line.trim().strip_prefix(";")) + .find_map(|comment| { + comment + .trim() + .strip_prefix("SPDX-License-Identifier:") + .map(|s| s.trim().to_string()) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + const GOOD: &str = r#" + ; SPDX-License-Identifier: MPL-2.0 + {:name "word-count" + :version "0.1.0" + :spdx "MPL-2.0" + :tier :ayo + :description "Counts words per note" + :entry-point "src/main.ld" + :capabilities [:notes/read :notes/write] + :config {:min-words {:type :int :default 0 :doc "ignore shorter notes"} + :mode {:type :enum :values [:fast :careful] :default :fast}} + :tests ["test/main.test.ld"]} + "#; + + #[test] + fn parses_a_full_manifest() { + let m = PackageManifest::from_source(GOOD).unwrap(); + assert_eq!(m.name, "word-count"); + assert_eq!(m.version, "0.1.0"); + assert_eq!(m.tier, Tier::Ayo); + assert_eq!(m.entry_point, "src/main.ld"); + assert!(m.requested.allows(&Capability::NotesRead)); + assert!(m.requested.allows(&Capability::NotesWrite)); + assert!(!m.requested.allows(&Capability::AgentsRun)); + assert_eq!(m.config.len(), 2); + assert_eq!(m.tests, vec!["test/main.test.ld".to_string()]); + assert_eq!(spdx_from_header_comments(GOOD).as_deref(), Some("MPL-2.0")); + } + + #[test] + fn minimal_manifest_defaults() { + let m = PackageManifest::from_source( + r#"{:name "bare" :version "1.0.0" :entry-point "src/main.ld"}"#, + ) + .unwrap(); + assert_eq!(m.tier, Tier::Ayo); + assert_eq!(m.config.len(), 0); + assert!(!m.requested.allows(&Capability::NotesRead)); + } + + #[test] + fn rejects_unknown_capabilities() { + let err = PackageManifest::from_source( + r#"{:name "x-ray" :version "1.0.0" :entry-point "m.ld" :capabilities [:disk/write]}"#, + ) + .unwrap_err(); + assert!(format!("{err}").contains("unknown capability")); + } + + #[test] + fn rejects_bad_names_and_versions() { + assert!(PackageManifest::from_source( + r#"{:name "Bad_Name" :version "1.0.0" :entry-point "m.ld"}"# + ) + .is_err()); + assert!( + PackageManifest::from_source(r#"{:name "ok" :version "1.0" :entry-point "m.ld"}"#) + .is_err() + ); + assert!(PackageManifest::from_source( + r#"{:name "ok" :version "1.0.0" :entry-point "../escape.ld"}"# + ) + .is_err()); + } + + #[test] + fn config_resolution_validates() { + let m = PackageManifest::from_source(GOOD).unwrap(); + // defaults fill + let resolved = m.resolve_config(&[]).unwrap(); + assert_eq!(resolved.len(), 2); + // override with correct type + let resolved = m + .resolve_config(&[("min-words".into(), Value::Int(10))]) + .unwrap(); + assert!(resolved + .iter() + .any(|(k, v)| k == "min-words" && matches!(v, Value::Int(10)))); + // wrong type rejected + assert!(m + .resolve_config(&[("min-words".into(), Value::str("ten"))]) + .is_err()); + // unknown key rejected + assert!(m + .resolve_config(&[("bogus".into(), Value::Int(1))]) + .is_err()); + // enum membership enforced + assert!(m + .resolve_config(&[("mode".into(), Value::kw("reckless"))]) + .is_err()); + } +} diff --git a/core/src/lambdadelta/provisioner.rs b/core/src/lambdadelta/provisioner.rs new file mode 100644 index 0000000..c31a94b --- /dev/null +++ b/core/src/lambdadelta/provisioner.rs @@ -0,0 +1,154 @@ +// SPDX-License-Identifier: MPL-2.0 +//! The λδ **provisioner** — issue #33. Installs a package into a notebook/host: +//! declared dependencies, the **capability grants** it requests, and version +//! pinning. The foundational half is *pure*: given a validated manifest, the +//! grants the user is willing to make, and any config overrides, compute an +//! [`InstallPlan`] — or refuse. Persistence (`InstallReceipt` in notebook +//! storage) and the UI prompt are the host's half, layered on top. +//! +//! The one rule that can never be bent (spec §7.1, issue #33): +//! +//! > **Nothing runs with capabilities the user hasn't granted.** +//! +//! So `plan_install` fails — it never silently narrows — when the manifest +//! requests more than the offered grants. Denial is total, not partial: a +//! package that gets 80% of what it declared is a package running code the +//! author wrote for 100%, which breaks in ways the user cannot predict. + +use std::fmt; + +use thiserror::Error; + +use super::capability::{Capability, CapabilitySet}; +use super::package::{ManifestError, PackageManifest, Tier}; +use super::value::Value; + +/// A fully-resolved installation: what will run, with which grants, under +/// which configuration. The host executes the plan by building a +/// [`crate::lambdadelta::harness::Harness`] or live interpreter with +/// `plan.granted` and loading `plan.entry_point`. +#[derive(Clone, Debug)] +pub struct InstallPlan { + pub name: String, + pub version: String, + pub tier: Tier, + /// Exactly the grants the sandbox will enforce — the *intersection check* + /// passed, so this is everything the manifest requested, no more. + pub granted: CapabilitySet, + /// Fully-resolved configuration (defaults + validated overrides). + pub config: Vec<(String, Value)>, + /// Package-relative path of the entry point to load. + pub entry_point: String, + /// Package-relative test files the harness can verify before first run. + pub tests: Vec, +} + +/// Why an installation was refused. +#[derive(Clone, Debug, PartialEq, Error)] +pub enum ProvisionError { + /// The manifest itself is invalid. + #[error("invalid manifest: {0}")] + Manifest(#[from] ManifestError), + /// The package requests capabilities beyond the offered grants. The + /// `missing` list is exactly what the user would additionally have to + /// allow (or the author would have to stop requesting). + #[error("capabilities requested but not granted: {}", .missing.iter().map(|c| c.keyword()).collect::>().join(" "))] + CapabilityDenied { missing: Vec }, +} + +/// Compute an install plan. `offered` is what the user consents to grant +/// (their answer to the provisioner prompt); `config_overrides` are +/// configurator input atop the manifest defaults. +pub fn plan_install( + manifest: &PackageManifest, + offered: &CapabilitySet, + config_overrides: &[(String, Value)], +) -> Result { + let missing = offered.missing(&manifest.requested); + if !missing.is_empty() { + return Err(ProvisionError::CapabilityDenied { missing }); + } + let config = manifest.resolve_config(config_overrides)?; + Ok(InstallPlan { + name: manifest.name.clone(), + version: manifest.version.clone(), + tier: manifest.tier, + // Grant exactly what was requested — never more. Least privilege is + // not a posture added later; it is the data the sandbox receives. + granted: manifest.requested.clone(), + config, + entry_point: manifest.entry_point.clone(), + tests: manifest.tests.clone(), + }) +} + +impl fmt::Display for InstallPlan { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{}@{} ({}) — grants: {}", + self.name, + self.version, + self.tier, + self.granted + .iter() + .map(|c| c.keyword()) + .collect::>() + .join(" ") + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn manifest() -> PackageManifest { + PackageManifest::from_source( + r#"{:name "word-count" + :version "0.1.0" + :entry-point "src/main.ld" + :capabilities [:notes/read :notes/write] + :config {:min-words {:type :int :default 0}}}"#, + ) + .unwrap() + } + + #[test] + fn full_grants_install() { + let plan = plan_install(&manifest(), &CapabilitySet::all(), &[]).unwrap(); + assert!(plan.granted.allows(&Capability::NotesWrite)); + assert_eq!(plan.config.len(), 1); + assert_eq!( + format!("{plan}"), + "word-count@0.1.0 (ayo) — grants: :notes/read :notes/write" + ); + } + + #[test] + fn partial_grants_are_refused_totally() { + let read_only = CapabilitySet::from_keywords([":notes/read"]).unwrap(); + match plan_install(&manifest(), &read_only, &[]) { + Err(ProvisionError::CapabilityDenied { missing }) => { + assert_eq!(missing, vec![Capability::NotesWrite]); + } + other => panic!("expected CapabilityDenied, got {other:?}"), + } + } + + #[test] + fn grants_are_exactly_what_was_requested() { + let plan = plan_install(&manifest(), &CapabilitySet::all(), &[]).unwrap(); + assert!(!plan.granted.allows(&Capability::AgentsRun)); + } + + #[test] + fn bad_overrides_fail_install() { + let err = plan_install( + &manifest(), + &CapabilitySet::all(), + &[("min-words".into(), Value::Bool(true))], + ); + assert!(matches!(err, Err(ProvisionError::Manifest(_)))); + } +} diff --git a/core/src/lambdadelta_host.rs b/core/src/lambdadelta_host.rs index c2539df..d8d4be4 100644 --- a/core/src/lambdadelta_host.rs +++ b/core/src/lambdadelta_host.rs @@ -23,6 +23,7 @@ use std::rc::Rc; use chrono::{DateTime, Utc}; use uuid::Uuid; +use crate::lambdadelta::capability::{Capability, CapabilitySet}; use crate::lambdadelta::{Budget, Interp, LdError, LdResult, Value}; use crate::note::Point2D; use crate::notebook::Notebook; @@ -50,6 +51,36 @@ pub fn register(interp: &mut Interp, nb: Rc>) { register_mutators(interp, &nb); } +/// Register the full notebook surface **behind capability enforcement** — the +/// plugin sandbox (issue #33, spec §7.1). Every builtin first asks `grants` +/// for permission: readers need [`Capability::NotesRead`], `!`-mutators need +/// [`Capability::NotesWrite`], `run-agent` needs [`Capability::AgentsRun`]. +/// A denied call fails with [`LdError::Capability`] *before* the notebook is +/// touched — enforcement is native, at the seam, so no λδ code can route +/// around it. +/// +/// ``` +/// use std::cell::RefCell; +/// use std::rc::Rc; +/// use nexia_core::lambdadelta::{Budget, CapabilitySet, Interp}; +/// use nexia_core::notebook::Notebook; +/// +/// let nb = Rc::new(RefCell::new(Notebook::new("demo"))); +/// let mut interp = Interp::new(); +/// let grants = CapabilitySet::from_keywords([":notes/read"]).unwrap(); +/// nexia_core::lambdadelta_host::register_gated(&mut interp, nb, Rc::new(grants)); +/// +/// // Reading is granted … +/// assert!(interp.eval_str("(notes)", Budget::new()).is_ok()); +/// // … but mutation is denied, with a structured error — never a panic. +/// let denied = interp.eval_str("(create-note! \"nope\")", Budget::new()).unwrap_err(); +/// assert!(format!("{denied}").contains("capability denied")); +/// ``` +pub fn register_gated(interp: &mut Interp, nb: Rc>, grants: Rc) { + register_readers_gated(interp, &nb, &grants); + register_mutators_gated(interp, &nb, &grants); +} + /// Register only the pure reader builtins — the surface a **formula** or /// **agent-predicate** context is allowed (spec §5). pub fn register_readers(interp: &mut Interp, nb: &Rc>) { @@ -100,6 +131,159 @@ pub fn eval_formula( interp.eval_str(src, budget) } +/// Gated readers — installed with their required capability. `agents` lists +/// agent metadata (read-level); `run-agent` evaluates a stored predicate over +/// the notebook and requires [`Capability::AgentsRun`] (which implies read — +/// see [`CapabilitySet::allows`]). +pub fn register_readers_gated( + interp: &mut Interp, + nb: &Rc>, + grants: &Rc, +) { + let r = Capability::NotesRead; + gated_reader( + interp, + nb, + Gate::new(grants, r), + "notes", + 0, + Some(0), + bi_notes, + ); + gated_reader( + interp, + nb, + Gate::new(grants, r), + "note", + 1, + Some(1), + bi_note, + ); + gated_reader( + interp, + nb, + Gate::new(grants, r), + "title", + 1, + Some(1), + bi_title, + ); + gated_reader( + interp, + nb, + Gate::new(grants, r), + "content", + 1, + Some(1), + bi_content, + ); + gated_reader( + interp, + nb, + Gate::new(grants, r), + "attrs", + 1, + Some(1), + bi_attrs, + ); + gated_reader( + interp, + nb, + Gate::new(grants, r), + "links", + 1, + Some(1), + bi_links, + ); + gated_reader( + interp, + nb, + Gate::new(grants, r), + "backlinks", + 1, + Some(1), + bi_backlinks, + ); + gated_reader( + interp, + nb, + Gate::new(grants, r), + "position", + 1, + Some(1), + bi_position, + ); + gated_reader( + interp, + nb, + Gate::new(grants, r), + "attr", + 2, + Some(2), + bi_attr, + ); + gated_reader( + interp, + nb, + Gate::new(grants, r), + "search", + 1, + Some(1), + bi_search, + ); + gated_reader( + interp, + nb, + Gate::new(grants, r), + "resolve-title", + 1, + Some(1), + bi_resolve_title, + ); + gated_reader( + interp, + nb, + Gate::new(grants, r), + "agents", + 0, + Some(0), + bi_agents, + ); + gated_reader( + interp, + nb, + Gate::new(grants, Capability::AgentsRun), + "run-agent", + 1, + Some(1), + bi_run_agent, + ); +} + +/// Gated mutators — every write requires [`Capability::NotesWrite`]. +pub fn register_mutators_gated( + interp: &mut Interp, + nb: &Rc>, + grants: &Rc, +) { + let w = Capability::NotesWrite; + let list: [(&str, usize, Option, MutFn); 10] = [ + ("create-note!", 1, Some(3), bi_create_note), + ("set-title!", 2, Some(2), bi_set_title), + ("set-content!", 2, Some(2), bi_set_content), + ("set-attr!", 3, Some(3), bi_set_attr), + ("remove-attr!", 2, Some(2), bi_remove_attr), + ("move-note!", 3, Some(3), bi_move_note), + ("resize-note!", 3, Some(3), bi_resize_note), + ("link!", 2, Some(2), bi_link), + ("unlink!", 2, Some(2), bi_unlink), + ("delete-note!", 1, Some(1), bi_delete_note), + ]; + for (name, min, max, f) in list { + gated_mutator(interp, nb, Gate::new(grants, w), name, min, max, f); + } +} + type ReadFn = fn(&Notebook, &[Value]) -> LdResult; type MutFn = fn(&mut Notebook, &[Value]) -> LdResult; @@ -133,6 +317,66 @@ fn mutator( }); } +/// The enforcement context carried into every gated builtin: which grants to +/// consult, and which single capability the wrapped builtin requires. +#[derive(Clone)] +struct Gate { + grants: Rc, + req: Capability, +} + +impl Gate { + fn new(grants: &Rc, req: Capability) -> Self { + Gate { + grants: grants.clone(), + req, + } + } + + /// Choke point: structured denial before any notebook access. + fn check(&self) -> LdResult<()> { + self.grants.require(self.req) + } +} + +/// A reader wrapped in capability enforcement: the grant check runs BEFORE +/// the notebook is borrowed, so a denied call observes nothing. +fn gated_reader( + interp: &mut Interp, + nb: &Rc>, + gate: Gate, + name: &str, + min: usize, + max: Option, + f: ReadFn, +) { + let n = nb.clone(); + interp.register_builtin(name, min, max, move |_i, a| { + gate.check()?; + let g = n.borrow(); + f(&g, a) + }); +} + +/// A mutator wrapped in capability enforcement: the grant check runs BEFORE +/// the notebook is mutably borrowed, so a denied call changes nothing. +fn gated_mutator( + interp: &mut Interp, + nb: &Rc>, + gate: Gate, + name: &str, + min: usize, + max: Option, + f: MutFn, +) { + let n = nb.clone(); + interp.register_builtin(name, min, max, move |_i, a| { + gate.check()?; + let mut g = n.borrow_mut(); + f(&mut g, a) + }); +} + // ── Bridge: Note → immutable snapshot map (spec §2) ────────────────────────── /// Build the immutable snapshot map for a note (spec §2). diff --git a/core/tests/lambdadelta_plugin_system.rs b/core/tests/lambdadelta_plugin_system.rs new file mode 100644 index 0000000..5c065dc --- /dev/null +++ b/core/tests/lambdadelta_plugin_system.rs @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: MPL-2.0 +//! End-to-end proof of the λδ plugin-system foundation (issue #33): +//! manifest → provisioner → harness, with capability enforcement proven at +//! the host seam. The fixture package is `plugins/word-count/` — itself +//! minted by `scripts/ld-mint.js`, so the minter's output is exercised here +//! too (four components dogfooding one foundation). +//! +//! The fixture notebook mirrors `plugins/word-count/test/main.test.ld`: +//! Alpha = "one two three four five" → 5 words +//! Beta = "" → 0 words +//! Gamma = "just three here" → 3 words + +use std::cell::RefCell; +use std::path::{Path, PathBuf}; +use std::rc::Rc; + +use nexia_core::lambdadelta::{ + plan_install, Budget, CapabilitySet, Harness, Interp, PackageManifest, ProvisionError, Tier, + Value, +}; +use nexia_core::lambdadelta_host; +use nexia_core::notebook::Notebook; + +fn package_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../plugins/word-count") +} + +fn fixture_notebook() -> Notebook { + let mut nb = Notebook::new("plugin-fixture"); + let alpha = nb.create_note("Alpha"); + nb.set_content(&alpha, "one two three four five"); + let beta = nb.create_note("Beta"); + nb.set_content(&beta, ""); + let gamma = nb.create_note("Gamma"); + nb.set_content(&gamma, "just three here"); + nb +} + +fn read_to_string(rel: &str) -> String { + std::fs::read_to_string(package_dir().join(rel)) + .unwrap_or_else(|e| panic!("cannot read {rel}: {e}")) +} + +#[test] +fn reference_package_manifest_validates() { + let m = PackageManifest::from_source(&read_to_string("manifest.ld")).unwrap(); + assert_eq!(m.name, "word-count"); + assert_eq!(m.tier, Tier::Ayo); + assert_eq!(m.entry_point, "src/main.ld"); + assert!(m + .requested + .allows(&nexia_core::lambdadelta::Capability::NotesRead)); + assert!(m + .requested + .allows(&nexia_core::lambdadelta::Capability::NotesWrite)); + assert_eq!(m.tests, vec!["test/main.test.ld".to_string()]); +} + +#[test] +fn mint_manifest_provisions_over_full_grants_and_runs_green() { + let manifest = PackageManifest::from_source(&read_to_string("manifest.ld")).unwrap(); + let plan = plan_install(&manifest, &CapabilitySet::all(), &[]).expect("provisioning"); + + let nb = Rc::new(RefCell::new(fixture_notebook())); + let grants = Rc::new(plan.granted.clone()); + let nb_for_sandbox = nb.clone(); + let mut harness = Harness::new(move |interp| { + lambdadelta_host::register_gated(interp, nb_for_sandbox, grants); + }); + + harness + .load_source("src/main.ld", &read_to_string("src/main.ld")) + .expect("package source loads"); + harness.run_tests("test/main.test.ld", &read_to_string("test/main.test.ld")); + + let report = harness.report(); + assert!( + report.is_green(), + "harness report must be green; failures: {:?}", + report + .assertions + .iter() + .filter(|a| !a.ok) + .collect::>() + ); + assert_eq!(report.passed, 7, "expected all 7 assertions to pass"); + + // And the mutation really happened in the host notebook. + let nb = nb.borrow(); + let alpha = nb.search_by_title("Alpha")[0]; + assert_eq!( + alpha.get_attribute("word-count"), + Some(&serde_json::json!(5)) + ); +} + +#[test] +fn provisioner_refuses_partial_grants_totally() { + let manifest = PackageManifest::from_source(&read_to_string("manifest.ld")).unwrap(); + let read_only = CapabilitySet::from_keywords([":notes/read"]).unwrap(); + match plan_install(&manifest, &read_only, &[]) { + Err(ProvisionError::CapabilityDenied { missing }) => { + assert_eq!( + missing, + vec![nexia_core::lambdadelta::Capability::NotesWrite] + ); + } + other => panic!("expected total refusal, got {other:?}"), + } +} + +#[test] +fn sandbox_enforces_read_only_grants_with_structured_denial() { + // Even if a provisioner bug somehow let a write-requiring package load, + // the HOST seam still denies the effect (defence in depth). + let nb = Rc::new(RefCell::new(fixture_notebook())); + let read_only = Rc::new(CapabilitySet::from_keywords([":notes/read"]).unwrap()); + let nb_for_sandbox = nb.clone(); + let grants = read_only.clone(); + let mut interp = Interp::new(); + lambdadelta_host::register_gated(&mut interp, nb_for_sandbox, grants); + + // Reads work. + let out = interp.eval_str("(count (notes))", Budget::new()).unwrap(); + assert!(matches!(out, Value::Int(3))); + + // The package's mutating entry point fails with LdError::Capability — + // a structured value, not a panic, and the notebook is untouched. + interp + .eval_str(&read_to_string("src/main.ld"), Budget::new()) + .expect("definitions under read grants load fine (no effects at def time)"); + let err = interp + .eval_str("(annotate-word-counts!)", Budget::new()) + .unwrap_err(); + assert!( + matches!(err, nexia_core::lambdadelta::LdError::Capability(_)), + "expected capability denial, got {err:?}" + ); + + let nb = nb.borrow(); + let alpha = nb.search_by_title("Alpha")[0]; + assert_eq!(alpha.get_attribute("word-count"), None); +} + +#[test] +fn agents_run_is_not_implied_by_notes_read() { + let nb = Rc::new(RefCell::new(fixture_notebook())); + let grants = Rc::new(CapabilitySet::from_keywords([":notes/read"]).unwrap()); + let nb_for_sandbox = nb.clone(); + let g = grants.clone(); + let mut interp = Interp::new(); + lambdadelta_host::register_gated(&mut interp, nb_for_sandbox, g); + + let err = interp + .eval_str("(run-agent \"anything\")", Budget::new()) + .unwrap_err(); + assert!( + matches!(err, nexia_core::lambdadelta::LdError::Capability(_)), + "run-agent must require :agents/run, got {err:?}" + ); +} diff --git a/docs/design/lambdadelta-plugin-system.adoc b/docs/design/lambdadelta-plugin-system.adoc new file mode 100644 index 0000000..f025fc5 --- /dev/null +++ b/docs/design/lambdadelta-plugin-system.adoc @@ -0,0 +1,207 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 += LambdaDelta (λδ) Plugin System — design + foundation status +:toc: macro +:toclevels: 2 +:icons: font + +Issue: https://github.com/hyperpolymath/nexia-list/issues/33[#33] · +Spec: link:lambdadelta-spec.adoc[λδ spec] · +ADR: link:../adr/0003-lambdadelta-lisp-substrate.md[ADR-0003] + +toc::[] + +== What exists now (the foundation, shipped) + +[%header,cols="1,2,2"] +|=== +| Component | Where | Status + +| *Capability model* (foundation 1/2) +| `core/src/lambdadelta/capability.rs` +| ✅ Implemented: `Capability` catalogue (`:notes/read`, `:notes/write`, +`:agents/run`), `CapabilitySet` grants with `allows`/`missing`/`require`, +`:agents/run ⇒ :notes/read` implication. Enforced natively at the seam: +`lambdadelta_host::register_gated` wraps every host builtin so the grant +check runs *before* the notebook is touched — denial is a structured +`LdError::Capability`, never a panic, never a partial effect. + +| *Package format* (foundation 2/2) +| `core/src/lambdadelta/package.rs` +| ✅ Implemented: homoiconic `manifest.ld` (a λδ map literal read by the +ordinary λδ reader — no second parser). Field names mirror BoJ +`cartridge.json` (`name`/`version`/`spdx`/`tier`/`description`) and PanLL +contracts; kebab-case names, `x.y.z` versions, package-relative paths, +unknown capabilities rejected as manifest errors. `resolve_config` is the +configurator's enforcement half (typed schema + defaults + no unknown keys). + +| *Minter* +| `scripts/ld-mint.js` (`just ld-new `) +| ✅ Implemented: scaffolds `plugins//` with `manifest.ld`, +`src/main.ld`, `test/main.test.ld`, `README.adoc`; tier and capability +flags; refuses overwrites. The reference package `plugins/word-count/` was +minted by it (dogfood). + +| *Harness* +| `core/src/lambdadelta/harness.rs` +| ✅ Implemented: sandbox registered by closure (kernel never depends on a +host); `assert-eq`/`assert` *record* into a `HarnessReport` instead of +aborting; a read/eval error becomes a failed assertion, not a panic; +per-harness `Budget` override. + +| *Provisioner* (validation core) +| `core/src/lambdadelta/provisioner.rs` +| ✅ Implemented (pure): `plan_install(manifest, offered, overrides) → +InstallPlan` — refuses *totally* when requested ⊄ granted (`missing` names +exactly the delta), grants exactly what was requested (least privilege by +data), resolves config through the manifest schema. + +| *End-to-end proof* +| `core/tests/lambdadelta_plugin_system.rs` +| ✅ manifest validates → provisions → runs green in the harness against a +fixture notebook; partial grants refused; read-only sandbox denies the +plugin's mutation with a structured error; `:agents/run` not implied by +`:notes/read`. + +| *Configurator* (UI) / *Wizard* / *Registry* +| — +| 🔲 Design below; the UI surface is generated from the manifest's +`:config` schema (already enforced), the wizard is glue over the four +components, signing/provenance is an estate-level open question. +|=== + +This satisfies the issue's sequencing guidance for what had to land early +(“capability model” and “package format”, because they shape the kernel/host +seam) plus the author loop (minter → harness). The spec’s §7 proof obligation +— “capability non-escalation … until authority is represented and checked at +dispatch” — is discharged for the gated path: authority is `CapabilitySet` and +it is checked at every gated builtin dispatch. + +== Design principles + +. *The seam is the whole game.* λδ is homoiconic and the kernel knows nothing +about notes; hosts register builtins through `Interp::register_builtin`. A +plugin system is therefore mostly: a package format, a capability model, and +ergonomics. Nothing here touches kernel semantics. +. *Nothing runs with capabilities the user hasn't granted.* Not “warns”, not +“best effort” — the provisioner refuses, and even if it didn’t, the host seam +denies (defence in depth by construction). +. *Enforcement is native.* Checks live in Rust before the effect, so no λδ +code can route around them; failure is a structured value (sandbox contract, +spec §6). +. *Reuse ecosystem vocabulary.* Tiers `teranga`/`shield`/`ayo` and the +manifest shape come from BoJ cartridges + PanLL minter/provisioner contracts +— estate tooling should read a λδ package without new concepts. + +== Package format (`manifest.ld`) + +A package is a directory (shared as a `.ldpkg` bundle): + +---- +word-count/ +├── manifest.ld ; λδ map literal — code is data +├── src/main.ld ; :entry-point +├── test/main.test.ld ; :tests (harness inputs) +└── README.adoc +---- + +[source,clojure] +---- +{:name "word-count" ; kebab-case, required + :version "0.1.0" ; x.y.z, required + :spdx "MPL-2.0" ; estate license policy + :tier :ayo ; :teranga core | :shield elevated | :ayo community (default) + :description "…" + :entry-point "src/main.ld" ; package-relative; no `..`, not absolute + :capabilities [:notes/read :notes/write] + :config {:min-words {:type :int :default 0 :doc "ignore shorter notes"}} + :tests ["test/main.test.ld"]} +---- + +Mapping to BoJ `cartridge.json` is mechanical (`name`/`version`/`spdx`/ +`description` identical; `tier` maps onto the tier enum; `:capabilities` is +the λδ analogue of cartridge `auth` + tool surface; `:entry-point` plays the +`mod.js`/`ffi.so_path` role). A `cartridge.json ⇄ manifest.ld` translator is +deliberately *not* built yet — one mechanical pass later, when a second host +needs it. + +== Capability model + +[source] +---- +:notes/read pure readers — notes, note, attr, search, resolve-title, … +:notes/write !-mutators — set-attr!, create-note!, link!, delete-note!, … +:agents/run run-agent (implies :notes/read: the predicate is read code) +---- + +* *Declared* in the manifest (unknown keywords → manifest error; the host +cannot grant what it cannot enforce). +* *Requested ≠ granted*: the provisioner intersects requests with the user’s +offered grants; shortfall is a total refusal naming `missing` capabilities. +* *Enforced* at dispatch by `register_gated`: each builtin closure checks its +`Gate` first. `:agents/run` grants read transitively +(`CapabilitySet::allows`). +* *Refinement path* (designed, deferred): keyword capabilities stay valid +while map forms like `[:notes/read {:titles "Journal *"}]` extend the same +slot — refinement is additive to the manifest grammar. + +== The four components + wizard + +=== Minter ✅ + +`just ld-new --caps notes/read,notes/write --tier ayo` — +mirrors `panll/contracts/minter.toml`’s role (scaffold-from-template) and BoJ +`minter.toml` fields (name/description/version/tier). Output passes +`PackageManifest::from_source` immediately — the integration test mints→loads +`plugins/word-count/` to keep that true. + +=== Provisioner ✅ (core) / 🔲 (host prompt + receipts) + +Pure half done: `plan_install`. Host half (next): surface the request +(manifest capabilities + why), record an `InstallReceipt` (package id, +version pin, grants, resolved config) in notebook storage so installed +packages travel with the data they extend. + +=== Configurator 🔲 (UI) / ✅ (schema + validation) + +The manifest `:config` map *is* the typed-attribute surface the config UI is +generated from — one widget per `:type` (`:string/:int/:float/:bool/:keyword/ +(:enum …)`), doc strings from `:doc`. Validation is already enforced +(`resolve_config`): no unvalidated value reaches a plugin. + +=== Harness ✅ + +Sandbox = interpreter + explicitly-installed host surface + budget + recorded +assertions. Same object runs author iteration (`just test-rust` → the plugin +integration suites) and CI verification — there is no second “test mode”. + +=== Wizard (design) + +Guided design→deploy glue over the four: *mint* (name, tier, capabilities +inferred by scanning the source for builtin usage — readers suggest +`:notes/read`, `!`-builtins suggest `:notes/write`) → *develop* (harness loop +with the fixture notebook) → *provision* (grant prompt rendering exactly the +`missing` delta) → *configure* (schema-generated form) → *deploy* (install +receipt into the notebook). Each stage is a call into a component that +already exists; the wizard is presentation, not new machinery. + +== Trust, tiers, and open questions + +* *Tiers*: `teranga` packages may ship with the app and request any +capability; `shield` packages additionally require signed provenance +(deferred — estate signing policy is a standards-level decision, tracked +below); `ayo` (community) is the minting default and gets the tightest +default budget (`Harness::with_budget`). +* *Where plugins live*: in-notebook first (packages travel with the data they +extend — install receipts per notebook); a shared registry is a distribution +layer on top, intentionally unspecified here. +* *Signing/provenance*: open estate question (the PanLL/BoJ ecosystems have +not settled it either). The manifest format reserves space (`:signature`, +`:provenance`) but nothing interprets them yet. + +== Failure modes the design refuses + +* A silent-capability plugin (code does more than the manifest admits) — +impossible: enforcement keys on the builtin dispatch, not the manifest text. +* A green-but-fake test run — assertions are recorded; an evaluation error is +a failed assertion; `HarnessReport::is_green` requires zero failures. +* Grant creep at install — plans carry *exactly* the requested set. diff --git a/docs/design/lambdadelta-spec.adoc b/docs/design/lambdadelta-spec.adoc index 314f853..d85a234 100644 --- a/docs/design/lambdadelta-spec.adoc +++ b/docs/design/lambdadelta-spec.adoc @@ -318,8 +318,14 @@ tiny and stable. The capability model and package/manifest format are specified in *#33*; this section fixes only that the seam exists and where it sits. -Capability non-escalation remains an engineering-blocked proof -obligation until authority is represented and checked at dispatch. +*Update (2026-09-22, #33 foundation):* the capability model +(`lambdadelta::capability`) and package format (`lambdadelta::package`) are +now implemented, and gated native registrations +(`lambdadelta_host::register_gated`) check authority at every builtin +dispatch — non-escalation is proven by `core/tests/lambdadelta_plugin_system.rs` +for the gated path. See link:lambdadelta-plugin-system.adoc[the plugin-system +design + status]. Ungated registration remains for trusted host contexts +(formulas, agents) only. ''''' diff --git a/plugins/README.adoc b/plugins/README.adoc new file mode 100644 index 0000000..399c724 --- /dev/null +++ b/plugins/README.adoc @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 += λδ packages + +λδ (LambdaDelta) packages extend Nexia-List from inside the sandbox: +declared capabilities, enforced by the host seam — nothing runs with +capabilities the user hasn't granted. + +[cols="1,2"] +|=== +| Mint a package | `just ld-new ` (the minter, `scripts/ld-mint.js`) +| Reference package | link:word-count/[`word-count/`] — minted by the minter, proven green in the harness +| Manifest format | `core/src/lambdadelta/package.rs` +| Capability model | `core/src/lambdadelta/capability.rs` +| Verifying tests | `core/tests/lambdadelta_plugin_system.rs` +| Design + status | link:../docs/design/lambdadelta-plugin-system.adoc[docs/design/lambdadelta-plugin-system.adoc] +|=== + +Every package directory is minter-shaped: + +---- +/ +├── manifest.ld ; λδ map: version, tier, requested capabilities, config schema +├── src/main.ld ; :entry-point +├── test/main.test.ld ; :tests — harness assertions (assert-eq/assert) +└── README.adoc +---- diff --git a/plugins/word-count/README.adoc b/plugins/word-count/README.adoc new file mode 100644 index 0000000..9b0235c --- /dev/null +++ b/plugins/word-count/README.adoc @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 += word-count +:tier: ayo + +Counts words per note and writes the :word-count attribute; the reference λδ package + +Minted with `just ld-new word-count` (scripts/ld-mint.js — the λδ minter, issue #33). + +== Layout + +[cols="1,2"] +|=== +| `manifest.ld` | Homoiconic manifest: version, tier (_{a.tier}_), requested capabilities, config schema +| `src/main.ld` | Entry point (definitions loaded by the provisioned interpreter) +| `test/main.test.ld` | Harness assertions (`assert-eq`/`assert`), run in the sandbox with the enforced grants +| `README.adoc` | This file +|=== + +== Lifecycle + +. *mint* — you are here +. *develop* — edit `src/main.ld`; iterate with the harness (`assert-eq` in `test/`) +. *provision* — the provisioner checks the requested capabilities against user grants +. *configure* — user settings validated against the manifest's `:config` schema +. *run* — inside the sandbox: enforced capabilities + evaluation budget diff --git a/plugins/word-count/manifest.ld b/plugins/word-count/manifest.ld new file mode 100644 index 0000000..6a691ac --- /dev/null +++ b/plugins/word-count/manifest.ld @@ -0,0 +1,16 @@ +; SPDX-License-Identifier: MPL-2.0 +; manifest.ld — λδ package manifest for 'word-count'. Homoiconic: this is a λδ +; map literal, validated by nexia-core (lambdadelta::package::PackageManifest). +{ + :name "word-count" + :version "0.1.0" + :spdx "MPL-2.0" + :tier :ayo ; :teranga core | :shield elevated-trust | :ayo community + :description "Counts words per note and writes the :word-count attribute; the reference λδ package" + :entry-point "src/main.ld" + :capabilities [:notes/read :notes/write] + ;; Configurator schema: the settings UI is generated from this map; no value + ;; reaches the plugin unvalidated (PackageManifest::resolve_config). + ;:config {:example {:type :int :default 0 :doc "an example setting"}} + :tests ["test/main.test.ld"] +} diff --git a/plugins/word-count/src/main.ld b/plugins/word-count/src/main.ld new file mode 100644 index 0000000..a0d23cd --- /dev/null +++ b/plugins/word-count/src/main.ld @@ -0,0 +1,23 @@ +;; SPDX-License-Identifier: MPL-2.0 +;; word-count/src/main.ld — the reference λδ package for Nexia-List. +;; Counts words per note and can stamp the :word-count attribute on every +;; note. Requested grants (manifest.ld): :notes/read + :notes/write. + +;; Words in a single note's content. +(def note-word-count + (fn [n] + (count (words (content n))))) + +;; Notes shorter than `limit` words. +(def short-notes + (fn [limit] + (filter (fn [n] (< (note-word-count n) limit)) (notes)))) + +;; Stamp :word-count onto every note. Requires :notes/write; a sandbox that +;; was granted read-only access fails this with a structured capability +;; denial — never a panic, never a partial write. +(def annotate-word-counts! + (fn [] + (map + (fn [n] (set-attr! (:id n) "word-count" (note-word-count n))) + (notes)))) diff --git a/plugins/word-count/test/main.test.ld b/plugins/word-count/test/main.test.ld new file mode 100644 index 0000000..6a40291 --- /dev/null +++ b/plugins/word-count/test/main.test.ld @@ -0,0 +1,24 @@ +;; SPDX-License-Identifier: MPL-2.0 +;; word-count/test/main.test.ld — harness tests. These run inside the λδ +;; harness against the fixture notebook built by +;; core/tests/lambdadelta_plugin_system.rs: +;; +;; Alpha = "one two three four five" → 5 words +;; Beta = "" → 0 words +;; Gamma = "just three here" → 3 words +;; +;; assert-eq/assert RECORD into the report — an aborted run is itself a +;; failure, so a red report always tells the whole story. + +(assert-eq 3 (count (notes))) + +;; Pure reads under :notes/read. +(assert-eq 5 (note-word-count (note (resolve-title "Alpha")))) +(assert-eq 0 (note-word-count (note (resolve-title "Beta")))) +(assert-eq 2 (count (short-notes 5))) ; Beta + Gamma are short +(assert-eq 3 (count (short-notes 10))) ; everything is short of 10 + +;; The mutation path under :notes/write … +(annotate-word-counts!) +(assert-eq 5 (attr (note (resolve-title "Alpha")) :word-count)) +(assert-eq 0 (attr (note (resolve-title "Beta")) :word-count)) diff --git a/scripts/ld-mint.js b/scripts/ld-mint.js new file mode 100644 index 0000000..2918835 --- /dev/null +++ b/scripts/ld-mint.js @@ -0,0 +1,181 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// ld-mint — the λδ package minter (issue #33). Scaffolds a new plugin: +// manifest, entry point, harness test, and docs stub, so authoring starts +// from a working template rather than a blank file. +// +// Runtime-agnostic (works on Bun and Node; the estate toolchain invokes it via +// Bun): bun scripts/ld-mint.js [--tier ayo|teranga|shield] +// [--caps notes/read,notes/write] [--description "…"] [--dir plugins] +// +// The minted shape mirrors boj-server-cartridges (cartridge.json fields map +// 1:1 onto manifest.ld keywords) and the PanLL minter contract +// (panll/contracts/minter.toml) — see docs/design/lambdadelta-plugin-system.adoc. + +import fs from "node:fs"; +import path from "node:path"; + +const VALID_TIERS = new Set(["teranga", "shield", "ayo"]); +const VALID_CAPS = new Set(["notes/read", "notes/write", "agents/run"]); + +function die(msg) { + console.error(`ld-mint: ${msg}`); + process.exit(2); +} + +function parseArgs(argv) { + const args = { + name: null, + tier: "ayo", + caps: ["notes/read"], + description: null, + dir: "plugins", + }; + const rest = [...argv]; + while (rest.length > 0) { + const a = rest.shift(); + if (!a.startsWith("--")) { + if (args.name !== null) die(`unexpected extra argument: ${a}`); + args.name = a; + continue; + } + const val = rest.shift(); + if (val === undefined) die(`flag ${a} needs a value`); + switch (a) { + case "--tier": + if (!VALID_TIERS.has(val)) die(`unknown tier ${val} (teranga|shield|ayo)`); + args.tier = val; + break; + case "--caps": { + const caps = val.split(",").map((c) => c.trim().replace(/^:/, "")); + for (const c of caps) { + if (!VALID_CAPS.has(c)) die(`unknown capability ${c} (${[...VALID_CAPS].join(", ")})`); + } + args.caps = caps; + break; + } + case "--description": + args.description = val; + break; + case "--dir": + args.dir = val; + break; + default: + die(`unknown flag ${a}`); + } + } + return args; +} + +function kebabCase(name) { + return ( + name.length > 0 && + /^[a-z0-9]+(-[a-z0-9]+)*$/.test(name) + ); +} + +const MANIFEST = (a) => `; SPDX-License-Identifier: MPL-2.0 +; manifest.ld — λδ package manifest for '${a.name}'. Homoiconic: this is a λδ +; map literal, validated by nexia-core (lambdadelta::package::PackageManifest). +{ + :name "${a.name}" + :version "0.1.0" + :spdx "MPL-2.0" + :tier :${a.tier} ; :teranga core | :shield elevated-trust | :ayo community + :description "${a.description ?? `${a.name} — a λδ package for Nexia-List`}" + :entry-point "src/main.ld" + :capabilities [${a.caps.map((c) => `:${c}`).join(" ")}] + ;; Configurator schema: the settings UI is generated from this map; no value + ;; reaches the plugin unvalidated (PackageManifest::resolve_config). + ;:config {:example {:type :int :default 0 :doc "an example setting"}} + :tests ["test/main.test.ld"] +} +`; + +const MAIN_LD = (a) => `;; SPDX-License-Identifier: MPL-2.0 +;; ${a.name}/src/main.ld — package entry point (loaded by the provisioned +;; interpreter; definitions persist for the host to call). +;; +;; Available builtins depend on the grants in manifest.ld :capabilities — +;; nothing runs with capabilities the user hasn't granted: +;; :notes/read → (notes) (note id) (attr n :key) (search s) … +;; :notes/write → (create-note! title) (set-attr! id "key" v) … +;; :agents/run → (run-agent "name") + +;; Example: a pure helper over the notebook surface. +(def note-titles + (fn [] (map (fn [n] (:title n)) (notes)))) +`; + +const TEST_LD = (a) => `;; SPDX-License-Identifier: MPL-2.0 +;; ${a.name}/test/main.test.ld — harness tests. assert-eq/assert RECORD into a +;; report (they never abort), and an evaluation error in this file becomes a +;; failed assertion — the harness report is the whole story. +;; +;; Run locally via the Rust integration tests (core/tests/) which build the +;; fixture notebook this package expects, or in CI the same way. + +(assert-eq 0 0) ; replace with real assertions, e.g.: +;; (assert-eq 3 (count (notes))) +`; + +const README = (a) => `// SPDX-License-Identifier: CC-BY-SA-4.0 += ${a.name} +:tier: ${a.tier} + +${a.description ?? `A λδ package for Nexia-List.`} + +Minted with \`just ld-new ${a.name}\` (scripts/ld-mint.js — the λδ minter, issue #33). + +== Layout + +[cols="1,2"] +|=== +| \`manifest.ld\` | Homoiconic manifest: version, tier (_{a.tier}_), requested capabilities, config schema +| \`src/main.ld\` | Entry point (definitions loaded by the provisioned interpreter) +| \`test/main.test.ld\` | Harness assertions (\`assert-eq\`/\`assert\`), run in the sandbox with the enforced grants +| \`README.adoc\` | This file +|=== + +== Lifecycle + +. *mint* — you are here +. *develop* — edit \`src/main.ld\`; iterate with the harness (\`assert-eq\` in \`test/\`) +. *provision* — the provisioner checks the requested capabilities against user grants +. *configure* — user settings validated against the manifest's \`:config\` schema +. *run* — inside the sandbox: enforced capabilities + evaluation budget +`; + +function main() { + const args = parseArgs(process.argv.slice(2)); + if (args.name === null) { + console.error(`usage: ld-mint [--tier ayo|teranga|shield] [--caps notes/read,notes/write] [--description "…"] [--dir plugins]`); + process.exit(2); + } + if (!kebabCase(args.name)) { + die(`invalid name ${args.name} — kebab-case (a-z, 0-9, single dashes)`); + } + const root = path.join(args.dir, args.name); + if (fs.existsSync(root)) { + die(`${root} already exists — refusing to overwrite`); + } + + const files = { + "manifest.ld": MANIFEST(args), + "src/main.ld": MAIN_LD(args), + "test/main.test.ld": TEST_LD(args), + "README.adoc": README(args), + }; + for (const [rel, contents] of Object.entries(files)) { + const p = path.join(root, rel); + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, contents); + console.log(` minted ${p}`); + } + console.log(`\nλδ package '${args.name}' minted (tier ${args.tier}).`); + console.log(`Next: edit ${path.join(root, "src/main.ld")}, then prove it in the harness.`); +} + +main();