Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
79fc1e2
feat: add descriptor-anchored capture authority
Trecek Jul 23, 2026
94ea1c5
fix: anchor shell capture to verified descriptors
Trecek Jul 23, 2026
326b94f
fix: complete shell capture trust-anchor remediation
Trecek Jul 23, 2026
4e82f53
fix: harden shell capture runtime failures
Trecek Jul 23, 2026
f3b5a05
fix: address shell capture trust-anchor audit
Trecek Jul 23, 2026
0045e95
fix: close remaining shell capture audit gaps
Trecek Jul 23, 2026
48cfb77
fix: scope shell capture provenance assertion
Trecek Jul 23, 2026
c6244be
fix(review): document output budget bridge names
Trecek Jul 23, 2026
a43f052
fix(review): validate capture directory ownership and modes
Trecek Jul 23, 2026
7644b44
fix(review): encode capture marker paths
Trecek Jul 23, 2026
e238867
fix(review): bound failed capture process shutdown
Trecek Jul 23, 2026
dbbbe5d
fix(review): preserve capture failure context
Trecek Jul 23, 2026
68deb53
fix(review): rename stale capture classifier
Trecek Jul 23, 2026
b4305b1
fix(review): centralize capture transport contract
Trecek Jul 23, 2026
761b132
fix(review): keep wrapped commands policy visible
Trecek Jul 23, 2026
1199bb7
fix(review): remove self-referential cleanup debt assertion
Trecek Jul 23, 2026
ed5fb6b
fix(review): validate hook strength matrix after xdist probes
Trecek Jul 23, 2026
bf624ae
fix(review): bound generated wrapper subprocesses
Trecek Jul 23, 2026
16d9808
fix(review): assert stale filename classifier decisions
Trecek Jul 23, 2026
606522c
fix(review): assert stale capture wall-clock threshold
Trecek Jul 23, 2026
a3c99bc
fix(review): bound shell capture hook subprocesses
Trecek Jul 23, 2026
f55812d
fix: align tests with capture review changes
Trecek Jul 23, 2026
6568403
fix: update remaining policy visibility assertions
Trecek Jul 23, 2026
dc07baa
fix(review): make capture authorities factory-only
Trecek Jul 23, 2026
c382f44
fix(review): preserve native bash command name
Trecek Jul 23, 2026
de7d8e0
fix(review): verify captured artifact bytes before publication
Trecek Jul 23, 2026
9c5ebf4
fix(review): fail successful commands on capture integrity errors
Trecek Jul 23, 2026
a9e01aa
fix(review): enforce capture trust guards with AST analysis
Trecek Jul 23, 2026
f7ef691
fix(review): cover capture policy clamp boundaries
Trecek Jul 23, 2026
7b8321b
fix(review): link shell capture retention debt
Trecek Jul 23, 2026
3b9e1f0
fix(review): assert generated hook exits cleanly
Trecek Jul 23, 2026
c5a2503
fix(review): exercise complete exec footprint limit
Trecek Jul 23, 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
6 changes: 4 additions & 2 deletions docs/decisions/0005-output-budget-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,10 @@ the selected decision without applying a second static shaping pass.

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.
retained); larger outputs are captured losslessly to the same descriptor-owned artifact
with a bounded head/tail slice and provenance marker inlined. SessionStart classifies
stale artifacts but conservatively retains them on portable Python because pathname
unlink cannot condition deletion on the validated inode.
`small_file_max_bytes` was removed — it existed solely for the classifier's
literal-small-JSONL exception, which is no longer needed.

Expand Down
29 changes: 22 additions & 7 deletions docs/decisions/0006-output-containment.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,11 @@ output-boundary bounding on measured bytes:
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
command in a minimal isolated runner invocation. The runner opens `cwd` first,
establishes descriptor-relative authority for policy and capture components,
creates the artifact exclusively without following symlinks, and drains child
output through its owned fd. The bounded inline slice includes a provenance
marker whose path is present only after marker-time identity verification. The
ordinary outer-result limit remains the backstop for hook-failure paths. The
separately configured `CODEX_HISTORY_RETENTION_TOKEN_LIMIT` governs later history.

Expand Down Expand Up @@ -66,10 +69,9 @@ hook can be retired in favor of that mechanism.
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.
2. Fatal self-signals (e.g. `kill -TERM $$`) are reported as the shell-compatible
`128 + signal` status by the runner. Output drained before termination remains
available inline and in the retained artifact. SIGKILL remains untrappable.
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
Expand All @@ -79,6 +81,18 @@ hook can be retired in favor of that mechanism.
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.
7. A supplied symlink spelling of `cwd` is accepted only by opening it first as
the `ProjectAnchor`; `.autoskillit`, `temp`, and `shell_capture` symlinks are
rejected. Physical path strings are display hints, not filesystem authority.
8. Same-user code inside the command can rename a verified directory entry after
it is opened. Output, hashing, and replay remain fd-bound; if the live pathname
chain no longer matches, the marker reports its path as `unavailable`.
9. Portable Linux/macOS Python exposes descriptor-relative unlink but no
expected-inode conditional unlink. SessionStart therefore retains stale
candidates rather than making a security claim across a validation/deletion
race. Artifact quota and lifecycle reclamation remain follow-up work tracked
by [#4327](https://github.com/TalonT-Org/AutoSkillit/issues/4327) and
[#4320](https://github.com/TalonT-Org/AutoSkillit/issues/4320), respectively.

## Consequences

Expand All @@ -89,4 +103,5 @@ hook can be retired in favor of that mechanism.
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_<uuid16>.log`.
the descriptor-anchored project capture root. A pathname is reported only
while it still binds to the opened project, directories, and artifact.
35 changes: 23 additions & 12 deletions docs/safety/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,25 +88,36 @@ in `.hook_config.json` for future recipes that legitimately need these operation
**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_<uuid16>.log`. Only a bounded inline
slice enters context: full content when small (artifact retained; reclaimed by SessionStart sweep), 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`.
minimal isolated-Python runner. The runner opens the supplied `cwd` as a
`ProjectAnchor` directory descriptor before deriving a physical path, then opens or
creates `.autoskillit`, `temp`, and `shell_capture` relative to retained directory
descriptors without following symlinks. It reads output policy through the verified
`temp` descriptor and creates `shell_<uuid16>.log` exclusively with no-follow
semantics. The child sends combined stdout+stderr through a pipe; the runner writes,
measures, hashes, and replays bytes through owned descriptors. Only a bounded inline
slice enters context: full content when small, otherwise head + provenance marker
(bytes, sha256, verified path or `unavailable`) + tail. The user-facing configuration
uses `output_budget.guard_enabled` and `output_budget.shell_max_inline_bytes`. The
server serializes those values into the stdlib hook bridge as
`output_budget_policy.disabled` (the inverse of `guard_enabled`) and
`output_budget_policy.shell_max_inline_bytes`; the runner reads that internal bridge
shape after verified policy loading.

#### Capture Artifact Lifecycle

| Phase | Behavior |
|-------|----------|
| Creation | Harness creates `<cwd>/.autoskillit/temp/shell_capture/shell_<uuid16>.log` via `mkdir -p` + redirect |
| Creation | Runner creates the artifact relative to a verified capture-directory fd with `O_CREAT | O_EXCL | O_NOFOLLOW` and mode `0600` |
| Retention | Artifact is always retained after command execution (both inline and spill branches) |
| Ownership | The hook subprocess; no downstream consumer reads these files |
| Cleanup — session lifecycle | `session_start_hook.py` sweeps stale captures (older than 1 hour, measured against wall-clock time) at every SessionStart |
| Ownership | The isolated runner retains all directory/artifact fds through measurement, replay, and marker-path identity verification |
| Cleanup — session lifecycle | `session_start_hook.py` calls the same descriptor-anchored helper to classify stale captures. Portable Python cannot unlink by expected inode, so candidates are conservatively retained rather than deleted through a race-prone pathname |
| Naming contract | `shell_[0-9a-f]{16}.log` — files not matching this pattern are never deleted |
| Safety | Sweep validates the filename allowlist and rejects symlinks via `lstat`-backed checks |
| Failure mode | Sweep errors are swallowed (fail-open); cleanup failure never blocks command execution |
| Safety | Capture components reject symlinks; artifacts reject collisions, symlinks, hardlinks, and world-writable entries; marker paths are emitted only while every current binding matches the opened identities |
| Failure mode | Capture setup failure stops before the user command. SessionStart cleanup errors remain fail-open and never block reminder injection |

Codex hook generation excludes the interactive-only SessionStart hook, so Codex
artifacts have no automatic deletion guarantee. Lifecycle/quota reclamation must not
weaken the descriptor and identity contract when it is added.

### `generated_file_write_guard.py`
**Guarded tools:** `Write`, `Edit`
Expand Down
10 changes: 7 additions & 3 deletions src/autoskillit/core/path_containment.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
"""Path containment guards for closure-mode artifact validation (IL-0, stdlib-only).

Resolves a path against an allowed root, rejecting symlinks, hardlinks,
traversal escapes, oversized files, and world-writable files. Also provides
a metadata-stability check for TOCTOU detection between pre- and post-read stats.
Resolves a child path against an already-trusted allowed root, rejecting child
symlinks, hardlinks, traversal escapes, oversized files, and world-writable
files. Root authority is a caller precondition; this module does not establish
whether ``allowed_root`` itself or its ancestors are hostile. Also provides a
metadata-stability check for TOCTOU detection between pre- and post-read stats.
"""

from __future__ import annotations
Expand All @@ -24,6 +26,8 @@ def resolve_contained_path(
*,
max_size_bytes: int = 50_000_000,
) -> Path:
"""Validate a child path beneath an allowed root whose authority is pre-established."""

original = Path(path)
orig_st = original.lstat()
if stat.S_ISLNK(orig_st.st_mode):
Expand Down
3 changes: 2 additions & 1 deletion src/autoskillit/hooks/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,9 @@ Sub-packages: guards/ (see guards/AGENTS.md), formatters/ (see formatters/AGENTS
| `ingredient_lock_guard.py` | PreToolUse guard script (see guards/AGENTS.md) |
| `_hook_utils.py` | Shared stdlib-only utilities for hook scripts (e.g., `find_project_root`, `STEP_SUFFIX_RE`) |
| `_command_classification.py` | Shared stdlib-only command classification primitives for guard scripts (interpreter/wrapper detection, git command classification) |
| `_capture_contract.py` | Shared stdlib-only shell-capture transport limits and identifier validation |
| `_policy_event.py` | Typed policy-event formatter for hook provenance messages (stdlib-only) |
| `_capture_cleanup.py` | Stdlib-only stale-capture-file sweep helpers (used by `session_start_hook.py`) |
| `_capture_artifacts.py` | Stdlib-only descriptor-anchored shell-capture authority, runner, and cleanup classifier used by `session_start_hook.py` |
| `shell_capture_hook.py` | `PreToolUse`: input-rewrite hook for Codex shell capture — wraps commands in a lossless capture harness (#4286 / ADR-0006) |

## Architecture Notes
Expand Down
Loading
Loading