Commit 931f8bc8 authored by xuwang's avatar xuwang
Browse files

feat(agentic): run loop workers under bypass + per-role deny guardrails



A looped/headless agent has no human to approve tool calls, so an allowlist
can only ever DENY (no prompt) the moment an agent needs a command nobody
pre-enumerated (a test runner, sed, jq, a new make target) — stalling it.

Run all four roles with --dangerously-skip-permissions and rely on a short
per-role `deny` list as the guardrail. `deny` is still enforced under bypass
(settings files still apply), so the allow lists became dead config and were
dropped. Denies: resolver → remote-retargeting + force-push; read-only roles
→ git push/commit, remote-retargeting, Edit/Write/NotebookEdit.

Verified: make validate green; resolver runs a non-allowlisted command;
force-push and the Edit tool are blocked by deny under bypass.

Changelog: changed

Co-Authored-By: default avatarClaude Opus 4.8 <noreply@anthropic.com>
parent 2b261789
Loading
Loading
Loading
Loading
+4 −9
Original line number Diff line number Diff line
{
  "permissions": {
    "allow": [
      "Bash(scripts/gl-flow.sh:*)",
      "Bash(git fetch:*)",
      "Bash(git log:*)",
      "Bash(git show:*)",
      "Bash(git rev-parse:*)",
      "Read(/**)"
    ],
    "deny": [
      "Bash(git push:*)",
      "Bash(git commit:*)",
      "Bash(git remote add:*)",
      "Bash(git remote set-url:*)"
      "Bash(git remote set-url:*)",
      "Edit",
      "Write",
      "NotebookEdit"
    ]
  }
}
+4 −5
Original line number Diff line number Diff line
{
  "permissions": {
    "allow": [
      "Bash(scripts/gl-flow.sh:*)",
      "Read(/**)"
    ],
    "deny": [
      "Bash(git push:*)",
      "Bash(git commit:*)",
      "Bash(git remote add:*)",
      "Bash(git remote set-url:*)"
      "Bash(git remote set-url:*)",
      "Edit",
      "Write",
      "NotebookEdit"
    ]
  }
}
+4 −21
Original line number Diff line number Diff line
{
  "permissions": {
    "allow": [
      "Bash(scripts/gl-flow.sh:*)",
      "Bash(make:*)",
      "Bash(git fetch:*)",
      "Bash(git switch:*)",
      "Bash(git checkout:*)",
      "Bash(git branch:*)",
      "Bash(git add:*)",
      "Bash(git commit:*)",
      "Bash(git push:*)",
      "Bash(git diff:*)",
      "Bash(git log:*)",
      "Bash(git show:*)",
      "Bash(git status:*)",
      "Bash(git restore:*)",
      "Bash(git stash:*)",
      "Bash(git rev-parse:*)",
      "WebFetch(domain:code.stanford.edu)",
      "Read(/**)"
    ],
    "deny": [
      "Bash(git remote add:*)",
      "Bash(git remote set-url:*)"
      "Bash(git remote set-url:*)",
      "Bash(git push --force:*)",
      "Bash(git push --force-with-lease:*)",
      "Bash(git push -f:*)"
    ]
  }
}
+4 −12
Original line number Diff line number Diff line
{
  "permissions": {
    "allow": [
      "Bash(scripts/gl-flow.sh:*)",
      "Bash(make:*)",
      "Bash(git fetch:*)",
      "Bash(git diff:*)",
      "Bash(git log:*)",
      "Bash(git show:*)",
      "Bash(git status:*)",
      "Bash(git rev-parse:*)",
      "Read(/**)"
    ],
    "deny": [
      "Bash(git push:*)",
      "Bash(git commit:*)",
      "Bash(git remote add:*)",
      "Bash(git remote set-url:*)"
      "Bash(git remote set-url:*)",
      "Edit",
      "Write",
      "NotebookEdit"
    ]
  }
}
+52 −23
Original line number Diff line number Diff line
@@ -204,12 +204,14 @@ idle** (and recurring in-session jobs auto-expire after 7 days). So:
starts the three loops as background Claude sessions — one per role, no TUI to
babysit and no manual dispatch:
```
claude --bg --name agent-<role> [--permission-mode acceptEdits] \
claude --bg --name agent-<role> --dangerously-skip-permissions \
    --settings agentic-<role>.settings.json "/loop <interval> /agent-<role>"
```
Each runs with its own bot identity (`~/.otica-tokens/<role>.token` if present, else
`~/.otica-token`) and per-role least-privilege settings; only the resolver gets
`acceptEdits`. They show up in `claude agents`.
`~/.otica-token`) and per-role settings. All roles run with
`--dangerously-skip-permissions` — a looped worker has no human to approve tool
calls, so the per-role `deny` list (still enforced under bypass) is the guardrail,
not an allowlist (see "Permission preset" below). They show up in `claude agents`.
```bash
make agents          # start the 3 loops in the background
make agents-view     # monitor (= claude agents); Ctrl+X stops a session
@@ -263,18 +265,45 @@ Invoke a headless issue on demand (e.g. from a script, webhook, or alert handler
All knobs are summarized in [Configuration](#configuration-all-knobs) below.

### Permission preset (headless authorization)
Headless mode has no human to approve tool calls, so `--permission-mode
acceptEdits` (auto-accept file edits) is paired with the allowlist in
`agent-instructions/agentic.settings.json` — the union of Bash/WebFetch calls the
four agents make (`gl-flow.sh`, `make`, scoped `git`
verbs, WebFetch to code.stanford.edu, reads under `~/.otica`). Anything **not**
listed is denied (not queued — there's no one to ask), so an agent that hits a
denial should park the item and exit rather than spin. `git remote add/set-url`
are explicitly **denied** to prevent retargeting away from `origin`.

Per-role least-privilege is already wired (see "Agent-mode internals" below):
`agent-tick.sh` auto-selects `agentic-<role>.settings.json` per role, falling back
to the union `agentic.settings.json`. Override with `OTICA_AGENTIC_SETTINGS`.
Headless and looped-background runs have **no human to approve tool calls**, so an
*allowlist* can only ever deny — silently, with no prompt — the instant an agent
needs a command nobody pre-enumerated (a test runner, `sed`, `jq`, a new `make`
target). That stalls the agent and is impossible to keep complete. So the roles
run with `--dangerously-skip-permissions` (bypass the allowlist) and lean on a
short per-role **`deny` list** as the actual guardrail.

This is safe because **`deny` rules are still enforced under bypass** — settings
files still apply; only the (now-irrelevant) allow side is skipped. Verified: with
`--dangerously-skip-permissions` and `deny: ["Bash(printf:*)"]`, a `printf` call is
blocked (`Permission to use Bash with command printf … has been denied`). The deny
side is what defines least-privilege now, so the `allow` lists were dropped from
`agentic-*.settings.json` entirely (they're dead config under bypass, and an
*invalid* settings file is silently ignored in headless mode — keep these minimal
and strictly valid or you lose the deny guardrail too).

What each role denies:
- **resolver** (edits, commits, pushes its MR branch to `origin`): denies remote
  retargeting (`git remote add/set-url`) and force-push (`git push --force[-with-lease]`/`-f`).
- **reviewer / integrator / reporter** (read-only): denies `git push`, `git commit`,
  remote retargeting, and the `Edit`/`Write`/`NotebookEdit` tools.

The meaningful mutations are pushing git and retargeting the remote — both denied
where they don't belong. A read-only role *could* still scribble in its checkout
via raw shell (bypass can't enumerate every mutating command), but that's inert:
it never pushes and runs against a throwaway checkout, and its GitLab writes go
through `gl-flow.sh` role-appropriate calls. The integrator merges via the API
(`gl-flow.sh`), not `git push`, so denying push doesn't impede it.

Per-role selection is wired (see "Agent-mode internals" below): `agent-tick.sh` and
`agent-start.sh` auto-select `agentic-<role>.settings.json`, falling back to the
union `agentic.settings.json`. Override with `OTICA_AGENTIC_SETTINGS`.

> **Stronger alternative — OS sandbox.** v2.1.177 also supports a `sandbox` block in
> settings (macOS Seatbelt / Linux bubblewrap). With `sandbox.enabled` +
> auto-allow, in-workspace reads/writes run un-prompted while network and
> out-of-tree writes fall back to the permission flow (and `allowedDomains` gates
> which hosts a tick may reach). That confines a rogue role at the OS level rather
> than trusting `deny` alone; layer it on top of the above if you want defense-in-depth.

### Agent-mode internals (subagents, per-role privilege & models)
The four pipeline roles stay **top-level sessions** — they need distinct GitLab
@@ -292,15 +321,15 @@ need to spawn helpers (a subagent can't spawn subagents). So "agent mode" is use

**Per-role least privilege**`agent-tick.sh` picks `agentic-<role>.settings.json`:

| Role | Bash it can run | Edits? | Model |
|------|-----------------|--------|-------|
| resolver | gl-flow, make, full git (push to origin), WebFetch | yes (`acceptEdits`) | `opus` |
| reviewer | gl-flow, make, read-only git (diff/log/…) | no | `opus` |
| integrator | gl-flow, read-only git | no | `sonnet` |
| reporter | gl-flow only | no | `haiku` |
| Role | Denied (the guardrail) | Edits? | Model |
|------|------------------------|--------|-------|
| resolver | remote retargeting, force-push | yes | `opus` |
| reviewer | `git push`/`commit`, remote retargeting, `Edit`/`Write` | no | `opus` |
| integrator | `git push`/`commit`, remote retargeting, `Edit`/`Write` | no | `sonnet` |
| reporter | `git push`/`commit`, remote retargeting, `Edit`/`Write` | no | `haiku` |

`git push`/`commit` and remote retargeting are **denied** for the read-only roles;
only the resolver runs with `--permission-mode acceptEdits`. Models are tunable
All roles run with `--dangerously-skip-permissions`; the `deny` column above is the
real boundary (enforced under bypass). Models are tunable
with `OTICA_MODEL` (all roles) or by editing the `case` in `agent-tick.sh`; the
helper subagents' models are set in their `.claude/agents/*.md` frontmatter
(`sonnet` by default — bump to `inherit`/`opus` for deeper review).
Loading