Skip to content

Instantly share code, notes, and snippets.

@lukehinds
Created February 16, 2026 14:14
Show Gist options
  • Select an option

  • Save lukehinds/c7228866afb79284e1ca2701b4c4d894 to your computer and use it in GitHub Desktop.

Select an option

Save lukehinds/c7228866afb79284e1ca2701b4c4d894 to your computer and use it in GitHub Desktop.

nono QA Test Plan

Comprehensive manual verification procedures for all nono subsystems across macOS and Linux.

Prerequisites: A built nono binary (make build).

Important: /tmp is in the base system groups with read+write access. All "outside" test paths must use locations NOT in any system group. $HOME/nono-qa/ works because $HOME itself is not in any allow group (only specific subdirs like ~/.claude, ~/.cargo, etc.).


Platform Capabilities

Feature Linux macOS
Sandbox backend Landlock Seatbelt
Deny semantics Allow-list only; no explicit deny Kernel-enforced (deny ...) rules
deny.access Path simply not in allow list Blocks content read+write (metadata allowed)
deny.unlink No separate control; part of write (deny file-write-unlink)
Deny within allow Does NOT work (allow is absolute) Works (specificity wins)
Symlink pairs Only resolved path used Both symlink + target get rules
platform_rules Ignored Injected verbatim in Seatbelt profile
Network blocking Landlock ABI V4+ (TCP only) Always enforced
Capability expansion seccomp-notify (kernel 5.14+) Disabled (DYLD issues with SIP/arm64)
Learn mode strace-based Not supported

Setup

# Test directories
mkdir -p ~/nono-qa/inside ~/nono-qa/outside ~/nono-qa/profile-test ~/nono-qa/undo-test

# Test files
echo "inside-file" > ~/nono-qa/inside/data.txt
echo "outside-file" > ~/nono-qa/outside/secret.txt
echo "profile-data" > ~/nono-qa/profile-test/notes.txt
echo "file-two" > ~/nono-qa/outside/other.txt

# Binary file for undo testing
dd if=/dev/urandom of=~/nono-qa/undo-test/binary.bin bs=1024 count=1 2>/dev/null
echo "original-content" > ~/nono-qa/undo-test/tracked.txt

# User profile directory
mkdir -p ~/.config/nono/profiles

Part 1: Sandbox Enforcement

Core kernel-level isolation via Landlock (Linux) and Seatbelt (macOS).

1.1 Basic Sandbox -- Allowed Path

Goal: Verify that an explicitly allowed path is accessible inside the sandbox.

nono run --allow ~/nono-qa/inside -- cat ~/nono-qa/inside/data.txt

Expected:

  • cat succeeds, prints inside-file
  • Exit code 0

1.2 Basic Sandbox -- Disallowed Path

Goal: Verify that a path not in the capability set is denied.

nono run --allow ~/nono-qa/inside -- cat ~/nono-qa/outside/secret.txt

Expected:

  • cat fails with "Operation not permitted" or "Permission denied"
  • Exit code non-zero

1.3 Read-Only Enforcement

nono run --read ~/nono-qa/inside -- cat ~/nono-qa/inside/data.txt

Expected: cat succeeds (read allowed).

nono run --read ~/nono-qa/inside -- sh -c 'echo "overwrite" > ~/nono-qa/inside/data.txt'

Expected: Write fails -- --read grants Read-only.

1.4 Write-Only Enforcement

touch ~/nono-qa/outside/write-target.txt
nono run --write ~/nono-qa/outside -- sh -c 'echo "written" > ~/nono-qa/outside/write-target.txt'

Expected: Write succeeds.

nono run --write ~/nono-qa/outside -- cat ~/nono-qa/outside/secret.txt

Expected: Read fails -- --write does not grant read access.

1.5 File-Level Permissions

nono run --read-file ~/nono-qa/outside/secret.txt -- cat ~/nono-qa/outside/secret.txt

Expected: cat succeeds -- file-level read granted.

nono run --read-file ~/nono-qa/outside/secret.txt -- cat ~/nono-qa/outside/other.txt

Expected: cat fails -- only the specific file was granted, not the directory.

1.6 Network Blocking

nono run --net-block -- curl -s --max-time 5 https://example.com

Expected:

  • curl fails with a network error
  • On macOS: Seatbelt (deny network*) blocks all sockets
  • On Linux: Landlock ABI V4+ blocks TCP; if ABI < V4, warning printed and network NOT blocked
nono run -- curl -s --max-time 5 https://example.com | head -5

Expected: Network succeeds -- allowed by default without --net-block.

1.7 Sandbox Is Irreversible

nono run --allow ~/nono-qa/inside -- sh -c 'cat ~/nono-qa/inside/data.txt && cat ~/nono-qa/outside/secret.txt'

Expected:

  • First cat succeeds
  • Second cat fails
  • The child cannot expand its own sandbox after restrict_self() / sandbox_init()

1.8 Symlink Pair Handling (macOS)

# macOS only
echo "test" > /tmp/nono-qa-symlink-test.txt
nono run --allow /tmp -- cat /tmp/nono-qa-symlink-test.txt

Expected: cat succeeds -- Seatbelt profile has rules for both /tmp and /private/tmp.

nono run --allow /tmp --dry-run -- true 2>&1

Expected: Dry-run output shows both symlink and target paths.

nono run -- cat /etc/hosts

Expected: Succeeds -- /etc is in system_read_macos with symlink pair to /private/etc.

1.9 Landlock ABI Detection (Linux)

nono setup 2>&1

Expected on Linux:

  • Shows detected Landlock ABI version (V1-V5)
  • Shows which features are available at each ABI level
  • If Landlock not available: actionable error message with boot parameter instructions

Part 2: Policy System

Group-based policy, profiles, CLI overrides, and capability resolution.

2.1 Base Policy Groups

Goal: Verify base groups are always applied.

System read paths

nono run -- cat /usr/share/zsh/5.9/functions/_ls 2>/dev/null | head -3

(Adjust path to any readable file under /usr/share/.)

Expected: Succeeds -- /usr is in system_read_macos / system_read_linux.

System write paths

nono run -- sh -c 'echo test > /tmp/nono-qa-write-test.txt && cat /tmp/nono-qa-write-test.txt && rm /tmp/nono-qa-write-test.txt'

Expected: Succeeds -- /tmp is in system_write_* groups.

Deny groups block credentials

mkdir -p ~/.aws && echo "test-cred" > ~/.aws/nono-qa-test
nono run -- cat ~/.aws/nono-qa-test
rm ~/.aws/nono-qa-test

Expected: cat fails -- ~/.aws is in deny_credentials group.

SSH directory blocked

nono run -- ls ~/.ssh/

Expected: Fails -- ~/.ssh is in deny_credentials.

Shell history blocked

nono run -- cat ~/.zsh_history 2>&1
nono run -- cat ~/.bash_history 2>&1

Expected: Both fail -- deny_shell_history group.

Browser data blocked

# macOS
nono run -- ls ~/Library/Application\ Support/Google/Chrome/ 2>&1
# Linux
nono run -- ls ~/.config/google-chrome/ 2>&1

Expected: Fails -- deny_browser_data_* groups.

2.2 Dangerous Command Blocking

rm is blocked

echo "expendable" > /tmp/nono-qa-expendable.txt
nono run --allow /tmp -- rm /tmp/nono-qa-expendable.txt

Expected: rm blocked -- in dangerous_commands group. File still exists.

dd is blocked

nono run -- dd if=/dev/zero of=/dev/null bs=1 count=1

Expected: dd blocked.

--allow-command overrides blocklist

echo "expendable" > /tmp/nono-qa-expendable.txt
nono run --allow /tmp --allow-command rm -- rm /tmp/nono-qa-expendable.txt

Expected: rm succeeds -- --allow-command rm overrides the blocklist.

--block-command extends blocklist

nono run --allow-cwd --block-command ls -- ls ~/nono-qa/inside/

Expected: ls blocked -- added by --block-command.

macOS-specific dangerous commands

# macOS only
nono run -- diskutil list 2>&1

Expected: diskutil blocked -- in dangerous_commands_macos.

2.3 Platform Group Filtering

macOS groups skipped on Linux

# Linux only
nono run -- ls /private/etc/ 2>&1

Expected: /private/etc does not exist; system_read_macos group was skipped.

Cross-platform groups apply everywhere

nono run --dry-run -- true 2>&1

Expected: deny_credentials resolved on both platforms; ~/.ssh, ~/.aws in deny paths.

2.4 Path Expansion in Groups

Tilde expansion

nono run -- ls ~/.cargo/bin/ 2>/dev/null | head -3

Expected: Succeeds if ~/.cargo/bin exists -- ~ expanded during group resolution.

$TMPDIR expansion

nono run -- sh -c 'echo $TMPDIR && ls $TMPDIR | head -3'

Expected: $TMPDIR accessible -- expanded in system write groups.

Non-existent allow paths

nono run --dry-run -vv -- true 2>&1 | grep -i "skip\|non-existent\|does not exist"

Expected: Debug warnings for non-existent paths; no errors.

2.5 Built-in Profile Resolution

claude-code profile

nono run --profile claude-code --dry-run -- true 2>&1

Expected:

  • Shows resolved groups (base + profile groups including node_runtime, rust_runtime, etc.)
  • Deny rules for credentials, browser data, keychains
  • Blocked commands
  • Network allowed
  • Profile filesystem entries: ~/.claude, ~/.vscode, etc.

Profile filesystem entries are additive

Expected from above dry-run:

  • $HOME/.claude in allowed paths (profile filesystem.allow)
  • $HOME/.gitconfig in read paths (profile filesystem.read_file)
  • Additive on top of group-resolved paths

Profile workdir access

cd ~/nono-qa/inside
nono run --profile claude-code --allow-cwd -- cat data.txt

Expected: Succeeds -- claude-code sets workdir.access = "readwrite", --allow-cwd skips prompt.

2.6 User TOML Profile

Create test profile

cat > ~/.config/nono/profiles/qa-test.toml << 'EOF'
[meta]
name = "qa-test"
version = "1.0.0"
description = "QA test profile"

[security]
groups = ["node_runtime", "rust_runtime"]

[filesystem]
allow = ["$HOME/nono-qa/profile-test"]
read = []
write = []
allow_file = []
read_file = []
write_file = []

[network]
block = false

[workdir]
access = "read"
EOF

Profile resolves correctly

nono run --profile qa-test --allow-cwd --dry-run -- true 2>&1

Expected: Base groups + node_runtime + rust_runtime; ~/nono-qa/profile-test in allowed paths; CWD with Read access.

Profile filesystem paths work

cd ~/nono-qa/inside
nono run --profile qa-test --allow-cwd -- cat ~/nono-qa/profile-test/notes.txt

Expected: Succeeds, prints profile-data.

Profile restricts ungranted paths

cd ~/nono-qa/inside
nono run --profile qa-test --allow-cwd -- cat ~/nono-qa/outside/secret.txt

Expected: Fails -- ~/nono-qa/outside not in any group or profile entry.

2.7 CLI Overrides on Profiles

--allow extends profile

cd ~/nono-qa/inside
nono run --profile qa-test --allow-cwd --allow ~/nono-qa/outside -- cat ~/nono-qa/outside/secret.txt

Expected: Succeeds -- CLI --allow adds ReadWrite.

--read adds Read-only

cd ~/nono-qa/inside
nono run --profile qa-test --allow-cwd --read ~/nono-qa/outside -- cat ~/nono-qa/outside/secret.txt

Expected: Read succeeds.

cd ~/nono-qa/inside
nono run --profile qa-test --allow-cwd --read ~/nono-qa/outside -- sh -c 'echo "x" > ~/nono-qa/outside/secret.txt'

Expected: Write fails.

CLI directory skipped if already covered

nono run --profile qa-test --allow-cwd --allow ~/nono-qa/profile-test --dry-run -v -- true 2>&1

Expected: ~/nono-qa/profile-test already covered by profile; CLI override skipped.

CLI file overrides always added

cd ~/nono-qa/inside
nono run --profile qa-test --allow-cwd --read-file ~/nono-qa/outside/secret.txt -- cat ~/nono-qa/outside/secret.txt

Expected: Succeeds -- --read-file bypasses directory coverage check.

--net-block overrides profile

cd ~/nono-qa/inside
nono run --profile qa-test --allow-cwd --net-block -- curl -s --max-time 5 https://example.com 2>&1

Expected: Fails -- --net-block overrides profile's network.block = false.

2.8 Capability Deduplication

Read + Write merge to ReadWrite

nono run --read ~/nono-qa/inside --write ~/nono-qa/inside --dry-run -- true 2>&1

Expected: ~/nono-qa/inside appears once with ReadWrite (merged).

User source overrides Group source

nono run --profile claude-code --read /tmp --dry-run -- true 2>&1

Expected: /tmp is ReadWrite from groups but User --read wins; shows Read-only.

Symlink originals preserved (macOS)

# macOS only
nono run --allow /tmp --dry-run -- true 2>&1

Expected: Both /tmp and /private/tmp appear in output.

2.9 Unlink Protection (macOS)

Writable paths get unlink override

echo "expendable" > ~/nono-qa/inside/expendable.txt
nono run --profile claude-code --allow ~/nono-qa/inside --allow-cwd --allow-command rm -- rm ~/nono-qa/inside/expendable.txt

Expected on macOS: Deletion succeeds -- apply_unlink_overrides() adds (allow file-write-unlink) for writable dirs.

Recreate: echo "expendable" > ~/nono-qa/inside/expendable.txt

Unlink is no-op on Linux

# Linux only
echo "expendable" > ~/nono-qa/inside/expendable.txt
nono run --allow ~/nono-qa/inside --allow-command rm -- rm ~/nono-qa/inside/expendable.txt

Expected: Succeeds -- Landlock write access implies deletion.

2.10 Empty Groups Fallback

cat > ~/.config/nono/profiles/qa-empty-groups.toml << 'EOF'
[meta]
name = "qa-empty-groups"
version = "1.0.0"
description = "Profile with empty groups"

[security]
groups = []

[filesystem]
allow = ["$HOME/nono-qa/inside"]
read = []
write = []
allow_file = []
read_file = []
write_file = []

[network]
block = false

[workdir]
access = "none"
EOF
nono run --profile qa-empty-groups -- cat ~/nono-qa/inside/data.txt

Expected: Succeeds -- empty groups falls back to base_groups.

nono run --profile qa-empty-groups -- cat ~/.ssh/id_rsa 2>&1

Expected: Denied -- base group deny_credentials still applies.

2.11 Unknown Group Name

cat > ~/.config/nono/profiles/qa-bad-group.toml << 'EOF'
[meta]
name = "qa-bad-group"
version = "1.0.0"
description = "Profile with unknown group"

[security]
groups = ["nonexistent_group_name"]

[filesystem]
allow = []
read = []
write = []
allow_file = []
read_file = []
write_file = []

[network]
block = false

[workdir]
access = "none"
EOF
nono run --profile qa-bad-group -- true 2>&1

Expected: Error: Unknown policy group: 'nonexistent_group_name'; exit non-zero; sandbox NOT applied (config failure is fatal).

2.12 Deny/Allow Overlap

Linux overlap warning

# Linux only
nono run --allow ~/.ssh --dry-run -v -- true 2>&1

Expected: Warning that ~/.ssh is in deny paths but also in allow paths.

macOS deny-within-allow enforcement

# macOS only
nono run --allow ~ -- cat ~/.ssh/id_rsa 2>&1

Expected on macOS: Fails -- Seatbelt deny for ~/.ssh is more specific than allow for ~.

Expected on Linux: Succeeds -- Landlock allow-list includes ~/.ssh as child of ~.

2.13 Multiple Group Composition

cat > ~/.config/nono/profiles/qa-multi-runtime.toml << 'EOF'
[meta]
name = "qa-multi-runtime"
version = "1.0.0"
description = "Multiple runtime groups"

[security]
groups = ["node_runtime", "rust_runtime", "python_runtime"]

[filesystem]
allow = []
read = []
write = []
allow_file = []
read_file = []
write_file = []

[network]
block = false

[workdir]
access = "none"
EOF
nono run --profile qa-multi-runtime --dry-run -- true 2>&1

Expected: All three runtime groups resolved; Node, Rust, Python paths accessible; deny rules still applied.

nono run --profile qa-multi-runtime -- cat ~/.ssh/id_rsa 2>&1

Expected: Denied -- runtime groups do not remove deny_credentials from base groups.


Part 3: Execution Strategies

Direct, Monitor, and Supervised modes.

3.1 Direct Mode (--exec)

Goal: Verify direct exec replaces the nono process.

nono run --allow ~/nono-qa/inside --exec -- cat ~/nono-qa/inside/data.txt

Expected:

  • cat succeeds, prints inside-file
  • nono process is replaced by cat (nono PID becomes cat PID)
  • No diagnostic footer on failure (nono is gone)
  • Exit code passes through

Direct mode -- no footer on failure

nono run --allow ~/nono-qa/inside --exec -- cat ~/nono-qa/outside/secret.txt

Expected: cat fails; NO [nono] diagnostic footer (nono exec'd away).

3.2 Monitor Mode (default)

Goal: Verify monitor mode sandboxes both parent and child, pipes output, and injects diagnostic footer.

nono run --allow ~/nono-qa/inside -- cat ~/nono-qa/inside/data.txt

Expected:

  • cat succeeds
  • Both parent and child are sandboxed
  • Output piped through parent

Diagnostic footer injection on permission error

nono run --allow ~/nono-qa/inside -- cat ~/nono-qa/outside/secret.txt

Expected:

  • cat fails
  • Diagnostic footer appears with [nono] prefix
  • Footer shows sandbox policy (allowed paths, network status)
  • Footer includes --allow suggestions

--no-diagnostics suppresses footer

nono run --allow ~/nono-qa/inside --no-diagnostics -- cat ~/nono-qa/outside/secret.txt

Expected: cat fails; NO diagnostic footer.

3.3 Supervised Mode

Goal: Verify supervised mode: parent unsandboxed, child sandboxed, IPC active.

nono run --supervised --allow ~/nono-qa/inside -- cat ~/nono-qa/inside/data.txt

Expected:

  • Banner shows supervised mode
  • cat succeeds
  • Exit code 0

Supervised -- disallowed path

nono run --supervised --allow ~/nono-qa/inside -- cat ~/nono-qa/outside/secret.txt

Expected (Linux): seccomp-notify intercepts; approval prompt appears. Expected (macOS): Seatbelt denies directly; diagnostic footer appears (no expansion).

3.4 Signal Forwarding

Goal: Verify signals are forwarded from parent to child.

nono run --allow ~/nono-qa/inside -- sleep 30 &
NONO_PID=$!
sleep 1
kill -INT $NONO_PID
wait $NONO_PID
echo "Exit code: $?"

Expected:

  • SIGINT forwarded to sleep
  • nono exits with 128+2 = 130

3.5 Exit Code Passthrough

nono run --allow ~/nono-qa/inside -- sh -c 'exit 42'
echo "Exit code: $?"

Expected: Exit code is 42.

nono run --allow ~/nono-qa/inside -- sh -c 'exit 0'
echo "Exit code: $?"

Expected: Exit code is 0.

3.6 Environment Variable Filtering

Goal: Verify dangerous environment variables are stripped.

LD_PRELOAD=/evil/lib.so DYLD_INSERT_LIBRARIES=/evil/lib.dylib BASH_ENV=/evil/script nono run --allow ~/nono-qa/inside -- env 2>/dev/null | grep -E "LD_PRELOAD|DYLD_INSERT|BASH_ENV"

Expected: None of the dangerous variables appear in the child environment.

HOME=/Users/test PATH=/usr/bin TERM=xterm nono run --allow ~/nono-qa/inside -- env 2>/dev/null | grep -E "^HOME=|^PATH=|^TERM="

Expected: Safe variables (HOME, PATH, TERM) are preserved.


Part 4: Supervisor IPC & Capability Expansion

Transparent capability expansion via seccomp-notify. Linux-only for expansion; macOS gets diagnostic footer and undo only.

4.1 Expansion -- Approve (Linux only)

nono run --supervised --allow ~/nono-qa/inside -- cat ~/nono-qa/outside/secret.txt

Expected sequence:

  1. cat calls openat() for the file
  2. seccomp BPF traps the syscall before Landlock
  3. Supervisor reads path from /proc/PID/mem
  4. Approval prompt appears:
    [nono] The sandboxed process is requesting additional access:
    [nono]   Path:   /Users/<you>/nono-qa/outside/secret.txt
    [nono]   Access: read-only
    [nono]
    [nono] Grant access? [y/N]
    
  5. Type y
  6. Supervisor opens file, injects fd via SECCOMP_IOCTL_NOTIF_ADDFD
  7. cat prints outside-file (zero retries)
  8. Exit code 0

4.2 Expansion -- Deny (Linux only)

nono run --supervised --allow ~/nono-qa/inside -- cat ~/nono-qa/outside/secret.txt

Expected:

  1. Prompt appears
  2. Type n (or press Enter -- default is deny)
  3. cat sees EPERM, fails
  4. Diagnostic footer appears
  5. Exit non-zero

4.3 never_grant Enforcement

/etc/shadow (Linux)

nono run --supervised --allow ~/nono-qa/inside -- cat /etc/shadow

Expected: No prompt (never_grant blocks immediately); cat fails.

/etc/passwd

nono run --supervised --allow ~/nono-qa/inside -- cat /etc/passwd

Expected (Linux): Denied without prompt. Expected (macOS): Succeeds -- /etc in system_read_macos; never_grant not enforced without expansion layer.

~/.ssh/authorized_keys

mkdir -p ~/.ssh && touch ~/.ssh/authorized_keys
nono run --supervised --allow ~/nono-qa/inside -- cat ~/.ssh/authorized_keys

Expected:

  • macOS: Seatbelt deny rule blocks at kernel level
  • Linux: seccomp intercepts; never_grant denies without prompt

4.4 Initial Set -- No Prompt (Linux)

nono run --supervised --allow ~/nono-qa/inside -- cat ~/nono-qa/inside/data.txt

Expected:

  • Succeeds immediately with NO approval prompt
  • On Linux: seccomp intercepts but supervisor recognizes path in initial set (HashSet fast-path)
  • On macOS: Seatbelt allows directly

4.5 Multiple File Access (Linux only)

nono run --supervised --allow ~/nono-qa/inside -- sh -c "cat ~/nono-qa/outside/secret.txt && cat ~/nono-qa/outside/other.txt"

Expected:

  1. Prompt for secret.txt -- approve
  2. Prints outside-file
  3. Prompt for other.txt -- approve
  4. Prints file-two
  5. Each file requires its own approval

Variation -- deny second file:

nono run --supervised --allow ~/nono-qa/inside -- sh -c "cat ~/nono-qa/outside/secret.txt; cat ~/nono-qa/outside/other.txt"

Approve first, deny second. First succeeds, second fails.

4.6 Write Expansion (Linux only)

File creation denied via expansion

nono run --supervised --allow ~/nono-qa/inside -- sh -c "echo 'written' > ~/nono-qa/outside/new-write.txt"

Expected: Fails -- open_path_for_access() uses create(false); non-existent files cannot be created via expansion.

Pre-existing file writable after approval

touch ~/nono-qa/outside/writable.txt
nono run --supervised --allow ~/nono-qa/inside -- sh -c "echo 'written' > ~/nono-qa/outside/writable.txt"

Expected: Prompt appears; approve; write succeeds. Verify: cat ~/nono-qa/outside/writable.txt shows written.

4.7 Rate Limiting (Linux only)

for i in $(seq 1 20); do echo "file $i" > ~/nono-qa/outside/rapid-$i.txt; done
nono run --supervised --allow ~/nono-qa/inside -- sh -c '
for i in $(seq 1 20); do
  cat ~/nono-qa/outside/rapid-$i.txt 2>/dev/null &
done
wait
'

Expected:

  • First several requests (burst capacity: 5) may produce prompts
  • Subsequent rapid requests auto-denied as "rate limited"
  • Far fewer than 20 prompts appear

4.8 No Terminal -- Auto-Deny (Linux only)

echo "cat ~/nono-qa/outside/secret.txt" | nono run --supervised --allow ~/nono-qa/inside -- sh

Expected:

  • TerminalApproval reads from /dev/tty, not stdin
  • If /dev/tty available: prompt appears despite piped stdin
  • If no tty (CI/cron): auto-denied with "No terminal available"

Part 5: Undo System

Content-addressable snapshots with merkle verification. Requires --supervised mode.

5.1 Baseline Snapshot Creation

nono run --supervised --allow ~/nono-qa/undo-test --no-undo-prompt -- sh -c 'echo "modified" > ~/nono-qa/undo-test/tracked.txt'

Expected:

  • Baseline snapshot taken before command runs
  • Final snapshot taken after command exits
  • Post-exit summary: "1 file changed (1 modified)"
  • --no-undo-prompt skips the interactive restore question

5.2 Change Detection

nono run --supervised --allow ~/nono-qa/undo-test -- sh -c '
echo "new-file" > ~/nono-qa/undo-test/created.txt
echo "modified" > ~/nono-qa/undo-test/tracked.txt
rm ~/nono-qa/undo-test/binary.bin
'

Expected post-exit summary:

  • + created.txt (created)
  • ~ tracked.txt (modified)
  • - binary.bin (deleted)
  • Undo prompt appears (if interactive)

5.3 Restore to Baseline

After 5.2, the undo prompt asks to restore:

Expected when answering y:

  • created.txt removed (did not exist at baseline)
  • tracked.txt restored to original-content
  • binary.bin restored from object store
  • Merkle root verified

5.4 Undo Session Management

List sessions

nono undo list

Expected: Shows recent sessions with ID, timestamp, command, and tracked paths.

Show session details

nono undo list --json | head -1
# Use the session_id from above
nono undo show <SESSION_ID>

Expected: Shows session metadata, snapshot timeline, merkle roots.

Dry-run restore

nono undo restore <SESSION_ID> --dry-run

Expected: Shows what would change without modifying disk.

5.5 Merkle Verification

nono undo verify <SESSION_ID>

Expected: "Integrity verified" -- merkle root matches recomputed tree from manifest.

5.6 Session Cleanup

nono undo cleanup --keep 5 --dry-run

Expected: Shows which sessions would be pruned without deleting anything.

nono undo cleanup --older-than 0 --dry-run

Expected: Shows all sessions as candidates for cleanup.

5.7 Exclusion Patterns

Goal: Verify excluded files are not tracked in snapshots.

mkdir -p ~/nono-qa/undo-test/.git/objects
echo "git-object" > ~/nono-qa/undo-test/.git/objects/abc123
echo "ds-store" > ~/nono-qa/undo-test/.DS_Store

nono run --supervised --allow ~/nono-qa/undo-test --no-undo-prompt -- sh -c '
echo "modified" > ~/nono-qa/undo-test/.git/objects/abc123
echo "modified" > ~/nono-qa/undo-test/.DS_Store
echo "modified" > ~/nono-qa/undo-test/tracked.txt
'

Expected:

  • .git/objects and .DS_Store changes NOT listed in change summary
  • Only tracked.txt modification shown
  • Base exclusions: .git/objects, .DS_Store

5.8 Only User-Writable Directories Tracked

nono run --supervised --allow ~/nono-qa/undo-test --read ~/nono-qa/outside --no-undo-prompt -- sh -c 'echo done'

Expected:

  • Only ~/nono-qa/undo-test tracked (writable, User source)
  • ~/nono-qa/outside NOT tracked (read-only)
  • System paths NOT tracked (Group source)

5.9 Symlinks Not Followed

ln -sf ~/nono-qa/outside/secret.txt ~/nono-qa/undo-test/link-to-outside.txt
nono run --supervised --allow ~/nono-qa/undo-test --no-undo-prompt -- true

Expected: Symlink link-to-outside.txt is NOT followed during snapshot walk; only regular files tracked.

5.10 Object Store Deduplication

# Create identical files
echo "same-content" > ~/nono-qa/undo-test/file-a.txt
echo "same-content" > ~/nono-qa/undo-test/file-b.txt
nono run --supervised --allow ~/nono-qa/undo-test --no-undo-prompt -- true

Expected: Object store contains only one blob for both files (SHA-256 dedup). Verify by checking ~/.nono/undo/<SESSION_ID>/objects/ -- both files map to the same hash path.

5.11 APFS Clone Optimization (macOS)

On macOS APFS, the object store uses clonefile() for instant copy-on-write. No direct QA verification needed beyond confirming snapshots work (5.1-5.3), but performance should be noticeably faster on APFS than HFS+.


Part 6: Diagnostic Footer

Error output when sandbox denials occur.

6.1 Monitor Mode -- Inline Injection

nono run --allow ~/nono-qa/inside -- cat ~/nono-qa/outside/secret.txt

Expected:

  • cat fails
  • Footer appears with [nono] prefix on every line
  • Shows: "Command exited with code N. This may be due to sandbox restrictions."
  • Shows sandbox policy summary (user paths listed, system paths summarized with count)
  • Shows help: --allow <path>, --read <path>, --write <path>

6.2 Supervised Mode -- Post-Exit Footer

nono run --supervised --allow ~/nono-qa/inside -- cat ~/nono-qa/outside/secret.txt

(On macOS, deny immediately. On Linux, deny at prompt.)

Expected:

  • Footer printed after child exits
  • On Linux with denials: groups by reason (PolicyBlocked, UserDenied)
  • On macOS: standard policy summary + re-run suggestions

6.3 Monitor Mode -- Debounced Injection

nono run --allow ~/nono-qa/inside -- sh -c '
cat ~/nono-qa/outside/secret.txt 2>&1
cat ~/nono-qa/outside/other.txt 2>&1
'

Expected: Footer appears once (debounced 2s minimum between injections), not twice.

6.4 Verbose Output

nono run --allow ~/nono-qa/inside -v -- cat ~/nono-qa/inside/data.txt

Expected at -v: All paths shown with source labels ([user], [group:name]).

nono run --allow ~/nono-qa/inside -- cat ~/nono-qa/inside/data.txt

Expected at default: Only user/profile paths shown; system/group paths hidden with count.

6.5 Silent Mode

nono run --allow ~/nono-qa/inside -s -- cat ~/nono-qa/inside/data.txt

Expected: No banner, no diagnostic output, no prompts. Only command stdout/stderr.


Part 7: Query System (nono why)

Permission checking without executing commands.

7.1 Query with CLI Context

nono why --allow ~/nono-qa/inside --path ~/nono-qa/inside --op read

Expected: "ALLOWED" -- path is in the capability set.

nono why --allow ~/nono-qa/inside --path ~/nono-qa/outside --op read

Expected: "DENIED" -- path not in capability set.

7.2 Query with Profile

nono why --profile claude-code --path ~/.claude --op readwrite

Expected: "ALLOWED" -- ~/.claude in profile filesystem entries.

nono why --profile claude-code --path ~/.ssh --op read

Expected: "DENIED" -- ~/.ssh in deny_credentials group.

7.3 Query Denied Path with Reason

nono why --path ~/.ssh --op read

Expected: Reports denied with explanation (sensitive/deny group).

7.4 Network Query

nono why --host example.com --port 443

Expected: "ALLOWED" -- network not blocked by default.

nono why --net-block --host example.com --port 443

Expected: "DENIED" -- network blocked.

7.5 JSON Output

nono why --allow ~/nono-qa/inside --path ~/nono-qa/inside --op read --json

Expected: Structured JSON with status field.

7.6 Self-Query from Inside Sandbox

nono run --allow ~/nono-qa/inside -- sh -c 'nono why --self --path ~/nono-qa/inside --op read'

Expected: Reads from NONO_CAP_FILE state file; reports "ALLOWED".

nono run --allow ~/nono-qa/inside -- sh -c 'nono why --self --path ~/nono-qa/outside --op read'

Expected: Reports "DENIED".


Part 8: CWD Handling

Working directory inclusion behavior.

8.1 CWD Prompt

cd ~/nono-qa/inside
nono run -- ls .

Expected: Prompt asks about sharing CWD. Answer y: ls succeeds. Answer n: ls may fail.

8.2 --allow-cwd Suppresses Prompt

cd ~/nono-qa/inside
nono run --allow-cwd -- ls .

Expected: No prompt; ls succeeds.

8.3 workdir.access = "read"

cat > ~/.config/nono/profiles/qa-readonly-cwd.toml << 'EOF'
[meta]
name = "qa-readonly-cwd"
version = "1.0.0"
description = "Read-only CWD"

[security]
groups = []

[filesystem]
allow = []
read = []
write = []
allow_file = []
read_file = []
write_file = []

[network]
block = false

[workdir]
access = "read"
EOF
cd ~/nono-qa/inside
nono run --profile qa-readonly-cwd --allow-cwd -- cat data.txt

Expected: Read succeeds.

cd ~/nono-qa/inside
nono run --profile qa-readonly-cwd --allow-cwd -- sh -c 'echo "new" > new-file.txt'

Expected: Write fails -- CWD has read-only access.

8.4 workdir.access = "none"

cat > ~/.config/nono/profiles/qa-no-cwd.toml << 'EOF'
[meta]
name = "qa-no-cwd"
version = "1.0.0"
description = "No CWD access"

[security]
groups = []

[filesystem]
allow = []
read = []
write = []
allow_file = []
read_file = []
write_file = []

[network]
block = false

[workdir]
access = "none"
EOF
cd ~/nono-qa/inside
nono run --profile qa-no-cwd -- cat data.txt

Expected: No CWD prompt; cat data.txt fails -- CWD not added.


Part 9: Variable Expansion in Profiles

9.1 $HOME Expansion

cat > ~/.config/nono/profiles/qa-vars.toml << 'EOF'
[meta]
name = "qa-vars"
version = "1.0.0"
description = "Variable expansion test"

[security]
groups = []

[filesystem]
allow = ["$HOME/nono-qa/inside"]
read = []
write = []
allow_file = []
read_file = []
write_file = []

[network]
block = false

[workdir]
access = "none"
EOF
nono run --profile qa-vars -- cat ~/nono-qa/inside/data.txt

Expected: $HOME expanded; cat succeeds.

9.2 $WORKDIR Expansion

cat > ~/.config/nono/profiles/qa-workdir-var.toml << 'EOF'
[meta]
name = "qa-workdir-var"
version = "1.0.0"
description = "Workdir variable test"

[security]
groups = []

[filesystem]
allow = ["$WORKDIR"]
read = []
write = []
allow_file = []
read_file = []
write_file = []

[network]
block = false

[workdir]
access = "none"
EOF
cd ~/nono-qa/inside
nono run --profile qa-workdir-var --workdir ~/nono-qa/inside -- cat data.txt

Expected: $WORKDIR expanded to ~/nono-qa/inside; cat succeeds.


Part 10: State File & Sandbox State

10.1 State File Created

nono run --allow ~/nono-qa/inside -- sh -c 'ls -la $NONO_CAP_FILE'

Expected:

  • File exists at /tmp/.nono-<PID>.json
  • Permissions: 0600
  • NONO_CAP_FILE environment variable set

10.2 State File Content

nono run --allow ~/nono-qa/inside -- sh -c 'cat $NONO_CAP_FILE'

Expected: JSON with fs array (path, access, is_file), net_blocked, allowed_commands, blocked_commands.

10.3 State File Cleaned Up

nono run --allow ~/nono-qa/inside -- true
ls /tmp/.nono-*.json 2>/dev/null

Expected: No stale state files for completed processes.


Part 11: Setup & System Verification

11.1 Setup Command

nono setup

Expected:

  • Phase 1: Installation check (binary path, version, platform)
  • Phase 2: Sandbox support test
    • macOS: Seatbelt verified via fork+sandbox_init
    • Linux: Landlock ABI detected, features listed
  • Phase 3: Protection summary (sensitive paths count, dangerous commands)
  • Phase 4: Built-in profiles listed
  • Quick start examples

11.2 Setup Detects Missing Support

On a system without Landlock (old kernel, not enabled):

Expected: Clear error with instructions (e.g., boot parameters for Landlock).


Part 12: Learn Mode (Linux only)

strace-based path discovery.

12.1 Basic Learn

# Linux only
nono learn -- cat ~/nono-qa/inside/data.txt

Expected: Shows paths accessed by cat, categorized as read/write.

12.2 Learn with Profile Comparison

# Linux only
nono learn --profile claude-code -- cat ~/nono-qa/inside/data.txt

Expected: Only shows paths NOT covered by the claude-code profile. System paths filtered out.

12.3 Learn JSON Output

# Linux only
nono learn --json -- cat ~/nono-qa/inside/data.txt

Expected: Profile-fragment JSON with filesystem: {allow: [], read: [], write: []}.


Part 13: Hooks (Claude Code Integration)

13.1 Hook Installation

nono run --profile claude-code --allow-cwd -- true
ls -la ~/.claude/hooks/nono-hook.sh

Expected:

  • Hook script installed at ~/.claude/hooks/nono-hook.sh
  • Executable (755)

13.2 CLAUDE.md Section

cat ~/.claude/CLAUDE.md | grep -A 5 "nono-sandbox-start"

Expected: <!-- nono-sandbox-start --> section present with sandbox instructions.

13.3 Hook Reads State File

cat ~/.claude/hooks/nono-hook.sh | head -20

Expected: Script reads NONO_CAP_FILE and uses jq to parse capability JSON.


Part 14: Dry-Run Output

14.1 Dry-run with CLI Args

nono run --allow ~/nono-qa/inside --read ~/nono-qa/outside --net-block --dry-run -- true 2>&1

Expected: Shows capabilities, deny rules, blocked commands, network status. Does NOT execute. Exit 0.

14.2 Dry-run with Profile

nono run --profile claude-code --dry-run -- true 2>&1

Expected: Profile name, resolved groups, all filesystem entries, deny rules, blocked commands, network policy.

14.3 Dry-run with Profile + CLI Overrides

nono run --profile claude-code --allow ~/nono-qa/outside --block-command ls --dry-run -- true 2>&1

Expected: Three-layer composition visible: groups + profile + CLI overrides.


Part 15: Edge Cases & Adversarial Testing

15.1 Path with Spaces

mkdir -p ~/nono-qa/"path with spaces"
echo "spaced" > ~/nono-qa/"path with spaces"/file.txt
nono run --allow ~/nono-qa/"path with spaces" -- cat ~/nono-qa/"path with spaces"/file.txt

Expected: Succeeds -- paths with spaces handled correctly.

15.2 Path with Special Characters

mkdir -p ~/nono-qa/'special-$chars'
echo "special" > ~/nono-qa/'special-$chars'/file.txt
nono run --allow ~/nono-qa/'special-$chars' -- cat ~/nono-qa/'special-$chars'/file.txt

Expected: Succeeds -- special characters in paths handled correctly.

15.3 Nonexistent --allow Path

nono run --allow ~/nono-qa/nonexistent -- true 2>&1

Expected: Error -- path does not exist; FsCapability::new_dir() fails with PathNotFound.

15.4 File Passed as Directory

nono run --allow ~/nono-qa/inside/data.txt -- true 2>&1

Expected: Error -- --allow expects a directory; ExpectedDirectory error.

15.5 Directory Passed as File

nono run --allow-file ~/nono-qa/inside -- true 2>&1

Expected: Error -- --allow-file expects a file; ExpectedFile error.

15.6 Seatbelt Control Character Injection (macOS)

Paths containing control characters (0x00-0x1F, 0x7F) must be rejected to prevent Seatbelt profile injection. This is enforced at the library level in escape_path() and covered by unit tests.

15.7 Empty Command

nono run --allow ~/nono-qa/inside -- 2>&1

Expected: Error -- no command specified.

15.8 Command Not Found

nono run --allow ~/nono-qa/inside -- nonexistent_command_12345 2>&1
echo "Exit: $?"

Expected: Error; exit code 127 (execve failed).

15.9 Supervised + Keystore Secrets = Error

# This should fail: supervised mode rejects keyring threads (deadlock risk)
nono run --supervised --allow ~/nono-qa/inside --secrets test_key -- true 2>&1

Expected: Error about threading context incompatibility (supervised requires single-threaded for fork safety; keyring backend creates threads).


Part 16: Keystore & Secrets

16.1 Secret Loading (requires keyring entry)

# First, add a test secret to the system keystore:
# macOS: security add-generic-password -a nono_qa_test -s nono -w "test-secret-value"
# Linux: secret-tool store --label='nono test' service nono username nono_qa_test <<< "test-secret-value"

nono run --allow ~/nono-qa/inside --secrets nono_qa_test -- sh -c 'echo $NONO_QA_TEST'

Expected: Prints test-secret-value -- secret loaded from system keystore and injected as env var (uppercased name).

16.2 Missing Secret

nono run --allow ~/nono-qa/inside --secrets nonexistent_key_12345 -- true 2>&1

Expected: Error: SecretNotFound for the missing key.

16.3 Secrets Loaded Before Sandbox

On macOS, keyring access requires Mach IPC which the sandbox blocks. Secrets must be loaded before sandbox_init().

Verify: If 16.1 works on macOS, secrets are being loaded pre-sandbox.


Cleanup

rm -rf ~/nono-qa
rm -f ~/.config/nono/profiles/qa-test.toml
rm -f ~/.config/nono/profiles/qa-empty-groups.toml
rm -f ~/.config/nono/profiles/qa-bad-group.toml
rm -f ~/.config/nono/profiles/qa-readonly-cwd.toml
rm -f ~/.config/nono/profiles/qa-no-cwd.toml
rm -f ~/.config/nono/profiles/qa-vars.toml
rm -f ~/.config/nono/profiles/qa-workdir-var.toml
rm -f ~/.config/nono/profiles/qa-multi-runtime.toml
rm -f /tmp/nono-qa-*

Platform Verification Checklists

macOS Checklist

Section Test Verify
1.1 Allowed path cat succeeds
1.2 Disallowed path cat fails
1.3 Read-only Read OK, write blocked
1.6 Network block curl fails with --net-block
1.8 Symlink pairs /tmp + /private/tmp both accessible
2.1 System read /usr/share readable
2.1 Deny credentials ~/.aws blocked by Seatbelt deny
2.1 Deny shell history ~/.zsh_history blocked
2.2 Dangerous commands rm, diskutil blocked
2.5 claude-code profile Groups resolved, deny rules active
2.7 CLI overrides --allow, --read, --net-block work
2.9 Unlink protection Writable dirs get override rules
2.12 Deny within allow ~/.ssh denied with --allow ~
3.2 Monitor mode Diagnostic footer on denial
3.3 Supervised mode Banner, sandbox, undo
4.3 never_grant ~/.ssh blocked by deny rule
5.1-5.3 Undo Snapshot, detect changes, restore
6.1 Diagnostic footer [nono] prefix, policy summary
7.2 Query profile Source attribution correct
8.3 CWD read-only Write to CWD fails
13.1 Hooks Claude Code hook installed
15.1 Path spaces Handled correctly

Linux Checklist

Section Test Verify
1.1 Allowed path cat succeeds
1.2 Disallowed path cat fails (Landlock)
1.3 Read-only Read OK, write blocked (Landlock)
1.6 Network block ABI V4+: blocked; ABI < V4: warning
1.9 Landlock ABI nono setup shows ABI version
2.1 System read /usr/share readable
2.1 Deny credentials ~/.aws not in allow list
2.2 Dangerous commands rm, dd blocked
2.5 claude-code profile Groups resolved
2.7 CLI overrides --allow, --read, --net-block work
2.9 Unlink no-op Deletion works with write access
2.12 Deny overlap Warning on deny/allow overlap
3.2 Monitor mode Diagnostic footer on denial
3.3 Supervised mode Banner, sandbox, IPC
4.1 Expansion approve Prompt, y, fd injected
4.2 Expansion deny Prompt, n, EPERM
4.3 never_grant /etc/shadow denied without prompt
4.4 Initial set No prompt for allowed paths
4.5 Multiple files Per-file approval
4.7 Rate limiting Rapid requests auto-denied
5.1-5.3 Undo Snapshot, detect changes, restore
5.5 Merkle verify nono undo verify succeeds
6.1 Diagnostic footer [nono] prefix, policy summary
7.2 Query profile Source attribution correct
8.3 CWD read-only Write to CWD fails (Landlock)
12.1 Learn mode strace paths categorized
15.1 Path spaces Handled correctly

Linux Prerequisites

Check Command
Kernel 5.14+ uname -r
seccomp-notify cat /proc/sys/kernel/seccomp/actions_avail contains user_notif
Landlock enabled cat /sys/kernel/security/lsm contains landlock
strace (for learn) which strace

Known System Group Paths

Do NOT use these as "denied" test targets -- they are in base system groups:

  • /tmp, /private/tmp -- system_write_*
  • /var, /private/var -- system_read_macos
  • /usr, /bin, /lib, /sbin -- system_read_*
  • /etc, /private/etc -- system_read_macos
  • /dev -- system_write_*
  • ~/Library/Caches, ~/Library/Logs -- user_caches_macos
  • ~/.cargo, ~/.rustup -- rust_runtime
  • ~/.nvm, ~/.fnm, ~/.npm -- node_runtime

Use $HOME/nono-qa/ for all "outside" test paths.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment