Skip to content

Commit fe6a831

Browse files
chernistryauto-heal-fixup
andauthored
feat(chat): Slack bidirectional driver with attested approvals (#1804)
Replaces the NotImplementedError stub in ``src/bernstein/core/chat/drivers/slack.py`` with a Socket Mode driver that conforms to ``BridgeProtocol`` end to end: slash dispatch, button decode, edit debounce, signed outbound messages, worktree-pinned approvals, and chained audit-log entries. * Outbound messages carry an Ed25519 detached signature over ``(install_id, session_id, content_hash)``; ``verify_chat_signature`` lets a recipient with the install's public key confirm authenticity and reject foreign-install forgeries. * Each Slack approval resolution lands as a ``chat.slack.approval`` event in the existing HMAC-chained ``AuditLog``; replay over the exported chain reproduces the post-approval scheduler state byte-identically (covered by the new integration test). * Cross-worktree approvals raise ``CrossWorktreeApprovalError`` and log a ``chat.slack.approval_rejected`` entry so the bypass attempt is visible to the operator. * ``edit_message`` debounces ``chat.update`` to one call per channel per second, comfortably below Slack's per-channel rate limit. * Optional ``bernstein[slack]`` extra pulls in ``slack-sdk``; the ``start()`` path raises ``SlackDependencyError`` with a pointer to the extra when the SDK is absent. CLI integration: ``bernstein chat serve --platform=slack`` reads the bot token from ``BERNSTEIN_SLACK_TOKEN`` and the Socket Mode app token from ``BERNSTEIN_SLACK_APP_TOKEN``. Docs added at ``docs/operations/chat-bridges.md``. Closes #1794 Co-authored-by: auto-heal-fixup <auto-heal-fixup@bernstein.local>
1 parent 4e221e6 commit fe6a831

10 files changed

Lines changed: 1822 additions & 56 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,13 @@ All notable project changes are tracked here (code + docs).
99
- `bernstein desktop-register --host <name>` covers the remaining priority hosts: Cursor, Continue, Cline, Zed, and Aider, alongside the existing Claude Desktop and Claude Code adapters. JSON hosts merge into their canonical `mcpServers` map (or `context_servers` for Zed); Aider records the entry in its YAML config under `mcp-servers` for community-wrapper consumption (#1676).
1010
- `bernstein doctor --substrate` reports which detected hosts have Bernstein registered, which do not, and which are stale (canonical command/args differ from the recorded entry) (#1676).
1111
- Operator docs at `docs/substrate/{cursor,continue,cline,zed,aider}.md` cover install, verification, and uninstall per host (#1676).
12+
- Slack bidirectional driver with attested approvals: `bernstein chat serve --platform=slack` connects via Socket Mode, dispatches `/bernstein` slash subcommands, decodes Approve/Reject block actions, debounces `chat.update` per channel, signs every outbound message with an Ed25519 detached signature over `(install_id, session_id, content_hash)`, and appends each approval resolution to the HMAC-chained audit log as a `chat.slack.approval` event covering `(approver, message_ts, decision, tool_call_hash, worktree_id)`. Approvals are worktree-pinned: cross-worktree resolutions raise `CrossWorktreeApprovalError` and emit a `chat.slack.approval_rejected` audit entry. Optional `bernstein[slack]` extra pulls in `slack-sdk` (#1794).
13+
- `docs/operations/chat-bridges.md` documents the Telegram and Slack drivers, the env-var contract, the signed metadata envelope, and how to verify an outbound Slack message offline (#1794).
1214

1315
### Changed
1416

1517
- `bernstein audit export --standard` no longer accepts `dora` or `finos-aigf`; the click choice list is `ai-act` only. The previous control maps for those two standards contained only placeholder rows (`status: "todo"`, `selector: "TODO"`) and have been removed from `SUPPORTED_STANDARDS` until their clause mappings are reviewed by subject-matter experts. Operators who pass either value now receive a clean usage error rather than a TODO-only zip (#1316).
16-
- `DiscordBridge.on_command` / `on_button` and `SlackBridge.on_command` / `on_button` now raise `NotImplementedError` at registration time instead of silently dropping the handler. Callers that wire handlers up front against the unimplemented drivers will see the failure immediately rather than at the first network call.
18+
- `DiscordBridge.on_command` / `on_button` now raise `NotImplementedError` at registration time instead of silently dropping the handler. Callers that wire handlers up front against the unimplemented drivers will see the failure immediately rather than at the first network call.
1719

1820
## [2.5.0] - Interoperability surfaces, host portability, deterministic replay
1921

docs/operations/chat-bridges.md

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
# Chat bridges
2+
3+
Bernstein ships bidirectional chat drivers so operators can drive a session,
4+
approve tool calls, and watch streamed agent output from a messaging app
5+
without keeping a terminal open. Bridges are configured per-platform via
6+
`bernstein chat serve --platform=<name>`.
7+
8+
## Supported platforms
9+
10+
| Platform | Status | Optional extra | Tokens |
11+
|----------|--------|---------------|--------|
12+
| Telegram | Production | `pip install 'bernstein[telegram]'` | `BERNSTEIN_TELEGRAM_TOKEN` |
13+
| Slack | Production | `pip install 'bernstein[slack]'` | `BERNSTEIN_SLACK_TOKEN` (bot) + `BERNSTEIN_SLACK_APP_TOKEN` (Socket Mode app) |
14+
| Discord | Stub only | n/a | n/a |
15+
16+
## Slack setup
17+
18+
1. Create a Slack app and enable Socket Mode.
19+
2. Add the scopes `chat:write`, `commands`, `app_mentions:read`, plus any
20+
scopes your slash command surface needs.
21+
3. Generate an app-level token (`xapp-...`) with the `connections:write`
22+
scope. Install the app to your workspace and copy the bot token
23+
(`xoxb-...`).
24+
4. Map a `/bernstein` slash command in your Slack app configuration. The
25+
driver routes subcommands (`run`, `approve`, `reject`, `status`,
26+
`switch`, `stop`, `handoff`) from the text body of the slash payload.
27+
5. Set the two env vars:
28+
29+
```sh
30+
export BERNSTEIN_SLACK_TOKEN=xoxb-...
31+
export BERNSTEIN_SLACK_APP_TOKEN=xapp-...
32+
```
33+
34+
6. Start the bridge:
35+
36+
```sh
37+
bernstein chat serve --platform=slack
38+
```
39+
40+
## What the driver guarantees
41+
42+
- **Slash dispatch and button decode.** `/bernstein run "..."` and the
43+
inline `Approve` / `Reject` buttons are routed through the same handler
44+
surface as the Telegram driver.
45+
- **Edit debounce.** Streaming agent output collapses into one
46+
`chat.update` per channel per second so Slack's per-channel rate limit
47+
on `chat.update` stays comfortably out of reach.
48+
- **Attested approvals.** Every approval resolution is appended to the
49+
HMAC-chained audit log as a `chat.slack.approval` event whose details
50+
cover `(approver, message_ts, decision, tool_call_hash, worktree_id)`.
51+
Replaying the chain reproduces the post-approval scheduler state
52+
byte-identically.
53+
- **Worktree pinning.** An `/approve` for a worker bound to worktree
54+
`wt-a` cannot resolve a pending approval registered against a
55+
different worktree. Cross-worktree attempts log a
56+
`chat.slack.approval_rejected` audit entry and raise
57+
`CrossWorktreeApprovalError` so the bypass is visible to the operator.
58+
- **Outbound message signing.** Every outbound chat message carries an
59+
Ed25519 detached signature over `(install_id, session_id,
60+
content_hash)`. A recipient with the install's public key can verify
61+
the message was not injected by another bernstein install
62+
impersonating the workspace.
63+
64+
## Verifying a Slack message
65+
66+
```python
67+
from bernstein.core.chat.drivers.slack import verify_chat_signature
68+
69+
ok = verify_chat_signature(
70+
install_id="<the install id that posted>",
71+
session_id="<session id from the metadata envelope>",
72+
content="<text content of the message>",
73+
signature="<base64 signature from metadata.event_payload>",
74+
public_key_pem=open(".bernstein/keys/slack/slack-bridge.ed25519.pub", "rb").read(),
75+
)
76+
```
77+
78+
Returns `True` on cryptographic match, `False` on tampered content or a
79+
foreign install public key.
80+
81+
## Missing SDK behaviour
82+
83+
`bernstein chat serve --platform=slack` raises a structured
84+
`SlackDependencyError` when `slack-sdk` is not installed, with a
85+
pointer to `pip install 'bernstein[slack]'`. Install the extra, or
86+
switch to a platform whose SDK is already on the host.

pyproject.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,13 @@ r2 = ["boto3>=1.43.6"]
192192
# Standard Telegram bot integration via long-poll: configure the bot
193193
# token (from @BotFather) and the chat id, no external services.
194194
telegram = ["python-telegram-bot>=22.7"]
195+
# Slack bidirectional driver over Socket Mode. Enable with:
196+
# pip install 'bernstein[slack]'
197+
# Configure a Slack app with bot scopes (``chat:write``, ``commands``),
198+
# enable Socket Mode, and set ``BERNSTEIN_SLACK_TOKEN`` (xoxb-...) plus
199+
# ``BERNSTEIN_SLACK_APP_TOKEN`` (xapp-...) before invoking ``bernstein
200+
# chat serve --platform=slack``.
201+
slack = ["slack-sdk>=3.27", "aiohttp>=3.9"]
195202
# Contamination-resistant eval harness (SWE-bench Pro / Terminal-Bench 2.0 /
196203
# SWE-rebench). Kept optional so production installs don't pull HuggingFace
197204
# datasets unless the operator opts into the nightly leaderboard path.

src/bernstein/cli/commands/chat_cmd.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,11 @@
5050
"slack": "BERNSTEIN_SLACK_TOKEN",
5151
}
5252

53+
#: Slack Socket Mode app-level token env var. Bot token (``xoxb-...``) goes
54+
#: in ``BERNSTEIN_SLACK_TOKEN``; the Socket Mode app token (``xapp-...``)
55+
#: lives separately so operators can rotate the two independently.
56+
_SLACK_APP_TOKEN_ENV = "BERNSTEIN_SLACK_APP_TOKEN"
57+
5358

5459
@click.group("chat")
5560
def chat_group() -> None:
@@ -86,7 +91,7 @@ def chat_serve(platform: str, token: str | None, allow: str | None) -> None:
8691
allow_list = load_allow_list(workdir / "bernstein.yaml", cli_override=overrides)
8792
bindings = BindingStore(workdir)
8893
driver_cls: Any = load_driver(platform)
89-
bridge: BridgeProtocol = driver_cls(resolved_token)
94+
bridge: BridgeProtocol = _instantiate_bridge(driver_cls, platform, resolved_token)
9095
session = ChatSession(bridge, bindings, allow_list, workdir)
9196
session.install_handlers()
9297

@@ -608,6 +613,25 @@ def _resolve_chat_token(platform: str) -> str:
608613
return os.environ.get(_ENV_TOKEN_MAP[platform], "")
609614

610615

616+
def _instantiate_bridge(driver_cls: Any, platform: str, token: str) -> BridgeProtocol:
617+
"""Construct the driver with platform-appropriate kwargs.
618+
619+
Telegram and Discord take a single ``token`` positional; Slack
620+
additionally needs the Socket Mode app token from
621+
``BERNSTEIN_SLACK_APP_TOKEN``. Surface a clear error rather than
622+
instantiating with an empty app token (the driver itself rejects it,
623+
but the CLI's message is more actionable).
624+
"""
625+
if platform == "slack":
626+
app_token = os.environ.get(_SLACK_APP_TOKEN_ENV, "")
627+
if not app_token:
628+
raise click.UsageError(
629+
f"Slack requires the Socket Mode app token in ${_SLACK_APP_TOKEN_ENV} (in addition to the bot token).",
630+
)
631+
return driver_cls(token=token, app_token=app_token)
632+
return driver_cls(token)
633+
634+
611635
def _extract_quoted_goal(msg: ChatMessage) -> str:
612636
"""Parse the goal from ``/run "..."`` style commands.
613637

0 commit comments

Comments
 (0)