Skip to content

fix: align access policy and harden agent loops - #12

Merged
leslielin2025 merged 4 commits into
mainfrom
fix/policy-parity-and-safe-silent
Jul 22, 2026
Merged

leslielin2025 merged 4 commits into
mainfrom
fix/policy-parity-and-safe-silent

Conversation

@leslielin2025

@leslielin2025 leslielin2025 commented Jul 22, 2026 •

Copy link
Copy Markdown
Collaborator

What & why

Hermes v0.1.5 aligns most human DM/group policy surfaces with Zylos, but still had unsafe default drift, contradictory owner bypass ordering, substring mention matches, non-live local policy tools, model-invoking silent, and no fine-grained Agent loop circuit breakers.

This PR:

  • aligns human defaults and group ordering: DM=owner, group=allowlist, disabled is absolute, owner mention bypasses only missing group registration, and owner remains exempt from registered-group allowFrom;
  • uses boundary-aware Core display-name plus configured-alias matching, while Agent group traffic requires a direct structured member mention;
  • validates config events before mutation/report/callback and normalizes persisted policy state;
  • routes local DM policy tools through the live bridge as the single writer, with immediate runtime/persistence change and asynchronous reported-policy refresh;
  • makes silent bridge-only observation: bounded admitted text history + watermark advance, without attachment/work hydration, billing, ack reaction, Hermes session/model delivery, or reply;
  • adds human group rejection notices only for live explicit mentions, never replay/Agent/System/background traffic;
  • adds fail-closed Agent sender allowlisting, normal group scope/registration/allowFrom enforcement, propagated hop metadata, duplicate suppression, and per-sender/conversation turn budgets;
  • preserves retryability when Hermes delivery fails and keeps Agent loop rejections silent.

The vendored v1 contract files remain unchanged per contract/PROVENANCE.md; the local runtime overlay documents that silent remains policy-admitted (handle:true) but is consumed before the Hermes host callback.

Type of change

  • Bug fix
  • New feature
  • Refactor / chore
  • Docs
  • CI / tooling

How was it tested?

  • Full non-live suite with a minimal Gateway host stub: 169 passed, 1 live deselected
  • Standalone non-live suite: 154 passed, 1 live deselected
  • Gateway-dependent adapter/integration modules: 15 passed
  • python3 -m compileall -q cws_agent_sdk hermes_openmax tests
  • git diff --check
  • Added/updated regressions for defaults, owner ordering, aliases/boundaries, invalid events, live local policy writes, silent consumption, rejection notices, Agent authorization, direct mentions, hop/duplicate/turn breakers, delivery retry, causation propagation, persistence normalization, and inbox/conversation watermark separation

Security checklist

  • Semgrep (SAST) passes in CI
  • Gitleaks passes in CI
  • No scanner suppressions added

Reviewer notes

  • Agent-to-Agent behavior is intentionally stricter than Zylos human policy: both the feature flag and CWS_ALLOWED_AGENT_SENDERS are required, and Agent group traffic must directly mention this member.
  • Hop metadata is defense in depth because it is opaque message metadata. Authenticated sender identity, direct structured mentions, explicit allowlists, duplicate suppression, and local turn budgets are the enforceable loop boundaries.
  • silent history is in-memory and bounded; it deliberately does not hydrate attachments or invoke the model.
  • This PR does not merge or deploy anything.

@leslielin2025

Copy link
Copy Markdown
Collaborator Author

Blocking: OpenMax billing gate incorrectly blocks externally hosted Hermes agents

I reproduced the source of the following user-visible reply from the PR/runtime code:

⚠️ 本组织的 LLM 服务因账务原因已暂停,我暂时无法处理消息。请联系组织管理员前往账单页面处理。

This is not a Hermes/model response. It is the hard-coded OVERDUE_NOTICE in cws_agent_sdk/reporters.py, sent by the bridge when:

GET /api/v1/billing/plan-state
usage_snapshot.enforcement_suspended == true

The bridge then skips _on_message, marks the inbound message seen, advances its watermark, and returns (cws_agent_sdk/bridge.py, billing branch immediately before _ack_received).

For hermes-openmax, this is the wrong ownership boundary. The connected Hermes is an externally hosted agent using its own model/provider credentials; an OpenMax organization-level suspension for hosted LLM service does not establish that the external Hermes model is unavailable. However:

  • CwsBridge(..., billing_gate_enabled=True) is the default;
  • CwsAdapter.connect() does not override it;
  • therefore every externally connected Hermes is gated by OpenMax hosted-LLM billing state.

This produces a false billing reply while the Agent/WebSocket can still be shown as online, and discards the user's message without ever invoking Hermes.

There is a second replay issue: the billing notice is emitted for /sync replay too. A historical message can therefore receive this notice much later after reconnect/startup. We observed a concrete UI example where a message sent at 21:35 received the notice at 22:09. The exact reason for that 34-minute delay still needs runtime-log confirmation, but the current code definitely has no is_sync_replay guard around the billing notice.

Requested change

  1. Do not apply the OpenMax hosted-LLM billing gate to externally hosted Hermes agents. At minimum, instantiate the plugin bridge with billing gating disabled; preferably make the server contract explicitly identify whether OpenMax owns model billing for this Agent rather than inferring it from organization plan state.
  2. Do not send billing/rejection notices for replayed historical messages.
  3. Add regressions proving:
    • enforcement_suspended=true does not block delivery to external Hermes;
    • a /sync replay never emits a delayed billing notice;
    • transport/online state is not presented as evidence that a model turn ran.

I consider this merge-blocking because it causes valid external-agent messages to be consumed without delivery and sends users an inaccurate explanation.

@zylos-luna-coco zylos-luna-coco left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review — PR #12: align access policy and harden agent loops

Verdict: Approve

This is a well-structured security-hardening PR that tightens defaults, adds defense-in-depth agent loop guards, and fixes several correctness issues around delivery ordering and billing gate scope. The test coverage is thorough (154+ tests), the changes are internally consistent, and the breaking changes are intentional and documented.


Key Findings

Important (no blockers, but worth tracking)

1. mode=open is silently dropped as a valid group mode
_VALID_GROUP_MODES = {"mention", "smart", "silent"} — any existing group configured with mode=open (e.g., via a historical config event or persisted policy.json) will now be normalized to mention on load, which changes behavior from "receive everything" to "require @mention". The test for invalid config events (test_invalid_config_events_do_not_mutate_persist_report_or_callback) correctly rejects mode=open as invalid. This is fine as a deliberate tightening, but worth a note in migration docs since anyone who was using mode=open will see changed behavior.

2. Recursive _deliver_by_id in the inflight-wait path
When /sync encounters a realtime delivery still in progress and that delivery fails, the sync handler recursively calls _deliver_by_id. The recursion is bounded (it can only recurse once since the retry will either succeed or fail cleanly without re-entering the inflight path), and the _inflight/_inflight_done cleanup in the finally block ensures no leaked state. Verified correct, but worth a comment noting the single-depth recursion invariant for future maintainers.

3. Breaking default changes for existing unconfigured installations
dm_policy: "open" → "owner" and group_policy: "open" → "allowlist" are security improvements, but any installation that relied on the old permissive defaults without explicitly setting CWS_DM_POLICY / CWS_GROUP_POLICY will become restrictive after upgrade. Persisted policy.json state will preserve the old values if they were saved, but fresh starts will lock down. The adapter's _policy_from_env() handles this correctly. Migrations should note this.

4. Agent-to-agent integration now requires CWS_ALLOWED_AGENT_SENDERS
Previously CWS_ALLOW_AGENT_SENDERS=true was sufficient. Now the sender must also appear in CWS_ALLOWED_AGENT_SENDERS (fail-closed empty list). Existing agent-to-agent integrations will break unless reconfigured. This is documented in the PR description and plugin.yaml, and is the right security posture.

Minor

5. send_image_file has a local import of new_client_msg_id
(bridge.py, inside the function body) — new_client_msg_id is already available at module scope via from .codec import .... The local import is harmless but unnecessary.

6. agent_turn_window_s env var is cast through positive_int then float()
(adapter.py) — float(positive_int("CWS_AGENT_TURN_WINDOW_S", 60)) truncates fractional seconds. Operators likely expect integer seconds, but worth noting in the env description or using a direct float parse if sub-second precision matters.

7. Defensive getattr/hasattr for _agent_causation in adapter
The __init__ already initializes _agent_causation, so getattr(self, "_agent_causation", {}) in _with_agent_causation and hasattr in _on_inbound are redundant. Likely defensive for hot-upgrade scenarios where an older __init__ ran; fine to keep but could use a brief comment.

Nit

8. Wrapped line in README
"Group messages therefore\nintentionally have no per-member Hermes user_id" — the sentence break at "therefore" reads slightly awkward across the line wrap.


What I verified

  • Policy ordering: disabled is now absolute (no owner bypass); owner mention bypass applies only for unregistered groups in allowlist mode; owner is exempt from registered group allowFrom. All three are tested and correct.
  • Agent loop guards: hop validation is strict (rejects bool, float, 0, negative), duplicates use content fingerprinting, turn budget is per-sender-per-conversation with time expiry and capacity bounds. Defense-in-depth layering is sound.
  • Silent mode: Bridge-only observation confirmed — no sender name resolution, no attachment hydration, no ack reaction, no model delivery. History caching is bounded. Watermarks advance correctly.
  • Delivery ordering: Realtime frames no longer commit the global inbox cursor; /sync replays advance it in server order under the sync lock. The inflight-done event mechanism correctly handles the race between realtime and sync delivery of the same message.
  • Billing gate: Default flipped to False, adapter explicitly passes False, sync replay never emits billing notice. All three regressions are present.
  • Config event validation: All six event types validated before mutation/persist/report/callback. Invalid events short-circuit with a warning log.
  • Policy persistence normalization: Corrupt policy.json values are sanitized on load, falling back to safe defaults. The test_corrupt_persisted_policy_falls_back_to_safe_normalized_state test covers this well.
  • Boundary-aware mention matching: @Name does not match @NameSuffix or @Name-team; aliases work; Agent group traffic requires structured mentions only.
  • Test coverage: 15+ new test functions covering defaults, owner ordering, agent guards, config validation, delivery ordering, billing, silent mode, reject notices, persistence normalization, outbound metadata, directory plugin bootstrap, and WS connect-wait.

Clean approve. Solid work.

@leslielin2025
leslielin2025 merged commit 92e3439 into main Jul 22, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants