Repository navigation
Add a drop hook to the source guardrail - #512
Conversation
|
@polyglotAI-bot please check this PR #491 |
|
Nerve PR review · ✅ Ready to merge (once CI runs) Updated for This adds an opt-in argv hook that receives the records the guardrail dropped (metadata only, no content), so an allowlist worker can audit or acknowledge them. It is source-agnostic, bounded, and never holds back the cursor. The value is narrow but real for worker deployments. Completeness: 28 tests against real subprocesses, including an end-to-end Worth considering:
Checked: full suite in an offline sandbox at |
A guardrail drop is silent by design: the record is never persisted and the agent never sees it. That leaves no way to answer the person whose message was dropped, and the only trace is a log line capped at 10 per batch. `sync.github.drop_hook` is an argv list. When the guardrail drops records, SourceRunner runs it once per batch with one JSON object per dropped record on stdin: source, id, record_type, summary, timestamp, metadata and dropped_on (the field of the rule that fired). The content is left out. The hook is best effort and never fails a sync or holds back the cursor: - argv exec only, in a new session; stdout discarded, stderr to a temp file so a leftover background job cannot hold a pipe; - 60 s timeout, then the whole process group is killed, also when the hook exited but a child still holds its stdin, and on cancellation; - any spawn or payload error is logged, and the call site is guarded; - runs after persist and before the cursor advances, so a failed persist re-fetches the batch and the hook gets its drops then; - a wrong-shaped value or an empty program name turns the hook off with a warning; startup warns when the program is not executable or when no guardrail is set. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Since ClickHouse#491, the GitHub source queries since=<cursor> inclusively. Storage skips a kept record that did not change, but a dropped record is never stored. So the thread in the cursor's own second was dropped again on every run, and the hook got it again every run until a newer notification came. The runner now remembers the (id, timestamp) pairs it passed to the hook (the last 4096, in process) and does not pass them again. New activity on a thread changes updated_at, so it reaches the hook. A failed attempt is remembered too: no retry, as documented. Two large-payload tests repeated one batch 2000 times. They now use distinct ids, so the payload still fills the pipe. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1b5ef14 to
bb2f3da
Compare
|
@pufit Thanks. I rebased onto The problem. Since #491, The change.
Tests. 5 new: a dropped record listed twice, then with a new timestamp; a failed hook that is not retried; a repeat in one batch; the memory bound; and an end-to-end run through Not changed. The guardrail log line and #513 (from the nerveai review) is also mine. Whichever of the two merges second will add |
Summary
Adds an optional drop hook to the source guardrail.
sync.github.drop_hookis an argv list. When the guardrail drops records from a batch, Nerve runs the command once and writes one JSON object per dropped record to its stdin.A guardrail drop is silent by design: the record is never persisted, and the agent never sees it. That is right for the agent. But nothing can respond to the person whose message was dropped, and the only trace is a log line, capped at 10 per batch. With a hook, an operator can act on a drop without sending its content to an agent. For example, a worker that only takes @-mentions from an allowlist can react to a dropped mention, so the commenter knows it was received but not acted on. Or the operator can keep an audit trail.
Behavior
source,id,record_type,summary,timestamp,metadata, anddropped_on(the field of the rule that fired).contentis not sent: the guardrail exists to keep untrusted content away from automation, and the identifiers and metadata are enough to act on a drop._str_list/@_coercedconvention). Entries pass as they are, so argument positions hold.killpg). That happens even when the hook already exited and a child it started still holds stdin. Reaping waits at most 5 s. stderr goes to a temporary file, not a pipe, so a background job the hook leaves behind can't stall the sync.(id, timestamp). Since GitHub source: read every notifications page instead of only the first #491, the GitHub source queriessince=<cursor>inclusively, so a dropped thread in the cursor's own second is listed and dropped again on every run. The runner remembers the last 4096(id, timestamp)pairs it passed to the hook (in process) and does not pass them again. New activity on a thread changesupdated_at, so it reaches the hook. After a restart, a source reload, or a CLInerve sync, the hook can get a record once more.drop_hookunset, behavior does not change. At startup, a wrong-shaped value or an empty program name turns the hook off with a warning. A program that isn't executable, or a hook with no active guardrail, logs a warning.SourceRunner(drop_hook=...)is source-agnostic, so another source can take it with one argument (documented under "Extending to other sources").Changes
nerve/sources/runner.py:drop_hookparameter and validation,_run_drop_hook()/_kill_drop_hook(), the(id, timestamp)memory, and the guarded call before the cursor advances.nerve/config.py:GitHubSyncConfig.drop_hook.nerve/sources/registry.py: pass the hook to the GitHub runner, log the validated argv, and warn when the program is missing or no guardrail is set.docs/sources.md: new "Drop hook" section, plus the pipeline diagram and config block. The guardrail paragraph no longer says that dropped records are not fetched again.tests/test_source_drop_hook.py: 32 tests with real subprocesses:dropped_on, no content.GitHubSourcewith a fakegh(secondsince= cursor, one hook call).ValueError; payload error.Test plan
pytest tests/test_source_drop_hook.py: 32 passed, also with-W error::ResourceWarning. 0 failures in 10 serial runs on the rebased branch (and in 40 serial and 20 4-way parallel runs before the rebase).(id, timestamp)memory.killpgwith a leader-onlykill()fails 2 tests. Keying the kill on the hook's exit code instead of oncommunicate()finishing fails the stdin-holder test.main7cfcb15(with GitHub source: read every notifications page instead of only the first #491): 6547 passed, 39 skipped, 3 failed. The 3 failures (test_setup_api::TestSecretsAndLogs::test_log_lines_carry_no_values,test_slack_channel::TestDispatchBounds× 2) fail the same way on a cleanmain.test_slack_integration.pywas skipped: it needsaiohttp, which the dev environment does not have.🤖 Generated with Claude Code