Skip to content

fix(security): validate agent_id character set on registration - #16

Merged
dundas merged 20 commits into
mainfrom
fix/agent-id-validation
Feb 26, 2026
Merged

fix(security): validate agent_id character set on registration#16
dundas merged 20 commits into
mainfrom
fix/agent-id-validation

Conversation

@dundas

@dundas dundas commented Feb 26, 2026

Copy link
Copy Markdown
Owner

Summary

Multi-layer defense against agent_id injection attacks. Validates at registration, envelope validation, and storage — ensuring no unsafe ID can be registered, routed, or persisted.

Security Issues Fixed

  • Newline injection: signing string injection via (request-target) canonical string — Critical
  • Slash: path traversal in URL routing — High
  • Encoded slash %2F: not in allowlist so blocked by character check — High
  • Angle brackets: XSS if agent IDs rendered in HTML — Medium
  • Spaces, null bytes, backslashes: URL encoding / escaping issues — Medium

Changes

src/services/agent.service.js (registration):

  • 3-step validation: (1) length <= 255 (O(1) guard), (2) regex ^[a-zA-Z0-9._:-]+$, (3) reserved-prefix guard rejects did: and agent: prefixes (case-insensitive)
  • Auto-generated ID changed from agent://agent-uuid to agent-uuid (old format failed its own validation)

src/services/inbox.service.js (envelope validation):

  • isValidAgentId() replaces old startsWith() prefix check with full-string anchored regexes, closing injection-via-suffix vector
  • Accepts: bare agent IDs, agent:// URIs (backward-compat for pre-PR#16 senders), did:seed: DIDs, and DID:web canonical form (did:web:domain.com:users:alice) — the latter now passes SAFE_CHARS to support federated DID:web agents
  • Intentionally more permissive than registration: envelope layer cannot know whether a given ID was ever registered

src/middleware/auth.js (DID:web shadow agent creation):

  • Added SAFE_DID_DOMAIN and SAFE_DID_SEGMENT checks on DID:web domain and path segments before constructing agent_id or issuing HTTP requests (SSRF + injection guard)
  • SAFE_DID_DOMAIN strips colons (not valid in hostnames); SAFE_DID_SEGMENT allows colons (valid per W3C DID spec)

src/storage/index.js (backstop):

  • Proxy wrapper on createAgent blocks control characters and backslashes for all storage writes
  • Only createAgent intercepted; update paths have their own guards and never change agent_id

Valid ID formats (registration)

auth.backend, clearauth-gm, decisive_redux, colons:ok all pass.

Reserved prefixes

did: and agent: prefixes rejected at registration (case-insensitive). Accepted in envelopes from/to for backward compatibility.

Known gaps (tracked)

Issue #17: DID:web bypass now mitigated in resolveDIDWebAgent() and backed by storage Proxy.

Generated with Claude Code

Block characters dangerous in URLs and HTTP headers:
- Newlines: signing string injection via (request-target)
- Slashes: path traversal in URL routing
- Spaces, null bytes, shell metacharacters, angle brackets (XSS)

Allowed: [a-zA-Z0-9._\-:] — covers all real patterns in use
(auth.backend, clearauth-gm, decisive_redux, did-web:domain.com)

Also fix auto-generated agent_id: was `agent://agent-<uuid>` which
contains `://` and would fail its own validation. Changed to `agent-<uuid>`.

DID:web shadow agents call storage.createAgent() directly and are
unaffected — system-generated IDs are trusted at that layer.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@claude

claude Bot commented Feb 26, 2026

Copy link
Copy Markdown

Code Review: fix(security): validate agent_id character set on registration

Overall this is a well-scoped, high-value security fix. The allowlist regex approach is the right call, and the PR description is excellent - clear threat model, severity ratings, and explicit callout of what is not protected (DID:web shadow agents). A few observations below.


The core fix is correct

The old default ID format (agent://agent-UUID) contained two forward slashes in the scheme prefix, which fail the new regex. Stripping the scheme prefix to agent-UUID is the minimal correct fix.


Issues and suggestions

1. No maximum length enforcement (Medium)

The regex enforces a minimum of 1 character via +, but there is no upper bound. An agent_id used in URL paths, HTTP headers, and database keys should have a reasonable ceiling to prevent abuse and avoid silent truncation at the storage layer.

Suggested addition after the regex check (line 42):

if (agent_id.length > 255) {
  throw new Error('agent_id must be 255 characters or fewer');
}

2. Dead code path: double getAgent call (Low - pre-existing)

Lines 44-48 guard against an already-existing agent and throw if found. Lines 124-130 then call storage.getAgent(agent_id) again and attempt to preserve a previous registration_status. Because the function already threw at line 47, existingAgent at line 125 will always be null, making lines 126-128 unreachable. This is pre-existing and not introduced by this PR. If re-registration is an intended code path, the early throw at line 47 should be removed and the flow consolidated at lines 124-130.

3. DID:web bypass documented but not guarded (Low)

The PR description notes that DID:web shadow agents call storage.createAgent() directly and bypass register() entirely. If DID:web agent IDs arrive from an untrusted source (e.g. a DNS response or HTTP header), those IDs would reach storage unvalidated. Consider adding the same regex check inside storage.createAgent() as a defense-in-depth measure.

4. Missing tests for the new validation (Medium)

No test files appear in the diff. For a security-focused change, tests covering the regex would prevent regressions. Suggested cases:

  • Valid: auth.backend, clearauth-gm, decisive_redux, did-web:domain.com
  • Valid: auto-generated agent-UUID format
  • Invalid: values with slashes, newlines, spaces, null bytes, angle brackets

5. %2F clarification (Trivial)

The PR description lists encoded slash %2F as a blocked character. It is blocked, but because % itself is not in the allowlist - not because %2F is matched as a unit. A small wording update would make this clearer.


Summary

Concern Severity Introduced by PR?
No max length on agent_id Medium Yes
Dead code: double getAgent call Low No (pre-existing)
DID:web bypass unguarded at storage layer Low No (pre-existing, documented)
No tests for validation logic Medium Yes
%2F description slightly misleading Trivial Yes

The security intent is solid and the fix is correct. Recommend adding a length cap and at minimum a few unit tests before merging.

@greptile-apps

greptile-apps Bot commented Feb 26, 2026

Copy link
Copy Markdown

Greptile Summary

Fixes auto-generated agent_id format to be compatible with the validation regex added in the base commit. Changes default ID from agent://agent-{uuid} to agent-{uuid}, removing the forward slashes that would cause validation to fail.

Changes:

  • Removed agent:// prefix from auto-generated IDs (now generates agent-{uuid} format)
  • Ensures auto-generated IDs pass the existing character set validation: ^[a-zA-Z0-9._\-:]+$

Note: DID:web shadow agents that call storage.createAgent() directly still bypass this validation entirely. Consider adding validation at the storage layer or in the DID:web code path to ensure all agent IDs meet security requirements.

Confidence Score: 5/5

  • This PR is safe to merge and fixes a critical bug
  • Single line change that corrects a format mismatch between auto-generated IDs and validation regex; no logical issues or side effects
  • No files require special attention

Important Files Changed

Filename Overview
src/services/agent.service.js Fixed auto-generated agent_id format to pass validation regex by removing agent:// prefix

Last reviewed commit: 90818cf

…alidation

- Strip agent:// prefix from all test agent_id values (34 occurrences) to
  match the new validation regex that forbids slashes in agent IDs
- Update inbox.service validateEnvelope to accept bare agent IDs (matching
  ^[a-zA-Z0-9._-:]+$) in addition to agent:// URIs and did:seed: DIDs
  for backward compatibility

This fixes CI test failures caused by the agent_id character validation
introduced in the previous commit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@dundas

dundas commented Feb 26, 2026

Copy link
Copy Markdown
Owner Author

CI Fixes Pushed

Two issues were found and fixed:

1. Test helper used agent:// prefix (breaking the new validation)

The registerAgent() helper and 34 explicit test registrations used agent:// prefixed IDs, which now fail the new character validation regex (slashes not allowed). Stripped the prefix from all test IDs.

2. Envelope from/to validation was too strict

inbox.service.validateEnvelope() only accepted agent:// and did:seed: schemes — rejecting bare agent IDs. Updated to also accept bare IDs matching ^[a-zA-Z0-9._\-:]+$ for backward compatibility.

This is actually a feature: agents using plain IDs (e.g. my-agent) can now send/receive messages without needing the agent:// wrapper in envelopes. Legacy agent:// URIs in envelopes are still accepted.

All tests pass with in-memory storage (136 pass, 2 mech-specific skipped).

@claude

claude Bot commented Feb 26, 2026

Copy link
Copy Markdown

PR Review: fix(security): validate agent_id character set on registration

Good security fix overall. The core intent — blocking dangerous characters in agent IDs — is sound and the identified vulnerabilities (newline injection in signing strings, path traversal) are real. A few issues worth addressing before merge.


Bugs / Security Gaps

1. validId() in inbox.service.js still allows unsanitised agent:// URIs

The agent:// branch in validId() accepts any string that starts with agent://, with no validation on the suffix. This means agent://foo\nbar, agent://../secret, and agent://<script> all pass envelope validation — the exact attacks this PR aims to block.

const validId = (id) =>
  id.startsWith('agent://') ||   // no secondary check; still injectable
  id.startsWith('did:seed:') ||  // same problem
  /^[a-zA-Z0-9._\-:]+$/.test(id);

Consider validating the suffix after the scheme, or rejecting agent:// entirely in envelopes now that registration no longer produces them.

2. Same gap applies to the did:seed: prefix

did:seed:\n or did:seed://../../ pass validId(). The suffix needs the same whitelist treatment as bare IDs.

3. did:seed: with an empty suffix passes

'did:seed:'.startsWith('did:seed:') is true. A minimum-length guard on the suffix would help.


Missing Test Coverage

The test changes update fixtures to the new bare-ID format, but there are no negative tests for the validation logic itself. The following scenarios should be covered:

// Should all return 400
await registerAgent('agent://injected');   // old format now invalid
await registerAgent('bad\nagent');         // newline injection
await registerAgent('../traversal');       // path traversal
await registerAgent('has spaces');         // spaces
await registerAgent('angle<brackets>');    // XSS

Without these, a future refactor could silently break the security property with no signal.


Inconsistency: Registration vs. Envelope Validation

Registration (agent.service.js) now blocks agent:// prefixes entirely. But inbox.service.js still accepts agent:// in envelope from/to fields. This asymmetry means:

  • New agents: bare IDs at registration, but the envelope layer still accepts agent:// from external senders.
  • Existing agents registered with agent:// IDs (pre-migration): can still send messages but cannot re-register.

If the intent is to deprecate agent:// as an ID format, inbox.service.js should reject it too. If backwards-compat is required, that should be noted explicitly.

The PR description also acknowledges DID:web shadow agents bypass register() entirely — meaning they bypass character-set validation too. The invariant that all agent IDs are safe does not hold system-wide until those paths are covered.


Minor Notes

  • The auto-generated ID change (dropping agent:// prefix) is a breaking change for any client that pattern-matches on agent:// in returned IDs. Worth noting in the changelog.
  • The regex [a-zA-Z0-9._\-:] works correctly (\- is an escaped hyphen), but conventional form places the hyphen at the end: [a-zA-Z0-9._:-]. Purely stylistic.

Summary

Area Verdict
Core security intent Correct and necessary
Auto-generated ID fix Fixes its own validation failure
Test fixture updates Thorough and mechanical
agent:// suffix validation gap Still injectable after scheme
Negative test coverage Missing
did:seed: suffix validation Same gap, lower exploitability
Migration story for existing IDs Not addressed

Overall a worthwhile fix — just needs the injection gap closed and targeted negative tests before landing.

…h, CLI v0.2.1

Updated all 6 docs outputs from source (docs-generator.json):

- agent_id now enforces ^[a-zA-Z0-9._\-:]+$ — documented in all guides
- Auto-generated IDs use agent-<uuid> format (no agent:// prefix)
- Envelope from/to now accept bare agent IDs alongside agent:// and did:seed:
- Cross-agent send (POST /agents/:id/messages) auth model clarified
- @agentdispatch/cli@0.2.1 subpath exports updated in CLI reference
- Added missing error codes (MASTER_KEY_REQUIRED, webhook/tenant errors)
- Architecture diagrams updated with cross-agent auth flow

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@claude

claude Bot commented Feb 26, 2026

Copy link
Copy Markdown

PR Review: fix(security): validate agent_id character set on registration

Overall this is a well-motivated security fix. The threat model is correct and the auto-generated ID bug fix is definitely needed. A few issues worth addressing before merge:


Critical / High

1. inbox.service.js — Validation gap on agent:// and did:seed: branches

The new validId function short-circuits on scheme prefix without validating the rest of the URI:

const validId = (id) =>
  id.startsWith('agent://') ||   // ← anything goes after the prefix
  id.startsWith('did:seed:') ||
  /^[a-zA-Z0-9._\-:]+$/.test(id);

A message envelope with from: "agent://evil\nX-Injected: header" passes this check. The same newline injection the PR title is protecting against in registration is still possible in envelope from/to fields for legacy-scheme identifiers.

Suggest tightening to validate the full URI, e.g.:

const validId = (id) =>
  /^agent:\/\/[a-zA-Z0-9._\-:]+$/.test(id) ||
  /^did:seed:[a-zA-Z0-9._\-:]+$/.test(id) ||
  /^[a-zA-Z0-9._\-:]+$/.test(id);

2. No length cap on agent IDs

Neither the registration validator nor the envelope validId enforces a max length. An arbitrarily long agent ID string will be stored, logged, and potentially used in signing strings. A 255-char cap is conventional and cheap to add.


Medium

3. Envelope validation change is undocumented in the PR scope

The PR title says "validate agent_id character set on registration" but inbox.service.js changes envelope from/to validation — a broader change than registration. This is correct behaviour (agents now register without agent:// prefix so envelopes need to accept bare IDs), but deserves explicit mention in the PR summary and ideally its own test case.

4. Missing negative test for from/to envelope validation

server.test.js updates existing agent IDs but adds no tests for the new inbox.service.js validation path. Suggest adding:

  • A message with from: "evil\ninjected" → expect 400/rejection
  • A message with a legacy agent:// from field → expect pass (backward-compat)

5. Backward compatibility for existing stored agents

Registration now rejects agent:// prefixes. Do any existing agents in the database have agent:// prefixed IDs? If so, their subsequent API calls using their stored agent_id in URL paths would likely still work (since the validator only fires at registration), but it's worth a migration note or check in the PR.


Low / Nitpick

6. Regex character class ordering

/^[a-zA-Z0-9._\-:]+$/ — escaping - with \- inside a character class works but is non-idiomatic. Conventional forms: [a-zA-Z0-9._:-] (move to end) or [a-zA-Z0-9._\-:]. Minor, but keeps it consistent with the registration validator.

7. Doc changes bundled with security fix

The docs/ changes are a large restructuring (1,400+ line net change) bundled with a 2-line security fix. This makes the security change much harder to review and increases blast radius if a rollback is needed. Consider splitting into separate PRs.


What's clearly correct ✓

  • The core threat model (newline injection into signing strings, path traversal via /, encoded %2F bypass) is accurate and the whitelist approach is the right defense.
  • Fixing the auto-generated ID from agent://agent-uuid to agent-uuid is necessary — the old format failed its own validation.
  • Test updates consistently reflect the new ID format.
  • The inbox.service.js intent (accepting bare IDs alongside legacy schemes) is correct for backward compatibility.

The security fix itself is sound — the gaps above are cases where the protection could be more complete. Addressing items 1 and 4 before merge would close the remaining injection surface.

Reviewed by Claude Code (claude-sonnet-4-6)

…tive tests

Address code review feedback on PR #16:

- Fix validId() in inbox.service.js to validate full URI (not just prefix).
  Before: agent://evil\nX-Injected: header passed because startsWith('agent://')
  After: /^agent:\/\/[a-zA-Z0-9._:-]+$/ validates the full string.
  Same fix applied to did:seed: prefix.

- Add 255-char max length cap on agent_id at registration to prevent abuse
  and silent truncation at the storage layer.

- Fix regex style: move hyphen to end of character class ([a-zA-Z0-9._:-])
  to avoid ambiguity (conventional form, noted in review).

- Add negative test: 7 dangerous agent_id values all return 400
  (spaces, newlines, slashes, null bytes, XSS, agent:// scheme, >255 chars).

- Add negative test: 5 malicious envelope from fields all return 400
  (newline injection, agent:// with injected newline, did:seed: with newline,
  spaces, path traversal). Also verifies legacy agent:// is accepted.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@dundas

dundas commented Feb 26, 2026

Copy link
Copy Markdown
Owner Author

Review Feedback Addressed

Pushed fixes for all blocking and medium issues raised in code review:

Changes Made

File Change Addresses
src/services/inbox.service.js validId() now validates full URI with regex — not just prefix Blocking: agent://evil\ninjected no longer passes
src/services/agent.service.js Added 255-char max length cap on agent_id Medium: unbounded length
src/services/agent.service.js Regex hyphen moved to end ([a-zA-Z0-9._:-]) Nit: conventional regex style
src/server.test.js Negative tests: 7 bad agent_id values → 400 Medium: missing negative tests
src/server.test.js Negative tests: 5 malicious envelope from fields → 400; legacy agent:// still passes Medium: missing envelope injection tests

Fixes Summary

  • Blocking issues resolved: 1/1 (validId injection gap)
  • Medium issues resolved: 2/2 (length cap + negative tests)
  • Nits addressed: 1/1 (regex style)

Skipped (pre-existing, out of scope)

  • Dead code double getAgent call — pre-existing, separate PR
  • DID:web shadow agent bypass — pre-existing, documented in PR description

CI running — ready for re-review.

@claude

claude Bot commented Feb 26, 2026

Copy link
Copy Markdown

PR Review: fix(security): validate agent_id character set on registration

Good security-focused PR. The fix is well-targeted and the whitelist approach is the right call. Here's my analysis:


✅ Strengths

Security fix is correct and necessary. The agent:// prefix containing // (slashes) meant auto-generated IDs would fail their own validation — catching this is important. The signing-string injection via newlines in (request-target) is a real attack vector and blocking it at registration is the right layer.

Whitelist over blacklist. ^[a-zA-Z0-9._:-]+$ is tighter and more predictable than trying to enumerate banned characters.

Test coverage is solid. The parameterized rejection tests for 'has space', 'newline\ninjection', 'path/traversal', '<script>xss</script>', etc. are exactly the right shape. The matching "valid IDs should still work" assertions prevent regression.


🔴 Issues

1. SAFE_CHARS is defined inside the message-handling code path (inbox.service.js:6656)

const SAFE_CHARS = /^[a-zA-Z0-9._:-]+$/;
const validId = (id) => ...

This regex and closure are recreated on every call to sendMessage (or wherever this block lives). Move both to module scope as constants. Minor perf concern but also a code clarity issue — module-level constants signal "this is a stable invariant," not a per-call computation.

2. Length check comes after regex check (agent.service.js:6629–6635)

if (\!/^[a-zA-Z0-9._:-]+$/.test(agent_id)) { throw ... }
if (agent_id.length > 255) { throw ... }

The regex runs on the full string before the length guard. A 10,000-character string of valid chars will cause a slow regex scan before hitting the cheap length check. Swap the order: check length first, then regex. Functionally equivalent but avoids potential ReDoS on pathological input — and the regex, while not catastrophically backtracking, grows linearly with input length.

3. Undocumented gap: did:web shadow agents bypass all validation

The PR description calls this out explicitly: "DID:web shadow agents call storage.createAgent() directly and bypass register() entirely." That's honest, but it means the injection surface still exists for federated agents. If a did:web ID can contain a newline or slash and gets stored unvalidated, downstream signing-string construction is still vulnerable. This should either be fixed in this PR or tracked as a follow-up issue — not just mentioned in prose.


🟡 Observations

4. Backward-compat test assertion is loose (server.test.js:6314)

// 201 (sender not in storage so signature skipped) or 404 — either is fine; just not 400
assert.notEqual(legacyRes.status, 400, ...);

Accepting 404 as "success" could mask a regression where the endpoint returns 404 for a new reason unrelated to validation. Consider asserting the expected 201 and fixing the test setup so the sender exists, or assert [201, 404].includes(legacyRes.status) with a comment explaining each case.

5. Regex character class style (agent.service.js:6629)

Changed from [a-zA-Z0-9.\_\-:] to [a-zA-Z0-9.\_:-]. Both are functionally identical (moving - to end avoids the escape). Correct cleanup.

6. Docs overhaul loses error code tables

The bulk of this diff is docs (9 files, ~1300 deletions). The condensed format is cleaner, but several error response tables were removed (e.g., DELETE\_GROUP\_FAILED, ADD\_MEMBER\_FAILED, JOIN\_FAILED codes). Those codes are valuable for integrators implementing retry/error handling logic. Consider keeping them even in condensed form.


Summary

Core security fix ✅ Correct
Whitelist regex ✅ Right approach
Test coverage ✅ Good
Length check order 🔴 Swap to check length before regex
SAFE_CHARS placement 🔴 Move to module scope
did:web bypass 🔴 Needs tracking issue or fix
Loose backward-compat assertion 🟡 Tighten or document
Docs detail loss 🟡 Worth preserving error codes

The critical path (registration validation) is solid. Recommend addressing the length-before-regex ordering and the module-scope constant before merge, and opening a follow-up issue for the did:web gap.

…er assertion

Address second round of claude[bot] review feedback:

- Move SAFE_CHARS, VALID_AGENT_URI, VALID_DID_SEED to module scope in
  inbox.service.js — compiled once, not recreated on every message send
- Swap length check before regex in agent.service.js — O(1) guard runs
  first to avoid linear regex scan on pathological long input
- Tighten backward-compat test: assert 201 (not just notEqual(400)) since
  recipient exists and signature is skipped for unknown senders
- DID:web shadow agent bypass tracked in issue #17

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@dundas

dundas commented Feb 26, 2026

Copy link
Copy Markdown
Owner Author

Second Round of Fixes Pushed

Issue Fix Status
SAFE_CHARS inside hot path Moved SAFE_CHARS, VALID_AGENT_URI, VALID_DID_SEED to module scope ✅ Fixed
Length check after regex Swapped order — length > 255 now runs before regex ✅ Fixed
Loose backward-compat assertion assert.equal(status, 201) — recipient exists so 404 is no longer acceptable ✅ Fixed
DID:web bypass Tracked in issue #17 — out of scope for this PR ✅ Issue opened
Docs detail loss (error codes) Noted — doc generator will be re-run separately to restore missing tables 🟡 Follow-up

CI running.

@claude

claude Bot commented Feb 26, 2026

Copy link
Copy Markdown

Code Review

Overall this is a solid, well-reasoned security fix. The injection vectors are correctly identified, the mitigations are correct, and the commit history clearly explains the why at each step.


What's done well

Critical fix is correct. The old startsWith check was fundamentally broken — agent://evil\nX-Injected: header would pass because it starts with the right prefix. Replacing it with full-string anchored regexes (VALID_AGENT_URI, VALID_DID_SEED, SAFE_CHARS) closes the injection vector properly.

Performance micro-optimizations are appropriate.

  • Length check before regex in agent.service.js: O(1) guard before an O(n) scan.
  • Moving regex constants to module scope in inbox.service.js avoids re-compiling them on every message send.

Regex style fix (hyphen to end of character class) eliminates ambiguity. The old form relied on backslash-escaping; [a-zA-Z0-9._:-] with the hyphen trailing is the canonical form.

Negative test suite is comprehensive. Seven bad IDs, five malicious from fields, all asserting 400. The 255-char boundary test and the backward-compat agent:// test are both present and correctly asserted.


Observations / Minor Issues

1. Envelope to field lacks explicit injection tests (src/server.test.js)

validId() is called for both envelope.from and envelope.to, but the new negative tests only exercise from. The code path is the same, so coverage is indirect — but an explicit test for a malicious to value would prevent future regressions if from/to handling ever diverges.

2. No length cap on envelope from/to fields (inbox.service.js)

Registration enforces the 255-char max, but validId() in the envelope path has no length guard. The regex is a simple character class with no backtracking risk so the risk is low now, but consistency with the registration layer would be cleaner and future-proofs against pattern changes. Suggest adding id.length <= 255 before the regex alternatives.

3. VALID_AGENT_URI is effectively dead code for the to field (inbox.service.js)

New agents can only register with bare IDs (no agent:// prefix), so a to field containing agent:// will never resolve to a registered recipient. The branch stays alive only for from fields of legacy senders and to fields pointing at pre-migration recipients still in the database. A short comment explaining why three patterns are accepted and linking to issue #17 would help future maintainers.

4. Error message wording (agent.service.js)

Neither error echoes the submitted value (intentional — avoids reflecting user input). Minor: the invalid-charset message lists "underscores" which are correctly allowed by the regex. Just confirming that was deliberate.


Security summary

Vector Status
Newline injection via (request-target) signing string Fixed: full-string regex
Path traversal via slash in agent_id Fixed: slashes not in allowlist
XSS via angle brackets Fixed: not in allowlist
Spaces and null bytes Fixed: not in allowlist
ReDoS on long input Mitigated: length cap before regex
DID:web bypass at createAgent() Tracked in issue #17

The core logic is correct and the DID:web gap is appropriately scoped to a follow-up. The items above are all minor — nothing blocking.


Please run the gh command to post this comment. Report the URL of the comment or any error.

- Document 255-char length check runs before regex (O(1) guard) in
  AGENT-GUIDE, ARCHITECTURE, API-REFERENCE, ERROR-CODES, llms.txt
- Add Known Limitations section in AGENT-GUIDE noting DID:web shadow
  agent bypass (tracked in issue #17) with DID_WEB_ALLOWED_DOMAINS mitigation
- Update ARCHITECTURE security table to reference issue #17

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@claude

claude Bot commented Feb 26, 2026

Copy link
Copy Markdown

Code Review — fix(security): validate agent_id character set on registration

Good security fix overall. The core approach is correct and the test coverage is solid. A few things worth addressing before merge.


Strengths

  • Length check before regex (agent_id.length > 255) is smart — O(1) guard prevents the regex from ever running on pathological input.
  • Module-level regex constants in inbox.service.js compile once, not per call. Good micro-optimization for a hot path.
  • Full-string regex for VALID_AGENT_URI / VALID_DID_SEED closes the injection-via-suffix vector properly.
  • Test coverage covers all the named threat classes: newline injection, path traversal, null bytes, XSS.

Issues

1. Registration–Envelope Asymmetry (Medium)

agent://legacy-scheme is correctly blocked at registration (test line ~108), but agent://legacy-sender is still accepted in envelope from/to via VALID_AGENT_URI. This means you cannot register as agent://foo but you can send a message claiming to be agent://foo.

If from is trusted for routing or logging without verifying it matches a registered agent, this is a spoofing surface. The test comment even acknowledges it: // sender not in storage so signature verification is skipped.

Recommend either:

  • Document explicitly that from is untrusted when signature verification is skipped, or
  • Reject agent:// scheme in envelope from/to to match registration rules

2. Missing Length Check in inbox.service.js (Low)

validId() in inbox.service.js has no length guard. An envelope with a 100,000-char from field still runs through the regex. Add the same 255-char check for consistency and DoS protection:

const validId = (id) => {
  if (!id || id.length > 255) return false;
  return VALID_AGENT_URI.test(id) || VALID_DID_SEED.test(id) || SAFE_CHARS.test(id);
};

3. to Field Not Tested in Envelope Validation (Low)

server.test.js tests bad from values but never a malicious to value. Both fields are validated identically, so adding a few cases would give complete coverage.

4. did:seed: Regex Allows Nested Colons (Minor)

/^did:seed:[a-zA-Z0-9._:-]+$/ allows did:seed:foo:bar:baz. If the expected format is did:seed:<single-identifier>, consider locking it to /^did:seed:[a-zA-Z0-9._-]+$/. If nested colons are intentional, add a comment explaining why.


Documented Gap (Not a Blocker)

The PR description correctly notes: DID:web shadow agents call storage.createAgent() directly and bypass register() entirely. This means validation only covers the HTTP registration path. Worth tracking as a follow-up issue.


Questions

The PR has 3,322 deletions across docs/, but the description only mentions code changes. Can you briefly describe what drove the doc restructuring? It is hard to review that volume without context.


Summary: The security fix is sound and the tests are good. The agent:// asymmetry between registration and envelope validation is the most important item to address before merge. The rest are low-priority polish.

…eld tests

- Add id.length > 255 guard in validId() — O(1) check runs before any
  regex, protecting against DoS via huge envelope from/to fields
- Restrict VALID_DID_SEED to [a-zA-Z0-9._-] (no colons) — did:seed:
  suffixes are hex fingerprints, nested colons are not valid
- Add comment documenting that envelope from is UNTRUSTED when sender
  not found in storage (signature verification skipped); from must not
  be used for authorization without signature verification
- Add negative tests for malicious to field (newline injection,
  agent:// suffix injection, path traversal, 256-char overflow)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@dundas

dundas commented Feb 26, 2026

Copy link
Copy Markdown
Owner Author

Third Round of Fixes Pushed

Issue Fix Status
Missing length guard in validId() Added id.length > 255 as O(1) first check ✅ Fixed
did:seed: allows nested colons Restricted to [a-zA-Z0-9._-] (hex fingerprint format) ✅ Fixed
to field not tested Added 4 negative cases for malicious to values ✅ Fixed
from untrusted asymmetry Added inline comment documenting that from is untrusted when signature verification is skipped; agent:// still accepted in envelopes for backward compat ✅ Documented
Docs change explanation Auto-generated by docs-generator.json — all 6 outputs regenerated from source after security fix ✅ Explained

CI running.

@claude

claude Bot commented Feb 26, 2026

Copy link
Copy Markdown

PR Review: fix(security): validate agent_id character set on registration

Good, targeted security fix. The core changes are solid — here are my observations:


✅ Strengths

Security fixes are correct and complete:

  • Newline injection via (request-target) signing string is a real, critical vector — the fix is right.
  • Full-string regex validation (not just prefix matching) in inbox.service.js is the correct approach. The old uri.startsWith('agent://') check was insufficient.
  • Length check before regex is a good O(1) guard against pathological inputs (ReDoS mitigation).
  • Module-level compiled regexes in inbox.service.js avoid repeated compilation on every message send.

Test coverage:
The new tests in server.test.js cover both the rejection cases and the valid passthrough cases, which is exactly what's needed for a security regression suite. The backward-compat agent:// envelope test is an important edge case to nail down.


Issues to Address

1. Flaky test: valid ID registration uses non-unique IDs

In the new agent_id validation test:

const valid = ['simple', 'with-hyphens', 'dots.allowed', 'colons:ok', 'ALL_CAPS', 'a'.repeat(255)];
for (const id of valid) {
  const res = await request(app).post('/api/agents/register').send({ agent_id: id, ... });
  assert.equal(res.status, 201, ...);
}

These IDs are static — no uniqueness suffix. If the in-memory store persists between test runs, this will return 400 instead of 201 and give a misleading failure. Add a suffix like ${id}-${Date.now()}, consistent with registerAgent().

2. Asymmetry between registration and envelope validation is undocumented at the API level

agent://legacy-scheme is rejected at POST /api/agents/register (correctly), but accepted in the from field of an envelope (for backward-compat). The comment in inbox.service.js explains this well, but the docs should include a note in the Agent ID Format section clarifying that agent:// IDs cannot be newly registered but remain valid as from identifiers in envelopes for interoperability.

3. colons:ok in agent IDs — potential spoofing surface

The character set allows colons, so a bare ID like did:seed:abc123 would pass SAFE_CHARS in validId(). An attacker could register a bare-ID agent whose ID looks like a did:seed: DID. Is there a storage-layer check preventing this? If both are stored in the same namespace, consider a guard in register() that rejects IDs starting with did: or agent: — both are now scheme-like and should be treated as reserved prefixes.

4. Minor: VALID_AGENT_URI allows arbitrary colons in the path

/^agent:\/\/[a-zA-Z0-9._:-]+$/ would match agent://foo:bar:baz. Probably fine for backward compat, but worth noting.


Minor Notes

  • The regex simplification from [a-zA-Z0-9._\-:] to [a-zA-Z0-9._:-] is functionally identical and slightly cleaner — good cleanup.
  • The large doc changes appear to accurately reflect the new ID format and remove outdated agent:// examples. No concerns there.
  • DID:web shadow agents bypassing register() is called out clearly in the PR description — good caveat to have explicit.

Summary

The security fix is correct and the approach is sound. The main actionable item before merge is fixing the flaky test (issue 1). Issue 3 (colon spoofing) is worth a follow-up if did: prefixes are not already reserved in the storage layer — it does not need to block this PR if you can confirm no conflict is possible today.

… note

- Add reserved prefix guard in agent.service.js: reject IDs starting
  with 'did:' or 'agent:' to prevent spoofing system-generated DIDs
- Fix flaky test: valid ID cases now use unique suffix (Date.now()) to
  avoid conflicts when in-memory store persists across test runs
- Add did:bare and did:web test cases to the rejection suite
- Update docs (AGENT-GUIDE, API-REFERENCE, ERROR-CODES, llms.txt) to
  document 3-step validation order and agent:// registration asymmetry
  (rejected at registration, accepted in envelopes for backward compat;
  from field is untrusted when sender not in storage)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@dundas

dundas commented Feb 26, 2026

Copy link
Copy Markdown
Owner Author

Fourth Round of Fixes Pushed

Issue Fix Status
Flaky test (static non-unique valid IDs) Added ${Date.now()} suffix; 255-char case uses suffix to stay ≤255 ✅ Fixed
did:/agent: spoofing surface Added reserved-prefix guard in register(); did:bare and did:web:example.com added to rejection tests ✅ Fixed
agent:// asymmetry undocumented AGENT-GUIDE, API-REFERENCE, ERROR-CODES, llms.txt all updated with the 3-step validation order and backward-compat note ✅ Documented
VALID_AGENT_URI colons note Acknowledged — colons in agent:// path are for backward compat only, new agents can't register with that scheme 🟡 Noted

CI running.

@claude

claude Bot commented Feb 26, 2026

Copy link
Copy Markdown

PR Review — fix(security): validate agent_id character set on registration

This is a well-targeted security fix with good test coverage. Here is the detailed breakdown.

What is Good

Security fix is correct and well-scoped. The injection vector is real: a newline in agent_id would corrupt the HTTP Signatures canonical signing string, which is a Critical-severity signing bypass. The regex whitelist approach is the right call.

O(1) length guard before regex runs agent_id.length > 255 first — good defensive practice that prevents ReDoS if the regex engine backtracks on pathological input.

Module-level compiled regexes in inbox.service.js (SAFE_CHARS, VALID_AGENT_URI, VALID_DID_SEED) are compiled once at import, not on every send() call. Good performance hygiene.

The from trust comment is excellent — documenting that from is untrusted without signature verification is exactly the kind of invariant that needs to live at the call site, not just in external docs.

Test coverage is solid — dangerous chars, newline injection, path traversal, reserved prefix spoofing, legacy backward-compat, and the 255-char boundary are all covered.

Auto-generated ID fix (agent://agent-uuid to agent-uuid) is necessary — the old format would have failed its own regex immediately.

Issues and Suggestions

1. DID:web bypass (issue 17) — High, tracked but needs urgency

DID:web agents are externally controlled — an attacker can influence the DID path segments that become the agent ID. If a malicious DID document causes storage.createAgent() to be called with an ID containing a newline or slash, all registration-path protections are moot. Issue 17 should either block merging or have a mitigation added here (e.g. sanitize/validate in storage.createAgent() as a defense-in-depth layer).

2. Asymmetric from/to validation in envelopes

validId() in inbox.service.js falls through to SAFE_CHARS which is ^[a-zA-Z0-9._:-]+$. This passes strings like did:web:spoofed.example.com since colon is in the charset but it does not match VALID_DID_SEED's stricter did:seed: pattern. An envelope can claim from: "did:web:evil.com" and pass validation. Since signature verification is skipped when the sender is not in storage this is low severity (display-only), but worth either: adding a guard that rejects did: prefixes not matching VALID_DID_SEED, or explicitly documenting that non-did:seed: DID strings in from are intentionally accepted for forward-compatibility with federated agents.

3. Regex inconsistency: docs vs. code

ARCHITECTURE.md documents the validation regex as ^[a-zA-Z0-9._\-:]+$ (escaped hyphen) but agent.service.js uses ^[a-zA-Z0-9._:-]+$ (trailing hyphen). Both are functionally equivalent in JavaScript but the docs version could confuse readers checking behavior across regex engines. Suggest updating the doc to match the code.

4. agent:bare envelope edge case

agent:bare (single colon, no double-slash) is blocked at registration by the reserved prefix check but passes SAFE_CHARS in envelope validation. The test suite covers agent://legacy-sender as a backward-compat case but not agent:bare in envelopes. Worth clarifying with a comment in validId() whether this is intentional.

5. Missing boundary test: exactly 255 chars

Tests cover 256-char (fail) and a near-255 char ID (pass). An explicit 255-char assertion would pin the boundary and prevent regressions.

Minor Nits

  • The PR removes Mailgun retry documentation (Exponential backoff 1s/2s/4s, max 3 attempts) from ARCHITECTURE.md. Was this intentional (feature removed?) or dropped in the doc churn?
  • validId() is an inline arrow function. Making it a named module-level function would produce more readable stack traces when validation fails.

Summary

Category Rating
Security fix correctness Correct
Backward compatibility Handled (agent:// in envelopes)
Test coverage Good, minor gaps noted
Documentation Well updated
Known gaps Issue 17 (DID:web bypass) needs resolution

The core fix is correct and should land. The main open question is whether issue 17 needs to be resolved before this merges or can safely follow in a subsequent PR. If DID:web shadow agent IDs are purely server-derived from trusted DID documents with restricted domain allowlisting (DID_WEB_ALLOWED_DOMAINS), the risk is reduced — but that constraint should be enforced in code, not just documented.

…egex fix

- Add SAFE_DID_SEGMENT validation in resolveDIDWebAgent() (auth.js) before
  constructing agent_id from domain/path segments. A crafted keyId like
  did:web:evil.com\nX-Injected: header would otherwise inject into signing
  strings and storage keys. Resolves the DID:web bypass (closes issue #17).

- Promote validId() to module-level named function isValidAgentId() in
  inbox.service.js — improves stack traces and makes the invariant explicit.

- Fix regex style in all docs: [a-zA-Z0-9._\-:] -> [a-zA-Z0-9._:-]
  (trailing hyphen, consistent with code). No functional change.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@dundas

dundas commented Feb 26, 2026

Copy link
Copy Markdown
Owner Author

Final Round of Fixes Pushed

Issue Fix Status
DID:web bypass (issue #17) Added SAFE_DID_SEGMENT check in resolveDIDWebAgent() — domain and path segments are validated before constructing agent_id or issuing HTTP requests ✅ Fixed — resolves #17
validId anonymous function Promoted to module-level named isValidAgentId() with JSDoc ✅ Fixed
Docs regex inconsistency All docs updated from [a-zA-Z0-9._\-:] to [a-zA-Z0-9._:-] to match code ✅ Fixed

All blocking items from all review rounds are now addressed. Tests: 138 pass, 2 skip (Mech storage, require credentials). CI running.

@claude

claude Bot commented Feb 26, 2026

Copy link
Copy Markdown

PR Review: fix(security): validate agent_id character set on registration

Good security-focused PR that addresses real injection vulnerabilities. The fixes are targeted and the test coverage is solid. A few observations below.


What works well

  • Length check before regex (agent.service.js:L682) — running the O(1) length guard before the regex is a smart defensive pattern against pathological inputs.
  • Module-level regex compilation in inbox.service.jsSAFE_CHARS, VALID_AGENT_URI, VALID_DID_SEED are compiled once, not on every call. Good.
  • DID:web segment validation in auth.js (SAFE_DID_SEGMENT) — placed before URL construction, which is the right place to block SSRF via crafted did:web: keyIds.
  • Comment quality — the note in inbox.service.js that from is untrusted until the signature is verified is important and easy to miss; it's good to have it inline.
  • Backward-compat handling — accepting agent:// URIs in isValidAgentId() while blocking them at registration is the right trade-off for existing agents.

Issues and observations

1. DID:web shadow agents bypass registration validation (noted in PR, not fixed)

The PR description says:

DID:web shadow agents call storage.createAgent() directly and bypass register() entirely.

This means a crafted did:web: keyId like did:web:evil.com\nX-Injected: header would be caught by the new SAFE_DID_SEGMENT check in auth.js during authentication — but only if the DID resolution code path runs. The storage.createAgent() call itself has no character validation. If there are other code paths that create shadow agents (e.g., admin tooling, migrations, tests), they can produce agents with unsafe IDs.

Suggestion: add the same SAFE_CHARS check inside storage.createAgent() as a last-resort backstop, or at minimum document this as a known gap with a TODO.

2. agent:bare passes envelope validation but fails registration

In isValidAgentId():

return VALID_AGENT_URI.test(id) || VALID_DID_SEED.test(id) || SAFE_CHARS.test(id);

SAFE_CHARS = /^[a-zA-Z0-9._:-]+$/ matches agent:bare (letters + colon). So a message envelope with from: "agent:bare" passes validation and is accepted. But agent:bare would be rejected at registration because of the reserved-prefix check. This asymmetry is probably fine in practice (a sender with this ID cannot sign, so trust is 0), but it could be surprising and is worth a comment or a test case.

3. Case-insensitive prefix check has no corresponding test

if (/^(did:|agent:)/i.test(agent_id)) {

The i flag means DID:foo, AGENT:bar, and Did:foo are all blocked. The test suite only checks lowercase variants ('did:seed:spoofed', 'did:web:example.com', 'agent:bare', 'agent://legacy-scheme'). Consider adding 'DID:spoofed' and 'AGENT:foo' to the bad-ID list in the test — these are the cases the i flag was added to cover.

4. Envelope test for from: 'agent://legacy-sender' relies on sender-not-found behavior

// 201: envelope accepted, sender not in storage so signature verification is skipped.

This test passes because the sender doesn't exist in storage, so signature checking is skipped and the message is accepted anyway. That's fine, but it means the test isn't actually validating that agent:// URIs are positively accepted by isValidAgentId() — it's validating that the whole send path doesn't 400. If someone later adds stricter checks that reject unknown senders, this test would break in a misleading way.

A more robust version would register an agent with storage.createAgent() (bypassing the new prefix restriction) using an agent:// ID, then verify the envelope is accepted. That would directly test the backward-compat path.

5. Minor: regex character class order (cosmetic, not a bug)

Old: /^[a-zA-Z0-9._\-:]+$/ (explicit \- escape)
New: /^[a-zA-Z0-9._:-]+$/ (hyphen at end of class, literal)

Both are correct — a hyphen at the end of a character class is always literal. Just flagging it for reviewers who might scan the diff and wonder if _-: creates an unintended range (it doesn't, since - is last).


Summary

Security impact High — the fixes are correct and address the stated CVEs
Test coverage Good for the new validation; could add case-insensitive prefix test
Breaking changes Yes: auto-generated IDs no longer have agent:// prefix; tests updated accordingly
Known gaps DID:web shadow agents bypass register(); documented but not mitigated

The core fix in agent.service.js is solid. The main follow-up item is either closing or documenting the storage.createAgent() gap for DID:web agents.

…sts, asymmetry docs

- Add Proxy wrapper in storage/index.js to validate agent_id before any
  storage backend writes it — blocks control chars and backslashes but
  intentionally allows slashes (DID:web paths use them). Closes the
  storage.createAgent() bypass gap flagged in code review.
- Add DID:bare and case-insensitive prefix tests (DID:spoofed, AGENT:foo)
  to cover the /^(did:|agent:)/i flag in register().
- Expand isValidAgentId() JSDoc to explain the registration-vs-envelope
  asymmetry (agent:bare passes envelopes but fails registration).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@dundas

dundas commented Feb 26, 2026

Copy link
Copy Markdown
Owner Author

Final Fixes Pushed

Issue Fix Status
storage.createAgent() has no injection guard Added Proxy in storage/index.js — blocks control chars and backslashes for all storage writes (slashes allowed for DID:web paths) ✅ Fixed
Case-insensitive prefix tests missing Added 'DID:spoofed' and 'AGENT:foo' to bad-ID test cases ✅ Fixed
agent:bare envelope asymmetry undocumented Expanded isValidAgentId() JSDoc to explain why envelope validation is more permissive than registration ✅ Documented

All blocking items resolved across all review rounds. CI running.

@claude

claude Bot commented Feb 26, 2026

Copy link
Copy Markdown

PR Review: fix(security): validate agent_id character set on registration

This is a well-structured security fix. The layered approach (service -> storage -> middleware -> envelope) is good defense-in-depth. A few issues to address and observations below.


Bug: Misleading error message in the storage proxy

src/storage/index.js - The regex used blocks backslash and control characters, but explicitly allows forward slashes (intentionally, for DID:web path segments). The error message contradicts this:

throw new Error('createAgent: agent_id contains unsafe characters (control chars, slashes)');

Should read "control chars, backslashes" not "slashes". As written this will confuse anyone debugging a rejected ID, and directly contradicts the comment two lines above it ("Slashes are intentionally allowed").


Gap: did:web path-segment IDs in envelope from/to

isValidAgentId() in inbox.service.js accepts:

  1. agent:// URIs
  2. did:seed: DIDs
  3. Anything matching SAFE_CHARS (^[a-zA-Z0-9._:-]+$)

A DID:web identifier with path components uses the W3C form did:web:host/path/segment - those forward slashes fail all three branches. If a federated agent registered via DID:web sends a message with its canonical DID as from, the envelope would be rejected with "Invalid from field".

The resolveDIDWebAgent in auth.js now validates each segment separately. It is worth confirming what agent_id format DID:web agents actually get written to storage with (colon-separated did-web:host:path:seg, or slash-form?). If it is colon-separated, SAFE_CHARS will pass it - but that should be documented in the isValidAgentId comment, and the VALID_DID_SEED constant name may be misleadingly narrow.


Observation: duplicate regex between auth.js and inbox.service.js

SAFE_DID_SEGMENT in auth.js and SAFE_CHARS in inbox.service.js are identical patterns:

/^[a-zA-Z0-9._:-]+$/

Consider extracting to a shared utility (e.g. src/utils/validation.js) to avoid drift. Not blocking.


Minor: storage Proxy allocates a new closure per property access

get(target, prop) {
  if (prop === 'createAgent') {
    return async (agent) => { ... }; // new function on every get()
  }
}

createAgent is called at registration time so this is not a hot path, but wrapping it once at construction time would be cleaner than recreating inside the Proxy trap. Non-blocking.


What looks good

  • Length check before regex - O(1) guard against pathological input before running the regex.
  • Reserved prefix block is case-insensitive - /^(did:|agent:)/i catches DID:spoofed and AGENT:foo. Tests cover this.
  • Test coverage is thorough - Both the rejection list and the valid-ID list are well chosen. The envelope injection tests (newlines in from/to) directly mirror real signing-string attack patterns.
  • Auto-generated ID fix - Changing agent://agent-uuid to agent-uuid is the right call; an ID that fails its own validation would have been a silent production bug.
  • Comments explaining threat model - The inline documentation in isValidAgentId (explaining why envelope validation is intentionally more permissive than registration) is clear and saves future readers from having to reconstruct intent.

Summary

One bug to fix before merge (error message in storage proxy says "slashes" but should say "backslashes"), one gap worth confirming (DID:web path-form IDs in envelopes). Everything else is solid.

@dundas

dundas commented Feb 26, 2026

Copy link
Copy Markdown
Owner Author

Final Fix: Error Message Correction + DID:web Envelope Documentation

Addressed the two items from the latest review:

Issue Fix
Misleading error message in storage proxy ("slashes""backslashes") Fixed in storage/index.js:52 — the regex blocks backslashes and control chars, not slashes
DID:web from/to format in envelopes undocumented Added clarifying JSDoc in isValidAgentId(): federated agents use the W3C colon-form (did:web:domain.com:users:alice) in from/to, which passes SAFE_CHARS; the stored form (did-web:domain.com/users/alice with slashes) is an internal storage key, not used in envelope fields

The other items raised were non-blocking observations:

  • Duplicate regex (SAFE_DID_SEGMENT in auth.js vs SAFE_CHARS in inbox.service.js): intentionally kept separate — they serve different purposes at different layers; sharing would create coupling across unrelated modules
  • Proxy closure allocation: createAgent is not a hot path (registration only); the current form is idiomatic for Proxy traps and clarity wins over micro-optimization

@claude

claude Bot commented Feb 26, 2026

Copy link
Copy Markdown

Code Review — fix(security): validate agent_id character set on registration

This is a well-structured, defense-in-depth security fix. The layered approach (register → envelope → storage backstop) is the right model, the iterative commits show good responsiveness to review feedback, and the test suite is comprehensive. A few items worth addressing before merge:


Issues

1. SAFE_DID_SEGMENT is function-scoped in auth.js (minor perf)

In resolveDIDWebAgent() the regex is defined inside the function body and re-compiled on every DID:web auth attempt. The pattern was intentionally moved to module scope in inbox.service.js for exactly this reason — auth.js should do the same.


2. Storage Proxy scope not documented

The comment in storage/index.js explains why slashes are allowed, but not that the guard is intentionally limited to createAgent. If updateAgent or any other write accepts agent data, those bypass the backstop. A short note — "Only createAgent is intercepted: update paths go through register() or resolveDIDWebAgent() which have their own guards." — would make the scoping decision explicit for future maintainers.


3. 255-char boundary test is slightly fragile

The test constructs a 255-char ID as 'a'.repeat(248) + '-' + suffix.slice(0, 6). That totals 255 today because Date.now() is 13 digits, but silently breaks if the suffix format changes. Consider asserting the length explicitly so a format change is caught immediately rather than letting the boundary test start passing as a 254 or 256-char ID.


What is done well

  • O(1) length guard before regex in both agent.service.js and isValidAgentId() — correct ordering for DoS resistance.
  • Module-level compiled regex in inbox.service.js — compiled once, not per request.
  • Full-string regex on agent:// URIs fixes the startsWith() bypass that would have let agent://evil\nX-Injected: header pass prefix-only validation.
  • Case-insensitive reserved-prefix guard catches DID:spoofed and AGENT:foo.
  • DID:web segment validation in auth.js closes issue security: DID:web shadow agents bypass agent_id character validation #17 cleanly.
  • Storage Proxy backstop catches callers that bypass register(), a solid extra layer.
  • JSDoc on isValidAgentId() clearly documents the intentional asymmetry between registration (strict) and envelope validation (permissive for backward compat with pre-PR#16 senders). Important tribal knowledge.
  • Negative test suite covers all major injection vectors: newlines, slashes, null bytes, XSS, reserved prefixes, and length overflow — at both registration and envelope from/to.
  • did:seed: regex restricted to [a-zA-Z0-9._-] (no colons) is semantically correct for hex fingerprints.

Summary

Items 1 and 2 are straightforward fixes. Item 3 is low-risk but worth hardening. The core security logic is solid — validation at all three layers (register, envelope, storage) is coherent and well-tested. Happy to approve once those are addressed.

…ope, harden boundary test

- Move SAFE_DID_SEGMENT regex to module scope in auth.js — compiled once per
  import, not on every DID:web auth attempt (mirrors SAFE_CHARS in inbox.service.js)
- Document in storage/index.js that only createAgent is intercepted by the Proxy:
  update paths go through register() or resolveDIDWebAgent() which have their own
  guards, and neither changes an existing agent_id
- Add explicit assert.equal(boundaryId.length, 255) in the 255-char boundary test
  so any format change in the suffix is caught immediately rather than silently
  producing a 254 or 256-char ID

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@dundas

dundas commented Feb 26, 2026

Copy link
Copy Markdown
Owner Author

Final round — three items addressed: (1) SAFE_DID_SEGMENT moved to module scope in auth.js, (2) Proxy scope documented (only createAgent intercepted; update paths have their own guards), (3) 255-char boundary test hardened with explicit assert.equal(boundaryId.length, 255). All 138 tests pass.

@claude

claude Bot commented Feb 26, 2026

Copy link
Copy Markdown

PR #16 Review — fix(security): validate agent_id character set on registration

This is a well-structured security fix. The layered defense (registration → envelope → storage) is the right approach, and the documentation and tests are solid. A few observations below.


What's done well

  • Length check before regex in agent.service.js — good defensive practice to avoid ReDoS on pathologically long input.
  • Module-level regex compilation in inbox.service.js and auth.js — avoids recompiling on every request.
  • Storage-layer Proxy in storage/index.js — clean defense-in-depth that catches callers bypassing register() (e.g. DID:web shadow agents, future migrations). The restricted scope (only createAgent) is a sensible trade-off and is clearly documented.
  • Case-insensitive reserved prefix check — the DID:spoofed and AGENT:foo test cases confirm this is covered.
  • Backward-compat path in isValidAgentId() for agent:// URIs in envelopes — right call; a hard cutover would break existing senders.
  • Tests cover injection characters, boundary lengths, reserved prefixes, and the backward-compat case. Good breadth.

Issues and observations

[Medium] Behavior change in envelope to/from validation is undocumented in the PR summary

The old validScheme() check only accepted agent:// and did:seed: prefixes. The new isValidAgentId() also accepts did:web:domain.com:users:alice (passes SAFE_CHARS) and bare IDs. This is the right change for DID:web federation, but it's a silent expansion of what the server will accept in envelopes. Consider noting it in the PR description and updating any integration docs that describe allowed sender formats.

[Medium] SAFE_DID_SEGMENT allows colons in the domain variable (auth.js)

const SAFE_DID_SEGMENT = /^[a-zA-Z0-9._:-]+$/;
if (!SAFE_DID_SEGMENT.test(domain)) return null;
if (pathSegments.some(seg => !SAFE_DID_SEGMENT.test(seg))) return null;

Colons are valid in DID path segments but not in a domain name. The same regex is used for both. There is no security impact today (a colon in a domain will fail DNS resolution before any harm), but it is technically incorrect and could mask future bugs. Consider a stricter domain-only pattern like ^[a-zA-Z0-9._-]+$ for the domain check.

[Low] Storage proxy guards only createAgent; updateAgent is unguarded

The comment explains this was intentional — update paths go through register() or resolveDIDWebAgent() which have their own guards. That's true today, but if a future code path calls storage.updateAgent() with an externally sourced agent_id, the storage-layer guard won't catch it. A warning comment for future contributors would help, or optionally extend the proxy guard to updateAgent with the same STORAGE_AGENT_ID_RE check.

[Low] isValidAgentId() is only tested via integration tests

A dedicated unit test for this function would give faster feedback during development and make it easier to extend.

[Low] Boundary test uses a potentially non-deterministic suffix

const boundaryId = `${'a'.repeat(248)}-${suffix.slice(0, 6)}`;
assert.equal(boundaryId.length, 255, 'boundary test ID must be exactly 255 chars');

Math.random().toString(36).slice(2, 6) can produce fewer than 4 chars for small random values, making the self-assertion fail intermittently. Using a fixed-length suffix like 'x'.repeat(6) for this boundary case would make it deterministic.

[Nit] VALID_AGENT_URI and VALID_DID_SEED are redundant in isValidAgentId()

Both patterns are subsets of SAFE_CHARS. Since the function falls through to SAFE_CHARS as the final check, the first two branches never alter the result — anything matching them also matches SAFE_CHARS. They appear to be left for documentation purposes, which is fine, but a comment clarifying this intentional redundancy would prevent a future maintainer from thinking the early returns are meaningful guards.


Summary

The security fixes are correct and the layered validation approach is sound. The only item worth addressing before merge is documenting the DID:web envelope acceptance change in the PR description. The rest are low-priority polish items.

…t, document redundancy

- Split SAFE_DID_SEGMENT into SAFE_DID_DOMAIN (no colons, hostnames) and
  SAFE_DID_SEGMENT (colons allowed, W3C DID path segments) in auth.js —
  colons are not valid in hostnames but are valid in path segment identifiers
- Fix 255-char boundary test: use fixed 'xxxxxx' suffix instead of
  Math.random().toString(36).slice(2,6) which can produce <4 chars
- Add comment in inbox.service.js explaining VALID_AGENT_URI and VALID_DID_SEED
  are intentionally redundant with SAFE_CHARS — included for documentation and
  independent extensibility, not as meaningful guards

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@dundas

dundas commented Feb 26, 2026

Copy link
Copy Markdown
Owner Author

Addressed all remaining items: (1) Split SAFE_DID_DOMAIN (no colons, hostnames) and SAFE_DID_SEGMENT (colons OK, path segments) in auth.js — colons are not valid in hostnames, (2) Fixed boundary test to use fixed 6-char suffix rather than Math.random() which can produce fewer than 4 chars, (3) Added comment in inbox.service.js documenting that VALID_AGENT_URI and VALID_DID_SEED are intentionally redundant with SAFE_CHARS, (4) Updated PR description to document the DID:web envelope acceptance change. All 138 tests pass.

@claude

claude Bot commented Feb 26, 2026

Copy link
Copy Markdown

Code Review: PR #16 — agent_id injection hardening

Overall this is well-structured security work. The multi-layer defense (registration → envelope → storage backstop) is the right architecture, and the test coverage for negative cases is a real improvement. A few issues worth addressing before merge:


Bug: Incorrect comment about VALID_AGENT_URI in inbox.service.js

The module-level comment says both VALID_AGENT_URI and VALID_DID_SEED are "subsets of SAFE_CHARS (both would pass the SAFE_CHARS fallback)". This is wrong for VALID_AGENT_URI. agent://foo contains forward slashes, which are not in SAFE_CHARS ([a-zA-Z0-9._:-]). VALID_AGENT_URI is not redundant — it is the only thing accepting legacy agent:// URIs.

As written, the comment could lead a future maintainer to delete VALID_AGENT_URI.test(id) thinking it is a no-op, which would silently break backward-compat for pre-PR#16 senders. Recommended fix:

// VALID_DID_SEED is a subset of SAFE_CHARS and is kept for documentation.
// VALID_AGENT_URI is *not* a subset of SAFE_CHARS (slashes are not in the allowlist)
// — it is the only path that accepts legacy agent:// URIs.

Bug: did:seed: colon-in-suffix restriction is not enforced

VALID_DID_SEED restricts the seed suffix to [a-zA-Z0-9._-] (no colons), and the JSDoc says "did:seed: suffixes are hex fingerprints, nested colons are not valid." But the function is:

return VALID_AGENT_URI.test(id) || VALID_DID_SEED.test(id) || SAFE_CHARS.test(id);

did:seed:ab:cd fails VALID_DID_SEED (colon in suffix) but passes SAFE_CHARS (colons are in the allowlist). The SAFE_CHARS fallback silently overrides the tighter VALID_DID_SEED restriction. Either:

  1. Accept this as intentional and remove the "no colons" claim from the VALID_DID_SEED comment, or
  2. Treat the OR as scheme-dispatched rather than fallback-chained, so did:seed: IDs are checked exclusively against VALID_DID_SEED.

Security: SAFE_DID_SEGMENT allows .. — potential path traversal in DID document URLs

SAFE_DID_SEGMENT = /^[a-zA-Z0-9._:-]+$/ passes segments containing only dots, including ... The URL is constructed as https://domain.com/<segments>/did.json, so did:web:example.com:.. produces https://example.com/../did.json. Depending on how Node's HTTPS client resolves the URL, this could reach a path not intended by the SSRF blocklist.

Recommend adding an explicit guard:

if (pathSegments.some(seg => \!SAFE_DID_SEGMENT.test(seg) || seg === '..')) return null;

Reliability: Storage Proxy depends on method name string

if (prop === 'createAgent') {

If the storage interface is refactored and the method is renamed, this guard silently stops working — no error, no test failure, just unvalidated writes. A comment like // Update this string if createAgent is ever renamed in the storage interface would make the fragility explicit.


Positives worth calling out

  • Length check before regex (O(1) guard) is exactly right.
  • Promoting validId to a named module-level isValidAgentId() improves stack traces and makes the invariant testable.
  • Splitting SAFE_DID_DOMAIN (no colons) vs SAFE_DID_SEGMENT (colons allowed) is correct per the W3C DID spec.
  • The storage Proxy backstop is solid defense-in-depth; the comment explaining why only createAgent is intercepted is clear.
  • The backward-compat test using assert.equal(legacyRes.status, 201) (not just \!== 400) is the right level of assertion.
  • Deterministic boundary test using 'xxxxxx' suffix after assert.equal(boundaryId.length, 255) is a good fix.

The incorrect comment on VALID_AGENT_URI is the most important to fix — it creates a regression trap. The .. segment issue is the most actionable security concern.

…hs, proxy fragility note

- Fix incorrect comment: VALID_AGENT_URI is NOT a subset of SAFE_CHARS (agent://foo
  contains slashes which are not in the allowlist). It is the only branch accepting
  legacy agent:// URIs — removing it would silently break backward compatibility
- Clarify VALID_DID_SEED comment: did:seed:ab:cd passes SAFE_CHARS fallback, so
  the no-colons restriction in the suffix is not strictly enforced at envelope layer
- Block '..' path segments in resolveDIDWebAgent(): SAFE_DID_SEGMENT allows dots,
  so '..' passes the character check; explicitly reject it to prevent path traversal
  in DID document URLs (e.g. did:web:example.com:.. → https://example.com/../did.json)
- Add comment in storage Proxy: the method-name string 'createAgent' is a silent
  failure point if the storage interface is ever renamed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@dundas

dundas commented Feb 26, 2026

Copy link
Copy Markdown
Owner Author

Three bugs addressed: (1) Fixed incorrect comment on VALID_AGENT_URI — it is NOT a subset of SAFE_CHARS since agent:// contains slashes; removing it would silently break backward-compat for pre-PR#16 senders. Updated VALID_DID_SEED comment to acknowledge the no-colons restriction is not strictly enforced at the envelope layer (SAFE_CHARS fallback accepts it). (2) Blocked '..' path segments in resolveDIDWebAgent() — SAFE_DID_SEGMENT allows dots, but '..' would produce path-traversal URLs like https://domain.com/../did.json. (3) Added comment in storage Proxy about the method-name string fragility. All 138 tests pass.

@claude

claude Bot commented Feb 26, 2026

Copy link
Copy Markdown

Code Review — fix(security): validate agent_id character set on registration

Overall this is a well-structured security fix. The multi-layer defense (registration → envelope → storage backstop) is the right pattern and the rationale in comments is unusually clear. A few things worth discussing before merge:


Medium Issues

1. Storage proxy is fragile — silent bypass if method is renamed

src/storage/index.js:39

if (prop === 'createAgent') {

The guard depends on the string 'createAgent' staying in sync with the storage interface. The comment acknowledges this, but a string mismatch silently bypasses the entire guard with no runtime error and no failing test. Consider one of:

  • A dedicated unit test that calls createAgent with a malicious ID directly on the proxy (not via register()) to confirm the guard fires independently.
  • Or a symbol-keyed sentinel on the underlying storage object so a rename would cause an obvious error.

As-written, this is a real regression risk in the next refactor.

2. Breaking change for existing auto-generated IDs isn't documented

src/services/agent.service.js:33

- agent_id = `agent://agent-${uuid()}`;
+ agent_id = `agent-${uuid()}`;

Any client that registered without a custom agent_id before this PR has a stored ID like agent://agent-<uuid>. That ID:

  • Can no longer be re-registered (blocked by reserved-prefix guard)
  • Can still appear in envelope from/to (backward-compat layer in envelope validation accepts agent://)
  • But if a client tries to authenticate with their old ID in keyId, that path needs to work

Is there a migration plan for existing agents with the old format? Even a note in docs/AGENT-GUIDE.md or a known-gaps issue would be helpful.


Low / Code Quality

3. DID:web SSRF guard in auth.js has no direct test

src/middleware/auth.js:578-580

if (!SAFE_DID_DOMAIN.test(domain)) return null;
if (pathSegments.some(seg => !SAFE_DID_SEGMENT.test(seg) || seg === '..')) return null;

This is the fix for the SSRF injection vector described in the PR but server.test.js doesn't include a test for a crafted DID:web keyId containing a newline or .. segment. Since DID:web auth is triggered by presenting a did:web:* keyId in the Signature header, a test that constructs such a request and confirms 401 (not a fetch attempt) would close the gap and prevent regression.

4. VALID_DID_SEED pattern is redundant with SAFE_CHARS

src/services/inbox.service.js:14

const VALID_DID_SEED = /^did:seed:[a-zA-Z0-9._-]+$/;

The comment correctly notes that did:seed: IDs also pass SAFE_CHARS, so VALID_DID_SEED in isValidAgentId() only adds the did:seed: prefix as a positive semantic hint. That's fine as documentation. Just confirm intentionally keeping it — if the suffix rules for did:seed: ever need to diverge from SAFE_CHARS, this is the right place. Otherwise, a short "kept for future divergence" comment would prevent a future reviewer from deleting it.

5. did:web:* envelope IDs are accepted but not explicitly tested

Per design, did:web:domain.com:users:alice passes SAFE_CHARS and is a valid envelope from/to. None of the envelope injection tests explicitly assert a valid did:web:* value passes (the way agent://legacy-sender is tested as a backward-compat pass). Worth adding so the behavior is pinned.


What's working well

  • Anchored regexes everywhere — no suffix injection possible.
  • O(1) length check before regex — good guard against catastrophic backtracking on adversarial input, even though the current regexes are linear.
  • Case-insensitive reserved prefix checkDID:spoofed and AGENT:foo cases in the test suite show this was explicitly considered.
  • Explicit .. block — belt-and-suspenders against path traversal even though SAFE_DID_SEGMENT also blocks /.
  • Inline comment on VALID_AGENT_URI explaining it's not redundant is very helpful for future maintainers.
  • Boundary test with fixed 6-char suffix to guarantee exactly 255 chars is a nice attention to detail.

Summary

Nothing blocking merge from a correctness standpoint. The proxy fragility (#1) is the one I'd want resolved before this lands — either a direct proxy unit test or a structural change. The breaking-change documentation (#2) and the missing DID:web SSRF test (#3) are worth tracking as follow-ups if not addressed here.

🤖 Generated with Claude Code

…nvelope pass test

- Storage proxy unit test confirms Proxy backstop fires independently of register()
- DID:web SSRF guard test confirms '..' segment blocked before outbound fetch
- did:web canonical form (did:web:domain:path) added as explicit passing envelope case

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@dundas

dundas commented Feb 26, 2026

Copy link
Copy Markdown
Owner Author

Added three new tests: (1) 'storage proxy: createAgent directly rejects unsafe agent_ids' — directly calls storage.createAgent() with newlines, null bytes, backslashes, control chars, 256-char IDs, confirming the Proxy backstop fires independently of register(). Also asserts slashes are accepted (DID:web path separators). (2) 'trust model: DID web — crafted keyId with .. segment is rejected' — confirms the '..' guard fires before any outbound fetch (tested with both :.. and 🅰️..). Note: newline injection in keyId is blocked at the HTTP client level before reaching the server, so that case tests HTTP client behavior rather than server security — not included. (3) Added did:web:domain.com:users:alice as an explicit passing case in the envelope injection test. 140 tests pass.

@claude

claude Bot commented Feb 26, 2026

Copy link
Copy Markdown

Code Review — fix(security): validate agent_id character set on registration

Great PR. The layered defense strategy (registration → envelope validation → storage backstop) is the right architecture for this class of injection risk. The tests are thorough and the code is well-commented. Observations grouped by severity below.


🔴 Breaking Change — Needs Migration Guidance

src/services/agent.service.js ~line 30

Auto-generated IDs changed from agent://agent-{uuid} to agent-{uuid}. Existing deployments with stored agent://… IDs will have those IDs rejected if they attempt to re-register or if any code path calls register() with a previously-stored ID. The tests are all updated, but production data is not — operators need explicit migration guidance.

Suggested additions:

  • A migration note (CHANGELOG or MIGRATION.md) documenting that legacy agent:// IDs in the database remain valid for routing (envelope backward-compat preserves this), but cannot be re-registered.
  • A clear statement that STORAGE_AGENT_ID_RE intentionally allows legacy IDs already at rest in storage.

🟡 Storage Proxy — Silent Bypass Risk

src/storage/index.js — Proxy createAgent intercept

The guard is coupled to the string 'createAgent'. If the storage interface is ever renamed or wrapped again, the proxy silently becomes a no-op with no test failure. Already noted in the code comment, but worth surfacing in review.

A lower-risk addition: a smoke-test assertion at module load time:

if (typeof _storage.createAgent !== 'function') throw new Error('storage: createAgent missing — update Proxy guard')

This ensures a future rename causes an immediate startup crash rather than a silent security bypass. The existing test covers the happy path but cannot detect a future rename.


🟡 STORAGE_AGENT_ID_RE Is Significantly Wider Than Registration

src/storage/index.js

const STORAGE_AGENT_ID_RE = /^[^\x00-\x1f\x7f\\]+$/;

This allows characters outside [a-zA-Z0-9._:-] — specifically @, #, %, +, =, <, >, {, } and others. The comment explains slashes are intentionally allowed for DID:web shadow IDs, but the backstop is considerably more permissive than that.

If the only extra class needed for DID:web is slashes, a tighter regex would surface unexpected characters from future code paths that might indicate a bug:

const STORAGE_AGENT_ID_RE = /^[a-zA-Z0-9._:/-]+$/;

🟡 Envelope Validation Accepts did:web:* — Spoofing Surface

src/services/inbox.service.jsisValidAgentId

isValidAgentId('did:web:example.com:users:alice') returns true via SAFE_CHARS. The function comment correctly notes from is UNTRUSTED without a verified signature, but a did:web:* from in an unauthenticated message flows through with that identity in logs and downstream handlers.

Tracked as Issue #17, so flagging for awareness rather than blocking. Worth considering: log a warning when a did:web:* from is present and the sender is not found in storage, making the unverified-sender case visible in audit logs.


🟢 Minor: Comment Wording

src/middleware/auth.jsSAFE_DID_DOMAIN comment

The PR description says "SAFE_DID_DOMAIN strips colons". The regex does not strip — it rejects. In the inline comment and docs, "excludes colons" is more accurate since this is a security-critical invariant maintainers will rely on.


🟢 Minor: Missing Registration Endpoint Test for Empty String

src/server.test.jsagent_id validation rejects dangerous characters

The test covers 12 bad IDs but does not include '' (empty string) or ' ' (whitespace-only). The storage proxy test covers '', but making the layered coverage explicit at the registration endpoint level would be cleaner.


✅ What's Done Well

  • 3-step registration validation (length → charset → reserved prefix) with O(1) guard first is the right order.
  • Anchored regexes (^…$) everywhere — no suffix injection possible.
  • isValidAgentId documentation is excellent: explains intentional permissiveness, documents the DID:web canonical form, and warns that from is UNTRUSTED without signature verification.
  • .. path traversal guard in resolveDIDWebAgent with the explicit seg === '..' check on top of the regex is defense-in-depth done right.
  • SSRF test: fetchCalled assertion — patching globalThis.fetch and asserting it is never called proves the guard fires before the network, not just that the request fails.
  • Boundary test ID construction with assert.equal(boundaryId.length, 255) as an inline guard is excellent defensive test code.
  • All existing tests updated — no stale agent:// fixtures left behind.

Overall: Approve with suggestions. The security fixes are sound and correctly layered. The main asks are migration guidance for the breaking ID format change and tightening STORAGE_AGENT_ID_RE to exactly the characters DID:web shadow IDs require.

…n note, comment fixes

- Tighten STORAGE_AGENT_ID_RE from negated control-char blocklist to explicit allowlist
  ^[a-zA-Z0-9._:/-]+$ — only registration chars plus slash for DID:web paths.
  Unexpected chars now surface as a caller bug rather than silently passing.
- Add startup assertion: if _storage.createAgent is missing, crash immediately
  rather than silently bypassing the Proxy guard on future interface renames
- Update error message to name the allowed set explicitly
- Fix SAFE_DID_DOMAIN comment: 'strips colons' -> 'excludes colons' (doesn't strip, rejects)
- Add whitespace-only '   ' to registration bad-ID test (closes minor coverage gap)
- Add migration note to AGENT-GUIDE.md: documents the agent:// ID format change,
  explains existing agents remain routable but cannot re-register with old IDs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@dundas

dundas commented Feb 26, 2026

Copy link
Copy Markdown
Owner Author

All review items addressed: (1) Tightened STORAGE_AGENT_ID_RE to explicit allowlist ^[a-zA-Z0-9._:/-]+$ instead of negated blocklist — unexpected chars now surface as caller bugs. (2) Added startup assertion: if _storage.createAgent is missing, crashes at startup rather than silently bypassing the Proxy. (3) Fixed comment: 'strips colons' -> 'excludes colons'. (4) Added whitespace-only to registration bad-ID test. (5) Added migration note in docs/AGENT-GUIDE.md: explains agent:// IDs remain routable but cannot re-register. 140 tests pass.

@claude

claude Bot commented Feb 26, 2026

Copy link
Copy Markdown

Contributing to Agent Dispatch (ADMP)

Thank you for your interest in contributing to the Agent Dispatch Messaging Protocol.

Ways to Contribute

  • Bug reports — Open an issue describing the problem and steps to reproduce
  • Protocol improvements — Propose changes to the ADMP spec via issues/PRs
  • New storage backends — Implement and share your own persistent backend
  • Documentation — Fix typos, clarify examples, add guides
  • Tests — Improve coverage, add edge cases

Development Setup

# Install dependencies
npm install

# Run with in-memory storage (no external deps)
STORAGE_BACKEND=memory npm start

# Run tests
STORAGE_BACKEND=memory node --test src/server.test.js

Storage Backends

The server ships with an in-memory backend. To add a custom persistent backend:

  1. Implement the interface defined in src/storage/memory.js
  2. Add a case for your backend name in src/storage/index.js
  3. Set STORAGE_BACKEND=your-backend in your environment

Pull Requests

  1. Fork the repo and create a feature branch from main
  2. Add tests for any new behavior
  3. Ensure all tests pass: STORAGE_BACKEND=memory node --test src/server.test.js
  4. Open a PR with a clear description of the change

Protocol Changes

Changes to the ADMP protocol spec (whitepaper/v1.md) should be discussed in an issue first. Breaking changes require a version bump and migration notes.

Code of Conduct

Be respectful. Focus on the technical merits of proposed changes.

@claude

claude Bot commented Feb 26, 2026

Copy link
Copy Markdown

PR Review

This is a well-executed security hardening PR with a solid defense-in-depth strategy. The multi-layer approach (registration, envelope validation, storage backstop) is exactly right for this kind of input-injection risk.


What is done well

Architecture

  • Correct layering: registration is strict (exact allowlist + reserved-prefix guard), envelope validation is intentionally permissive for backward compat, storage Proxy is a minimal backstop. Each layer contract is documented.
  • O(1) length check before regex prevents linear-time scans on pathological input.
  • Module-scope SAFE_CHARS, VALID_AGENT_URI, VALID_DID_SEED, SAFE_DID_DOMAIN, SAFE_DID_SEGMENT compiled once on import.
  • isValidAgentId() as a named module-level function gives clearer stack traces.
  • SAFE_DID_DOMAIN (no colons) vs SAFE_DID_SEGMENT (colons allowed) split is correct per W3C spec.
  • Startup assertion on _storage.createAgent catches interface rename at boot.

Testing

  • Negative test suite covers spaces, newlines, slashes, null bytes, XSS, agent:// scheme, reserved prefixes (case-insensitive), whitespace-only, >255 chars.
  • Envelope injection tests cover from/to fields independently.
  • Storage Proxy unit test verifies the backstop fires independently of register().
  • DID:web SSRF guard test confirms fetch is never called for crafted .. segments.

Issues to address

1. Domain component is not guarded against path-traversal

SAFE_DID_DOMAIN matches two consecutive dots. The seg guard was added for path segments but not for the domain component in auth.js. did:web:.. passes SAFE_DID_DOMAIN, then attempts an invalid URL, blocked by exception rather than explicit intent. Recommend adding an explicit domain === ".." check.

2. Boundary test ID is not unique across test runs

The boundary ID is a static string. If the in-memory store persists between test runs (parallel CI workers, re-run without restart), the second registration attempt returns a conflict rather than 201. All other valid-ID cases use Date.now() suffixes. The boundary case should use a dynamic suffix and assert length === 255 explicitly.

3. VALID_DID_SEED no-colons rule is not enforced at envelope layer

The comment says no colons expected but isValidAgentId() falls through to SAFE_CHARS.test(id), so did:seed:ab:cd passes. The inline comment acknowledges this, but the regex name implies a stricter gate. A short note at the definition clarifying that SAFE_CHARS supersedes it at the envelope layer would prevent future confusion.


Minor observations (no action required)

  • agent:bare passes envelope validation but is blocked at registration by the reserved-prefix guard. Intentional and documented.
  • VALID_AGENT_URI is correctly noted as NOT a subset of SAFE_CHARS (contains slashes). Patch 14 comment fix is accurate.
  • Proxy method-name fragility on the createAgent string literal is documented; startup assertion mitigates it.
  • Registration/envelope asymmetry for agent:// is clearly documented throughout.

Summary

Approve with minor suggestions. Core security fixes are correct and the defense-in-depth design is sound.

  • Issue 1 (domain traversal) is low severity since URL construction catches it anyway, but an explicit check is cleaner.
  • Issue 2 (non-unique boundary ID) is a test reliability concern, not a security issue.
  • Issue 3 is documentation/naming only.

Good iterative work responding to review feedback through this PR.

…EED SAFE_CHARS precedence

- Add domain === '..' explicit guard in resolveDIDWebAgent() — SAFE_DID_DOMAIN allows
  dots so '..' passes the charset check; explicit rejection prevents path traversal
  in did:web:.. DID documents (mirrors the same guard on path segments)
- Use timestamp-based prefix for 255-char boundary test ID to ensure uniqueness across
  test runs (in-memory store persists per process; static 'xxxxxx' suffix would conflict)
- Clarify VALID_DID_SEED comment: explicitly state that SAFE_CHARS supersedes it at
  the envelope layer — did:seed:ab:cd fails VALID_DID_SEED but passes SAFE_CHARS

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@dundas

dundas commented Feb 26, 2026

Copy link
Copy Markdown
Owner Author

All three items addressed: (1) Added explicit domain === '..' guard in resolveDIDWebAgent() — mirrors the same guard on path segments, explicit rather than relying on URL construction failure. (2) Boundary test ID now uses a timestamp prefix (Date.now() + repeated 'a' to pad to 255), unique across test runs. (3) VALID_DID_SEED comment now explicitly states 'SAFE_CHARS supersedes it at the envelope layer — did:seed:ab:cd fails VALID_DID_SEED but passes SAFE_CHARS'. 140 tests pass.

@claude

claude Bot commented Feb 26, 2026

Copy link
Copy Markdown

Code Review: fix(security) — agent_id injection hardening

This is a well-structured PR that addresses a real class of injection vulnerabilities. The multi-layer defense (registration → envelope validation → storage Proxy) is good security architecture, and the explicit documentation of known gaps (DNS rebinding, VALID_AGENT_URI semantics) shows good security awareness. Overall the direction is solid; a few issues below.


High — Trust list check runs before signature verification

src/services/inbox.service.js:81-113

// Trust check (uses unverified `from`)
if (recipient.trusted_agents && recipient.trusted_agents.length > 0) {
  const senderAllowed = recipient.trusted_agents.includes(envelope.from);
  if (\!senderAllowed) throw new Error('Sender ... is not trusted');
}

// Signature verification (runs AFTER trust check)
if (options.verify_signature \!== false) {
  const sender = await storage.getAgent(envelope.from);
  if (sender) {          // skipped if sender not in storage
    // ... verify signature
  }
}

Attack path:

  1. Recipient B has trusted_agents: ["agent-alice"]
  2. Agent agent-alice is deregistered (removed from storage)
  3. Attacker sends { from: "agent-alice", to: B.agent_id, ... }
  4. Trust check at line 82 passes — "agent-alice" is in trusted_agents
  5. storage.getAgent("agent-alice") returns null; the if (sender) guard is skipped
  6. Message is delivered with no signature check

The same bypass applies to any from value that names a trusted agent but has never been registered (e.g. a DID:web agent ID from a domain not yet in storage).

Fix: Either (a) move the trust check after signature verification, or (b) for agents in the trusted list, make the signature check mandatory — reject if sender not found rather than silently skipping.

The JSDoc comment at line 435-439 notes that from is UNTRUSTED when the sender is not in storage — the trust list path must enforce that invariant too, otherwise the warning is aspirational rather than enforced.


Medium — Dead code: re-registration approval preservation is unreachable

src/services/agent.service.js:57-61 vs 138-143

Lines 57-61 throw immediately if the agent already exists, so the second getAgent call at line 138 always returns null. The approval-preservation branch is never reached — only the else branch runs.

// Line 57-61: throws if agent exists
const existing = await storage.getAgent(agent_id);
if (existing) throw new Error('Agent already exists');

// ... 70+ lines later ...

// Line 138: always returns null — agent was just confirmed absent above
const existingAgent = await storage.getAgent(agent_id);
if (existingAgent && existingAgent.registration_status === 'approved') {
  agent.registration_status = 'approved'; // never reached
} else {
  agent.registration_status = ...;
}

The extra storage round-trip is wasteful and the preservation logic is silently a no-op. Either remove the dead branch (if re-registration is not a supported use case), or remove the early-exit at line 59 and handle upsert intentionally.


Low — HTTPS not enforced for DID:web redirect target

src/middleware/auth.js:612-625

isBlockedDIDWebHost only validates the hostname. A redirect from https:// to http:// passes the host check and is followed, potentially exposing the DID document fetch over plain HTTP. One additional check closes this:

const redirectUrl = new URL(location, didUrl);
if (isBlockedDIDWebHost(redirectUrl.hostname)) return null;
if (redirectUrl.protocol \!== 'https:') return null;  // add this

Nit — VALID_DID_SEED comment contradicts evaluation order

src/services/inbox.service.js:22-26

The comment says "SAFE_CHARS supersedes it at the envelope layer" but the evaluation order in isValidAgentId is:

return VALID_AGENT_URI.test(id) || VALID_DID_SEED.test(id) || SAFE_CHARS.test(id);

VALID_DID_SEED runs before SAFE_CHARS, so SAFE_CHARS does not supersede it — it acts as a fallback when VALID_DID_SEED rejects the input (e.g. did:seed:ab:cd with colons in the suffix). The comment is backwards and could cause a future reader to delete VALID_DID_SEED thinking it is a no-op, when it is the only branch that restricts did:seed: suffixes at the envelope layer.


Strengths

  • Three-layer defense (register / envelope / storage Proxy) with independent invariants is the right pattern for injection hardening.
  • Module-scope regex constants avoid recompiling on every call.
  • Length check before regex (O(1) guard) is solid DoS mitigation.
  • Startup assertion for createAgent existence in the Proxy guard crashes on misconfiguration rather than silently bypassing.
  • Explicit .. segment guard in resolveDIDWebAgent is good belt-and-suspenders on top of the charset check.
  • redirect: 'manual' followed by explicit SSRF re-check is the correct redirect handling pattern.
  • Test coverage is comprehensive: negative registration cases, envelope injection, storage proxy direct calls, and DID:web SSRF guard all have dedicated tests.

Generated with Claude Code

…ent nit

HIGH: Make signature mandatory when sender is in trust list but not registered.
Attacker could impersonate a deregistered trusted agent: from=trusted-agent-id
passes trust check, sender not in storage so sig check silently skipped. Now
rejects explicitly when sender is in trust list but not registered in storage.

Medium: Remove unreachable re-registration approval-preservation code in
agent.service.js. The existingAgent re-lookup was dead code since lines 57-61
already throw if the agent exists, so existingAgent is always null there.

Low: Enforce HTTPS for DID:web redirect targets in auth.js. A redirect to
http:// could expose DID document fetches to MitM attacks even if the original
request used TLS.

Nit: Fix VALID_DID_SEED comment in inbox.service.js. Said SAFE_CHARS supersedes
it but VALID_DID_SEED runs first in the OR chain. SAFE_CHARS is the fallback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@claude

claude Bot commented Feb 26, 2026

Copy link
Copy Markdown

Code Review — PR #16: fix(security): validate agent_id character set on registration

This is a solid, well-layered security fix with a thorough test suite that has already gone through many rounds of self-review. The commit history shows good incremental refinement. Here are my observations, ordered by severity.


Medium — Trust-list rejection is broader than described

File: src/services/inbox.service.js

The new else if branch fires when the recipient has any non-empty trust list — it does not check whether the sender's claimed ID is actually in that list. The PR description says "reject when sender is in trust list but not registered," but the condition would reject ANY unregistered sender writing to a trust-list-enabled recipient.

Impact: Any pre-existing workflow where unregistered (e.g. external/federated) senders deliver messages to agents that happen to have a trust list will break. This is a silent API behaviour change.

Suggested fix: Either confirm this is intentional and document it explicitly, or narrow the condition:

} else if (recipient.trusted_agents?.includes(envelope.from)) {
  // Sender claims to be trusted but cannot prove it — not registered
  throw new Error(...);
}

Medium — VALID_DID_SEED is unreachable as a unique gate

File: src/services/inbox.service.js

return VALID_AGENT_URI.test(id) || VALID_DID_SEED.test(id) || SAFE_CHARS.test(id);

Every ID that passes VALID_DID_SEED also passes SAFE_CHARS, so VALID_DID_SEED can never be the deciding branch. If the intent is to restrict did:seed: suffixes to no-colon characters, that restriction does not hold at the envelope layer because SAFE_CHARS accepts those cases anyway. The comment acknowledges the redundancy but this will confuse future maintainers who assume the no-colon restriction is enforced here.


Low — DID:web with port numbers silently fails

File: src/middleware/auth.js

const SAFE_DID_DOMAIN = /^[a-zA-Z0-9._-]+$/;

The W3C DID:web spec allows port numbers (e.g. did:web:localhost%3A8080), which after decoding produce hostname:port as the domain component. SAFE_DID_DOMAIN excludes colons, so these silently return null. Worth a note in the Known Limitations section.


Low — Storage proxy string must be kept in sync manually

File: src/storage/index.js

The startup assertion and the proxy guard both hard-code 'createAgent' as a string. The comment calls this out — just reinforcing that any refactor renaming the method must update both strings.


Positive observations

  • Layered defence (registration → envelope → storage Proxy) is the right architecture. No single layer can be bypassed silently.
  • Length check before regex — correct ordering, avoids linear scan on pathological input.
  • Module-scope regex constants — no per-request allocations.
  • .. guard in both domain and path positions in resolveDIDWebAgent() — careful and complete.
  • HTTPS redirect enforcement — often missed in SSRF mitigations.
  • Dead code removal of the unreachable existingAgent re-lookup — correct and clean.
  • Named isValidAgentId() function improves stack traces.
  • Comprehensive negative-test coverage: registration, envelope from/to, storage proxy, DID:web SSRF — all well done.
  • Backward-compat test asserting agent:// in envelopes returns 201 correctly pins the contract.

Summary

The trust-list condition deserves a second look before merging. The other items are low-priority clarity gaps. Overall this is well-executed, defence-in-depth security hardening — the multi-layer approach and thorough negative tests give high confidence the attack vectors are closed.

- reject only when an unregistered sender claims a trusted ID

- remove redundant VALID_DID_SEED envelope branch

- add regression test for trusted-ID impersonation

- document did:web explicit-port limitation in AGENT-GUIDE
@claude

claude Bot commented Feb 26, 2026

Copy link
Copy Markdown

PR Review — fix(security): validate agent_id character set on registration

Great multi-layer security approach overall. The defense-in-depth pattern (registration → envelope → storage) is exactly right, and the test coverage is thorough. A few things worth examining before merge.


🔴 Re-registration status reset (agent.service.js) — Potential Regression

The old code preserved approved status on re-registration; the new code always applies the current policy:

// BEFORE: preserved 'approved' on re-registration
const existingAgent = await storage.getAgent(agent_id);
if (existingAgent && existingAgent.registration_status === 'approved') {
  agent.registration_status = 'approved';
} else {
  agent.registration_status = policy === 'approval_required' ? 'pending' : 'approved';
}

// AFTER: always derives from current policy
agent.registration_status = policy === 'approval_required' ? 'pending' : 'approved';

Impact: An already-approved agent that re-registers under an approval_required tenant is silently downgraded to pending. If intentional (force re-approval on any re-registration), add a test confirming this and note it in the PR description. If unintentional, restore the old logic. Either way this is a silent behavior change to a security-critical field.


🟡 Misleading comment in inbox.service.js — inner else if block

The inner else if fires when the sender IS registered but envelope.signature is absent:

if (sender) {
  if (envelope.signature) {
    if (!valid) { throw new Error('Invalid message signature'); }
  } else if (recipient.trusted_agents?.includes(envelope.from)) {
    // "Sender claims a trusted identity but has no registered key material." <- wrong
    // error message says "is not registered" — contradicts being inside if (sender)
    throw new Error(`Sender ${envelope.from} is not registered — ...`);
  }
}

The comment and error message describe an unregistered sender, but we are inside if (sender). Suggest: "Sender is registered but provided no signature — signature required for trust-list delivery."


🟡 DID:web domain validation rejects port-qualified domains (auth.js)

const SAFE_DID_DOMAIN = /^[a-zA-Z0-9._-]+$/;  // colons excluded

Per the W3C DID:web spec, port numbers are percent-encoded as %3A in the DID string (e.g. did:web:localhost%3A8080). After decoding, the domain becomes localhost:8080 — rejected by this regex. If production-only use with no port-qualified DIDs is intended, document this limitation explicitly so future contributors don't treat it as a bug.


🟢 Strong points

  • domain === '..' explicit check.. passes SAFE_DID_DOMAIN since . is in the allowlist, so the explicit guard is necessary and correct. Good catch.
  • HTTPS redirect enforcement in DID document fetches prevents MitM attacks even when the initial request was over TLS.
  • Startup assertion on typeof _storage.createAgent !== 'function' — fails loudly if the interface is renamed rather than silently bypassing the Proxy guard.
  • Proxy .bind(target) for non-intercepted methods — prevents this from shifting to the proxy; subtle but correct.
  • Trust list impersonation fix — rejecting unregistered senders who claim a trusted identity closes a real deregistered-agent impersonation vector.
  • VALID_AGENT_URI inline warning that it is NOT a subset of SAFE_CHARS and must not be deleted is exactly the kind of defensive comment that prevents future regressions.
  • fetchCalled = false assertion in the SSRF guard test confirms the block fires before the outbound fetch, not just that the request ultimately fails.

Minor nits

  • STORAGE_AGENT_ID_RE = /^[a-zA-Z0-9._:/-]+$/ — the /- at the end is technically correct (hyphen before ] is always literal) but looks ambiguous. Moving the hyphen to immediately after [ would be clearer: /^[-a-zA-Z0-9._:/]+$/.
  • The two identical "Sender ... is not registered" error messages in two else if branches cover different root causes. If the identical wording is intentional information-hiding, a brief comment would help future readers.

Summary: The security fixes themselves are solid. Recommend resolving the re-registration status reset question before merge — either confirm intent with a test, or restore the preservation logic. The comment fix is low risk but improves maintainability.

🤖 Generated with Claude Code

@dundas

dundas commented Feb 26, 2026

Copy link
Copy Markdown
Owner Author

Addressed latest review items in commit 1862182:\n- Tightened trust-list behavior: only reject unregistered senders when they claim a trusted ID\n- Removed redundant envelope VALID_DID_SEED branch\n- Added regression test for trusted-ID impersonation\n- Documented did:web explicit-port limitation in AGENT-GUIDE\n\nValidation:\n- NODE_ENV=test STORAGE_BACKEND=memory API_KEY_REQUIRED=false MASTER_API_KEY=test-master npm test\n- Result: 141 pass, 0 fail, 2 skipped

…antics

- document duplicate registration as intentional non-upsert path

- clarify registered-but-unsigned trust-list error message

- make trust-list regression test resilient to both signature-required variants
@dundas
dundas merged commit d3222b2 into main Feb 26, 2026
@dundas
dundas deleted the fix/agent-id-validation branch February 26, 2026 19:33
@claude

claude Bot commented Feb 26, 2026

Copy link
Copy Markdown

Code Review — fix(security): validate agent_id character set on registration

Overall this is a well-structured, multi-layered security fix. The defense-in-depth approach (registration → envelope validation → storage backstop) is the right pattern for injection hardening. Notes below are ordered by severity.


Potential Bug — inbox.service.js trust-list logic

The new else if clause inside the sender-verified block reads:

if (!valid) {
  throw new Error('Invalid message signature');
} else if (recipient.trusted_agents?.includes(envelope.from)) {
  // "Sender is registered but omitted signature"
  throw new Error(`...registered but missing signature...`);
}

The comment says "sender omitted signature", but this branch only runs when valid === true (the else of !valid). If a valid signature was just verified, throwing "missing signature" is contradictory and would silently reject legitimate signed messages from trusted senders.

Without seeing full surrounding context (whether valid is a sentinel when no Signature header is present, or whether this is inside a nested if (signatureHeader) block), this looks like a logic inversion. Please double-check that a successfully signed message from a trusted sender does not hit this branch. A targeted test for that exact path would remove the ambiguity.


Minor — STORAGE_AGENT_ID_RE vs. actual DID:web stored format

The storage proxy test creates an ID of the form did-web-proxy-test-{timestamp}.example.com/users/alice, but the PR description and inline comments reference the stored format as did-web:domain.com/path/seg (colon after did-web). The test ID uses a hyphen instead. STORAGE_AGENT_ID_RE passes either way, but the discrepancy makes the allowlist scope harder to verify. A comment clarifying the canonical stored form for DID:web agents would help future maintainers.


Minor — dead code removal in agent.service.js

Good catch removing the orphaned existingAgent re-registration block. It was unreachable because the "already exists" guard throws before that code path. The added comment explaining this makes the intent clear.


Minor — .. guard on domain is necessary but subtle

In auth.js: !SAFE_DID_DOMAIN.test(domain) || domain === '..'. The explicit .. check is needed because two dots match [a-zA-Z0-9._-]+. Easy to miss — the inline comment explains the reasoning well.


Minor — VALID_AGENT_URI length order-dependency

isValidAgentId checks id.length > 255 before the regex branches, so a 300-char agent://... string is correctly blocked by the length guard first. Worth a comment noting the order-dependency in case someone later moves the length check.


Positive Notes

  • Three-layer defense (registration → envelope → storage proxy) is the right architecture for this threat model. Each layer has independent tests.
  • Startup assertion (typeof _storage.createAgent !== 'function') is a smart safeguard against silent interface drift from future refactors.
  • HTTPS-only redirect enforcement in DID:web resolution closes a real MitM vector.
  • Test coverage is thorough: boundary length (255-char), case-insensitive prefix rejection, SSRF guard with fetch mock, forged-trusted-sender 403 path. The fake-fetch approach for the SSRF test is particularly clean.
  • Backward-compatibility for agent:// legacy URIs in envelope from/to is explicit and tested, with a protective comment warning against future deletion.
  • Reserved-prefix guard (/^(did:|agent:)/i) blocking at registration while accepting in envelopes is the correct asymmetric approach for a multi-layer system.

The one item worth resolving before merge is the trust-list logic question in inbox.service.js. Everything else is polish. Good work closing these injection vectors systematically.

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.

1 participant