Skip to content

fix(permission): scan the tool title off the event loop too - #8278

Closed
bolichen97 wants to merge 1 commit into
kirodotdev:mainfrom
bolichen97:fix/permission-title-scan-offloop
Closed

fix(permission): scan the tool title off the event loop too#8278
bolichen97 wants to merge 1 commit into
kirodotdev:mainfrom
bolichen97:fix/permission-title-scan-offloop

Conversation

@bolichen97

@bolichen97 bolichen97 commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Problem / Motivation

After #7941 moved the tool_input scan in _resolve_permission onto a worker thread, the title tier — is_sensitive_path, is_sensitive_bash_command and is_denied on event.title — still ran inline on the event loop. For a shell tool the title is the command. A user's hourly cron emitted a ~9 KB command; the crash dump shows the main thread in security.py inside is_sensitive_bash_command, called from _resolve_permission at the title check, for the full 25 s watchdog budget. The offload that existed did not cover the crash path.

Why it matters

stream_and_collect_resolve_permission is the funnel for every streamed permission request on cron, Slack, the dashboard side panel and workflows. A long title there stalled the loop for as long as the scan took; #8277 makes the scan itself linear and bounded, this PR takes it off the loop so the loop is live even while it runs, and a caller's asyncio.wait_for can cancel the await.

What changed (motivation → approach → change)

  • _title_denial(title, denied_regexes) — the three title checks, pure and synchronous, returning the exact reason strings the inline code produced.
  • _resolve_permission runs the title tier and the tool_input tier in one asyncio.to_thread hop, title first, so a request denied on its title keeps the title-tier reason and the always_deny mechanism and a payload-string denial keeps always_deny_input. The empty-title refusal stays on the loop (nothing to scan). _regex_deny_mechanism still runs on the loop afterwards, because it consults the HookManager.
  • The comments claimed CPython's re holds the GIL for a whole match call, which would have made a worker hop pointless within one scan. Measured on 3.10, 3.11 and 3.12: a 16–23 s re.search on a worker thread left the main thread's 20 ms tick gaps at 20 ms. re releases the GIL while matching, so the hop keeps the loop live within a scan as well as between scans. The comments now say so; the size ceiling stays as a bound on the worker's own wall clock.
  • hooks.on_tool_call (HOOK_BASED policy, and the channel dispatchers that call it synchronously) still runs inline; its liveness bound is the gate's own linear cost and ceiling (fix(security): make the shell sensitive-path gate linear and bounded #8277), stated in the spec.

Spec: docs/system-specs/modules/security.md.

Tests

test/test_llm_helpers_tool_input_offload.py::TestTitleTierOffLoop:

  • title bash / path / regex denials keep their reason text and always_deny
  • title decides before tool_input when both would deny
  • the title predicates run on a non-loop thread, in the same hop as the tool_input predicates (thread idents equal, neither equal to the loop's)
  • the empty-title refusal never enters the scan

The existing equivalence, off-loop and liveness tests pass unchanged (the _resolve helper gained a title parameter).

Manual verification

GIL measurement script (worker-thread re.search vs main-thread tick gaps) run on 3.10.20, 3.11.15, 3.12.13/3.12.14. N/A beyond that — unit coverage exercises the real _resolve_permission.

Related Issues

Companion to #8277 (linear gate) and #8279 (cron loop-stall attribution + breaker + doctor). Independently mergeable: without #8277 the scan is slower but the loop stays live; with it the worker returns in tens of milliseconds.

Pattern harvest

Rule candidate: review-prompt
Pattern: an offload that moves a loop over N inputs to a worker while the same predicates still run inline on the request's primary field (the title) — check every call site of the offloaded predicates on the same path, not only the loop.

Checklist

  • At most two commits (one is the norm), with a Conventional Commits title
  • Existing tests pass and new tests added for new functionality
  • Self-review completed; code follows project style guidelines
  • Documentation updated (if applicable)
  • No secrets, credentials, or internal references in the diff

The title tier of _resolve_permission -- is_sensitive_path,
is_sensitive_bash_command and is_denied on event.title -- still ran
inline on the event loop after kirodotdev#7941 moved the tool_input scan onto a
worker. For a shell tool the title IS the command, and a ~9 KB command
emitted by an hourly cron held the loop past the 25 s stall watchdog
(crash dump: security.py inside is_sensitive_bash_command, called from
_resolve_permission at the title check). The offload that existed did
not cover the crash path.

Title and tool_input now go through one asyncio.to_thread hop, title
first, so a request denied on its title keeps the title-tier reason and
the always_deny mechanism, and a payload-string denial keeps
always_deny_input. The empty-title refusal stays on the loop (no scan).

The comments claimed CPython's re holds the GIL for a whole match, which
would have made the hop pointless within one scan. Measured on 3.10,
3.11 and 3.12: a 16-23 s search on a worker thread left the main
thread's 20 ms tick gaps at 20 ms, so re does release the GIL while
matching, the loop keeps ticking, and a caller's asyncio.wait_for can
cancel the await. The size ceiling stays as a bound on the worker.

hooks.on_tool_call (HOOK_BASED policy and the channel dispatchers that
call it synchronously) still runs inline; its liveness bound is the
gate's own linear cost and ceiling, stated in the spec.
@bolichen97

Copy link
Copy Markdown
Collaborator Author

Superseded by #8282, which carries this change together with its two companions as one PR.

@bolichen97 bolichen97 closed this Sep 3, 2026
@github-actions github-actions Bot added the fork Pull request from a fork (external contributor) label Sep 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

fork Pull request from a fork (external contributor)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant