Skip to content

v0.9 sub-issue #4: Stage 3 Assemble (graded sandbox: built_in + user_installed subprocess + capability_bound audit) #123

@devin-ai-integration

Description

@devin-ai-integration

v0.9 Sub-issue #4 — Stage 3 Assemble

Part of v0.9 epic.

Implements v0.8 Part B Stage 3 (Assemble): instantiate each resolved
Provider in its declared sandbox class with hard_constraints injected,
emit capability_bound audit events, and produce an "assembled mount" that
Stage 4 (Run) can drive.

Spec ref

  • docs/LIFE_RUNTIME_STANDARD.md §2.6–§2.8 (asset handling, personality
    load, bind runtime obligations)
  • docs/LIFE_RUNTIME_STANDARD.md Part B §B.4 (sandbox classes)
  • docs/LIFE_RUNTIME_STANDARD.md Part B §B.4.1 (bundled_in_life refusal)
  • docs/LIFE_RUNTIME_STANDARD.md Part B §B.7 (capability_bound audit)
  • docs/LIFE_BINDING_SPEC.md §6 (hard_constraints, fail-close on missing)
  • docs/LIFE_BINDING_SPEC.md §8 (surface.ui_hints.disclosure_label)

Sandbox classes

Class Implementation
built_in Provider runs in the same Python process as lifectl. Direct method calls on the LifeCapabilityProvider instance. No subprocess.
user_installed Provider runs in a separate OS subprocess. IPC over stdin/stdout JSON-RPC 2.0 (one request, one response, line-delimited). The subprocess is launched as a Python child running a runtime.assemble.subprocess_host shim that imports the user-installed Provider package, hands stdin lines to invoke(), writes responses to stdout. SIGTERM on teardown; SIGKILL after 5s. The shim mode satisfies the §B.4 "separate OS process with IPC" minimum boundary. Stricter sandboxing (firejail / nsjail / seccomp / wasm) is RECOMMENDED future work, NOT required at v0.9.
bundled_in_life REJECTED at Stage 3 even if it slipped past Stage 2. Defence-in-depth per §B.4.1.

Assemble flow per capability

Given a ProviderRef from Stage 2:

  1. Read the binding/runtime_binding.json::capability_binding[capability]
    entry: asset_paths[], params{}, hard_constraints{}.
  2. Resolve asset_paths[] to absolute paths inside the mounted zip.
    For pointer-mode .life, resolve the pointer target with offline-first
    semantics (read from local cache; warn but DO NOT auto-fetch).
  3. Inject hard_constraints keys into the Provider's initialize() call.
    Per binding spec §6, missing-constraint = fail-close.
  4. If sandbox_class == built_in: import + instantiate the Provider in-
    process; call initialize(asset_paths, params, hard_constraints).
  5. If sandbox_class == user_installed: spawn the subprocess host, hand
    it the Provider import path + the same args; await initialize_ack.
  6. If sandbox_class == bundled_in_life: refuse fail-close.
  7. After successful initialize, register the Provider's invoke
    handle in the runtime's capability table.
  8. Inject surface.ui_hints.disclosure_label (binding spec §8) into
    the runtime's user-surface controller (will be consumed by Stage 4 Run).
  9. Emit capability_bound{capability, provider_name, provider_version, sandbox_class}.

After all capabilities bound: emit mount_succeeded{package_id, capabilities_bound[]}.
This single event marks the runtime as "live" — Stage 5 watchers may
begin polling immediately after this event.

Module layout

runtime/assemble/
├── __init__.py            # exports assemble(verify_result, resolve_result) -> AssembleResult
├── _hard_constraints.py   # inject + fail-close-on-missing
├── _disclosure.py         # ui_hints injection
├── builtin_host.py        # in-process Provider instantiation
├── subprocess_host.py     # user_installed subprocess + JSON-RPC shim
└── audit.py               # capability_bound + mount_succeeded

AssembleResult dataclass:

@dataclass
class AssembleResult:
    capability_table: dict[str, BoundCapability]   # capability → live invoke handle
    disclosure_label: str
    forbidden_uses: dict                            # passthrough to Stage 4
    hosted_api_preference: dict                     # passthrough to Stage 4
    sandbox_subprocesses: list[Popen]               # for Stage 5 teardown

Audit events emitted

  • capability_bound{capability, provider_name, provider_version, sandbox_class} — once per capability.
  • mount_succeeded{package_id, capabilities_bound[]} — once at end of Stage 3.
  • assembly_aborted{stage: "assemble", reason} — fail-close on hard_constraint
    miss / sandbox spawn error / bundled refusal / initialize exception.

CLI surface

lifectl run <pkg.life> after this PR: runs Stages 1–3; on PASS prints
the bound capability table + disclosure label. Stages 4–5 still TODO.

Stage 1 Verify   ✓
Stage 2 Resolve  ✓  3 caps resolved
Stage 3 Assemble ✓  3 caps bound (1 built_in, 2 user_installed subprocess)
                    disclosure_label="(AI digital life instance of …)"
Stage 4+ pending sub-issues 5-7

Tests

tools/test_runtime_assemble.py:

  1. Built-in echo path: registry has only an in-process echo Provider
    for text_chat → assemble succeeds, capability_table populated,
    capability_bound + mount_succeeded emitted.
  2. Hard-constraint miss: binding declares
    hard_constraints.max_response_length: 500; Provider's
    initialize() does not accept it → fail-close.
  3. Sandbox subprocess happy path: dummy user_installed Provider
    shipped under tests/fixtures/dummy_provider/ exposed via JSON-RPC
    shim; assemble spawns subprocess + initialize succeeds.
  4. Sandbox subprocess crash: Provider raises in initialize → fail-close
    assembly_aborted{stage: "assemble"} emitted; subprocess reaped (no zombie).
  5. bundled_in_life sneak path: registry returns a Provider with
    sandbox_class == "bundled_in_life" (bypassing Stage 2 filter via test
    harness) → Stage 3 still rejects.
  6. Disclosure label injection: assemble result includes the binding's
    surface.ui_hints.disclosure_label verbatim.

Acceptance

  • In-process built_in host implemented
  • Subprocess host with JSON-RPC IPC implemented
  • Hard-constraint injection + fail-close
  • All 6 test cases pass
  • No subprocess zombies after teardown
  • CI runtime-assemble job green

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions