Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
5a57c13
feat(jail): expose host-configured grant discovery over the bus
senamakel Oct 11, 2026
b214510
test(jail): support case-insensitive filesystems and current Clippy
senamakel Oct 11, 2026
6eb91d5
fix(jail): enforce protected roots and complete grant contract admission
senamakel Oct 11, 2026
5915d5e
fix(jail): preserve protected aliases and reserve procfs grants
senamakel Oct 11, 2026
4adc410
chore: files changed crates/tinybox-jail/src/grants.rs,crates/tinybox…
senamakel Oct 11, 2026
515b073
test(grants): cover recursive ancestor grants against protected prefixes
senamakel Oct 11, 2026
92c92c7
fix(grants): reject protected prefixes that are descendants of a grant
senamakel Oct 11, 2026
53d586f
test(grants): cover protected prefix aliases and cargo source promotion
senamakel Oct 11, 2026
b8f866c
test(grants): cover credential root alias ancestor protection
senamakel Oct 11, 2026
e2f8c79
fix(jail): block writable grants reaching shared cargo sources
senamakel Oct 11, 2026
e45cb6c
docs(tinybus-module): document alias and cache grant rules
senamakel Oct 11, 2026
7abf36b
test(grants): cover credential floor with aliased parent directories
senamakel Oct 11, 2026
7545b72
fix(jail): resolve protected roots through missing ancestors
senamakel Oct 11, 2026
0677075
test(grants): cover writable extras that target protected cargo and t…
senamakel Oct 11, 2026
c3ce0b4
fix(jail): block writable grants reaching fixed host toolchain state
senamakel Oct 11, 2026
3e1c67e
docs(tinybox-module): document toolchain and git config protections
senamakel Oct 11, 2026
abe63a0
test(jail): cover protected paths under directory extras
senamakel Oct 11, 2026
05b9d2e
feat(jail): inspect recursive grants for protected entries
senamakel Oct 11, 2026
9a7c6e9
test(grants): drop system path assertions from toolchain homes test
senamakel Oct 11, 2026
320200d
test(grants): cover symlink bypass of subtree pair protection
senamakel Oct 11, 2026
08aa55f
fix(jail): reject pair-prefix symlinks in protected path scan
senamakel Oct 11, 2026
67bb209
test(jail): cover git include comments and quoted markers
senamakel Oct 11, 2026
a79534f
refactor(grants): split grant resolution into focused submodules
senamakel Oct 11, 2026
c910127
chore: files changed crates/tinybox-jail/src/grants/floor.rs,crates/t…
senamakel Oct 11, 2026
bdc3f44
test(grants): reformat malformed include value cases
senamakel Oct 11, 2026
d8d23be
test(grants): use raw string with double hashes in git config test
senamakel Oct 11, 2026
8be2a7e
refactor(tinybox-jail): collapse nested if in git config include parsing
senamakel Oct 11, 2026
9a8e296
test(grants): cover symlinked git configs under writable parents
senamakel Oct 11, 2026
e5acb1a
fix(git_config): protect each requested gitconfig link location
senamakel Oct 11, 2026
af64d94
fix(jail): treat empty xdg_config_home as unset for git config
senamakel Oct 11, 2026
e90167c
chore: files changed crates/tinybox-core/src/error/mod.rs,crates/tiny…
senamakel Oct 11, 2026
bcd846c
docs: document local grant discovery contract
senamakel Oct 11, 2026
d5dc940
test(grants): cover git include continuation and missing include paths
senamakel Oct 11, 2026
104b067
fix(grants): handle git config line continuations and absolute xdg paths
senamakel Oct 11, 2026
fce19b8
test(grants): cover gitconfig candidates for empty xdg config home
senamakel Oct 11, 2026
4313be1
test(git_config): cover includeIf sections with tab separators
senamakel Oct 11, 2026
02b10d3
fix(jail): handle tab separators in git config section headers
senamakel Oct 11, 2026
afc2af6
test(grants): extract discovery assertions into a helper
senamakel Oct 11, 2026
3d55348
Merge remote-tracking branch 'origin/main' into merge-pr-51
senamakel Oct 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

61 changes: 61 additions & 0 deletions crates/tinybox-bus/src/grants.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
//! Host-configured filesystem grant discovery and protected-path vocabulary.
use serde::{Deserialize, Serialize};

/// Configuration used to discover toolchain and explicit filesystem grants.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct LocalGrantConfig {
Comment thread
senamakel marked this conversation as resolved.
/// Include existing toolchain homes and git configuration.
pub toolchain_homes: bool,
/// Additional read-only paths, accepting a leading tilde.
pub extra_read_only: Vec<String>,
/// Additional writable paths, accepting a leading tilde.
pub extra_read_write: Vec<String>,
/// Explicitly permit read access to `/proc`.
pub allow_proc: bool,
}
impl Default for LocalGrantConfig {
fn default() -> Self {
Self {
toolchain_homes: true,
extra_read_only: Vec::new(),
extra_read_write: Vec::new(),
allow_proc: false,
}
}
}

/// Host-owned protected-path floor; matching ignores case and separator style.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct ProtectedPaths {
/// Forbidden path components at any depth (including credential filenames).
pub components: Vec<String>,
/// Forbidden consecutive path-component pairs.
pub component_pairs: Vec<[String; 2]>,
/// Forbidden absolute roots and their descendants. Trailing separators and
/// Windows extended-path namespaces are normalized before matching.
pub prefixes: Vec<String>,
/// Credential roots whose ancestors must also never be granted.
pub credential_roots: Vec<String>,
}

/// Request to resolve existing local grant paths, without launching a command.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResolveLocalGrantsRequest {
/// Operator home; absent for hosts with no operator-home access.
pub home: Option<String>,
/// Toolchain and additional-grant configuration.
pub config: LocalGrantConfig,
/// Host's unconditional protected-path floor.
pub protected: ProtectedPaths,
}

/// Canonical existing filesystem grants beyond a workload's writable root.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct LocalGrants {
/// Read and executable paths.
pub read_only: Vec<String>,
/// Read, executable, and writable paths.
pub read_write: Vec<String>,
}
9 changes: 7 additions & 2 deletions crates/tinybox-bus/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;

mod grants;
pub use grants::{LocalGrantConfig, LocalGrants, ProtectedPaths, ResolveLocalGrantsRequest};

mod jail;
pub use jail::{JailEnforcement, NativeJailConfig};

Expand All @@ -38,8 +41,9 @@ pub use files::{
/// 1.7 adds explicitly approved native directory-jail execution. 1.8 adds a
/// bounded, module-owned one-shot Docker operation. 1.9 adds bounded Docker
/// daemon status and label-scoped cleanup operations. 1.10 adds a bounded
/// Docker command that does not mount a host workspace.
pub const CONTRACT_VERSION: (u32, u32) = (1, 10);
/// Docker command that does not mount a host workspace. 1.11 adds local grant
/// discovery.
pub const CONTRACT_VERSION: (u32, u32) = (1, 11);

/// Whether this host vocabulary can bind to a module's advertised version.
/// The major must match and the module's minor must include every host member.
Expand Down Expand Up @@ -112,6 +116,7 @@ pub const METHODS: &[&str] = &[
"DockerStatus",
"CleanupDocker",
"RunDockerWithoutWorkspace",
"ResolveLocalGrants",
];

/// Facts about the detected native directory-jail backend.
Expand Down
46 changes: 38 additions & 8 deletions crates/tinybox-bus/src/lib_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,14 @@ fn docker_run_vocabulary_is_typed_bounded_and_additive() -> Result<(), serde_jso
timeout_ms: 120_000,
};
assert!(METHODS.contains(&"RunDocker"));
assert_eq!(METHODS.get(METHODS.len() - 2), Some(&"CleanupDocker"));
assert_eq!(METHODS.last(), Some(&"RunDockerWithoutWorkspace"));
assert_eq!(CONTRACT_VERSION, (1, 10));
assert_eq!(METHODS.get(METHODS.len() - 3), Some(&"CleanupDocker"));
assert_eq!(
METHODS.get(METHODS.len() - 2),
Some(&"RunDockerWithoutWorkspace")
);
assert_eq!(METHODS.last(), Some(&"ResolveLocalGrants"));
assert!(METHODS.contains(&"RunDockerWithoutWorkspace"));
assert_eq!(CONTRACT_VERSION, (1, 11));
assert_eq!(MAX_DOCKER_RUN_TIMEOUT_MS, 86_400_000);
let serialized = serde_json::to_value(&request)?;
assert_eq!(serialized["read_only_rootfs"], false);
Expand Down Expand Up @@ -127,7 +132,7 @@ fn host_gateway_and_docker_facts_are_additive_and_default_safely() -> Result<(),
#[test]
fn version_and_capability_wire_snapshot_preserve_contract_compatibility()
-> Result<(), serde_json::Error> {
assert_eq!(CONTRACT_VERSION, (1, 10));
assert_eq!(CONTRACT_VERSION, (1, 11));
assert!(is_compatible(CONTRACT_VERSION));
assert!(!is_compatible((1, 8)));
assert!(!is_compatible((1, 7)));
Expand All @@ -145,7 +150,7 @@ fn version_and_capability_wire_snapshot_preserve_contract_compatibility()
exec_backends: Vec::new(),
spawn_backends: Vec::new(),
};
let snapshot = serde_json::json!({"contract_version":[1,10],"create_backends":["passthrough"],"exec_backends":[],"spawn_backends":[]});
let snapshot = serde_json::json!({"contract_version":[1,11],"create_backends":["passthrough"],"exec_backends":[],"spawn_backends":[]});
assert_eq!(serde_json::to_value(&capabilities)?, snapshot);
assert_eq!(
serde_json::from_value::<ModuleCapabilities>(snapshot)?,
Expand All @@ -155,16 +160,19 @@ fn version_and_capability_wire_snapshot_preserve_contract_compatibility()
}

#[test]
fn no_workspace_docker_operation_is_appended_to_the_contract() {
assert_eq!(METHODS.last(), Some(&"RunDockerWithoutWorkspace"));
fn no_workspace_docker_operation_is_present_in_the_contract() {
assert_eq!(
METHODS.get(METHODS.len() - 2),
Some(&"RunDockerWithoutWorkspace")
);
assert_eq!(
METHODS
.iter()
.filter(|method| **method == "RunDockerWithoutWorkspace")
.count(),
1
);
assert_eq!(CONTRACT_VERSION, (1, 10));
assert_eq!(CONTRACT_VERSION, (1, 11));
}

#[test]
Expand Down Expand Up @@ -248,3 +256,25 @@ fn workspace_file_transfers_are_chunked_typed_and_bounded() -> Result<(), serde_
);
Ok(())
}

#[test]
fn local_grant_configuration_defaults_and_floor_round_trip() -> Result<(), serde_json::Error> {
let config: LocalGrantConfig = serde_json::from_str("{}")?;
assert!(config.toolchain_homes);
assert!(!config.allow_proc);
let request = ResolveLocalGrantsRequest {
home: Some("/operator".into()),
config,
protected: ProtectedPaths {
components: vec![".ssh".into()],
component_pairs: vec![[".cargo".into(), "credentials.toml".into()]],
prefixes: vec!["/etc".into()],
credential_roots: vec!["/operator/.ssh".into()],
},
};
assert_eq!(
serde_json::from_value::<ResolveLocalGrantsRequest>(serde_json::to_value(&request)?)?,
request
);
Ok(())
}
4 changes: 4 additions & 0 deletions crates/tinybox-core/src/error/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ use crate::capability::Capability;
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
#[non_exhaustive]
pub enum Error {
/// Local grant discovery cannot resolve a Git user-home or installation-prefix include.
#[error("unsupported git include pathname expansion")]
UnsupportedGitIncludePath,

/// An identifier was empty, over-long, or contained characters outside the
/// permitted `[A-Za-z0-9._-]` set.
#[error("identifier {value:?} is not a valid {kind}")]
Expand Down
2 changes: 2 additions & 0 deletions crates/tinybox-jail/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ landlock = ["dep:landlock"]
log = "0.4"
# Reuse the workspace clock so registry timestamps can be controlled in tests.
tinybox-core.workspace = true
# Serialized host-owned grant configuration and protected-path floor.
tinybox-bus.workspace = true
serde.workspace = true
serde_json.workspace = true

Expand Down
32 changes: 32 additions & 0 deletions crates/tinybox-jail/src/grants/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Local grant discovery

resolve_local_jail_grants returns canonical existing JailGrants through the
workspace Result type. ResolveLocalGrants exposes the same behavior through
the module, with UTF-8 paths and an input/final 64-grant limit.

mod.rs orchestrates discovery and read-only precedence; types.rs holds results.
floor.rs matches components, adjacent pairs, protected prefixes and credential
roots, preserving requested and canonical aliases and ancestor boundaries.
inspection.rs scans directory descendants without following links. Errors,
over 1,000,000 entries or over 128 levels refuse a grant. Pair-prefix symlinks
are refused conservatively. Discovery is a snapshot, not a kernel filename
deny rule for entries created later; hosts must control tree mutation.

git_config.rs reads admitted Git configs only. Relative includes retain their
logical parent, and visited identity combines canonical file and resolved parent.
Each link location remains read-only even when targets repeat. Includes stop at
depth eight. Quotes, supported Git escapes and unquoted comments are parsed.
Empty XDG_CONFIG_HOME is treated as unset. User-name and installation-prefix
include expansion cannot be resolved portably from the supplied contract:
~user/ and %(prefix)/ therefore reject the whole request explicitly, without
returning a partial grant set.

Automatic toolchains, Cargo executable/configuration state, shared dependency
caches and Git configuration are fixed read-only. Writable extras cannot
promote them or their ancestors/descendants. Manual grants retain writable
precedence when they do not reach fixed state. Only allow_proc grants /proc,
read-only. Mutable tools need separately approved disposable state.

Hosts supply the complete protected floor and keep authorization. This module
does not start commands or weaken unavailable confinement. See
[the contract specification](../../../../docs/specs/local-grants.md).
107 changes: 107 additions & 0 deletions crates/tinybox-jail/src/grants/floor.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
//! Protected root spellings and component-boundary matching.
use super::Builder;
use std::path::Path;

impl Builder<'_> {
pub(super) fn refuses_path(&self, path: &Path) -> bool {
let normalized = normalized_floor_path(&path.to_string_lossy());
// Procfs is reserved for the dedicated read-only toggle, including
// ancestors whose recursive grant would expose it.
let exposes_proc = Path::new("/proc").exists()
&& (same_or_descendant(&normalized, "/proc")
|| same_or_descendant("/proc", &normalized));
self.is_protected(path) || self.exposes_credentials(path) || exposes_proc
}

/// True when `path` is, contains, or sits inside a credential directory.
pub(super) fn exposes_credentials(&self, path: &Path) -> bool {
let path = normalized_floor_path(&path.to_string_lossy());
self.protected.credential_roots.iter().any(|root| {
root_spellings(Path::new(root))
.any(|cred| same_or_descendant(&path, &cred) || same_or_descendant(&cred, &path))
})
}

pub(super) fn has_protected_components(&self, path: &Path) -> bool {
let normalized = normalized_floor_path(&path.to_string_lossy());
let components: Vec<_> = normalized
.split('/')
.filter(|part| !part.is_empty())
.collect();
components.iter().any(|part| {
self.protected
.components
.iter()
.any(|blocked| part.eq_ignore_ascii_case(blocked))
}) || components.windows(2).any(|pair| {
self.protected.component_pairs.iter().any(|blocked| {
pair[0].eq_ignore_ascii_case(&blocked[0])
&& pair[1].eq_ignore_ascii_case(&blocked[1])
})
})
}

pub(super) fn is_protected(&self, path: &Path) -> bool {
let normalized = normalized_floor_path(&path.to_string_lossy());
self.has_protected_components(path)
|| self.protected.prefixes.iter().any(|prefix| {
root_spellings(Path::new(prefix)).any(|prefix| {
same_or_descendant(&normalized, &prefix)
|| same_or_descendant(&prefix, &normalized)
})
})
}
}

// A configured protected root may itself be a symlink (for example /etc on
// macOS). Preserve its requested spelling and its existing resolved target.
pub(super) fn root_spellings(root: &Path) -> impl Iterator<Item = String> {
let location = root.parent().and_then(|parent| {
let mut ancestor = parent;
let mut missing = Vec::new();
let mut canonical = loop {
if let Ok(canonical) = ancestor.canonicalize() {
break canonical;
}
missing.push(ancestor.file_name()?);
ancestor = ancestor.parent()?;
};
for component in missing.into_iter().rev() {
canonical.push(component);
}
canonical.push(root.file_name()?);
Some(normalized_floor_path(&canonical.to_string_lossy()))
});
std::iter::once(normalized_floor_path(&root.to_string_lossy()))
.chain(location)
.chain(
root.canonicalize()
.ok()
.map(|canonical| normalized_floor_path(&canonical.to_string_lossy())),
)
}

// Windows canonical paths may carry an extended drive or UNC namespace;
// compare the underlying filesystem path, regardless of spelling or case.
pub(super) fn normalized_floor_path(path: &str) -> String {
let normalized = path.to_ascii_lowercase().replace('\\', "/");
if let Some(rest) = normalized.strip_prefix("//?/unc/") {
format!("//{rest}")
} else {
normalized
.strip_prefix("//?/")
.unwrap_or(&normalized)
.to_owned()
}
}

pub(super) fn same_or_descendant(candidate: &str, parent: &str) -> bool {
let root = parent.trim_end_matches('/');
if root.is_empty() {
return parent.starts_with('/') && candidate.starts_with('/');
}
candidate == root
|| candidate
.strip_prefix(root)
.is_some_and(|rest| rest.starts_with('/'))
}
Loading
Loading