feat(proactive): daily briefing as a read-only autonomous agent turn (Phase 2) - #5
Merged
Conversation
…(Phase 2)
The first "agent acts on its own clock" feature. At a configured local time,
server.py starts proactive.briefing_loop (retained task, cancelled on shutdown)
which runs one agent turn that pulls a brief (prompt asks it to call the
messageinfra.get_briefing MCP tool + summarise in zh with a `>` spoken line)
and pushes the result to connected surfaces. Opt-in (off by default).
Security model — autonomous-origin sandbox (new tool_guard primitive, chosen
deliberately over alternatives): an unattended self-clocked turn has no human to
approve a gate and runs over untrusted content, so it must not be able to act.
tool_guard._AUTONOMOUS_ORIGINS ({"briefing"}): gate() REJECTS any name-keyed /
requires_confirmation / write-side-effect tool from these origins regardless of
mode (even off), audited REJECTED-AUTONOMOUS. So an injected briefing can read
(get_briefing/web_search/read_file) but never shell/write/exfil. The check runs
BEFORE the OFF short-circuit, so default installs are protected. Reusable for
all future proactive features.
Hardening from a 2-lens adversarial review (loop/cancellation/cost verified
sound; these are the real findings, all fixed):
- HIGH: full-tool autonomous turn over untrusted content + default-off gate →
injection→shell. Fixed by the read-only autonomous sandbox above (Arcana's
create_chat_session has no per-session tool allowlist, so the gate is the
right enforcement point).
- MED: brief bypassed _chat_in_flight → a self-upgrade could re-exec mid-brief.
Now wrapped in _chat_in_flight_cm so self_upgrade defers the re-exec.
- MED+MED: per-delta relay fan-out (separate loop) reorders, and an unsolicited
stream clobbers an in-flight user turn's shared bubble. Fixed by collecting
the turn and pushing ONE self-contained {"type":"response","kind":"briefing"}
frame instead of streaming per-delta.
- LOW: quoted `enabled: "false"` was truthy. Now strict-parsed.
config.example.yaml + CLAUDE.md documented. Follow-ups noted: proactive TTS
auto-play, relay/Telegram surfaces, persistent inbox.
563 pytest green (+autonomous-origin + proactive tests). Sandbox probe verified:
briefing-origin shell/write/MCP-write all REJECTED in off mode; get_briefing
AUTO; local origin unaffected.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What & why
Phase 2 first slice — the first "agent acts on its own clock" feature. At a configured local time, the agent runs one turn that pulls a brief (prompt asks it to call
messageinfra.get_briefingand summarise in zh with a>spoken line) and pushes the result to connected surfaces. Opt-in, off by default.Security model — autonomous-origin sandbox (new tool_guard primitive)
This was the key design decision (chosen over "fetch + tool-less summary" and "doc-only"): an unattended, self-clocked turn has no human to approve a gate and runs over untrusted content (whatever
get_briefingreturns), so it must not be able to act.tool_guard._AUTONOMOUS_ORIGINS({"briefing"}):gate()REJECTS any name-keyed /requires_confirmation/ write-side-effect tool from these origins regardless of mode (evenoff), auditedREJECTED-AUTONOMOUS. So an injected briefing can read (get_briefing/web_search/read_file) but never shell/write/exfil. The check runs before the OFF short-circuit, so default installs are protected. Reusable for all future proactive features.(Arcana's
create_chat_sessionhas no per-session tool allowlist, so the gate is the right enforcement point.)Changes
proactive.py(new):seconds_until_daily+run_briefing_once+briefing_loop.server.py: startup starts the loop (retained, cancelled on shutdown);_broadcast_framefan-out;_chat_in_flight_cm.config.example.yaml+CLAUDE.md: documented.Adversarial review → all findings fixed
A 2-lens review (lifecycle/concurrency + fan-out/security/cost) verified the loop, cancellation, and cost are sound, and found:
_chat_in_flight, so a self-upgrade could re-exec mid-brief. → wrapped in_chat_in_flight_cm.{"type":"response","kind":"briefing"}frame instead of per-delta streaming.enabled: "false"was truthy. → strict-parsed.Testing
uv run pytest -q→ 563 passed. Sandbox probe verified: briefing-origin shell/write/MCP-write all REJECTED inoffmode;get_briefingAUTO;localorigin unaffected.Follow-ups
Proactive TTS auto-play on the
responseframe; relay/Telegram surfaces (MCP is server-loop-only today); a persistent inbox so a brief fired with no console connected isn't lost.