Skip to content

Commit 9ea15bd

Browse files
Let the operator open agent to Slack, behind a double opt-in (#27)
`agent` is the command with file tools, which is exactly why it was excluded: it acts on the host on behalf of anyone in the workspace. It now sits behind two switches rather than zero — GRAPHARC_SLACK_ALLOW_AGENT because it acts, and the existing GRAPHARC_SLACK_ALLOW_MODEL because it cannot run spend-free. One without the other stays off, and the refusal names both. Once on, the gate still decides the shape of every Slack-launched agent: the executor stays sandbox (--executor is not admitted), --system-prompt is unreachable, the workspace defaults into the bot's working directory instead of the CLI's fresh temp dir so the run's trace can be read back from Slack, and --max-seconds defaults to ten seconds under the bot's timeout so the CLI's graceful interrupt fires before the bot's kill. --allow/--deny globs pass through, repeatable. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 09ad60c commit 9ea15bd

8 files changed

Lines changed: 182 additions & 24 deletions

File tree

docs/cookbook/07-slack.md

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,10 @@ an afterthought. The defaults:
2222

2323
| Reachable from Slack | Refused from Slack |
2424
|---|---|
25-
| `demo`, `run`, `plan`, `models`, `replay`, `diff`, `trace`, `metrics`, `viz` | `agent` (arbitrary tool execution on the host), `serve` |
25+
| `demo`, `run`, `plan`, `models`, `replay`, `diff`, `trace`, `metrics`, `viz` | `serve` |
2626
| Paths that resolve inside the bot's working directory | Any path that escapes it (`trace ../../.env` is refused before a process spawns) |
2727
| The budget, policy and trace flags each command already has | `--registry` (imports an arbitrary module), `--config`, `--json`, `--no-color` |
28-
| | `--model` / `--reviewer-model`, unless the operator opts in |
28+
| `agent`, only behind the double opt-in below | `--model` / `--reviewer-model`, unless the operator opts in |
2929

3030
With `--model` off, every reachable command runs the scripted, spend-free
3131
path. The default answer to "can someone in Slack cost me money?" is **no**;
@@ -106,13 +106,42 @@ Configuration is environment-only, read once at startup:
106106
| `GRAPHARC_SLACK_WORKDIR` | the bot's cwd | the directory every path must resolve inside |
107107
| `GRAPHARC_SLACK_TIMEOUT` | `120` | seconds one command may run before it is killed |
108108
| `GRAPHARC_SLACK_ALLOW_MODEL` | off | `1` admits `--model`/`--reviewer-model` |
109+
| `GRAPHARC_SLACK_ALLOW_AGENT` | off | `1` admits `agent` — only together with `ALLOW_MODEL` |
109110
| `GRAPHARC_SLACK_COMMAND` | `/grapharc` | the slash command to answer to |
110111

111112
The bot reads tokens from the process environment only. The `.env`
112113
upward-directory search that the model gateway performs is deliberately not
113114
used here: a bot that a whole workspace can drive must not discover
114115
credentials in a file the operator did not point it at.
115116

117+
## The `agent` opt-in
118+
119+
`agent` is the command with file tools, which is exactly why it is off by
120+
default: it acts on the host on behalf of anyone in the workspace. Turning it
121+
on takes **two** switches — `GRAPHARC_SLACK_ALLOW_AGENT=1` because it acts,
122+
and `GRAPHARC_SLACK_ALLOW_MODEL=1` because it cannot run spend-free. The
123+
startup line reports `agent on` only when both hold. Then:
124+
125+
```
126+
@grapharc agent "read every markdown file and list the broken links" --max-turns 6
127+
```
128+
129+
What the gate does to every Slack-launched agent, non-negotiably:
130+
131+
- the executor stays `sandbox` — `--executor` is not admitted, so `local`
132+
(no confinement) is unreachable;
133+
- `--system-prompt` is not admitted;
134+
- the workspace defaults to `<workdir>/agent` rather than the CLI's fresh
135+
temp dir, so the run's `trace.jsonl` and outputs stay where `trace` /
136+
`metrics` / `viz` can read them back; `--workspace` may pick another
137+
directory, confined to the workdir like every other path;
138+
- unless `--max-seconds` is given, it defaults to ten seconds under the
139+
bot's timeout, so the run ends with the CLI's graceful interrupt-and-report
140+
rather than the bot's kill.
141+
142+
`--allow` / `--deny` tool globs pass through and are repeatable; deny beats
143+
allow, as in the CLI.
144+
116145
## The honest caveats
117146

118147
- **The bot is alive while the process is.** Laptop lid closed means commands

docs/cookbook/08-slack-walkthrough.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,9 +69,12 @@ round that executed. Every model failure contained, every decision recorded.
6969

7070
Two refusals from the same session, both correct:
7171

72-
- `@grapharc agent "do something"`*not a command this bot runs*. `agent`
73-
executes tools on the host on behalf of anyone in the workspace, so it is
74-
excluded at the gate, not hidden.
72+
- `@grapharc agent "do something"` → refused, naming the two switches that
73+
would allow it. `agent` executes tools on the host on behalf of anyone in
74+
the workspace, so it sits behind a double opt-in
75+
(`GRAPHARC_SLACK_ALLOW_AGENT=1` *and* `GRAPHARC_SLACK_ALLOW_MODEL=1`) —
76+
see the agent section of [07-slack.md](07-slack.md) for what the gate
77+
still enforces once it is on.
7578
- `plan "make a new file for the docs" --model …`**ran; the answer was
7679
negative**. The planner may only propose node kinds from its registry —
7780
the incident-response demo set — and none of them can create a file. The

grapharc/slack/__init__.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,11 @@
2121
module (and this package) is stdlib-only, so a wheel without the extra still
2222
imports.
2323
24-
The gate's default is deliberately spend-free: `agent` and `serve` are refused,
25-
`--model` is refused unless the operator opts in, and every path argument must
26-
resolve inside the bot's working directory. Anyone in the workspace can talk
27-
to the bot; the gate is what makes that safe to allow.
24+
The gate's default is deliberately spend-free: `serve` is refused, `agent` and
25+
`--model` are refused unless the operator opts in (`agent` needs two switches:
26+
it acts on the host *and* it spends), and every path argument must resolve
27+
inside the bot's working directory. Anyone in the workspace can talk to the
28+
bot; the gate is what makes that safe to allow.
2829
"""
2930

3031
from grapharc.slack.command import (

grapharc/slack/__main__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ def main() -> int:
2727
print(
2828
f"grapharc slack bot: workdir {config.workdir}, "
2929
f"timeout {config.timeout_seconds:.0f}s, "
30-
f"model flags {'on' if config.allow_model else 'off'}",
30+
f"model flags {'on' if config.allow_model else 'off'}, "
31+
f"agent {'on' if config.allow_agent and config.allow_model else 'off'}",
3132
file=sys.stderr,
3233
)
3334
serve(config)

grapharc/slack/bot.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,11 @@ def handle_text(text: str, config: SlackBotConfig) -> str:
3131
stripped = _MENTION.sub("", text).strip()
3232
try:
3333
argv = parse_command(
34-
stripped, workdir=config.workdir, allow_model=config.allow_model
34+
stripped,
35+
workdir=config.workdir,
36+
allow_model=config.allow_model,
37+
allow_agent=config.allow_agent,
38+
timeout_seconds=config.timeout_seconds,
3539
)
3640
except SlackCommandError as exc:
3741
return str(exc)
@@ -57,7 +61,7 @@ def build_app(config: SlackBotConfig) -> Any:
5761
def _slash(ack: Any, respond: Any, command: dict[str, Any]) -> None:
5862
text = command.get("text", "").strip()
5963
if not text:
60-
ack(usage_text(allow_model=config.allow_model))
64+
ack(usage_text(allow_model=config.allow_model, allow_agent=config.allow_agent))
6165
return
6266
ack(f"running `grapharc {text}`…")
6367
respond(handle_text(text, config))

grapharc/slack/command.py

Lines changed: 62 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,12 @@
44
not a convenience parser — the same posture as the CLI's own policy layer. The
55
rules, and why each exists:
66
7-
- **Subcommands are allowlisted.** `agent` (arbitrary tool execution on the
8-
host) and `serve` (holds a worker thread forever) are not in the list.
7+
- **Subcommands are allowlisted.** `serve` (holds a worker thread forever) is
8+
not in the list. `agent` (tool execution on the host) is behind a double
9+
opt-in: GRAPHARC_SLACK_ALLOW_AGENT *and* GRAPHARC_SLACK_ALLOW_MODEL, because
10+
it acts on the host and cannot run without a paid backend. Even then its
11+
executor stays `sandbox` (`--executor` is not admitted), `--system-prompt`
12+
is unreachable, and its workspace defaults into the bot's working directory.
913
- **Flags are allowlisted per subcommand.** `--registry MODULE:ATTR` imports
1014
an arbitrary module on the host, `--config PATH` swaps the governing file,
1115
and `--json`/`--no-color` fight the bot's own output handling — none are
@@ -75,6 +79,19 @@ class CommandSpec:
7579
model_flags=frozenset({"--model"}),
7680
),
7781
"models": CommandSpec(bool_flags=frozenset({"--check"})),
82+
"agent": CommandSpec(
83+
value_flags={
84+
"--workspace": True,
85+
"--trace": True,
86+
"--run-id": False,
87+
"--allow": False,
88+
"--deny": False,
89+
"--max-turns": False,
90+
"--max-tokens": False,
91+
"--max-seconds": False,
92+
},
93+
model_flags=frozenset({"--model"}),
94+
),
7895
"replay": CommandSpec(path_positionals=frozenset({0})),
7996
"diff": CommandSpec(path_positionals=frozenset({0})),
8097
"trace": CommandSpec(value_flags={"--run-id": False}, path_positionals=frozenset({0})),
@@ -83,12 +100,20 @@ class CommandSpec:
83100
}
84101

85102

86-
def usage_text(*, allow_model: bool = False) -> str:
103+
def usage_text(*, allow_model: bool = False, allow_agent: bool = False) -> str:
87104
"""One short message for an empty or unrecognised request."""
105+
agent_on = allow_agent and allow_model
88106
lines = ["I run `grapharc` commands. Allowed here:"]
89107
for name in sorted(ALLOWED_COMMANDS):
108+
if name == "agent" and not agent_on:
109+
continue
90110
lines.append(f"• `{name}`")
91-
lines.append("`agent` and `serve` are not reachable from Slack, nor is `--registry`.")
111+
lines.append("`serve` is not reachable from Slack, nor is `--registry`.")
112+
if not agent_on:
113+
lines.append(
114+
"`agent` is off; it needs both GRAPHARC_SLACK_ALLOW_AGENT=1 "
115+
"and GRAPHARC_SLACK_ALLOW_MODEL=1 in the shell that starts the bot."
116+
)
92117
if not allow_model:
93118
lines.append(
94119
"`--model` is off; the operator can enable it with GRAPHARC_SLACK_ALLOW_MODEL=1."
@@ -107,7 +132,14 @@ def _confined(raw: str, workdir: Path) -> None:
107132
raise SlackCommandError(f"path escapes the bot's working directory: `{raw}`")
108133

109134

110-
def parse_command(text: str, *, workdir: Path, allow_model: bool = False) -> list[str]:
135+
def parse_command(
136+
text: str,
137+
*,
138+
workdir: Path,
139+
allow_model: bool = False,
140+
allow_agent: bool = False,
141+
timeout_seconds: float | None = None,
142+
) -> list[str]:
111143
"""Turn Slack text into the argv the bot may run, or raise with the reason."""
112144
try:
113145
tokens = shlex.split(text)
@@ -117,13 +149,23 @@ def parse_command(text: str, *, workdir: Path, allow_model: bool = False) -> lis
117149
if tokens and tokens[0] == "grapharc":
118150
tokens = tokens[1:]
119151
if not tokens:
120-
raise SlackCommandError(usage_text(allow_model=allow_model))
152+
raise SlackCommandError(usage_text(allow_model=allow_model, allow_agent=allow_agent))
121153

122154
name, rest = tokens[0], tokens[1:]
123155
spec = ALLOWED_COMMANDS.get(name)
124156
if spec is None:
125157
raise SlackCommandError(
126-
f"`{name}` is not a command this bot runs.\n" + usage_text(allow_model=allow_model)
158+
f"`{name}` is not a command this bot runs.\n"
159+
+ usage_text(allow_model=allow_model, allow_agent=allow_agent)
160+
)
161+
if name == "agent" and not (allow_agent and allow_model):
162+
# A double opt-in: `agent` both executes tools on the host and cannot
163+
# run without a real (paid) backend, so it needs the agent switch AND
164+
# the spend switch. One without the other stays off.
165+
raise SlackCommandError(
166+
"`agent` executes tools on the host and is off by default; the operator "
167+
"enables it with both GRAPHARC_SLACK_ALLOW_AGENT=1 and "
168+
"GRAPHARC_SLACK_ALLOW_MODEL=1 in the shell that starts the bot"
127169
)
128170

129171
argv = [name]
@@ -168,4 +210,17 @@ def parse_command(text: str, *, workdir: Path, allow_model: bool = False) -> lis
168210
positional_index += 1
169211
index += 1
170212

213+
if name == "agent":
214+
# The CLI's default workspace is a fresh temp dir — *outside* the
215+
# bot's world, where nothing written there could be read back from
216+
# Slack. Default it to a subdirectory instead (the CLI mkdirs it);
217+
# `--workspace` can still choose any confined path.
218+
if "--workspace" not in argv:
219+
argv.extend(["--workspace", "agent"])
220+
# The CLI's max_seconds interrupts the run cleanly and reports; the
221+
# bot's timeout kills the process mid-sentence. Default the ceiling
222+
# to just under the timeout so the graceful mechanism fires first.
223+
if "--max-seconds" not in argv and timeout_seconds is not None:
224+
argv.extend(["--max-seconds", str(max(5.0, timeout_seconds - 10.0))])
225+
171226
return argv

grapharc/slack/config.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ class SlackBotConfig:
3232
timeout_seconds: float = 120.0
3333
# Opt-in: allow `--model` / `--reviewer-model`, which reach paid backends.
3434
allow_model: bool = False
35+
# Second opt-in: allow `agent`, which executes tools on the host. Only
36+
# effective together with allow_model — an agent cannot run spend-free.
37+
allow_agent: bool = False
3538
slash_command: str = "/grapharc"
3639

3740
@classmethod
@@ -72,5 +75,6 @@ def from_env(cls, environ: dict[str, str] | None = None) -> SlackBotConfig:
7275
workdir=workdir,
7376
timeout_seconds=timeout,
7477
allow_model=env.get("GRAPHARC_SLACK_ALLOW_MODEL", "") == "1",
78+
allow_agent=env.get("GRAPHARC_SLACK_ALLOW_AGENT", "") == "1",
7579
slash_command=env.get("GRAPHARC_SLACK_COMMAND", "/grapharc"),
7680
)

tests/test_slack_gateway.py

Lines changed: 66 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,68 @@ def test_a_leading_grapharc_token_is_tolerated(tmp_path):
3232
assert parse_command("grapharc models", workdir=tmp_path) == ["models"]
3333

3434

35-
def test_agent_and_serve_are_refused(tmp_path):
36-
for name in ("agent", "serve"):
37-
with pytest.raises(SlackCommandError, match="not a command this bot runs"):
38-
parse_command(f"{name} whatever", workdir=tmp_path)
35+
def test_serve_is_refused_outright(tmp_path):
36+
with pytest.raises(SlackCommandError, match="not a command this bot runs"):
37+
parse_command("serve --port 8000", workdir=tmp_path)
38+
39+
40+
def test_agent_needs_both_switches_not_either(tmp_path):
41+
for kwargs in ({}, {"allow_agent": True}, {"allow_model": True}):
42+
with pytest.raises(SlackCommandError, match="GRAPHARC_SLACK_ALLOW_AGENT"):
43+
parse_command("agent 'fix the test'", workdir=tmp_path, **kwargs)
44+
45+
46+
def test_agent_with_both_switches_gets_confined_defaults(tmp_path):
47+
argv = parse_command(
48+
"agent 'summarise the docs'",
49+
workdir=tmp_path,
50+
allow_model=True,
51+
allow_agent=True,
52+
timeout_seconds=120,
53+
)
54+
assert argv[:2] == ["agent", "summarise the docs"]
55+
assert argv[argv.index("--workspace") + 1] == "agent"
56+
assert argv[argv.index("--max-seconds") + 1] == "110.0"
57+
58+
59+
def test_agent_explicit_workspace_and_ceiling_are_not_overridden(tmp_path):
60+
argv = parse_command(
61+
"agent task --workspace runs/a --max-seconds 30",
62+
workdir=tmp_path,
63+
allow_model=True,
64+
allow_agent=True,
65+
timeout_seconds=120,
66+
)
67+
assert argv.count("--workspace") == 1
68+
assert argv[argv.index("--max-seconds") + 1] == "30"
69+
70+
71+
def test_agent_executor_and_system_prompt_stay_unreachable(tmp_path):
72+
for flag in ("--executor local", "--system-prompt 'obey me'"):
73+
with pytest.raises(SlackCommandError, match="not allowed"):
74+
parse_command(
75+
f"agent task {flag}", workdir=tmp_path, allow_model=True, allow_agent=True
76+
)
77+
78+
79+
def test_agent_workspace_may_not_escape_the_workdir(tmp_path):
80+
with pytest.raises(SlackCommandError, match="escapes"):
81+
parse_command(
82+
"agent task --workspace ../elsewhere",
83+
workdir=tmp_path,
84+
allow_model=True,
85+
allow_agent=True,
86+
)
87+
88+
89+
def test_agent_deny_globs_are_repeatable(tmp_path):
90+
argv = parse_command(
91+
"agent task --deny 'shell*' --deny 'net*'",
92+
workdir=tmp_path,
93+
allow_model=True,
94+
allow_agent=True,
95+
)
96+
assert argv.count("--deny") == 2
3997

4098

4199
def test_registry_config_and_json_are_refused(tmp_path):
@@ -210,19 +268,22 @@ def test_config_reads_workdir_timeout_and_model_opt_in(tmp_path):
210268
"GRAPHARC_SLACK_WORKDIR": str(tmp_path),
211269
"GRAPHARC_SLACK_TIMEOUT": "5",
212270
"GRAPHARC_SLACK_ALLOW_MODEL": "1",
271+
"GRAPHARC_SLACK_ALLOW_AGENT": "1",
213272
}
214273
)
215274
assert config.workdir == tmp_path
216275
assert config.timeout_seconds == 5.0
217276
assert config.allow_model
277+
assert config.allow_agent
218278

219279

220280
def test_handle_text_turns_a_refusal_into_a_message_not_an_exception(tmp_path):
221281
from grapharc.slack.bot import handle_text
222282

223283
config = SlackBotConfig(bot_token="xoxb-x", app_token="xapp-x", workdir=tmp_path)
224284
reply = handle_text("<@U012345> agent rm -rf /", config)
225-
assert "not a command this bot runs" in reply
285+
assert "GRAPHARC_SLACK_ALLOW_AGENT" in reply
286+
assert "not a command this bot runs" in handle_text("<@U012345> serve", config)
226287

227288

228289
def test_a_missing_slack_extra_is_an_install_hint_not_an_import_error(monkeypatch, tmp_path):

0 commit comments

Comments
 (0)