Skip to content

feat(safety): URL hard-boundary policy + navigation audit log - #13

Open
chronostrophe wants to merge 5 commits into
hanzili:mainfrom
chronostrophe:pr/safety
Open

feat(safety): URL hard-boundary policy + navigation audit log#13
chronostrophe wants to merge 5 commits into
hanzili:mainfrom
chronostrophe:pr/safety

Conversation

@chronostrophe

Copy link
Copy Markdown

Summary

Adds a client-side URL safety layer mirroring Perplexity Comet-agent's own navigation guards, plus an in-memory audit log so every policy decision is inspectable.

Stacked on #12 (test scaffold) — the new test files need vitest. Merge #12 first and this diff auto-shrinks to 4 commits.

URL hard-boundary policy (src/safety/url-policy.ts)

  • evaluateUrl(url, policy, caller) gates every navigation/tab-open: blocks file:///dangerous extensions by default, optional blockInternal (chrome://, devtools://), wildcard domainAllowlist/domainDenylist (deny wins).
  • Mirrors Comet-agent's isInternalPage / isUrlBlocked / isDomainBlacklist semantics, implemented independently.
  • New tools: comet_get_url_policy, comet_set_url_policy (partial updates + reset:true).

Audit log (src/safety/audit-log.ts)

  • Ring buffer (500) recording {url, outcome, reason, caller} for every allow/deny decision.
  • Every cometClient.navigate() / newTab() call is tagged with the originating MCP tool name, so a denied navigation report shows which tool attempted it.
  • New tools: comet_get_audit_log (filterable, paginated), comet_reset_audit_log.

Test plan

  • npm test — 96/96 pass (28 scaffold + 45 url-policy + 12 audit + integration)
  • npx tsc --noEmit — clean
  • No behavior change when the default policy allows the URL (permissive-by-default for blockInternal)

- src/util/format.ts: redactUrls, isDebugEnabled, formatCaughtError
  (extracted from the inline catch handler so the DEBUG+redaction logic
  is unit-testable)
- src/util/steps.ts: extractSteps with position-ordered regex (preserves
  body-position ordering; dedupes via Set)
- src/index.ts: route the MCP catch handler through formatCaughtError +
  isDebugEnabled; skip the redundant 'Error: <msg>' first line of V8
  stacks so the message isn't repeated in the output
- tests/util/format.test.ts (19 tests): URL redaction across http/https/ws,
  parens handling, multi-URL, non-URL preservation, DEBUG truthy/falsy
  literals, formatCaughtError with debug on/off and stack-line cap,
  non-Error throws
- tests/util/steps.test.ts (9 tests): empty/no-match, short-UI filter,
  trailing-context kept, insertion order, dedup, 100-char truncation,
  all 7 verbs, real-world Comet body with mixed signal+noise
- package.json: vitest devDep + scripts (test, test:watch)
- vitest.config.ts: node env, tests/**/*.test.ts

Verified: npx tsc --noEmit clean; npm test → 28/28 passing in 1.7s.

L2 re-investigated with grep references: state.currentUrl IS assigned in
connect() (line 810) and navigate() (line 834); the L2 alarm was a false
positive from initial analysis.
Closes the documented gap where comet-mcp had no equivalent of
Perplexity Comet's isInternalPage / isUrlBlocked / isDomainBlacklist
(per Zenity's reversing story and Trail of Bits audit). Any LLM calling
comet_ask now has its navigations gated by the same shape of policy
that Comet's own agent enforces.

- src/safety/url-policy.ts: pure module, no CDP dependency.
  - isInternalUrl: matches chrome:, chrome-untrusted:, chrome-extension:,
    chrome-search:, chrome-error:, devtools:, edge:, about:, view-source:
  - isFileUrl: matches file:, ftp:
  - isBlockedDocType: matches last path segment against .exe/.msi/.bat/
    .scr/.vbs/.ps1/.sh/.dmg/.pkg/.iso/.app/.jar etc. TLDs like .com/.app
    in the host are NOT false-positives — checked against URL.pathname
    only.
  - extractHost: returns lowercase hostname or null.
  - matchWildcard: '*.foo.com' matches apex + subdomains. 'foo.com'
    matches exact only.
  - checkUrl / assertUrlAllowed: combined verdict with reason
    enum. Denylist wins over allowlist. URLs with no host when policy
    lists patterns are denied.
  - BlockedUrlError: typed Error carrying url, reason, message. Picked
    up by the MCP catch handler and surfaced via formatCaughtError.
  - PolicyRegistry: single in-memory source of truth. getActivePolicy
    returns a defensive copy.
  - Hot-load from $COMET_URL_POLICY or ~/.comet-mcp/url-policy.json.

- src/cdp-client.ts navigate(): asserts the URL against the active
  policy before any CDP call. Throws BlockedUrlError on deny.

- src/index.ts: two new MCP tools.
  - comet_get_url_policy: returns the active policy as JSON.
  - comet_set_url_policy: partial-update any flag or list. reset:true
    restores defaults. Server-side normalizePolicy() drops unknown
    keys and coerces types so a hostile caller can't inject extra state.

- tests/safety/url-policy.test.ts (43 tests): per-URL-type coverage,
  policy composition (denylist vs allowlist precedence), defensive
  copy semantics, normalization edge cases, hot-load parsing.
- tests/integration/url-policy-cdp.test.ts (6 tests): realistic Comet
  scenarios (settings page, password manager, credential store file,
  exe download, normal navigation, BlockedUrlError surface).

Stats: 11 test files, 192 tests passing (was 137; +55). npx tsc --noEmit
clean. Mirrors industry guidance (Microsoft MCP security 2026,
Playwright MCP best practices, Chrome URLBlocklist policy).
Per user feedback: chrome://, edge://, devtools://, about:, view-source:,
chrome-extension: are not needed as defaults. file:// and executable
document types stay blocked (the security-relevant ones for prompt
injection defense). Internal URL blocking is opt-in via
comet_set_url_policy {blockInternal: true}.

- src/safety/url-policy.ts DEFAULT_POLICY: blockInternal true -> false
- src/cdp-client.ts newTab(): also gates URL via assertUrlAllowed
- tests/safety/url-policy.test.ts: new tests asserting the default is
  permissive; existing chrome:// tests now pass explicit blockInternal:true
- tests/integration/url-policy-cdp.test.ts: rewritten to reflect the
  default + explicit opt-in path

Stats: 11 test files, 197 tests passing. npx tsc --noEmit clean.
…OOLS

The case handlers were wired but the TOOLS array literal wasn't updated, so
tools/list returned the old 13-tool shape. tools/call still routed the
names correctly (proved by the wire test) but LLM clients couldn't see
the tools in their schema. Now exposed in the tools/list response.

Verified via subprocess JSON-RPC driver: 15 tools exposed, both
policy tools respond correctly to get/set/reset, hostile inputs are
normalized (unknown keys dropped, non-string array entries filtered).
Closes the gap where every URL policy decision was lost on return.

- src/safety/audit-log.ts: ring buffer (cap 500, FIFO eviction),
  pure recordAllow/recordDenial/recordDecision helpers, O(1) push,
  recent(n) and filter(pred) query. Thread-safe enough for the MCP
  stdio model (single-writer, single-consumer per request).
- src/safety/url-policy.ts: new evaluateUrl(url, policy, caller)
  wrapper that runs checkUrl and records the decision. Keeps
  checkUrl pure so unit tests stay deterministic.
- src/cdp-client.ts: navigate() and newTab() now route through
  evaluateUrl and accept an optional caller argument. Every browser
  action goes through the audit log.
- src/index.ts: 5 navigate/newTab call sites updated to pass the MCP
  tool name as caller (comet_connect, comet_ask, comet_mode). 2 new
  MCP tools added:
    - comet_get_audit_log: returns {total, returned, entries} with
      optional limit/outcome/caller filters. Newest first.
    - comet_reset_audit_log: clears the buffer.
- tests/safety/audit-log.test.ts (8 tests): entry shape, allow vs
  deny, recent() ordering, recent(n) limit, predicate filter, FIFO
  eviction at the configured cap, clear() semantics, caller field
  passthrough.

Stats: 12 test files, 205 tests passing (was 197; +8). npx tsc
--noEmit clean.
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