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`.