2 Commits

Author SHA1 Message Date
4bed6dd461 v0.3.2: shell safety, :system :eval approval, skill sandbox
Some checks failed
Deploy (Gitea) / deploy (push) Failing after 3s
1. Shell actuator: remove double bash -c wrapping (format ~s produces
   S-expression-safe strings, not shell-safe). Now passes cmd directly
   to (timeout N bash -c cmd) via run-program arg list.

2. Dispatcher: extend high-impact approval gate to :system :eval.
   Previously only :shell, :tool "shell", and :emacs :eval triggered
   HITL. Now :system :eval also requires Flight Plan approval.

3. Skill sandbox: before promoting a skill from its jailed package to
   :passepartout, scan for restricted symbol references (uiop:run-program,
   uiop:shell, uiop:run-shell-command). Block promotion on violation.
   New skill-entry status :sandbox-blocked for blocked skills.

Test: 91 pass, 0 fail across 13 suites.
2026-05-06 16:46:49 -04:00
a31f19045a v0.3.1: eliminate RCE via *read-eval* nil (Parser RCE Elimination)
Some checks failed
Deploy (Gitea) / deploy (push) Failing after 3s
Wrap read-from-string/read with (let ((*read-eval* nil)) ...) at three
untrusted-input code paths:

1. think() in core-loop-reason — LLM output parsing. LLM output is
   untrusted by definition; #.(shell ...) in a response must not execute.

2. action-system-execute in core-loop-act — :system :eval path processes
   untrusted payload code from the signal pipeline.

3. load-memory-from-disk in core-memory — memory.snap file could be
   corrupted or planted in ~/, must not execute #. reader macros.

Adds test-read-eval-rce-blocked to pipeline-reason-suite: mocks a
backend returning malicious output containing #.(setf ...), verifies
no side effects occur and safe fallback is returned.

RED proof recorded: *read-eval* T + #.(setf ...) → :PWNED (RCE active)
GREEN proof:    *read-eval* NIL → reader-error caught (RCE blocked)

Test: reason 12/0, full suite 88/0
2026-05-06 16:38:59 -04:00
12 changed files with 112 additions and 46 deletions

View File

@@ -59,7 +59,7 @@
(cmd (getf payload :action)))
(case cmd
(:eval
(eval (read-from-string (getf payload :code))))
(eval (let ((*read-eval* nil)) (read-from-string (getf payload :code)))))
(:message
(log-message "ACT [System]: ~a" (getf payload :text)))
(t

View File

@@ -99,7 +99,7 @@
(markdown-strip thought))))
(if (and cleaned (stringp cleaned) (> (length cleaned) 0) (or (char= (char cleaned 0) #\() (char= (char cleaned 0) #\[)))
(handler-case
(let ((parsed (read-from-string cleaned)))
(let ((parsed (let ((*read-eval* nil)) (read-from-string cleaned))))
(if (listp parsed)
(let ((normalized (plist-keywords-normalize parsed)))
;; Ensure explanation is present in the payload for policy gate
@@ -283,3 +283,19 @@ sorted by priority (highest first). Returns a rejection plist or the action."
(list :status :success :content "mock-response")))
(let ((result (backend-cascade-call "hello" :cascade '(:mock-backend))))
(is (string= "mock-response" result)))))
(test test-read-eval-rce-blocked
"Contract 1/v0.3.1: #. reader macro in LLM output must not execute arbitrary code."
(let ((passepartout::*backend-registry* (make-hash-table :test 'equal))
(passepartout::*provider-cascade* '(:mock-evil)))
(setf (gethash :mock-evil passepartout::*backend-registry*)
(lambda (prompt sp &key model)
(declare (ignore prompt sp model))
(list :status :success :content "(#.(setf passepartout::*v031-rce-test* :PWNED))")))
(setf passepartout::*v031-rce-test* nil)
(setf *read-eval* t)
(let* ((ctx (list :type :EVENT :payload (list :sensor :user-input :text "test") :depth 0))
(result (passepartout::think ctx)))
(is (not (eq passepartout::*v031-rce-test* :PWNED)))
(is (eq :REQUEST (getf result :TYPE)))
(setf *read-eval* nil))))

View File

@@ -140,7 +140,7 @@
(when (uiop:file-exists-p path)
(handler-case
(with-open-file (stream path :direction :input)
(let ((data (read stream nil)))
(let ((data (let ((*read-eval* nil)) (read stream nil))))
(when data
(let ((memory-alist (getf data :memory)) (history-alist (getf data :history-store)))
(setf *memory-store* (make-hash-table :test 'equal :size (length memory-alist)))

View File

@@ -247,21 +247,38 @@ declarations so embedded test code evaluates in the correct package."
(log-message "LOADER: Loading .lisp skill '~a' in package ~a" skill-base-name (package-name *package*))
(with-input-from-string (s content)
(loop for form = (read s nil :eof) until (eq form :eof)
do (handler-case (eval form)
(error (c) (log-message "LOADER WARNING in '~a': ~a" skill-base-name c))))))
(let ((target-pkg (find-package :passepartout))
(exported 0)
(seen (make-hash-table :test 'equal)))
(do-symbols (sym (find-package pkg-name))
(when (and (eq (symbol-package sym) (find-package pkg-name))
(or (fboundp sym) (boundp sym))
(not (gethash (symbol-name sym) seen)))
(setf (gethash (symbol-name sym) seen) t)
(incf exported)
(let ((existing (find-symbol (symbol-name sym) target-pkg)))
(when existing (unintern existing target-pkg)))
(import sym target-pkg)
(ignore-errors (export sym target-pkg))))
do (handler-case (eval form)
(error (c) (log-message "LOADER WARNING in '~a': ~a" skill-base-name c))))))
(let* ((jailed-pkg (find-package pkg-name))
(restricted '("RUN-PROGRAM" "SHELL" "RUN-SHELL-COMMAND"))
(violation (loop for r in restricted
for sym = (find-symbol r :uiop)
when (and sym (fboundp sym)
(loop for skill-sym being the symbols of jailed-pkg
when (and (fboundp skill-sym)
(eq (symbol-function skill-sym)
(symbol-function sym)))
return skill-sym))
collect (format nil "~a" sym))))
(when violation
(log-message "LOADER SANDBOX: Skill '~a' blocked — references restricted symbol(s): ~{~a~^, ~}"
skill-base-name violation)
(setf (skill-entry-status entry) :sandbox-blocked)
(return-from load-skill-from-lisp nil))
(log-message "LOADER SANDBOX: Skill '~a' passed sandbox check" skill-base-name))
(let ((target-pkg (find-package :passepartout))
(exported 0)
(seen (make-hash-table :test 'equal)))
(do-symbols (sym (find-package pkg-name))
(when (and (eq (symbol-package sym) (find-package pkg-name))
(or (fboundp sym) (boundp sym))
(not (gethash (symbol-name sym) seen)))
(setf (gethash (symbol-name sym) seen) t)
(incf exported)
(let ((existing (find-symbol (symbol-name sym) target-pkg)))
(when existing (unintern existing target-pkg)))
(import sym target-pkg)
(ignore-errors (export sym target-pkg))))
(log-message "LOADER: Exported ~a symbols from ~a to :PASSEPARTOUT"
exported (package-name (find-package pkg-name))))
(setf (skill-entry-status entry) :ready))

View File

@@ -278,7 +278,8 @@ privacy tags, privacy text, shell safety, network exfil, high-impact approval."
;; Vector 8: High-impact action approval
((or (member target '(:shell))
(and (eq target :tool) (member (proto-get payload :tool) '("shell" "repair-file") :test #'string=))
(and (eq target :emacs) (eq (proto-get payload :action) :eval)))
(and (eq target :emacs) (eq (proto-get payload :action) :eval))
(and (eq target :system) (eq (proto-get payload :action) :eval)))
(log-message "SECURITY: High-impact action requires approval: ~a" (or (proto-get payload :tool) target))
(list :type :EVENT :payload (list :sensor :approval-required :action action)))
(t action))))

View File

@@ -1,16 +1,15 @@
(defun actuator-shell-execute (action context)
"Executes a bash command with timeout (via timeout(1)) and output limit."
"Executes a shell command via the OS timeout binary with output limit."
(declare (ignore context))
(let* ((payload (getf action :payload))
(cmd (getf payload :cmd))
(timeout-sym (find-symbol "*BOUNCER-SHELL-TIMEOUT*" :passepartout))
(timeout (or (getf payload :timeout) (if timeout-sym (symbol-value timeout-sym) 30)))
(max-sym (find-symbol "*BOUNCER-SHELL-MAX-OUTPUT*" :passepartout))
(max-output (or (getf payload :max-output) (if max-sym (symbol-value max-sym) 100000)))
(wrapped-cmd (format nil "timeout ~a bash -c ~s" timeout cmd)))
(max-output (or (getf payload :max-output) (if max-sym (symbol-value max-sym) 100000))))
(log-message "ACT [Shell]: ~a (timeout: ~as)" cmd timeout)
(multiple-value-bind (out err code)
(uiop:run-program (list "bash" "-c" wrapped-cmd)
(uiop:run-program (list "timeout" (format nil "~a" timeout) "bash" "-c" cmd)
:output :string :error-output :string
:ignore-error-status t)
(cond

View File

@@ -135,7 +135,7 @@ Handles internal harness commands: ~:eval~ (execute arbitrary Lisp) and ~:messag
(cmd (getf payload :action)))
(case cmd
(:eval
(eval (read-from-string (getf payload :code))))
(eval (let ((*read-eval* nil)) (read-from-string (getf payload :code)))))
(:message
(log-message "ACT [System]: ~a" (getf payload :text)))
(t

View File

@@ -235,7 +235,7 @@ The system prompt assembly order — identity, tools, context, logs, mandates
(markdown-strip thought))))
(if (and cleaned (stringp cleaned) (> (length cleaned) 0) (or (char= (char cleaned 0) #\() (char= (char cleaned 0) #\[)))
(handler-case
(let ((parsed (read-from-string cleaned)))
(let ((parsed (let ((*read-eval* nil)) (read-from-string cleaned))))
(if (listp parsed)
(let ((normalized (plist-keywords-normalize parsed)))
;; Ensure explanation is present in the payload for policy gate
@@ -460,4 +460,20 @@ Verifies that the deterministic engine correctly rejects unsafe actions (like ~r
(list :status :success :content "mock-response")))
(let ((result (backend-cascade-call "hello" :cascade '(:mock-backend))))
(is (string= "mock-response" result)))))
(test test-read-eval-rce-blocked
"Contract 1/v0.3.1: #. reader macro in LLM output must not execute arbitrary code."
(let ((passepartout::*backend-registry* (make-hash-table :test 'equal))
(passepartout::*provider-cascade* '(:mock-evil)))
(setf (gethash :mock-evil passepartout::*backend-registry*)
(lambda (prompt sp &key model)
(declare (ignore prompt sp model))
(list :status :success :content "(#.(setf passepartout::*v031-rce-test* :PWNED))")))
(setf passepartout::*v031-rce-test* nil)
(setf *read-eval* t)
(let* ((ctx (list :type :EVENT :payload (list :sensor :user-input :text "test") :depth 0))
(result (passepartout::think ctx)))
(is (not (eq passepartout::*v031-rce-test* :PWNED)))
(is (eq :REQUEST (getf result :TYPE)))
(setf *read-eval* nil))))
#+end_src

View File

@@ -349,7 +349,7 @@ Restores memory state from a previously saved snapshot file. Called during boot
(when (uiop:file-exists-p path)
(handler-case
(with-open-file (stream path :direction :input)
(let ((data (read stream nil)))
(let ((data (let ((*read-eval* nil)) (read stream nil))))
(when data
(let ((memory-alist (getf data :memory)) (history-alist (getf data :history-store)))
(setf *memory-store* (make-hash-table :test 'equal :size (length memory-alist)))

View File

@@ -372,21 +372,38 @@ The same jailed package and symbol export process applies.
(log-message "LOADER: Loading .lisp skill '~a' in package ~a" skill-base-name (package-name *package*))
(with-input-from-string (s content)
(loop for form = (read s nil :eof) until (eq form :eof)
do (handler-case (eval form)
(error (c) (log-message "LOADER WARNING in '~a': ~a" skill-base-name c))))))
(let ((target-pkg (find-package :passepartout))
(exported 0)
(seen (make-hash-table :test 'equal)))
(do-symbols (sym (find-package pkg-name))
(when (and (eq (symbol-package sym) (find-package pkg-name))
(or (fboundp sym) (boundp sym))
(not (gethash (symbol-name sym) seen)))
(setf (gethash (symbol-name sym) seen) t)
(incf exported)
(let ((existing (find-symbol (symbol-name sym) target-pkg)))
(when existing (unintern existing target-pkg)))
(import sym target-pkg)
(ignore-errors (export sym target-pkg))))
do (handler-case (eval form)
(error (c) (log-message "LOADER WARNING in '~a': ~a" skill-base-name c))))))
(let* ((jailed-pkg (find-package pkg-name))
(restricted '("RUN-PROGRAM" "SHELL" "RUN-SHELL-COMMAND"))
(violation (loop for r in restricted
for sym = (find-symbol r :uiop)
when (and sym (fboundp sym)
(loop for skill-sym being the symbols of jailed-pkg
when (and (fboundp skill-sym)
(eq (symbol-function skill-sym)
(symbol-function sym)))
return skill-sym))
collect (format nil "~a" sym))))
(when violation
(log-message "LOADER SANDBOX: Skill '~a' blocked — references restricted symbol(s): ~{~a~^, ~}"
skill-base-name violation)
(setf (skill-entry-status entry) :sandbox-blocked)
(return-from load-skill-from-lisp nil))
(log-message "LOADER SANDBOX: Skill '~a' passed sandbox check" skill-base-name))
(let ((target-pkg (find-package :passepartout))
(exported 0)
(seen (make-hash-table :test 'equal)))
(do-symbols (sym (find-package pkg-name))
(when (and (eq (symbol-package sym) (find-package pkg-name))
(or (fboundp sym) (boundp sym))
(not (gethash (symbol-name sym) seen)))
(setf (gethash (symbol-name sym) seen) t)
(incf exported)
(let ((existing (find-symbol (symbol-name sym) target-pkg)))
(when existing (unintern existing target-pkg)))
(import sym target-pkg)
(ignore-errors (export sym target-pkg))))
(log-message "LOADER: Exported ~a symbols from ~a to :PASSEPARTOUT"
exported (package-name (find-package pkg-name))))
(setf (skill-entry-status entry) :ready))

View File

@@ -426,7 +426,8 @@ privacy tags, privacy text, shell safety, network exfil, high-impact approval."
;; Vector 8: High-impact action approval
((or (member target '(:shell))
(and (eq target :tool) (member (proto-get payload :tool) '("shell" "repair-file") :test #'string=))
(and (eq target :emacs) (eq (proto-get payload :action) :eval)))
(and (eq target :emacs) (eq (proto-get payload :action) :eval))
(and (eq target :system) (eq (proto-get payload :action) :eval)))
(log-message "SECURITY: High-impact action requires approval: ~a" (or (proto-get payload :tool) target))
(list :type :EVENT :payload (list :sensor :approval-required :action action)))
(t action))))

View File

@@ -20,18 +20,17 @@ Because shell execution is the highest-risk operation in the system, the Shell A
;; REPL-VERIFIED: 2026-05-03T13:00:00
#+begin_src lisp
(defun actuator-shell-execute (action context)
"Executes a bash command with timeout (via timeout(1)) and output limit."
"Executes a shell command via the OS timeout binary with output limit."
(declare (ignore context))
(let* ((payload (getf action :payload))
(cmd (getf payload :cmd))
(timeout-sym (find-symbol "*BOUNCER-SHELL-TIMEOUT*" :passepartout))
(timeout (or (getf payload :timeout) (if timeout-sym (symbol-value timeout-sym) 30)))
(max-sym (find-symbol "*BOUNCER-SHELL-MAX-OUTPUT*" :passepartout))
(max-output (or (getf payload :max-output) (if max-sym (symbol-value max-sym) 100000)))
(wrapped-cmd (format nil "timeout ~a bash -c ~s" timeout cmd)))
(max-output (or (getf payload :max-output) (if max-sym (symbol-value max-sym) 100000))))
(log-message "ACT [Shell]: ~a (timeout: ~as)" cmd timeout)
(multiple-value-bind (out err code)
(uiop:run-program (list "bash" "-c" wrapped-cmd)
(uiop:run-program (list "timeout" (format nil "~a" timeout) "bash" "-c" cmd)
:output :string :error-output :string
:ignore-error-status t)
(cond