Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
13 changes: 7 additions & 6 deletions docs/adr/0001-capability-tiered-agentic-execution-harness.md
Original file line number Diff line number Diff line change
Expand Up @@ -946,14 +946,15 @@ pass without executing code.
qualification-only. The disposable Windows sandbox qualification harness now
composes AppContainer/Job Object controls and a fixed decoder corpus. Storage-only
signed worker provenance now binds exactly one image-transform role to the fixed
worker entrypoint, but native launch/resource/broker gates remain blocked; collect
opt-in aggregate reliability metrics, never content, only after the provider is
sandbox-qualified.
worker entrypoint, and the disposable launcher now qualifies pre-resume Job Object
resource policy. Native worker launch and broker PID/token binding remain blocked;
collect opt-in aggregate reliability metrics, never content, only after the
provider is sandbox-qualified.

**Implementation gate:** parser, artifact-boundary, qualification-provider,
worker-provenance, and sandbox-qualification regression suites pass; no source
overwrite is possible. The qualification harness must remain fail-closed when the
signed worker is absent.
worker-provenance, sandbox-qualification, and native-launcher-policy regression
suites pass; no source overwrite is possible. The qualification harness must remain
fail-closed when the signed worker is absent.
**Release gate:** parser fuzzing, artifact security review, hostile decoder corpus
executed inside the worker, OS sandbox qualification, signed provenance, broker
identity, resource/watchdog accounting, external review, and fixed-function provider
Expand Down
11 changes: 9 additions & 2 deletions docs/adr/0001-phase2-evidence.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
| User-artifact copy-in, output validation, and publication | **Complete (boundary only)** | Explicit owner/turn grants, bounded stable snapshots, link/reparse/hardlink/sparse/ADS rejection, byte-derived MIME policy, exact output claims, quarantine, hash/size limits, atomic repository publication, rollback, and cleanup categories are covered by `tests/test_phase2_artifact_boundary.py`. |
| Fixed-function image provider core | **Complete (qualification-only)** | `RecipeImageProvider` validates allowlisted PNG/JPEG/WebP bytes, verifies/loads one frame with Pillow bomb/resource limits, applies only parsed steps, strips metadata, revalidates encoded output, checks cancellation, and remains disabled until external sandbox health passes. |
| Windows recipe sandbox qualification harness | **Complete (qualification harness; worker gate blocked)** | `recipe_sandbox_qualification.py` composes out-of-process AppContainer isolation and Job Object cancellation with a fixed decoder corpus, then fails closed because the signed `recipe_worker.exe` bundle and trust-root launch verification are not shipped. |
| Suspended native launcher/resource policy | **Complete (disposable control spike)** | `native_launcher_qualification.py` creates a fixed suspended AppContainer child, applies and queries active-process, kill-on-close, CPU-time, memory, and no-breakaway Job Object policy before resume; real worker enforcement and broker binding remain blocked. |
| OS sandbox provider and provider-produced image outputs | **Blocked / release gate** | The actual provider worker still needs signed provenance, LPAC/AppContainer policy, Job Object resource limits/accounting, broker PID/token binding, watchdog, hostile decoder execution inside the sandbox, external review, and lifecycle wiring. |

## Security invariants
Expand Down Expand Up @@ -80,6 +81,9 @@
22. Worker provenance is storage-only: only an installer-validated immutable
generation with one exact `image_transform`/`recipe_worker.exe` role and stable
byte identity can proceed to a future launcher; no executable is loaded here.
23. The disposable launcher applies all required Job Object policy before resume,
queries the configured limits/accounting, never grants breakaway, and reports
the absent worker/broker gates as blocking.

## Re-run target

Expand All @@ -92,7 +96,9 @@ python -m pytest tests/test_phase2_bundle_installer.py -q
python -m pytest tests/test_phase2_artifact_boundary.py -q
python -m pytest tests/test_phase2_recipe_provider.py -q
python -m pytest tests/test_phase2_worker_provenance.py -q
python -m pytest tests/test_native_launcher_qualification.py -q
python -m pytest tests/test_recipe_sandbox_qualification.py -q
python tools/execution_spikes/native_launcher_qualification.py
python tools/execution_spikes/recipe_sandbox_qualification.py --json --strict
python -m compileall -q backend\cortex_backend\execution tests
python -m pytest -q
Expand All @@ -105,10 +111,11 @@ npm.cmd test --prefix frontend -- --run

**Validation result (2026-07-21):** 16 Phase 2 contract tests, 9 signed-manifest tests,
7 broker-contract tests, 9 native-broker tests, 7 bundle-installer tests, 16
artifact-boundary tests, 17 recipe-provider tests, 6 worker-provenance tests, and
artifact-boundary tests, 17 recipe-provider tests, 6 worker-provenance tests, 4
native-launcher tests, and
5 sandbox-qualification tests
passed; the full Python suite
passed (215 tests total) with one
passed (219 tests total) with one
native-platform skip and one pre-existing `pytest-asyncio` deprecation warning.
Frontend lint, typecheck, production build, and all 39 frontend tests passed. Contract
generation, compileall, and `git diff --check` passed. No production execution
Expand Down
64 changes: 64 additions & 0 deletions docs/adr/0001-phase2-native-launcher.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# ADR-0001 Phase 2 disposable native launcher qualification

- **Status:** Suspended-launch and Job Object policy spike complete; real worker launch blocked
- **Phase:** 2 - fixed-function image provider
- **Parent:** [Capability-tiered agentic execution harness](0001-capability-tiered-agentic-execution-harness.md)
- **Depends on:** [Windows sandbox qualification](0001-phase2-sandbox-qualification.md) and [signed worker provenance](0001-phase2-worker-provenance.md)
- **Scope:** Qualify construction order and resource-policy application with fixed benign Windows executables only.

## Decision

`tools/execution_spikes/native_launcher_qualification.py` exercises the reviewed
native construction sequence without accepting a worker path or user input:

1. create a unique zero-capability AppContainer profile;
2. create a fixed `findstr.exe` child suspended with the AppContainer token;
3. assign it to a Job Object before resume;
4. apply kill-on-close, active-process, process/job CPU-time, and process/job
memory limits, with breakaway flags absent;
5. resume only after policy configuration;
6. query the Job Object policy/accounting and require every configured value; and
7. close all handles and remove the disposable profile/marker.

The lower-level `appcontainer_smoke._run_child()` now exposes bounded optional
resource-limit parameters and returns the queried policy/accounting. Existing
filesystem/network and cancellation probes continue to use the same helper and
remain green after this change.

This is qualification evidence for control application, not approval to launch a
recipe worker. The fixed probe reports `provider_launch_authorized=false` until
the signed worker package and broker identity gates pass.

## Evidence and limits

On the controlled Windows host (2026-07-22), the resource-policy check passed with:

- AppContainer token confirmed;
- active-process limit `1`;
- process memory `64 MiB`, job memory `128 MiB`;
- process CPU `2 s`, job CPU `4 s`;
- kill-on-close present; and
- breakaway flags absent.

The policy values are queried before the Job Object handle closes. This does not
yet prove enforcement against a real signed recipe worker, resource exhaustion
behavior across supported Windows versions, or external launcher review.

## Remaining blockers

- The fixed signed `recipe_worker.exe` package is not shipped at the immutable
installer generation used by the launcher.
- The native broker transport is not yet bound to the launched worker PID and OS
token by a single reviewed launcher transaction.
- Watchdog progress, output framing, staging ACLs, hostile decoder execution, and
lifecycle health-gated wiring remain separate release gates.

If any control is missing or cannot be verified, the provider remains unavailable;
there is no host-process or weaker-sandbox fallback.

## Verification

`tests/test_native_launcher_qualification.py` covers non-Windows blocking, report
fail-closed behavior, and the no-breakaway policy invariant. The full repository
suite and the existing AppContainer/cancellation corpus are required before this
spike can be merged.
1 change: 1 addition & 0 deletions docs/adr/0001-phase2-sandbox-qualification.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ fallback.
| --- | --- | --- |
| AppContainer token and zero-capability denials | `appcontainer_smoke.py`, child report `recipe_appcontainer_control` | Required prerequisite; does not prove LPAC policy or provider launch identity |
| Job Object kill-on-close and tree cancellation | `cancellation_corpus.py`, child report `recipe_cancellation_control` | Required prerequisite; resource limits and accounting remain open |
| Suspended launch/resource policy | `native_launcher_qualification.py`, child report `recipe_native_launcher_policy` | Policy application/query passes for a fixed benign child; real worker enforcement/review remains open |
| Decoder hostile corpus | Fixed one-pixel PNG, truncated PNG, and active SVG against the core | Qualification-only evidence; not OS-sandbox evidence |
| Signed worker provenance | Storage-only `verify_active_worker()` role binding plus fixed package precondition | **Storage gate complete; launch remains blocked** until a packaged executable and native launcher exist |
| Broker identity and framed IPC | Native broker transport tests and ADR | Must be bound to the actual worker PID/token before launch |
Expand Down
82 changes: 82 additions & 0 deletions tests/test_native_launcher_qualification.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"""Regression tests for the non-production suspended-launch qualification."""

from __future__ import annotations

import pytest

import tools.execution_spikes.appcontainer_smoke as native
import tools.execution_spikes.native_launcher_qualification as qualification


def test_non_windows_resource_probe_fails_closed(monkeypatch):
monkeypatch.setattr(qualification.sys, "platform", "linux")

result = qualification._probe_resource_policy()

assert result["status"] == "blocked"


def test_launcher_report_never_authorizes_when_worker_or_broker_is_blocked(monkeypatch):
monkeypatch.setattr(
qualification,
"_probe_resource_policy",
lambda: {"name": "native_launcher_resource_policy", "status": "pass"},
)
monkeypatch.setattr(
qualification,
"_probe_worker_package",
lambda: {"name": "native_launcher_worker_package", "status": "blocked"},
)
monkeypatch.setattr(
qualification,
"_probe_broker_binding",
lambda: {"name": "native_launcher_broker_binding", "status": "blocked"},
)

report = qualification.build_report()

assert report["qualification_status"] == "blocked"
assert report["provider_launch_authorized"] is False


def test_resource_policy_requires_no_breakaway_flag():
breakaway = (
qualification._JOB_OBJECT_LIMIT_BREAKAWAY_OK
| qualification._JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK
)
required = (
qualification._JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
| qualification._JOB_OBJECT_LIMIT_ACTIVE_PROCESS
| qualification._JOB_OBJECT_LIMIT_PROCESS_MEMORY
| qualification._JOB_OBJECT_LIMIT_JOB_MEMORY
| qualification._JOB_OBJECT_LIMIT_PROCESS_TIME
| qualification._JOB_OBJECT_LIMIT_JOB_TIME
)

assert required & breakaway == 0


def test_child_policy_limits_reject_unbounded_values_before_process_creation():
with pytest.raises(ValueError):
native._run_child(
None,
None,
None,
None,
None,
"fixed.exe",
[],
process_memory_limit_bytes=0,
)

with pytest.raises(ValueError):
native._run_child(
None,
None,
None,
None,
None,
"fixed.exe",
[],
active_process_limit=65,
)
5 changes: 5 additions & 0 deletions tests/test_recipe_sandbox_qualification.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,11 @@ def test_report_remains_blocked_when_worker_gate_is_blocked(monkeypatch):
"_probe_signed_worker_precondition",
lambda: {"name": "recipe_signed_worker_provenance", "status": "blocked"},
)
monkeypatch.setattr(
qualification,
"_probe_future_worker_controls",
lambda: [{"name": "recipe_native_launcher_policy", "status": "blocked"}],
)

report = qualification.build_report()

Expand Down
9 changes: 9 additions & 0 deletions tools/execution_spikes/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ does not accept model input and never falls back to host-process decoding:
```powershell
python tools/execution_spikes/recipe_sandbox_qualification.py --json
python tools/execution_spikes/recipe_sandbox_qualification.py --json --strict
python tools/execution_spikes/native_launcher_qualification.py
```

The expected result at this stage is `qualification_status=blocked`: the native
Expand All @@ -42,6 +43,11 @@ bundle is not shipped yet. A blocked worker-provenance check is intentional and
must remain blocking until trust-root verification, native worker identity, and
resource/watchdog enforcement are implemented.

The native launcher qualification prints a passing resource-policy subcheck when
the fixed suspended child receives and reports Job Object CPU/memory/active-process
limits with no breakaway flags. Its overall exit remains blocked until the signed
worker package and broker PID/token binding exist.

## What the probes prove

- `environment`: supported Windows host and interpreter metadata.
Expand Down Expand Up @@ -69,6 +75,9 @@ resource/watchdog enforcement are implemented.
- `recipe_sandbox_qualification`: composes the native isolation/cancellation
controls with the qualification-only decoder corpus and a mandatory signed
worker provenance gate; it never authorizes a host-process fallback.
- `native_launcher_qualification`: creates only a fixed suspended `findstr.exe`
child, applies and queries Job Object resource policy before resume, and reports
the signed-worker and broker-binding blockers without launching either.
- `security_review`: records the conditional Phase 0 spike review and residual
blockers.
- `pyinstaller_package_preconditions`: all currently known one-folder package
Expand Down
72 changes: 72 additions & 0 deletions tools/execution_spikes/appcontainer_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,11 @@
_CREATE_NO_WINDOW = 0x08000000
_PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES = 0x00020009
_JOB_OBJECT_EXTENDED_LIMIT_INFORMATION = 9
_JOB_OBJECT_LIMIT_PROCESS_TIME = 0x00000002
_JOB_OBJECT_LIMIT_JOB_TIME = 0x00000004
_JOB_OBJECT_LIMIT_ACTIVE_PROCESS = 0x00000008
_JOB_OBJECT_LIMIT_PROCESS_MEMORY = 0x00000100
_JOB_OBJECT_LIMIT_JOB_MEMORY = 0x00000200
_JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000
_TOKEN_IS_APPCONTAINER = 29
_WAIT_OBJECT_0 = 0
Expand Down Expand Up @@ -270,7 +274,21 @@ def _run_child(
arguments: list[str],
timeout_ms: int = 5000,
active_process_limit: int = 1,
process_memory_limit_bytes: int | None = None,
job_memory_limit_bytes: int | None = None,
process_user_time_100ns: int | None = None,
job_user_time_100ns: int | None = None,
) -> dict[str, Any]:
for name, value in (
("process_memory_limit_bytes", process_memory_limit_bytes),
("job_memory_limit_bytes", job_memory_limit_bytes),
("process_user_time_100ns", process_user_time_100ns),
("job_user_time_100ns", job_user_time_100ns),
):
if value is not None and (type(value) is not int or not 1 <= value <= 1024**3):
raise ValueError(f"{name} is outside the disposable probe bounds")
if type(active_process_limit) is not int or not 1 <= active_process_limit <= 64:
raise ValueError("active_process_limit is outside the disposable probe bounds")
stage = "start"
command_line = subprocess.list2cmdline([executable, *arguments])
command_buffer = ctypes.create_unicode_buffer(command_line)
Expand Down Expand Up @@ -343,6 +361,20 @@ def _run_child(
_JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | _JOB_OBJECT_LIMIT_ACTIVE_PROCESS
)
limits.basic_limit_information.active_process_limit = active_process_limit
if process_memory_limit_bytes is not None:
limits.basic_limit_information.limit_flags |= _JOB_OBJECT_LIMIT_PROCESS_MEMORY
limits.process_memory_limit = process_memory_limit_bytes
if job_memory_limit_bytes is not None:
limits.basic_limit_information.limit_flags |= _JOB_OBJECT_LIMIT_JOB_MEMORY
limits.job_memory_limit = job_memory_limit_bytes
if process_user_time_100ns is not None:
limits.basic_limit_information.limit_flags |= _JOB_OBJECT_LIMIT_PROCESS_TIME
limits.basic_limit_information.per_process_user_time.quad_part = (
process_user_time_100ns
)
if job_user_time_100ns is not None:
limits.basic_limit_information.limit_flags |= _JOB_OBJECT_LIMIT_JOB_TIME
limits.basic_limit_information.per_job_user_time.quad_part = job_user_time_100ns
stage = "job-configure"
if not kernel32.SetInformationJobObject(
job,
Expand All @@ -360,6 +392,7 @@ def _run_child(
stage = "wait"
wait_result = kernel32.WaitForSingleObject(process_info.process, timeout_ms)
timed_out = wait_result == _WAIT_TIMEOUT
job_information = _job_extended_info(kernel32, job)
job_process_ids: list[int] = []
all_job_processes_reaped: bool | None = None
reap_seconds: float | None = None
Expand All @@ -383,6 +416,7 @@ def _run_child(
"job_process_ids": job_process_ids,
"all_job_processes_reaped": all_job_processes_reaped,
"reap_seconds": reap_seconds,
"job_information": job_information,
}
except Exception as exc:
raise RuntimeError(f"{stage}: {exc}") from exc
Expand Down Expand Up @@ -425,6 +459,44 @@ def _job_process_ids(kernel32: Any, job: wintypes.HANDLE) -> list[int]:
return [int(ids[index]) for index in range(count)]


def _job_extended_info(kernel32: Any, job: wintypes.HANDLE) -> dict[str, int]:
"""Read bounded Job Object policy/accounting before the handle is closed."""

query = kernel32.QueryInformationJobObject
query.argtypes = [
wintypes.HANDLE,
wintypes.INT,
wintypes.LPVOID,
wintypes.DWORD,
ctypes.POINTER(wintypes.DWORD),
]
query.restype = wintypes.BOOL
info = _ExtendedLimitInformation()
returned = wintypes.DWORD()
if not query(
job,
_JOB_OBJECT_EXTENDED_LIMIT_INFORMATION,
ctypes.byref(info),
ctypes.sizeof(info),
ctypes.byref(returned),
):
raise ctypes.WinError(ctypes.get_last_error())
return {
"limit_flags": int(info.basic_limit_information.limit_flags),
"active_process_limit": int(info.basic_limit_information.active_process_limit),
"process_memory_limit": int(info.process_memory_limit),
"job_memory_limit": int(info.job_memory_limit),
"process_user_time_limit_100ns": int(
info.basic_limit_information.per_process_user_time.quad_part
),
"job_user_time_limit_100ns": int(
info.basic_limit_information.per_job_user_time.quad_part
),
"peak_process_memory_used": int(info.peak_process_memory_used),
"peak_job_memory_used": int(info.peak_job_memory_used),
}


def _all_processes_reaped(kernel32: Any, process_ids: list[int]) -> bool:
"""Confirm every process observed in the Job is terminated after close."""

Expand Down
Loading
Loading