Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
c7d907d
wip(sandbox): persistent-sandbox program wave 3b snapshot (pre-rebase…
henrypark133 Jul 22, 2026
2f49a42
feat(sandbox): add coarse {tenant,user} container identity key
henrypark133 Jul 22, 2026
e679cd6
style(host_runtime): cargo fmt line-wrapping in shell/port/limits
henrypark133 Jul 22, 2026
8fb26bb
feat(sandbox): add labels-as-identity registry and push-based activit…
henrypark133 Jul 22, 2026
82a0d04
test(architecture): sync composition pub-use ratchet baseline for ten…
henrypark133 Jul 22, 2026
6815ed4
feat(sandbox): replace ephemeral container exec with persistent docke…
henrypark133 Jul 22, 2026
8331330
refactor(sandbox): introduce SandboxRuntimeBindings as the single san…
henrypark133 Jul 22, 2026
39cb8e2
style(composition): cargo fmt normalization in sandbox_boot/sandbox_q…
henrypark133 Jul 22, 2026
249d617
feat(sandbox): add background:true detached execution with live-proce…
henrypark133 Jul 22, 2026
20c6eef
refactor(sandbox): flip reaper from invocation-liveness reaping to tw…
henrypark133 Jul 22, 2026
5dd4952
refactor(sandbox): move concurrency ceiling from ResourceAccount::ten…
henrypark133 Jul 22, 2026
36a1991
feat(sandbox): extend process-sandbox image with git/node/python/rust…
henrypark133 Jul 22, 2026
83b382c
fix(sandbox): rewrite sandbox_reaper_docker integration test for two-…
henrypark133 Jul 22, 2026
b7d77b2
feat(sandbox): mount per-user sandbox workspace at /workspace abstrac…
henrypark133 Jul 22, 2026
a988b69
test(sandbox): Docker-real shell<->abstract-FS /workspace byte parity
henrypark133 Jul 22, 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
68 changes: 67 additions & 1 deletion .github/workflows/docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ on:

env:
IMAGE_NAME: nearaidev/ironclaw
# Sandbox worker image (Dockerfile.process-sandbox): the image the
# `ironclaw-dind` bake step pulls for scoped process-sandbox launches.
# Published from this same run so it always matches `ironclaw`'s tag/sha.
IMAGE_NAME_WORKER: nearaidev/ironclaw-worker

jobs:
build:
Expand All @@ -42,6 +46,9 @@ jobs:
contents: read
packages: read
actions: write
outputs:
skip: ${{ steps.check.outputs.skip }}
worker_sha_tag: ${{ steps.tags.outputs.sha_tag }}
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
Expand Down Expand Up @@ -79,6 +86,7 @@ jobs:
IS_RELEASE_BUILD: ${{ inputs.release && 'true' || 'false' }}
INPUT_TAG: ${{ inputs.tag }}
SOURCE_SHA: ${{ steps.source_sha.outputs.sha }}
IMAGE_NAME_WORKER: ${{ env.IMAGE_NAME_WORKER }}
run: |
if [[ -n "${INPUT_TAG}" && ! "${INPUT_TAG}" =~ ^[A-Za-z0-9_][A-Za-z0-9._-]{0,127}$ ]]; then
echo "::error::Input tag '${INPUT_TAG}' does not match Docker tag grammar"
Expand Down Expand Up @@ -108,6 +116,11 @@ jobs:
fi
echo "tags=${TAGS}" >> "$GITHUB_OUTPUT"

# ironclaw-worker mirrors ironclaw's tag scheme exactly — derive it
# by swapping the image name so the two never drift apart.
WORKER_TAGS="${TAGS//${IMAGE_NAME}/${IMAGE_NAME_WORKER}}"
echo "worker_tags=${WORKER_TAGS}" >> "$GITHUB_OUTPUT"

# The Reborn Dockerfile has a single `runtime` stage — WASM
# extensions are compiled into the binary via
# `ironclaw_first_party_extensions`, so there is no separate
Expand Down Expand Up @@ -161,6 +174,24 @@ jobs:
cache-from: type=gha
cache-to: type=gha,mode=max

- name: Build and push (ironclaw-worker)
if: steps.check.outputs.skip != 'true'
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
with:
context: .
file: Dockerfile.process-sandbox
push: true
tags: ${{ steps.tags.outputs.worker_tags }}
labels: |
ironclaw.git.sha=${{ steps.source_sha.outputs.sha }}
platforms: linux/amd64
# Separate cache scope: process-sandbox has a completely different
# layer graph (debian:bookworm-slim + apt packages) than the
# ironclaw runtime image — a shared scope would evict useful layers
# from both builds on every run.
cache-from: type=gha,scope=worker
cache-to: type=gha,mode=max,scope=worker

- name: Create releases-manager app token
id: app-token
if: steps.check.outputs.skip != 'true'
Expand Down Expand Up @@ -197,6 +228,7 @@ jobs:
if: steps.check.outputs.skip != 'true'
env:
TAGS: ${{ steps.tags.outputs.tags }}
WORKER_TAGS: ${{ steps.tags.outputs.worker_tags }}
VERSION: ${{ steps.version.outputs.version }}
SOURCE_SHA: ${{ steps.source_sha.outputs.sha }}
run: |
Expand All @@ -208,6 +240,11 @@ jobs:
echo "${TAGS}" | tr ',' '\n'
echo '```'
echo ""
echo "**ironclaw-worker:**"
echo '```'
echo "${WORKER_TAGS}" | tr ',' '\n'
echo '```'
echo ""
echo "- version: \`${VERSION}\`"
echo "- sha: \`${SOURCE_SHA}\`"
} >> "$GITHUB_STEP_SUMMARY"
Expand All @@ -220,6 +257,35 @@ jobs:
{
echo "## Docker Images — skipped"
echo ""
echo "Current commit already built for \`${IMAGE_NAME}:staging\`."
echo "Current commit already built for \`${IMAGE_NAME}:staging\` / \`${IMAGE_NAME_WORKER}:staging\`."

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Check the worker image before skipping staging builds

The skipped summary now claims both ironclaw:staging and ironclaw-worker:staging are current, but the preceding skip check only pulls/inspects IMAGE_NAME:staging. If a prior run pushed the main image and failed before pushing or smoking the worker image, the next scheduled/staging run will set skip=true, skip the worker build and smoke job, and leave the worker tag missing or stale.

Useful? React with 👍 / 👎.

echo "- sha: \`${SOURCE_SHA}\`"
} >> "$GITHUB_STEP_SUMMARY"
Comment on lines +260 to 262

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Verify the worker image before skipping.

The skip gate only pulls and inspects ${IMAGE_NAME}:staging. If ironclaw-worker:staging is missing or stale, this run skips its build and the worker smoke job while claiming both images are current. Inspect the worker image’s ironclaw.git.sha too, and skip only when both labels match SOURCE_SHA.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/docker.yml around lines 260 - 262, Update the
staging-image skip gate in the workflow to pull and inspect both
IMAGE_NAME:staging and IMAGE_NAME_WORKER:staging, including each image’s
ironclaw.git.sha label. Only skip the builds and worker smoke job when both
labels match SOURCE_SHA; otherwise continue the build path.


smoke-worker-image:
name: Smoke test (ironclaw-worker)
needs: build
if: needs.build.outputs.skip != 'true'
runs-on: ubuntu-24.04
permissions:
contents: read
packages: read
env:
IMAGE_NAME_WORKER: nearaidev/ironclaw-worker
steps:
- name: Log in to Docker Hub
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
username: ${{ vars.DOCKER_REGISTRY_USER }}
password: ${{ secrets.DOCKER_REGISTRY_TOKEN }}

- name: Pull and run ironclaw-worker
env:
WORKER_SHA_TAG: ${{ needs.build.outputs.worker_sha_tag }}
run: |
IMAGE="${IMAGE_NAME_WORKER}:${WORKER_SHA_TAG}"
echo "Smoke testing ${IMAGE}"
docker pull "${IMAGE}"
# Proves the tini -> process-sandbox-entrypoint -> capsh chain
# actually execs the given command instead of falling through to
# the image's "missing command" default CMD.
docker run --rm "${IMAGE}" sh -c "echo ok"
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.

26 changes: 25 additions & 1 deletion Dockerfile.process-sandbox
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,39 @@ RUN apt-get update \
iptables \
libcap2-bin \
tini \
tmux \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*

# GitHub CLI (gh)
RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
-o /usr/share/keyrings/githubcli-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
> /etc/apt/sources.list.d/github-cli.list \
&& apt-get update && apt-get install -y --no-install-recommends gh \
&& apt-get clean && rm -rf /var/lib/apt/lists/*

COPY docker/process-sandbox-entrypoint.sh /usr/local/bin/process-sandbox-entrypoint
RUN chmod +x /usr/local/bin/process-sandbox-entrypoint \
&& useradd -m -u 1000 -s /bin/bash sandbox \
&& mkdir -p /workspace /ironclaw/state/tools /ironclaw/state/cache /ironclaw/broker \
&& mkdir -p /workspace/.home /ironclaw/state/tools /ironclaw/state/cache /ironclaw/broker \
&& chown -R sandbox:sandbox /workspace /ironclaw

# Rust toolchain, installed as the sandbox user under the workspace-relative
# HOME so it survives container-rm/recreate only via the bind-mounted
# workspace (matches the design's "HOME=/workspace/.home so caches/dotfiles
# persist" decision) — installed at build time into a location Cargo can
# still find once HOME is redirected to /workspace/.home at runtime via
# CARGO_HOME/RUSTUP_HOME pointed at a workspace-relative path is NOT done at
# build time (the workspace doesn't exist yet); instead rustup installs to
# the image's default /home/sandbox/.cargo, and the container launch env
# additionally sets CARGO_HOME=/workspace/.home/.cargo, RUSTUP_HOME=/workspace/.home/.rustup
# with a first-run copy step in the entrypoint script — see entrypoint change below.
USER sandbox
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Make Rust bootstrap fail on download errors.

With /bin/sh, a failed curl can feed an empty script to sh, which exits successfully; the image then builds without Rust. Download first, then execute the installer.

Proposed fix
-RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
+RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs -o /tmp/rustup-init \
+    && sh /tmp/rustup-init -y --profile minimal \
+    && /home/sandbox/.cargo/bin/cargo --version \
+    && rm /tmp/rustup-init

As per path instructions, the Fail loud invariant rejects silent-failure patterns.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs -o /tmp/rustup-init \
&& sh /tmp/rustup-init -y --profile minimal \
&& /home/sandbox/.cargo/bin/cargo --version \
&& rm /tmp/rustup-init
🧰 Tools
🪛 Hadolint (2.14.0)

[warning] 57-57: Set the SHELL option -o pipefail before RUN with a pipe in it. If you are using /bin/sh in an alpine image or if your shell is symlinked to busybox then consider explicitly setting your SHELL to /bin/ash, or disable this check

(DL4006)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Dockerfile.process-sandbox` at line 57, Update the Rust bootstrap RUN command
in Dockerfile.process-sandbox to download the installer with curl into a
temporary file first, then execute that file with sh using the existing rustup
arguments. Preserve strict curl failure behavior and ensure the installer is
only run after a successful download.

Sources: Path instructions, Linters/SAST tools

ENV PATH="/home/sandbox/.cargo/bin:${PATH}"
USER root

WORKDIR /workspace
ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/process-sandbox-entrypoint"]
CMD ["bash", "-lc", "echo missing command >&2; exit 64"]
20 changes: 18 additions & 2 deletions crates/ironclaw_authorization/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ use ironclaw_host_api::{
AgentId, CapabilityDescriptor, CapabilityGrant, CapabilityGrantId, Decision, DenyReason,
EffectKind, ExecutionContext, HostApiError, InvocationFingerprint, MissionId, NetworkPolicy,
Obligation, Obligations, Principal, ProjectId, ResourceCeiling, ResourceEstimate,
ResourceScope, RuntimeCredentialRequirementSource, RuntimeKind, SandboxQuota, ScopedPath,
TenantId, ThreadId, UserId,
ResourceReservationId, ResourceScope, RuntimeCredentialRequirementSource, RuntimeKind,
SandboxQuota, ScopedPath, TenantId, ThreadId, UserId,
};
use ironclaw_trust::{AuthorityCeiling, TrustDecision};
use serde::{Deserialize, Serialize};
Expand Down Expand Up @@ -1074,6 +1074,22 @@ fn obligations_for_grant(
});
}

// Process-spawning capabilities must reserve resources before dispatch so a
// configured governor can enforce a ceiling on concurrent/aggregate spawns.
// NOTE: this is currently a no-op behaviorally — `reserve_resource_obligation`
// (ironclaw_host_runtime::obligations::BuiltinObligationHandler) only fails
// closed when no resource governor is configured, and production always
// constructs a governor with an unlimited default ceiling. Emitting the
// obligation here just wires the dispatch-time hook; an actual limit is set
// by a later slice. This applies to both TenantSandboxProcessPort and the
// unsandboxed local-dev HostProcessPort, since both share
// `EffectKind::SpawnProcess`.
if descriptor.effects.contains(&EffectKind::SpawnProcess) {
obligations.push(Obligation::ReserveResources {
reservation_id: ResourceReservationId::new(),
});
}

if !descriptor.runtime_credentials.is_empty() {
if !descriptor.effects.contains(&EffectKind::UseSecret) {
return None;
Expand Down
63 changes: 63 additions & 0 deletions crates/ironclaw_authorization/tests/capability_access_contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,69 @@ async fn capability_access_returns_grant_constraints_as_runtime_obligations() {
);
}

#[tokio::test]
async fn obligations_include_reserve_resources_for_spawn_process_capability() {
let descriptor = CapabilityDescriptor {
effects: vec![EffectKind::DispatchCapability, EffectKind::SpawnProcess],
..wasm_descriptor()
};
let grant = grant_for(
descriptor.id.clone(),
Principal::Extension(ExtensionId::new("caller").unwrap()),
vec![EffectKind::DispatchCapability, EffectKind::SpawnProcess],
);

let decision = GrantAuthorizer::new()
.authorize_dispatch(
&execution_context(CapabilitySet {
grants: vec![grant],
}),
&descriptor,
&ResourceEstimate::default(),
)
.await;

let Decision::Allow { obligations } = decision else {
panic!("expected allow decision with obligations, got {decision:?}");
};
assert!(
obligations
.as_slice()
.iter()
.any(|obligation| matches!(obligation, Obligation::ReserveResources { .. }))
);
}

#[tokio::test]
async fn obligations_omit_reserve_resources_for_non_spawn_process_capability() {
let descriptor = wasm_descriptor();
let grant = grant_for(
descriptor.id.clone(),
Principal::Extension(ExtensionId::new("caller").unwrap()),
vec![EffectKind::DispatchCapability],
);

let decision = GrantAuthorizer::new()
.authorize_dispatch(
&execution_context(CapabilitySet {
grants: vec![grant],
}),
&descriptor,
&ResourceEstimate::default(),
)
.await;

let Decision::Allow { obligations } = decision else {
panic!("expected allow decision with obligations, got {decision:?}");
};
assert!(
!obligations
.as_slice()
.iter()
.any(|obligation| matches!(obligation, Obligation::ReserveResources { .. }))
);
}

#[tokio::test]
async fn capability_access_allows_first_party_dynamic_secret_consumers_without_static_secret_grant()
{
Expand Down
5 changes: 5 additions & 0 deletions crates/ironclaw_host_api/src/path.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@ const VIRTUAL_ROOTS: &[&str] = &[
"/system/skills",
"/users",
"/projects",
// Sandboxed-profile boot-owner workspace mount (Task A8): the abstract-FS
// backend root the `HostedSingleTenantVolumeSandboxed` profile registers
// via `mount_sandbox_user_workspace_root`, redirecting the `/workspace`
// capability grant directly onto it instead of `/projects/workspace`.
"/workspace",
"/memory",
"/artifacts",
"/tmp",
Expand Down
1 change: 1 addition & 0 deletions crates/ironclaw_host_runtime/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ hex = "0.4"
ironclaw_approvals = { path = "../ironclaw_approvals" }
ironclaw_authorization = { path = "../ironclaw_authorization" }
ironclaw_capabilities = { path = "../ironclaw_capabilities" }
ironclaw_common = { path = "../ironclaw_common" }
ironclaw_process_sandbox = { path = "../ironclaw_process_sandbox" }
ironclaw_dispatcher = { path = "../ironclaw_dispatcher" }
ironclaw_events = { path = "../ironclaw_events" }
Expand Down
48 changes: 47 additions & 1 deletion crates/ironclaw_host_runtime/src/first_party_tools/schemas.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,9 @@ pub(crate) fn resolve_builtin_input_schema_ref(reference: &str) -> Option<Value>
"properties": {
"command": { "type": "string", "description": "Shell command to execute. Prefer ONE command that does the whole job: combine steps with '&&' or pipes, or write and run a single script (awk/python) — do NOT issue one command per metric/day/line, and don't re-read files you already have." },
"workdir": { "type": "string", "description": "Optional scoped working directory" },
"timeout": { "type": "integer", "minimum": 1, "description": "Timeout in seconds" }
"timeout": { "type": "integer", "minimum": 1, "description": "Timeout in seconds. Default 120, maximum 600 (values above are clamped)." },
"output_limit": { "type": "integer", "minimum": 1024, "description": "Maximum captured output (stdout+stderr) in bytes. Default 65536, maximum 1048576 (values above are clamped)." },
"background": { "type": "boolean", "description": "Run detached and return immediately with a pid and log path instead of waiting for completion. Default false. Use for long-running processes such as dev servers; the container survives after this call returns, and subsequent shell calls report still-live background processes in a footer." }
},
"required": ["command"],
"additionalProperties": false
Expand Down Expand Up @@ -855,6 +857,18 @@ fn response_body_limit_schema(require_save_to: bool) -> Value {
mod tests {
use super::resolve_builtin_input_schema_ref;

#[test]
fn shell_schema_documents_background_flag_default_and_capability() {
let schema = resolve_builtin_input_schema_ref("schemas/builtin/shell.input.v1.json")
.expect("shell schema is registered");
let description = schema["properties"]["background"]["description"]
.as_str()
.expect("background description is a string");
assert!(description.contains("false"));
assert!(description.contains("dev server") || description.contains("returns immediately"));
assert_eq!(schema["properties"]["background"]["type"], "boolean");
}

#[test]
fn trigger_create_prompt_description_warns_against_self_referential_creation_prompts() {
// Issue #5505 (generation-time defense): the model must write the
Expand Down Expand Up @@ -935,4 +949,36 @@ mod tests {
);
}
}

#[test]
fn shell_schema_timeout_and_output_limit_descriptions_state_default_and_max() {
// The schema is the model's contract: it must see the same
// default/ceiling numbers the process ports actually clamp to
// (`sandbox_process::shell_limits`), so a call that overshoots isn't
// a surprise.
let schema = resolve_builtin_input_schema_ref("schemas/builtin/shell.input.v1.json")
.expect("shell schema is registered");

let timeout_description = schema["properties"]["timeout"]["description"]
.as_str()
.expect("timeout description is a string");
assert!(
timeout_description.contains("120") && timeout_description.contains("600"),
"timeout description must state its default (120) and max (600): {timeout_description}"
);

let output_limit_description = schema["properties"]["output_limit"]["description"]
.as_str()
.expect("output_limit description is a string");
assert!(
output_limit_description.contains("65536")
&& output_limit_description.contains("1048576"),
"output_limit description must state its default (65536) and max (1048576): {output_limit_description}"
);
assert_eq!(
schema["properties"]["output_limit"]["minimum"].as_u64(),
Some(1024),
"output_limit schema floor must match SHELL_OUTPUT_LIMIT_MIN_BYTES"
);
}
}
Loading
Loading