Skip to content

Commit 2b44790

Browse files
committed
Implement permission request handling with classification for Git commands and UI updates for approval prompts
1 parent 050410e commit 2b44790

9 files changed

Lines changed: 484 additions & 44 deletions

File tree

‎GOVERNANCE_ENGINE.md‎

Lines changed: 93 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -199,12 +199,14 @@ work rather than folding into the base governance engine build.
199199
| Piece | Status |
200200
|---|---|
201201
| Sandbox per user, Claude OAuth login, GitHub device-flow linking, task execution, cron scheduling | **Already built** ([code/](../code)) |
202-
| Action risk-bucket taxonomy + rule engine (allow/deny/ask) | **Not built** — this doc's core proposal |
203-
| Inline chat-based approval prompts | **Not built** |
202+
| 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. |
204205
| Server-side outcome verification (vs. trusting the AI's self-report) | **Not built** |
205-
| 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) |
206207
| Connector library beyond Claude/GitHub (Medium, YouTube, Discord, etc.) | **Not built**, not yet explicitly scoped in PLAN.md |
207208
| 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 |
208210

209211
## Open decisions to resolve before implementation
210212

@@ -234,6 +236,23 @@ work rather than folding into the base governance engine build.
234236
cookie-based auth to replace the web UI's localStorage-trusted identity
235237
before this ships publicly. Telegram's side of identification doesn't
236238
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.
237256

238257
## 10. How the engine actually hooks into task execution
239258

@@ -256,15 +275,28 @@ used for credential injection — [box_operations.py](../backend/app/services/bo
256275
configuring that hook to POST to a backend endpoint whenever a tool call
257276
needs a decision. The command template drops `--dangerously-skip-permissions`
258277
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.
262283

263284
Two real constraints this surfaces: (1) the sandbox is a remote box and
264285
can't reach `localhost` — dev needs a tunnel (ngrok or similar), production
265286
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).
268300

269301
**Rejected alternative**: `--permission-prompt-tool` (registering as an MCP
270302
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:
284316
plumbing.
285317
- **Rakazo** (`elie222/rakazo`)'s approval engine
286318
(`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.
294326
- **OpenBot** (`CopilotKit/OpenBot`)'s audit-trail ordering: write the audit
295327
row *before* honoring any policy decision, so refused actions are logged
296328
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
344+
pending question — `task_id`, `bucket`/`connector`/`tool`, `status`
345+
(`pending` → `answered`/`expired`), `decision`, `always`.
346+
- **`permission_ask_service.py`**: `create_ask` records the question and
347+
flips the task to a new `waiting_approval` status; `answer_ask` resolves
348+
it (writing a standing `permission_rule` if `always=True`) and resumes the
349+
task; `poll_for_answer` is the sleep-loop the hook endpoint blocks on
350+
(bounded under the hook's own timeout, defaulting to **deny** on expiry —
351+
consistent with §1, an unanswered question is still "no rule").
352+
- **Two answering channels, one function**: a Telegram inline keyboard
353+
(`notification_service.send_permission_ask`, decoded by a
354+
`CallbackQueryHandler` in `telegram_bot.py`) and a web endpoint
355+
(`POST /permissions/ask/{id}/answer`, surfaced in the frontend's task
356+
card) both call the exact same `answer_ask` — whichever channel a human
357+
answers from, the result is identical. This matters because a web-only
358+
guest has no Telegram chat to be asked in at all.
359+
- **A real bug this surfaced and fixed**: the "always allow" rule must be
360+
written at the *same specificity the decision was actually checked at*.
361+
Writing a rule scoped to a `tool` that the hook never passes into
362+
`check_permission` makes that rule permanently invisible — it was
363+
committed once (coarse `Bash`→`"system"` mapping, no real tool identity)
364+
and is now correct again now that the GitHub classifier makes `connector`/
365+
`tool` meaningful and consistently derived.
366+
- **`github_classifier.py`**: the first (and so far only) per-connector
367+
classifier, turning a raw `Bash` command into `(bucket, connector, tool)`
368+
when it's a `git` invocation — `status`/`log`/`diff`/etc. → `read`,
369+
`push` → `publish`, anything else recognized or not → `create` (unknown
370+
verbs default to the risk-bearing bucket, not the safe one, per §1). A
371+
compound command (`git add . && git commit && git push`) is classified by
372+
its single riskiest verb. Adding a classifier for another connector is
373+
the only change needed to extend this pattern — `check_permission`'s
374+
specificity model and the ask/answer mechanism don't change at all.

‎README.md‎

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,12 @@ The web UI covers the same ground from a browser:
6969
- A Telegram bot token from [@BotFather](https://t.me/BotFather) (only
7070
needed if you want to run the bot, or let people link the web UI to
7171
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.
7278

7379
## Setup
7480

@@ -87,6 +93,8 @@ Fill in `backend/.env`:
8793
| `TELEGRAM_BOT_TOKEN` | only for the bot / Telegram linking | From @BotFather. |
8894
| `TELEGRAM_BOT_USERNAME` | no | Your bot's `@username` (no `@`), shown as a clickable link in the web UI. Cosmetic only. |
8995
| `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. |
9098
| `POSTGRES_*` | no | Defaults match `backend/docker-compose.yml`. |
9199

92100
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
194202
Telegram gives the bot no way to read the user's timezone — the bot's
195203
replies always spell out times in UTC.
196204

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
233+
connectors).
234+
197235
## Project layout
198236

199237
- [backend/](backend) — FastAPI app, Telegram bot, sandbox/auth/task
@@ -213,8 +251,16 @@ replies always spell out times in UTC.
213251
- Link codes expire after 10 minutes; there's no rate limiting on generating
214252
them.
215253
- The web UI has no real authentication — a browser's identity is whatever
216-
id (guest or Telegram) it last linked, persisted in `localStorage`.
217-
- No automated tests yet.
254+
id (guest or Telegram) it last linked, persisted in `localStorage`. This
255+
also means the permission engine's accumulated rules are only as safe as
256+
that identity — see [GOVERNANCE_ENGINE.md](GOVERNANCE_ENGINE.md)'s open
257+
decisions.
258+
- Automated tests exist for the permission engine
259+
(`backend/tests/services/`) but not yet for the rest of the app
260+
(sandbox/task/schedule services, routers).
261+
- The `git` command classifier only covers the verbs it explicitly knows
262+
about (see `github_classifier.py`); an unrecognized verb defaults to the
263+
safer "mutation" bucket rather than being assumed read-only.
218264
- Schedules poll on a 30s interval, so a run can start up to ~30s after its
219265
exact due time.
220266
- A schedule missed by more than 15 minutes (e.g. the API was down) is

‎backend/app/routers/permission.py‎

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,17 @@
55
from app.db.session import get_db
66
from app.schemas.permission import AnswerAskRequest, PermissionAskResponse
77
from app.services import notification_service, permission_ask_service
8+
from app.services.github_classifier import classify_bash_command
89
from app.services.permission_service import check_permission as check_permission_rule
910

1011
router = APIRouter(
1112
prefix="/permissions",
1213
tags=["Permissions"],
1314
)
1415

15-
# Coarse first pass: no real connectors exist yet, so every tool maps to a
16-
# bucket only (no connector/tool-level granularity). Anything not listed
17-
# here defaults to "system" — the most cautious bucket — rather than being
18-
# assumed safe.
16+
# Coarse default: no connector/tool-level granularity for these yet.
17+
# Anything not listed here defaults to "system" — the most cautious bucket
18+
# — rather than being assumed safe.
1919
TOOL_BUCKETS = {
2020
"Read": "read",
2121
"Grep": "read",
@@ -27,6 +27,20 @@
2727
"Bash": "system",
2828
}
2929

30+
31+
def _classify(tool_name: str, body: dict) -> tuple[str, str | None, str | None]:
32+
"""(bucket, connector, tool) for a hook call. Bash commands are run
33+
through per-connector classifiers first (currently just github_classifier
34+
for git) — this is the one place that knowledge plugs in, so adding a
35+
new connector's classifier here is the only change it needs."""
36+
if tool_name == "Bash":
37+
command = body.get("tool_input", {}).get("command", "")
38+
classified = classify_bash_command(command)
39+
if classified is not None:
40+
return classified
41+
42+
return TOOL_BUCKETS.get(tool_name, "system"), None, None
43+
3044
# Kept under the hook's own 120s timeout (settings.json's PermissionRequest
3145
# config) so we always get a chance to return "deny" ourselves on expiry,
3246
# rather than letting the hook call itself time out with no answer.
@@ -60,12 +74,12 @@ async def check_permission(
6074

6175
body = await request.json()
6276
tool_name = body.get("tool_name", "")
63-
bucket = TOOL_BUCKETS.get(tool_name, "system")
77+
bucket, connector, tool = _classify(tool_name, body)
6478

65-
decision = await check_permission_rule(db, user_id, bucket)
79+
decision = await check_permission_rule(db, user_id, bucket, connector=connector, tool=tool)
6680
if decision == "ask":
6781
ask = await permission_ask_service.create_ask(
68-
db, user_id, bucket=bucket, task_id=task_id, tool=tool_name or None
82+
db, user_id, bucket=bucket, task_id=task_id, connector=connector, tool=tool
6983
)
7084
chat_id = notification_service.telegram_chat_id(user_id)
7185
if chat_id is not None:

0 commit comments

Comments
 (0)