Skip to content

fix(guard): survive unlookable paths and judge each action under a deadline (#460) - #466

Merged
CryptoJones merged 4 commits into
mainfrom
fix/460-oserror-and-hook-deadline
Oct 3, 2026
Merged

CryptoJones merged 4 commits into
mainfrom
fix/460-oserror-and-hook-deadline

Conversation

@CryptoJones

Copy link
Copy Markdown
Owner

Summary

Fixes #460 in two parts. Version 10.2.26, one patch above main (10.2.25 after #463).

1. Unlookable paths no longer raise (invariant 2)

On main, decide() raised OSError: File name too long on cd x; × 2000, cd a/a/…, git -C a/a/… commit and a long Write path. It raised ValueError on a Write path holding a NUL. Each of these paths now degrades to "unknown repo", which is fail-open for the soft gates:

  • _is_worktree_root returns False on OSError/ValueError. That covers _enclosing_repo and _dir_repo (the guard: Bash writes into a repo other than the resolved one skip the consult gate #448 write-target walk).
  • _repo_root_for_action treats an unstat-able Write path as a file and walks its parents.
  • _is_global_config_path also suppresses ValueError from resolve().
  • rules.py repo lookups needed no change. They only see roots that _is_worktree_root already found on disk, and their subprocess calls already catch OSError.
  • _chdir joins paths as text, not with /. On Python 3.13 a /-built Path keeps every segment it was joined from, so a cd chain re-joined all of them at each step. That made it quadratic: cd x; × 20k took about 1 s just to walk, and × 100k ran past the deadline. Past 4,096 characters (PATH_MAX) the directory is unknowable (None), which is the walk's existing "unknowable cd" value. cd x; × 100k now takes about 1.1 s through check_action.

2. The overall judging deadline

New module omind/deadline.py holds a small cooperative deadline in a ContextVar. check() raises DeadlineExceededError once the budget is spent and does nothing outside a judging scope.

  • Budget: 8 s. provision.OMI_GUARD_TIMEOUT is 15 s, which is the shortest hook timeout omind writes for the OMI guard. The other 7 s cover interpreter start (it can take seconds under antivirus), adapter rendering and the gate's retrieval suggestion. A test pins budget + floor <= OMI_GUARD_TIMEOUT - 3.
  • Scope: check_action() opens one scope for the whole action, so note rules count against it. decide() opens one too when called directly. A nested scope keeps the outer deadline.
  • Check points: the _shell_sites event loop, _local_shell_subjects stages, the _hard_rule_subjects loops, each hard rule and each subject search, _enclosing_repo/_dir_repo parent walks, write-target loops, and in rules.evaluate each rule, each _rule_matches and each _judge.
  • Hard rules fail CLOSED. _hard_policy_verdict catches the deadline and denies with omi-guard (hard): command too large/complex to judge safely — split it or shorten it, the guard: _shell_walk / _program_stages scale superlinearly (x=1; repeats quadratic; bash -c body n^1.5) #445 budget reason, under rule_id omi-guard-deadline. It logs a deadline-exceeded compliance event with outcome deny. _hard_rule_subjects re-raises the deadline instead of swallowing it, so a partial subject list is never lru_cached.
  • Soft gates fail open. decide() now runs the hard rules before resolving the repo; the repo lookup is pure and nothing before the hard rules used it. Anything after the hard rules that hits the deadline allows the action and logs deadline-exceeded with outcome fail-open. This covers the repo, freshness, capability, global-config and consult gates. Note rules already fail open on any exception, so a deadline inside them does the same.
  • Hard-rule floor: the hard rules always get at least 3 s of their own (deadline.at_least). Without it, a slow note-rule network lookup (gh repo view / ls-remote) that spent the budget would turn every plain command into a deadline deny.

Why cooperative checks and not signal.SIGALRM or a thread: SIGALRM is POSIX-only, fires only on the main thread (the web app judges off it), and interrupts at an arbitrary bytecode, possibly inside a lock or a log write. A watchdog thread cannot stop the judging thread in CPython; it could only race to print a verdict while judging continues. Checks at the loop points work on Windows and on any thread, and the guard always stops at a known point. The cost is one ContextVar.get and time.monotonic() per iteration. A single long C-level regex call is not interruptible, but #445 already bounds those.

One existing test changed: test_check_fail_open_still_honours_hard_policy_rules crashed _repo_root_for_action to reach the fail-open path. That function now runs after the hard rules, so the crash can no longer pre-empt them. The test now crashes _note_rules_verdict, which still runs first, and its intent and assertions are unchanged.

Test evidence

New tests in tests/test_guard.py. All 12 fail on main (verified by checking out main's sources): six OSError/ValueError raises, a hard-time-limit overrun, and missing deadline machinery.

  • test_paths_the_filesystem_cannot_look_up_do_not_raise (6 shapes)
  • test_a_long_cd_chain_is_walked_in_linear_time (cd x; × 50k inside a 1 s bound)
  • test_the_deadline_is_a_no_op_outside_a_scope, test_the_judging_budget_fits_inside_the_hook_timeout
  • test_hard_rules_fail_closed_past_the_deadline: a slow subject search denies with the reason and logs the event. The partial result is not cached.
  • test_soft_gates_fail_open_past_the_deadline: an omi-gate deny becomes an allow logged fail-open, and sudo id is still denied.
  • test_a_slow_earlier_step_does_not_deny_a_plain_command: the hard-rule floor.

Gates, all passing locally:

  • ruff check .: passes
  • mypy src (strict): no issues, 59 files
  • pytest: 3554 passed, 12 skipped

#445 timings are unchanged. perf446.py measures walk 0.01–0.02 s and hard ≤0.01 s. probe459.py measures ≤0.02 s for every shape, and its budget verdict is unchanged.

Issue shapes through check_action at n = 100,000:

Shape Time
bash -c 'echo hi'; 2.7 s
echo x > f; 1.0 s
cd x; + commit 1.1 s
5,000 heredocs plus their bodies 0.06 s

All are well inside the deadline, and none is denied.

Closes #460

Proudly Made in Nebraska. Go Big Red! 🌽 https://xkcd.com/2347/

🤖 Generated with Claude Code

https://claude.ai/code/session_011Zj1PCfsSSJgzLxi32jvde

@coderabbitai

coderabbitai Bot commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 39 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: 2de522d3-5b99-44c0-97e6-54a0863a9702
📥 Commits

Reviewing files that changed from the base of the PR and between 13854e9 and dbd9b06.

⛔ Files ignored due to path filters (1)
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (10)
  • BACKLOG.md
  • CHANGELOG.md
  • pyproject.toml
  • src/omind/__init__.py
  • src/omind/compliance.py
  • src/omind/deadline.py
  • src/omind/guard.py
  • src/omind/rules.py
  • tests/test_guard.py
  • tests/test_rules.py
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Comment thread src/omind/guard.py
from typing import TYPE_CHECKING, Any, TextIO

from omind import compliance, filelock, paths, policy
from omind import compliance, deadline, filelock, paths, policy
@CryptoJones

Copy link
Copy Markdown
Owner Author

EM review: changes requested (Flatline Roundtable: HAL9000, Cortana, TheDixieFlatline)

The OSError fix is solid: guarded lookups, a 4,096-char cap on the walked cwd, and a linear _chdir. The cooperative deadline is the right mechanism. Two things aren't right yet: which gates fail open when the deadline fires, and the issue's acceptance line on timing, which isn't demonstrated.

EM decision: once the deadline fires, judging fails CLOSED

Several gates in _soft_verdict carry the omi-guard (hard) prefix and are logged at SEVERITY_HARD: global-config-explicit-auth (an agent rewriting its own hook config), capability-question-explicit-auth and repo-work-*. Operator note-rule deny rules are hard denies too.

Today all of them fail open past the budget, and _note_rules_verdict's except Exception swallows DeadlineExceededError silently (Cortana 2a/2b, HAL9000 A/B). Padding a command until judging passes 8 s bypasses every one of them, for example cd x; × 2000 followed by a write to ~/.claude/settings.json.

Rule: when the deadline fires anywhere in judging, decide() returns a deny with the explicit DEADLINE_RULE reason ("command too large/complex to judge safely — split it or shorten it") and logs deadline-exceeded with outcome deny. Normal commands must never get there:

  • Per-lookup timeouts. Every network or subprocess fact lookup (for example gh repo view for visibility) gets its own timeout, well inside the budget. A lookup that times out makes that fact "unknown" (fail-open for that fact). It must not trigger the deadline.

  • Keep the at_least floor, so the hard rules still get their time.

  • Stop swallowing DeadlineExceededError. Catch it explicitly and re-raise it to decide() in every handler where except Exception currently swallows it:

    • _note_rules_verdict
    • _git_dash_c_path
    • _local_code_texts
    • _rules_command_view
    • CommandSite.write_repos
    • the suppress(Exception) around repo lookup

    None of these may turn it into partial information.

Must fix

  1. The fail-closed rule above. Tests:
    • An expired deadline during the note-rule phase gives a deny, with the reason and the event.
    • An expired deadline during the soft gates gives a deny. Use a global-config write padded past the deadline.
    • A slow gh fact lookup on a plain command is allowed. Monkeypatch the lookup to sleep past its own timeout; only that fact becomes unknown.
  2. Quadratic _rules_command_view on cd chains (Cortana, plausible). It calls _enclosing_repo once per distinct site.cwd with no parent memo. Memoise the parent walk per command, as _dir_repo does. Test: cd x; × 2000 through check_action with omi_dir set, under the time bound.
  3. Acceptance line 2 is not demonstrated (HAL9000, Cortana). Run each issue shape through check_action/decide() with a time bound well under the budget: cd x; × 2000, bash -c 'echo hi'; × 100k, and the heredoc flood. If a shape can't be brought under the budget, say so. It must then deny with the deadline reason, not time out.
  4. _enclosing_repo must also catch ValueError on resolve() (Cortana 2c). The case is a NUL in a git -C or cd path reaching it through harness.py/provision.py, which call decide() bare. Add a test.

Should fix

  • Tail of _check_action (Cortana 2d, HAL9000 D). Exclude budget_verdict, the excerpt and retrieve.suggest_message from the deadline scope, or give them their own bound, so a deadline hit there isn't misreported as guard-internal-error.
  • _fail_open_verdict re-runs _hard_policy_verdict and opens a second 3 s floor (HAL9000 D). Reuse the cached verdict, or bound it.
  • Coverage shift (HAL9000). Restore a case where _repo_root_for_action crashes during the soft gates; it must fail open with the hard rules re-checked.

Cut

  • deadline.active(), deadline.expired() and Deadline.expired() have no callers. Delete them (HAL9000, Cortana).

Dismissed

  • TheDixieFlatline's "ship it" didn't engage with the gate classification above.

Version: at push time, set it one patch above origin/main. #465 (#455) is merging as 10.2.26, so this becomes 10.2.27.

Proudly Made in Nebraska. Go Big Red! 🌽 https://xkcd.com/2347/

CryptoJones added a commit that referenced this pull request Oct 3, 2026
…okup (#460)

EM review on #466: once the deadline fires anywhere in judging, decide()
and check_action() deny with the explicit "command too large/complex to
judge safely" reason and a deadline-exceeded event. Several gates after
the hard rules are hard denies too (global config, capability questions,
repo work, operator note-rule denies), and padding a command past the
budget skipped all of them.

- Every fact lookup (gh repo view, git ls-remote, git config ...) runs
  under its own timeout from deadline.lookup_timeout: at most 3 s, and
  never into the 3 s reserve left for judging. A slow lookup makes that
  fact unknown; it never spends the budget.
- Handlers that failed open on any exception now re-raise the deadline:
  _note_rules_verdict, _git_dash_c_path, _local_code_texts,
  _rules_command_view, _bash_write_repo, the repo lookup in
  _repo_root_for_action, and CommandSite.write_repos.
- _rules_command_view memoises resolution and the parent walk per
  command; _dir_repo walks parents lazily. `cd x; ` x 2000 through
  check_action with note rules: 8 s (deadline) -> about 1 s.
- The steps after the verdict (budget re-arm, excerpt, retrieval
  suggestion) run outside the deadline; the hard-rule verdict is cached
  per check, so the fail-open re-check reuses it.
- Removed the unused deadline.active(), deadline.expired() and
  Deadline.expired().

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Zj1PCfsSSJgzLxi32jvde
@CryptoJones
CryptoJones force-pushed the fix/460-oserror-and-hook-deadline branch from 92406d8 to 9d8b7b0 Compare October 3, 2026 01:21
@CryptoJones

Copy link
Copy Markdown
Owner Author

Response to the EM review (9d8b7b0)

Rebased onto main after #465 (10.2.26) merged. This PR is now 10.2.27, with pyproject.toml, __init__.py and uv.lock bumped together and the CHANGELOG/BACKLOG entries placed above 10.2.26.

EM decision: past the deadline, judging fails CLOSED. Done

  • decide() (src/omind/guard.py:4603) wraps all of judging: the consult step, the hard rules and the soft gates. A DeadlineExceededError from any of them becomes _deadline_deny (guard.py:4485). It denies with DEADLINE_RULE and the reason "command too large/complex to judge safely — split it or shorten it", and logs deadline-exceeded with outcome deny and severity hard. The fail-open deadline path is gone.
  • _check_action (guard.py:4952) turns a deadline in the note-rule phase into the same deny (guard.py:4958).
  • Per-lookup timeouts. Every gh/git fact lookup in rules.py (gh repo view, ls-remote, remote -v, remote get-url, rev-parse, git config/for-each-ref via _git_out) now goes through rules._lookup (src/omind/rules.py:247), which takes its timeout from deadline.lookup_timeout (src/omind/deadline.py:82):
    • Outside the guard the timeout is the old cap (5 or 10 s).
    • Inside the guard it is at most LOOKUP_TIMEOUT_SECONDS = 3.0, and it never cuts into the LOOKUP_RESERVE_SECONDS = 3.0 left for judging.
    • If no lookup time is left, _lookup raises TimeoutExpired without spawning anything, and every caller already treats that as an unknown fact.
    • So lookups can never spend the judging budget.
  • The at_least floor is kept (_hard_policy_verdict).
  • DeadlineExceededError is re-raised explicitly (except deadline.DeadlineExceededError: raise before except Exception) in:
    • _note_rules_verdict (guard.py:4803)
    • _git_dash_c_path (guard.py:2269)
    • _local_code_texts (guard.py:2792)
    • _rules_command_view (guard.py:2549)
    • CommandSite.write_repos (rules.py:796)
    • the repo lookup in _repo_root_for_action, where the suppress(Exception) is now a try/except (guard.py:2509)
    • One addition: _bash_write_repo (guard.py:3141). It has the same swallow, on the same soft-gate path (_is_repo_sensitive_action).

Must fix

  1. The fail-closed rule. Tests, all in tests/test_guard.py:
    • test_note_rules_fail_closed_past_the_deadline: an expired deadline in the note-rule phase gives the deny with the reason and the event, and the hard rules never run.
    • test_soft_gates_fail_closed_past_the_deadline: cd x; × 2000 followed by echo '{}' > ~/.claude/settings.json.
      • In time, it is denied as global-config-explicit-auth.
      • Padded past the budget, it is denied as omi-guard-deadline with the event.
      • sudo id still names sudo-use-fleet-sudo, because of the floor.
    • test_a_slow_gh_lookup_leaves_only_that_fact_unknown: a faked gh sleeps past its own 0.2 s timeout.
      • The plain command is allowed.
      • The lookup got its own bounded timeout.
      • No deadline-exceeded event is logged.
      • The note rule logs unknown-visibility.
    • Also added:
      • test_lookups_stop_short_of_the_judging_reserve
      • test_handlers_never_turn_the_deadline_into_partial_information, parametrised over all six handlers plus _bash_write_repo
      • test_a_command_sites_write_repos_re_raise_the_deadline
    • test_a_slow_earlier_step_does_not_deny_a_plain_command is rewritten as test_the_hard_rules_keep_their_floor_after_a_slow_step. Under the new rule, a plain command after an unbounded slow step is a deadline deny, while the hard rules still name their own rule.
  2. Quadratic _rules_command_view. Two causes, both fixed:
    • _dir_repo built (path, *path.parents) eagerly, so it listed every parent before the memo could answer. It now walks them lazily (guard.py:2470).
    • Each step re-resolved, with realpath calling lstat once per component. _resolve_dir (guard.py:2427) memoises resolution per command. A child of a directory that does not exist resolves to the parent's resolution plus its name, which is what resolve() does there anyway.
    • _enclosing_repo (guard.py:2400) takes both memos, and _rules_command_view shares them across sites (guard.py:2533).
    • cd x; × 2000 through check_action with omi_dir went from 8.0 s (hitting the deadline) to about 0.95 s.
    • Test: test_a_cd_chain_through_the_note_rules_is_linear, bound traced_bound(2.5).
  3. Acceptance line 2. test_the_issue_460_shapes_are_judged_well_inside_the_budget runs each shape through check_action, with an omi_dir holding a repo-scoped rule, and through decide(). The bound is traced_bound(5.0) and the verdict must not be the deadline one. The budget is raised in the test so that a slow CI runner measures the shape instead of tripping it. Untraced times on this machine:
Shape check_action with note rules decide()
cd x; × 2000 0.95 s 0.30 s
bash -c 'echo hi'; × 100k 2.3 s 1.9 s
cat <<E <<E … × 20k heredocs + 20k bodies 0.27 s 0.13 s
cat <<E\nx\nE\n × 20k 0.47 s 0.24 s

Every shape is under the 8 s budget. The bash -c flood is the slowest: it is linear, but 100k separate commands each cost something.
4. ValueError on resolve() in _enclosing_repo. The catch was already there (except ValueError: continue, from #413 review 3). The new memoised path keeps it, because _resolve_dir raises exactly as resolve() does. decide() on git -C /tm\0p commit, cd /tm\0p && git push and cd a\0b; git push did not raise on the branch either. I added test_enclosing_repo_skips_a_path_holding_a_nul to pin it. It covers both the plain and the memoised _enclosing_repo, falling back to the next candidate, plus those three commands through a bare decide().

Should fix

  • Tail of _check_action: budget_verdict, the excerpt, retrieve.suggest_message and the deny log now run under deadline.suspended() (guard.py:4997; deadline.py:98). Test: test_the_steps_after_the_verdict_are_outside_the_deadline, which checks for no guard-internal-error and no deadline event.
  • _fail_open_verdict: hard-rule verdicts are cached per check_action in _HARD_JUDGED (guard.py:4512). The fail-open re-check, and the git-rules-missing re-decide, reuse the cached verdict instead of judging again and opening a second floor. Outside a check (the adapter render stage) it is still bounded by the single 3 s floor.
  • Coverage shift: test_a_crash_in_the_soft_gates_fails_open_with_the_hard_rules_rechecked. _repo_root_for_action raises in the soft gates. The action fails open with a guard-internal-error fail-open event, the hard rules are judged once (the re-check reuses the cache), and sudo id is still denied.

Cut

  • deadline.active(), deadline.expired() and Deadline.expired() are deleted.

Other notes

  • tests/test_rules.py::test_write_repo_lookup_failure_fails_open patched guard._dir_repo to simulate a crash in the write targets. The cwd lookup now shares that memoised walk, so the patch target moved to guard._write_target, which only the write targets use. The test's intent and assertions are unchanged.

Gates

  • ruff check .: clean.
  • mypy src (strict): clean.
  • pytest -q: 3735 passed, 12 skipped.
  • ruff format: clean on my lines.
  • Probes: probe_rules, probe_classify, probe446, probe447, probe456, probe457, probe461 give the same results before and after.
  • perf446 and probe459 are all at or under 0.02 s.

Proudly Made in Nebraska. Go Big Red! 🌽 https://xkcd.com/2347/

Comment thread src/omind/guard.py
candidates.append(base / dash_c)
except deadline.DeadlineExceededError:
raise # never partial information: the guard denies (#460)
except Exception:
Comment thread src/omind/guard.py
rule_id=verdict.rule_id,
)
if not verdict.allow and verdict.rule_id == "omi-gate" and omi_dir is not None:
from omind import retrieve
CryptoJones and others added 3 commits October 2, 2026 20:58
…adline (#460)

decide() raised OSError (ENAMETOOLONG) on `cd x; ` repeated ~2000 times,
and ValueError on a Write path holding a NUL: the walked cwd or target went
to stat/resolve uncaught, breaking invariant 2. Such paths now degrade to an
unknown repo (_is_worktree_root, _repo_root_for_action,
_is_global_config_path). A `cd` chain stops at 4,096 chars (unknowable past
PATH_MAX) and joins as text, so it is walked in linear time.

Judging one action now runs under a cooperative wall-clock deadline
(omind.deadline, 8 s inside the 15 s OMI guard hook timeout), checked at the
walk, stage, rule and note-rule loop points. Past it the hard rules fail
CLOSED ("command too large/complex to judge safely — split it or shorten
it", rule omi-guard-deadline, event deadline-exceeded); the soft gates fail
open. The hard rules always get a 3 s floor, so a slow note-rule lookup
never turns a plain command into a deny.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Zj1PCfsSSJgzLxi32jvde
…okup (#460)

EM review on #466: once the deadline fires anywhere in judging, decide()
and check_action() deny with the explicit "command too large/complex to
judge safely" reason and a deadline-exceeded event. Several gates after
the hard rules are hard denies too (global config, capability questions,
repo work, operator note-rule denies), and padding a command past the
budget skipped all of them.

- Every fact lookup (gh repo view, git ls-remote, git config ...) runs
  under its own timeout from deadline.lookup_timeout: at most 3 s, and
  never into the 3 s reserve left for judging. A slow lookup makes that
  fact unknown; it never spends the budget.
- Handlers that failed open on any exception now re-raise the deadline:
  _note_rules_verdict, _git_dash_c_path, _local_code_texts,
  _rules_command_view, _bash_write_repo, the repo lookup in
  _repo_root_for_action, and CommandSite.write_repos.
- _rules_command_view memoises resolution and the parent walk per
  command; _dir_repo walks parents lazily. `cd x; ` x 2000 through
  check_action with note rules: 8 s (deadline) -> about 1 s.
- The steps after the verdict (budget re-arm, excerpt, retrieval
  suggestion) run outside the deadline; the hard-rule verdict is cached
  per check, so the fail-open re-check reuses it.
- Removed the unused deadline.active(), deadline.expired() and
  Deadline.expired().

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Zj1PCfsSSJgzLxi32jvde
…endent of runner speed (#460)

CI run 37085768401 (ubuntu, coverage on) overran the bash -c x 100,000
shape. The stage split (_program_stages_cached, one Python step per
segment) and _stage_ends ran before the walk's first deadline.check():
about 0.4 s untraced, several seconds under coverage on a slow runner.
Both loops now check the deadline. With a 0.3 s budget and 0.2 s floor
under coverage, bash -c x 100,000 returns in 0.33 s (was 0.99 s); the
longest stretch between checks is about 0.13 s untraced.

Tests:
- test_the_issue_460_shapes_always_return_inside_the_hook_margin: the
  issue's shapes at full size on a deliberately slowed judge (busy-wait
  in _basename and _chdir) must return within budget + floor + margin.
  It fails (20 s) without the new check points.
- The "well inside the budget" test runs at the real budget, at sizes
  that hold on a CI runner under coverage; the cd-chain tests are scaled
  the same way.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Zj1PCfsSSJgzLxi32jvde
@CryptoJones
CryptoJones force-pushed the fix/460-oserror-and-hook-deadline branch from 9d8b7b0 to c645613 Compare October 3, 2026 02:00
@CryptoJones

Copy link
Copy Markdown
Owner Author

CI fix for run 37085768401 (c645613)

Rebased onto main after #467. This PR is now 10.2.28, and its CHANGELOG/BACKLOG section sits above #464's 10.2.27.

What CI caught. Some judging ran with no deadline.check() at all. The worst stretch was the stage split: _program_stages_cached does one Python step per segment, and _stage_ends runs one loop per stage. Both ran before the walk's first check. For 100,000 bash -c commands that was about 0.4 s untraced, and several seconds under coverage on a slow runner.

I found it by recording the gap between consecutive deadline.check() calls, then added a check to each of the two loops (guard.py, _stage_ends and _program_stages_cached). The longest unchecked stretch is now about 0.13 s untraced.

Under coverage, with a 0.3 s budget and a 0.2 s floor:

Shape, issue size Before After
bash -c × 100,000 0.99 s 0.33 s
cd x; × 2,000 0.30 s 0.30 s
heredoc line 0.30 s 0.30 s

All three end with the deadline deny. At the real 8 s budget under coverage, the bash -c flood returns the deadline deny at 8.02 s.

The at_least floor is bounded too: inside it, check() raises once the extended deadline passes, and _hard_policy_verdict turns that into the deadline deny.

The bash-c test failure itself. That test raised the budget to 600 s so it could measure the shape, which is why it ran past 15 s. The runner was just slow under coverage; the real defect is the unchecked stretch above.

Timing tests no longer depend on runner speed

  • New: test_the_issue_460_shapes_always_return_inside_the_hook_margin. It runs the issue's shapes at full size on a deliberately slowed judge: _basename and _chdir busy-wait 100 µs per call, with a 0.2 s budget and a 0.2 s floor.
    • It requires check_action and decide() to return within budget + floor + traced_bound(0.5), with either a real verdict or the deadline deny.
    • Without the new check points it fails: bash -c takes 20.5 s and cd x takes 4.1 s.
  • test_the_issue_460_shapes_are_judged_well_inside_the_budget now runs at the real 8 s budget, at sizes that hold on a CI runner under coverage:
    • cd x; × 500, bash -c × 10,000, a 5,000-heredoc line.
    • Bound traced_bound(1.5); each takes about 0.25 s or less untraced.
  • The cd-chain tests are scaled the same way:
    • test_a_long_cd_chain_is_walked_in_linear_time: 20,000 steps, bound traced_bound(1.5), about 0.3 s untraced.
    • test_a_cd_chain_through_the_note_rules_is_linear: 1,000 steps, bound traced_bound(1.5), about 0.3 s untraced.

Gates

Proudly Made in Nebraska. Go Big Red! 🌽 https://xkcd.com/2347/

…overage)

The 100k find-actions verdict takes ~0.4 s untraced; CI's 3.10 coverage
run is ~7x slower and crossed the 3 s traced bound. A quadratic regression
still costs 10x+ more, so traced_bound(2.0) keeps the test meaningful.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Zj1PCfsSSJgzLxi32jvde
@CryptoJones

Copy link
Copy Markdown
Owner Author

EM merge (#460). Reviewed by the Flatline Roundtable (HAL9000, Cortana, TheDixieFlatline), then two fix rounds and an EM CI-headroom fix.

Part 1: OSError/ValueError on unresolvable paths. All six shapes now degrade to "repo unknown" instead of raising: long cd chains, long -C paths, and NUL bytes. _chdir is linear and caps the walked cwd at 4,096 chars.

Part 2: the hook deadline. EM decision: once the 8 s judging budget is spent, judging fails closed. The action is denied with the explicit "command too large/complex to judge safely" reason, and a deadline-exceeded event is logged as deny. Before this change, the gates labelled hard and the operator note-rule denies all failed open past the deadline, so padding a command bypassed them.

  • Every gh/git fact lookup now has its own timeout, and on timeout only that fact becomes unknown. Ordinary commands never reach the deadline.
  • DeadlineExceededError is never swallowed by a fail-open handler.
  • A slowed-judge test checks decide()/check_action return inside the hook margin at the issue's full sizes, whatever the runner speed.

Verified: every probe suite is unchanged (#413, #434, #446, #447, #453, #456, #457, #461, #465, #467), and the #445 timings hold.

EM fix: the #455 find-actions timing bound went from traced_bound(1.0) to traced_bound(2.0). The check takes about 0.4 s untraced and CI's 3.10 coverage run is about 7× slower, so the old bound was too tight. A quadratic regression would still trip the new bound.

All 23 CI jobs are green. Releasing as 10.2.28.

Proudly Made in Nebraska. Go Big Red! 🌽 https://xkcd.com/2347/

@CryptoJones
CryptoJones merged commit c8c1015 into main Oct 3, 2026
23 checks passed
@CryptoJones
CryptoJones deleted the fix/460-oserror-and-hook-deadline branch October 3, 2026 02:18
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.

guard: decide() raises OSError (ENAMETOOLONG) on long cd chains; add an overall fail-closed hook deadline

2 participants