# Known risks

> **The register, as of 25 Oct 2026.** One row per risk the project knows about, whatever its state. *Fixed* means on the CLI's `dev` branch with a test that pins it; *accepted* means it stays, with the reason written down; *open* means a decision or a setting is still needed; *designed* means the launcher or a component will close it and nothing does today. The weight is the reviewer's judgement of what it would cost someone relying on the code as documented. A risk that is accepted is as much a part of this page as one that is fixed: the aim is that an outside review finds nothing here it has to tell the project.

## How to read the states

| State | Meaning | Where the proof is |
|---|---|---|
| **fixed** | the code refuses, closes or declares it; a test fails if it comes back | `tests/unit/test_security.py` in the CLI, the test named in the row |
| **accepted** | it stays, because fixing it costs more than it buys at this stage, or because nothing below the launcher can close it; the reason is in the row and the code says so at run time | the row; the `not covered` line of `run` and `hello` |
| **open** | needs a person: a GitHub or PyPI setting, or a design decision | [Hardening §3](../hardening/index.md#3-the-repositories-and-the-accounts-settings-only-a-person-can-change); the roadmap |
| **designed** | the launcher or a component closes it; until then every row is a Boundary for a child and a Setting for whoever applied it | [Policies §2](../../design/policies/index.md#2-who-enforces-decides-the-barrier-kind) |

## The register

| # | Weight | Risk | Where | State | What was done, or why it stays |
|---|---|---|---|---|---|
| SR-01 | High | a path a policy lists as writable was also executable, so a file the child wrote there could run | `run.py`, `landlock.py` | **fixed** 25 Oct 2026 | the write right set excludes execute (`landlock.access_write`); only the exec list grants it. Test: `test_a_writable_path_is_not_executable` |
| SR-02 | High | a writable path that is a symlink binds the rule to its target, so the policy's page grants less than the kernel does; a cloned repository can carry such a link | `run.py` | **fixed** 25 Oct 2026 | `run` refuses before forking when a writable path resolves elsewhere, and the child re-checks on the descriptor the rule binds to. Read and exec paths may still resolve elsewhere (`/lib64` on a merged-`/usr` system) and are reported. Test: `test_a_symlinked_writable_path_is_refused_before_anything_runs` |
| SR-03 | High | `network: "none"` ran with the network open when no namespace could be made and the kernel's Landlock had no TCP rules (ABI 1 to 3); port lists likewise | `run.py` | **fixed** 25 Oct 2026 | a row that cannot be enforced is a refusal (exit 126, nothing ran); `--best-effort` runs anyway and says so on stderr. Test: `test_network_none_without_namespace_or_tcp_rules_is_refused` |
| SR-04 | High | `max_pids` did nothing for a root child without a pids cgroup, because the kernel exempts uid 0 from `RLIMIT_NPROC`; the measured result came from the cgroup alone | `limits.py`, `run.py` | **fixed** 25 Oct 2026 | refused unless a pids cgroup was made, or `--best-effort`. `limits.nproc_binds` says when the rlimit counts; the cgroup v2 controllers are enabled before use. Test: `test_max_pids_as_root_without_a_cgroup_is_refused` |
| SR-05 | High | the release pipeline ran code from outside the project in the job that held the PyPI publishing token: a composite action that fetched an installer over the network and installed unpinned packages | `.github/workflows/workflow.yml` | **fixed** 25 Oct 2026, **untested until the next push to `main`** | the bump is a few lines of shell in the workflow; build and publish are separate jobs; only GitHub's and pypa's actions, pinned to commits; PEP 740 attestations. A `workflow_dispatch` dry run builds and stops. [Supply chain §3](../supply-chain/index.md#3-how-a-release-is-built-and-published) |
| SR-06 | Medium | `hello` started the runner with `python -m`, which puts the current directory first on the import path, so a `sharness_ai/` folder in the workspace would have been imported by the process that applies the policy, before any policy | `hello.py` | **fixed** 25 Oct 2026 | the runner uses `-P -s` (no cwd, no user site); the probes already used `-I`; the guest's wrapper does the same. Test: `test_a_planted_package_in_the_workspace_is_not_imported` |
| SR-07 | Medium | the browser bench downloaded the guest's packages and BIOS without checking them, the BIOS from a moving branch; the chroot form runs those binaries as root on the host | `experiments/browser/build_guest.py` | **fixed** 25 Oct 2026 | every download pinned by sha256 and refused on mismatch, cached copies re-verified every build, the BIOS fetched from one commit, https only. Test: `test_guest_build_pins_every_download` |
| SR-08 | Medium | **Unix-domain sockets by path are not covered.** Landlock has no rule for connecting to a pathname socket and an empty network namespace does not stop it either, so under `network: "none"` a child can still reach a Docker daemon, an X server or a local MCP server by its socket file | Landlock itself | **accepted, declared** | nothing below the launcher closes it; a mount namespace (hide the sockets) or a separate uid (no permission on them) will. `run` says it on stderr every time, `policy check` says it in its advice, `hello` prints it as *not covered*. Test: `test_unix_socket_gap_is_declared` |
| SR-09 | Medium | `hello` counted any failure of a probe as the policy's refusal, so an unreachable network, a read-only filesystem or a small machine produced a pass the policy had not earned | `hello.py` | **fixed** 25 Oct 2026 | an unambiguous refusal (`EACCES`, `MemoryError`) counts; an ambiguous one is checked against a control run without the policy and reported *inconclusive* (`?`) when the control fails too. The control runs only when needed |
| SR-10 | Medium | a file descriptor opened before the policy stayed usable after it: Landlock binds new opens, not what is already open | `run.py` | **fixed** 25 Oct 2026 | the child closes every descriptor above 2 before the policy applies. Test: `test_inherited_descriptors_are_closed` |
| SR-11 | Medium | **the child inherits every environment variable the applying process has**, API keys and proxy credentials included; v0 has no row for the environment | the v0 schema | **open: a design decision**, declared | proposed: `process.env` with `"inherit"`, `"clean"` or an allowlist of names, in the next policy version (a new key leaves every existing hash unchanged; old versions refuse the unknown key, which is right). Until then `run` says it on stderr every time and `policy check` in its advice. The designed end state removes the need: `sharness.llm` and `sharness.kms` hold the secrets, so no other process needs them in its environment |
| SR-12 | Low | the Python and the browser hashed differently for integers above 2^53 (JavaScript rounds them) and for strings with lone surrogates (Python cannot encode them and raised a traceback) | `policy.py`, `playground.js` | **fixed** 25 Oct 2026 | both refused in `canonical()`; the examples are hashed by the JavaScript canonicaliser under test. Tests: `test_integers_beyond_2_53_are_refused`, `test_lone_surrogates_are_refused_not_a_traceback`, `test_the_browser_canonicaliser_agrees_on_the_examples` |
| SR-13 | Low | a policy file with the same key twice kept the last value silently, so the page showed two values and the hash covered one | `policy.py` | **fixed** 25 Oct 2026 | refused on load. Test: `test_duplicate_keys_are_refused` |
| SR-14 | Low | paths with `..`, `.`, a trailing slash or a doubled slash were accepted, so a path could read as one place and bind another; control characters were accepted in paths and ids | `policy.py` | **fixed** 25 Oct 2026 | `check` refuses paths that are not normalised or not printable, and bounds the id. Tests: `test_paths_must_be_normalised`, `test_paths_with_control_characters_are_refused`, `test_id_is_bounded_and_printable` |
| SR-15 | Low | `policy translate --to kubernetes` wrote paths into YAML unquoted, so a path could carry YAML; `--to docker` could not express a path containing `:` | `translate.py` | **fixed** 25 Oct 2026 | paths are written as JSON strings in the YAML; the docker form notes the `:` case. Test: `test_kubernetes_quotes_paths` |
| SR-16 | Low | a boolean passed as a port number | `policy.py` | **fixed** 25 Oct 2026 | refused. Test: `test_ports_cannot_be_booleans` |
| SR-17 | Low | `policy hash` and `check` on a missing file ended in a traceback | `cli.py` | **fixed** 25 Oct 2026 | exit 2 with the error |
| SR-18 | Low | the bench's local static server resolved `..` in a URL above the folder it served | `experiments/browser/boot_browser.mjs` | **fixed** 25 Oct 2026 | the resolved path must stay inside `out/www`. Test: `test_static_server_refuses_path_traversal` |
| SR-19 | Low | the playground wrote a runtime's output into the page as HTML | `experiments/browser/playground.js` | **fixed** 25 Oct 2026 | text nodes only |
| SR-20 | Low | this site loaded the storyboards kit's script without the integrity string the kit publishes for it | `explain/storyboards/*/index.html` | **fixed** 25 Oct 2026 | `integrity` and `crossorigin` on the tag; the gate checks every third-party script tag carries them |
| SR-21 | Low | the skill told an agent to install from a moving branch | `.claude/skills/sharness-hello/SKILL.md` | **fixed** 25 Oct 2026 | install from a named commit |
| SR-22 | Info | `SHARNESS_AI_POLICY_HASH` in the child's environment is a statement by whoever started it, not something the child can verify | `run.py` | **documented** | said in `experiments/README.md`; a harnessed app may print it, never rely on it |
| SR-23 | Info | what could not be applied (no cgroup, no namespace) was only visible with `--verbose` | `run.py` | **fixed** 25 Oct 2026 | printed on stderr every run |
| SR-24 | Medium | `main` and `dev` are unprotected, one account administers both repositories, and a push to `main` publishes to PyPI | GitHub settings | **open: settings** | the list on [Hardening §3](../hardening/index.md#3-the-repositories-and-the-accounts-settings-only-a-person-can-change). Nothing in a repository can set this |
| SR-26 | Low | **telemetry.** Since v0.5.11 every page runs `assets/telemetry.js`: one encrypted event per page per session to a write-only lane, and a geolocation lookup the reader's browser makes to a third party (`ipapi.co`, then `ipwho.is`), which therefore sees the reader's IP address. The lane's token and key are public, so rows can be forged or flooded. Both are declared on [Telemetry](../../project/telemetry/index.md); Do Not Track and Global Privacy Control switch it off | `assets/telemetry.js` | **accepted by its owner (sherpa), for the editor to confirm (R11)**; noted here 26 Oct 2026 | the register's view: the lookup is the one place a reader's address leaves for a party the project does not run; dropping `region` and `city`, or making the lookup opt-in, would remove it at the cost of the country counts. Recorded, not changed: the row is the sherpa's |
| SR-27 | Info | **the bench's page-side network.** Since the CLI's `dev` of 25 Oct 2026 the guest in the tab is on a network the page provides (`netwatch.js`): the guest speaks plain HTTP to the page, the page upgrades to TLS and applies a hostname allowlist outside the guest. It demonstrates `sharness.net`'s job; it is not the component, and the guest cannot verify the TLS the page speaks on its behalf | `experiments/browser/netwatch.js` | **documented** 26 Oct 2026 | the page and the write-up say so; a reader should not read "the page vetoes" as the design's Boundary |
| SR-25 | Low | the playground loads Pyodide from jsDelivr with no integrity string (Pyodide fetches its own dozens of files, so one string cannot cover it) | `code/playground/` | **accepted, declared** | what a compromised copy could do: show a wrong hash or a wrong result in the Pyodide tab of this one page. What it could not do: reach anything of the reader's beyond that tab, or touch the Linux tab, which is served from this site. The three-way hash exists so one lying runtime is visible. Self-hosting Pyodide at an immutable path is the fix if the risk is ever judged worth 10 MB per release; see [Supply chain §4](../supply-chain/index.md#4-what-this-site-loads-from-elsewhere) |

## Risks the design carries, not the code

These are in the design pages already; they are repeated here so the register is one list.

| # | Risk | Where it is said | State |
|---|---|---|---|
| SD-01 | a policy a process applies to its own children is a Setting for that process: it could skip it. Every measured row today is of this kind | [Policies §2](../../design/policies/index.md#2-who-enforces-decides-the-barrier-kind); every `hello` run | **designed**: the launcher above the agent |
| SD-02 | the hash the person supplies is only a boundary if the person obtained it independently of the agent | [Threat model §3](../threat-model/index.md#3-the-two-trust-boundaries) | **documented**; the mockup and the playground let the person compute it |
| SD-03 | v0 names ports, not hosts: any host on an allowed port | [Policies §10](../../design/policies/index.md#10-a-v0-that-two-implementations-hash-the-same-runnable) | **designed**: `sharness.net` |
| SD-04 | UDP is not seen by Landlock; without the namespace DNS resolves | [Experiments E03](../../code/experiments/index.md) | **designed**: the namespace from the launcher; declared every run |
| SD-05 | `RLIMIT_AS` caps address space, not resident memory; the memory cgroup is the real wall and needs a writable cgroup tree | [Containment §1](../../design/containment/index.md#1-can-every-process-be-constrained-memory-included) | **documented**; declared when the cgroup could not be made |
| SD-06 | an unprivileged user namespace, used to get an empty network namespace without root, gives the child capabilities inside that namespace and widens the kernel surface it can reach | `run.py` | **accepted**: the alternative is no namespace at all; the designed launcher runs as a separate uid and does not need it |
| SD-07 | a per-child cgroup that a lingering grandchild keeps alive is left for the kernel to free | `limits.py` | **accepted**, low |
| SD-08 | `/etc` listed whole as readable includes files only root should read, when the child is root | the example policies | **documented**; narrowing is in the advice |
| SD-09 | the example profiles list `/etc` as executable, which nothing needs | `experiments/policies/` | **accepted for now**: changing a profile changes its pinned hash, quoted on this site; `policy check` advises against it; the next profile revision drops it |
| SD-10 | seccomp, per-component uids, the execute allowlist for the process tree, mutual TLS: the rows that make the containment claim strongest are not built | [Containment §4](../../design/containment/index.md#4-two-caveats-that-matter-more-than-the-kernel) | **designed**; E05 to E08 |
| SD-11 | root and the kernel are trusted; a kernel bug from inside a policy is out of scope; a microVM around the harness is the answer | [Why](../../design/why/index.md#threat-model) | **out of scope, by decision** |
| SD-12 | the chroot form of E09 runs the guest's binaries on the host kernel as root; it is a test bench, and now a pinned one | [Linux in the browser](../../code/browser/index.md) | **accepted**: documented as needing root; the pins are SR-07 |

## Adding to this page

A new finding gets the next `SR-` number and a row the day it is found, in whatever state it is in. A fixed row keeps its number for ever; nothing is deleted. The CLI's `SECURITY.md` points here, and every review on [Reviews](../reviews/index.md) cites rows here.
