Skip to content

feat(reborn): add filesystem substrate - #2996

Merged
serrrfirat merged 4 commits into
reborn-integrationfrom
reborn-land-02b-filesystem-substrate
Apr 28, 2026
Merged

serrrfirat merged 4 commits into
reborn-integrationfrom
reborn-land-02b-filesystem-substrate

Conversation

@serrrfirat

@serrrfirat serrrfirat commented Apr 27, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Closes the filesystem half of PR2 from #2987. The event substrate half is already in review as #2993; this PR keeps the filesystem substrate separate so reviewers can evaluate it independently.

Adds crates/ironclaw_filesystem with:

  • RootFilesystem, ScopedFilesystem, and CompositeRootFilesystem
  • FilesystemCatalog, MountDescriptor, PathPlacement, and backend placement metadata
  • V1 filesystem operations: read_file, write_file, append_file, list_dir, stat, delete, create_dir_all
  • local filesystem backend with containment/symlink escape checks
  • PostgreSQL and libSQL DB-backed root filesystem backends behind feature flags
  • explicit directory representation for DB-backed filesystem entries
  • crate-local guardrails in crates/ironclaw_filesystem/CLAUDE.md

Migrations are included as V26/V27 because reborn-integration already has V25__wasm_fuel_limit_bump.sql:

  • migrations/V26__root_filesystem_entries.sql
  • migrations/V27__root_filesystem_entries_directories.sql

Scope boundaries

This PR intentionally does not include:

Exposure checklist

Does this affect existing src/ runtime behavior? no
Does this alter app startup/config defaults? no
Does this expose new routes/CLI/user-visible APIs? no
Does this create a second writer for existing production state? no
Are all Reborn paths feature-gated or unused by default? yes, unused internal crate substrate
Are docs/status labels accurate: implemented slice vs product complete? yes, filesystem substrate only

Verification

TDD note: copied the filesystem contract tests first against a RED stub and confirmed cargo test -p ironclaw_filesystem failed due to missing contract types before porting the implementation.

Passed:

CARGO_TARGET_DIR=/tmp/ironclaw-fs-target cargo test -p ironclaw_host_api -p ironclaw_filesystem
CARGO_TARGET_DIR=/tmp/ironclaw-fs-target cargo test -p ironclaw_filesystem --features libsql
CARGO_TARGET_DIR=/tmp/ironclaw-fs-target cargo test -p ironclaw_filesystem --features postgres
CARGO_TARGET_DIR=/tmp/ironclaw-fs-target cargo clippy -p ironclaw_host_api -p ironclaw_filesystem --all-targets --features libsql,postgres -- -D warnings
cargo fmt --check
git diff --check

Earlier boundary grep returned no forbidden normal Reborn dependencies:

cargo tree -p ironclaw_filesystem -e normal --features libsql,postgres | grep -E 'ironclaw_(authorization|approvals|capabilities|dispatcher|events|extensions|host_runtime|mcp|network|processes|resources|run_state|scripts|secrets|wasm)' || true

Refs #2987.

@github-actions github-actions Bot added scope: db/postgres PostgreSQL backend scope: docs Documentation scope: dependencies Dependency updates DB MIGRATION PR adds or modifies PostgreSQL or libSQL migration definitions labels Apr 27, 2026
@github-actions github-actions Bot added size: XL 500+ changed lines risk: medium Business logic, config, or moderate-risk modules contributor: core 20+ merged PRs labels Apr 27, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request introduces the ironclaw_filesystem crate, which provides a scoped filesystem service supporting local, PostgreSQL, and libSQL backends. It implements a composite root for managing multiple virtual mounts and a scoped view for enforcing granular permissions. Feedback focuses on critical performance and architectural improvements: replacing synchronous I/O with asynchronous operations in the local backend to avoid blocking the Tokio executor, optimizing database backends to use targeted SQL queries instead of in-memory filtering, and refining error handling by replacing string-based 'not found' checks with a dedicated error variant.

Comment on lines +680 to +785
impl RootFilesystem for LocalFilesystem {
async fn read_file(&self, path: &VirtualPath) -> Result<Vec<u8>, FilesystemError> {
let resolved = self.resolve_existing(path, FilesystemOperation::ReadFile)?;
std::fs::read(resolved).map_err(|error| FilesystemError::Backend {
path: path.clone(),
operation: FilesystemOperation::ReadFile,
reason: io_reason(error),
})
}

async fn write_file(&self, path: &VirtualPath, bytes: &[u8]) -> Result<(), FilesystemError> {
let resolved = self.resolve_for_write(path, FilesystemOperation::WriteFile)?;
std::fs::write(resolved, bytes).map_err(|error| FilesystemError::Backend {
path: path.clone(),
operation: FilesystemOperation::WriteFile,
reason: io_reason(error),
})
}

async fn append_file(&self, path: &VirtualPath, bytes: &[u8]) -> Result<(), FilesystemError> {
let resolved = self.resolve_for_write(path, FilesystemOperation::AppendFile)?;
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(resolved)
.map_err(|error| FilesystemError::Backend {
path: path.clone(),
operation: FilesystemOperation::AppendFile,
reason: io_reason(error),
})?;
file.write_all(bytes)
.map_err(|error| FilesystemError::Backend {
path: path.clone(),
operation: FilesystemOperation::AppendFile,
reason: io_reason(error),
})
}

async fn list_dir(&self, path: &VirtualPath) -> Result<Vec<DirEntry>, FilesystemError> {
let resolved = self.resolve_existing(path, FilesystemOperation::ListDir)?;
let mut entries = Vec::new();
for entry in std::fs::read_dir(resolved).map_err(|error| FilesystemError::Backend {
path: path.clone(),
operation: FilesystemOperation::ListDir,
reason: io_reason(error),
})? {
let entry = entry.map_err(|error| FilesystemError::Backend {
path: path.clone(),
operation: FilesystemOperation::ListDir,
reason: io_reason(error),
})?;
let name = entry.file_name().to_string_lossy().to_string();
let entry_path =
VirtualPath::new(format!("{}/{}", path.as_str().trim_end_matches('/'), name))?;
let metadata = entry.metadata().map_err(|error| FilesystemError::Backend {
path: entry_path.clone(),
operation: FilesystemOperation::Stat,
reason: io_reason(error),
})?;
entries.push(DirEntry {
name,
path: entry_path,
file_type: file_type_from_metadata(&metadata),
});
}
entries.sort_by(|left, right| left.name.cmp(&right.name));
Ok(entries)
}

async fn stat(&self, path: &VirtualPath) -> Result<FileStat, FilesystemError> {
let resolved = self.resolve_existing(path, FilesystemOperation::Stat)?;
let metadata = std::fs::metadata(resolved).map_err(|error| FilesystemError::Backend {
path: path.clone(),
operation: FilesystemOperation::Stat,
reason: io_reason(error),
})?;
Ok(FileStat {
path: path.clone(),
file_type: file_type_from_metadata(&metadata),
len: metadata.len(),
})
}

async fn delete(&self, path: &VirtualPath) -> Result<(), FilesystemError> {
let resolved = self.resolve_existing(path, FilesystemOperation::Delete)?;
let metadata = std::fs::metadata(&resolved).map_err(|error| FilesystemError::Backend {
path: path.clone(),
operation: FilesystemOperation::Delete,
reason: io_reason(error),
})?;
if metadata.is_dir() {
std::fs::remove_dir_all(resolved)
} else {
std::fs::remove_file(resolved)
}
.map_err(|error| FilesystemError::Backend {
path: path.clone(),
operation: FilesystemOperation::Delete,
reason: io_reason(error),
})
}

async fn create_dir_all(&self, path: &VirtualPath) -> Result<(), FilesystemError> {
self.resolve_for_create_dir_all(path).map(|_| ())
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

The LocalFilesystem implementation uses synchronous std::fs operations (read, write, canonicalize, etc.) within an async_trait. In a high-concurrency service environment, these blocking calls can lead to thread starvation in the Tokio runtime. Consider using tokio::fs for all filesystem operations to ensure the executor remains responsive.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in f4a83cd. LocalFilesystem async RootFilesystem methods now use tokio::fs / tokio::io for canonicalize/read/write/append/list/stat/delete/create-dir paths, so the async backend no longer performs synchronous file I/O in the executor.

Comment thread crates/ironclaw_filesystem/src/lib.rs Outdated
Comment on lines +986 to +996
let rows = self.all_paths().await?;
if rows
.iter()
.any(|(child_path, _, _)| virtual_prefix_matches(path.as_str(), child_path.as_str()))
{
return Ok(FileStat {
path: path.clone(),
file_type: FileType::Directory,
len: 0,
});
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

The stat implementation for PostgresRootFilesystem fetches all paths from the database using all_paths() when an exact entry is not found, just to check if the path is a directory prefix. This is extremely inefficient for large filesystems. Use a targeted SQL query with LIKE and LIMIT 1 instead to prevent performance bottlenecks.

Suggested change
let rows = self.all_paths().await?;
if rows
.iter()
.any(|(child_path, _, _)| virtual_prefix_matches(path.as_str(), child_path.as_str()))
{
return Ok(FileStat {
path: path.clone(),
file_type: FileType::Directory,
len: 0,
});
}
let client = self.client().await?;
let prefix = format!("{}/%", path.as_str());
let row = client
.query_opt(
"SELECT 1 FROM root_filesystem_entries WHERE path LIKE $1 LIMIT 1",
&[&prefix],
)
.await
.map_err(|error| db_error(path.clone(), FilesystemOperation::Stat, error))?;
if row.is_some() {
return Ok(FileStat {
path: path.clone(),
file_type: FileType::Directory,
len: 0,
});
}
References
  1. Use targeted database queries to fetch specific records instead of loading all records and filtering in the application, especially for public endpoints, to prevent performance bottlenecks and DoS vulnerabilities.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in f4a83cd. PostgresRootFilesystem::stat now uses a targeted SELECT 1 ... WHERE path LIKE $1 ESCAPE '!' LIMIT 1 child-existence query instead of loading every path.

Comment thread crates/ironclaw_filesystem/src/lib.rs Outdated
reason: "not a directory".to_string(),
});
}
let rows = self.all_paths().await?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

The list_dir implementation for LibSqlRootFilesystem (and PostgresRootFilesystem) fetches all paths from the database to filter them in memory. This will not scale as the number of files grows. Use a targeted SQL query with a prefix match (LIKE 'path/%') to fetch only the relevant entries and avoid performance bottlenecks.

References
  1. Use targeted database queries to fetch specific records instead of loading all records and filtering in the application, especially for public endpoints, to prevent performance bottlenecks and DoS vulnerabilities.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in f4a83cd. PostgreSQL and libSQL list_dir now use targeted escaped-prefix LIKE queries for child rows instead of fetching all paths and filtering in memory.

Comment on lines +53 to +75
pub enum FilesystemError {
#[error(transparent)]
Contract(#[from] HostApiError),
#[error("permission denied for {operation} on scoped path {path:?}")]
PermissionDenied {
path: ScopedPath,
operation: FilesystemOperation,
},
#[error("no backend mount found for virtual path {path:?}")]
MountNotFound { path: VirtualPath },
#[error("virtual path escaped backend mount {path:?}")]
PathOutsideMount { path: VirtualPath },
#[error("symlink escapes backend mount at virtual path {path:?}")]
SymlinkEscape { path: VirtualPath },
#[error("backend mount conflict at virtual path {path:?}")]
MountConflict { path: VirtualPath },
#[error("filesystem backend error during {operation} at {path:?}: {reason}")]
Backend {
path: VirtualPath,
operation: FilesystemOperation,
reason: String,
},
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The FilesystemError enum should include a dedicated NotFound variant. Currently, the code relies on string matching (e.g., reason == "not found" at line 1232) to detect missing files, which is fragile and inconsistent across backends. Creating specific error variants for different failure modes provides semantically correct and clear error messages and avoids the use of generic string matching patterns.

References
  1. Create specific error variants for different failure modes (e.g., DownloadFailed with a URL string vs. ManifestRead with a file path) to provide semantically correct and clear error messages.
  2. When classifying errors by matching substrings, avoid overly generic patterns like "request failed" that can cause false positives. Prefer more specific markers like HTTP status codes or explicit error messages.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in f4a83cd. Added FilesystemError::NotFound and removed the stringly reason == "not found" classification path; DB not-found helpers and local ErrorKind::NotFound now use the typed variant.

Comment on lines +1219 to +1236
if matches!(
self.exact_entry(path).await?,
Some((_, FileType::Directory))
) {
return Err(FilesystemError::Backend {
path: path.clone(),
operation: FilesystemOperation::AppendFile,
reason: "cannot append to a directory".to_string(),
});
}
let mut contents = match self.read_file(path).await {
Ok(contents) => contents,
Err(FilesystemError::Backend { reason, .. }) if reason == "not found" => Vec::new(),
Err(error) => return Err(error),
};
contents.extend_from_slice(bytes);
self.write_file(path, &contents).await
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The LibSqlRootFilesystem::append_file implementation reads the entire file into memory, appends the new bytes, and writes it back. This is inefficient and can lead to high memory usage for large files. Moving this logic to the database layer using the SQL concatenation operator (||) will improve performance and simplify the application code.

    async fn append_file(&self, path: &VirtualPath, bytes: &[u8]) -> Result<(), FilesystemError> {
        if matches!(
            self.exact_entry(path).await?,
            Some((_, FileType::Directory))
        ) {
            return Err(FilesystemError::Backend {
                path: path.clone(),
                operation: FilesystemOperation::AppendFile,
                reason: "cannot append to a directory".to_string(),
            });
        }
        let conn = self.connect().await?;
        conn.execute(
            r#"
            INSERT INTO root_filesystem_entries (path, contents, is_dir, updated_at)
            VALUES (?1, ?2, 0, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
            ON CONFLICT (path) DO UPDATE SET
                contents = contents || excluded.contents,
                is_dir = 0,
                updated_at = excluded.updated_at
            "#,
            libsql::params![path.as_str(), libsql::Value::Blob(bytes.to_vec())],
        )
        .await
        .map_err(|error| libsql_db_error(path.clone(), FilesystemOperation::AppendFile, error))?;
        Ok(())
    }
References
  1. When application logic becomes complex or inefficient, consider moving it to the database layer (e.g., a dedicated SQL query) to improve performance and simplify application code.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in f4a83cd. LibSqlRootFilesystem::append_file now performs append in SQL with INSERT ... ON CONFLICT DO UPDATE; the concat result is cast back to BLOB to avoid libSQL returning an invalid value type when reading appended contents.

@henrypark133

Copy link
Copy Markdown
Collaborator

Filesystem substrate mental model + clarifications

My understanding is that this PR implements the filesystem substrate primitives, not the full deployment composition yet. The core model I'm reading is:

flowchart TD
  Tool["Runtime / tool caller"] -->|ScopedPath\n/workspace/README.md| Scoped["ScopedFilesystem"]
  Scoped --> MountView["MountView\naliases + permissions"]
  MountView -->|resolves to VirtualPath\n/projects/.../README.md| Root["RootFilesystem"]
  Host["Trusted host service"] -->|VirtualPath\n/engine/...| Root

  Root --> Composite["CompositeRootFilesystem\nlongest-prefix router"]
  Composite -->|/projects/*| Local["Local directory backend"]
  Composite -->|/engine/* or /memory/*| DB["Postgres / libSQL backend"]
  Composite -. follow-up .-> Object["Object store backend"]
  Composite -. follow-up .-> Memory["In-memory test backend"]

  Catalog["FilesystemCatalog\ntrusted diagnostics only"] -. describes .-> Composite
Loading

So routing is automatic from the caller's perspective, but split into two stages:

Tool/runtime path:
  /workspace/README.md

ScopedFilesystem + MountView:
  /workspace -> /projects/tenants/t1/users/u1/project-a

CompositeRootFilesystem:
  /projects/tenants/t1/users/u1/project-a/README.md
  -> backend mounted at /projects

That separation looks right to me:

  • ScopedFilesystem owns caller-visible aliases and permission checks.
  • RootFilesystem is the trusted canonical virtual-path API.
  • CompositeRootFilesystem is a backend router, not a separate caller-facing access surface.
  • FilesystemCatalog describes placement/capabilities but should not grant runtime authority.

A few clarifications / follow-up notes:

  1. Deployment composition: I assume a follow-up will add something like DeploymentProfile -> FilesystemComposition, where local dev, hosted, and tests choose different backends per virtual prefix.

  2. Tenant/user scoping: For hosted mode, should scoping be enforced by the composition builder, typed path constructors, or a hosted-specific root wrapper? Example target:

    /workspace -> /projects/tenants/<tenant>/users/<user>/<project>
    /memory    -> /memory/tenants/<tenant>/users/<user>
    
  3. Artifacts/tmp roots: Should /artifacts and/or /tmp become first-class virtual roots, or should they intentionally live under /engine/...? My instinct is /artifacts may deserve first-class status if artifacts are a broad product concept, while /tmp can stay under /engine/tmp if it is purely runtime scratch.

  4. Catalog visibility: I'd like to confirm FilesystemCatalog is trusted-host-only, so tools do not receive backend IDs/kinds or placement metadata directly.

  5. Deferred backends: Object-store and in-memory backends seem represented in the model but not implemented in this PR. That seems fine for a substrate PR, but worth tracking as follow-up.

Overall, this looks aligned with the mental model: the PR gives us the scoped view, trusted root interface, composite router, and concrete local/DB backends. The remaining work is mostly deployment-policy and composition wiring rather than the core filesystem abstraction.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Adds the Reborn filesystem substrate as a new internal crate, including root/scoped filesystem abstractions, backend routing/catalog metadata, local + DB-backed implementations (feature-gated), and initial schema migrations.

Changes:

  • Introduces crates/ironclaw_filesystem with RootFilesystem, ScopedFilesystem, CompositeRootFilesystem, and mount/catalog metadata types.
  • Adds local filesystem backend with containment/symlink-escape checks plus libSQL/Postgres DB-backed backends behind feature flags.
  • Adds DB schema migrations (V26/V27) and contract tests for scoped/composite/catalog/root filesystem behavior.

Reviewed changes

Copilot reviewed 9 out of 10 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
migrations/V26__root_filesystem_entries.sql Creates root_filesystem_entries table and index for DB-backed root filesystem storage.
migrations/V27__root_filesystem_entries_directories.sql Adds explicit directory support (is_dir) and default contents for DB-backed entries.
crates/ironclaw_filesystem/src/lib.rs Implements filesystem traits/types, local backend, composite routing, and DB backends (feature-gated).
crates/ironclaw_filesystem/tests/filesystem_contract.rs Contract tests for scoped permissions, mount resolution, local backend behavior, and symlink escape denial.
crates/ironclaw_filesystem/tests/catalog_contract.rs Contract tests for catalog placement metadata and composite routing behavior.
crates/ironclaw_filesystem/tests/db_root_filesystem_contract.rs Contract tests for libSQL DB backend behavior (and postgres trait conformance).
crates/ironclaw_filesystem/Cargo.toml Adds new crate manifest with postgres/libsql feature flags and deps.
crates/ironclaw_filesystem/CLAUDE.md Adds crate-local guardrails for boundaries and invariants.
Cargo.toml Wires ironclaw_filesystem into the workspace members.
Cargo.lock Adds lockfile entries for the new crate and its dependencies.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

);

CREATE INDEX IF NOT EXISTS idx_root_filesystem_entries_path
ON root_filesystem_entries(path);

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

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

path is the PRIMARY KEY, which already creates a unique btree index in Postgres. The additional idx_root_filesystem_entries_path index is redundant for equality lookups and also won’t help the LIKE '/prefix/%' queries used by the DB backends unless it uses a pattern opclass. Consider removing this index, or replacing it with an index tailored for prefix LIKE (e.g., btree (path text_pattern_ops)), depending on the intended query patterns.

Suggested change
ON root_filesystem_entries(path);
ON root_filesystem_entries(path text_pattern_ops);

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 7f6e735. The PostgreSQL migration index is now path text_pattern_ops, so prefix LIKE ... ESCAPE '!' child scans can use the index; equality remains covered by the primary key.

ADD COLUMN IF NOT EXISTS is_dir BOOLEAN NOT NULL DEFAULT FALSE;

ALTER TABLE root_filesystem_entries
ALTER COLUMN contents SET DEFAULT '\\x';

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

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

The BYTEA default is set to '\\x', which is easy to misread and depends on bytea input escaping rules. Prefer an unambiguous empty-bytea default such as ''::bytea (or E'\\x'::bytea / '\x'::bytea) to make the migration’s intent clear and avoid subtle escaping differences across tooling.

Suggested change
ALTER COLUMN contents SET DEFAULT '\\x';
ALTER COLUMN contents SET DEFAULT ''::bytea;

Copilot uses AI. Check for mistakes.

@serrrfirat serrrfirat Apr 28, 2026 •

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 7f6e735. V27 now uses ALTER COLUMN contents SET DEFAULT ''::bytea for an explicit empty-bytea default.

Comment thread crates/ironclaw_filesystem/src/lib.rs Outdated
// backend root. If its canonical parent leaves the backend root, an
// existing symlink in the parent chain caused the escape.
ensure_contained(path, mount, &canonical_parent, true)?;
Ok(joined)

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

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

resolve_for_write returns joined (a non-canonical path) for new targets after checking the canonical parent is contained. Subsequent writes/opens on this path can still follow symlinks if an attacker swaps a path component between the check and the write (TOCTOU), enabling mount escape despite the containment checks. Consider using fd-relative operations (e.g., openat/cap-std) and O_NOFOLLOW/component-by-component traversal to make containment robust against races.

Suggested change
Ok(joined)
let file_name = joined
.file_name()
.ok_or_else(|| FilesystemError::PathOutsideMount { path: path.clone() })?;
// Return a path rooted at the canonicalized, containment-checked parent
// rather than the original joined path so later writes do not re-resolve
// unchecked ancestor components.
Ok(canonical_parent.join(file_name))

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 7f6e735. New write targets are now re-rooted on the canonical containment-checked parent before returning the final leaf path. I also documented the remaining local-backend TOCTOU limitation at crate level because fully closing it needs fd-relative/openat-style traversal.

Comment thread crates/ironclaw_filesystem/src/lib.rs Outdated
Comment on lines +1398 to +1400
let len = row.get::<i64>(0).unwrap_or(0).max(0) as u64;
let is_dir = row.get::<i64>(1).unwrap_or(0) != 0;
(

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

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

row.get::<i64>(0).unwrap_or(0) and the similar is_dir read silently convert any decoding/type error into 0, which can mask DB/schema issues and lead to incorrect behavior (e.g., treating a directory as a file). It’s safer to propagate the libsql error via map_err(...) (as done elsewhere in this file) rather than defaulting.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 7f6e735. LibSqlRootFilesystem::exact_entry now maps libSQL decode errors into FilesystemError::Backend instead of using unwrap_or(0).

Comment thread crates/ironclaw_filesystem/src/lib.rs Outdated
Comment on lines +1434 to +1436
let len = row.get::<i64>(1).unwrap_or(0).max(0) as u64;
let is_dir = row.get::<i64>(2).unwrap_or(0) != 0;
paths.push((

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

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

These unwrap_or(0) calls on row.get(...) in child_entries silently swallow decoding/type errors and can misclassify entries or lengths. Prefer to propagate the error (or at least map it into a FilesystemError::Backend) so failures are explicit and diagnosable.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 7f6e735. LibSqlRootFilesystem::child_entries now propagates path/length/is_dir decode errors instead of defaulting failed reads to zero.

Comment on lines +225 to +238
fn empty_local_backend(virtual_root: &str) -> LocalFilesystem {
let dir = tempdir().unwrap();
let path = dir.path().to_path_buf();
std::mem::forget(dir);

let mut backend = LocalFilesystem::new();
backend
.mount_local(
VirtualPath::new(virtual_root).unwrap(),
HostPath::from_path_buf(path),
)
.unwrap();
backend
}

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

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

std::mem::forget(dir) intentionally leaks the TempDir to keep the directory alive for the test, but it also leaks resources and can accumulate when running many tests. Prefer TempDir::into_path() (and clean up explicitly) or return a wrapper that holds the TempDir alongside the backend so it stays alive without leaking.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 7f6e735. The catalog test helper now returns the TempDir alongside the backend so the directory stays alive without std::mem::forget or deprecated into_path().

Comment thread crates/ironclaw_filesystem/src/lib.rs Outdated
Comment on lines +1130 to +1131
CREATE INDEX IF NOT EXISTS idx_root_filesystem_entries_path
ON root_filesystem_entries(path);

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

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

This schema creates idx_root_filesystem_entries_path, but path is already a PRIMARY KEY (and therefore already indexed). If the goal is to accelerate prefix LIKE queries (used by child_entries/delete), consider using a pattern opclass index; otherwise, remove the redundant index to save write amplification and storage.

Suggested change
CREATE INDEX IF NOT EXISTS idx_root_filesystem_entries_path
ON root_filesystem_entries(path);

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 7f6e735. The embedded PostgreSQL schema now uses the migration files as its source via include_str!, and V26 defines the text_pattern_ops prefix index.

@ilblackdragon

Copy link
Copy Markdown
Member

Code Review

Overview

Adds a new internal crate ironclaw_filesystem (~2,700 LOC) introducing a layered filesystem abstraction for the Reborn architecture: RootFilesystem (canonical virtual-path namespace), ScopedFilesystem (permission-gated invocation view), CompositeRootFilesystem (mount routing), and a FilesystemCatalog for placement metadata. Backends: local FS (with symlink-escape checks) plus optional PG and libSQL backends behind feature flags. Adds migrations V26/V27 and three contract test files. The crate is unwired — pure substrate, no production callers yet.

Strengths

  • Strong type discipline. Newtypes (BackendId, VirtualPath, ScopedPath, MountAlias) enforced at construction, no string-typed internals.
  • TDD signals throughout. PR description calls out RED-stub commit; the three contract files exercise the public API thoroughly (permissions × ops × happy/sad paths, longest-prefix routing, symlink escape on read and write and through symlinked parent dirs).
  • Security posture is good. Path containment via canonicalize + starts_with(host_root), both file-target and parent-chain symlinks blocked; error Display impls deliberately omit host paths and there are explicit tests asserting that.
  • Fail-closed defaults. Default trait impls for append_file/delete/create_dir_all return Backend { reason: \"not supported\" } rather than silently no-op'ing; MountLocal is hardcoded to deny in operation_allowed; unknown mount → MountNotFound before any backend touch.
  • No unwrap/expect outside tests. Even valid_engine_path() uses unwrap_or_else with unreachable!() rather than a bare unwrap.

Issues / Concerns

Correctness

  • PostgresRootFilesystem size truncates at 2 GiB (src/lib.rs:1146, 1182). OCTET_LENGTH(contents) returns int4; let len: i32 = row.get(\"len\") then len.max(0) as u64 will silently overflow for any blob > 2 GiB. Cast in SQL: OCTET_LENGTH(contents)::bigint and read as i64. Probably moot for memory docs, but the contract publishes len: u64.
  • Schema duplication risk between embedded POSTGRES_ROOT_FILESYSTEM_SCHEMA (src/lib.rs:1212) and migration files V26/V27. The crate's run_migrations() does its own CREATE TABLE IF NOT EXISTS + ALTER TABLE ADD COLUMN IF NOT EXISTS. If the project's migration runner also applies V26/V27, both paths must stay in sync forever, and either path could mask drift in the other. Pick one source of truth, or have run_migrations() no-op on PG when the project runner owns it.
  • V27 default '\\x' is fragile (migrations/V27__root_filesystem_entries_directories.sql:7). In PG, the literal '\\x' is the two-character string \x, which bytea_in interprets as the hex-format prefix with zero bytes — empty bytea, which is what's intended, but only works because PG accepts hex format. Prefer SET DEFAULT '\x'::bytea or SET DEFAULT ''::bytea to make intent explicit.
  • Migration numbering hazard. PR notes V25 lives only on reborn-integration. Diffing against staging, there is no V25, so V26/V27 may collide if another PR merges first. Confirm the merge order with whoever owns the migration sequence.
  • Tenant scoping is path-only. root_filesystem_entries is a global table; tenant/user separation lives entirely in the path string. A future bug in path construction or a new caller skipping ScopedFilesystem would cross tenants silently. The crate-local CLAUDE.md asks for tenant-scoped persistence — worth at minimum a tenant_id/user_id column for defense in depth, even if unused initially.

Local backend TOCTOU window

In resolve_for_write (src/lib.rs:685-718), the canonical-parent check happens before tokio::fs::write opens the file. Between those two syscalls, a writable mount root could have a subdirectory swapped for a symlink. The comment at :713-715 acknowledges the parent-chain check but doesn't close the window. On Linux, openat2 with RESOLVE_BENEATH would close it; absent that, calling out the residual race in the crate-level docs is fair given this is substrate.

Test hygiene

  • std::mem::forget(dir) in tests/catalog_contract.rs:1914 deliberately leaks TempDir to keep the directory alive — leaves debris in \$TMPDIR across test runs. Prefer keeping the TempDir owned by the LocalFilesystem (e.g. an Arc<TempDir> field for tests) or returning (LocalFilesystem, TempDir) from the helper.
  • libSQL test DB filename collision. tests/db_root_filesystem_contract.rs:2110 builds db_dir from PID + an atomic counter; concurrent CI runners on the same host with identical PIDs (containers!) could collide. Use a UUID, or lean on tempfile::tempdir().

Minor

  • MountView.mounts accessed as a public field in matching_mount (src/lib.rs:594-599). If MountView is meant to be encapsulated, consider an iterator method instead of exposing the Vec.
  • io_error collapses to error.kind().to_string() (src/lib.rs:927-941), which is great for path confidentiality but loses message detail — anyone debugging will need to add ad-hoc logging. Worth adding a tracing::debug! of the original error inside the helper.
  • create_dir_all on the DB backends loops INSERTs without a transaction (:1109-1133, :1400-1424). Partial-success on error leaves orphan parent rows. Wrap in a single transaction.
  • mount_local uses sync std::fs::canonicalize (src/lib.rs:649) inside an async crate. It only runs at mount setup time, but flag with tokio::task::spawn_blocking or move to tokio::fs::canonicalize for consistency.

Risk

Low for merge — the crate is unwired and feature-gated as PR description states. The risks above mostly land when something starts depending on it. The 2 GiB truncation, schema duplication, and tenant column are worth resolving before the first production caller, not necessarily before merging substrate.

Suggested follow-ups

  1. Fix OCTET_LENGTH int4 truncation (::bigint cast).
  2. Decide schema ownership: project migrations or run_migrations(), not both.
  3. Add tenant/user columns to the DB-backed schema as defense in depth.
  4. Replace mem::forget(TempDir) in tests.
  5. Document the local-backend TOCTOU residual at the crate level.

@serrrfirat
serrrfirat force-pushed the reborn-land-02b-filesystem-substrate branch from f4a83cd to 7f6e735 Compare April 28, 2026 10:52
@serrrfirat

Copy link
Copy Markdown
Collaborator Author

Addressed the current review pass in 7f6e73513 after rebasing the PR onto current reborn-integration (30165d2ae, includes #2993 and #2997). PR is mergeable again.

Implemented from inline/Copilot/Gemini/Ilblackdragon feedback:

  • PostgreSQL file lengths now use OCTET_LENGTH(contents)::bigint and read as i64 before converting to u64, avoiding the 2 GiB int4 truncation issue.
  • PostgreSQL schema source-of-truth is now the migration files: POSTGRES_ROOT_FILESYSTEM_SCHEMA is built with include_str!(V26) + include_str!(V27) rather than duplicating SQL in lib.rs.
  • V26 uses path text_pattern_ops for prefix LIKE ... ESCAPE '!' scans; V27 uses explicit ''::bytea for the empty bytea default. checksums.lock now pins V26/V27.
  • Local new-write paths now return canonical_parent.join(file_name) after containment checks, and crate docs call out the residual TOCTOU window plus the stronger future openat2/O_NOFOLLOW/cap-fs direction.
  • PostgreSQL and libSQL create_dir_all now run the prefix inserts inside a transaction.
  • libSQL row decoding now propagates errors instead of using unwrap_or(0) for len / is_dir.
  • Test hygiene: removed std::mem::forget(TempDir) and changed libSQL tests to use a TempDir-held wrapper rather than PID/counter paths.

Clarifications / intentionally deferred:

  • Tenant/user DB columns: not added in this PR. The generic RootFilesystem API currently receives only VirtualPath; adding nullable columns here would not enforce authority and would risk duplicating path grammar owned by higher layers (ScopedFilesystem, composition builders, and memory-specific services). This should be a pre-production-caller design decision with an API/storage contract, not a silent substrate patch.
  • Migration numbering: rebased on current reborn-integration, which already contains V25__wasm_fuel_limit_bump.sql; this PR now adds V26/V27 cleanly after it.
  • MountView.mounts: left unchanged because it is a ironclaw_host_api public contract issue rather than filesystem substrate internals. We can add an iterator/accessor in a foundation follow-up if we want to encapsulate that field.
  • mount_local still uses sync canonicalization because it is a synchronous setup API, not an async runtime operation. All async local backend file operations are on tokio::fs / tokio::io.

Verification run with target artifacts off the NVME volume:

CARGO_TARGET_DIR=/tmp/ironclaw-fs-target cargo test -p ironclaw_filesystem
CARGO_TARGET_DIR=/tmp/ironclaw-fs-target cargo test -p ironclaw_filesystem --features libsql
CARGO_TARGET_DIR=/tmp/ironclaw-fs-target cargo test -p ironclaw_filesystem --features postgres
CARGO_TARGET_DIR=/tmp/ironclaw-fs-target cargo clippy -p ironclaw_filesystem --all-targets --features libsql,postgres -- -D warnings
cargo fmt --check
git diff --check

Note: I attempted the full cargo test -p ironclaw -- --ignored regenerate_migration_checksums_lockfile, but the NVME volume was full. I computed the V26/V27 refinery checksums with a minimal refinery-core helper in /tmp instead and updated migrations/checksums.lock.

@ilblackdragon

Copy link
Copy Markdown
Member

Code Review

Overview

Adds a new crates/ironclaw_filesystem substrate with:

  • RootFilesystem trait + ScopedFilesystem (permission-checked façade over MountView)
  • CompositeRootFilesystem (longest-virtual-prefix routing)
  • Local backend with canonicalize+containment symlink-escape protection
  • Postgres + libSQL DB-backed backends with explicit directory rows (V26/V27 migrations)
  • FilesystemCatalog placement metadata (backend kind, content kind, index policy)
  • ~1000 lines of contract tests covering permission gates, symlink escapes, longest-prefix routing, error confidentiality

Cleanly scoped — no src/ wiring, no behavior change. Crate CLAUDE.md enforces the "depends only on ironclaw_host_api" boundary, and the PR's cargo tree grep confirms it.

Strengths

  • Defense-in-depth honestly documented. Crate-level doc admits canonicalize-then-open is not race-free against hostile mount roots and points at openat2/cap-std as proper fixes (lib.rs:104-110, 720-731). Better than pretending the check is bulletproof.
  • Symlink coverage is good. Both denies_symlink_escape and denies_write_through_symlinked_parent_escape cover the existing-leaf and ancestor cases. resolve_for_write re-roots the leaf on the canonicalized parent, narrowing the TOCTOU window between the containment check and the open.
  • Error confidentiality is tested, not just claimed (display_errors_do_not_leak_raw_host_paths, nonexistent_backend_mount_root_fails_without_leaking_host_path). Aligns with the project's "no raw paths to user" rule.
  • Catalog separates placement from authority — descriptors are diagnostic metadata only; runtime access still goes through ScopedFilesystem. Right design.
  • Fail-closed defaults. delete/create_dir_all/append_file default trait impls return Backend { reason: "not supported" } rather than silently no-op'ing.

Issues

Correctness / consistency

  1. DB delete semantics diverge from local backend. PostgresRootFilesystem::delete and LibSqlRootFilesystem::delete (lib.rs:1114-1125, 1414-1423) execute DELETE … WHERE path = $1 OR path LIKE $2 and ignore the affected-row count, returning Ok(()) even when nothing existed. LocalFilesystem::delete calls resolve_existing first, so it returns NotFound (lib.rs:865-871). Pick one — silent-OK delete or fail-on-missing — and apply it across all backends. The contract is part of RootFilesystem, not the backend.

  2. PermissionDenied for Stat allows read || list (lib.rs:620). Defensible but worth pinning in a doc comment on operation_allowed — the test stat_is_allowed_by_read_or_list_and_denied_without_both documents it but the rationale (e.g., "stat is metadata-only, either capability implies it") isn't anywhere in the source.

  3. ScopedFilesystem::resolve_with_permission resolves twice (lib.rs:577-599): once via matching_mount, once via self.mounts.resolve(path). Not a perf bug, but the matching_mount helper duplicates MountView::resolve's longest-alias logic (and alias_matches is copy-pasted from mount.rs). Consider exposing MountView::resolve_with_grant(&ScopedPath) -> Result<(VirtualPath, &MountGrant)> in host_api so this crate doesn't carry a divergent copy.

  4. Postgres helpers re-acquire pool clients. PostgresRootFilesystem::append_file grabs a client then calls self.exact_entry(path) which self.client().await? again from the pool (lib.rs:1049-1051). Same in list_dir (1077-1088), stat (1096-1110), create_dir_all (1128). Two pool acquisitions per logical op under contention. Pass the client through the helper, or reorder so the held client is acquired after the lookup.

  5. error.kind().to_string() discards the underlying message (lib.rs:949-958, 961-963). ErrorKind::Other.to_string() is just \"other error\"; for backend debugging that loses signal. Either tracing::debug! the full error before discarding, or include .to_string() on a debug-only path. Currently silent failures will be hard to root-cause.

Style / convention

  1. Error Display uses {path:?} (Debug-format) for VirtualPath/ScopedPath (lib.rs:158-180). That prints VirtualPath(\"/foo\") rather than /foo. Either implement Display on the path types in host_api or switch the format strings to {} after adding it. Cosmetic but the messages will surface in logs.

  2. PR body typo: "failed due missing contract types" → "due to missing".

  3. virtual_prefix_matches and alias_matches are the same function under two names (lib.rs:610-612 vs 885-887). Pick one and inline the other.

  4. mount_local uses sync std::fs::canonicalize on what's likely an async context (lib.rs:657). Setup-time only, but tokio::fs::canonicalize would match the rest of the file and avoid blocking the runtime if mount_local is ever called inside a tokio::spawn.

Architecture / future-proofing

  1. DB-backed append_file is O(n²) over time — Postgres uses contents || EXCLUDED.contents, libSQL uses CAST(contents || excluded.contents AS BLOB), both rewrite the whole row each append. Crate CLAUDE.md already says "do not force structured records through file byte APIs" — worth a // TODO: next to these append impls pointing at that guidance, so future event-JSONL mounts don't reach for the file API.

  2. postgres_root_filesystem_implements_root_filesystem_contract is a trait-bound check, not a behavior test (db_root_filesystem_contract.rs:2160-2164). Acknowledged limitation per project status ("Integration tests need testcontainers for PostgreSQL"), but the libSQL backend gets real round-trip coverage and Postgres doesn't. Acceptable for substrate-only PR; the divergent-delete-semantics issue above won't surface until Postgres gets real coverage.

Risk Assessment

  • Blast radius: zero — internal substrate, no src/ wiring, no migrations applied at runtime by src/db either.
  • Migrations V26/V27: only consumed by ironclaw_filesystem::PostgresRootFilesystem::run_migrations, which has no production caller. Safe to land as dormant DDL.
  • Boundary check passes per the PR's cargo tree grep — only depends on ironclaw_host_api.

Recommendation

Approve with the delete-semantics divergence (#1) and double pool acquisition (#4) addressed before any production wiring lands. Everything else is style/cleanup that can ride a follow-up PR. The substrate is well-scoped, well-tested, and the security tradeoffs are documented honestly.

@serrrfirat

Copy link
Copy Markdown
Collaborator Author

Addressed the latest Ilblackdragon review in 41b4b2ba2.

Implemented:

  • Aligned delete semantics across backends: DB-backed delete now returns FilesystemError::NotFound when no exact path or child rows were deleted, matching the local backend contract.
  • Added regression coverage for missing-delete behavior in local/scoped and libSQL DB filesystem tests.
  • Documented the Stat permission rationale at the enforcement point: stat is metadata-only, so either read or list authority is sufficient.
  • Added MountView::resolve_with_grant(...) in ironclaw_host_api and switched ScopedFilesystem to it so permission checks and path resolution share the same longest-alias logic instead of duplicating it.
  • Reworked PostgreSQL append_file, list_dir, and stat to reuse one pool client for the operation instead of acquiring a second client inside helper calls.
  • Added debug tracing for the original local backend I/O error before returning sanitized FilesystemError variants.
  • Added Display for VirtualPath, ScopedPath, and MountAlias; filesystem error messages now render /path instead of VirtualPath("/path") / ScopedPath("/path").
  • Consolidated duplicate prefix-match helpers to one path_prefix_matches helper.
  • Added explicit setup-time docs on mount_local: it remains synchronous because it mutates trusted mount configuration, while async runtime operations use tokio::fs.
  • Added TODOs beside DB-backed append_file implementations warning that row-concat append rewrites the full row and should not be used for high-volume JSONL/event streams.
  • Updated PR body typo and verification commands.

Left intentionally as follow-up / explicit limitation:

  • PostgreSQL behavior tests still need testcontainers or a project-level PG test harness; this PR keeps the existing trait-bound check plus libSQL behavioral contract coverage.

Verification passed:

CARGO_TARGET_DIR=/tmp/ironclaw-fs-target cargo test -p ironclaw_host_api -p ironclaw_filesystem
CARGO_TARGET_DIR=/tmp/ironclaw-fs-target cargo test -p ironclaw_filesystem --features libsql
CARGO_TARGET_DIR=/tmp/ironclaw-fs-target cargo test -p ironclaw_filesystem --features postgres
CARGO_TARGET_DIR=/tmp/ironclaw-fs-target cargo clippy -p ironclaw_host_api -p ironclaw_filesystem --all-targets --features libsql,postgres -- -D warnings
cargo fmt --check
git diff --check

@serrrfirat
serrrfirat merged commit b308603 into reborn-integration Apr 28, 2026
18 checks passed
@serrrfirat
serrrfirat deleted the reborn-land-02b-filesystem-substrate branch April 28, 2026 11:56
@serrrfirat serrrfirat added the reborn IronClaw Reborn architecture and landing work label Apr 29, 2026
theredspoon pushed a commit to theredspoon/ironclaw that referenced this pull request Jun 21, 2026
* feat(reborn): add filesystem substrate

* fix(reborn): address filesystem review findings

* fix(reborn): address filesystem substrate review

* fix(reborn): align filesystem substrate semantics
henrypark133 added a commit that referenced this pull request Jul 27, 2026
coderabbitai (round 7) flagged that leaf containment checks aren't
atomic with the mutation they gate. The gap is real but matches the
already-deferred residual documented on resolve_for_write's re-rooting
step (full fix needs openat/O_NOFOLLOW/cap-std, tracked via PR #2996
review) — extend that documentation to resolve_for_create_dir_all and
delete instead of re-litigating the same heavy-lift follow-up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
henrypark133 added a commit that referenced this pull request Jul 28, 2026
…ity primitives (#6695)

* feat(sandbox): leaf-scoped mount containment + per-user sandbox identity primitives

Ships two related, unwired-by-design slices of the persistent per-user
sandbox program:

- ironclaw_filesystem: leaf-scoped mount containment. resolve_joined now
  returns a per-request containment_root that, for a leaf_scoped mount, is
  host_root/<first-tail-segment> instead of the shared host_root — closing
  a same-mount cross-leaf symlink escape a plain mount_local containment
  check would miss. mount_local_per_leaf is the constructor; a bare-root
  request against such a mount is rejected outright (no safe containment
  root for "every caller's leaf").

- ironclaw_host_runtime: identity + attribution primitives for the
  persistent per-user sandbox container model — RebornSandboxUserKey
  ({tenant,user}-only container/workspace key), the labels-as-identity
  registry (Docker label helpers, SandboxActivityRegistry,
  BackgroundJobRegistry), and ConnectionAttributionResolver (source-IP to
  {tenant,user} resolution for the shared egress proxy, design decision D9).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(sandbox): attribution real-docker test reuses connect_docker() fallback

docker_gate::docker_available() shells out to the docker CLI, which resolves the daemon through whatever context is active (Colima, Docker Desktop, a remote host). The test then connected directly via Docker::connect_with_local_defaults(), which only honors DOCKER_HOST or the hardcoded /var/run/docker.sock, so the gate could pass while the connection still failed on any machine using a non-default socket.

Reuse sandbox_process::connect_docker() instead of reimplementing resolution: it already tries connect_with_local_defaults() then falls back through unix_socket_candidates(), the same path production containers connect through. A connect_docker() failure now prints a SKIP line rather than panicking.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(sandbox): address PR review — leaf-creation bug, attribution hygiene, docker CI gate

Leaf-scoped mounts rejected a brand-new leaf's first write/create_dir_all
(ensure_existing_ancestor_contained had no bootstrap case for the shared
host_root when a caller's leaf doesn't exist yet); accept that one ancestor
now and add regression coverage for write-path creation, write-path
cross-leaf symlink escape, and create_dir_all bootstrap.

Attribution cache: sweep expired entries on miss so a long-running resolver
doesn't grow the cache unboundedly, and log the missing/malformed-label
fail-closed branch like its sibling branches. Add coverage for malformed/
missing container network IPs.

Docker CI gate: the attribution real-Docker test's connect_docker() failure
branch always skipped, even under IRONCLAW_REQUIRE_DOCKER_TESTS=1 — panic
in that mode instead, matching docker_gate's existing fail-closed pattern.
Pull busybox:1.36 before create_container so the test doesn't depend on a
pre-warmed local image cache (this is what broke it in CI).

registry.rs/attribution.rs: crate::-rooted imports per repo convention;
trim sandbox_process.rs's module header to stable ownership, not PR-state.
Add BackgroundJobRegistry, malformed-candidate-parsing, and concurrent
SandboxActivityRegistry coverage.

docs/reborn/contracts/host-runtime.md: one forward-pointing sentence
noting RebornSandboxUserKey's future coarser identity model doesn't yet
supersede the scope-derived identity this contract already documents.

architecture ratchet: baseline the two new test/dead-code seams this
introduces (attribution.rs dead-code method x4, test-support method x1).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(sandbox): O(1) drop_dead pruning + coverage for empty-alive-pids and concurrent attribution resolve

Addresses review 4784267719 on PR #6695:
- BackgroundJobRegistry::drop_dead now builds a HashSet once instead of
  a per-job linear scan of alive_pids (O(J) not O(J x A)).
- Add background_job_registry_drop_dead_with_empty_alive_pids_removes_all_jobs.
- Add concurrent_resolve_calls_complete_with_consistent_attribution covering
  simultaneous ConnectionAttributionResolver::resolve calls.
- Doc-comment the two known-but-deferred tradeoffs (attribution thundering
  herd, unbounded BackgroundJobRegistry growth) — both need the not-yet-wired
  caller (W6 / exec_transport+reaper) to know the right shape, so building a
  mechanism now would be guessing; left as explicit follow-ups instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(sandbox): force real cache-miss overlap in attribution concurrency test

FakeLookup::containers_on_network returned immediately with no yield
point, so the 20 spawned resolve() tasks could run to sequential
completion without ever actually overlapping in the miss/query/insert
window the test claims to exercise. Add an optional Barrier that all
callers wait on inside containers_on_network, forcing genuine
concurrent cache misses before any insert proceeds.

Addresses CodeRabbit review 4788508880.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(sandbox): address new PR #6695 review round (CI-hang test, silent-ok docs, IPv6 attribution, typed mount resolution, key codec dedup

- attribution.rs: bound the concurrent-resolve barrier test with a timeout
  so a regression can't hang CI; add silent-ok rationale comments on the
  Docker-boundary .ok() parses; fix container_addresses_on_network to also
  read bollard's global_ipv6_address field (an IPv6-only peer was
  previously never matchable); add empty-listing and IPv6 coverage.
- registry.rs: module header now names all three responsibilities
  (label codec, activity registry, background-job registry); add a
  concurrent record/jobs_for/drop_dead test for BackgroundJobRegistry to
  match the existing SandboxActivityRegistry coverage.
- user_key.rs: clarify that RebornSandboxScopeKey remains authoritative
  for the currently-wired transport; RebornSandboxUserKey is reserved for
  the future persistent per-user transport.
- docker_gate.rs: header now describes the daemon-only gate accurately
  instead of overclaiming a required ironclaw-worker image.
- local.rs: resolve_joined now returns a typed ResolvedMountPath (joined,
  containment_root, bootstrap_root) instead of a positional tuple plus a
  separately re-derived bootstrap_root at two of three call sites.
- New key_codec module: shared length-prefixed encoding + SHA-256 digest
  for RebornSandboxScopeKey and RebornSandboxUserKey, replacing two
  independently-maintained copies of the same framing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(sandbox): split attribution.rs test module into its own file

My prior fixes pushed attribution.rs from 951 to 1029 lines, crossing the
hard 1000-line threshold. Moved #[cfg(test)] mod tests (FakeLookup harness,
17 unit tests, the gated real-Docker integration test) into a sibling
attribution_tests.rs via #[path], matching the file's existing docker_gate
#[path] pattern. Pure move, no behavior change: production attribution.rs
is now 367 lines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(sandbox): address round-6 review findings on #6695

- crate::-qualify key_codec imports in user_key.rs/scope_key.rs (matches
  the PR's own crate:: convention already used for registry/attribution)
- add missing silent-ok rationale for the dropped created_at parse error
  in UserContainerCandidate::from_summary
- gate the two cross-leaf symlink-escape tests in local.rs with
  #[cfg(unix)] (std::os::unix::fs::symlink does not compile on the
  windows release target)
- document mount_local_per_leaf's bare-root-denial, first-use-bootstrap,
  and per-leaf symlink-containment contract in filesystem.md
- add missing-tenant and malformed-tenant-label fail-closed tests to
  attribution_tests.rs (existing coverage only exercised the user field)
- correct the ConnectionAttributionResolver doc comments: invalidate()
  collapses staleness "toward" zero, not "to" zero — a concurrent
  in-flight resolve() can still re-insert a stale entry after
  invalidate() removes it. Not fixed (no caller exists yet to fix a
  race against), but the doc must not overclaim a guarantee it does
  not have.

* fix(filesystem): reject dangling final symlink in resolve_for_write

Addresses coderabbitai review 4790451629 on PR #6695:
try_exists follows symlinks and reports false for a dangling one, so
a pre-planted dangling symlink at the write target fell through to
the brand-new-file bootstrap path. write_file/append_file open with
O_CREAT, so the OS would create the file wherever the symlink points,
escaping leaf containment. resolve_for_write now checks existence via
symlink_metadata (lstat) and fails closed with SymlinkEscape when a
symlink entry can't be canonicalized, instead of silently falling
through.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(filesystem): document TOCTOU residual on create_dir_all/delete

coderabbitai (round 7) flagged that leaf containment checks aren't
atomic with the mutation they gate. The gap is real but matches the
already-deferred residual documented on resolve_for_write's re-rooting
step (full fix needs openat/O_NOFOLLOW/cap-std, tracked via PR #2996
review) — extend that documentation to resolve_for_create_dir_all and
delete instead of re-litigating the same heavy-lift follow-up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
l3ocifer pushed a commit to l3ocifer/frick-ironclaw that referenced this pull request Sep 3, 2026
…ity primitives (nearai#6695)

* feat(sandbox): leaf-scoped mount containment + per-user sandbox identity primitives

Ships two related, unwired-by-design slices of the persistent per-user
sandbox program:

- ironclaw_filesystem: leaf-scoped mount containment. resolve_joined now
  returns a per-request containment_root that, for a leaf_scoped mount, is
  host_root/<first-tail-segment> instead of the shared host_root — closing
  a same-mount cross-leaf symlink escape a plain mount_local containment
  check would miss. mount_local_per_leaf is the constructor; a bare-root
  request against such a mount is rejected outright (no safe containment
  root for "every caller's leaf").

- ironclaw_host_runtime: identity + attribution primitives for the
  persistent per-user sandbox container model — RebornSandboxUserKey
  ({tenant,user}-only container/workspace key), the labels-as-identity
  registry (Docker label helpers, SandboxActivityRegistry,
  BackgroundJobRegistry), and ConnectionAttributionResolver (source-IP to
  {tenant,user} resolution for the shared egress proxy, design decision D9).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(sandbox): attribution real-docker test reuses connect_docker() fallback

docker_gate::docker_available() shells out to the docker CLI, which resolves the daemon through whatever context is active (Colima, Docker Desktop, a remote host). The test then connected directly via Docker::connect_with_local_defaults(), which only honors DOCKER_HOST or the hardcoded /var/run/docker.sock, so the gate could pass while the connection still failed on any machine using a non-default socket.

Reuse sandbox_process::connect_docker() instead of reimplementing resolution: it already tries connect_with_local_defaults() then falls back through unix_socket_candidates(), the same path production containers connect through. A connect_docker() failure now prints a SKIP line rather than panicking.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(sandbox): address PR review — leaf-creation bug, attribution hygiene, docker CI gate

Leaf-scoped mounts rejected a brand-new leaf's first write/create_dir_all
(ensure_existing_ancestor_contained had no bootstrap case for the shared
host_root when a caller's leaf doesn't exist yet); accept that one ancestor
now and add regression coverage for write-path creation, write-path
cross-leaf symlink escape, and create_dir_all bootstrap.

Attribution cache: sweep expired entries on miss so a long-running resolver
doesn't grow the cache unboundedly, and log the missing/malformed-label
fail-closed branch like its sibling branches. Add coverage for malformed/
missing container network IPs.

Docker CI gate: the attribution real-Docker test's connect_docker() failure
branch always skipped, even under IRONCLAW_REQUIRE_DOCKER_TESTS=1 — panic
in that mode instead, matching docker_gate's existing fail-closed pattern.
Pull busybox:1.36 before create_container so the test doesn't depend on a
pre-warmed local image cache (this is what broke it in CI).

registry.rs/attribution.rs: crate::-rooted imports per repo convention;
trim sandbox_process.rs's module header to stable ownership, not PR-state.
Add BackgroundJobRegistry, malformed-candidate-parsing, and concurrent
SandboxActivityRegistry coverage.

docs/reborn/contracts/host-runtime.md: one forward-pointing sentence
noting RebornSandboxUserKey's future coarser identity model doesn't yet
supersede the scope-derived identity this contract already documents.

architecture ratchet: baseline the two new test/dead-code seams this
introduces (attribution.rs dead-code method x4, test-support method x1).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(sandbox): O(1) drop_dead pruning + coverage for empty-alive-pids and concurrent attribution resolve

Addresses review 4784267719 on PR nearai#6695:
- BackgroundJobRegistry::drop_dead now builds a HashSet once instead of
  a per-job linear scan of alive_pids (O(J) not O(J x A)).
- Add background_job_registry_drop_dead_with_empty_alive_pids_removes_all_jobs.
- Add concurrent_resolve_calls_complete_with_consistent_attribution covering
  simultaneous ConnectionAttributionResolver::resolve calls.
- Doc-comment the two known-but-deferred tradeoffs (attribution thundering
  herd, unbounded BackgroundJobRegistry growth) — both need the not-yet-wired
  caller (W6 / exec_transport+reaper) to know the right shape, so building a
  mechanism now would be guessing; left as explicit follow-ups instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(sandbox): force real cache-miss overlap in attribution concurrency test

FakeLookup::containers_on_network returned immediately with no yield
point, so the 20 spawned resolve() tasks could run to sequential
completion without ever actually overlapping in the miss/query/insert
window the test claims to exercise. Add an optional Barrier that all
callers wait on inside containers_on_network, forcing genuine
concurrent cache misses before any insert proceeds.

Addresses CodeRabbit review 4788508880.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(sandbox): address new PR nearai#6695 review round (CI-hang test, silent-ok docs, IPv6 attribution, typed mount resolution, key codec dedup

- attribution.rs: bound the concurrent-resolve barrier test with a timeout
  so a regression can't hang CI; add silent-ok rationale comments on the
  Docker-boundary .ok() parses; fix container_addresses_on_network to also
  read bollard's global_ipv6_address field (an IPv6-only peer was
  previously never matchable); add empty-listing and IPv6 coverage.
- registry.rs: module header now names all three responsibilities
  (label codec, activity registry, background-job registry); add a
  concurrent record/jobs_for/drop_dead test for BackgroundJobRegistry to
  match the existing SandboxActivityRegistry coverage.
- user_key.rs: clarify that RebornSandboxScopeKey remains authoritative
  for the currently-wired transport; RebornSandboxUserKey is reserved for
  the future persistent per-user transport.
- docker_gate.rs: header now describes the daemon-only gate accurately
  instead of overclaiming a required ironclaw-worker image.
- local.rs: resolve_joined now returns a typed ResolvedMountPath (joined,
  containment_root, bootstrap_root) instead of a positional tuple plus a
  separately re-derived bootstrap_root at two of three call sites.
- New key_codec module: shared length-prefixed encoding + SHA-256 digest
  for RebornSandboxScopeKey and RebornSandboxUserKey, replacing two
  independently-maintained copies of the same framing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(sandbox): split attribution.rs test module into its own file

My prior fixes pushed attribution.rs from 951 to 1029 lines, crossing the
hard 1000-line threshold. Moved #[cfg(test)] mod tests (FakeLookup harness,
17 unit tests, the gated real-Docker integration test) into a sibling
attribution_tests.rs via #[path], matching the file's existing docker_gate
#[path] pattern. Pure move, no behavior change: production attribution.rs
is now 367 lines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(sandbox): address round-6 review findings on nearai#6695

- crate::-qualify key_codec imports in user_key.rs/scope_key.rs (matches
  the PR's own crate:: convention already used for registry/attribution)
- add missing silent-ok rationale for the dropped created_at parse error
  in UserContainerCandidate::from_summary
- gate the two cross-leaf symlink-escape tests in local.rs with
  #[cfg(unix)] (std::os::unix::fs::symlink does not compile on the
  windows release target)
- document mount_local_per_leaf's bare-root-denial, first-use-bootstrap,
  and per-leaf symlink-containment contract in filesystem.md
- add missing-tenant and malformed-tenant-label fail-closed tests to
  attribution_tests.rs (existing coverage only exercised the user field)
- correct the ConnectionAttributionResolver doc comments: invalidate()
  collapses staleness "toward" zero, not "to" zero — a concurrent
  in-flight resolve() can still re-insert a stale entry after
  invalidate() removes it. Not fixed (no caller exists yet to fix a
  race against), but the doc must not overclaim a guarantee it does
  not have.

* fix(filesystem): reject dangling final symlink in resolve_for_write

Addresses coderabbitai review 4790451629 on PR nearai#6695:
try_exists follows symlinks and reports false for a dangling one, so
a pre-planted dangling symlink at the write target fell through to
the brand-new-file bootstrap path. write_file/append_file open with
O_CREAT, so the OS would create the file wherever the symlink points,
escaping leaf containment. resolve_for_write now checks existence via
symlink_metadata (lstat) and fails closed with SymlinkEscape when a
symlink entry can't be canonicalized, instead of silently falling
through.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(filesystem): document TOCTOU residual on create_dir_all/delete

coderabbitai (round 7) flagged that leaf containment checks aren't
atomic with the mutation they gate. The gap is real but matches the
already-deferred residual documented on resolve_for_write's re-rooting
step (full fix needs openat/O_NOFOLLOW/cap-std, tracked via PR nearai#2996
review) — extend that documentation to resolve_for_create_dir_all and
delete instead of re-litigating the same heavy-lift follow-up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contributor: core 20+ merged PRs DB MIGRATION PR adds or modifies PostgreSQL or libSQL migration definitions reborn IronClaw Reborn architecture and landing work risk: medium Business logic, config, or moderate-risk modules scope: db/postgres PostgreSQL backend scope: dependencies Dependency updates scope: docs Documentation size: XL 500+ changed lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants