Skip to content

Rectify: Guard Decision Architecture, Capture Diagnostics & Store Reconciliation Immunity - #4468

Merged
Trecek merged 84 commits into
developfrom
impl-rectify-guard-capture-immunity-20260804-210149
Aug 6, 2026
Merged

Rectify: Guard Decision Architecture, Capture Diagnostics & Store Reconciliation Immunity#4468
Trecek merged 84 commits into
developfrom
impl-rectify-guard-capture-immunity-20260804-210149

Conversation

@Trecek

@Trecek Trecek commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Summary

The investigation confirmed two live defects and one debris field:

  1. Guard over-blocking (Bug 1). github_mutation_guard.py denies every run_cmd whose cwd tool argument differs from the hook payload's session cwd, before any mutation classification runs (src/autoskillit/hooks/guards/github_mutation_guard.py:84-85). A worktree cwd is the standard — effectively the only — way for a Codex cook session to operate in a worktree, so every such call is denied, including pwd, with a reason about GitHub mutations that has nothing to do with the trigger. All nine deny paths share one hardcoded reason string (_DENY_REASON, :47-51).
  2. Diagnostics mislabeling (Bug 2, messaging half). emit_runner_diagnostic (src/autoskillit/hooks/_capture/_reconcile.py:121-126) hardcodes "cleanup failed" around healthy, by-design bounded deferral (progress=retired blocker=record_budget errors=0), and shell_capture_hook.py:68-70 reports the permanent, by-design absence of managed launch controls in cook sessions as transient breakage — on every command.
  3. Unreachable backlog (Bug 2, mechanism half). ~74,000 unledgered shell_*.log files (~650 MB) are permanently invisible to every cleanup path (zero directory enumeration exists in hooks/_capture/), and the SessionStart sweep — where drain capacity lives — aborts on the first flock contention with no retry, so the ledger-visible backlog (~3.7k eligible records) sits in equilibrium.

This PR makes each bug class structurally impossible or instantly caught, in four workstreams:

  • W1 — Guard decision architecture: execution-cwd semantics replace envelope-equality checks; a typed decision object with a trigger→message mapping makes misdescribing denials impossible; a shared payload-extraction module removes hand-rolled envelope parsing; widened mutation coverage compensates the removed content-independent deny; a fail-closed guard registry plus meta-tests make false-positive corpora and doc accuracy standing invariants.
  • W2 — Diagnostic severity & declared session identity: severity derived from state by one pure, exhaustively-tested classifier; every residual hook message routed through the existing (previously orphaned) PolicyEvent formatter mandated by ADR-0006; cook builders positively declare capture mode so absence-of-declaration once again signals a genuine anomaly.
  • W3 — Capture-store convergence & reconciliation: bounded lock-retry inside existing budgets (covering both the sweep body and store-open contention); a budgeted directory-reconciliation (orphan-adoption) sweep phase; a CLI stats/reclamation path; production-scale convergence tests enforcing the documented "one record cannot starve the backlog" guarantee.
  • W4 — Cross-cutting enforcement: remaining guards migrated to the shared payload module with an AST test forbidding regression; payload-cwd-aware state-root resolution for the nine guards that mislocate state in worktree topologies; a session-replay harness driving realistic multi-command sessions through the full PreToolUse chain asserting no benign command is blocked and no error-grade message appears without errors.

The implementation was independently audited against the plan (audit-impl, structural diff review) — plan fidelity is solid across all four workstreams. Actually running the test suite (task test-check, not part of the structural audit by design) surfaced 59 genuine regressions the structural review couldn't catch: a dropped test fixture (umask), a deleted architectural-contract constant, a cross-package import boundary violation, and several legitimate test-registry/budget counts needing updates for this plan's own additions. All were root-caused and fixed with real test verification — tests/hooks/ + tests/arch/ now passes cleanly except 4 pre-existing, unrelated failures that also fail identically on develop.

A handful of additional failures outside that scope (tests/cli/, tests/docs/test_doc_counts.py) surfaced in a full unscoped run and have not yet been triaged — tracked as follow-up, not blocking this PR.

Implementation Plan

Plan file: .autoskillit/temp/rectify/rectify_guard_capture_immunity_2026-08-04_200806.md

🤖 Generated with Claude Code via AutoSkillit

Trecek and others added 30 commits August 5, 2026 11:28
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…subcommands

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…cution-cwd semantics

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…to command position

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…mantics

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds a state-derived diagnostic severity model to
hooks/_capture/_types.py: CleanupSeverity (HEALTHY/DEFERRED/STALLED/
FAILED), an exhaustive BLOCKER_FAMILY mapping every CleanupBlocker
member to "healthy"/"budget"/"external", and the pure total function
classify_cleanup_outcome(progress, blocker, errors) that derives
severity from them. errors always wins; a healthy-family blocker is
always healthy; a budget-family blocker is deferred with progress and
stalled without; an external-family blocker is always stalled
regardless of progress, so an externally-blocked store never goes
silent. A future CleanupBlocker member without a family assignment
raises KeyError immediately via the plain-dict subscript, rather than
silently defaulting.

Part of Workstream 2 (diagnostic severity & declared session
identity) of the guard/capture-immunity rectify plan.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…mission

Rebuilds hooks/_capture/_reconcile.py's cleanup_diagnostic on the new
severity classifier and replaces emit_runner_diagnostic with an
owner-neutral emit_owner_diagnostic(outcome, owner, write): silent for
HEALTHY/DEFERRED, a neutral one-line note for STALLED, a failure-worded
line for FAILED. Every non-silent message is rendered via
hooks/_policy_event.py's PolicyEvent + render_provenance_prefix instead
of a hand-rolled "[AutoSkillit ...]" literal — decision carries the
severity token, so FAILED is the only rendered text that can contain
"failed". A single shared DIAGNOSTIC_MAX_BYTES bound replaces the
former 240 (runner-tail) vs. 512 (SessionStart) divergence.

This fixes the incident: a healthy, by-design bounded deferral
(progress=retired blocker=record_budget errors=0) previously always
rendered "cleanup failed" on every command; it is now silent, and an
externally-blocked store (e.g. migration_blocked) still surfaces a
neutral line every pass rather than going silent.

capture_lifecycle_hook.py and _capture_artifacts.py's runner-tail
sweep migrate to the shared emitter: capture_lifecycle_hook.py drops
its private _bounded_stderr/_MAX_DIAGNOSTIC_BYTES entirely, and both
hooks' generic crash-fallback paths (an exception escaping reconcile
itself, distinct from outcome.errors > 0) also render through
PolicyEvent rather than a bare literal.

Test updates mechanically required by this rebuild: the frozen
narrow-import AST assertion in test_capture_lifecycle_hook.py now
expects the shared emitter's import set; the two test_capture_artifacts.py
tests asserting the old "shell capture cleanup failed" wording now
assert the PolicyEvent-rendered text; new tests cover the severity
classifier's full cross-product, the emission-policy contract (incident
silence via both a real seeded store and direct outcome construction,
neutral STALLED wording, FAILED wording preservation), and DEFERRED
silence through both the runner-tail and SessionStart wrappers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds tests/arch/test_hook_message_provenance.py: a static AST scan of
_reconcile.py, shell_capture_hook.py, and capture_lifecycle_hook.py
asserting no string literal carrying the "AutoSkillit" provenance
token (bracketed or bare) exists outside hooks/_policy_event.py, plus
a standing check that _policy_event.py has at least one production
importer so it can no longer regress to an orphaned component. One
pre-existing literal is narrowly exempted: shell_capture_hook.py's
_LOCAL_REJECTION_DETAIL, which is baked into the generated shell
harness itself (a `printf ... >&2` inside the wrapped command) rather
than emitted as a hook policy/diagnostic message by the hook process —
a companion test asserts the exemption list never goes stale.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Splits shell_capture_hook.py's single "managed launch controls missing
or invalid" fallback into a three-way declared-mode contract, each
rendered via PolicyEvent: (i) mode=capture with no managed-identity
vars is a normal declared state with no diagnostic at all — previously
this exact shape hit the same "missing or invalid" fallback as a
genuine anomaly; (ii) a complete, valid managed=direct identity tuple
resolves to the managed path unchanged; (iii) a declared mode with a
partial/invalid identity tuple falls back to capture with a distinct
"incomplete managed native-shell controls; falling back to capture"
note; (iv) no declaration at all falls back to capture with a neutral
"native-shell control undeclared; using capture" note.

codex.py's build_interactive_cmd (the Codex cook/interactive launch
path) now positively injects AUTOSKILLIT_NATIVE_SHELL_CAPTURE_MODE=
"capture" after CodexEnvPolicy().build_env() returns — extras-side
injection would be silently stripped by build_env's protected
native-shell env filter, so it must follow the same post-build_env
env.update() pattern already used by build_skill_session_cmd,
build_food_truck_cmd, and build_resume_cmd. Cook remains structurally
unmanaged (no managed-identity params added) but is now declaredly so:
case (i) above is what cook triggers, so shell_capture_hook.py no
longer needs to infer "normal" from the absence of a declaration.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ADR-0006 gains an "Implemented" note under the Provenance Rule naming
the now-wired PolicyEvent formatter, the severity vocabulary
(healthy/deferred/stalled/failed), and the owner-neutral emission
path. docs/safety/hooks.md's capture-lifecycle section gains matching
"Cleanup diagnostic severity" and "Declared native-shell control mode"
subsections describing the classifier's trigger table and cook's
capture-normal declared state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Bounded lock retry (2a): CaptureLifecycleStore._locked(blocking=False) —
the only non-blocking caller shape, used exclusively by sweep-path helpers
while a sweep is active — now retries on EAGAIN/EWOULDBLOCK with jittered,
doubling backoff (5-20ms base, capped) bounded by the sweep's own existing
max_duration_seconds budget instead of aborting on first contention.
LOCK_CONTENDED is only ever returned once the entire budget elapsed
without acquisition; blocking callers are unaffected; a non-blocking call
outside an active sweep falls back to today's single-attempt behavior.

Directory-reconciliation orphan-adoption scan phase (2b): new stdlib-only
hooks/_capture/_orphan_scan.py performs a budget-bounded, cursor-resumed
directory scan (SweepBudgetSpec gains max_directory_entries_scanned, 0 by
default so RUNNER_TAIL_BUDGET is unaffected; SESSION_START_BUDGET sets
512) identifying shell_[0-9a-f]{16}.log files that are regular (lstat, no
symlink traversal — #4319), aged past a 24h threshold, and not the public
name of any non-DELETED-phase ledger record (so a DELETING-phase record's
file mid-quarantine is never re-adopted — #4321). _ledger.py gains
adopted_orphan_record(), the LEGACY_CLEANUP_ONLY constructor for adopted
orphans; CaptureLifecycleStore._admit_new_record() admits them through the
same capacity check and max_transitions budget reserve_capture() uses, so
orphan adoption can only ever compete for the active-record ceiling real
captures do, never bypass or starve it (#4440). run_bounded_sweep runs the
scan phase after record-sweep work, only while duration budget remains.

Also adds capture_store_stats()/CaptureStoreStats — a read-only ledger and
directory statistics adapter shared by the upcoming doctor check and CLI
command (2c).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
doctor is a flat @app.command running a read-only check battery under a
standing read-only architectural guard (tests/arch/test_doctor_readonly.py)
— there is no subcommand mechanism, and a mutating reclaim cannot live
under doctor. Two pieces:

- cli/doctor/_doctor_capture_store.py: a read-only capture-store stats
  check appended to the existing doctor battery, calling
  hooks._capture._reconcile.capture_store_stats() — never reconcile_capture_store,
  never anything from the orphan-adoption path.
- cli/_capture_store.py: a new flat @app.command capture-store (CLI-visible
  as capture-store, matching quota_status -> quota-status), printing the
  same stats by default; --reclaim loops reconcile_capture_store with a
  generous one-time RECLAIM_BUDGET until a clean pass (no due records, no
  adoptable orphans) or a hard iteration cap, printing per-pass progress.
  This is the one-time bulk path for pre-existing debris; the SessionStart
  scan phase (previous commit) keeps new debris from ever accumulating
  again. Imports hooks._capture._reconcile directly — none of pyproject's
  import-linter contracts mention hooks or restrict cli imports.

Both surfaces call the same capture_store_stats() adapter so neither can
drift from what a real reconciliation pass would find.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
docs/safety/hooks.md capture-lifecycle section gains three subsections
after declared native-shell control mode: the directory-reconciliation
scan phase (budget field, cursor sidecar, adoption gate table, capacity
sharing with reserve_capture(), Codex hook-generation coverage), bounded
lock-contention retry (jittered backoff mechanics, budget bound, when
LOCK_CONTENDED is actually returned), and the stats/reclamation CLI
(shared read-only adapter, --reclaim's one-time bulk purpose).

ADR-0006 gains a Resolved section acknowledging both gaps this workstream
closes: unledgered orphan files (previously permanently invisible to
cleanup) and lock contention (previously aborted a sweep immediately,
including the 256-attempt SessionStart pass).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…, and CLI/doc tests

tests/hooks/test_capture_lifecycle.py:
- Production-scale convergence (1a): seeds ~1,024 file-less reserved
  records via the store API, asserts remaining_due strictly decreases
  every SESSION_START_BUDGET invocation until 0 within
  ceil(N/max_attempts)+2 invocations — the documented 'one record cannot
  starve the backlog' guarantee, previously validated by nothing (issue
  #4440 regression guard: cleanup capacity must outpace admission at
  scale, not merely drain a static backlog eventually). A single
  RUNNER_TAIL_BUDGET call against a non-empty backlog always retires >= 1
  record.
- Lock-contention recovery (1b): replaces
  test_sweep_defers_without_blocking_on_contended_ledger_lock, whose
  premise the bounded lock-retry change removes by design, with two tests
  matching the new contract — a lock released a few ms into a sweep
  recovers within the same invocation and makes progress; a lock held for
  the whole budget reports a bounded, clean STALLED-class LOCK_CONTENDED
  outcome.
- Orphan adoption (1c): the plan's exact seeded corpus (20 aged
  unledgered, 5 fresh unledgered, 3 active-record, 1 aged
  DELETING-tracked, 1 aged symlink, 1 aged non-matching name) — only the
  20 aged orphans are ever adopted and deleted; no duplicate ledger record
  is ever created for the DELETING-tracked name. Plus budget-bounded
  scan/cursor-resume coverage and RUNNER_TAIL_BUDGET no-scanning coverage.

tests/cli/test_capture_store.py (1d): the doctor check and
autoskillit capture-store (without --reclaim) report stats without
mutating a seeded ledger+orphan backlog (filesystem snapshot before/after);
--reclaim drains both backlogs to convergence.

tests/docs/test_capture_store_reconciliation_docs.py (1e): ratchets
presence of the scan-phase, adoption-gate, and contention-retry
documentation in docs/safety/hooks.md and the Resolved acknowledgment in
ADR-0006 — the doc gap was itself a finding.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
reconcile_capture_store's call chain is open_capture_lifecycle(...) ->
lifecycle.sweep(budget). open_capture_lifecycle -> from_open_authorities
unconditionally calls _normalize_interrupted_deliveries(), which acquires
the ledger lock via store._locked() (blocking=True, the default) BEFORE
.sweep(budget) is ever reached — so _sweep_budget/_sweep_started_monotonic
were still None at that point, and this specific lock acquisition never
went through the bounded-retry path added for the sweep body. A contended
lock at store-open time blocked the whole reconcile_capture_store call
indefinitely regardless of budget.max_duration_seconds, exactly the
incident scenario this workstream exists to close (session_scope="any"
means every concurrent SessionStart hook opens the store simultaneously).

Reproduced concretely: holding the lock 2.0s and calling
reconcile_capture_store(cwd, SweepBudgetSpec(max_duration_seconds=0.15))
returned after ~2.0s with blocker=none — the budget was silently ignored.

Fix: open_capture_lifecycle and CaptureLifecycleStore.from_open_authorities
gain an optional open_budget parameter. When supplied, it primes
_sweep_budget/_sweep_started_monotonic before _normalize_interrupted_deliveries
runs and clears them when the open_capture_lifecycle 'with' block exits.
_delivery.py's normalize_interrupted_deliveries — the only blocking=True
caller reachable during that window — now acquires its two ledger locks
with blocking=(store._sweep_budget is None), so store-open lock
acquisition participates in the same jittered-backoff bounded-retry
mechanism the sweep body already used, and LockContended surfaces through
reconcile_capture_store as the same LOCK_CONTENDED outcome the sweep body
reports (new except clause + local duration tracking). capture_store_stats()
gets the identical treatment with RUNNER_TAIL_BUDGET as its own open_budget
(and its own record-load lock made conditional) — a diagnostic read must
never hang either. Every other from_open_authorities/open_capture_lifecycle
caller (create_artifact, direct test construction, ...) passes nothing and
keeps today's blocking-until-acquired behavior, verified concretely.

Verified against the exact reported repro plus both RUNNER_TAIL_BUDGET and
SESSION_START_BUDGET: contention bounded to ~budget.max_duration_seconds
with blocker=LOCK_CONTENDED, errors=0; a lock released within budget
recovers and completes successfully; non-sweep callers still block exactly
as before (measured elapsed matches the holder's real hold duration).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ook payload module

resolve_state_root(payload_cwd) resolves the project root whose
.autoskillit/ directory holds session state, per the plan's three-tier
order: AUTOSKILLIT_STATE_ROOT env var (production signal set by launch
preparation) wins unconditionally; failing that, an upward walk from
payload_cwd looking for a directory containing .autoskillit/, operating
on resolved (symlink-free) paths so a symlinked .autoskillit escaping
the trust anchor is never accepted (issue #4319); failing that, the
process cwd, unchanged from every existing guard's pre-migration
behavior.

extract_apply_patch_text(data) extracts the patch text from a Codex
apply_patch tool payload's "command" field — a distinct semantic use of
that key name from a shell command, kept in the shared stdlib-only
module so write_guard.py's read of it stays inside the payload-extraction
AST conformance boundary in the same commit that migrates write_guard.py.

_hook_settings.read_merged_hook_config's bare default (root=None) now
resolves via a new _default_state_root() helper (AUTOSKILLIT_STATE_ROOT
env var, else process cwd) instead of bare Path.cwd(). Implemented as an
inline duplicate of resolve_state_root's env-var tier rather than an
import, because _hook_settings.py is imported both as a bare sibling
module by guard subprocesses (which pre-insert hooks/ onto sys.path) and
via the normal package path by in-process test imports — only the latter
resolves a cross-sibling-module import correctly, so importing
_hook_payload here would break guard subprocess execution.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ommand

Mechanical extraction swap for pr_create_guard.py, git_ops_guard.py,
unsafe_install_guard.py, compose_pr_body_guard.py,
planner_gh_discovery_guard.py, artifact_download_guard.py,
test_runner_guard.py, recipe_read_guard.py, write_guard.py —
behavior-identical except write_guard.py.

pr_create_guard.py, git_ops_guard.py, and compose_pr_body_guard.py also
migrate their state/body-path Path.cwd() resolution to
resolve_state_root(parsed.payload_cwd), and pass the resolved root
through read_merged_hook_config's existing root parameter rather than
re-resolving.

write_guard.py additionally resolves relative write targets against
parsed.execution_cwd when non-empty, falling back to the AUTOSKILLIT_CWD
env var — fixes the cwd-source divergence where relative Bash/run_cmd
write targets were resolved only against the env var, ignoring the
per-call cwd available in worktree flows. Its apply_patch handling now
reads the patch text via the new extract_apply_patch_text helper instead
of a direct tool_input["command"] read, since that field is patch text
rather than a shell command but shares the same key name.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…uards

reset_resume_gate.py, open_kitchen_guard.py (three sites),
pipeline_step_guard.py (two Path.cwd() sites plus its
read_merged_hook_config() call), resume_ownership_guard.py,
ask_user_question_guard.py, and review_loop_gate.py all resolved state
files via bare Path.cwd(), which in worktree topologies silently looks
in the wrong .autoskillit/temp/ tree: pr_create_guard/git_ops_guard
(migrated in the prior commit) then fail open in the unsafe direction
(kitchen "not open" -> check skipped), and ask_user_question_guard
fails closed (spurious deny) — the exact incident symptom.

Each guard now threads the hook payload's top-level cwd (best-effort;
several of these MCP-tool-matched guards receive no payload cwd at all
in current fixtures) through resolve_state_root(), which checks the new
AUTOSKILLIT_STATE_ROOT env var first — the load-bearing signal for
guards with no payload cwd — then falls back to the payload-cwd walk,
then process cwd (no worse than today when both are absent).

open_kitchen_guard.py and resume_ownership_guard.py keep their existing
AUTOSKILLIT_STATE_DIR override as the first-checked branch (test
isolation, pre-existing) and only replace the bare Path.cwd() fallback.

review_loop_gate.py now captures the parsed stdin payload (previously
discarded after the try/except) to extract its cwd field.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…and cook sessions

Adds AUTOSKILLIT_STATE_ROOT_ENV_VAR to core/types/_type_constants_env.py
(named constant for core-side producer code; hooks/_hook_payload.py's
resolve_state_root reads the same value as a bare string literal, since
stdlib-only hook scripts cannot import this constant) and its private-env
scrub/re-inject list, plus the core/__init__.pyi re-export.

_assemble_shared_env_extras (shared by both backends' skill-session and
food-truck builders) now injects AUTOSKILLIT_STATE_ROOT alongside
AUTOSKILLIT_CWD whenever cwd is set — cwd is the orchestrating project
root at every current call site. The interactive cook launch path
(_session_launch.py's _run_interactive_session) injects it from its
resolved _project_dir into extra_env before either build_interactive_cmd
call, since cook sessions have no other path through
_assemble_shared_env_extras.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…n coverage

test_guard_payload_extraction.py (new): AST scan of hooks/guards/*.py
asserting no guard reads tool_input["cmd"/"command"/"cwd"] directly
outside hooks/_hook_payload.py's parse_hook_command. EXEMPT is empty —
every guard listed in the plan is already migrated as of the prior two
commits. Also asserts EXEMPT entries (currently none) reference live
files and still need the exemption, so a stale entry fails the same as
a missing migration.

test_state_root_resolution.py (new): resolve_state_root's three-tier
resolution order (env var wins; payload-cwd upward walk fallback finds
the nearest ancestor's .autoskillit/; process cwd last resort, matching
pre-migration Path.cwd() behavior exactly when both signals are absent),
symlink handling for both the env-var path and the walk, and the
worktree-topology scenario for pr_create_guard, git_ops_guard, and
ask_user_question_guard — state under an orchestrating project root,
reachable only via AUTOSKILLIT_STATE_ROOT while the hook payload's own
cwd points into an unrelated sibling worktree with its own checked-in
.autoskillit/.

Fixes a real gap resolve_state_root's symlink-defense tests surfaced:
the upward walk accepted a symlinked .autoskillit/ entry via bare
is_dir() (which follows symlinks), which would let every caller's
subsequent `root / ".autoskillit" / "temp" / ...` file access
transparently follow the symlink outside the trust anchor (issue #4319).
Now rejects any .autoskillit entry that is itself a symlink and keeps
walking upward past it.

Also updates test_backend_cmd_builder_base.py's exact-key-count assertion
(12 -> 13) and adds AUTOSKILLIT_STATE_ROOT coverage now that
_assemble_shared_env_extras injects it, and adds cook-launch producer-side
coverage to test_session_launch.py for the AUTOSKILLIT_STATE_ROOT
injection from _project_dir.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
test_session_replay.py drives a fixture-described session through the
full PreToolUse hook chain via the real hooks/_dispatch.py subprocess —
for each event, selects every HOOK_REGISTRY guard whose matcher
fullmatches the tool name AND whose session_scope is compatible with the
fixture's simulated session (mirroring hooks.json generation-time gating,
which _dispatch.py itself has no notion of), runs each via subprocess
with the fixture's session_env applied, and asserts every allowed:true
event is denied by no matched guard while no stderr line anywhere in the
replay carries failure-grade wording (failed/error/invalid) unless the
owning event's max_severity is "failure".

Fixture format: tests/hooks/fixtures/session_replays/*.jsonl — first
line a header ({session_env, state_setup}), each subsequent line one
event ({payload, expectations}). String values may carry
{{ORCHESTRATING_ROOT}}/{{WORKTREE_ROOT}} placeholders the harness
substitutes with real per-test tmp_path directories.

incident_transcript_v1.jsonl is modeled on the incident session: three
dual-cwd worktree run_cmd calls (pwd, git rev-parse, sed -n) with
tool_cwd pointing into a worktree while the payload's top-level cwd is
the orchestrating repo root, two Bash reads (a file read, a benign
loop), and one genuine gh pr review --approve mutation that must still
be denied by github_mutation_guard.py — proving the W1 fix and this
workstream's guard migrations compose correctly end to end. Verified by
direct execution: the five benign events pass through all 11-13 matched
guards per event with zero denials, and the one deny-expected event is
denied with the review_mutation trigger, exactly as expected.

Registers the new .jsonl fixture pattern in
.autoskillit/test-filter-manifest.yaml so changes to it route to the
hooks/ test suite, matching the existing tests/fixtures/codex/*.jsonl
precedent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
_segment_has_possible_github_exec_token previously checked every token
in a shell segment for exact equality to the exec name set, causing a
bare-word argument that merely mentions the two-character executable
name (e.g. echo "gh", which shlex collapses to the same tokens as an
unquoted echo gh) to be misclassified as a possible exec reachable
through a loop, denying a benign command that never invokes it in
command position.

The check now derives the segment's actual command verb via
command_verb_and_args (which already skips env/wrapper prefixes and
loop control words), narrowing to command position only. An inline
function definition (name() open-brace) or bare compound-command
opener fuses its body's first command into the same shlex segment as
the opener, so the check re-derives the verb from the token
immediately following such an opener to keep catching the exec name
reachable through a function body -- verified this does not regress
the existing "function" id case in
test_multiple_or_repeatable_mutations_are_not_single and
test_wrappers_and_repeatable_shell_constructs_cannot_bypass_guard.

Adds the plan's literal named test case
(test_bare_gh_token_as_argument_inside_a_loop_is_none) as its own
test, distinct from the existing multi-word-variant test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…esolution

write_guard.py's write-target resolution was migrated to prefer
parse_hook_command's execution_cwd (the run_cmd tool's own cwd
argument, independent of the payload's session-level cwd) over the
AUTOSKILLIT_CWD env var fallback, fixing a cwd-source divergence in
worktree flows -- but shipped with no standing test coverage of that
behavior.

Adds TestWriteGuardRunCmdExecutionCwd with:
- an allow case: a relative write resolves inside the allowed prefix
  via the run_cmd tool's own cwd, even though AUTOSKILLIT_CWD points
  elsewhere (would incorrectly deny if still consulted first)
- a deny case: the same shape, but the run_cmd tool's own cwd resolves
  the target outside the allowed prefix, even though AUTOSKILLIT_CWD
  points inside it (would incorrectly allow if still consulted first)
- a regression case: when the run_cmd tool omits its own cwd argument,
  resolution still falls back to AUTOSKILLIT_CWD as before

Reuses the shared make_hook_event harness from conftest.py (already
used by test_github_mutation_guard.py for the same execution_cwd vs
payload_cwd distinction).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Trecek added 25 commits August 5, 2026 20:28
@Trecek
Trecek added this pull request to the merge queue Aug 6, 2026
Merged via the queue into develop with commit 7fd12a9 Aug 6, 2026
3 checks passed
@Trecek
Trecek deleted the impl-rectify-guard-capture-immunity-20260804-210149 branch August 6, 2026 15:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant