commit 6dfb427fbfa52cbb037b31445b7e596c3f877075 from: mtmn date: Fri Aug 28 18:35:56 2026 UTC replace fzf with fd, save on exit commit - c2ef7846314b3e8c70adc3a313cef7da32995f57 commit + 6dfb427fbfa52cbb037b31445b7e596c3f877075 blob - f7c8555345e82288b69856537a76e184616c5216 blob + f26d533171dc19fa023971ebbf7818c8298d912a --- README.md +++ README.md @@ -1,71 +1,68 @@ # 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+=( @@ -75,30 +72,24 @@ 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 @@ -108,7 +99,8 @@ make install 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 @@ -1,6 +1,6 @@ -.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 @@ -13,72 +13,68 @@ pisol \- configure and run working-directory Landrun i .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 @@ -86,51 +82,46 @@ If .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 @@ -10,7 +10,6 @@ (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)) @@ -49,106 +48,45 @@ (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)) @@ -187,7 +125,7 @@ (define (render configuration status) (clear-screen) - (display (intense "pisol ยท isolation")) + (display (intense "pisol")) (newline) (display (muted "config: ")) (display (configuration-config-path configuration)) @@ -217,13 +155,10 @@ (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) @@ -231,16 +166,16 @@ (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) @@ -260,7 +195,7 @@ [(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") @@ -293,21 +228,15 @@ (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 @@ -2,7 +2,8 @@ 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 @@ -24,6 +25,9 @@ chmod +x "$fake_bin/landrun" 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 \ @@ -59,7 +63,7 @@ grep -Fx -- '443' "$test_root/args.txt" >/dev/null 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=$? @@ -71,10 +75,10 @@ run_pisol --help | grep -F 'pisol COMMAND' >/dev/null 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 @@ -86,7 +90,7 @@ if grep -E 'project:|p project|h home|browse/add' "$te 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 @@ -98,9 +102,13 @@ printf 'p 8443\n' | run_pisol >/dev/null [ "$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 @@ -120,7 +128,7 @@ grep -Fx -- '443' "$test_root/args.txt" >/dev/null # 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