44not a convenience parser — the same posture as the CLI's own policy layer. The
55rules, 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
0 commit comments