You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 2b44790
Browse filesBrowse the repository at this point in the historyBrowse files
| Action risk-bucket taxonomy + rule engine (allow/deny/ask), hooked into real task execution via `PermissionRequest`|**Built** — `permission_rule`/`check_permission`, wired through a `.claude/settings.json` hook (§10) |
203
+
| Inline chat-based + web-UI approval prompts, with pause/resume |**Built** — `permission_ask` table, Telegram inline buttons and a web answer endpoint both converge on one `answer_ask` function (§12) |
204
+
| Per-connector action classification beyond a flat tool→bucket map |**Built for GitHub only** (`github_classifier.py`, §12) — a `git status`/`commit`/`push` get independently governed instead of one flat `Bash` bucket. Other connectors still need their own classifier. |
| Audit trail of attempted vs. actual actions |**Not built**|
206
+
| Audit trail of attempted vs. actual actions |**Not built**(the `permission_ask`/`permission_rule` tables are a start, but nothing yet records actual post-execution outcomes) |
206
207
| Connector library beyond Claude/GitHub (Medium, YouTube, Discord, etc.) |**Not built**, not yet explicitly scoped in PLAN.md |
207
208
| Multiple named "employees" per user, with per-bot rules |**Not built** — requires schema changes (`bot_id` on tasks/schedules, new permission-rule table) |
209
+
| Real web-UI authentication |**Not built** — see open decision 5 below |
208
210
209
211
## Open decisions to resolve before implementation
210
212
@@ -234,6 +236,23 @@ work rather than folding into the base governance engine build.
234
236
cookie-based auth to replace the web UI's localStorage-trusted identity
235
237
before this ships publicly. Telegram's side of identification doesn't
236
238
need to change.
239
+
6. Git identity inside the sandbox. The `claude` and `github` provider
240
+
credentials are injected independently per `user_id`
241
+
(`inject_credential_into_box`), with nothing enforcing they belong to the
242
+
same real-world person — confirmed in testing, where a sandbox's GitHub
243
+
CLI was correctly authenticated as the real connected account, while git
244
+
commit authorship fell back to whatever identity the *Claude* login
245
+
happened to carry, since no `user.name`/`user.email` was ever configured.
246
+
Not a security issue (nothing leaked — the Claude account's own harness
247
+
context is a visible, documented feature, not a secret), but commits made
248
+
on a real user's behalf could be misattributed unless `start_task`
249
+
explicitly sets a per-user git identity before running anything.
250
+
7. Classifier coverage. `github_classifier.py` is the only per-connector
251
+
classifier today — every other `Bash` command, and every tool besides
252
+
`Bash`, still gets the flat per-tool bucket from §3's original coarse
253
+
map. Extending real specificity to another connector (or to non-git
254
+
shell commands generally) means writing another classifier, not
255
+
touching `check_permission` or the ask/answer mechanism.
237
256
238
257
## 10. How the engine actually hooks into task execution
239
258
@@ -256,15 +275,28 @@ used for credential injection — [box_operations.py](../backend/app/services/bo
256
275
configuring that hook to POST to a backend endpoint whenever a tool call
257
276
needs a decision. The command template drops `--dangerously-skip-permissions`
258
277
so the hook actually fires. No MCP server needed — just a plain HTTP
259
-
endpoint that maps `tool_name`/`tool_input` to `(bucket, connector, tool)`,
260
-
calls `check_permission`, and returns an allow/deny decision in the shape
261
-
Claude Code expects.
278
+
endpoint ([permission.py](../backend/app/routers/permission.py)) that maps
279
+
`tool_name`/`tool_input` to `(bucket, connector, tool)`, calls
280
+
`check_permission`, and returns a decision in the shape Claude Code expects.
281
+
A decision of `"ask"` is no longer collapsed to deny — see §12 for how it's
282
+
actually answered.
262
283
263
284
Two real constraints this surfaces: (1) the sandbox is a remote box and
264
285
can't reach `localhost` — dev needs a tunnel (ngrok or similar), production
265
286
needs the backend's real public URL; (2) the hook payload carries no
266
-
`user_id`, so it has to be baked into the per-task `settings.json` at
267
-
write-time (e.g. in the hook URL or a header), not assumed from context.
287
+
`user_id`, so it's baked into the hook URL itself at write-time, along with
288
+
the `task_id` (needed so a `permission_ask` row — §12 — can be tied back to
289
+
the right task; a user can have more than one task running at once).
290
+
291
+
**A built-in gotcha worth remembering**: Claude Code auto-approves a fixed,
292
+
non-configurable set of read-only Bash commands (`ls`, `cat`, `grep`,
293
+
`diff`, and read-only forms of `git` like `status`/`log`/`diff` among
294
+
others) *before* the permission system runs at all — these never reach the
295
+
hook, regardless of what the hook or any rule says. This is why the
296
+
`github_classifier`'s "read" bucket for git verbs is effectively unreachable
297
+
for the verbs on that built-in list; it only actually matters for verbs
298
+
Claude Code doesn't already auto-approve (e.g. `git config`, which looked
299
+
read-only but isn't on the built-in list and correctly still asks).
268
300
269
301
**Rejected alternative**: `--permission-prompt-tool` (registering as an MCP
270
302
server the CLI calls back into). Works, and is confirmed as a real pattern
@@ -284,14 +316,59 @@ pieces relevant to this doc rather than re-reading the full audit each time:
284
316
plumbing.
285
317
-**Rakazo** (`elie222/rakazo`)'s approval engine
286
318
(`packages/core/src/action-approval.ts`) uses the same specificity
287
-
ordering (tool > connector > category) as `check_permission`, plus
288
-
something we haven't built: a **regex-based verb classifier**
289
-
(`send/pay/delete/charge` vs `get/list/search`) that auto-assigns a risk
290
-
bucket from a tool's name/shape. Worth reading when building real
291
-
connectors (§7) — it's a shortcut for bucket-assignment on tools nobody's
292
-
hand-tagged yet, rather than requiring every connector action to be
293
-
manually classified at build time as §3 currently assumes.
319
+
ordering (tool > connector > category) as `check_permission`, plus a
320
+
**regex-based verb classifier** (`send/pay/delete/charge` vs
321
+
`get/list/search`) that auto-assigns a risk bucket from a tool's
322
+
name/shape. **Now built for GitHub**: `github_classifier.py` adapts this
323
+
idea to git's own verbs (§12) instead of requiring every connector action
324
+
to be manually classified at build time as §3 originally assumed. Still
325
+
worth re-reading when building the next connector's classifier.
294
326
-**OpenBot** (`CopilotKit/OpenBot`)'s audit-trail ordering: write the audit
295
327
row *before* honoring any policy decision, so refused actions are logged
296
328
too, not just approved ones. Apply this when building the audit trail
297
-
(§6/§9).
329
+
(§6/§9) — `permission_ask` rows are already written before a decision is
330
+
known (status starts `pending`), so this ordering is already followed for
331
+
asks; a true audit trail covering allow/deny-without-asking is still open.
332
+
333
+
## 12. What's actually built: the "ask" flow, end to end
334
+
335
+
§5 described the inline-approval UX as a goal; this section records how it
336
+
was actually implemented, since the real design differs in one important
337
+
way from the original sketch: the backend and the thing a human answers
338
+
from are **different processes that only share the database** (the
339
+
Telegram bot runs its own long-polling process, entirely separate from the
340
+
FastAPI app) — so "pause and wait for an answer" can't be an in-memory
341
+
`asyncio.Event`, it has to be a database row that gets polled.
342
+
343
+
-**`permission_ask`** (`app/db/models/permission_ask.py`): one row per
Copy file name to clipboardExpand all lines: README.md
+48-2Lines changed: 48 additions & 2 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -69,6 +69,12 @@ The web UI covers the same ground from a browser:
69
69
- A Telegram bot token from [@BotFather](https://t.me/BotFather) (only
70
70
needed if you want to run the bot, or let people link the web UI to
71
71
Telegram — the web UI's guest mode works without it)
72
+
- A public URL the sandbox can reach to call back into your backend for
73
+
permission decisions (see [Permission prompts](#permission-prompts)) — in
74
+
local dev this means tunneling your backend with
75
+
[ngrok](https://ngrok.com/) or similar; not needed to run the app, only
76
+
for any task that triggers a risky action (writing files, shell commands,
77
+
etc.) to actually get a decision instead of hanging until it times out.
72
78
73
79
## Setup
74
80
@@ -87,6 +93,8 @@ Fill in `backend/.env`:
87
93
|`TELEGRAM_BOT_TOKEN`| only for the bot / Telegram linking | From @BotFather. |
88
94
|`TELEGRAM_BOT_USERNAME`| no | Your bot's `@username` (no `@`), shown as a clickable link in the web UI. Cosmetic only. |
89
95
|`FRONTEND_ORIGINS`| no | Comma-separated origins allowed to call the API from a browser. Defaults to `http://localhost:3000`. |
96
+
|`HOOK_TOKEN`| yes | Shared secret the permission hook's caller (the sandbox) must present, so only your backend's own sandboxes can submit permission decisions. Generate with `python -c "import secrets; print(secrets.token_urlsafe(32))"`. |
97
+
|`PERMISSION_HOOK_BASE_URL`| yes | The public URL from the ngrok-or-similar prerequisite above, no trailing slash — e.g. `https://your-tunnel.ngrok-free.app`. Baked into each sandbox's `.claude/settings.json` at task start. |
90
98
|`POSTGRES_*`| no | Defaults match `backend/docker-compose.yml`. |
91
99
92
100
The web UI (`frontend/`) has its own `.env.example` — `scripts/dev.sh` copies
@@ -194,6 +202,36 @@ captured at creation time. One created from Telegram is always UTC, since
194
202
Telegram gives the bot no way to read the user's timezone — the bot's
195
203
replies always spell out times in UTC.
196
204
205
+
## Permission prompts
206
+
207
+
Every risky action the AI attempts inside a sandbox — writing a file,
208
+
running a shell command, a `git push`, etc. — is intercepted by a
209
+
`PermissionRequest` hook before it runs, and checked against a per-user
210
+
rule table (`permission_rule`). No matching rule means the task pauses
211
+
(status `waiting_approval`) and you're asked, in whichever channel you're
212
+
using:
213
+
214
+
-**Telegram**: a message with **Allow once** / **Always allow** / **Deny**
215
+
buttons.
216
+
-**Web UI**: the same three choices appear inline on the task once it's
217
+
expanded, no page navigation needed.
218
+
219
+
"Always allow" writes a standing rule so the same action doesn't ask again;
220
+
"Allow once"/"Deny" apply only to that one attempt. An unanswered prompt
221
+
times out (110s) to a safe default of deny rather than hanging forever.
222
+
223
+
Shell (`Bash`) commands get a further pass: a small classifier
224
+
(`app/services/github_classifier.py`) recognizes `git` subcommands and
225
+
scopes the rule to the specific operation (`git status` read-only,
226
+
`git commit` a local change, `git push` a publish) instead of treating every
227
+
shell command the same — so "always allow committing" doesn't also silently
228
+
allow pushing. Everything else run via `Bash`, and any tool without its own
229
+
classifier, falls back to one coarse bucket per tool.
230
+
231
+
See [GOVERNANCE_ENGINE.md](GOVERNANCE_ENGINE.md) for the full design
232
+
rationale and what's still open (e.g. extending classification to other
0 commit comments