v0.8.2: cleanup + prose + structure + decomposition + budget + errors

Phase 1 — dedup + hardening (~9 items):
- Remove duplicate *skill-registry* defvar from core-skills
- Merge *backend-registry* into *probabilistic-backends*, delete backend-register
- Remove inject-stimulus alias, standardize on stimulus-inject
- Add pre-eval sandbox (skill-source-scan) blocks restricted symbols before eval
- Remove dead plist-get function; remove duplicate json-alist-to-plist export
- Fix read-framed-message whitespace DoS (4096-iteration max)
- Add *read-eval* nil to dispatcher-approvals-process read-from-string (RCE)
- Add test-op to ASDF; update .asd version 0.4.3→0.7.2

Phase 2 — prose + contracts + reorder:
- Split ROADMAP: 2623→1089 lines (TODO only), CHANGELOG: 260→1528 lines (full DONE history, 14 versions reverse chron)
- Add Contracts + Overview to 6 channel files + embedding-native + programming-standards + symbolic-scope
- Reorder 28 .org files: Contract → Test Suite → Implementation (TDD order)
- Add 7-phase inline prose to think() in core-reason
- Expand USER_MANUAL: 183→461 lines (10 new sections)

Phase 3 — decomposition + export organization:
- Decompose think() into think-assemble-prompt, think-call-llm, think-parse-response orchestrator
- Organize 188 exports into 16 grouped sections by module

Phase 4 — budget enforcement + error protocol:
- Per-session budget enforcement (SESSION_BUDGET_USD env var, budget-exhausted-p, guard in think-call-llm)
- Error condition hierarchy (6 conditions: pipeline-error, llm-error, gate-error, budget-error, protocol-error)
- Restarts in loop-process: skip-signal, use-fallback, abort-pipeline
This commit is contained in:
2026-05-10 09:07:44 -04:00
parent 27d203ad67
commit 8fd56dece3
68 changed files with 7014 additions and 6521 deletions

View File

@@ -33,6 +33,45 @@ This is how the "thin org, fat skills" principle works in practice: the org prov
~#+DEPENDS_ON:~ declarations, returns files sorted such that
dependencies come before dependents.
* Test Suite
Verifies that the topological sorter correctly orders skills by their ~#+DEPENDS_ON:~ declarations.
#+begin_src lisp
(eval-when (:compile-toplevel :load-toplevel :execute)
(ql:quickload :fiveam :silent t))
(defpackage :passepartout-boot-tests
(:use :cl :fiveam :passepartout)
(:export #:boot-suite))
(in-package :passepartout-boot-tests)
(def-suite boot-suite :description "Verification of the Skill Engine loader")
(in-suite boot-suite)
(test test-topological-sort-basic
"Contract 2: dependency ordering puts dependencies before dependents."
(let ((tmp-dir "/tmp/passepartout-boot-test/"))
(uiop:ensure-all-directories-exist (list tmp-dir))
(with-open-file (out (merge-pathnames "org-skill-a.org" tmp-dir) :direction :output :if-exists :supersede)
(format out "#+DEPENDS_ON: skill-b-id~%"))
(with-open-file (out (merge-pathnames "org-skill-b.org" tmp-dir) :direction :output :if-exists :supersede)
(format out ":PROPERTIES:~%:ID: skill-b-id~%:END:~%"))
(unwind-protect
(let ((sorted (passepartout::skill-topological-sort tmp-dir)))
(let ((pos-a (position "org-skill-a" sorted :key #'pathname-name :test #'string-equal))
(pos-b (position "org-skill-b" sorted :key #'pathname-name :test #'string-equal)))
(is (< pos-b pos-a))))
(uiop:delete-directory-tree (uiop:ensure-directory-pathname tmp-dir) :validate t))))
(test test-lisp-syntax-validate-valid
"Contract 1: valid Lisp code passes syntax validation."
(is (eq t (lisp-syntax-validate "(+ 1 2)"))))
(test test-lisp-syntax-validate-invalid
"Contract 1: unbalanced Lisp code fails syntax validation."
(is (null (lisp-syntax-validate "(+ 1 2"))))
#+end_src
* Implementation
** Package Context
@@ -71,10 +110,6 @@ The ~skill~ struct holds all metadata about a loaded skill: its name, priority,
(defstruct skill name priority dependencies trigger-fn probabilistic-prompt deterministic-fn)
#+end_src
#+begin_src lisp
(defvar *skill-registry* (make-hash-table :test 'equal))
#+end_src
#+begin_src lisp
(defvar *skill-catalog* (make-hash-table :test 'equal)
"Tracks all discovered skill files and their loading state.")
@@ -326,6 +361,14 @@ declarations so embedded test code evaluates in the correct package."
(progn
(multiple-value-bind (valid-p err) (lisp-syntax-validate lisp-code)
(unless valid-p (error err)))
;; Pre-eval sandbox scan: block before any code executes
(multiple-value-bind (blocked-p blocked-syms)
(skill-source-scan lisp-code)
(when blocked-p
(log-message "LOADER SANDBOX: Skill '~a' blocked before eval — references restricted symbol(s): ~{~a~^, ~}"
skill-base-name blocked-syms)
(setf (skill-entry-status entry) :sandbox-blocked)
(return-from load-skill-from-org nil)))
(unless (find-package pkg-name)
(let ((new-pkg (make-package pkg-name :use '(:cl)))) (use-package :passepartout new-pkg)))
(let ((*read-eval* nil) (*package* (find-package pkg-name)))
@@ -355,12 +398,48 @@ declarations so embedded test code evaluates in the correct package."
(setf (skill-entry-status entry) :failed) nil))))
#+end_src
** Sandbox Source Scan (skill-source-scan)
Scans Lisp source text for references to restricted symbols before any
code is evaluated. This prevents malicious skills from executing even a
single form. The restricted symbols cover process spawning
(~uiop:run-program~, ~uiop:shell~, ~uiop:run-shell-command~), thread
creation (~bt:make-thread~), HTTP calls (~dex:get~, ~dex:post~), and
socket operations (~usocket:socket-connect~, ~hunchentoot:start~).
Returns two values: T/NIL (blocked-p) and a list of matched symbol names.
The scan is a text-level regex check — it catches direct references but
not obfuscated ones. The post-eval ~symbol-function~ comparison in
~load-skill-from-lisp~ catches those.
#+begin_src lisp
(defvar *skill-restricted-symbols*
'("uiop:run-program" "uiop:shell" "uiop:run-shell-command"
"bt:make-thread" "bordeaux-threads:make-thread"
"dex:get" "dex:post" "dexador:get" "dexador:post"
"usocket:socket-connect" "usocket:socket-listen"
"hunchentoot:start" "hunchentoot:accept-connections")
"Symbol patterns blocked from skill source code at load time.")
(defun skill-source-scan (code-string)
"Scans CODE-STRING for restricted symbol references.
Returns (values blocked-p matched-symbols)."
(let ((lower (string-downcase code-string))
(matches nil))
(dolist (pattern *skill-restricted-symbols*)
(when (search pattern lower)
(push pattern matches)))
(values (and matches t) (nreverse matches))))
#+end_src
** Loading from Pre-Tangled Lisp (skill-load-from-lisp)
Loads a pre-tangled ~.lisp~ file directly, without parsing the Org source. This is faster than ~load-skill-from-org~ because it skips the block extraction and syntax validation (the Lisp was already validated when tangled).
The same jailed package and symbol export process applies.
The sandbox check runs *before* evaluation: the source text is scanned for references to restricted symbols (~uiop:run-program~, ~uiop:shell~, ~uiop:run-shell-command~, ~bt:make-thread~, ~dex:get~, ~dex:post~, ~usocket:socket-connect~, ~hunchentoot:start~). If the source references any restricted symbol, the skill is blocked immediately without executing any code. A post-eval secondary check catches indirect references (via ~symbol-function~ comparison).
#+begin_src lisp
(defun load-skill-from-lisp (filepath)
"Loads a .lisp skill file directly, filtering out in-package forms."
@@ -372,6 +451,14 @@ The same jailed package and symbol export process applies.
(pkg-name (intern (string-upcase (format nil "PASSEPARTOUT.SKILLS.~a" skill-base-name)) :keyword)))
(multiple-value-bind (valid-p err) (lisp-syntax-validate content)
(unless valid-p (error err)))
;; Pre-eval sandbox scan: block before any code executes
(multiple-value-bind (blocked-p blocked-syms)
(skill-source-scan content)
(when blocked-p
(log-message "LOADER SANDBOX: Skill '~a' blocked before eval — references restricted symbol(s): ~{~a~^, ~}"
skill-base-name blocked-syms)
(setf (skill-entry-status entry) :sandbox-blocked)
(return-from load-skill-from-lisp nil)))
(unless (find-package pkg-name)
(let ((new-pkg (make-package pkg-name :use '(:cl)))) (use-package :passepartout new-pkg)))
(let ((*read-eval* nil) (*package* (find-package pkg-name)))
@@ -440,41 +527,3 @@ files live after tangling. The org source files live in ~org/~.
(log-message "LOADER: Boot Complete."))))
#+end_src
* Test Suite
Verifies that the topological sorter correctly orders skills by their ~#+DEPENDS_ON:~ declarations.
#+begin_src lisp
(eval-when (:compile-toplevel :load-toplevel :execute)
(ql:quickload :fiveam :silent t))
(defpackage :passepartout-boot-tests
(:use :cl :fiveam :passepartout)
(:export #:boot-suite))
(in-package :passepartout-boot-tests)
(def-suite boot-suite :description "Verification of the Skill Engine loader")
(in-suite boot-suite)
(test test-topological-sort-basic
"Contract 2: dependency ordering puts dependencies before dependents."
(let ((tmp-dir "/tmp/passepartout-boot-test/"))
(uiop:ensure-all-directories-exist (list tmp-dir))
(with-open-file (out (merge-pathnames "org-skill-a.org" tmp-dir) :direction :output :if-exists :supersede)
(format out "#+DEPENDS_ON: skill-b-id~%"))
(with-open-file (out (merge-pathnames "org-skill-b.org" tmp-dir) :direction :output :if-exists :supersede)
(format out ":PROPERTIES:~%:ID: skill-b-id~%:END:~%"))
(unwind-protect
(let ((sorted (passepartout::skill-topological-sort tmp-dir)))
(let ((pos-a (position "org-skill-a" sorted :key #'pathname-name :test #'string-equal))
(pos-b (position "org-skill-b" sorted :key #'pathname-name :test #'string-equal)))
(is (< pos-b pos-a))))
(uiop:delete-directory-tree (uiop:ensure-directory-pathname tmp-dir) :validate t))))
(test test-lisp-syntax-validate-valid
"Contract 1: valid Lisp code passes syntax validation."
(is (eq t (lisp-syntax-validate "(+ 1 2)"))))
(test test-lisp-syntax-validate-invalid
"Contract 1: unbalanced Lisp code fails syntax validation."
(is (null (lisp-syntax-validate "(+ 1 2"))))
#+end_src