diff --git a/openshell-filesystem-hierarchical-policy/HOW_TO.md b/openshell-filesystem-hierarchical-policy/HOW_TO.md new file mode 100644 index 0000000..66cd903 --- /dev/null +++ b/openshell-filesystem-hierarchical-policy/HOW_TO.md @@ -0,0 +1,59 @@ +# HOW TO: OpenShell Filesystem Hierarchical Policy + +## Purpose + +Reproduce the Landlock hierarchical filesystem policy experiment +that tests whether a `read_only` subdirectory inside a `read_write` +parent is correctly enforced as read-only by OpenShell. + +## Requirements + +| Requirement | Link | +|-----------------------------|------------------------------------------------------------| +| OpenShell CLI | | +| Docker | | +| Python 3 | | +| GitHub CLI (repo mode only) | | + +## Steps + +1. Start the OpenShell gateway if it is not already running: + + ```bash + openshell gateway start + ``` + +2. Navigate to the experiment directory: + + ```bash + cd openshell-filesystem-hierarchical-policy + ``` + +3. Run with synthetic fixtures (no external dependencies): + + ```bash + ./run.sh + ``` + + Or, to test against a real GitHub repo: + + ```bash + ./run.sh --repo octocat/Hello-World + ``` + + The `--repo` flag requires `gh` to be installed and authenticated. + +## Expected Output + +- Terminal prints a table of 24 test assertions with pass/fail indicators + + ```text + ID CAT OP PATH EXPECT RESULT + 1.3 overlap write …/target-repo/README.md EACCES EACCES + ``` + +- Summary line shows total passed and failed counts +- `results/` directory is created containing: + - `probe-output.jsonl` — one JSON object per test assertion + - `sandbox-logs.txt` — sandbox container logs +- Exit code is 0 if all 24 assertions pass, nonzero otherwise diff --git a/openshell-filesystem-hierarchical-policy/README.md b/openshell-filesystem-hierarchical-policy/README.md new file mode 100644 index 0000000..4468a4d --- /dev/null +++ b/openshell-filesystem-hierarchical-policy/README.md @@ -0,0 +1,86 @@ +# OpenShell Filesystem Policy Test + +Tests whether OpenShell's Landlock-based filesystem policy +correctly enforces read\_only/read\_write path restrictions. + +**Primary question:** When a subdirectory is marked +`read_only` inside a parent marked `read_write`, does the +more-specific restriction win? + +## Prerequisites + +- OpenShell CLI installed and gateway running + (`openshell gateway start`) +- Docker available (OpenShell uses it for sandboxes) + +### For real-repo mode only + +- GitHub CLI (`gh`) installed and authenticated + +## Run + +```shell +# Synthetic fixtures (no external dependencies) +./run.sh + +# Real GitHub repo cloned into target-repo +./run.sh --repo octocat/Hello-World +``` + +## Hypotheses + +| ID | Hypothesis | Tests | +|----|----------------------------------|----------| +| H0 | Overlap: specific path wins | 1.3–1.5 | +| H1 | Unlisted paths are inaccessible | 4.1–4.4 | +| H2 | Read-only: reads ok, writes fail | 3.1–3.6 | +| H3 | Read-write: both operations work | 2.1–2.4 | +| H4 | Symlinks resolved before policy | 5.1 | +| H5 | Traversal resolved before policy | 5.2 | +| H6 | include\_workdir: false honored | all | +| H7 | Runs on Fedora 44 | all | + +## Policy + +See `policy.yaml`. Key detail: `include_workdir: false` +is required — the default is `true`, which would silently +add the workdir to `read_write` and mask the overlap +behavior. + +## Results + +Place the results after running in "./findings.md", using the following +format + +````markdown +## Assertion summary + +| Cat | Tests | Passed | Failed | +|---------|-------|--------|--------| +| overlap | 7 | | | +| rw | 4 | | | +| ro | 6 | | | +| deny | 4 | | | +| edge | 3 | | | +| Total | 24 | | | + +## Hypothesis outcomes + +| ID | Status | Notes | +|----|----------|-------| +| H0 | | | +| H1 | | | +| H2 | | | +| H3 | | | +| H4 | | | +| H5 | | | +| H6 | | | +| H7 | | | + +## Environment + +- **OpenShell version:** +- **Kernel:** +- **OS:** +- **Date:** +```` diff --git a/openshell-filesystem-hierarchical-policy/findings.md b/openshell-filesystem-hierarchical-policy/findings.md new file mode 100644 index 0000000..106967b --- /dev/null +++ b/openshell-filesystem-hierarchical-policy/findings.md @@ -0,0 +1,251 @@ +# Filesystem Hierarchical Policy Experiment — Findings + +## Experiment overview + +This experiment tests whether OpenShell's Landlock-based filesystem +policy enforces hierarchical path precedence: when a subdirectory is +marked `read_only` inside a parent marked `read_write`, does the +more-specific restriction win? + +The policy under test marks `/sandbox` as `read_write` and +`/sandbox/workspace/target-repo/` as `read_only`, with +`include_workdir: false` to prevent OpenShell from silently adding +the working directory to `read_write`. + +24 assertions across five categories (overlap, read-write, read-only, +deny, edge cases) probe the policy boundary. + +## Execution + +```bash +./run.sh +``` + +Ran with synthetic fixtures (no `--repo` flag). The orchestrator +created a sandbox with the test policy, seeded a +`target-repo/README.md`, uploaded `probe.py`, and collected JSONL +results. + +## Results + +20 of 24 assertions passed. **4 failures, all in the overlap +category:** + +**Overlap** (target-repo is ro child of rw /sandbox): + +| ID | Op | Path | Expect | Got | +| --- | ------- | ---------------- | ------ | ------ | +| 1.1 | read | …/README.md | ok | ok | +| 1.2 | listdir | …/target-repo/ | ok | ok | +| 1.3 | write | …/README.md | EACCES | **ok** | +| 1.4 | create | …/new-file.txt | EACCES | **ok** | +| 1.5 | mkdir | …/newdir/ | EACCES | **ok** | +| 1.6 | write | …/other/file.txt | ok | ok | +| 1.7 | write | …/workspace/file | ok | ok | + +**Read-write:** + +| ID | Op | Path | Expect | Got | +| --- | ---------- | ---------------- | ------ | --- | +| 2.1 | write+read | /sandbox/test-rw | ok | ok | +| 2.2 | write+read | /tmp/test-rw | ok | ok | +| 2.3 | write | /dev/null | ok | ok | +| 2.4 | mkdir | /sandbox/newdir/ | ok | ok | + +**Read-only:** + +| ID | Op | Path | Expect | Got | +| --- | ----- | ----------------- | ------ | ------ | +| 3.1 | read | /usr/bin/ls | ok | ok | +| 3.2 | write | /usr/test-write | EACCES | EACCES | +| 3.3 | read | /etc/hostname | ok | ok | +| 3.4 | write | /etc/test-write | EACCES | EACCES | +| 3.5 | read | /proc/self/status | ok | ok | +| 3.6 | read | /dev/urandom | ok | ok | + +**Deny (unlisted paths):** + +| ID | Op | Path | Expect | Got | +| --- | ----- | --------------- | ------ | ------ | +| 4.1 | read | /home/ | EACCES | EACCES | +| 4.2 | read | /root/ | EACCES | EACCES | +| 4.3 | read | /opt/ | EACCES | EACCES | +| 4.4 | write | /opt/test-write | EACCES | EACCES | + +**Edge cases:** + +| ID | Op | Path | Expect | Got | +| --- | --------- | --------------------- | ------ | ------ | +| 5.1 | symlink | /tmp/link→/etc/passwd | EACCES | EACCES | +| 5.2 | traversal | …/repo/../other/file | ok | ok | +| 5.3 | delete | …/README.md | EACCES | **ok** | + +Raw data: [results/probe-output.jsonl](results/probe-output.jsonl) + +### Assertion summary + +| Category | Tests | Passed | Failed | +| --------- | ------ | ------ | ------ | +| overlap | 7 | 4 | 3 | +| rw | 4 | 4 | 0 | +| ro | 6 | 6 | 0 | +| deny | 4 | 4 | 0 | +| edge | 3 | 2 | 1 | +| **Total** | **24** | **20** | **4** | + +### Hypothesis outcomes + +| ID | Status | Hypothesis | +| -- | ----------- | ---------------------------------- | +| H0 | **REFUTED** | Overlap: specific path wins | +| H1 | Confirmed | Unlisted paths inaccessible | +| H2 | Confirmed | Read-only: reads ok, writes fail | +| H3 | Confirmed | Read-write: both ops work | +| H4 | Confirmed | Symlinks resolved before policy | +| H5 | Confirmed | Traversal resolved before policy | +| H6 | Confirmed | `include_workdir: false` honored | +| H7 | Confirmed | Runs on Fedora 44 | + +**H0 detail:** Tests 1.3, 1.4, 1.5 all returned `ok` — the +`read_write` parent grant is not overridden by a more-specific +`read_only` child. See analysis below. + +## Key finding: Landlock unions permissions, it does not override + +The headline result is that **H0 is refuted**. When both +`/sandbox` (read\_write) and `/sandbox/workspace/target-repo/` +(read\_only) appear in the policy, the child path is writable. + +This is not a bug — it is how Landlock works. Landlock rules are +**additive**: a rule on a path grants permissions to that path and +all its descendants. When a parent path grants `read + write`, that +grant propagates to every child path. A separate `read_only` rule +on a child path adds `read` access (which the parent already +granted), but it cannot revoke the `write` access granted by the +parent. + +In Landlock's permission model: + +- A ruleset declares which access rights it **handles** (restricts) +- For each handled right, access is denied unless at least one rule + grants it +- Rules are per-path and apply to the path **and all descendants** +- Multiple rules on overlapping paths are **unioned**, not + overridden + +So `target-repo` gets: `read + write` (from `/sandbox` rule) +**union** `read` (from `/sandbox/workspace/target-repo/` rule) = +`read + write`. The `read_only` intent is lost. + +### Sandbox logs confirm the mechanism + +From `results/sandbox-logs.txt`: + +```text +CONFIG:PROBED [INFO] Landlock filesystem sandbox available + [abi:v8 compat:BestEffort ro:8 rw:3] +CONFIG:APPLYING [INFO] Applying Landlock filesystem sandbox + [abi:V2 compat:BestEffort ro:8 rw:3] +CONFIG:BUILT [INFO] Landlock ruleset built + [rules_applied:9 skipped:2] +``` + +- Landlock ABI v8 is available on this kernel, but OpenShell applies + rules using **ABI V2** with `BestEffort` compatibility +- 8 read-only + 3 read-write = 11 rules declared, but only 9 + applied and **2 were skipped** — OpenShell silently drops rules + it cannot map to the Landlock ABI version it uses +- The ABI downgrade may explain why `read_only` within `read_write` + is not enforced, but the more likely explanation is the additive + permission model described above — this is fundamental to + Landlock's design, not an ABI limitation + +### Test 5.3 failure confirms the same root cause + +Test 5.3 (delete `target-repo/README.md`) also failed: `unlink` +requires write permission on the parent directory, and the parent +directory inherits write from `/sandbox`. This is consistent with +the overlap behavior, not a separate bug. + +## Conclusions + +### What works + +OpenShell's Landlock integration correctly enforces: + +- **Non-overlapping read-only paths** (H2): `/usr`, `/etc`, + `/proc`, `/dev/urandom` are all properly read-only +- **Read-write paths** (H3): `/sandbox`, `/tmp`, `/dev/null` + allow both operations +- **Deny-by-default** (H1): unlisted paths (`/home`, `/root`, + `/opt`) are inaccessible +- **Symlink resolution** (H4): writing through a symlink to a + protected path is blocked +- **Traversal resolution** (H5): `..` is resolved before policy + evaluation +- **Fedora 44 compatibility** (H7): kernel 7.0.x with Landlock v8 + +### What does not work + +**Hierarchical path restriction is not possible with the current +policy model.** You cannot mark a subdirectory as `read_only` inside +a `read_write` parent and expect the restriction to hold. Landlock's +additive permission model means the parent's `read_write` grant +propagates to all descendants regardless of child rules. + +### Implications for OpenShell policy design + +The `read_only` / `read_write` distinction in OpenShell's policy +YAML implies hierarchical override semantics that Landlock does not +provide. This creates a gap between user intent and enforcement: + +- A policy author writing `read_only: [/sandbox/workspace/repo/]` + alongside `read_write: [/sandbox]` reasonably expects the repo to + be read-only +- Landlock silently ignores the intent and makes the repo writable +- No warning is emitted — the `rules_applied` log does not flag the + semantic conflict + +### Recommendations + +1. **OpenShell should warn on overlapping paths.** When a + `read_only` path is a descendant of a `read_write` path, the + policy validator should emit a warning that the `read_only` + restriction will not be enforced. + +2. **Restructure policies to avoid overlap.** Instead of marking a + subdirectory `read_only` inside a `read_write` parent, list the + writable siblings explicitly: + + ```yaml + # BROKEN — read_only inside read_write is not enforced + read_only: + - /sandbox/workspace/target-repo/ + read_write: + - /sandbox + + # WORKING — enumerate writable paths, exclude the repo + read_only: + - /sandbox/workspace/target-repo/ + read_write: + - /sandbox/workspace/other-project/ + - /sandbox/workspace/scratch/ + - /tmp + ``` + + The trade-off: every new writable path must be explicitly listed, + which is more verbose but matches Landlock's actual enforcement. + +3. **Investigate ABI V2 downgrade.** Kernel 7.0 provides Landlock + ABI v8, but OpenShell applies rules at V2. Later ABI versions + may offer features relevant to path restriction granularity. + The 2 skipped rules should be investigated to determine what + policy intent is being silently dropped. + +## Environment + +- **OpenShell version:** 0.17.x (from gateway logs) +- **Kernel:** 7.0.12-201.fc44.x86_64 +- **Landlock ABI:** v8 available, V2 applied (BestEffort) +- **OS:** Fedora 44 +- **Date:** 2026-06-17 diff --git a/openshell-filesystem-hierarchical-policy/policies/policy.yaml b/openshell-filesystem-hierarchical-policy/policies/policy.yaml new file mode 100644 index 0000000..84d16e4 --- /dev/null +++ b/openshell-filesystem-hierarchical-policy/policies/policy.yaml @@ -0,0 +1,17 @@ +version: 1 + +filesystem_policy: + include_workdir: false + read_only: + - /usr + - /lib + - /proc + - /dev/urandom + - /app + - /etc + - /var/log + - /sandbox/workspace/target-repo/ + read_write: + - /sandbox + - /tmp + - /dev/null diff --git a/openshell-filesystem-hierarchical-policy/results/probe-output.jsonl b/openshell-filesystem-hierarchical-policy/results/probe-output.jsonl new file mode 100644 index 0000000..c137e1c --- /dev/null +++ b/openshell-filesystem-hierarchical-policy/results/probe-output.jsonl @@ -0,0 +1,24 @@ +{"id": "1.1", "cat": "overlap", "op": "read", "path": "/sandbox/workspace/target-repo/README.md", "expect": "ok", "actual": "ok", "pass": true, "detail": ""} +{"id": "1.2", "cat": "overlap", "op": "listdir", "path": "/sandbox/workspace/target-repo/", "expect": "ok", "actual": "ok", "pass": true, "detail": ""} +{"id": "1.3", "cat": "overlap", "op": "write", "path": "/sandbox/workspace/target-repo/README.md", "expect": "EACCES", "actual": "ok", "pass": false, "detail": ""} +{"id": "1.4", "cat": "overlap", "op": "create", "path": "/sandbox/workspace/target-repo/new-file.txt", "expect": "EACCES", "actual": "ok", "pass": false, "detail": ""} +{"id": "1.5", "cat": "overlap", "op": "mkdir", "path": "/sandbox/workspace/target-repo/newdir/", "expect": "EACCES", "actual": "ok", "pass": false, "detail": ""} +{"id": "1.6", "cat": "overlap", "op": "write", "path": "/sandbox/workspace/other-project/file.txt", "expect": "ok", "actual": "ok", "pass": true, "detail": ""} +{"id": "1.7", "cat": "overlap", "op": "write", "path": "/sandbox/workspace/file.txt", "expect": "ok", "actual": "ok", "pass": true, "detail": ""} +{"id": "2.1", "cat": "rw", "op": "write+read", "path": "/sandbox/test-rw", "expect": "ok", "actual": "ok", "pass": true, "detail": ""} +{"id": "2.2", "cat": "rw", "op": "write+read", "path": "/tmp/test-rw", "expect": "ok", "actual": "ok", "pass": true, "detail": ""} +{"id": "2.3", "cat": "rw", "op": "write", "path": "/dev/null", "expect": "ok", "actual": "ok", "pass": true, "detail": ""} +{"id": "2.4", "cat": "rw", "op": "mkdir", "path": "/sandbox/newdir/", "expect": "ok", "actual": "ok", "pass": true, "detail": ""} +{"id": "3.1", "cat": "ro", "op": "read", "path": "/usr/bin/ls", "expect": "ok", "actual": "ok", "pass": true, "detail": ""} +{"id": "3.2", "cat": "ro", "op": "write", "path": "/usr/test-write", "expect": "EACCES", "actual": "EACCES", "pass": true, "detail": "[Errno 13] Permission denied: '/usr/test-write'"} +{"id": "3.3", "cat": "ro", "op": "read", "path": "/etc/hostname", "expect": "ok", "actual": "ok", "pass": true, "detail": ""} +{"id": "3.4", "cat": "ro", "op": "write", "path": "/etc/test-write", "expect": "EACCES", "actual": "EACCES", "pass": true, "detail": "[Errno 13] Permission denied: '/etc/test-write'"} +{"id": "3.5", "cat": "ro", "op": "read", "path": "/proc/self/status", "expect": "ok", "actual": "ok", "pass": true, "detail": ""} +{"id": "3.6", "cat": "ro", "op": "read", "path": "/dev/urandom", "expect": "ok", "actual": "ok", "pass": true, "detail": ""} +{"id": "4.1", "cat": "deny", "op": "read", "path": "/home/", "expect": "EACCES", "actual": "EACCES", "pass": true, "detail": "[Errno 13] Permission denied: '/home'"} +{"id": "4.2", "cat": "deny", "op": "read", "path": "/root/", "expect": "EACCES", "actual": "EACCES", "pass": true, "detail": "[Errno 13] Permission denied: '/root'"} +{"id": "4.3", "cat": "deny", "op": "read", "path": "/opt/", "expect": "EACCES", "actual": "EACCES", "pass": true, "detail": "[Errno 13] Permission denied: '/opt'"} +{"id": "4.4", "cat": "deny", "op": "write", "path": "/opt/test-write", "expect": "EACCES", "actual": "EACCES", "pass": true, "detail": "[Errno 13] Permission denied: '/opt/test-write'"} +{"id": "5.1", "cat": "edge", "op": "symlink_write", "path": "/tmp/link-to-etc-passwd", "expect": "EACCES", "actual": "EACCES", "pass": true, "detail": "[Errno 13] Permission denied: '/tmp/link-to-etc-passwd'"} +{"id": "5.2", "cat": "edge", "op": "traversal_write", "path": "/sandbox/workspace/target-repo/../other/file", "expect": "ok", "actual": "ok", "pass": true, "detail": ""} +{"id": "5.3", "cat": "edge", "op": "delete", "path": "/sandbox/workspace/target-repo/README.md", "expect": "EACCES", "actual": "ok", "pass": false, "detail": ""} diff --git a/openshell-filesystem-hierarchical-policy/results/sandbox-logs.txt b/openshell-filesystem-hierarchical-policy/results/sandbox-logs.txt new file mode 100644 index 0000000..cc89a58 --- /dev/null +++ b/openshell-filesystem-hierarchical-policy/results/sandbox-logs.txt @@ -0,0 +1,69 @@ +Warning: log buffer contains only the last 68 lines; --since results may be incomplete. +[1781705567.203] [gateway] [INFO ] [openshell_server::grpc::sandbox] minted sandbox JWT +[1781705567.229] [gateway] [INFO ] [openshell_driver_podman::driver] Creating sandbox container +[1781705568.376] [sandbox] [INFO ] [openshell_sandbox] Starting sandbox command=["sleep", "infinity"] +[1781705568.376] [sandbox] [INFO ] [openshell_sandbox] Fetching sandbox policy via gRPC endpoint=https://host.containers.internal:17670 sandbox_id=99287863-e0f9-42bb-97e2-f6f4ceb0840b +[1781705568.408] [gateway] [INFO ] [openshell_driver_podman::driver] Sandbox container started +[1781705568.408] [gateway] [INFO ] [openshell_server::grpc::sandbox] CreateSandbox request completed successfully +[1781705568.445] [gateway] [INFO ] [openshell_server::grpc::policy] GetSandboxConfig served from spec (backfilled version 1) +[1781705568.448] [gateway] [INFO ] [openshell_server::grpc::policy] GetSandboxProviderEnvironment request completed successfully +[1781705568.627] [gateway] [INFO ] [openshell_server::supervisor_session] supervisor session: accepted +[1781705568.730] [gateway] [INFO ] [openshell_server::supervisor_session] supervisor session: relay opened successfully +[1781705568.445] [sandbox] [INFO ] [openshell_sandbox] Creating OPA engine from proto policy data +[1781705568.447] [sandbox] [OCSF ] [ocsf] CONFIG:VALIDATED [INFO] Validated 'sandbox' user exists in image +[1781705568.489] [sandbox] [OCSF ] [ocsf] CONFIG:LOADED [INFO] Fetched provider environment [env_count:0] +[1781705568.533] [sandbox] [OCSF ] [ocsf] CONFIG:ENABLED [INFO] TLS termination enabled: ephemeral CA generated +[1781705568.533] [sandbox] [OCSF ] [ocsf] CONFIG:CREATING [INFO] Creating network namespace [ns:sandbox-68ca2a99 host_veth:veth-h-68ca2a99 sandbox_veth:veth-s-68ca2a99] +[1781705568.555] [sandbox] [OCSF ] [ocsf] CONFIG:CREATED [INFO] Network namespace created [ns:sandbox-68ca2a99 host_ip:10.200.0.1 sandbox_ip:10.200.0.2] +[1781705568.560] [sandbox] [OCSF ] [ocsf] CONFIG:INSTALLED [INFO] Bypass detection rules installed [ns:sandbox-68ca2a99] +[1781705568.560] [sandbox] [INFO ] [openshell_sandbox] Fetching inference route bundle from gateway endpoint=https://host.containers.internal:17670 +[1781705568.616] [sandbox] [OCSF ] [ocsf] CONFIG:LOADED [INFO] Loaded inference route bundle [route_count:0 revision:d1fba762150c532c] +[1781705568.616] [sandbox] [OCSF ] [ocsf] CONFIG:WAITING [INFO] Inference route bundle is empty; keeping routing enabled and waiting for refresh +[1781705568.616] [sandbox] [OCSF ] [ocsf] CONFIG:ENABLED [INFO] Inference routing enabled with local execution [route_count:0] +[1781705568.624] [sandbox] [OCSF ] [ocsf] NET:LISTEN [INFO] 10.200.0.1:3128 +[1781705568.624] [sandbox] [INFO ] [openshell_sandbox::proxy] Trusted host gateway detected from /etc/hosts; host-gateway aliases exempt from SSRF always-blocked check ip=169.254.1.2 +[1781705568.625] [sandbox] [OCSF ] [ocsf] SSH:LISTEN [INFO] +[1781705568.625] [sandbox] [OCSF ] [ocsf] LIFECYCLE:INSTALL [INFO] OpenShell Sandbox Supervisor success +[1781705568.625] [sandbox] [INFO ] [openshell_sandbox] supervisor session task spawned +[1781705568.625] [sandbox] [OCSF ] [ocsf] CONFIG:PROBED [INFO] Landlock filesystem sandbox available [abi:v8 compat:BestEffort ro:8 rw:3] +[1781705568.625] [sandbox] [OCSF ] [ocsf] CONFIG:APPLYING [INFO] Applying Landlock filesystem sandbox [abi:V2 compat:BestEffort ro:8 rw:3] +[1781705568.625] [sandbox] [OCSF ] [ocsf] CONFIG:BUILT [INFO] Landlock ruleset built [rules_applied:9 skipped:2] +[1781705568.626] [sandbox] [OCSF ] [ocsf] PROC:LAUNCH [INFO] sleep(23) +[1781705568.668] [sandbox] [OCSF ] [ocsf] NET:OPEN [INFO] host.containers.internal:17670 +[1781705568.729] [sandbox] [OCSF ] [ocsf] NET:OPEN [INFO] [msg:ssh relay open (channel_id=ab852ef9-226b-4094-a240-be0ac519f2e2, target=unix:/run/openshell/ssh.sock)] +[1781705568.729] [sandbox] [OCSF ] [ocsf] SSH:OPEN [INFO] ALLOWED +[1781705568.818] [sandbox] [OCSF ] [ocsf] CONFIG:APPLYING [INFO] Applying Landlock filesystem sandbox [abi:V2 compat:BestEffort ro:8 rw:3] +[1781705568.818] [sandbox] [OCSF ] [ocsf] CONFIG:BUILT [INFO] Landlock ruleset built [rules_applied:9 skipped:2] +[1781705568.862] [sandbox] [OCSF ] [ocsf] NET:CLOSE [INFO] [msg:ssh relay closed (channel_id=ab852ef9-226b-4094-a240-be0ac519f2e2, target=unix:/run/openshell/ssh.sock)] +[1781705568.943] [gateway] [INFO ] [openshell_server::supervisor_session] supervisor session: relay opened successfully +[1781705569.152] [gateway] [INFO ] [openshell_server::supervisor_session] supervisor session: relay opened successfully +[1781705568.943] [sandbox] [OCSF ] [ocsf] NET:OPEN [INFO] [msg:ssh relay open (channel_id=bd1992a9-fb97-492b-b500-c96d1a399dc3, target=unix:/run/openshell/ssh.sock)] +[1781705568.943] [sandbox] [OCSF ] [ocsf] SSH:OPEN [INFO] ALLOWED +[1781705569.031] [sandbox] [OCSF ] [ocsf] CONFIG:APPLYING [INFO] Applying Landlock filesystem sandbox [abi:V2 compat:BestEffort ro:8 rw:3] +[1781705569.031] [sandbox] [OCSF ] [ocsf] CONFIG:BUILT [INFO] Landlock ruleset built [rules_applied:9 skipped:2] +[1781705569.075] [sandbox] [OCSF ] [ocsf] NET:CLOSE [INFO] [msg:ssh relay closed (channel_id=bd1992a9-fb97-492b-b500-c96d1a399dc3, target=unix:/run/openshell/ssh.sock)] +[1781705569.127] [sandbox] [INFO ] [openshell_sandbox] Container filesystem accessible, resolving policy binary symlinks attempt=1 pid=23 +[1781705569.129] [sandbox] [INFO ] [openshell_sandbox] Policy binary symlink resolution complete (check logs above for per-binary results) pid=23 +[1781705569.152] [sandbox] [OCSF ] [ocsf] NET:OPEN [INFO] [msg:ssh relay open (channel_id=41746da2-2620-4207-81cd-b1adf0e45604, target=unix:/run/openshell/ssh.sock)] +[1781705569.152] [sandbox] [OCSF ] [ocsf] SSH:OPEN [INFO] ALLOWED +[1781705569.280] [sandbox] [OCSF ] [ocsf] CONFIG:APPLYING [INFO] Applying Landlock filesystem sandbox [abi:V2 compat:BestEffort ro:8 rw:3] +[1781705569.280] [sandbox] [OCSF ] [ocsf] CONFIG:BUILT [INFO] Landlock ruleset built [rules_applied:9 skipped:2] +[1781705569.326] [sandbox] [OCSF ] [ocsf] NET:CLOSE [INFO] [msg:ssh relay closed (channel_id=41746da2-2620-4207-81cd-b1adf0e45604, target=unix:/run/openshell/ssh.sock)] +[1781705569.442] [gateway] [INFO ] [openshell_server::supervisor_session] supervisor session: relay opened successfully +[1781705569.651] [gateway] [INFO ] [openshell_server::supervisor_session] supervisor session: relay opened successfully +[1781705569.442] [sandbox] [OCSF ] [ocsf] NET:OPEN [INFO] [msg:ssh relay open (channel_id=5c9339a0-91e1-40b5-ad03-cf3560426875, target=unix:/run/openshell/ssh.sock)] +[1781705569.442] [sandbox] [OCSF ] [ocsf] SSH:OPEN [INFO] ALLOWED +[1781705569.530] [sandbox] [OCSF ] [ocsf] CONFIG:APPLYING [INFO] Applying Landlock filesystem sandbox [abi:V2 compat:BestEffort ro:8 rw:3] +[1781705569.530] [sandbox] [OCSF ] [ocsf] CONFIG:BUILT [INFO] Landlock ruleset built [rules_applied:10 skipped:1] +[1781705569.574] [sandbox] [OCSF ] [ocsf] NET:CLOSE [INFO] [msg:ssh relay closed (channel_id=5c9339a0-91e1-40b5-ad03-cf3560426875, target=unix:/run/openshell/ssh.sock)] +[1781705569.651] [sandbox] [OCSF ] [ocsf] NET:OPEN [INFO] [msg:ssh relay open (channel_id=310213da-f7c1-4c43-86fa-1aa0256e7057, target=unix:/run/openshell/ssh.sock)] +[1781705569.651] [sandbox] [OCSF ] [ocsf] SSH:OPEN [INFO] ALLOWED +[1781705569.738] [sandbox] [OCSF ] [ocsf] CONFIG:APPLYING [INFO] Applying Landlock filesystem sandbox [abi:V2 compat:BestEffort ro:8 rw:3] +[1781705569.738] [sandbox] [OCSF ] [ocsf] CONFIG:BUILT [INFO] Landlock ruleset built [rules_applied:10 skipped:1] +[1781705569.827] [sandbox] [OCSF ] [ocsf] NET:CLOSE [INFO] [msg:ssh relay closed (channel_id=310213da-f7c1-4c43-86fa-1aa0256e7057, target=unix:/run/openshell/ssh.sock)] +[1781705569.951] [gateway] [INFO ] [openshell_server::supervisor_session] supervisor session: relay opened successfully +[1781705569.951] [sandbox] [OCSF ] [ocsf] NET:OPEN [INFO] [msg:ssh relay open (channel_id=473329eb-02bf-47ce-8006-776e72ba6a52, target=unix:/run/openshell/ssh.sock)] +[1781705569.951] [sandbox] [OCSF ] [ocsf] SSH:OPEN [INFO] ALLOWED +[1781705570.039] [sandbox] [OCSF ] [ocsf] CONFIG:APPLYING [INFO] Applying Landlock filesystem sandbox [abi:V2 compat:BestEffort ro:8 rw:3] +[1781705570.039] [sandbox] [OCSF ] [ocsf] CONFIG:BUILT [INFO] Landlock ruleset built [rules_applied:10 skipped:1] +[1781705570.151] [sandbox] [OCSF ] [ocsf] NET:CLOSE [INFO] [msg:ssh relay closed (channel_id=473329eb-02bf-47ce-8006-776e72ba6a52, target=unix:/run/openshell/ssh.sock)] diff --git a/openshell-filesystem-hierarchical-policy/run.sh b/openshell-filesystem-hierarchical-policy/run.sh new file mode 100755 index 0000000..4aaa89f --- /dev/null +++ b/openshell-filesystem-hierarchical-policy/run.sh @@ -0,0 +1,315 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +RESULTS_DIR="$SCRIPT_DIR/results" +POLICY_FILE="$SCRIPT_DIR/policies/policy.yaml" +PROBE_FILE="$SCRIPT_DIR/src/probe.py" +SANDBOX_NAME="fs-policy-test" + +REPO="" + +BOLD='\033[1m' +CYAN='\033[36m' +GREEN='\033[32m' +RED='\033[31m' +YELLOW='\033[33m' +DIM='\033[2m' +RESET='\033[0m' + +step() { + printf "\n${BOLD}${CYAN}▸ %s${RESET}\n\n" "$1" +} +pass() { printf " ${GREEN}✓ %s${RESET}\n" "$1"; } +fail() { printf " ${RED}✗ %s${RESET}\n" "$1"; } +warn() { printf " ${YELLOW}⚠ %s${RESET}\n" "$1"; } +info() { printf " ${DIM}%s${RESET}\n" "$1"; } + +SSH_CONFIG="" +SSH_HOST="" + +sandbox_exec() { + ssh -F "$SSH_CONFIG" "$SSH_HOST" "$@" 2>&1 +} + +wait_for_ssh() { + local retries=20 + for _i in $(seq 1 "$retries"); do + if ssh -F "$SSH_CONFIG" "$SSH_HOST" \ + true >/dev/null 2>&1; then + return 0 + fi + sleep 3 + done + fail "SSH connection timed out after $retries attempts" + return 1 +} + +connect_sandbox() { + SSH_CONFIG=$(mktemp) + openshell sandbox ssh-config "$SANDBOX_NAME" \ + > "$SSH_CONFIG" + SSH_HOST=$(awk '/^Host / { print $2; exit }' \ + "$SSH_CONFIG") + wait_for_ssh +} + +# ── Prerequisites ────────────────────────────────────── + +check_prereqs() { + step "Checking prerequisites" + local ok=true + + if command -v openshell &>/dev/null; then + pass "openshell CLI found" + else + fail "openshell CLI not found" + ok=false + fi + + if openshell gateway info &>/dev/null; then + pass "openshell gateway running" + else + fail "openshell gateway not running" + info "Run: openshell gateway start" + ok=false + fi + + if [[ -n "$REPO" ]]; then + if ! command -v gh &>/dev/null; then + fail "gh CLI not found (required for --repo)" + ok=false + elif ! gh auth status &>/dev/null; then + fail "gh not authenticated" + ok=false + elif ! gh api "repos/$REPO" -q .full_name \ + &>/dev/null; then + fail "Cannot access repo $REPO" + ok=false + else + pass "Repo $REPO accessible" + fi + fi + + $ok || { echo "Prerequisites not met"; exit 1; } +} + +# ── Fixtures ─────────────────────────────────────────── + +seed_fixtures() { + step "Seeding test fixtures" + + if [[ -n "$REPO" ]]; then + info "Cloning $REPO into target-repo..." + sandbox_exec "git clone \ + https://github.com/$REPO.git \ + /sandbox/workspace/target-repo" || { + fail "Failed to clone $REPO" + return 1 + } + pass "Cloned $REPO" + else + info "Creating synthetic fixtures..." + sandbox_exec "mkdir -p \ + /sandbox/workspace/target-repo" + sandbox_exec "echo '# Test repo' > \ + /sandbox/workspace/target-repo/README.md" + pass "Created target-repo with README.md" + fi +} + +# ── Probe ────────────────────────────────────────────── + +run_probe() { + step "Running filesystem policy probe" + mkdir -p "$RESULTS_DIR" + + info "Uploading probe.py..." + scp -F "$SSH_CONFIG" "$PROBE_FILE" \ + "$SSH_HOST:/sandbox/probe.py" + pass "Uploaded probe.py" + + info "Executing probe..." + sandbox_exec "python3 /sandbox/probe.py" \ + > "$RESULTS_DIR/probe-output.jsonl" 2>&1 + pass "Probe complete" +} + +# ── Results ──────────────────────────────────────────── + +parse_results() { + step "Results" + local total=0 passed=0 failed=0 + local output="$RESULTS_DIR/probe-output.jsonl" + + if [[ ! -f "$output" ]]; then + fail "No probe output found" + return 1 + fi + + printf "\n %-5s %-8s %-18s %-25s %-8s %-8s\n" \ + "ID" "CAT" "OP" "PATH" "EXPECT" "RESULT" + printf " %s\n" \ + "$(printf '%.0s─' {1..76})" + + while IFS= read -r line; do + local id cat op path expect actual pass_val + id=$(echo "$line" | python3 -c \ + "import json,sys; print(json.load(sys.stdin)['id'])") + cat=$(echo "$line" | python3 -c \ + "import json,sys; print(json.load(sys.stdin)['cat'])") + op=$(echo "$line" | python3 -c \ + "import json,sys; print(json.load(sys.stdin)['op'])") + path=$(echo "$line" | python3 -c \ + "import json,sys; print(json.load(sys.stdin)['path'])") + expect=$(echo "$line" | python3 -c \ + "import json,sys; print(json.load(sys.stdin)['expect'])") + actual=$(echo "$line" | python3 -c \ + "import json,sys; print(json.load(sys.stdin)['actual'])") + pass_val=$(echo "$line" | python3 -c \ + "import json,sys; print(json.load(sys.stdin)['pass'])") + + total=$((total + 1)) + + local icon + if [[ "$pass_val" == "True" ]]; then + passed=$((passed + 1)) + icon="${GREEN}✓${RESET}" + else + failed=$((failed + 1)) + icon="${RED}✗${RESET}" + fi + + # Truncate long paths for display + local short_path="$path" + if [[ ${#path} -gt 25 ]]; then + short_path="…${path: -24}" + fi + + printf " ${icon} %-4s %-8s %-18s %-25s %-8s %-8s\n" \ + "$id" "$cat" "$op" "$short_path" "$expect" \ + "$actual" + done < "$output" + + printf " %s\n" \ + "$(printf '%.0s─' {1..76})" + printf " Total: %d " "$total" + printf "${GREEN}Passed: %d${RESET} " "$passed" + if [[ $failed -gt 0 ]]; then + printf "${RED}Failed: %d${RESET}\n" "$failed" + else + printf "Failed: 0\n" + fi + + return "$failed" +} + +# ── Logs ─────────────────────────────────────────────── + +collect_logs() { + step "Collecting logs" + mkdir -p "$RESULTS_DIR" + openshell logs "$SANDBOX_NAME" --since 30m -n 200 \ + > "$RESULTS_DIR/sandbox-logs.txt" 2>&1 || true + info "Logs saved to $RESULTS_DIR/" +} + +# ── Cleanup ──────────────────────────────────────────── + +cleanup() { + if [[ -n "$SSH_CONFIG" && -f "$SSH_CONFIG" ]]; then + rm -f "$SSH_CONFIG" + fi + if openshell sandbox get "$SANDBOX_NAME" \ + &>/dev/null 2>&1; then + info "Deleting sandbox $SANDBOX_NAME..." + openshell sandbox delete "$SANDBOX_NAME" \ + 2>/dev/null || true + fi +} + +# ── Main ─────────────────────────────────────────────── + +usage() { + cat <] + +Options: + --repo Clone a real GitHub repo into + target-repo instead of using + synthetic fixtures + +Examples: + ./run.sh # synthetic fixtures + ./run.sh --repo octocat/Hello-World # real repo +USAGE +} + +main() { + # Parse args + while [[ $# -gt 0 ]]; do + case "$1" in + --repo) + REPO="${2:?--repo requires a value}" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 1 + ;; + esac + done + + echo "" + printf "${BOLD}OpenShell Filesystem Policy Test${RESET}\n" + if [[ -n "$REPO" ]]; then + printf "${DIM}Mode: real repo ($REPO)${RESET}\n" + else + printf "${DIM}Mode: synthetic fixtures${RESET}\n" + fi + echo "" + + trap cleanup EXIT + + check_prereqs + + step "Creating sandbox" + info "Name: $SANDBOX_NAME" + info "Policy: $POLICY_FILE" + openshell sandbox create \ + --name "$SANDBOX_NAME" \ + --policy "$POLICY_FILE" \ + --keep \ + --no-tty \ + --from base \ + -- true + pass "Sandbox created" + + connect_sandbox + pass "SSH connected" + + seed_fixtures + run_probe + + local exit_code=0 + parse_results || exit_code=$? + + collect_logs + + if [[ $exit_code -eq 0 ]]; then + printf "\n${BOLD}${GREEN}✓ All assertions passed.${RESET}\n" + else + printf "\n${BOLD}${RED}✗ %d assertion(s) failed.${RESET}\n" \ + "$exit_code" + fi + printf " Results in: $RESULTS_DIR/\n\n" + + exit "$exit_code" +} + +main "$@" diff --git a/openshell-filesystem-hierarchical-policy/src/probe.py b/openshell-filesystem-hierarchical-policy/src/probe.py new file mode 100644 index 0000000..6b081f2 --- /dev/null +++ b/openshell-filesystem-hierarchical-policy/src/probe.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python3 +"""Filesystem policy probe for OpenShell sandbox. + +Runs 24 assertions against the active Landlock policy and +emits one JSON line per test to stdout. Always exits 0 so the +host orchestrator can parse results even when assertions fail. +""" + +import errno +import json +import os + + +OVERLAP_BASE = "/sandbox/workspace/target-repo" + + +def emit(test_id, cat, op, path, expect, actual, passed, + detail=""): + print( + json.dumps( + { + "id": test_id, + "cat": cat, + "op": op, + "path": path, + "expect": expect, + "actual": actual, + "pass": passed, + "detail": detail, + } + ), + flush=True, + ) + + +def ename(code): + return errno.errorcode.get(code, f"errno={code}") + + +def try_read(path): + try: + if os.path.isdir(path): + os.listdir(path) + else: + with open(path, "rb") as f: + f.read(1) + return "ok", "" + except OSError as exc: + return ename(exc.errno), str(exc) + + +def try_write(path, content=b"probe-test\n"): + try: + with open(path, "wb") as f: + f.write(content) + return "ok", "" + except OSError as exc: + return ename(exc.errno), str(exc) + + +def try_write_readback(path, content=b"probe-test\n"): + actual, detail = try_write(path, content) + if actual != "ok": + return actual, detail + try: + with open(path, "rb") as f: + got = f.read() + if got != content: + return "MISMATCH", f"wrote {content!r}, read {got!r}" + return "ok", "" + except OSError as exc: + return ename(exc.errno), f"readback failed: {exc}" + + +def try_mkdir(path): + try: + os.mkdir(path) + return "ok", "" + except OSError as exc: + return ename(exc.errno), str(exc) + + +def try_listdir(path): + try: + os.listdir(path) + return "ok", "" + except OSError as exc: + return ename(exc.errno), str(exc) + + +def try_unlink(path): + try: + os.unlink(path) + return "ok", "" + except OSError as exc: + return ename(exc.errno), str(exc) + + +def try_symlink_write(link_path, target, content=b"x\n"): + try: + os.symlink(target, link_path) + except OSError as exc: + return ename(exc.errno), f"symlink failed: {exc}" + try: + with open(link_path, "wb") as f: + f.write(content) + return "ok", "" + except OSError as exc: + return ename(exc.errno), str(exc) + + +def check(test_id, cat, op, path, expect, result): + actual, detail = result + emit(test_id, cat, op, path, expect, actual, + actual == expect, detail) + + +def run_overlap(): + tr = OVERLAP_BASE + check("1.1", "overlap", "read", f"{tr}/README.md", "ok", + try_read(f"{tr}/README.md")) + check("1.2", "overlap", "listdir", f"{tr}/", "ok", + try_listdir(tr)) + check("1.3", "overlap", "write", f"{tr}/README.md", + "EACCES", try_write(f"{tr}/README.md")) + check("1.4", "overlap", "create", f"{tr}/new-file.txt", + "EACCES", try_write(f"{tr}/new-file.txt")) + check("1.5", "overlap", "mkdir", f"{tr}/newdir/", + "EACCES", try_mkdir(f"{tr}/newdir")) + sibling = "/sandbox/workspace/other-project" + os.makedirs(sibling, exist_ok=True) + check("1.6", "overlap", "write", + f"{sibling}/file.txt", "ok", + try_write(f"{sibling}/file.txt")) + check("1.7", "overlap", "write", + "/sandbox/workspace/file.txt", "ok", + try_write("/sandbox/workspace/file.txt")) + + +def run_readwrite(): + check("2.1", "rw", "write+read", "/sandbox/test-rw", + "ok", try_write_readback("/sandbox/test-rw")) + check("2.2", "rw", "write+read", "/tmp/test-rw", + "ok", try_write_readback("/tmp/test-rw")) + check("2.3", "rw", "write", "/dev/null", + "ok", try_write("/dev/null")) + check("2.4", "rw", "mkdir", "/sandbox/newdir/", + "ok", try_mkdir("/sandbox/newdir")) + + +def run_readonly(): + check("3.1", "ro", "read", "/usr/bin/ls", + "ok", try_read("/usr/bin/ls")) + check("3.2", "ro", "write", "/usr/test-write", + "EACCES", try_write("/usr/test-write")) + check("3.3", "ro", "read", "/etc/hostname", + "ok", try_read("/etc/hostname")) + check("3.4", "ro", "write", "/etc/test-write", + "EACCES", try_write("/etc/test-write")) + check("3.5", "ro", "read", "/proc/self/status", + "ok", try_read("/proc/self/status")) + check("3.6", "ro", "read", "/dev/urandom", + "ok", try_read("/dev/urandom")) + + +def run_deny(): + check("4.1", "deny", "read", "/home/", + "EACCES", try_listdir("/home")) + check("4.2", "deny", "read", "/root/", + "EACCES", try_listdir("/root")) + check("4.3", "deny", "read", "/opt/", + "EACCES", try_listdir("/opt")) + check("4.4", "deny", "write", "/opt/test-write", + "EACCES", try_write("/opt/test-write")) + + +def run_edge(): + check("5.1", "edge", "symlink_write", + "/tmp/link-to-etc-passwd", "EACCES", + try_symlink_write("/tmp/link-to-etc-passwd", + "/etc/passwd")) + traversal = ( + "/sandbox/workspace/target-repo/../other/file" + ) + os.makedirs("/sandbox/workspace/other", exist_ok=True) + check("5.2", "edge", "traversal_write", + traversal, "ok", try_write(traversal)) + check("5.3", "edge", "delete", + f"{OVERLAP_BASE}/README.md", "EACCES", + try_unlink(f"{OVERLAP_BASE}/README.md")) + + +def main(): + run_overlap() + run_readwrite() + run_readonly() + run_deny() + run_edge() + + +if __name__ == "__main__": + main() diff --git a/openshell-filesystem-hierarchical-policy/superpowers/2026-06-12-1556-openshell-filesystem-policy-experiment-design.md b/openshell-filesystem-hierarchical-policy/superpowers/2026-06-12-1556-openshell-filesystem-policy-experiment-design.md new file mode 100644 index 0000000..cf45889 --- /dev/null +++ b/openshell-filesystem-hierarchical-policy/superpowers/2026-06-12-1556-openshell-filesystem-policy-experiment-design.md @@ -0,0 +1,247 @@ +# OpenShell filesystem policy test + +**Date:** 2026-06-12 +**Status:** Draft + +## Goal + +Validate that OpenShell's Landlock-based filesystem policy correctly +enforces read\_only/read\_write path restrictions, with a primary +focus on **overlap behavior**: a subdirectory marked `read_only` +inside a parent marked `read_write` should be read-only. + +## Policy under test + +```yaml +version: 1 + +filesystem_policy: + include_workdir: false + read_only: + - /usr + - /lib + - /proc + - /dev/urandom + - /app + - /etc + - /var/log + - /sandbox/workspace/target-repo/ + read_write: + - /sandbox + - /tmp + - /dev/null +``` + +`include_workdir` must be explicitly set to `false` — it defaults +to `true` throughout the OpenShell implementation and would silently +add the sandbox's working directory to `read_write`, potentially +masking the overlap behavior under test. + +## Architecture + +```text +experiments/openshell-filesystem-policy/ +├── run.sh # Bash orchestrator (host-side) +├── probe.py # Python test runner (inside sandbox) +├── policy.yaml # Policy under test +├── README.md # Hypotheses, instructions, results +└── results/ # Output directory (.gitignored) + ├── probe-output.jsonl + └── sandbox-logs.txt +``` + +### run.sh (orchestrator) + +Host-side bash script following the pattern established in +`experiments/openshell-policy-bypass/run.sh`: + +1. Check prerequisites (`openshell` CLI, gateway running) +2. Create sandbox with `--policy policy.yaml --keep + --no-tty --from base` +3. Connect via SSH (poll for readiness) +4. Seed fixtures (mkdir, write test files; optionally + clone a real repo if `--repo` flag provided) +5. SCP `probe.py` into `/sandbox/probe.py` +6. Execute: `ssh ... python3 /sandbox/probe.py` +7. Parse JSONL output, print summary table by category +8. Collect sandbox logs via `openshell logs` +9. Delete sandbox +10. Exit nonzero if any assertion failed + +### probe.py (runs inside sandbox) + +Python script executing inside the sandbox. Each test +attempts one filesystem operation, captures the result and +`errno` on failure, and emits a JSON line. + +Output format (one JSON object per line): + +```json +{"id":"1.3","cat":"overlap","op":"write","path":"/sandbox/workspace/target-repo/README.md","expect":"EACCES","actual":"EACCES","pass":true} +``` + +Fields: + +- `id`: test number from the matrix +- `cat`: category (overlap, rw, ro, deny, edge) +- `op`: operation attempted +- `path`: filesystem path tested +- `expect`: expected outcome ("ok" or errno name) +- `actual`: actual outcome +- `pass`: boolean +- `detail`: optional context on failure + +The probe always exits 0 so the orchestrator can parse +results even when assertions fail. + +Errno values that distinguish policy enforcement from +other failures: + +- EACCES (13): permission denied — Landlock blocked it +- EPERM (1): operation not permitted — also a policy denial +- ENOENT (2): path doesn't exist — not a policy block + (indicates a fixture setup problem) + +## Test matrix + +24 assertions across five categories. + +### Category 1 — Overlap (primary focus, 7 tests) + +- **1.1** Read file + `…/target-repo/README.md` → ok +- **1.2** List directory + `…/target-repo/` → ok +- **1.3** Write file + `…/target-repo/README.md` → EACCES + *Headline test — write blocked despite parent rw* +- **1.4** Create new file + `…/target-repo/new-file.txt` → EACCES +- **1.5** Create subdirectory + `…/target-repo/newdir/` → EACCES +- **1.6** Write to sibling + `…/workspace/other-project/file.txt` → ok + *Sibling inherits /sandbox read\_write* +- **1.7** Write to parent + `…/workspace/file.txt` → ok + +All `…/` paths are under `/sandbox/workspace/`. + +### Category 2 — Read-write (4 tests) + +| ID | Op | Path | Exp | +|-----|------------------|------------------|-----| +| 2.1 | Write + readback | /sandbox/test-rw | ok | +| 2.2 | Write + readback | /tmp/test-rw | ok | +| 2.3 | Write | /dev/null | ok | +| 2.4 | Create directory | /sandbox/newdir/ | ok | + +### Category 3 — Read-only (6 tests) + +| ID | Op | Path | Exp | +|-----|-------------|--------------------|--------| +| 3.1 | Read binary | /usr/bin/ls | ok | +| 3.2 | Write | /usr/test-write | EACCES | +| 3.3 | Read file | /etc/hostname | ok | +| 3.4 | Write | /etc/test-write | EACCES | +| 3.5 | Read | /proc/self/status | ok | +| 3.6 | Read 1 byte | /dev/urandom | ok | + +### Category 4 — Deny / unlisted paths (4 tests) + +| ID | Op | Path | Exp | +|-----|-------|-----------------|--------| +| 4.1 | Read | /home/ | EACCES | +| 4.2 | Read | /root/ | EACCES | +| 4.3 | Read | /opt/ | EACCES | +| 4.4 | Write | /opt/test-write | EACCES | + +### Category 5 — Edge cases (3 tests) + +- **5.1** Write via symlink + Create `/tmp/link` → `/etc/passwd`, write through it + → EACCES + *Landlock resolves symlink target before policy check* +- **5.2** Write via traversal + `…/target-repo/../other/file` → ok + *Resolved path is outside the read\_only subtree* +- **5.3** Delete file + `…/target-repo/README.md` (unlink) → EACCES + *Unlink is a write on the parent directory* + +## Hypotheses + +### Primary + +**H0:** Landlock resolves read\_only/read\_write overlap in +favor of the more-specific path. +Confirmed by: tests 1.3, 1.4, 1.5 return EACCES while +1.6, 1.7 succeed. + +### Secondary + +**H1:** Unlisted paths are completely inaccessible. +Confirmed by: tests 4.1–4.4 return EACCES. + +**H2:** Read-only paths permit reads but block writes. +Confirmed by: 3.1, 3.3, 3.5, 3.6 succeed; +3.2, 3.4 return EACCES. + +**H3:** Read-write paths permit both operations. +Confirmed by: tests 2.1–2.4 succeed. + +**H4:** Landlock resolves symlink targets before checking +policy. +Confirmed by: test 5.1 returns EACCES. + +**H5:** Path traversal (`..`) is resolved before policy +check. +Confirmed by: test 5.2 succeeds. + +**H6:** `include_workdir: false` prevents implicit +read\_write grants. +Confirmed by: no surprise writable paths beyond those +explicitly listed. + +### Environmental + +**H7:** OpenShell runs on Fedora 44 (kernel 7.0.10) +despite being outside the support matrix. +Confirmed by: the test completes — sandbox creation, +Landlock enforcement, and SSH all function. + +## Success criteria + +- All 24 assertions pass +- Every "expect EACCES" test returns errno 13 (EACCES) + specifically, not errno 2 (ENOENT) — ENOENT means the + path doesn't exist, indicating a fixture setup problem +- The test runs end-to-end on Fedora without workarounds + +## Failure modes + +If H0 is refuted (overlap not enforced): significant +finding about Landlock/OpenShell behavior. Investigate +whether it is a Landlock kernel limitation (unlikely — +Landlock is designed for hierarchical path rules) or an +OpenShell policy translation issue (more likely — how +OpenShell maps the YAML to Landlock rulesets). + +If H7 is refuted (Fedora doesn't work): document what +fails and fall back to the cloud VM approach from prior +experiments (`experiments/openshell-sandbox-evaluation.md`). + +## Prior art + +- `experiments/openshell-sandbox-evaluation.md` — first + OpenShell evaluation, tested network policies and + container builds. Ran on Ubuntu cloud VM. +- `experiments/openshell-policy-bypass/` — tested + binary-scoped network policy enforcement and agent + bypass resistance. Landlock filesystem read-only on + `/usr` was a supporting enforcement layer there; this + experiment makes filesystem policy the primary subject. +- `docs/ADRs/0030-openshell-sandbox-interaction-model.md` + — documents the sandbox interaction model used by + `fullsend run`, including policy delivery via `--policy` + flag. diff --git a/openshell-filesystem-hierarchical-policy/superpowers/2026-06-12-1600-openshell-filesystem-policy-experiment.md b/openshell-filesystem-hierarchical-policy/superpowers/2026-06-12-1600-openshell-filesystem-policy-experiment.md new file mode 100644 index 0000000..a8692bc --- /dev/null +++ b/openshell-filesystem-hierarchical-policy/superpowers/2026-06-12-1600-openshell-filesystem-policy-experiment.md @@ -0,0 +1,986 @@ +# OpenShell Filesystem Policy Test — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use +> superpowers:subagent-driven-development (recommended) or +> superpowers:executing-plans to implement this plan +> task-by-task. Steps use checkbox (`- [ ]`) syntax for +> tracking. + +**Goal:** Build a bash orchestrator + Python probe that +validates Landlock read\_only/read\_write filesystem policy +enforcement inside an OpenShell sandbox, focusing on overlap +behavior where a read\_only subdirectory sits inside a +read\_write parent. + +**Architecture:** A host-side bash script (`run.sh`) creates +an OpenShell sandbox with a declarative policy, seeds test +fixtures, SCPs a Python probe into the sandbox, and executes +it. The probe (`probe.py`) runs 24 filesystem assertions and +emits JSONL. The orchestrator parses results and prints a +summary table. + +**Tech Stack:** Bash (orchestrator), Python 3 (probe), +OpenShell CLI, SSH/SCP. + +**Spec:** +`docs/superpowers/specs/2026-06-12-openshell-filesystem-policy-test-design.md` + +**Pattern to follow:** +`experiments/openshell-policy-bypass/run.sh` + +--- + +## File Structure + +```text +experiments/openshell-filesystem-policy/ +├── run.sh # Bash orchestrator (host-side) +├── probe.py # Python probe (runs inside sandbox) +├── policy.yaml # Filesystem policy under test +├── README.md # Hypotheses, setup, results +└── .gitignore # Exclude results/ +``` + +- `policy.yaml` — static, checked in, the exact policy + from the spec +- `probe.py` — self-contained, no dependencies beyond + Python stdlib; emits one JSONL line per assertion +- `run.sh` — manages full lifecycle: prereqs → sandbox + create → seed fixtures → run probe → parse → logs → + cleanup; supports `--repo ` for real-repo + mode +- `README.md` — hypotheses table, prerequisites, run + instructions, results section to fill in after running + +--- + +### Task 1: Scaffold experiment directory + +**Files:** + +- Create: `experiments/openshell-filesystem-policy/policy.yaml` +- Create: `experiments/openshell-filesystem-policy/.gitignore` + +- [ ] **Step 1: Create the experiment directory** + +```bash +mkdir -p experiments/openshell-filesystem-policy +``` + +- [ ] **Step 2: Write policy.yaml** + +Write `experiments/openshell-filesystem-policy/policy.yaml`: + +```yaml +version: 1 + +filesystem_policy: + include_workdir: false + read_only: + - /usr + - /lib + - /proc + - /dev/urandom + - /app + - /etc + - /var/log + - /sandbox/workspace/target-repo/ + read_write: + - /sandbox + - /tmp + - /dev/null +``` + +- [ ] **Step 3: Write .gitignore** + +Write `experiments/openshell-filesystem-policy/.gitignore`: + +```text +results/ +``` + +- [ ] **Step 4: Commit scaffold** + +```bash +git add experiments/openshell-filesystem-policy/policy.yaml \ + experiments/openshell-filesystem-policy/.gitignore +git commit -m "chore(experiment): scaffold filesystem policy test + +Assisted-by: Claude Code (Fable 5)" +``` + +--- + +### Task 2: Write probe.py + +**Files:** + +- Create: `experiments/openshell-filesystem-policy/probe.py` + +This is the Python script that runs inside the sandbox. It +attempts 24 filesystem operations and emits JSONL results. +No external dependencies — stdlib only (`os`, `errno`, +`json`). + +- [ ] **Step 1: Write probe.py with all 24 test cases** + +Write `experiments/openshell-filesystem-policy/probe.py`: + +```python +#!/usr/bin/env python3 +"""Filesystem policy probe for OpenShell sandbox. + +Runs 24 assertions against the active Landlock policy and +emits one JSON line per test to stdout. Always exits 0 so the +host orchestrator can parse results even when assertions fail. +""" + +import errno +import json +import os + + +OVERLAP_BASE = "/sandbox/workspace/target-repo" + + +def emit(test_id, cat, op, path, expect, actual, passed, + detail=""): + print( + json.dumps( + { + "id": test_id, + "cat": cat, + "op": op, + "path": path, + "expect": expect, + "actual": actual, + "pass": passed, + "detail": detail, + } + ), + flush=True, + ) + + +def ename(code): + return errno.errorcode.get(code, f"errno={code}") + + +def try_read(path): + try: + if os.path.isdir(path): + os.listdir(path) + else: + with open(path, "rb") as f: + f.read(1) + return "ok", "" + except OSError as exc: + return ename(exc.errno), str(exc) + + +def try_write(path, content=b"probe-test\n"): + try: + with open(path, "wb") as f: + f.write(content) + return "ok", "" + except OSError as exc: + return ename(exc.errno), str(exc) + + +def try_write_readback(path, content=b"probe-test\n"): + actual, detail = try_write(path, content) + if actual != "ok": + return actual, detail + try: + with open(path, "rb") as f: + got = f.read() + if got != content: + return "MISMATCH", f"wrote {content!r}, read {got!r}" + return "ok", "" + except OSError as exc: + return ename(exc.errno), f"readback failed: {exc}" + + +def try_mkdir(path): + try: + os.mkdir(path) + return "ok", "" + except OSError as exc: + return ename(exc.errno), str(exc) + + +def try_listdir(path): + try: + os.listdir(path) + return "ok", "" + except OSError as exc: + return ename(exc.errno), str(exc) + + +def try_unlink(path): + try: + os.unlink(path) + return "ok", "" + except OSError as exc: + return ename(exc.errno), str(exc) + + +def try_symlink_write(link_path, target, content=b"x\n"): + try: + os.symlink(target, link_path) + except OSError as exc: + return ename(exc.errno), f"symlink failed: {exc}" + try: + with open(link_path, "wb") as f: + f.write(content) + return "ok", "" + except OSError as exc: + return ename(exc.errno), str(exc) + + +def check(test_id, cat, op, path, expect, result): + actual, detail = result + emit(test_id, cat, op, path, expect, actual, + actual == expect, detail) + + +def run_overlap(): + tr = OVERLAP_BASE + check("1.1", "overlap", "read", f"{tr}/README.md", "ok", + try_read(f"{tr}/README.md")) + check("1.2", "overlap", "listdir", f"{tr}/", "ok", + try_listdir(tr)) + check("1.3", "overlap", "write", f"{tr}/README.md", + "EACCES", try_write(f"{tr}/README.md")) + check("1.4", "overlap", "create", f"{tr}/new-file.txt", + "EACCES", try_write(f"{tr}/new-file.txt")) + check("1.5", "overlap", "mkdir", f"{tr}/newdir/", + "EACCES", try_mkdir(f"{tr}/newdir")) + sibling = "/sandbox/workspace/other-project" + os.makedirs(sibling, exist_ok=True) + check("1.6", "overlap", "write", + f"{sibling}/file.txt", "ok", + try_write(f"{sibling}/file.txt")) + check("1.7", "overlap", "write", + "/sandbox/workspace/file.txt", "ok", + try_write("/sandbox/workspace/file.txt")) + + +def run_readwrite(): + check("2.1", "rw", "write+read", "/sandbox/test-rw", + "ok", try_write_readback("/sandbox/test-rw")) + check("2.2", "rw", "write+read", "/tmp/test-rw", + "ok", try_write_readback("/tmp/test-rw")) + check("2.3", "rw", "write", "/dev/null", + "ok", try_write("/dev/null")) + check("2.4", "rw", "mkdir", "/sandbox/newdir/", + "ok", try_mkdir("/sandbox/newdir")) + + +def run_readonly(): + check("3.1", "ro", "read", "/usr/bin/ls", + "ok", try_read("/usr/bin/ls")) + check("3.2", "ro", "write", "/usr/test-write", + "EACCES", try_write("/usr/test-write")) + check("3.3", "ro", "read", "/etc/hostname", + "ok", try_read("/etc/hostname")) + check("3.4", "ro", "write", "/etc/test-write", + "EACCES", try_write("/etc/test-write")) + check("3.5", "ro", "read", "/proc/self/status", + "ok", try_read("/proc/self/status")) + check("3.6", "ro", "read", "/dev/urandom", + "ok", try_read("/dev/urandom")) + + +def run_deny(): + check("4.1", "deny", "read", "/home/", + "EACCES", try_listdir("/home")) + check("4.2", "deny", "read", "/root/", + "EACCES", try_listdir("/root")) + check("4.3", "deny", "read", "/opt/", + "EACCES", try_listdir("/opt")) + check("4.4", "deny", "write", "/opt/test-write", + "EACCES", try_write("/opt/test-write")) + + +def run_edge(): + check("5.1", "edge", "symlink_write", + "/tmp/link-to-etc-passwd", "EACCES", + try_symlink_write("/tmp/link-to-etc-passwd", + "/etc/passwd")) + traversal = ( + "/sandbox/workspace/target-repo/../other/file" + ) + os.makedirs("/sandbox/workspace/other", exist_ok=True) + check("5.2", "edge", "traversal_write", + traversal, "ok", try_write(traversal)) + check("5.3", "edge", "delete", + f"{OVERLAP_BASE}/README.md", "EACCES", + try_unlink(f"{OVERLAP_BASE}/README.md")) + + +def main(): + run_overlap() + run_readwrite() + run_readonly() + run_deny() + run_edge() + + +if __name__ == "__main__": + main() +``` + +- [ ] **Step 2: Verify syntax** + +```bash +python3 -m py_compile \ + experiments/openshell-filesystem-policy/probe.py +``` + +Expected: exits 0, no output. + +- [ ] **Step 3: Verify JSONL output structure** + +Run the probe locally. It will fail most assertions (no +sandbox enforcement, paths may not exist), but the output +format should be valid JSONL with the expected fields: + +```bash +python3 experiments/openshell-filesystem-policy/probe.py \ + 2>/dev/null \ + | head -3 \ + | python3 -c " +import json, sys +for line in sys.stdin: + obj = json.loads(line) + assert 'id' in obj, 'missing id' + assert 'cat' in obj, 'missing cat' + assert 'pass' in obj, 'missing pass' + print(f' {obj[\"id\"]:4s} {obj[\"cat\"]:8s} -> ok') +print('JSONL structure valid') +" +``` + +Expected: prints test IDs and "JSONL structure valid". +Some tests may error before producing output (missing +paths) — that's fine; we only need a few lines to +validate the format. + +- [ ] **Step 4: Commit probe.py** + +```bash +git add experiments/openshell-filesystem-policy/probe.py +git commit -m "feat(experiment): add filesystem policy probe + +24-assertion Python probe testing Landlock read_only/read_write +enforcement across five categories: overlap, read-write, +read-only, deny, and edge cases. + +Assisted-by: Claude Code (Fable 5)" +``` + +--- + +### Task 3: Write run.sh + +**Files:** + +- Create: `experiments/openshell-filesystem-policy/run.sh` + +Host-side orchestrator following the pattern in +`experiments/openshell-policy-bypass/run.sh`. Manages the +full lifecycle: prereqs, sandbox creation, fixture seeding, +probe execution, result parsing, log collection, cleanup. + +- [ ] **Step 1: Write run.sh** + +Write `experiments/openshell-filesystem-policy/run.sh`: + +```bash +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +RESULTS_DIR="$SCRIPT_DIR/results" +POLICY_FILE="$SCRIPT_DIR/policy.yaml" +PROBE_FILE="$SCRIPT_DIR/probe.py" +SANDBOX_NAME="fs-policy-test" + +REPO="${1:-}" + +BOLD='\033[1m' +CYAN='\033[36m' +GREEN='\033[32m' +RED='\033[31m' +YELLOW='\033[33m' +DIM='\033[2m' +RESET='\033[0m' + +step() { + printf "\n${BOLD}${CYAN}▸ %s${RESET}\n\n" "$1" +} +pass() { printf " ${GREEN}✓ %s${RESET}\n" "$1"; } +fail() { printf " ${RED}✗ %s${RESET}\n" "$1"; } +warn() { printf " ${YELLOW}⚠ %s${RESET}\n" "$1"; } +info() { printf " ${DIM}%s${RESET}\n" "$1"; } + +SSH_CONFIG="" +SSH_HOST="" + +sandbox_exec() { + ssh -F "$SSH_CONFIG" "$SSH_HOST" "$@" 2>&1 +} + +wait_for_ssh() { + local retries=20 + for i in $(seq 1 "$retries"); do + if ssh -F "$SSH_CONFIG" "$SSH_HOST" \ + true >/dev/null 2>&1; then + return 0 + fi + sleep 3 + done + fail "SSH connection timed out after $retries attempts" + return 1 +} + +connect_sandbox() { + SSH_CONFIG=$(mktemp) + openshell sandbox ssh-config "$SANDBOX_NAME" \ + > "$SSH_CONFIG" + SSH_HOST=$(awk '/^Host / { print $2; exit }' \ + "$SSH_CONFIG") + wait_for_ssh +} + +# ── Prerequisites ────────────────────────────────────── + +check_prereqs() { + step "Checking prerequisites" + local ok=true + + if command -v openshell &>/dev/null; then + pass "openshell CLI found" + else + fail "openshell CLI not found" + ok=false + fi + + if openshell gateway info &>/dev/null; then + pass "openshell gateway running" + else + fail "openshell gateway not running" + info "Run: openshell gateway start" + ok=false + fi + + if [[ -n "$REPO" ]]; then + if ! command -v gh &>/dev/null; then + fail "gh CLI not found (required for --repo)" + ok=false + elif ! gh auth status &>/dev/null; then + fail "gh not authenticated" + ok=false + elif ! gh api "repos/$REPO" -q .full_name \ + &>/dev/null; then + fail "Cannot access repo $REPO" + ok=false + else + pass "Repo $REPO accessible" + fi + fi + + $ok || { echo "Prerequisites not met"; exit 1; } +} + +# ── Fixtures ─────────────────────────────────────────── + +seed_fixtures() { + step "Seeding test fixtures" + + if [[ -n "$REPO" ]]; then + info "Cloning $REPO into target-repo..." + sandbox_exec "git clone \ + https://github.com/$REPO.git \ + /sandbox/workspace/target-repo" || { + fail "Failed to clone $REPO" + return 1 + } + pass "Cloned $REPO" + else + info "Creating synthetic fixtures..." + sandbox_exec "mkdir -p \ + /sandbox/workspace/target-repo" + sandbox_exec "echo '# Test repo' > \ + /sandbox/workspace/target-repo/README.md" + pass "Created target-repo with README.md" + fi +} + +# ── Probe ────────────────────────────────────────────── + +run_probe() { + step "Running filesystem policy probe" + mkdir -p "$RESULTS_DIR" + + info "Uploading probe.py..." + scp -F "$SSH_CONFIG" "$PROBE_FILE" \ + "$SSH_HOST:/sandbox/probe.py" + pass "Uploaded probe.py" + + info "Executing probe..." + sandbox_exec "python3 /sandbox/probe.py" \ + > "$RESULTS_DIR/probe-output.jsonl" 2>&1 + pass "Probe complete" +} + +# ── Results ──────────────────────────────────────────── + +parse_results() { + step "Results" + local total=0 passed=0 failed=0 + local output="$RESULTS_DIR/probe-output.jsonl" + + if [[ ! -f "$output" ]]; then + fail "No probe output found" + return 1 + fi + + printf "\n %-5s %-8s %-18s %-25s %-8s %-8s\n" \ + "ID" "CAT" "OP" "PATH" "EXPECT" "RESULT" + printf " %s\n" \ + "$(printf '%.0s─' {1..76})" + + while IFS= read -r line; do + local id cat op path expect actual pass_val + id=$(echo "$line" | python3 -c \ + "import json,sys; print(json.load(sys.stdin)['id'])") + cat=$(echo "$line" | python3 -c \ + "import json,sys; print(json.load(sys.stdin)['cat'])") + op=$(echo "$line" | python3 -c \ + "import json,sys; print(json.load(sys.stdin)['op'])") + path=$(echo "$line" | python3 -c \ + "import json,sys; print(json.load(sys.stdin)['path'])") + expect=$(echo "$line" | python3 -c \ + "import json,sys; print(json.load(sys.stdin)['expect'])") + actual=$(echo "$line" | python3 -c \ + "import json,sys; print(json.load(sys.stdin)['actual'])") + pass_val=$(echo "$line" | python3 -c \ + "import json,sys; print(json.load(sys.stdin)['pass'])") + + total=$((total + 1)) + + local icon + if [[ "$pass_val" == "True" ]]; then + passed=$((passed + 1)) + icon="${GREEN}✓${RESET}" + else + failed=$((failed + 1)) + icon="${RED}✗${RESET}" + fi + + # Truncate long paths for display + local short_path="$path" + if [[ ${#path} -gt 25 ]]; then + short_path="…${path: -24}" + fi + + printf " ${icon} %-4s %-8s %-18s %-25s %-8s %-8s\n" \ + "$id" "$cat" "$op" "$short_path" "$expect" \ + "$actual" + done < "$output" + + printf " %s\n" \ + "$(printf '%.0s─' {1..76})" + printf " Total: %d " "$total" + printf "${GREEN}Passed: %d${RESET} " "$passed" + if [[ $failed -gt 0 ]]; then + printf "${RED}Failed: %d${RESET}\n" "$failed" + else + printf "Failed: 0\n" + fi + + return "$failed" +} + +# ── Logs ─────────────────────────────────────────────── + +collect_logs() { + step "Collecting logs" + mkdir -p "$RESULTS_DIR" + openshell logs "$SANDBOX_NAME" --since 30m -n 200 \ + > "$RESULTS_DIR/sandbox-logs.txt" 2>&1 || true + info "Logs saved to $RESULTS_DIR/" +} + +# ── Cleanup ──────────────────────────────────────────── + +cleanup() { + if [[ -n "$SSH_CONFIG" && -f "$SSH_CONFIG" ]]; then + rm -f "$SSH_CONFIG" + fi + if openshell sandbox get "$SANDBOX_NAME" \ + &>/dev/null 2>&1; then + info "Deleting sandbox $SANDBOX_NAME..." + openshell sandbox delete "$SANDBOX_NAME" \ + 2>/dev/null || true + fi +} + +# ── Main ─────────────────────────────────────────────── + +usage() { + cat <] + +Options: + --repo Clone a real GitHub repo into + target-repo instead of using + synthetic fixtures + +Examples: + ./run.sh # synthetic fixtures + ./run.sh --repo octocat/Hello-World # real repo +USAGE +} + +main() { + # Parse args + while [[ $# -gt 0 ]]; do + case "$1" in + --repo) + REPO="${2:?--repo requires a value}" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 1 + ;; + esac + done + + echo "" + printf "${BOLD}OpenShell Filesystem Policy Test${RESET}\n" + if [[ -n "$REPO" ]]; then + printf "${DIM}Mode: real repo ($REPO)${RESET}\n" + else + printf "${DIM}Mode: synthetic fixtures${RESET}\n" + fi + echo "" + + trap cleanup EXIT + + check_prereqs + + step "Creating sandbox" + info "Name: $SANDBOX_NAME" + info "Policy: $POLICY_FILE" + openshell sandbox create \ + --name "$SANDBOX_NAME" \ + --policy "$POLICY_FILE" \ + --keep \ + --no-tty \ + --from base \ + -- true + pass "Sandbox created" + + connect_sandbox + pass "SSH connected" + + seed_fixtures + run_probe + + local exit_code=0 + parse_results || exit_code=$? + + collect_logs + + if [[ $exit_code -eq 0 ]]; then + printf "\n${BOLD}${GREEN}✓ All assertions passed.${RESET}\n" + else + printf "\n${BOLD}${RED}✗ %d assertion(s) failed.${RESET}\n" \ + "$exit_code" + fi + printf " Results in: $RESULTS_DIR/\n\n" + + exit "$exit_code" +} + +main "$@" +``` + +- [ ] **Step 2: Make executable** + +```bash +chmod +x experiments/openshell-filesystem-policy/run.sh +``` + +- [ ] **Step 3: Validate with shellcheck** + +```bash +shellcheck experiments/openshell-filesystem-policy/run.sh +``` + +Expected: exits 0 or only style warnings (SC2059 for +printf format strings with color codes is acceptable). +Fix any errors before proceeding. + +- [ ] **Step 4: Validate syntax** + +```bash +bash -n experiments/openshell-filesystem-policy/run.sh +``` + +Expected: exits 0, no output. + +- [ ] **Step 5: Verify --help works** + +```bash +experiments/openshell-filesystem-policy/run.sh --help +``` + +Expected: prints usage information and exits 0. + +- [ ] **Step 6: Commit run.sh** + +```bash +git add experiments/openshell-filesystem-policy/run.sh +git commit -m "feat(experiment): add filesystem policy orchestrator + +Bash orchestrator that creates an OpenShell sandbox, seeds +fixtures, runs the Python probe, and reports results. Supports +--repo flag for testing against a real GitHub repository. + +Assisted-by: Claude Code (Fable 5)" +``` + +--- + +### Task 4: Write README.md + +**Files:** + +- Create: `experiments/openshell-filesystem-policy/README.md` + +- [ ] **Step 1: Write README.md** + +Write +`experiments/openshell-filesystem-policy/README.md`: + +````markdown +# OpenShell Filesystem Policy Test + +Tests whether OpenShell's Landlock-based filesystem policy +correctly enforces read\_only/read\_write path restrictions. + +**Primary question:** When a subdirectory is marked +`read_only` inside a parent marked `read_write`, does the +more-specific restriction win? + +## Prerequisites + +- OpenShell CLI installed and gateway running + (`openshell gateway start`) +- Docker available (OpenShell uses it for sandboxes) + +### For real-repo mode only + +- GitHub CLI (`gh`) installed and authenticated + +## Run + +```shell +# Synthetic fixtures (no external dependencies) +./run.sh + +# Real GitHub repo cloned into target-repo +./run.sh --repo octocat/Hello-World +``` + +## Hypotheses + +| ID | Hypothesis | Tests | +|----|----------------------------------|----------| +| H0 | Overlap: specific path wins | 1.3–1.5 | +| H1 | Unlisted paths are inaccessible | 4.1–4.4 | +| H2 | Read-only: reads ok, writes fail | 3.1–3.6 | +| H3 | Read-write: both operations work | 2.1–2.4 | +| H4 | Symlinks resolved before policy | 5.1 | +| H5 | Traversal resolved before policy | 5.2 | +| H6 | include\_workdir: false honored | all | +| H7 | Runs on Fedora 44 | all | + +## Policy + +See `policy.yaml`. Key detail: `include_workdir: false` +is required — the default is `true`, which would silently +add the workdir to `read_write` and mask the overlap +behavior. + +## Results + +Fill in after running. + +### Assertion summary + +| Cat | Tests | Passed | Failed | +|---------|-------|--------|--------| +| overlap | 7 | | | +| rw | 4 | | | +| ro | 6 | | | +| deny | 4 | | | +| edge | 3 | | | +| Total | 24 | | | + +### Hypothesis outcomes + +| ID | Status | Notes | +|----|----------|-------| +| H0 | | | +| H1 | | | +| H2 | | | +| H3 | | | +| H4 | | | +| H5 | | | +| H6 | | | +| H7 | | | + +## Environment + +- **OpenShell version:** +- **Kernel:** +- **OS:** +- **Date:** +```` + +- [ ] **Step 2: Lint README.md** + +```bash +markdownlint-cli2 \ + experiments/openshell-filesystem-policy/README.md +``` + +Expected: 0 errors. Fix any violations before committing. + +- [ ] **Step 3: Commit README.md** + +```bash +git add experiments/openshell-filesystem-policy/README.md +git commit -m "docs(experiment): add filesystem policy test README + +Assisted-by: Claude Code (Fable 5)" +``` + +--- + +### Task 5: Integration validation + +Run the full experiment against a live OpenShell instance +to validate that all components work together. + +**Prerequisites:** OpenShell CLI installed, gateway running, +Docker available. This task runs on the actual machine — +it cannot be done in a dry-run or mock environment. + +- [ ] **Step 1: Start the OpenShell gateway if needed** + +```bash +openshell gateway info || openshell gateway start +``` + +- [ ] **Step 2: Run the experiment with synthetic fixtures** + +```bash +cd experiments/openshell-filesystem-policy +./run.sh +``` + +Expected: the script creates a sandbox, seeds fixtures, +runs the probe, and prints a results table. All 24 +assertions should pass. + +If the script fails at sandbox creation (Fedora +compatibility issue — H7), document the error in +`README.md` and note that OpenShell still requires +Ubuntu/Debian. + +- [ ] **Step 3: Review probe output** + +```bash +cat results/probe-output.jsonl \ + | python3 -c " +import json, sys +for line in sys.stdin: + r = json.loads(line) + status = 'PASS' if r['pass'] else 'FAIL' + print(f'{status} {r[\"id\"]:4s} {r[\"cat\"]:8s} ' + f'{r[\"op\"]:18s} {r[\"expect\"]:8s} ' + f'-> {r[\"actual\"]}') +" +``` + +Verify: + +- All "expect EACCES" tests show `actual: EACCES` + (not ENOENT or EPERM) +- All "expect ok" tests show `actual: ok` +- 24 lines total + +- [ ] **Step 4: Review sandbox logs** + +```bash +cat results/sandbox-logs.txt +``` + +Check for any Landlock-related messages or unexpected +errors. + +- [ ] **Step 5: Fill in README.md results** + +Update the results tables in `README.md` with actual +pass/fail counts and hypothesis outcomes. + +- [ ] **Step 6: Commit results update** + +```bash +git add experiments/openshell-filesystem-policy/README.md +git commit -m "docs(experiment): record filesystem policy test results + +Assisted-by: Claude Code (Fable 5)" +``` + +--- + +### Task 6: Optional — real repo mode + +Only run this if Task 5 passed with synthetic fixtures +and you want to validate real-repo mode. + +- [ ] **Step 1: Run with a real repo** + +```bash +cd experiments/openshell-filesystem-policy +./run.sh --repo +``` + +Substitute `` with a repo you have read +access to. + +- [ ] **Step 2: Compare results** + +Results should be identical to synthetic mode — the +filesystem policy doesn't care about file content, only +paths. + +- [ ] **Step 3: Update README.md if behavior differs** + +If any assertions differ between synthetic and real-repo +mode, document the difference and investigate. The most +likely cause would be file permissions or ownership +differences from `git clone`.