Skip to content

Commit ebc6736

Browse files
authored
feat(agent-teams): v0.4.0 — run ID, bulk approve, dashboard grouping (#66)
* feat(agent-teams): v0.4 — run ID, bulk approve, dashboard grouping (#64) * feat(run-id): W1 — add RunID to data model and plumbing Add run ID support throughout the stack to enable agent team grouping. Changes: - hookInput: add session_id, transcript_path, cwd, permission_mode, hook_event_name, tool_use_id fields (full Claude Code PreToolUse schema) - deriveRunID(): new func, priority: RAMPART_RUN > session_id > CLAUDE_CONVERSATION_ID > "" - hookParseResult: add RunID field, populated from deriveRunID(session_id) - engine.ToolCall: add RunID string field - audit.Event: add run_id field (omitempty) - hook cmd: wire RunID through call and audit event - proxy server: accept run_id in createApprovalRequest, expose in list response - hook_approval client: pass run_id to POST /v1/approvals - tests: update requestApproval calls to pass empty runID string The session_id field in Claude Code's PreToolUse JSON is shared across all agents in the same Claude Code session, making it the ideal run ID source for agent team grouping with zero new integration required. Priority order for run ID: 1. RAMPART_RUN env (explicit override for scripted orchestration) 2. session_id from Claude Code hook stdin JSON 3. CLAUDE_CONVERSATION_ID env (future fallback) 4. "" (no grouping, standalone call) * feat(bulk-approve): W2 — bulk resolve API and auto-approve cache * ci: fix docs deploy target — push to peg/rampart-docs, not local gh-pages The live docs at docs.rampart.sh are served from peg/rampart-docs (gh-pages branch). The previous workflow was deploying to peg/rampart's own gh-pages branch, which nobody visits, causing docs to appear permanently stale. Fix: build site with mkdocs build, then rsync+push to peg/rampart-docs via a PAT. Requires DOCS_DEPLOY_TOKEN secret (PAT with contents:write on peg/rampart-docs). * feat(dashboard): W3 — group pending approvals by run_id * test(proxy): Go tests for bulk-resolve and run_groups (W2/W3) Adds 7 new tests covering the v0.4 agent teams features: BulkResolve: - TestBulkResolve_ApprovesAllInRun — resolves 2 approvals, returns ids - TestBulkResolve_EmptyRunIDRejected — 400 on empty/whitespace/missing run_id - TestBulkResolve_NoAuth — 401 without token - TestBulkResolve_ZeroResolved_WhenNoPendingForRun — 0 resolved, ids=[] (not null) AutoApproveCache: - TestAutoApproveCache_SubsequentCallsSkipQueue — after bulk-approve, new approval from same run gets status=approved without queuing ListApprovals run_groups: - TestListApprovals_RunGroups — 2+ pending with same run_id form a group; solo item (no run_id) excluded; flat approvals still has all items - TestListApprovals_RunGroupsSortedByEarliestCreatedAt — groups sorted by MIN(created_at) chronologically, not by run_id UUID * feat(cline): wire taskId as run_id for agent team grouping Cline's taskId is scoped to a single task/conversation, making it equivalent to Claude Code's session_id for run grouping. Maps via deriveRunID() so RAMPART_RUN override still takes priority. * chore: CHANGELOG for v0.4.0 and v0.3.1 * fix(hook): handle 200 auto-approve response from serve When bulk-resolve is called for a run, subsequent POST /v1/approvals from that run return 200 {status: approved} instead of 201 (pending). The hook approval client only checked for StatusCreated (201), so the 200 was treated as an error and fell back to hookAsk (native Claude Code prompt). Auto-approve never actually worked in the real hook flow. Fix: check for 200 + status=approved before the 201 check, return hookAllow immediately without entering the poll loop. * docs: overhaul 5-minute tutorial, quickstart, add agent teams page tutorial.md: - Lead with rampart quickstart (one command) instead of manual setup - Add approval flow section with dashboard (require_approval → approve) - Agent teams tip (run grouping, Approve All) - Replace stale rampart watch ASCII UI with accurate dashboard reference - Cut bloated MCP section to a link - Add rampart doctor verification step - Flow diagram kept but tightened quickstart.md: - Trim to essentials: one-command setup, doctor, test, approve - Remove fake ASCII watch terminal UI - Fix action: log → action: watch language - Dashboard mention for approval flow features/agent-teams.md (new): - Run ID grouping, auto-approve cache, bulk-resolve API - Dashboard cluster card UX - Audit trail run_id field - Supported agents table (Claude Code / Cline / RAMPART_RUN override) - Wired into mkdocs.yml nav under Features * fix(test): check http response error before using resp (go vet)
1 parent c5e2a90 commit ebc6736

File tree

16 files changed

+1134
-226
lines changed

16 files changed

+1134
-226
lines changed

.github/workflows/docs.yml

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ on:
1010
- '.github/workflows/docs.yml'
1111

1212
permissions:
13-
contents: write
13+
contents: read
1414

1515
jobs:
1616
deploy:
@@ -24,4 +24,22 @@ jobs:
2424

2525
- run: pip install mkdocs-material mkdocs-minify-plugin
2626

27-
- run: mkdocs gh-deploy --force
27+
- name: Build docs
28+
run: mkdocs build
29+
30+
# Deploy to peg/rampart-docs (the repo that serves docs.rampart.sh via GitHub Pages).
31+
# Requires a PAT with contents:write on peg/rampart-docs stored as DOCS_DEPLOY_TOKEN.
32+
- name: Push to peg/rampart-docs
33+
env:
34+
DOCS_DEPLOY_TOKEN: ${{ secrets.DOCS_DEPLOY_TOKEN }}
35+
run: |
36+
git config --global user.name "github-actions[bot]"
37+
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
38+
cd /tmp
39+
git clone --depth=1 https://x-access-token:${DOCS_DEPLOY_TOKEN}@github.com/peg/rampart-docs.git rampart-docs
40+
rsync -a --delete --exclude='.git' "${GITHUB_WORKSPACE}/site/" rampart-docs/
41+
cd rampart-docs
42+
git add -A
43+
git diff --cached --quiet && echo "No changes" && exit 0
44+
git commit -m "docs: deploy from peg/rampart@${GITHUB_SHA::8}"
45+
git push origin gh-pages

CHANGELOG.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.4.0] — 2026-02-19
11+
12+
### Added
13+
14+
- **Agent team run grouping**: Every tool call is now tagged with a `run_id` derived from Claude Code's `session_id` (shared across all agents in the same session). Cline users get `taskId` as the run ID. Override with `RAMPART_RUN` env var; `CLAUDE_CONVERSATION_ID` used as fallback. Zero configuration required — existing policies and users see no change.
15+
- **`POST /v1/approvals/bulk-resolve`**: Resolve all pending approvals for an agent team run in one API call. Body: `{"run_id": "...", "action": "approve|deny", "resolved_by": "..."}`. Returns `{"resolved": N, "ids": [...]}`. Empty or missing `run_id` is hard-rejected with 400 to prevent accidental mass-approval.
16+
- **Auto-approve cache**: After bulk-approving a run, subsequent tool calls from that run bypass the approval queue automatically for the duration of the approval timeout (default 1h). Cache is TTL-based and cleaned up in the regular `Cleanup()` cycle — no goroutine leaks.
17+
- **Dashboard run clusters**: When 2+ pending approvals share a `run_id`, the Active tab groups them into a collapsible cluster card showing `Run: {id[:8]}… (N pending)`. Clusters have **Approve All** and **Deny All** buttons with confirmation dialogs. Solo items (no `run_id`, or unique `run_id`) render exactly as before.
18+
- **`GET /v1/approvals` run_groups field**: The approvals list response now includes a `run_groups` array alongside the flat `approvals` array. Each entry has `run_id`, `count`, `earliest_created_at`, and `items`. Only groups with 2+ pending items are included; groups are sorted by `MIN(created_at)` (chronological, not UUID order). Fully backwards compatible — existing consumers ignore the new field.
19+
- **Full PreToolUse hook schema**: `hookInput` now captures all fields Claude Code sends: `session_id`, `transcript_path`, `cwd`, `permission_mode`, `hook_event_name`, `tool_use_id`. Previously only `tool_name` and `tool_input` were parsed.
20+
- **`run_id` in audit events**: Audit log entries include `"run_id"` (omitempty) so team runs are traceable across the full audit trail.
21+
22+
### Fixed
23+
24+
- **CI docs deploy**: The Deploy Docs workflow was pushing compiled output to `peg/rampart`'s own `gh-pages` branch instead of `peg/rampart-docs`, which is the repo actually serving `docs.rampart.sh`. Fixed to clone and push to `peg/rampart-docs`. Requires `DOCS_DEPLOY_TOKEN` secret (PAT with `contents:write` on `peg/rampart-docs`).
25+
26+
## [0.3.1] — 2026-02-18
27+
28+
### Fixed
29+
30+
- **Mobile hero font size**: `.hero-title` heading was 2.5rem on narrow screens (≤600px), causing word-wrap on small phones. Reduced to 1.75rem via media query.
31+
- **`file_path` parameter support**: Claude Code sends `file_path` (not `path`) in `Read`, `Write`, and `Edit` tool input. `Path()` method and dashboard `extractCmd` function now check `file_path` first, then fall back to `path`. Previously the file path was silently dropped from audit events and approval cards for all file operations.
32+
33+
### Changed
34+
35+
- **Docs subtitle**: Updated from "Open-source firewall for AI agents" to "Open-source guardrails for AI agents. A policy firewall for shell commands, file access, and MCP tools."
36+
1037
## [0.3.0] — 2026-02-18
1138

1239
### Added

cmd/rampart/cli/hook.go

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,21 @@ import (
2121
)
2222

2323
// hookInput is the JSON sent by Claude Code on stdin for PreToolUse/PostToolUse hooks.
24-
// PostToolUse includes tool_response (free-form object whose schema varies by tool).
24+
// The base object (from oZ() in Claude Code source) includes session_id, transcript_path,
25+
// cwd, and permission_mode. PreToolUse adds hook_event_name, tool_name, tool_input, and
26+
// tool_use_id. PostToolUse additionally includes tool_response.
2527
type hookInput struct {
28+
// Base fields (present on all hook events)
29+
SessionID string `json:"session_id"`
30+
TranscriptPath string `json:"transcript_path,omitempty"`
31+
CWD string `json:"cwd,omitempty"`
32+
PermissionMode string `json:"permission_mode,omitempty"`
33+
34+
// Event-type fields
35+
HookEventName string `json:"hook_event_name,omitempty"`
36+
ToolUseID string `json:"tool_use_id,omitempty"`
37+
38+
// Tool-specific fields
2639
ToolName string `json:"tool_name"`
2740
ToolInput map[string]any `json:"tool_input"`
2841
ToolResponse map[string]any `json:"tool_response,omitempty"`
@@ -72,6 +85,7 @@ type hookParseResult struct {
7285
Params map[string]any
7386
Agent string
7487
Response string // non-empty for PostToolUse events
88+
RunID string // run ID derived from session_id (or env overrides)
7589
}
7690

7791
// gitContext holds the git repository context for the current working directory.
@@ -80,6 +94,28 @@ type gitContext struct {
8094
root string // absolute git root path e.g. "/home/user/projects/myapp"
8195
}
8296

97+
// deriveRunID returns the run ID for the current hook invocation, used to group
98+
// all tool calls from the same agent orchestration run.
99+
//
100+
// Priority order:
101+
// 1. RAMPART_RUN env var — explicit override, useful for scripted orchestration
102+
// 2. sessionID — the session_id field from Claude Code's hook stdin JSON,
103+
// shared across all agents in the same Claude Code session
104+
// 3. CLAUDE_CONVERSATION_ID env var — fallback for future Claude Code versions
105+
// 4. "" — no grouping; each call is standalone
106+
func deriveRunID(sessionID string) string {
107+
if v := strings.TrimSpace(os.Getenv("RAMPART_RUN")); v != "" {
108+
return v
109+
}
110+
if sessionID != "" {
111+
return sessionID
112+
}
113+
if v := strings.TrimSpace(os.Getenv("CLAUDE_CONVERSATION_ID")); v != "" {
114+
return v
115+
}
116+
return ""
117+
}
118+
83119
// deriveGitContext returns the git context for the current working directory.
84120
// The RAMPART_SESSION env var overrides the session name if set (root is still detected).
85121
// Returns an empty gitContext if not in a git repo or git is unavailable.
@@ -292,6 +328,7 @@ Cline setup: Use "rampart setup cline" to install hooks automatically.`,
292328
ID: audit.NewEventID(),
293329
Agent: parsed.Agent,
294330
Session: hookSession,
331+
RunID: parsed.RunID,
295332
Tool: parsed.Tool,
296333
Params: parsed.Params,
297334
Timestamp: time.Now().UTC(),
@@ -320,6 +357,7 @@ Cline setup: Use "rampart setup cline" to install hooks automatically.`,
320357
Timestamp: call.Timestamp,
321358
Agent: call.Agent,
322359
Session: call.Session,
360+
RunID: call.RunID,
323361
Tool: call.Tool,
324362
Request: parsed.Params,
325363
Decision: eventDecision,
@@ -364,7 +402,7 @@ Cline setup: Use "rampart setup cline" to install hooks automatically.`,
364402
}
365403
command, _ := call.Params["command"].(string)
366404
path := call.Path() // handles both "file_path" (Claude Code) and "path"
367-
result := approvalClient.requestApprovalCtx(cmd.Context(), call.Tool, command, call.Agent, path, decision.Message, 5*time.Minute)
405+
result := approvalClient.requestApprovalCtx(cmd.Context(), call.Tool, command, call.Agent, path, call.RunID, decision.Message, 5*time.Minute)
368406
return outputHookResult(cmd, format, result, false, decision.Message, cmdStr)
369407
}
370408
return outputHookResult(cmd, format, hookAsk, false, decision.Message, cmdStr)
@@ -403,6 +441,7 @@ func parseClaudeCodeInput(reader interface{ Read([]byte) (int, error) }, logger
403441
Tool: toolType,
404442
Params: params,
405443
Agent: "claude-code",
444+
RunID: deriveRunID(input.SessionID),
406445
}
407446

408447
// Extract response text from PostToolUse tool_response.
@@ -464,6 +503,9 @@ func parseClineInput(reader interface{ Read([]byte) (int, error) }, logger *slog
464503
Tool: toolType,
465504
Params: params,
466505
Agent: "cline",
506+
// Cline's taskId is scoped to a single task/conversation — equivalent
507+
// to Claude Code's session_id for run grouping purposes.
508+
RunID: deriveRunID(input.TaskID),
467509
}
468510

469511
// For PostToolUse, extract output from parameters if present

cmd/rampart/cli/hook_approval.go

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ type createApprovalRequest struct {
3333
Agent string `json:"agent"`
3434
Path string `json:"path,omitempty"`
3535
Message string `json:"message"`
36+
RunID string `json:"run_id,omitempty"`
3637
}
3738

3839
// createApprovalResponse is the JSON returned from POST /v1/approvals.
@@ -52,19 +53,20 @@ type pollApprovalResponse struct {
5253
// Returns hookAllow if approved, hookDeny if denied/expired/error.
5354
// Falls back to hookAsk if the serve instance is unreachable.
5455
// The context allows cancellation (e.g., user Ctrl-C).
55-
func (c *hookApprovalClient) requestApproval(tool, command, agent, path, message string, timeout time.Duration) hookDecisionType {
56-
return c.requestApprovalCtx(context.Background(), tool, command, agent, path, message, timeout)
56+
func (c *hookApprovalClient) requestApproval(tool, command, agent, path, runID, message string, timeout time.Duration) hookDecisionType {
57+
return c.requestApprovalCtx(context.Background(), tool, command, agent, path, runID, message, timeout)
5758
}
5859

5960
// requestApprovalCtx is like requestApproval but accepts a context for cancellation.
60-
func (c *hookApprovalClient) requestApprovalCtx(ctx context.Context, tool, command, agent, path, message string, timeout time.Duration) hookDecisionType {
61+
func (c *hookApprovalClient) requestApprovalCtx(ctx context.Context, tool, command, agent, path, runID, message string, timeout time.Duration) hookDecisionType {
6162
// Create the approval
6263
body := createApprovalRequest{
6364
Tool: tool,
6465
Command: command,
6566
Agent: agent,
6667
Path: path,
6768
Message: message,
69+
RunID: runID,
6870
}
6971

7072
data, err := json.Marshal(body)
@@ -94,6 +96,19 @@ func (c *hookApprovalClient) requestApprovalCtx(ctx context.Context, tool, comma
9496
}
9597
defer resp.Body.Close()
9698

99+
// 200 means the run was already bulk-approved — no queuing needed.
100+
if resp.StatusCode == http.StatusOK {
101+
var autoResp struct {
102+
Status string `json:"status"`
103+
}
104+
if err := json.NewDecoder(resp.Body).Decode(&autoResp); err == nil && autoResp.Status == "approved" {
105+
c.logger.Debug("hook: run auto-approved by bulk-resolve, skipping queue")
106+
return hookAllow
107+
}
108+
c.logger.Error("hook: unexpected 200 from approval create", "url", c.serveURL)
109+
return hookAsk
110+
}
111+
97112
if resp.StatusCode != http.StatusCreated {
98113
respBody, _ := io.ReadAll(resp.Body)
99114
c.logger.Error("hook: create approval failed", "status", resp.StatusCode, "body", string(respBody))

cmd/rampart/cli/hook_approval_test.go

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ func TestHookApprovalClient_Approved(t *testing.T) {
4646
logger: testLogger(),
4747
}
4848

49-
result := client.requestApproval("exec", "kubectl delete pod foo", "claude-code", "/tmp", "needs approval", 10*time.Second)
49+
result := client.requestApproval("exec", "kubectl delete pod foo", "claude-code", "/tmp", "", "needs approval", 10*time.Second)
5050
if result != hookAllow {
5151
t.Fatalf("expected hookAllow, got %d", result)
5252
}
@@ -77,7 +77,7 @@ func TestHookApprovalClient_Denied(t *testing.T) {
7777
logger: testLogger(),
7878
}
7979

80-
result := client.requestApproval("exec", "rm -rf /tmp/stuff", "claude-code", "/tmp", "dangerous", 10*time.Second)
80+
result := client.requestApproval("exec", "rm -rf /tmp/stuff", "claude-code", "/tmp", "", "dangerous", 10*time.Second)
8181
if result != hookDeny {
8282
t.Fatalf("expected hookDeny, got %d", result)
8383
}
@@ -90,7 +90,7 @@ func TestHookApprovalClient_Unreachable(t *testing.T) {
9090
logger: testLogger(),
9191
}
9292

93-
result := client.requestApproval("exec", "echo hi", "claude-code", "/tmp", "test", 2*time.Second)
93+
result := client.requestApproval("exec", "echo hi", "claude-code", "/tmp", "", "test", 2*time.Second)
9494
if result != hookAsk {
9595
t.Fatalf("expected hookAsk (fallback), got %d", result)
9696
}
@@ -107,7 +107,7 @@ func TestHookApprovalClient_FallbackOnUnreachablePort1(t *testing.T) {
107107
logger: logger,
108108
}
109109

110-
result := client.requestApproval("exec", "echo hi", "claude-code", "/tmp", "test", 5*time.Second)
110+
result := client.requestApproval("exec", "echo hi", "claude-code", "/tmp", "", "test", 5*time.Second)
111111
if result != hookAsk {
112112
t.Fatalf("expected hookAsk (native prompt fallback), got %d", result)
113113
}
@@ -146,7 +146,7 @@ func TestHookApprovalClient_AutoDiscoverApproved(t *testing.T) {
146146
autoDiscovered: true,
147147
}
148148

149-
result := client.requestApproval("exec", "kubectl apply -f deploy.yaml", "claude-code", "/tmp", "needs approval", 10*time.Second)
149+
result := client.requestApproval("exec", "kubectl apply -f deploy.yaml", "claude-code", "/tmp", "", "needs approval", 10*time.Second)
150150
if result != hookAllow {
151151
t.Fatalf("expected hookAllow, got %d", result)
152152
}
@@ -165,7 +165,7 @@ func TestHookApprovalClient_AutoDiscoverUnreachableSilent(t *testing.T) {
165165
autoDiscovered: true,
166166
}
167167

168-
result := client.requestApproval("exec", "echo hi", "claude-code", "/tmp", "test", 5*time.Second)
168+
result := client.requestApproval("exec", "echo hi", "claude-code", "/tmp", "", "test", 5*time.Second)
169169
if result != hookAsk {
170170
t.Fatalf("expected hookAsk (silent fallback), got %d", result)
171171
}
@@ -193,7 +193,7 @@ func TestHookApprovalClient_ExplicitUnreachableShowsWarning(t *testing.T) {
193193
autoDiscovered: false,
194194
}
195195

196-
result := client.requestApproval("exec", "echo hi", "claude-code", "/tmp", "test", 5*time.Second)
196+
result := client.requestApproval("exec", "echo hi", "claude-code", "/tmp", "", "test", 5*time.Second)
197197
if result != hookAsk {
198198
t.Fatalf("expected hookAsk, got %d", result)
199199
}
@@ -231,7 +231,7 @@ func TestHookApprovalClient_Timeout(t *testing.T) {
231231
}
232232

233233
start := time.Now()
234-
result := client.requestApproval("exec", "echo hi", "claude-code", "/tmp", "test", 2*time.Second)
234+
result := client.requestApproval("exec", "echo hi", "claude-code", "/tmp", "", "test", 2*time.Second)
235235
elapsed := time.Since(start)
236236

237237
if result != hookDeny {

0 commit comments

Comments
 (0)