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
2 changes: 1 addition & 1 deletion docs/decisions/0006-output-containment.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,4 +89,4 @@ 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_<uuid8>.log`.
`<cwd>/.autoskillit/temp/shell_capture/shell_<uuid16>.log`.
16 changes: 14 additions & 2 deletions docs/safety/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,12 +90,24 @@ in `.hook_config.json` for future recipes that legitimately need these operation
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 +
`<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`.

#### Capture Artifact Lifecycle

| Phase | Behavior |
|-------|----------|
| Creation | Harness creates `<cwd>/.autoskillit/temp/shell_capture/shell_<uuid16>.log` via `mkdir -p` + redirect |
| 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 |
| 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 |

### `generated_file_write_guard.py`
**Guarded tools:** `Write`, `Edit`
Denies writes to generated files (`hooks.json`, `settings.json`). The hooks
Expand Down
1 change: 1 addition & 0 deletions src/autoskillit/hook_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@ def __post_init__(self) -> None:
HookDef(
matcher=r"Bash",
scripts=["shell_capture_hook.py"],
timeout_seconds=5,
session_scope="any",
codex_status="works-as-is",
mechanism="input-rewrite",
Expand Down
1 change: 1 addition & 0 deletions src/autoskillit/hooks/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ Sub-packages: guards/ (see guards/AGENTS.md), formatters/ (see formatters/AGENTS
| `_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) |
| `_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`) |
| `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
101 changes: 101 additions & 0 deletions src/autoskillit/hooks/_capture_cleanup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
"""Stdlib-only stale-capture-file sweep helpers (used by ``session_start_hook.py``).

Reaps capture artifacts written by ``shell_capture_hook.py`` after their
capture-session lifetime has elapsed. Mirrors the TTL-sweep pattern in
``session_start_hook.py`` (kitchen_state, pipeline_tracker) with containment
checks inlined from ``core.path_containment.resolve_contained_path`` —
the stdlib-only hook import boundary prevents importing from ``autoskillit.*``.

The 1-hour age threshold (``max_age_seconds=3600`` default) is the liveness
guard: a capture file currently being written by an in-flight harness has an
``mtime`` within seconds of now and will never be swept. POSIX ``unlink`` on
an open fd is safe (the inode persists until fd close), so even in the
pathological case of a sweep racing with an active reader, no data
corruption occurs — only premature path removal, which the age threshold
makes effectively impossible.
"""

from __future__ import annotations

import os
import re
import stat
import time
from pathlib import Path

__all__ = ["_CAPTURE_FILENAME_RE", "_is_safe_capture_file", "sweep_stale_captures"]

# Strict allowlist matching the 16-hex-char uid produced by
# ``shell_capture_hook._build_harness`` (``uuid4().hex[:16]``).
_CAPTURE_FILENAME_RE = re.compile(r"^shell_[0-9a-f]{16}\.log$")


def _is_safe_capture_file(path: Path, capture_dir: Path) -> bool:
"""Return True iff ``path`` is a safe, in-place, regular capture file.

Rejects:
- Names that don't match the strict ``shell_<16hex>.log`` pattern
- Symlinks (``stat.S_ISLNK``)
- Paths whose resolved target escapes ``capture_dir``
- Hardlinks (``st_nlink > 1``)
- World-writable files (``st_mode & 0o002``)
"""
if not _CAPTURE_FILENAME_RE.match(path.name):
return False
try:
st = path.lstat()
except OSError:
return False
if stat.S_ISLNK(st.st_mode):
return False
if st.st_nlink > 1:
return False
if st.st_mode & 0o002:
return False
try:
capture_dir_resolved = Path(capture_dir).resolve(strict=True)
resolved = path.resolve(strict=True)
except (OSError, RuntimeError):
return False
if not resolved.is_relative_to(capture_dir_resolved):
return False
return True


def sweep_stale_captures(
capture_dir: Path | str,
*,
max_age_seconds: int = 3600,
) -> int:
"""Delete capture files in ``capture_dir`` older than ``max_age_seconds``.

Returns the count of deleted files. Fail-open: any per-file exception
is swallowed and the iteration continues, mirroring ``session_start_hook.py``
TTL-sweep behavior.
"""
capture_dir_path = Path(capture_dir)
if not capture_dir_path.is_dir():
return 0
try:
capture_dir_path.resolve(strict=True)
except OSError:
return 0
mtime_threshold = time.time() - max_age_seconds

deleted = 0
try:
entries = list(capture_dir_path.iterdir())
except OSError:
return 0
for entry in entries:
try:
if not _is_safe_capture_file(entry, capture_dir_path):
continue
st = entry.stat()
if st.st_mtime > mtime_threshold:
continue
os.unlink(entry)
deleted += 1
except (FileNotFoundError, OSError):
continue
return deleted
2 changes: 1 addition & 1 deletion src/autoskillit/hooks/registry.sha256
Original file line number Diff line number Diff line change
@@ -1 +1 @@
9d99ecc511cc4592d920a0981a639751e6ab31996a64d0d7d70c4ee757855773
5e547deac3484e0c1d79b53b7badfd1ecfb27ec73ea23075d9341b8f280e8818
20 changes: 20 additions & 0 deletions src/autoskillit/hooks/session_start_hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

import json
import os
import re
import sys
from datetime import UTC, datetime
from pathlib import Path
Expand Down Expand Up @@ -79,6 +80,25 @@ def main() -> None:
except Exception:
pass

try:
_capture_dir = Path.cwd() / ".autoskillit" / "temp" / "shell_capture"
if _capture_dir.is_dir():
_CAPTURE_RE = re.compile(r"^shell_[0-9a-f]{16}\.log$")
_now = datetime.now(UTC)
for _cp in _capture_dir.iterdir():
try:
if not _CAPTURE_RE.match(_cp.name):
continue
if _cp.is_symlink():
continue
_c_age = _now.timestamp() - _cp.stat().st_mtime
if _c_age >= 3600:
_cp.unlink()
except Exception:
pass
except Exception:
pass

transcript_path = data.get("transcript_path", "")
if not transcript_path:
sys.exit(0)
Expand Down
3 changes: 1 addition & 2 deletions src/autoskillit/hooks/shell_capture_hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ def _read_policy(cwd: str) -> tuple[bool, int]:


def _build_harness(command: str, cwd: str, inline_bytes: int) -> str:
uid = uuid4().hex[:8]
uid = uuid4().hex[:16]
capture_dir = str(Path(cwd) / _CAPTURE_SUBDIR)
capture_file = str(Path(cwd) / _CAPTURE_SUBDIR / f"shell_{uid}.log")
head = (2 * inline_bytes) // 3
Expand Down Expand Up @@ -110,7 +110,6 @@ def _build_harness(command: str, cwd: str, inline_bytes: int) -> str:
__as_sz=$(wc -c < "$__as_f")
if [ "$__as_sz" -le {inline_bytes} ]; then
cat "$__as_f"
rm -f -- "$__as_f"
else
{sha_line}
head -c {head} "$__as_f"
Expand Down
2 changes: 1 addition & 1 deletion tests/arch/test_subpackage_isolation.py
Original file line number Diff line number Diff line change
Expand Up @@ -887,7 +887,7 @@ def test_no_subpackage_exceeds_10_files() -> None:
"core": 24, # +closure_hashing +path_containment +closure_verifier
"core/types": 32, # +invariant_registry (INVARIANT_REGISTRY) +closure_report
"cli": 21,
"hooks": 17, # +recipe_confirmed_post_hook, +quota_guard_state_post_hook, +_policy_event, +shell_capture_hook (#4286) # noqa: E501
"hooks": 18, # +recipe_confirmed_post_hook, +quota_guard_state_post_hook, +_policy_event, +shell_capture_hook (#4286), +_capture_cleanup.py # noqa: E501
"pipeline": 12,
"fleet": 23, # +_issue_url_helpers.py # noqa: E501
"recipe/rules": 54, # +commit_guard_regression_route +rules_model +rules_gitignored_deliverable +rules_issue_scope_threading +rules_inventory_gate_bilateral +rules_verdict_context # noqa: E501
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"_registry_hash": "9d99ecc511cc4592d920a0981a639751e6ab31996a64d0d7d70c4ee757855773",
"_registry_hash": "5e547deac3484e0c1d79b53b7badfd1ecfb27ec73ea23075d9341b8f280e8818",
"hooks": {
"PostToolUse": [
{
Expand Down Expand Up @@ -189,6 +189,7 @@
"hooks": [
{
"command": "python3 SANITIZED_HOOKS_DIR/_dispatch.py shell_capture_hook",
"timeout": 5,
"trusted_hash": "SANITIZED_FOR_DETERMINISM",
"type": "command"
}
Expand Down
Loading
Loading