You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Investigation started from NVIDIA/NemoClaw#6329 (approved network rule shown as rejected mid-session with no human action). Traced through this repo's source rather than a live cluster:
Enumerated every production writer of chunk status "rejected": the supersede pass (scans pending only), the human RejectDraftChunk handler (also un-merges via remove_chunk_from_policy and emits a removed audit log), and self_reject_mechanistic_if_already_covered. Only the last can flip an approved row without human action.
Verified the defective code is identical on main (abe42fb5) and on the v0.0.72 tag (the reporter's deployed version), and that the self-reject path first shipped in v0.0.53 (e98ea3ee, feat(policy): add agentic approval loop #1528).
An approved mechanistic draft chunk can be flipped to rejected by the gateway itself, with no human action, leaving the governance ledger inconsistent with the enforced policy.
Chain (all in SubmitPolicyAnalysis):
A denial inside the sandbox triggers the denial-analysis flush, which resubmits a mechanistic proposal for the denied endpoint (flush_proposals_to_gateway -> generate_proposals; denials are grouped by (host, port, binary) with no stage filter, so L7 method denials resubmit too).
put_draft_chunk upserts mechanistic chunks on dedup_key = "{host}|{port}|{binary}". On conflict it only bumps hit_count, and RETURNING "id" hands back the existing row's id. If the endpoint's chunk was already approved, effective_id now names the approved chunk.
self_reject_mechanistic_if_already_covered(state, sandbox_id, &effective_id, host, port, binary) scans approved chunks for one covering (host, port, binary):
let covered_by = approved
.iter().find(|c| c.host == host && c.port == port && c.binary == binary);
It neither excludes new_chunk_id from the scan nor checks that the chunk it is about to reject is still pending. The approved chunk "covers itself" and gets update_draft_chunk_status(new_chunk_id, "rejected", ...) — rejected with reason "already covered by approved chunk ".
Consequences:
Unlike the human reject path (handle_reject_draft_chunk_inner), this path never calls remove_chunk_from_policy. The merged policy still contains the rule, so enforcement and ledger now disagree: TUI/audit show [rejected] review required while the rule is still active. An operator reading the TUI believes access was revoked when it is not.
The chunk is stuck: subsequent dedup resubmissions land on a rejected row (hit_count keeps climbing), and auto_approve_chunk only approves from pending, so the endpoint can never be re-approved through this path.
Expected asymmetry per the function's own doc comment ("only the mechanistic side is asymmetric... incoming mechanistic drafts") is violated: a previously granted approval is silently revoked in the ledger.
Field evidence (NVIDIA/NemoClaw#6329): rule allow_172_17_0_1_8090 — the exact generate_rule_name mechanistic pattern — went from [approved] 50% auto-approved 17x to [rejected] 50% review required 18x between two screenshots hours apart; the +1 hit count is the dedup upsert's ON CONFLICT bump, and the logs show an L7 DELETE /v1/mcp denial followed by Flushed denial analysis to gateway right in that window.
Reproduction Steps
Unit-level (drives the exact production functions):
Submit a mechanistic policy analysis for endpoint (hostA, 8090, python3.13) with auto-approve enabled (or approve the resulting chunk) so the chunk reaches approved.
Submit a second mechanistic analysis for the same (host, port, binary) (as any new denial for that endpoint would).
Observe: put_draft_chunk dedups into the approved row and returns its id; self_reject_mechanistic_if_already_covered flips it to rejected; hit_count incremented; no remove_chunk_from_policy call.
Expected: the approved chunk stays approved (hit count may increment); only genuinely new pending drafts are ever auto-rejected.
Live sketch: sandbox with an approved mechanistic rule for an endpoint whose L7 policy still denies some method (e.g. DELETE); trigger that method from inside the sandbox; wait for the denial flush; watch the rule flip in the TUI.
Suggested fix
let covered_by = approved
.iter().find(|c| c.id != new_chunk_id && c.host == host && c.port == port && c.binary == binary);
plus, defensively, only reject when the effective chunk's current status is pending (the dedup upsert can return a non-pending row; nothing downstream should treat it as a fresh draft). I am happy to submit this as a PR with the regression test above (first-time external contributor — I understand vouching applies).
Agent Diagnostic
Investigation started from NVIDIA/NemoClaw#6329 (approved network rule shown as
rejectedmid-session with no human action). Traced through this repo's source rather than a live cluster:crates/openshell-server/src/grpc/policy.rs(SubmitPolicyAnalysisflow:put_draft_chunk->supersede_other_pending_chunks_for_endpoint->self_reject_mechanistic_if_already_covered->auto_approve_chunk),crates/openshell-server/src/persistence/sqlite.rs(put_draft_chunkupsert),crates/openshell-server/src/policy_store.rs(observation_dedup_key),crates/openshell-sandbox/src/lib.rs(flush_proposals_to_gateway),crates/openshell-sandbox/src/mechanistic_mapper.rs(generate_proposals,generate_rule_name)."rejected": the supersede pass (scanspendingonly), the humanRejectDraftChunkhandler (also un-merges viaremove_chunk_from_policyand emits aremovedaudit log), andself_reject_mechanistic_if_already_covered. Only the last can flip anapprovedrow without human action.main(abe42fb5) and on thev0.0.72tag (the reporter's deployed version), and that the self-reject path first shipped inv0.0.53(e98ea3ee, feat(policy): add agentic approval loop #1528).Description
An approved mechanistic draft chunk can be flipped to
rejectedby the gateway itself, with no human action, leaving the governance ledger inconsistent with the enforced policy.Chain (all in
SubmitPolicyAnalysis):A denial inside the sandbox triggers the denial-analysis flush, which resubmits a mechanistic proposal for the denied endpoint (
flush_proposals_to_gateway->generate_proposals; denials are grouped by(host, port, binary)with no stage filter, so L7 method denials resubmit too).put_draft_chunkupserts mechanistic chunks ondedup_key = "{host}|{port}|{binary}". On conflict it only bumpshit_count, andRETURNING "id"hands back the existing row's id. If the endpoint's chunk was already approved,effective_idnow names the approved chunk.self_reject_mechanistic_if_already_covered(state, sandbox_id, &effective_id, host, port, binary)scans approved chunks for one covering(host, port, binary):It neither excludes
new_chunk_idfrom the scan nor checks that the chunk it is about to reject is stillpending. The approved chunk "covers itself" and getsupdate_draft_chunk_status(new_chunk_id, "rejected", ...)— rejected with reason "already covered by approved chunk ".Consequences:
handle_reject_draft_chunk_inner), this path never callsremove_chunk_from_policy. The merged policy still contains the rule, so enforcement and ledger now disagree: TUI/audit show[rejected] review requiredwhile the rule is still active. An operator reading the TUI believes access was revoked when it is not.rejectedrow (hit_countkeeps climbing), andauto_approve_chunkonly approves frompending, so the endpoint can never be re-approved through this path.Field evidence (NVIDIA/NemoClaw#6329): rule
allow_172_17_0_1_8090— the exactgenerate_rule_namemechanistic pattern — went from[approved] 50% auto-approved 17xto[rejected] 50% review required 18xbetween two screenshots hours apart; the +1 hit count is the dedup upsert'sON CONFLICTbump, and the logs show an L7DELETE /v1/mcpdenial followed byFlushed denial analysis to gatewayright in that window.Reproduction Steps
Unit-level (drives the exact production functions):
(hostA, 8090, python3.13)with auto-approve enabled (or approve the resulting chunk) so the chunk reachesapproved.(host, port, binary)(as any new denial for that endpoint would).put_draft_chunkdedups into the approved row and returns its id;self_reject_mechanistic_if_already_coveredflips it torejected;hit_countincremented; noremove_chunk_from_policycall.Expected: the approved chunk stays
approved(hit count may increment); only genuinely new pending drafts are ever auto-rejected.Live sketch: sandbox with an approved mechanistic rule for an endpoint whose L7 policy still denies some method (e.g. DELETE); trigger that method from inside the sandbox; wait for the denial flush; watch the rule flip in the TUI.
Suggested fix
plus, defensively, only reject when the effective chunk's current status is
pending(the dedup upsert can return a non-pending row; nothing downstream should treat it as a fresh draft). I am happy to submit this as a PR with the regression test above (first-time external contributor — I understand vouching applies).Environment
v0.0.72andmain(abe42fb5); introduced inv0.0.53(e98ea3ee, feat(policy): add agentic approval loop #1528).Logs
TUI before/after (screenshots in NVIDIA/NemoClaw#6329):
allow_172_17_0_1_8090 [approved] 50% auto-approved 17x->allow_172_17_0_1_8090 [rejected] 50% review required 18x.