Skip to content

Let a jail's scratch live under a denied path, and deny paths by name (contract 2.5) - #158

Merged
M3gA-Mind merged 6 commits into
tinyhumansai:mainfrom
M3gA-Mind:fix/89-seatbelt-scratch-carve
Nov 12, 2026
Merged

M3gA-Mind merged 6 commits into
tinyhumansai:mainfrom
M3gA-Mind:fix/89-seatbelt-scratch-carve

Conversation

@M3gA-Mind

Copy link
Copy Markdown
Collaborator

Summary

Two gaps stopped a host from denying every credential file it knows about on macOS (#89):

  1. The jail's own scratch directory couldn't sit under a denied path. The native shell adds its per-call scratch (TMPDIR) to read_write after admission, and render_profile emitted the deny_read block as the profile's last rule, overriding every write grant. A host that denied the folder holding its state directory (OpenHuman's ~/.openhuman) left every jailed command without a usable TMPDIR. Now each writable grant that sits strictly inside a denied path is re-allowed after the deny block. Admission already refuses a host's writable grant under a denial, so in practice only the scratch is carved out.
  2. Denials were exact paths only. NativeJailConfig.deny_read_names denies a file or directory name wherever it appears (a project's .npmrc). Seatbelt enforces it with a regex rule, placed after the carve-out. Every other backend refuses a request carrying names, with the reason, rather than silently ignoring them.

Related issue

Closes #89. Host side: tinyhumansai/openhuman#7917 / #7824.

API or behavior changes

Contract minor bump: 2.4 → 2.5. A host built on this bus requires a module at 2.5, and a 2.5 host refuses a 2.4 module.

  • NativeJailConfig.deny_read_names: Vec<String> (serde default, omitted when empty), so 2.4 requests decode unchanged. See docs/specs/contract-2-5.md.
  • impl Default for NativeJailConfig: the most restrictive request. Hosts that build with ..NativeJailConfig::default() keep compiling when the contract adds a field. Hosts building it as a full literal (tinymcp transport/stdio/jailed.rs, OpenHuman) need the new field on their next bump.
  • JailBackend::denies_by_name(), default false; Seatbelt returns true. Jail.deny_read_names.
  • Behaviour: on macOS, a writable grant inside a denied path is now writable and readable. Names are refused with UNSUPPORTED_OPERATION on bubblewrap, Landlock and AppContainer, and with INVALID_FILE_PATH when a name isn't one plain path component.

Validation

macOS behaviour was established first with a throwaway probe on our fork (run https://github.com/M3gA-Mind/tinybox/actions/runs/43003256907, macos-latest, macOS 26.7.2). Each case got the expected result:

  • an allow after a deny re-opens a subpath for reads and writes, and the rest stays denied;
  • require-not carves a subpath out of a deny;
  • a regex denial blocks a name at any depth and allows siblings;
  • literal denies an exact file.

On our Linux CI runner, at 6e7d4e2879 (the head differs only by applying cargo fmt's one-hunk patch):

  • cargo clippy --all-targets --all-features -- -D warnings: 0 errors, same as main.
  • cargo build --all-targets --all-features and RUSTDOCFLAGS="-D warnings" cargo doc: ok.
  • cargo test -p tinybox-bus -p tinybox-jail -p tinybox-module --no-fail-fast: 538 passed, 0 failed, with no failures unique to this branch. All nine new tests ran, including the profile-rendering ones, which run on every host.
  • Red controls, each restored afterwards:
    • dropping the carve-out: the carve-out test fails;
    • admitting names on a backend that can't deny by name: the spawn-helper refusal test fails.

The live Seatbelt tests (a_scratch_inside_a_denied_folder_stays_usable_while_the_folder_is_denied, a_denied_name_is_unreadable_wherever_it_appears) run on this PR's macOS job.

Tests

  • Profile: the carve-out comes after the denial and covers only grants inside one; name rules come last, with every regex metacharacter escaped.
  • Live (macOS): the scratch under a denied folder is readable and writable while the folder stays denied; a denied name is unreadable at the top and at depth, and a sibling is visible.
  • Admission: refused with the reason on a backend that can't deny by name; admitted into the jail on one that can; "", ., .. and names with a separator, quote or NUL are refused as invalid paths.
  • tinybox_jail spawn helpers refuse names on the no-op backend.
  • Wire: the field is omitted when empty and round-trips; the Default is the most restrictive.

Documentation

docs/specs/contract-2-5.md, plus rustdoc on every new item.

Checklist

  • The change is focused on one logical change
  • No new #[allow(...)], #[ignore], or relaxed lints
  • No secrets, tokens, or .env contents in the diff or the description

render_profile emitted the deny_read block as the profile's last rule,
so it overrode every write grant. The native shell adds its per-call
scratch (TMPDIR) to read_write after admission, so a host that denied the
folder holding its state directory, such as ~/.openhuman, left every
jailed command without a usable TMPDIR.

After the deny block, re-allow reads and writes for each writable grant
that sits strictly inside a denied path. Seatbelt applies the last
matching rule, which a macOS probe confirmed: an allow after a deny
re-opens the subpath and leaves the rest denied. Admission already
refuses a host's writable grant under a denial, so in practice this
carves out only the scratch.

Refs tinyhumansai#89
deny_read takes exact paths, so a host could not deny a credential file
name wherever it appears, such as a project's .npmrc (tinyhumansai#89).

- NativeJailConfig gains deny_read_names (contract 2.5, empty and omitted
  by default): one path component each, validated at admission and
  counted against MAX_NATIVE_JAIL_GRANTS.
- JailBackend gains denies_by_name (default false). Seatbelt returns true
  and renders each name as a regex deny of /<name> and everything below
  it, as the profile's last rule, after the scratch carve-out. A macOS
  probe confirmed sandbox-exec honours a regex denial at any depth.
- Every other backend refuses such a jail instead of ignoring it, both in
  the module's admission (with the reason) and in the tinybox_jail spawn
  helpers.
- NativeJailConfig gets a most-restrictive Default, so a host building
  requests with ..NativeJailConfig::default() keeps compiling when the
  contract grows a field. Every literal in this workspace gains the new
  field.

Closes tinyhumansai#89
@coderabbitai

coderabbitai Bot commented Nov 11, 2026 •

Copy link
Copy Markdown

Important

  • Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

Run configuration

  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 7146a45a-0311-44a6-9ccc-0a3fefe71260
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

@tinysweeper

tinysweeper Bot commented Nov 11, 2026 •

Copy link
Copy Markdown

Tiny Sweeper review

Reviewed head: 11db5690c21ef482971364373cd32763807ccd41

Verdict: Ready for maintainer review · Priority: none
Coverage: Reviewed 22 of 22 files.
Pull request purpose (title): Let a jail's scratch…

Diagnostics and supporting details

Full summary

Pull request purpose (title): Let a jail's scratch live under a denied path, and deny paths by name (contract 2.5). The change closes #89 by addressing two limits on macOS: the native shell's per-call scratch could not sit under a denied path because the deny block was the profile's last rule, and denials were exact paths only. It ships as a contract minor bump from 2.4 to 2.5: `NativeJailConfig` gains `deny_read_names` plus a most-restrictive `Default`, `JailBackend` gains `denies_by_name`, Seatbelt re-allows writable grants strictly inside a denied path after the deny block and emits escaped regex name denials as the final rule, and every other backend refuses a request carrying names instead of ignoring it. The latest-push delta is unavailable: the review state has the prior reviewed head and rendered evidence, but no trusted base-to-head diff comparison for the current head.

Review snapshot

Change surface Files Review signal Count
Production 8 Active findings 0
Tests 13 Noted findings 0
Documentation 1 Resolved findings 0
Configuration 0 Pending checks/questions 0

Completeness: Complete
Test assessment: Test coverage is assessed from changed tests and lane evidence; execution is not claimed without trusted check data.

What changed

Contract 2.5 adds `NativeJailConfig.deny_read_names: Vec<String>` (serde default, omitted when empty) so 2.4 requests decode unchanged, plus a most-restrictive `Default` for `NativeJailConfig` so hosts can build with `..NativeJailConfig::default()`. Module admission refuses a name denial on a backend without name support with `UNSUPPORTED_OPERATION`, and refuses names that are not one plain path component (`""`, `.`, `..`, over `MAX_FILE_PATH_BYTES`, or containing `/`, `\`, `"` or NUL) with `INVALID_FILE_PATH`; names also count against `MAX_NATIVE_JAIL_GRANTS`. The `tinybox_jail` spawn helpers refuse a jail carrying names on a backend whose `denies_by_name` is false. On macOS, `render_profile` re-allows writable grants strictly inside a denied path after the deny block, and emits name denials as the profile's final rule using escaped regexes. A separate change boxes `ShellHost::Jailed`, judged a mechanical size fix that does not change behaviour. The change is documented in a new contract 2.5 spec.

Features

  • Added — `NativeJailConfig.deny_read_names` contract field and most-restrictive `Default`: Hosts can ask a jail to deny a file or directory name wherever it appears; the field is absent from the wire when empty so existing 2.4 requests round-trip unchanged, and builders using `..NativeJailConfig::default()` keep compiling when future fields are added. (crates/tinybox-bus/src/jail.rs pub struct NativeJailConfig {)
  • Modified — Contract version bump 2.4 → 2.5: A 2.5 host refuses a 2.4 module, so hosts and modules must be upgraded together; 2.4 is no longer reported compatible while 2.5 is. (crates/tinybox-bus/src/lib.rs)
  • Added — `Jail.deny_read_names` and `JailBackend::denies_by_name`: Backends advertise whether they can enforce denials by name; the default is false, so path-based allow-list backends keep their existing behaviour and must refuse names rather than silently drop them. (crates/tinybox-jail/src/jail.rs impl Jail {, crates/tinybox-jail/src/jail.rs pub trait JailBackend: Send + Sync {)
  • Added — Spawn-helper refusal of name denials on non-name backends: A jail carrying name denials cannot be spawned on a backend that would ignore them, avoiding a false sense of protection; the error is `Unsupported` and names the backend. (crates/tinybox-jail/src/lib.rs pub fn spawn_owned_captured_with_env(, crates/tinybox-jail/src/lib.rs fn checked_owned_jail(backend: &dyn JailBackend, jail: &Jail) -> std::io::Result)
  • Added — Module preflight validation of name denials: A request carrying names is refused before canonicalization on a backend without name support, and malformed names are rejected as invalid paths rather than reaching the profile renderer. (crates/tinybox-module/src/tinybus_module/native_jail.rs pub(super) fn prepare()
  • Added — Seatbelt carve-out for writable grants nested inside a denied path: A host that denies the folder holding its state directory no longer leaves jailed commands without a usable `TMPDIR`: grants strictly inside a denial are re-allowed after the deny block, while the rest of the denied folder stays denied. (crates/tinybox-jail/src/macos.rs fn render_profile(jail: &Jail) -> String {)
  • Added — Seatbelt name denials as a final, escaped regex rule: A denied name is unreadable and unwritable at any depth and as a directory, and the rule is emitted last so even a carved-out scratch cannot expose it; regex metacharacters in names are escaped so a name matches literally. (crates/tinybox-jail/src/macos.rs fn render_profile(jail: &Jail) -> String {, crates/tinybox-jail/src/macos.rs fn escape(s: &str) -> String {)
  • Internal refactor — `ShellHost::Jailed` boxed variant: A type-shape change that adds an allocation on the jailed shell path; the description lane judged it a mechanical size fix that does not change behaviour. (crates/tinybox-module/src/tinybus_module/native_shell/types.rs)
  • 1 additional supported feature(s) omitted by the configured limit.

Tests

  • unit — Asserts the new field is omitted when empty, round-trips when set, and that the `Default` is the most restrictive request.: Covers the additive wire contract and the `Default`; the tests lane is answered and reported no reportable defects. (crates/tinybox-bus/src/jail_tests.rs fn native_jail_21_resource_subset_is_additive_and_keeps_disk_explicit())
  • unit — Admission refuses name denials on a backend without name support with `UNSUPPORTED_OPERATION`, admits them into the jail on one with it, and rejects empty, `.`, `..`, separator, quote and NUL names as invalid paths.: Covers the module-side validation and refusal path; the tests lane is answered and reported no reportable defects. (crates/tinybox-module/src/tinybus_module/native_jail_tests.rs impl JailBackend for Backend {)

Findings

No active actionable findings.

Before merge

None.

How this fits together

flowchart LR
  n0["NativeJailConfig<br/>changed"]:::changed
  n1["...ubset_is_additive_and_keeps_disk_explicit<br/>changed"]:::changed
  n2["..._vocabulary_is_typed_bounded_and_additive<br/>changed"]:::changed
  n3["...wire_shape_contains_only_structural_facts<br/>changed"]:::changed
  n4["clone"]:::impacted
  n5["tempdir"]:::impacted
  n1 -->|uses| n0
  n1 -->|calls| n4
  n1 -->|tests| n4
  n3 -->|calls| n4
  n3 -->|tests| n4
  n5 -->|calls| n4
  classDef changed fill:#0d4429,stroke:#238636,color:#e6edf3
  classDef impacted fill:#161b22,stroke:#6e7681,color:#c9d1d9
  classDef flagged fill:#5a1e02,stroke:#d93f0b,color:#ffffff
  classDef blocking fill:#67060c,stroke:#f85149,color:#ffffff
Loading
Agent review details

critique

  • Conclusion: Success
  • Scope reviewed: all assigned evidence
  • Lane summary: The change adds a `denies_by_name` override to the test fixture `Backend`, an empty `deny_read_names` to the `config()` helper, and three new tests covering name-denial refusal, propagation, and component validation. The fixtures and assertions match `prepare` and `check_name_denials` in `native_jail.rs`, and the diff is test-only code that compiles against the shown signatures. No defects found.

security

  • Conclusion: Success
  • Scope reviewed: all assigned evidence
  • Positive: The security lane answered with no findings on the test-only diff; it noted it could not inspect `error.wire_name()` against `INVALID_FILE_PATH`, but saw no evidence of a mismatch.
  • Lane summary: This is a test-only diff: it adds fixture support for the new `deny_read_names` field, a `denies_by_name` override on the mock backend, and three unix tests covering refusal by a backend that cannot deny by name, pass-through on one that can, and rejection of malformed names. No production code or security boundary is touched, and I see nothing that fails or weakens a defence. One thing I could not verify entirely: the third test compares against `error.wire_name()`/`tinybox_bus::INVALID_FILE_PATH`, which I did not inspect, but the values it uses (`"a/b"`, `"."`, `".."`, empty) are the invalid cases the native path validation is documented to refuse, so there is no evidence of a mismatch.

tests

  • Conclusion: Success
  • Scope reviewed: all assigned evidence
  • Lane summary: The new tests exercise the real behaviour of the additions: the wire format of `deny_read_names`, the most-restrictive `Default`, the refusal on backends without `denies_by_name`, the component validation, and the Seatbelt profile rules with live checks. I found nothing reportable in this increment.

commits

  • Conclusion: Success
  • Scope reviewed: all assigned evidence
  • Positive: The commits lane answered and found no sensitive content in the committed files.
  • Lane summary: No sensitive content in committed files.

description

  • Conclusion: Success
  • Scope reviewed: all assigned evidence
  • Positive: The description lane answered and judged the revision to match the diff, covering the fixture backend and the admission tests for name denials, and noted the tests are deterministic fixtures with no live dependency.
  • Lane summary: The new revision is test-only: it adds `denies_by_name` to the fixture backend plus admission tests for name denials (refused on a non-seatbelt backend, reaching the jail on seatbelt, and each of `""`, `.`, `..`, `a/b`, `a\b`, `a"b`, `a\0b` rejected as invalid paths). It compiles against the existing `prepare`/`check_name_denials`/`is_plain_name` code and matches the contract-2.5 spec, and the tests are deterministic fixtures with no live dependency. No defects found.
Evidence and run details
  • Models: ladder/vectors-oai3, deepseek-v4.1-flash
  • Spend: $0.006728
  • Tokens: 405659 input · 13536 output · 87680 cached · 810 embedding
  • Updated: 2026-11-12T08:34:21Z
Head State Pass summary
0970aecf5d02 incomplete 0 active finding(s), 0 resolved finding(s) (at 2026-11-11T12:13:58Z)
018f5de1c041 ready for maintainer review 0 active finding(s), 0 resolved finding(s) (at 2026-11-11T14:17:57Z)
56468f3095fb ready for maintainer review 0 active finding(s), 0 resolved finding(s) (at 2026-11-12T07:30:15Z)
11db5690c21e ready for maintainer review 0 active finding(s), 0 resolved finding(s) (at 2026-11-12T08:34:21Z)

tinysweeper 0.1.2

@tinysweeper tinysweeper Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tinysweeper could not complete coverage (16 unanswered item(s)), so this is not an approval.

Diagnostics

Total spend: $0.0326

@tinysweeper tinysweeper Bot added the priority: p2 Soon. Real but survivable — a rough edge, a gap, a thing that will bite later. label Nov 11, 2026

@tinysweeper tinysweeper Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tinysweeper found nothing blocking. Approving.

Diagnostics

Total spend: $0.0136

@tinysweeper tinysweeper Bot added priority: p3 Whenever. Cosmetic, a nicety, or a cleanup with no user visible effect. and removed priority: p2 Soon. Real but survivable — a rough edge, a gap, a thing that will bite later. labels Nov 11, 2026
# Conflicts:
#	crates/tinybox-module/src/tinybus_module/native_jail_tests.rs
@tinysweeper tinysweeper Bot added priority: p2 Soon. Real but survivable — a rough edge, a gap, a thing that will bite later. and removed priority: p3 Whenever. Cosmetic, a nicety, or a cleanup with no user visible effect. labels Nov 12, 2026
@tinysweeper tinysweeper Bot added priority: p3 Whenever. Cosmetic, a nicety, or a cleanup with no user visible effect. and removed priority: p2 Soon. Real but survivable — a rough edge, a gap, a thing that will bite later. labels Nov 12, 2026
@M3gA-Mind
M3gA-Mind merged commit 20239dd into tinyhumansai:main Nov 12, 2026
23 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

priority: p3 Whenever. Cosmetic, a nicety, or a cleanup with no user visible effect.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Seatbelt deny_read: keep the jail scratch usable under a denied path, and support name/pattern read denials (0.1.36)

1 participant