Skip to content

Approved mechanistic policy chunk rejects itself when a denial resubmits the same endpoint (dedup upsert id aliasing in SubmitPolicyAnalysis) #2165

Description

@Hokonoken

Agent Diagnostic

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:

  • Read crates/openshell-server/src/grpc/policy.rs (SubmitPolicyAnalysis flow: 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_chunk upsert), 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).
  • 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).
  • Not reproduced on a live gateway; this is a source-level trace. The reporter-side evidence (screenshots + OCSF logs in nemoclaw openshell container updating network policy during agent turns NemoClaw#6329) matches every step.

Description

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):

  1. 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).

  2. 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.

  3. 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):

  1. 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.
  2. Submit a second mechanistic analysis for the same (host, port, binary) (as any new denial for that endpoint would).
  3. 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).

Environment

Logs

2026-07-06T13:16:38.626Z OCSF HTTP:DELETE [MED] DENIED /usr/bin/python3.13(392063) -> DELETE http://172.17.0.1:8090/v1/mcp [policy:spiceai engine:l7] [reason:FORWARD_L7 deny DELETE 172.17.0.1:8090/v1/mcp reason=DELETE /v1/mcp not permitted by policy]
2026-07-06T13:16:44.916Z INFO openshell_sandbox: Flushed activity summary to gateway
2026-07-06T13:16:44.917Z INFO openshell_sandbox: Flushed denial analysis to gateway

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    state:triage-neededOpened without agent diagnostics and needs triage

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions