Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
8fb21f3
feat: scope output_budget_guard to Codex-only + typed provenance (#42…
Trecek Jul 18, 2026
ce6ba21
feat: lossless capture promotion for run_cmd (#4286 Phase B infrastru…
Trecek Jul 18, 2026
fa5306c
feat: complete Phase B — lossless capture promotion + test fixes (#4286)
Trecek Jul 18, 2026
0062b7a
feat: complete Phase C — Codex lossless shell capture + retirement (#…
Trecek Jul 18, 2026
b7f17c6
test: add audit-required error-path and conformance tests (#4286)
Trecek Jul 18, 2026
6249a73
fix(review): use expansion-safe quoting for shell capture marker vari…
Trecek Jul 18, 2026
124732c
fix(review): wrap NamedTemporaryFile in CaptureSetupError handler
Trecek Jul 18, 2026
a0829ef
fix(review): validate JSON payload is a dict before accessing attributes
Trecek Jul 18, 2026
54fb9b1
fix(review): close directory fd after fsync to prevent fd leak
Trecek Jul 18, 2026
05611a4
fix(review): re-export capture symbols at execution package boundary
Trecek Jul 18, 2026
1b80bf0
fix(review): clean up orphaned stream file on CaptureReadError
Trecek Jul 18, 2026
2b6706a
fix(review): add behavioral tests for run_managed_sync capture_dir
Trecek Jul 18, 2026
56e8116
fix(review): add interleaving test case with merged-descriptor oracle
Trecek Jul 18, 2026
993b2a7
fix(review): add real-file summarize_capture test for no-materialization
Trecek Jul 18, 2026
2244d6b
fix(review): update marker safety test for expansion-safe quoting
Trecek Jul 18, 2026
6443bad
fix(review): move stdlib imports to module scope, handle os.replace f…
Trecek Jul 18, 2026
5104f31
fix(review): use current hook_id in policy_event tests
Trecek Jul 18, 2026
fbb79bd
fix(review): assert failed-stream orphan cleanup in partial capture test
Trecek Jul 18, 2026
6980735
fix(review): move _summarize_streams to _execution_helpers to fix lin…
Trecek Jul 18, 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
31 changes: 18 additions & 13 deletions docs/decisions/0005-output-budget-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,12 @@ containment; downstream layers are independent backstops rather than substitutes
handler representation and uses routing- and shape-preserving projection. This is
producer-blind, post-execution enforcement. A response that cannot be persisted or
projected safely fails closed without returning the original payload.
3. **Pre-spend command guard:** enumerated high-confidence unbounded shell shapes are
denied before execution and receive a bounded-rewrite instruction. This is static,
pre-execution enforcement for rules R1 through R3.
3. **Pre-spend command guard:** *Retired by ADR-0006 (#4286).* The command-shape
classifier and its `output_budget_guard` are deleted. Codex native shell is now
bounded by a PreToolUse input-rewrite hook (`shell_capture_hook`) that captures
complete output to a mechanism-owned artifact and emits only a bounded inline
slice. The transport ceiling (CODEX_TOOL_OUTPUT_TOKEN_LIMIT) remains as the
backstop for hook-failure paths.*
4. **Producer-aware discipline and derived transport ceiling:** one evidence-output
policy is delivered on backend surfaces that support it, while Codex's stored
tool/function output receives a derived damage ceiling. The policy is advisory;
Expand All @@ -61,11 +64,12 @@ an exemption requires re-measurement and a deliberate registry-digest change.
| `response_max_bytes = 90_000` | Bound the exact compact serialized handler payload before a coarser transport can clip it. Bytes are authoritative here; this is not a token or full JSON-RPC-envelope estimate. |
| `MAX_MCP_OUTPUT_TOKENS = 50_000` | Keep Claude's independently defined setting separate. It has no shared source of truth with `CODEX_TOOL_OUTPUT_TOKEN_LIMIT` and does not control Claude Code's observed disk-persistence gate. Claude's native Bash spill behavior covers shell output on that backend. |

The command guard also reads `small_file_max_bytes = 5_000` for its narrow literal-JSONL
exception and accepts proven byte sinks only through `shell_max_inline_bytes = 12_000`.
Those are classification bounds, not transport limits. Parser limits of 65,536 command
characters and nesting depth 8 bound classification work; exceeding them produces an
unknown disposition and cannot authorize a risky R1-R3 command.
The shell capture hook uses `shell_max_inline_bytes = 12_000` as the inline threshold:
commands whose combined output fits within that budget are inlined in full (artifact
deleted); larger outputs are captured losslessly to a mechanism-owned artifact with a
bounded head/tail slice and provenance marker inlined.
`small_file_max_bytes` was removed — it existed solely for the classifier's
literal-small-JSONL exception, which is no longer needed.

## Ceiling and Backstop Reconciliation

Expand Down Expand Up @@ -121,9 +125,9 @@ recorded explicitly in the issue body.

## Accepted Gaps

1. Non-JSONL single-file searches and shell verbs outside R1-R3, including `python -c`
file dumps, `git log -p`, and `curl`, are bounded on Codex only by the derived ceiling.
Codex may still truncate and discard their excess output.
1. Non-JSONL single-file searches and arbitrary shell verbs (`python -c` file dumps,
`git log -p`, `curl`) are now captured losslessly on Codex by the shell capture hook
(#4286 / ADR-0006). The transport ceiling remains the backstop for hook-failure paths.
2. The interactive discipline digest does not survive Codex `resume` and cannot
guarantee post-compaction reinjection. Those paths retain the command guard, transport
ceilings, and file-materialized skill content.
Expand All @@ -138,8 +142,9 @@ recorded explicitly in the issue body.
members must retrieve bounded slices from that artifact.
7. Repeated individually bounded calls can still exhaust cumulative context. No pre-call
component owns current context usage, so reserve instructions remain advisory.
8. Subprocess output is still materialized in worker memory before model-context shaping.
The protocol does not bound worker memory or temporary-disk consumption.
8. Closed for the `run_cmd` channel by #4286 (capture files promoted in place; only
bounded slices enter worker memory). Still open for `run_skill` and `test_check`,
whose adjudication requires the full text.

## Operational Signals

Expand Down
92 changes: 92 additions & 0 deletions docs/decisions/0006-output-containment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# ADR-0006: Output-Boundary Containment

**Status:** Accepted
**Date:** 2026-07-18
**Issue:** #4286

## Context

ADR-0005 (Layer 3) established a pre-execution command-shape classifier
(`output_budget_guard`) to deny unbounded shell commands before they run. The
classifier's failure mode is structural: static pre-execution boundedness proof
over arbitrary shell is unwinnable — every enumerated shape can be expressed in
an equivalent, un-enumerable form. Enforcement must move to the output boundary:
bound what actually enters model context (measured bytes, lossless spill to
durable artifacts, bounded inline slices), per backend.

## Decision

Retire the pre-execution command-shape classifier and replace it with per-backend
output-boundary bounding on measured bytes:

1. **Claude Code native shell** — already bounded by the harness's native Bash
spill mechanism (no AutoSkillit surface needed).
2. **MCP `run_cmd` channel** — lossless capture-file promotion: subprocess output
goes to artifact-directory files; only bounded slices enter worker memory;
oversized outputs are promoted in place with a contract (bytes, sha256,
completeness).
3. **Codex native shell** — PreToolUse input-rewrite hook wraps every shell
command in a capture harness: complete output to a mechanism-owned artifact,
bounded inline slice with provenance marker, exit code preserved. The
transport ceiling (CODEX_TOOL_OUTPUT_TOKEN_LIMIT) remains as the backstop for
hook-failure paths.

### Sequencing Rule

The guard is retired on Codex only in the same change that delivers the Codex
lossless mechanism. Claude Code relief is immediate (Phase A).

### Provenance Rule

Every residual hook message uses a typed policy event rendered by a shared
formatter. Suggested rewrites are classifier-validated before emission.

### Pre-Spend Decision

No shape-based pre-execution backstop remains in the end state. Execution cost is
accepted — bounded by tool timeouts — because context cost is what this mechanism
exists to bound. Catastrophic side-effect prevention belongs to `write_guard` and
the Codex sandbox, not to output budgeting.

### Unified Exec Assumption

The hook contract for `exec_command` is identical (tool `"Bash"`, string `command`),
so the rewrite applies there too. AutoSkillit does not enable Codex's experimental
`unified_exec` surface in the config it writes; interactive stdin-driven sessions are
the only case where file-redirected output would change observable behavior.

### Future Direction

Route (c) — upstream pre-truncation integration — is the ideal end state but outside
this repo's control. If Codex exposes a pre-truncation hook point, the shell capture
hook can be retired in favor of that mechanism.

## Accepted Gaps

1. `disown`ed/job-table-detached children are outside the `wait` drain guarantee.
Their post-exit writes into the artifact are best-effort — mirrors native
detached-output semantics.
2. Fatal self-signals (e.g. `kill -TERM $$`) skip the slice-emission postlude.
Exit-code parity holds, the artifact persists as lossless evidence, but no inline
slice is emitted and the file is not deleted. SIGKILL is untrappable, so full
closure is impossible.
3. Head/tail slices are byte-cut and may split multibyte UTF-8 at slice edges. The
artifact is authoritative.
4. Draining non-detached background jobs makes the harness synchronous for their
duration, bounded by the tool timeout. `disown` is the fire-and-forget escape.
5. A bare trailing backslash at EOF loses its literal backslash from output under
continuation semantics. Exit code is preserved.
6. Vendored-tree version discrepancy: the checkout tag is `rust-v0.143.0-alpha.10`
vs the 0.144.1 description in the issue/ADR. The hook contract must be re-verified
against the deployed Codex version before shipping.

## Consequences

- Claude Code sessions no longer see AutoSkillit shell deny or rewrite surfaces.
- The classification engine (`classify_command_output_budget` and supporting
functions) is deleted; shared tokenization utilities are preserved.
- Configuration surface reduces: `small_file_max_bytes` is removed (existed solely
for the classifier's literal-small-JSONL exception).
- `shell_max_inline_bytes` survives with its new capture-threshold meaning.
- Complete Codex shell output is captured to mechanism-owned artifacts at
`<cwd>/.autoskillit/temp/shell_capture/shell_<uuid8>.log`.
1 change: 1 addition & 0 deletions docs/decisions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@
- [0003-skill-args.md](0003-skill-args.md) — Pass skill inputs as positional arguments, not environment variables
- [0004-recipe-redelivery.md](0004-recipe-redelivery.md) — Sanctioned `load_recipe` channel for recipe knowledge re-delivery after Codex context compaction
- [0005-output-budget-protocol.md](0005-output-budget-protocol.md) — Bound per-response model-context output with lossless artifacts, pre-spend guards, and derived transport ceilings
- [0006-output-containment.md](0006-output-containment.md) — Retire pre-execution command-shape classification in favor of per-backend output-boundary bounding on measured bytes
20 changes: 11 additions & 9 deletions docs/safety/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,15 +84,17 @@ and nested-shell forms. Session scope: headless only. Orchestrator sessions
are exempt. Per-subcommand allow-overrides are available via `git_ops_policy`
in `.hook_config.json` for future recipes that legitimately need these operations.

### `output_budget_guard.py`
**Guarded tools:** `Bash`, `run_cmd`
Denies high-confidence unbounded recursive searches, JSONL reads, and `find`
operations before execution. Commands can prove bounded output by routing both
stdout and stderr through a same-pipeline byte cap, redirecting both descriptors
under `.autoskillit/temp/`, or using a narrowly defined output-free/small-file
exception. The guard applies to interactive and headless sessions. Set
`output_budget.guard_enabled: false` to disable it; byte caps are constrained by
`output_budget.shell_max_inline_bytes`.
### `shell_capture_hook.py`
**Matched tool:** `Bash`
**Scope:** Codex sessions only (#4286 / ADR-0006); Claude Code is unaffected.
PreToolUse input-rewrite hook that wraps every native shell command on Codex in a
capture harness. The original command runs unmodified in a subshell; its complete
combined stdout+stderr goes to a mechanism-owned artifact at
`<cwd>/.autoskillit/temp/shell_capture/shell_<uuid8>.log`. Only a bounded inline
slice enters context: full content when small (artifact deleted), else head +
provenance marker (bytes, sha256, path) + tail. Exit codes are preserved via a
trap-EXIT postlude. Set `output_budget.guard_enabled: false` to disable;
inline threshold controlled by `output_budget.shell_max_inline_bytes`.

### `generated_file_write_guard.py`
**Guarded tools:** `Write`, `Edit`
Expand Down
1 change: 0 additions & 1 deletion src/autoskillit/config/_config_dataclasses.py
Original file line number Diff line number Diff line change
Expand Up @@ -355,7 +355,6 @@ class OutputBudgetConfig:
tail_chars: int = 2500
response_max_bytes: int = 90_000
guard_enabled: bool = True
small_file_max_bytes: int = 5000
shell_max_inline_bytes: int = 12_000

def __post_init__(self) -> None:
Expand Down
1 change: 0 additions & 1 deletion src/autoskillit/config/defaults.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,6 @@ output_budget:
tail_chars: 2500
response_max_bytes: 90000
guard_enabled: true
small_file_max_bytes: 5000
shell_max_inline_bytes: 12000

branching:
Expand Down
1 change: 1 addition & 0 deletions src/autoskillit/core/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,7 @@ from .types import CampaignProtector as CampaignProtector
from .types import CanonicalTokenUsage as CanonicalTokenUsage
from .types import CapabilityNotSupportedError as CapabilityNotSupportedError
from .types import CapabilityResolutionDetail as CapabilityResolutionDetail
from .types import CapturedStream as CapturedStream
from .types import CaptureEntrySpec as CaptureEntrySpec
from .types import CaptureValueType as CaptureValueType
from .types import CaptureValueTypeError as CaptureValueTypeError
Expand Down
21 changes: 20 additions & 1 deletion src/autoskillit/core/types/_type_results.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"LoadReport",
"LoadResult",
"ModelIdentity",
"CapturedStream",
"SpilledOutput",
"SpillSpec",
"TestResult",
Expand Down Expand Up @@ -180,7 +181,12 @@ class WriteBehaviorSpec:

@dataclass(frozen=True, slots=True)
class SpillSpec:
"""Character budgets for lossless artifact-backed output previews."""
"""Character budgets for lossless artifact-backed output previews.

Dual denomination: ``spill_output`` uses ``inline_max_chars`` as a character
threshold; ``summarize_capture`` uses it as a byte threshold (identical for
ASCII; at most more conservative for multibyte).
"""

inline_max_chars: int = 5000
head_chars: int = 2500
Expand All @@ -191,6 +197,19 @@ def __post_init__(self) -> None:
raise ValueError("spill character budgets must be non-negative")


@dataclass(frozen=True, slots=True)
class CapturedStream:
"""Streaming capture result — bounded slices only, never a full read."""

path: Path
total_bytes: int
sha256: str
inline_text: str | None
head: str
tail: str
complete: bool


@dataclass(frozen=True, slots=True)
class SpilledOutput:
"""Lossless spill result with a bounded inline representation."""
Expand Down
3 changes: 3 additions & 0 deletions src/autoskillit/core/types/_type_subprocess.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,8 @@ class SubprocessResult:
``SubprocessRunner.__call__`` and the callback completes before process exit.
Consumed downstream to annotate termination provenance.
"""
stdout_path: Path | None = None
stderr_path: Path | None = None


@runtime_checkable
Expand Down Expand Up @@ -177,4 +179,5 @@ def __call__(
workload_basenames: frozenset[str] | None = None,
on_session_id_resolved: Callable[[str], None] | None = None,
child_deferral_ceiling: float = 0.0,
capture_dir: Path | None = None,
) -> Awaitable[SubprocessResult]: ...
6 changes: 6 additions & 0 deletions src/autoskillit/execution/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,12 +82,15 @@
partition_files_by_domain,
)
from autoskillit.execution.process import (
CaptureReadError,
CaptureSetupError,
DefaultSubprocessRunner,
_has_active_execution_marker, # noqa: F401 — re-exported for cli/app.py signal guard
async_kill_process_tree,
kill_process_tree,
run_managed_async,
run_managed_sync,
summarize_capture,
)
from autoskillit.execution.quota import (
QUOTA_CACHE_SCHEMA_VERSION,
Expand Down Expand Up @@ -146,9 +149,12 @@
"CmdSpec",
"ClaudeHeadlessCmd",
# process
"CaptureReadError",
"CaptureSetupError",
"DefaultSubprocessRunner",
"run_managed_async",
"run_managed_sync",
"summarize_capture",
# recording
"RecordingSubprocessRunner",
"ReplayingSubprocessRunner",
Expand Down
Loading
Loading