Harden inbound policy gate - #4
Open
raysvitla wants to merge 8 commits into
Open
Conversation
Extend the policy reason vocabulary with the four fail-closed tokens the hardened gate will need: - malformed-event: structural fields missing/wrong type - unknown-event-kind: kind is neither 'group' nor 'dm' - duplicate-event: same messageId already evaluated successfully - not-a-member: gate-level membership recheck failed (defence-in-depth) POLICY_REASONS exports the full, sorted vocabulary so tests can lock the public surface — any future addition or rename surfaces as a deliberate diff. validateInboundEvent (packages/node/src/policy/validate.ts) is a pure, strict, defence-against-direct-injection structural check. The function intentionally takes `unknown` so the trust boundary holds even when TypeScript would otherwise vouch for the input. AgentPolicy.decide() keeps its typed input; the gate (next commit) is responsible for calling validate() first and short-circuiting fail-closed paths before invoking decide(). Adds 13 tests covering null/non-object input, unknown kind, missing/empty required fields, wrong types on plaintext/senderPublicKey, non-finite numbers, missing groupId for group events, prototype-pollution-style inputs, and the stability of POLICY_REASONS itself. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
PolicyGate is the new chokepoint between authenticated/decrypted/persisted
inbound events and any agent-runtime side effect. Order of operations:
1. validateInboundEvent (structural; fail-closed: malformed-event /
unknown-event-kind). Never invokes AgentPolicy.decide on invalid
input.
2. dedup check (fail-closed: duplicate-event).
3. for group events, isMember(...) recheck via injected predicate
(fail-closed: not-a-member). Defence-in-depth against direct
in-process injection.
4. AgentPolicy.decide(ev) — pure.
5. audit.record(entry) — metadata-only PolicyAuditEntry.
6. mark messageId in dedup ONLY here, post-audit. If any earlier step
fails, or audit.record throws, the messageId is NOT remembered, so a
legitimate retry is re-evaluated rather than silently denied.
7. emit('decision', decision); return GateOutcome.
PolicyAuditEntry (in @networkselfmd/core) is metadata-only by
construction: eventKind, optional messageId / groupIdHex /
senderFingerprint, byteLength of plaintext (size only — no content),
action, reason, decision booleans, gateRejected flag. New fields require
explicit review against the privacy invariant. redactPlaintext() is the
canonical helper for any new code that handles plaintext but needs to
log size.
PolicyAuditLog is an in-memory bounded ring buffer (default 1000). No
persistence in this PR — durability is a follow-up concern.
10 unit tests cover happy path, all four fail-closed branches, dedup
ordering, FIFO eviction at capacity, retry-poison invariant for
validation-fail / membership-fail / audit-throw paths, and a canary
proving plaintext never appears in the audit entry's serialized form.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The inboundQueue.push() and Agent-level inbound:message re-emission are
now both downstream of PolicyGate.evaluate(). They run only when the gate
returns allowed: true. Validation, dedup, membership recheck, and the
pure decision all happen first; the audit entry is recorded regardless
of the outcome.
- Agent constructs PolicyAuditLog, AgentPolicy, and PolicyGate during
start() once repos and identity are ready.
- AgentOptions gains policyConfig (initial gate configuration; defaults
to {}, which means AgentPolicy.decide returns ignore/not-addressed for
unaddressed/untrusted/no-interest events — unconfigured agents do not
surface noise) and policyAuditMax (audit ring buffer capacity).
- agent.setPolicyConfig(config) updates the gate's policy in place;
decisions are pure over (config, identity, event) so the next event
picks it up without resetting audit/dedup.
- agent.policy / agent.policyGate / agent.policyAudit are exposed for
introspection.
- Two new Agent-level events: 'policy:decision' (live, decision payload)
and 'policy:audit' (live, full PolicyAuditEntry — useful when the gate
rejected before decide() ran).
- Legacy 'group:message' from GroupManager still fires unchanged
(additive, no listener regressions).
Public API extension: AgentPolicy.setConfig replaces the internal config
in place; existing tests and the AgentPolicy.decide signature are
unchanged.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Drives the full Agent inbound flow (post-decrypt GroupManager event →
PolicyGate → queue / emission / audit) and asserts the nine adversarial
scenarios from the hardening mission:
1. allowed member event reaches the queue and emits inbound:message
2. denied non-member event blocked at the gate (reason=not-a-member)
3. unknown event kind fails closed (reason=unknown-event-kind)
4. malformed event fails closed across five mutation shapes (null,
missing fields, wrong plaintext type, NaN timestamp, empty messageId)
5. duplicate (same messageId) does not double-emit nor double-queue
5b. dedup does NOT poison a retry that follows a transient malformed
failure on the same messageId
6. plaintext canary appears in neither audit entries nor decisions when
serialized
7. POLICY_REASONS exports a stable, locked vocabulary
8. ordering: policy:audit fires BEFORE the queue push and before the
public inbound:message event (gate is post-audit only on success)
9. queue is post-gate: denied events from all four paths leave it empty
Tests use the existing hyperswarm/hyperdht mocks so a real Agent.start()
runs without network. Inbound events are injected by emitting on the
Agent's internal GroupManager — exactly the production seam the gate
subscribes to.
10 new test cases. Total node-package tests: 110 (was 100).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Three property tests with seeded mulberry32 RNG (no fast-check dep):
1. Malformed payloads never throw, never proceed (N=500). For each
iteration the gate must (a) return a structured GateOutcome and
(b) deny — allowed:false. Every iteration adds one audit entry.
2. Random plaintext never leaks into audit/log output (N=500). Each
event embeds a canary token ('PLAINTEXT-CANARY-TOKEN-ZZ') in plaintext;
for the duration of the loop console.log/info/warn/error/debug and
process.stdout.write/stderr.write are intercepted. The canary must
appear in zero captured lines and zero audit-entry serializations.
3. byteLength tracks plaintext.byteLength exactly; PolicyAuditEntry keys
are locked. The third assertion — Object.keys(entry).sort() — fails
loudly the moment anyone adds a new (potentially content-bearing)
field to PolicyAuditEntry, which is the privacy contract.
Seeds are pinned (printed in failure messages) so a CI flake is exactly
reproducible locally. Total node-package tests: 113 (was 110).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Owner-private, local-only MCP tool for inspecting recent policy gate decisions. Tool description explicitly warns against forwarding results to public dashboards/census/shared logs. toPolicyAuditDTO is an explicit projection — it enumerates every field copied from PolicyAuditEntry rather than spread-rest. If a future PR adds a field to the audit entry (intentionally or otherwise) it will NOT auto-propagate through the MCP surface; defence in depth. 6 leak tests cover: - byte-for-byte preservation of privacy-safe fields - pollution rejection: plaintext/decryptedBody/toolArgs canaries stuffed onto an entry never appear in the DTO's JSON - caller cannot mutate the audit log via the returned DTO (matchedInterests is sliced on the way out) - optional fields produce no "key": undefined leakage in JSON - byteLength survives without any byte content keys - the published DTO key set is locked to the metadata vocabulary Total mcp-package tests: 14 (was 8). Cumulative tests: 186. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
POLICY.md covers: - the full inbound event lifecycle (network frame → GroupManager → PolicyGate → queue / emission), with explicit ASCII diagram - where policy is enforced (and what's NOT under the gate's privacy invariant — namely the owner-private message store) - the act/ask/ignore decision table and addressedToMe semantics - the stable POLICY_REASONS vocabulary, split into decision reasons and fail-closed reasons - the privacy invariant: explicit list of what is and is not allowed in audit / decision / MCP surfaces, and the three test layers that enforce it (adversarial, fuzz, MCP DTO) - the dedup retry-poison invariant and the three poison scenarios that are tested - the future tool-execution extension point — explicitly NOT implemented in this PR; consumers subscribe to 'policy:decision' for act-actions ARCHITECTURE.md gains a short "Policy gate" subsection that links to POLICY.md and summarizes where the gate sits in the pipeline. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…allbacks, readonly fields) Address concrete issues found in self-review of PR #4: 1. PolicyAuditLog stored entries by reference. Callers reading via recent() could mutate the audit trail (entries[0].action = 'act'; entries[0].matchedInterests.push(...)). record() now structuredClones the entry and Object.freezes both the entry and its matchedInterests array. Audit integrity no longer depends on caller discipline. 5 new tests in policy-gate-immutability cover input-mutation-after-record, frozen-entry-throws-on-write, matchedInterests-is-frozen, record-returns-frozen-copy, and recent()-stability. 2. PolicyGate.evaluate invoked the isMember callback unguarded. A db hiccup in groupRepo.getMembers would throw out of the EventEmitter listener registered by Agent.setupGroupManagerEvents and record no audit row. The callback is now wrapped in try/catch; failure collapses to fail-closed reason 'not-a-member' with an audit row. Dedup is not poisoned, so a retry after the predicate recovers is re-evaluated. 3. PolicyGate.evaluate emitted 'decision' synchronously inside the evaluate() body. A buggy listener could throw, propagate out of evaluate, abort the gate's return path, and desynchronize audit/ dedup from the queue/emit downstream. Wrapped in try/catch with queueMicrotask rethrow so listener bugs surface on the next tick without breaking gate flow. Same pattern as InboundEventQueue. 4. agent.policy / policyGate / policyAudit are now readonly. Matches the existing convention on agent.inboundQueue and prevents callers swapping the gate out from under setupGroupManagerEvents wiring. One-time assignment in start() uses a typed cast so the seam is internal. 5. MCP get_policy_audit_recent.limit gains a .max(1000) clamp on top of the existing .int().positive(). Prevents oversized JSON responses; the audit log itself caps at policyAuditMax (default 1000) so larger limits were dead weight anyway. Test covers the accept/reject grid (1, 50, 1000, 1001, MAX_SAFE_INTEGER, 0, -1, NaN, '50', undefined). 6. POLICY.md gains an explicit "Legacy event compatibility" section noting that 'group:message' bypasses the gate (carries plaintext as .content for backward-compat consumers). The privacy invariant applies to the gate / audit / decision / MCP audit surfaces only; legacy listeners are the consumer's responsibility. New code should listen on 'inbound:message'. 8 new tests; total 194 (was 186). All previous tests unchanged. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This was referenced Apr 25, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Depends on #3.
Turns the policy-runner skeleton into a hardened single chokepoint between authenticated network reception and any agent-runtime side effect. Every event reaching
Agent.inboundQueueor firinginbound:messageon the Agent has now passedPolicyGate.evaluate(). Fail-closed paths produce no side effect.No tool execution. No plaintext logging. Fails closed.
Architecture
The gate is the only writer of
Agent.inboundQueueand the only emitter of Agent-levelinbound:message.'group:message'(legacy) is preserved unchanged.Files changed
New source (8 files):
packages/core/src/policy/audit.ts—PolicyAuditEntry(metadata-only) +redactPlaintexthelper.packages/node/src/policy/validate.ts— strictvalidateInboundEvent(unknown)returning a typed result.packages/node/src/policy/audit-log.ts— bounded in-memory ring buffer.packages/node/src/policy/policy-gate.ts— the gate itself.packages/mcp/src/tools/policy.ts—get_policy_audit_recentMCP tool +toPolicyAuditDTO.Modified (5 files):
packages/core/src/policy/types.ts— extendsPolicyReasonwith the 4 fail-closed tokens; exports stablePOLICY_REASONSvocabulary.packages/node/src/policy/agent-policy.ts— addssetConfig(decisions remain pure).packages/node/src/agent.ts— constructsPolicyAuditLog/AgentPolicy/PolicyGateinstart(); routesgroupManager.on('inbound:message')through the gate; addssetPolicyConfig,'policy:decision','policy:audit'.packages/node/src/index.tsandpackages/mcp/src/server.ts— register the new exports/tool.docs/ARCHITECTURE.md— adds policy-gate subsection cross-linking POLICY.md.New docs:
docs/POLICY.md— full lifecycle, decision table, reason codes, privacy invariant, dedup retry-poison invariant, future tool-execution extension point (explicitly NOT implemented).Diffstat: 18 files, 1871 insertions, 3 deletions.
Tests added (42 new; total 186; previous 144 unchanged)
policy-validate.test.tspolicy-gate-unit.test.tspolicy-gate-adversarial.test.tspolicy-gate-fuzz.test.tspolicy-audit-dto.test.tsplaintext,decryptedBody,toolArgs) stripped; locked DTO key set; matchedInterests is sliced (caller cannot mutate audit log)Invariants protected
messageId, and non-member sender all bypassAgentPolicy.decideentirely and produce no side effect (no queue push, no public re-emit).messageIdenters the dedup set ONLY after validation, membership recheck, the decision, and the audit write all succeed. Validation/membership failure oraudit.recordthrow does NOT poison the dedup set; legitimate retries with the samemessageIdare re-evaluated.AgentPolicy.decideis unchanged in signature and remains pure; validation/dedup/audit/membership all live outside it.PolicyAuditEntryand a separate explicit-key test onPolicyAuditDTOfail the moment any new (potentially content-bearing) field is added without deliberate review.POLICY_REASONSis exhaustively listed and asserted in tests; renaming or adding a token is a deliberate diff.'group:message'event payload is unchanged; existing 144 tests pass without modification.messages.content TEXTcolumn (plaintext at rest, owner-only) is intentionally outside the privacy invariant. No schema/storage changes in this PR.Known limitations
Agent.handleDirectMessageis still fail-closed per fix: harden group protocol and key storage #1. The gate acceptskind: 'dm'for forward compatibility, but no DM emitter exists. Wires when DM signing / Double Ratchet lands.{}). Without a configured mention/trust/interest list,decidereturnsignore/not-addressedfor everything; the queue stays empty. Production deployments configure viaAgentOptions.policyConfigoragent.setPolicyConfig(...).agent.on('policy:decision', ...)for action;actactions are NOT auto-invoked.SenderKeys) is the network layer's responsibility.Hard constraints honored
Agent.policy/policyGate/policyAuditsurfaces, two new'policy:decision'/'policy:audit'events,setPolicyConfig. All documented in POLICY.md and exercised by tests.Exact commands run
🤖 Generated with Claude Code