commit - c2ef7846314b3e8c70adc3a313cef7da32995f57
commit + 6dfb427fbfa52cbb037b31445b7e596c3f877075
blob - f7c8555345e82288b69856537a76e184616c5216
blob + f26d533171dc19fa023971ebbf7818c8298d912a
--- README.md
+++ README.md
# pisol
-Run commands in a Landrun sandbox and manage each project's isolation settings.
-The terminal interface is written in Chez Scheme.
+Run commands in a Landrun sandbox. Run `pisol` with no command to manage the
+current project's isolation settings.
-`pisol` uses a trusted local `.isolate` Bash file. It denies outbound TCP by
-default. Use the editor to add writable and executable directories, and allow
-connections to specific TCP ports.
+`pisol` reads trusted Bash from `.isolate` in the current directory. It denies
+outbound TCP by default.
-## Run
+## Requirements
-You need Bash, [Landrun](https://github.com/Zouuup/landrun), Chez Scheme 10.4
-or later, and Make.
+You need:
-Install `fzf` to search for directories. Without it, enter a path directly.
+- Bash
+- [Landrun](https://github.com/Zouuup/landrun)
+- Chez Scheme 10.4 or later
+- Make
+## Start
+
+Build and run the editor:
+
```sh
make tui
-make test
```
-After installation, open the editor in the working directory:
+Run a command in the current project's sandbox:
```sh
-pisol
-```
-
-Run a command in that directory sandbox:
-
-```sh
pisol your-command --with arguments
```
-Use `pisol -- COMMAND` when a command name conflicts with a pisol option.
+Use `pisol -- COMMAND` when the command name is also a pisol option.
-The command receives your environment, `PROMPT_ENV_INDICATOR=isolated` and
-`ISOLATE_ENV`, set to the resolved working directory. If you run `pisol COMMAND`
-inside an existing pisol sandbox, it warns you and runs the command directly.
+The command receives your environment and these variables:
-## Terminal interface commands
+- `PROMPT_ENV_INDICATOR=isolated`
+- `ISOLATE_ENV`, set to the resolved project directory
+If `ISOLATE_ENV` is already set, pisol warns you and runs the command directly.
+It does not create a nested Landlock domain.
+
+## Use the editor
+
+The editor accepts these commands:
+
```text
-a fuzzy-find or enter a writable directory path
-r choose and remove a writable directory
-p PORT allow outbound TCP connections to a destination port
-d enter and remove an outbound TCP port
-c show global and local configuration file contents
-s save
-? help
-x exit (discards any unsaved changes)
+a add a writable directory
+r remove a writable directory
+p PORT allow outbound TCP to PORT
+d remove an outbound TCP port
+c show configuration files
+? show help
+x save and exit
```
-After you select `a`, enter a base path such as `/home/miro`. Press Enter to
-start in your home directory. `fzf` shows the path and its immediate child
-directories. Press Enter to open a directory. Press Alt+Enter to select the
-directory under the cursor. Press Ctrl-S to save the current directory. Press
-Escape to use the path you entered.
+When you add a directory, the input starts at your home directory. Press Tab to
+complete a path. Press Tab twice to list matches. Press Enter to add the path.
-You cannot add the baseline writable tree again.
+You cannot add the project's writable tree again.
-Set `NO_COLOR=1` to disable colour. `COLUMNS` and `LINES` are respected by the
-terminal environment. Set `ISOLATE_EXTRA_CONFIG` to use a config path other
-than `CURRENT_DIRECTORY/.isolate`.
+## Configuration
-## Configuration and trust
+`.isolate` is trusted Bash code. Only use configuration files you trust.
-`.isolate` is trusted Bash code. The launcher sources it. A generated file
-looks like this:
+A generated file looks like this:
```bash
args+=(
```
A global init file at `~/.config/pisol/init` runs before the local `.isolate`
-file. Put shared grants there. Project settings build on those grants. Set
-`PISOL_INIT_CONFIG` to use a different global init file.
+file. Use it for shared grants. Set `PISOL_INIT_CONFIG` to use another path.
-The source tree includes a permissive example init at `config/pisol/init`.
-Copy it to `~/.config/pisol/init` and change it for your needs. Remove
+The source tree includes an example init file at `config/pisol/init`. Copy it
+to `~/.config/pisol/init` and change it for your needs. Remove
`--unrestricted-network` to deny outbound TCP by default.
-The editor evaluates existing custom `.isolate` code. It manages the resulting
-`--rwx` and `--connect-tcp` values and keeps the other Landrun arguments.
+pisol evaluates custom `.isolate` files and manages their `--rwx` and
+`--connect-tcp` arguments. Saving replaces custom code with a static argument
+list. This removes comments and dynamic logic.
-Saving custom code needs confirmation. It replaces the code with a static
-argument array, so it removes comments and dynamic logic. The editor shows
-global grants but does not let you change them. Add or remove project grants
-instead.
+The project tree is readable, writable and executable. Temporary storage is
+writable. System configuration, shared data and standard runtime paths are
+read-only. pisol grants the standard null, zero, random and terminal devices.
+It adds a Wayland socket when `WAYLAND_DISPLAY` and `XDG_RUNTIME_DIR` are set.
-The default policy gives the working tree read, write and execute access. It
-also gives write access to temporary storage. System configuration and shared
-data are read-only. Standard runtime and absolute `PATH` directories are
-readable and executable.
+Set `NO_COLOR=1` to disable colour. Set `ISOLATE_EXTRA_CONFIG` to use a local
+configuration file other than `CURRENT_DIRECTORY/.isolate`.
-The policy allows the standard null, zero, random and terminal devices. It does
-not allow unrestricted networking. It adds a Wayland socket when
-`WAYLAND_DISPLAY` and `XDG_RUNTIME_DIR` are set.
-
## Build and install
```sh
make copy-config
```
-`make install` defaults to `/usr/local`; use `sudo make install` only when your
-chosen destination requires it.
-Set `PREFIX`, `DESTDIR`, `BINDIR`, `LIBEXECDIR`, `MANDIR`, or `SCHEME` to
-override the defaults. See `pisol(1)` after installation.
+`make install` uses `/usr/local` by default. Use `sudo make install` only when
+your chosen destination needs it.
+
+Set `PREFIX`, `DESTDIR`, `BINDIR`, `LIBEXECDIR`, `MANDIR` or `SCHEME` to change
+the build or installation paths.
blob - 11d180712ace8005b1e22e65eed4c54e881ab6e2
blob + ff730a838058e6ffc6f53adc6d606321ff25dad7
--- doc/pisol.1
+++ doc/pisol.1
-.TH PISOL 1 "2026-08-27" "pisol" "User Commands"
+.TH PISOL 1 "2026-08-28" "pisol" "User Commands"
.SH NAME
-pisol \- configure and run working-directory Landrun isolation
+pisol \- run commands in a Landrun sandbox
.SH SYNOPSIS
.B pisol
.br
.RI [ ARG ...]
.SH DESCRIPTION
.B pisol
-opens a Chez Scheme terminal editor for the current directory's isolation
-settings. With a command, it replaces itself with
-.BR landrun (1)
-and runs the command in a sandbox for the current directory. Use
+opens a terminal editor for the current project's isolation settings. With a
+command, it runs that command in a Landrun sandbox for the current directory.
+Use
.B --
before a command whose name conflicts with a pisol option.
.PP
-The resolved current directory defines the writable tree. The configuration
-path is
+The current directory is the writable project tree. pisol reads its
+configuration from
.I CURRENT_DIRECTORY/.isolate
unless
.B ISOLATE_EXTRA_CONFIG
is set.
.SH ISOLATION POLICY
-The working tree is readable, writable and executable. Temporary storage is
-writable. Standard runtime and absolute PATH directories are readable and
-executable. System configuration and shared data are read-only. The standard
+The project tree is readable, writable and executable. Temporary storage is
+writable. Standard runtime paths and absolute PATH directories are readable and
+executable. System configuration and shared data are read-only. The standard
null, zero, random and controlling-terminal devices are writable.
.PP
-Outbound TCP is denied by default. Only destination ports configured with
-.B --connect-tcp
-are granted. Scoped IPC restrictions are relaxed, and the current Wayland
-socket is granted when its environment variables are available.
+Outbound TCP is denied by default. pisol grants only destination ports listed
+with
+.B --connect-tcp .
+It relaxes scoped IPC restrictions. It grants the current Wayland socket when
+its environment variables are available.
.PP
-The command receives the caller environment. It also receives
+The command receives the caller environment, plus
.B PROMPT_ENV_INDICATOR=isolated
and
.B ISOLATE_ENV
-set to the resolved working directory. If
+set to the resolved project directory. If
.B ISOLATE_ENV
-is already non-empty, pisol warns and runs the command directly. It does not
-start a nested Landlock domain.
-.SH TERMINAL INTERFACE COMMANDS
+is already set, pisol warns and runs the command directly. It does not start a
+nested Landlock domain.
+.SH TERMINAL COMMANDS
.TP
.B a
-Prompt for a base directory path and add a read/write/execute grant. With no
-input, the picker opens in the user's home directory. When
-.BR fzf (1)
-is available, the picker lists the typed path and its immediate child
-directories. Press Enter to open a directory. Press Alt+Enter to select the
-directory under the cursor. Press Ctrl-S to save the current directory. Press
-Escape to use the typed path directly.
+Add a read, write and execute directory grant. The path starts at the home
+directory. Press Tab to complete a path. Press Tab twice to list matches.
.TP
.B r
-Prompt for the number of a writable directory and remove it.
+Remove a writable directory by number.
.TP
.BI p " PORT"
-Allow outbound TCP connections to PORT. Valid ports are 1 to 65535.
+Allow outbound TCP to PORT. Valid ports are 1 to 65535.
.TP
.B d
-Prompt for a configured outbound TCP port and remove it.
+Remove an outbound TCP port.
.TP
.B c
-Show the global init file and local configuration file, including their raw
-contents. A missing file is labelled absent.
+Show the global init file and local configuration file. A missing file is
+labelled absent.
.TP
-.B s
-Save atomically with mode 0600.
+.B ?
+Show help.
.TP
-.BR ? , " x"
-Show help or exit. Exit discards any unsaved changes.
+.B x
+Save changes atomically with mode 0600, then exit.
.SH CONFIGURATION
.I .isolate
-is trusted Bash code. The launcher sources it. It changes the Bash array
-.IR args ,
-normally with an
+is trusted Bash code. The launcher sources it. It normally changes the Bash
+array
+.IR args
+with an
.B args+=(...)
fragment.
.PP
.I ~/.config/pisol/init
exists, the launcher sources it before the local
.I .isolate .
-Put shared grants in the global init file. Per-project configuration builds on
-it. Set
-.BR PISOL_INIT_CONFIG .
+Use it for shared grants. Set
+.B PISOL_INIT_CONFIG
+to use another init file.
.PP
-A permissive example global init based on the legacy
-.I isolate
-script is included in the source tree at
+The source tree includes an example init file at
.IR config/pisol/init .
Copy it to
.I ~/.config/pisol/init
-and change it for your needs. Remove
+and change it for your needs. Remove
.B --unrestricted-network
-from the copy to deny outbound TCP by default.
+to deny outbound TCP by default.
.PP
-The editor evaluates an existing file. It manages the resulting
+pisol evaluates an existing configuration. It manages the resulting
.B --rwx
and
.B --connect-tcp
-pairs and keeps other resulting arguments. Saving custom shell code requires
-confirmation. It replaces the code with a static array, so it removes source
-comments and dynamic logic. The terminal interface shows global init grants.
-It does not let you change them.
+arguments and keeps other arguments. Saving custom shell code replaces it with
+a static array. This removes comments and dynamic logic. The editor shows global
+init grants but does not let you change them.
.SH ENVIRONMENT
.TP
.B ISOLATE_EXTRA_CONFIG
Override the local configuration path.
.TP
.B PISOL_INIT_CONFIG
-Override the global init path. Defaults to
+Override the global init path. The default is
.IR ~/.config/pisol/init .
.TP
.B ISOLATE_ENV
Marks an existing pisol sandbox and prevents nesting.
.TP
.B NO_COLOR
-Disable TUI colour when set to any value.
+Disable TUI colour when set.
.TP
.BR HOME , " PATH" , " TMPDIR"
Supply runtime paths and grants.
.TP
.BR WAYLAND_DISPLAY , " XDG_RUNTIME_DIR"
-Identify an optional Wayland Unix socket grant.
+Identify an optional Wayland socket grant.
.SH EXIT STATUS
-The terminal interface returns zero on normal exit and non-zero on load or save errors. In
-command mode, pisol propagates the executed command status. Usage errors
-return 2 and a missing Landrun executable returns 127.
-.SH SEE ALSO
-.BR fzf (1)
+The editor returns zero on normal exit. It returns non-zero on load or save
+errors. In command mode, pisol returns the command's status. Usage errors return
+2. A missing Landrun executable returns 127.
blob - 141b2d3a14bded8e4f707718e6d773d08d4056c4
blob + deea5a5cd9bc438d38efd8180505449c9f483752
--- src/pisol/tui.sls
+++ src/pisol/tui.sls
(define (ansi code text)
(if color? (string-append esc "[" code "m" text esc "[0m") text))
(define (cyan text) (ansi "38;2;155;246;255" text))
- (define (green text) (ansi "38;2;202;255;191" text))
(define (yellow text) (ansi "38;2;253;255;182" text))
(define (muted text) (ansi "38;2;108;117;125" text))
(define (intense text) (ansi "1;38;2;248;249;250" text))
(let ([line (get-line (current-input-port))])
(and (not (eof-object? line)) line)))
- (define (command-available? command)
- (zero? (system (string-append "command -v " command " >/dev/null 2>&1"))))
-
(define (terminal-input?)
(zero? (system "test -t 0")))
(define (temporary-path)
(let ([now (current-time)])
- (format "~a/pisol-fzf-~a.~a"
+ (format "~a/pisol-path-~a.~a"
(or (getenv "TMPDIR") "/tmp")
(time-second now)
(time-nanosecond now))))
- (define (read-output-lines path)
- (and (file-exists? path)
- (call-with-input-file path
- (lambda (port)
- (let loop ([lines '()])
- (let ([line (get-line port)])
- (if (eof-object? line)
- (reverse lines)
- (loop (cons line lines)))))))))
(define (read-text path)
(call-with-input-file path get-string-all))
- (define (read-current-path path)
- (and (file-exists? path)
- (let ([content (read-text path)])
- (let ([trimmed (trim content)])
- (if (string=? trimmed "") #f trimmed)))))
-
- (define (fuzzy-directory base)
- (if (or (not (terminal-input?))
- (not (command-available? "fzf"))
- (not (file-directory? base)))
- base
- (let* ([output (temporary-path)]
- [current-file (string-append output ".dir")]
- [fd? (command-available? "fd")]
- [child-list (lambda (dir)
- (if fd?
- (string-append "fd --absolute-path --type directory "
- "--hidden --exclude .git --max-depth 1 . "
- (shell-quote dir) " 2>/dev/null")
- (string-append "find " (shell-quote dir)
- " -maxdepth 1 -type d 2>/dev/null")))]
- [initial (child-list base)]
- [reload (child-list "{+1}")]
- [command
- (string-append
- "{ " initial "; } "
- "| fzf --scheme=path --layout=reverse --height=80% --border"
- " --expect=esc,ctrl-s"
- " --prompt='Directory path: '"
- " --header='Enter drills down. Alt+Enter selects the cursor item. Ctrl-S saves the current directory. Esc uses the typed path.'"
- " --bind 'enter:execute-silent(printf %s '{+1}' > " (shell-quote current-file) ")+reload(" reload ")+clear-query'"
- " --bind 'alt-enter:accept'"
- " >" (shell-quote output))])
+ (define (read-directory-path prompt initial)
+ ;; Bash Readline supplies native filename completion on TAB.
+ (if (not (terminal-input?))
+ (read-input prompt)
+ (let ([output (temporary-path)])
(dynamic-wind
(lambda () #f)
(lambda ()
- (let ([status (system command)])
- (cond
- [(not (zero? status)) #f]
- [else
- (let ([lines (read-output-lines output)])
- (cond
- [(not lines) base]
- [(null? lines) base]
- [(string=? (car lines) "esc") base]
- [(string=? (car lines) "ctrl-s") (or (read-current-path current-file) base)]
- [else
- (let ([selection (car lines)])
- (if (not (string=? (trim selection) ""))
- selection
- base))]))])))
+ (let ([status (system (string-append
+ "bash -c "
+ (shell-quote "IFS= read -e -r -i \"$3\" -p \"$1\" path || exit $?; printf '%s' \"$path\" > \"$2\"")
+ " bash " (shell-quote prompt) " " (shell-quote output) " " (shell-quote initial)))])
+ (and (zero? status) (file-exists? output) (read-text output))))
(lambda ()
- (when (file-exists? output) (delete-file output))
- (when (file-exists? current-file) (delete-file current-file)))))))
+ (when (file-exists? output) (delete-file output)))))))
(define (choose-directory)
(let* ([home (getenv "HOME")]
- [default (or home "")]
- [prompt (if (string=? default "")
- "Directory path: "
- (string-append "Directory path [" default "]: "))]
- [entry (read-input prompt)])
- (and entry
- (let ([base (trim entry)])
- (if (string=? base "")
- (and (not (string=? default ""))
- (fuzzy-directory default))
- (fuzzy-directory base))))))
+ [default (if (and home (file-directory? home))
+ (if (string=? home "/") home (string-append home "/"))
+ "")])
+ (let ([entry (read-directory-path "" default)])
+ (and entry
+ (let ([path (trim entry)])
+ (if (string=? path "") default path))))))
- (define (yes? prompt)
- (let ([answer (read-input (string-append prompt " [y/N] "))])
- (and answer
- (member (string-downcase (trim answer)) '("y" "yes"))
- #t)))
-
(define (integer-text text)
(and (> (string-length text) 0)
(for-all char-numeric? (string->list text))
(define (render configuration status)
(clear-screen)
- (display (intense "pisol ยท isolation"))
+ (display (intense "pisol"))
(newline)
(display (muted "config: "))
(display (configuration-config-path configuration))
(newline)
(display "a add dir r remove dir p add port d remove port")
(newline)
- (display "s save c show config ? help x exit")
+ (display "c config x save and exit")
(newline)
- (when (configuration-dirty? configuration)
- (display (yellow "unsaved changes"))
- (newline))
- (when (and status (not (string=? status "")))
- (display (if (string-prefix? "saved" status) (green status) (yellow status)))
+ (unless (string=? status "")
+ (display (yellow status))
(newline)))
(define (show-help)
(display (intense "pisol help"))
(newline)
(newline)
- (display "Enter a base path; fuzzy selection lists it and its immediate children.")
+ (display "The path starts at your home directory. Press Tab to complete it.")
(newline)
- (display "Cancel the picker to use the typed path directly.")
+ (display "Press Tab twice to list matching paths.")
(newline)
(display "The baseline writable tree cannot be added again. TCP is denied unless listed.")
(newline)
(newline)
- (display "Existing custom .isolate shell is trusted and evaluated. Saving it replaces")
+ (display "Custom .isolate files are trusted shell code. Saving replaces them with")
(newline)
- (display "the source with the canonical evaluated Landrun argument list.")
+ (display "a static list of Landrun arguments.")
(newline)
(display "c shows the raw global and local configuration files.")
(newline)
[(not path) #t]
[(string=? (trim path) "") (loop "directory path is required")]
[(add-directory! configuration (trim path))
- (loop (string-append "added " (trim path)))]
+ (loop (string-append (trim path) " added"))]
[else
(loop "directory is missing, duplicated, or already writable")]))]
[(string=? command "r")
(if (and port (remove-port! configuration port))
(loop (format "removed outbound TCP port ~a" port))
(loop "configured port not found")))))]
- [(string=? command "s")
- (cond
- [(not (configuration-dirty? configuration))
- (loop "nothing to save")]
- [(and (eq? (configuration-source-style configuration) 'custom)
- (not (yes? "Replace custom shell with canonical evaluated arguments?")))
- (loop "save cancelled")]
- [else
- (guard (condition
- (else (loop (format "save failed: ~a" condition))))
- (save-configuration! configuration)
- (loop "saved .isolate"))])]
[(string=? command "?") (show-help) (loop "")]
[(string=? command "c") (show-config-files configuration) (loop "")]
- [(string=? command "x") #t]
+ [(string=? command "x")
+ (if (not (configuration-dirty? configuration))
+ #t
+ (guard (condition
+ (else (loop (format "save failed: ~a" condition))))
+ (save-configuration! configuration)
+ #t))]
[(string=? command "") (loop status)]
[else (loop "unknown command; enter ? for help")]))))))
)
blob - 09a680f38add099be98173fb824792979f375e2a
blob + 3a6505499fbcbbb348dac2226dc9734c39f81d9c
--- test/integration.sh
+++ test/integration.sh
set -eu
project_root=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
-test_root=${TMPDIR:-/tmp}/pisol-integration.$$
+outer_isolate_env=${ISOLATE_ENV:-}
+test_root=$(mktemp -d "$project_root/.pisol-integration.XXXXXX")
fake_bin=$test_root/bin
project=$test_root/project
home=$test_root/home
run_pisol() {
(
cd "$project"
+ if [ "${ISOLATE_ENV:-}" = "$outer_isolate_env" ]; then
+ unset ISOLATE_ENV
+ fi
PATH=$fake_bin:$PATH \
HOME=$home \
PISOL_RUNTIME_DIR=$project_root/build/runtime \
grep -Fx -- "$home/extra dir" "$test_root/args.txt" >/dev/null
before=$(cksum "$capture")
-if ISOLATE_ENV=already run_pisol -- sh -c 'exit 9'; then
+if ISOLATE_ENV=already run_pisol -- sh -c 'exit 9' 2>/dev/null; then
exit 1
else
status=$?
run_pisol --help | grep -F 'pisol -- COMMAND' >/dev/null
rm -f "$project/.isolate"
-printf 'a\n%s\np 443\ns\nx\n' "$home/extra dir" | run_pisol >"$test_root/tui.out"
+printf 'a\n%s\np 443\nx\n' "$home/extra dir" | run_pisol >"$test_root/tui.out"
grep -F -- "--rwx '$home/extra dir'" "$project/.isolate" >/dev/null
grep -F -- '--connect-tcp 443' "$project/.isolate" >/dev/null
-grep -F 'saved' "$test_root/tui.out" >/dev/null
+grep -F 'x save and exit' "$test_root/tui.out" >/dev/null
grep -F 'r remove dir' "$test_root/tui.out" >/dev/null
grep -F 'd remove port' "$test_root/tui.out" >/dev/null
if grep -E 'r NUMBER remove dir|d PORT remove' "$test_root/tui.out" >/dev/null; then
exit 1
fi
-printf 'r\n1\nd\n443\ns\nx\n' | run_pisol >"$test_root/remove.out"
+printf 'r\n1\nd\n443\nx\n' | run_pisol >"$test_root/remove.out"
if grep -F -- '--rwx' "$project/.isolate" >/dev/null ||
grep -F -- '--connect-tcp' "$project/.isolate" >/dev/null; then
echo "pisol integration: prompted removal did not remove configuration" >&2
[ "$saved" = "$(cksum "$project/.isolate")" ]
printf '%s\n' 'if true; then args+=(--ro /custom); fi' >"$project/.isolate"
-custom=$(cksum "$project/.isolate")
-printf 'p 80\ns\nn\nx\ny\n' | run_pisol >/dev/null
-[ "$custom" = "$(cksum "$project/.isolate")" ]
+printf 'p 80\nx\n' | run_pisol >/dev/null
+grep -F -- '/custom' "$project/.isolate" >/dev/null
+grep -F -- '--connect-tcp 80' "$project/.isolate" >/dev/null
+if grep -F 'if true' "$project/.isolate" >/dev/null; then
+ echo "pisol integration: custom configuration was not canonicalized on exit" >&2
+ exit 1
+fi
# Global init defaults are sourced before the local .isolate.
global_init=$home/.config/pisol/init
# show-config command prints both global and local configuration files.
printf 'c\n\nx\n' | run_pisol >"$test_root/showconfig.out"
-grep -F 'c show config' "$test_root/showconfig.out" >/dev/null
+grep -F 'c config' "$test_root/showconfig.out" >/dev/null
grep -F -- "$global_init" "$test_root/showconfig.out" >/dev/null
grep -F -- "$project/.isolate" "$test_root/showconfig.out" >/dev/null
grep -F -- "$home/extra dir" "$test_root/showconfig.out" >/dev/null