@desplega.ai/agent-swarm 1.112.0 → 1.113.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/dist/{actions-eckb4g8r.js → actions-q4n7cz6e.js} +6 -6
  2. package/dist/{app-b05sgm02.js → app-es4nzc71.js} +3 -3
  3. package/dist/{assistant-7255sbyw.js → assistant-0x8ey0vs.js} +9 -9
  4. package/dist/{boot-reembed-y1bt3ttn.js → boot-reembed-69agkhv6.js} +4 -4
  5. package/dist/{boot-reembed-yv7awg4y.js → boot-reembed-p0ny05t8.js} +3 -3
  6. package/dist/{boot-scrub-logs-ddyaeqar.js → boot-scrub-logs-yybrkmvr.js} +2 -2
  7. package/dist/{cli-s9pj3c7z.js → cli-30bbaveh.js} +2 -2
  8. package/dist/{cli-r1btc895.js → cli-36ymmpmp.js} +2 -2
  9. package/dist/{cli-15598a4k.js → cli-4xyj9jtq.js} +1215 -235
  10. package/dist/{cli-52tbq17n.js → cli-bgef578c.js} +1 -1
  11. package/dist/{cli-76d1vtvq.js → cli-bgvskydy.js} +1 -1
  12. package/dist/{cli-pt7c7qxz.js → cli-bnzbn8j8.js} +3 -3
  13. package/dist/{cli-8f0dwca2.js → cli-c3frqf65.js} +4 -3
  14. package/dist/{cli-9p0qhead.js → cli-c9mtm09b.js} +1 -1
  15. package/dist/{cli-709t753c.js → cli-f3qa069v.js} +2 -2
  16. package/dist/{cli-94vzr3nq.js → cli-gtkqc144.js} +4 -4
  17. package/dist/{cli-7xkwgkrs.js → cli-h3k622d0.js} +1 -1
  18. package/dist/{cli-h9jx5mag.js → cli-hjhvekf4.js} +4 -4
  19. package/dist/{cli-tp7e60s3.js → cli-hsetkkd3.js} +2 -2
  20. package/dist/{cli-tch0ypp6.js → cli-mq580e06.js} +4 -4
  21. package/dist/{cli-951w7c79.js → cli-ncfgsqbd.js} +63 -7
  22. package/dist/{cli-30d2ct3k.js → cli-pwc5e83c.js} +3 -3
  23. package/dist/{cli-3r5y6ayj.js → cli-qwgtacd6.js} +2 -2
  24. package/dist/{cli-56s4ehzw.js → cli-r5g6ngtn.js} +1 -1
  25. package/dist/{cli-rp8pca98.js → cli-tbw13sx8.js} +1 -1
  26. package/dist/{cli-hm27prrc.js → cli-trmapn9k.js} +5 -5
  27. package/dist/{cli-0c3n0eba.js → cli-y4ycrnjc.js} +1 -1
  28. package/dist/{cli-a5e8n5hg.js → cli-y6mrptcn.js} +1 -1
  29. package/dist/{cli-tg0f2wmz.js → cli-z46pnksz.js} +5 -5
  30. package/dist/cli.js +11 -10
  31. package/dist/{commands-14g7taf4.js → commands-z5dbwta3.js} +2 -2
  32. package/dist/{db-3tge4jrn.js → db-bbgahh4z.js} +2 -2
  33. package/dist/{handlers-sy64egg6.js → handlers-6b44317z.js} +9 -9
  34. package/dist/{hook-rnkzw8vp.js → hook-sbp5fmps.js} +1 -1
  35. package/dist/{http-xx9xptcs.js → http-2xz43gz3.js} +354 -94
  36. package/dist/{index-zfx5p959.js → index-0gzmqj4k.js} +8 -8
  37. package/dist/{index-3wr6v2mm.js → index-5ghxn8s6.js} +7 -7
  38. package/dist/{index-brma88d4.js → index-nhat35em.js} +9 -9
  39. package/dist/{index-ha8e9xn2.js → index-vfedgdw6.js} +26 -15
  40. package/dist/{keepalive-rbz05zad.js → keepalive-62gfj91d.js} +4 -4
  41. package/dist/{lead-s790v7r0.js → lead-ag0akn0v.js} +17 -17
  42. package/dist/{maintenance-420rmadb.js → maintenance-xxrrzk5x.js} +4 -4
  43. package/dist/{onboard-jgqamqnw.js → onboard-64zyfcbs.js} +2 -2
  44. package/dist/{otel-impl-ch3n3783.js → otel-impl-b86qseaa.js} +1 -1
  45. package/dist/{pricing-refresh-rmtrby6h.js → pricing-refresh-cgtkffpa.js} +4 -4
  46. package/dist/{seed-pricing-1szjzfj1.js → seed-pricing-1r1y1xkq.js} +3 -3
  47. package/dist/{setup-8jfzkyzv.js → setup-mkcgkjf7.js} +2 -2
  48. package/dist/{worker-40vq0pm8.js → worker-351sze63.js} +17 -17
  49. package/openapi.json +314 -3
  50. package/package.json +4 -3
  51. package/src/be/db.ts +43 -3
  52. package/src/be/memory/providers/sqlite-store.ts +5 -1
  53. package/src/be/migrations/108_rbac_permission_audit.sql +26 -0
  54. package/src/be/rbac-audit.ts +218 -0
  55. package/src/http/all-routes.ts +58 -0
  56. package/src/http/config.ts +55 -0
  57. package/src/http/favorites.ts +5 -0
  58. package/src/http/fs.ts +27 -7
  59. package/src/http/index.ts +26 -0
  60. package/src/http/kv.ts +21 -4
  61. package/src/http/route-def.ts +9 -0
  62. package/src/http/schedules.ts +56 -22
  63. package/src/http/scripts.ts +33 -0
  64. package/src/http/workflows.ts +13 -2
  65. package/src/prompts/base-prompt.ts +7 -6
  66. package/src/prompts/session-templates.ts +38 -11
  67. package/src/rbac/can.ts +44 -0
  68. package/src/rbac/index.ts +13 -0
  69. package/src/rbac/legacy-policy.ts +185 -0
  70. package/src/rbac/permissions.ts +182 -0
  71. package/src/rbac/types.ts +45 -0
  72. package/src/scripts-runtime/sdk-allowlist.ts +1 -0
  73. package/src/scripts-runtime/swarm-sdk.ts +81 -0
  74. package/src/scripts-runtime/types/stdlib.d.ts +18 -2
  75. package/src/scripts-runtime/types/swarm-sdk.d.ts +18 -2
  76. package/src/server.ts +2 -0
  77. package/src/stdio.ts +43 -0
  78. package/src/tests/base-prompt.test.ts +7 -4
  79. package/src/tests/harness-provider-resolution.test.ts +5 -0
  80. package/src/tests/http-api-integration.test.ts +8 -1
  81. package/src/tests/prompt-template-session.test.ts +21 -8
  82. package/src/tests/rbac-audit.test.ts +345 -0
  83. package/src/tests/rbac-charact-http.test.ts +272 -0
  84. package/src/tests/rbac-charact-misc-tools.test.ts +428 -0
  85. package/src/tests/rbac-charact-skills.test.ts +492 -0
  86. package/src/tests/rbac-charact-slack.test.ts +283 -0
  87. package/src/tests/rbac-e2e-helpers.ts +305 -0
  88. package/src/tests/rbac-engine.test.ts +433 -0
  89. package/src/tests/rbac-lifecycle-e2e.test.ts +262 -0
  90. package/src/tests/rbac-wire-e2e.test.ts +574 -0
  91. package/src/tests/schedule-http-triage-tools.test.ts +121 -0
  92. package/src/tests/scheduled-tasks.test.ts +34 -0
  93. package/src/tests/scripts-http.test.ts +56 -7
  94. package/src/tests/swarm-config-reserved-keys.test.ts +17 -0
  95. package/src/tests/tool-annotations.test.ts +1 -0
  96. package/src/tests/update-schedule-mcp-tool.test.ts +57 -0
  97. package/src/tests/workflow-http-v2.test.ts +80 -0
  98. package/src/tools/cancel-task.ts +13 -3
  99. package/src/tools/context-diff.ts +12 -1
  100. package/src/tools/context-history.ts +12 -1
  101. package/src/tools/credential-bindings/tool.ts +12 -1
  102. package/src/tools/delete-channel.ts +12 -1
  103. package/src/tools/get-task-details.ts +1 -1
  104. package/src/tools/inject-learning.ts +12 -1
  105. package/src/tools/kv/kv-delete.ts +3 -18
  106. package/src/tools/kv/kv-incr.ts +3 -18
  107. package/src/tools/kv/kv-set.ts +3 -20
  108. package/src/tools/kv/kv-write-auth.ts +40 -0
  109. package/src/tools/manage-user.ts +12 -1
  110. package/src/tools/mcp-servers/mcp-server-create.ts +12 -1
  111. package/src/tools/mcp-servers/mcp-server-delete.ts +12 -1
  112. package/src/tools/mcp-servers/mcp-server-install.ts +12 -1
  113. package/src/tools/mcp-servers/mcp-server-uninstall.ts +12 -1
  114. package/src/tools/mcp-servers/mcp-server-update.ts +12 -1
  115. package/src/tools/memory-delete.ts +12 -4
  116. package/src/tools/register-kapso-number.ts +23 -2
  117. package/src/tools/schedules/create-schedule.ts +4 -3
  118. package/src/tools/schedules/index.ts +1 -0
  119. package/src/tools/schedules/list-schedules.ts +36 -2
  120. package/src/tools/schedules/patch-schedule.ts +405 -0
  121. package/src/tools/schedules/update-schedule.ts +2 -2
  122. package/src/tools/script-connections/tool.ts +12 -1
  123. package/src/tools/skills/skill-create.ts +12 -1
  124. package/src/tools/skills/skill-delete.ts +12 -1
  125. package/src/tools/skills/skill-install-remote.ts +12 -1
  126. package/src/tools/skills/skill-install.ts +12 -1
  127. package/src/tools/skills/skill-uninstall.ts +12 -1
  128. package/src/tools/skills/skill-update.ts +25 -2
  129. package/src/tools/slack-delete.ts +8 -1
  130. package/src/tools/slack-post.ts +8 -1
  131. package/src/tools/slack-read.ts +8 -1
  132. package/src/tools/slack-start-thread.ts +8 -1
  133. package/src/tools/slack-update.ts +8 -1
  134. package/src/tools/slack-upload-file.ts +8 -1
  135. package/src/tools/swarm-config/delete-config.ts +28 -1
  136. package/src/tools/swarm-config/get-config.ts +32 -5
  137. package/src/tools/swarm-config/list-config.ts +31 -5
  138. package/src/tools/swarm-config/set-config.ts +38 -1
  139. package/src/tools/task-action.ts +1 -1
  140. package/src/tools/task-tool-ctx.ts +19 -3
  141. package/src/tools/tool-config.ts +2 -1
  142. package/src/tools/update-profile.ts +8 -1
  143. package/src/tools/workflows/list-workflows.ts +14 -2
  144. package/templates/skills/swarm-scripts/SKILL.md +4 -9
  145. package/templates/skills/swarm-scripts/content.md +20 -9
@@ -0,0 +1,218 @@
1
+ /**
2
+ * RBAC permission-audit batched writer + retention GC (DES-445, Phase 6).
3
+ *
4
+ * `enqueueAuditRow` is the concrete `AuditSink` wired into `can()` at server
5
+ * boot (src/http/index.ts, src/stdio.ts). Rows buffer in memory and flush in
6
+ * a single prepared-statement transaction every FLUSH_INTERVAL_MS (2s) or at
7
+ * FLUSH_MAX_ROWS (200), whichever comes first. Every path is try/caught — a
8
+ * failed flush logs a warning and DROPS the batch; auditing must never throw
9
+ * into the request path. Rows are structured ids/verbs only (no payloads, no
10
+ * secret-bearing content).
11
+ *
12
+ * Kill-switch: `RBAC_AUDIT_DISABLED=true` makes the sink a no-op (checked per
13
+ * call so tests and live config reloads can toggle it).
14
+ *
15
+ * Retention: `startAuditGc` (pattern: startMemoryGc in src/http/memory.ts)
16
+ * ticks daily and deletes rows older than RBAC_AUDIT_RETENTION_DAYS
17
+ * (default 30).
18
+ */
19
+ import type { RbacCheck, RbacDecision } from "../rbac";
20
+ import { getDb } from "./db";
21
+
22
+ type AuditRow = {
23
+ principalType: "agent" | "user" | "operator";
24
+ principalId: string | null;
25
+ originatorUserId: string | null;
26
+ verb: string;
27
+ resourceType: string | null;
28
+ resourceId: string | null;
29
+ decision: "allow" | "deny";
30
+ reason: string | null;
31
+ source: "mcp" | "http";
32
+ };
33
+
34
+ const FLUSH_INTERVAL_MS = 2_000;
35
+ const FLUSH_MAX_ROWS = 200;
36
+ const AUDIT_GC_INTERVAL_MS = 24 * 60 * 60 * 1000; // daily
37
+ const DEFAULT_RETENTION_DAYS = 30;
38
+
39
+ let buffer: AuditRow[] = [];
40
+ let flushTimer: ReturnType<typeof setInterval> | null = null;
41
+ let auditGcTimer: ReturnType<typeof setInterval> | null = null;
42
+ let thresholdFlushScheduled = false;
43
+
44
+ function isAuditDisabled(): boolean {
45
+ return process.env.RBAC_AUDIT_DISABLED === "true";
46
+ }
47
+
48
+ function principalIdOf(principal: RbacCheck["principal"]): string | null {
49
+ switch (principal.kind) {
50
+ case "agent":
51
+ return principal.agentId;
52
+ case "user":
53
+ return principal.userId;
54
+ case "operator":
55
+ return null;
56
+ }
57
+ }
58
+
59
+ function resourceIdOf(resource: RbacCheck["resource"]): string | null {
60
+ if (!resource) return null;
61
+ switch (resource.kind) {
62
+ case "task":
63
+ return resource.taskId;
64
+ case "agent":
65
+ return resource.agentId;
66
+ case "kv-namespace":
67
+ return resource.namespace;
68
+ case "owned":
69
+ return resource.ownerAgentId ?? null;
70
+ case "none":
71
+ return null;
72
+ }
73
+ }
74
+
75
+ function toRow(check: RbacCheck, decision: RbacDecision): AuditRow {
76
+ return {
77
+ principalType: check.principal.kind,
78
+ principalId: principalIdOf(check.principal),
79
+ // Reserved: slice-1 RbacCheck carries no originating-user field yet.
80
+ originatorUserId: null,
81
+ verb: check.verb,
82
+ resourceType: check.resource?.kind ?? null,
83
+ resourceId: resourceIdOf(check.resource),
84
+ decision: decision.allow ? "allow" : "deny",
85
+ reason: decision.allow ? null : decision.reason,
86
+ source: check.source,
87
+ };
88
+ }
89
+
90
+ /**
91
+ * The concrete AuditSink for `can()`. Never throws — `can()` already swallows
92
+ * sink exceptions, but the audit path defends independently.
93
+ */
94
+ export function enqueueAuditRow(check: RbacCheck, decision: RbacDecision): void {
95
+ if (isAuditDisabled()) return;
96
+ try {
97
+ buffer.push(toRow(check, decision));
98
+ // The threshold flush is deferred to a zero-delay timeout so the can()
99
+ // call that happens to be the 200th never pays for the SQLite transaction
100
+ // itself — audit stays fire-and-forget on the request path.
101
+ if (buffer.length >= FLUSH_MAX_ROWS && !thresholdFlushScheduled) {
102
+ thresholdFlushScheduled = true;
103
+ const t = setTimeout(() => {
104
+ thresholdFlushScheduled = false;
105
+ flushAuditBuffer();
106
+ }, 0);
107
+ if (typeof t.unref === "function") t.unref();
108
+ }
109
+ } catch (err) {
110
+ console.warn("[rbac-audit] enqueue failed, dropping row:", (err as Error).message);
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Drain the buffer into permission_audit in one transaction. Called by the
116
+ * flush interval, the 200-row threshold, and shutdown. A failed flush logs a
117
+ * warning and drops the batch — never throws.
118
+ */
119
+ export function flushAuditBuffer(): void {
120
+ if (buffer.length === 0) return;
121
+ const rows = buffer;
122
+ buffer = [];
123
+ try {
124
+ const db = getDb();
125
+ const stmt = db.prepare(
126
+ `INSERT INTO permission_audit
127
+ (principalType, principalId, originatorUserId, verb, resourceType, resourceId, decision, reason, source)
128
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
129
+ );
130
+ const insertAll = db.transaction((batch: AuditRow[]) => {
131
+ for (const r of batch) {
132
+ stmt.run(
133
+ r.principalType,
134
+ r.principalId,
135
+ r.originatorUserId,
136
+ r.verb,
137
+ r.resourceType,
138
+ r.resourceId,
139
+ r.decision,
140
+ r.reason,
141
+ r.source,
142
+ );
143
+ }
144
+ });
145
+ insertAll(rows);
146
+ } catch (err) {
147
+ console.warn(
148
+ `[rbac-audit] flush failed, dropping ${rows.length} row(s):`,
149
+ (err as Error).message,
150
+ );
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Test hook, not a production knob: rbac-lifecycle-e2e.test.ts sets a huge
156
+ * interval so the SIGTERM-drain assertion can prove rows were persisted by
157
+ * the shutdown drain rather than a racing timer tick.
158
+ */
159
+ function flushIntervalMs(): number {
160
+ const v = Number(process.env.RBAC_AUDIT_FLUSH_MS);
161
+ return Number.isFinite(v) && v > 0 ? v : FLUSH_INTERVAL_MS;
162
+ }
163
+
164
+ /** Start the periodic flush (2s tick). Idempotent. */
165
+ export function startAuditWriter(intervalMs = flushIntervalMs()): void {
166
+ if (flushTimer) return;
167
+ flushTimer = setInterval(() => flushAuditBuffer(), intervalMs);
168
+ if (typeof flushTimer?.unref === "function") flushTimer.unref();
169
+ }
170
+
171
+ /** Stop the periodic flush. Does NOT flush — shutdown calls flushAuditBuffer(). */
172
+ export function stopAuditWriter(): void {
173
+ if (flushTimer) {
174
+ clearInterval(flushTimer);
175
+ flushTimer = null;
176
+ }
177
+ }
178
+
179
+ /** Delete audit rows older than the retention window. Returns rows deleted. */
180
+ export function purgeExpiredAuditRows(): number {
181
+ try {
182
+ const days = Number(process.env.RBAC_AUDIT_RETENTION_DAYS) || DEFAULT_RETENTION_DAYS;
183
+ // ts is CURRENT_TIMESTAMP format ("YYYY-MM-DD HH:MM:SS"); compute the
184
+ // cutoff in SQLite so the string formats always match.
185
+ const result = getDb()
186
+ .prepare("DELETE FROM permission_audit WHERE ts < datetime('now', ?)")
187
+ .run(`-${days} days`);
188
+ return result.changes;
189
+ } catch (err) {
190
+ console.warn("[rbac-audit] retention purge failed:", (err as Error).message);
191
+ return 0;
192
+ }
193
+ }
194
+
195
+ /** Start the retention GC (daily tick, immediate first run). Idempotent. */
196
+ export function startAuditGc(intervalMs = AUDIT_GC_INTERVAL_MS): void {
197
+ if (auditGcTimer) return;
198
+
199
+ const purged = purgeExpiredAuditRows();
200
+ if (purged > 0) {
201
+ console.log(`[rbac-audit] Initial retention purge removed ${purged} audit row(s)`);
202
+ }
203
+
204
+ auditGcTimer = setInterval(() => {
205
+ const n = purgeExpiredAuditRows();
206
+ if (n > 0) {
207
+ console.log(`[rbac-audit] Retention purge removed ${n} audit row(s)`);
208
+ }
209
+ }, intervalMs);
210
+ if (typeof auditGcTimer?.unref === "function") auditGcTimer.unref();
211
+ }
212
+
213
+ export function stopAuditGc(): void {
214
+ if (auditGcTimer) {
215
+ clearInterval(auditGcTimer);
216
+ auditGcTimer = null;
217
+ }
218
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Side-effect import of every HTTP handler module so their `route()` calls
3
+ * populate `routeRegistry`. Single source of truth for "all registered
4
+ * routes" — consumed by scripts/generate-openapi.ts and
5
+ * scripts/check-rbac-coverage.ts.
6
+ *
7
+ * When you add a new handler file, add its import HERE (keep the existing
8
+ * order — OpenAPI output and route matching in the generators follow
9
+ * registration order).
10
+ */
11
+ import "./active-sessions";
12
+ import "./agents";
13
+ import "./approval-requests";
14
+ import "./budgets";
15
+ import "./codex-oauth-keep-warm";
16
+ import "./config";
17
+ import "./context";
18
+ import "./db-query";
19
+ import "./ecosystem";
20
+
21
+ import "./api-keys";
22
+ import "./events";
23
+ import "./favorites";
24
+ import "./fs";
25
+ import "./heartbeat";
26
+ import "./inbox-state";
27
+ import "./integrations";
28
+ import "./kv";
29
+ import "./memory";
30
+ import "./metrics";
31
+ import "./oauth-locks";
32
+ import "./page-proxy";
33
+ import "./pages";
34
+ import "./pages-public";
35
+ import "./prompt-templates";
36
+ import "./poll";
37
+ import "./pricing";
38
+ import "./repos";
39
+ import "./schedules";
40
+ import "./script-runs";
41
+ import "./session-data";
42
+ import "./sessions";
43
+ import "./skills";
44
+ import "./scripts";
45
+ import "./mcp-bridge";
46
+ import "./mcp-oauth";
47
+ import "./mcp-servers";
48
+ import "./stats";
49
+ import "./status";
50
+ import "./tasks";
51
+ import "./task-templates";
52
+ import "./trackers/jira";
53
+ import "./trackers/linear";
54
+ import "./users";
55
+ import "./webhooks";
56
+ import "./workflow-events";
57
+ import "./workflows";
58
+ import "./x";
@@ -2,6 +2,7 @@ import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import { z } from "zod";
3
3
  import {
4
4
  deleteSwarmConfig,
5
+ getAgentById,
5
6
  getResolvedConfig,
6
7
  getSwarmConfigById,
7
8
  getSwarmConfigLookupById,
@@ -14,6 +15,8 @@ import {
14
15
  reservedKeyError,
15
16
  validateConfigValue,
16
17
  } from "../be/swarm-config-guard";
18
+ import { can, type PermissionVerb } from "../rbac";
19
+ import { getRequestAuth } from "../utils/request-auth-context";
17
20
  import { registerVolatileSecret } from "../utils/secret-scrubber";
18
21
  import { reloadGlobalConfigsAndIntegrations, scheduleIntegrationsReload } from "./core";
19
22
  import { route } from "./route-def";
@@ -32,6 +35,54 @@ function stripApiOnlyKeys<T extends { key: string }>(configs: T[]): T[] {
32
35
  return configs.filter((config) => !API_ONLY_CONFIG_KEYS.has(config.key));
33
36
  }
34
37
 
38
+ function singleHeader(req: IncomingMessage, name: string): string | undefined {
39
+ const raw = req.headers[name];
40
+ return Array.isArray(raw) ? raw[0] : raw;
41
+ }
42
+
43
+ /**
44
+ * Gate a config write/delete over HTTP (DES-445 follow-up).
45
+ *
46
+ * Config administration is trusted to the operator (shared swarm key) and to
47
+ * authenticated users — they short-circuit as allowed, matching the
48
+ * operator/user-before-agent ordering used by fs.ts (Appendix A row 36). This
49
+ * preserves every existing HTTP caller (dashboard, codex-oauth token refresh,
50
+ * devin playbook cache — all operate as operator). Only an agent-context
51
+ * principal (X-Agent-ID without operator/user request auth) is gated to lead;
52
+ * the real per-agent enforcement lives on the MCP set-config/delete-config
53
+ * tools, where the principal is always an agent.
54
+ *
55
+ * Returns true when the request may proceed; on denial it writes a 403 and
56
+ * returns false.
57
+ */
58
+ function ensureConfigAdmin(
59
+ req: IncomingMessage,
60
+ res: ServerResponse,
61
+ verb: Extract<PermissionVerb, "config.write.any" | "config.delete.any">,
62
+ ): boolean {
63
+ const auth = getRequestAuth(req);
64
+ if (auth?.kind === "operator" || auth?.kind === "user") return true;
65
+ const agentId = singleHeader(req, "x-agent-id");
66
+ const agent = agentId ? getAgentById(agentId) : undefined;
67
+ const decision = can({
68
+ principal: { kind: "agent", agentId: agentId ?? "", isLead: agent?.isLead ?? false },
69
+ verb,
70
+ resource: { kind: "none" },
71
+ source: "http",
72
+ });
73
+ if (!decision.allow) {
74
+ jsonError(
75
+ res,
76
+ verb === "config.write.any"
77
+ ? "Writing swarm config requires the lead agent"
78
+ : "Deleting swarm config requires the lead agent",
79
+ 403,
80
+ );
81
+ return false;
82
+ }
83
+ return true;
84
+ }
85
+
35
86
  // ─── Route Definitions ───────────────────────────────────────────────────────
36
87
 
37
88
  const getResolvedConfigRoute = route({
@@ -119,6 +170,7 @@ const upsertConfig = route({
119
170
  summary:
120
171
  "Create or update a config entry (reserved env-only keys are rejected). Global-scope writes auto-trigger an integrations reload (debounced ~250ms) so Slack/GitHub/Linear/Jira/AgentMail pick up new credentials without an explicit /api/config/reload call.",
121
172
  tags: ["Config"],
173
+ rbac: { permission: "config.write.any" },
122
174
  body: z.object({
123
175
  scope: z.enum(["global", "agent", "repo"]),
124
176
  scopeId: z.string().nullish(),
@@ -141,6 +193,7 @@ const deleteConfig = route({
141
193
  summary:
142
194
  "Delete a config entry by ID (including legacy reserved rows for cleanup). Global-scope deletes auto-trigger an integrations reload.",
143
195
  tags: ["Config"],
196
+ rbac: { permission: "config.delete.any" },
144
197
  params: z.object({ id: z.string() }),
145
198
  responses: {
146
199
  200: { description: "Config deleted" },
@@ -251,6 +304,7 @@ export async function handleConfig(
251
304
  if (upsertConfig.match(req.method, pathSegments)) {
252
305
  const parsed = await upsertConfig.parse(req, res, pathSegments, queryParams);
253
306
  if (!parsed) return true;
307
+ if (!ensureConfigAdmin(req, res, "config.write.any")) return true;
254
308
  const { scope, scopeId, key, value, isSecret, envPath, description } = parsed.body;
255
309
 
256
310
  if (scope === "global" && scopeId) {
@@ -303,6 +357,7 @@ export async function handleConfig(
303
357
  if (deleteConfig.match(req.method, pathSegments)) {
304
358
  const parsed = await deleteConfig.parse(req, res, pathSegments, queryParams);
305
359
  if (!parsed) return true;
360
+ if (!ensureConfigAdmin(req, res, "config.delete.any")) return true;
306
361
  const existing = getSwarmConfigLookupById(parsed.params.id);
307
362
  if (!existing) {
308
363
  jsonError(res, "Config not found", 404);
@@ -28,6 +28,11 @@ const putFavorite = route({
28
28
  pattern: ["api", "favorites"],
29
29
  summary: "Set favorite state for an item",
30
30
  tags: ["Favorites"],
31
+ // Self-scoped write: the row is keyed to the authenticated user
32
+ // (resolveHttpAuditUserId), never a body-supplied id, so there is no
33
+ // cross-principal authorization to gate — a user can only favorite for
34
+ // themselves.
35
+ rbac: { ungated: "self-scoped to the authenticated user; no cross-principal access" },
31
36
  body: z.object({
32
37
  itemType: FavoriteItemTypeSchema,
33
38
  itemId: z.string().min(1),
package/src/http/fs.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  import { ensureAgentFsCredentialsForAgent } from "../be/seed/agent-fs-provision";
12
12
  import { type FileObject, type FileScope, FilesError, normalizeFilesError } from "../fs/provider";
13
13
  import { getFileStorageProvider } from "../fs/registry";
14
+ import { can, type RbacPrincipal, type RbacResource } from "../rbac";
14
15
  import type { TaskAttachment } from "../types";
15
16
  import { getCurrentRequestAuth, getRequestAuth } from "../utils/request-auth-context";
16
17
  import { scrubSecrets } from "../utils/secret-scrubber";
@@ -93,6 +94,7 @@ const uploadTaskFileRoute = route({
93
94
  413: { description: "Upload exceeds 50 MiB" },
94
95
  },
95
96
  auth: { apiKey: true, agentId: true },
97
+ rbac: { permission: "task.fs.mutate" },
96
98
  });
97
99
 
98
100
  const getTaskFileRoute = route({
@@ -150,6 +152,7 @@ const deleteTaskFileRoute = route({
150
152
  404: { description: "Task or attachment not found" },
151
153
  },
152
154
  auth: { apiKey: true, agentId: true },
155
+ rbac: { permission: "task.fs.mutate" },
153
156
  });
154
157
 
155
158
  export async function handleFs(
@@ -430,17 +433,34 @@ function findAttachment(
430
433
  }
431
434
 
432
435
  function canMutateTask(
433
- task: { agentId: string | null; creatorAgentId?: string },
436
+ task: { id: string; agentId: string | null; creatorAgentId?: string },
434
437
  myAgentId: string | undefined,
435
438
  req: IncomingMessage,
436
439
  ): boolean {
440
+ const resource: RbacResource = {
441
+ kind: "task",
442
+ taskId: task.id,
443
+ agentId: task.agentId,
444
+ creatorAgentId: task.creatorAgentId,
445
+ };
446
+ // Decision order preserved (plan Appendix A row 36): operator/user request
447
+ // auth short-circuits BEFORE agent identity — an operator bearer with a
448
+ // non-owner X-Agent-ID is still allowed. The agent branches only bind when
449
+ // the request-auth context is unset.
437
450
  const auth = getRequestAuth(req);
438
- if (auth?.kind === "operator") return true;
439
- if (auth?.kind === "user") return true;
440
- if (!myAgentId) return false;
441
- const agent = getAgentById(myAgentId);
442
- if (agent?.isLead) return true;
443
- return task.agentId === myAgentId || task.creatorAgentId === myAgentId;
451
+ let principal: RbacPrincipal;
452
+ if (auth?.kind === "operator") {
453
+ principal = { kind: "operator" };
454
+ } else if (auth?.kind === "user") {
455
+ principal = { kind: "user", userId: auth.userId };
456
+ } else {
457
+ // A missing caller identity cannot be lead/assignee/creator — same denial
458
+ // as before (no separate "agent not found" branch).
459
+ if (!myAgentId) return false;
460
+ const agent = getAgentById(myAgentId);
461
+ principal = { kind: "agent", agentId: myAgentId, isLead: agent?.isLead ?? false };
462
+ }
463
+ return can({ principal, verb: "task.fs.mutate", resource, source: "http" }).allow;
444
464
  }
445
465
 
446
466
  async function readRawBody(
package/src/http/index.ts CHANGED
@@ -9,6 +9,14 @@ import type { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/se
9
9
  import { getEnabledCapabilities, hasCapability } from "@/server";
10
10
  import { initAgentMail } from "../agentmail";
11
11
  import { closeDb, getSwarmConfigs, upsertSwarmConfig } from "../be/db";
12
+ import {
13
+ enqueueAuditRow,
14
+ flushAuditBuffer,
15
+ startAuditGc,
16
+ startAuditWriter,
17
+ stopAuditGc,
18
+ stopAuditWriter,
19
+ } from "../be/rbac-audit";
12
20
  import { initGitHub } from "../github";
13
21
  import { initGitLab } from "../gitlab";
14
22
  import { stopHeartbeat } from "../heartbeat";
@@ -21,6 +29,7 @@ import {
21
29
  withRemoteContext,
22
30
  withSpanContext,
23
31
  } from "../otel";
32
+ import { clearAuditSink, setAuditSink } from "../rbac";
24
33
  import { startScriptRunSupervisor, stopScriptRunSupervisor } from "../script-workflows/supervisor";
25
34
  import { getServerSessionsProcessed } from "../server-runtime-counters";
26
35
  import { startSlackApp, stopSlackApp } from "../slack";
@@ -416,6 +425,12 @@ async function shutdown() {
416
425
  // Stop memory expired-row garbage collector
417
426
  stopMemoryGc();
418
427
 
428
+ // Stop RBAC audit: retention GC, flush interval, final drain, detach sink
429
+ stopAuditGc();
430
+ stopAuditWriter();
431
+ flushAuditBuffer();
432
+ clearAuditSink();
433
+
419
434
  if (globalState.__apiGcInterval) {
420
435
  clearInterval(globalState.__apiGcInterval);
421
436
  delete globalState.__apiGcInterval;
@@ -502,6 +517,15 @@ try {
502
517
  console.error("[startup] Failed to seed built-in entities:", err);
503
518
  }
504
519
 
520
+ // Wire the RBAC permission-audit sink into can() and start the batched writer
521
+ // (2s flush) + retention GC (daily tick) BEFORE the server accepts traffic —
522
+ // installing it inside the listen callback would leave an unaudited startup
523
+ // window (requests can land while telemetry/Slack init awaits are pending).
524
+ // RBAC_AUDIT_DISABLED=true makes the sink a no-op inside enqueueAuditRow.
525
+ setAuditSink(enqueueAuditRow);
526
+ startAuditWriter();
527
+ startAuditGc();
528
+
505
529
  // business-use initialization (no-op if envs not set)
506
530
  initialize();
507
531
 
@@ -593,6 +617,8 @@ httpServer
593
617
  // Start expired-memory garbage collector (1-hour tick, immediate first run)
594
618
  startMemoryGc();
595
619
 
620
+ // (RBAC audit sink is wired pre-listen — see above httpServer.listen.)
621
+
596
622
  // Background backfill: re-embed any agent_memory rows with wrong-dimension
597
623
  // embeddings (e.g. 1536d instead of 512d). Non-blocking, idempotent, no-op
598
624
  // when the DB is clean. See src/be/memory/boot-reembed.ts.
package/src/http/kv.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  listKv,
12
12
  upsertKv,
13
13
  } from "../be/db";
14
+ import { can } from "../rbac";
14
15
  import { agentContextKey, pageContextKey } from "../tasks/context-key";
15
16
  import { KvKeySchema, KvNamespaceSchema, KvValueTypeSchema } from "../types";
16
17
  import { route } from "./route-def";
@@ -107,6 +108,7 @@ const putKvHeader = route({
107
108
  params: z.object({ key: KvKeySchema }),
108
109
  body: kvSetBodySchema,
109
110
  responses: RESPONSES_PUT,
111
+ rbac: { permission: "kv.write.any" },
110
112
  });
111
113
 
112
114
  const deleteKvHeader = route({
@@ -122,6 +124,7 @@ const deleteKvHeader = route({
122
124
  403: { description: "Caller may not write this namespace" },
123
125
  400: { description: "Validation error or unresolvable namespace" },
124
126
  },
127
+ rbac: { permission: "kv.write.any" },
125
128
  });
126
129
 
127
130
  const incrKvHeader = route({
@@ -133,6 +136,7 @@ const incrKvHeader = route({
133
136
  params: z.object({ key: KvKeySchema }),
134
137
  body: kvIncrBodySchema,
135
138
  responses: RESPONSES_PUT,
139
+ rbac: { permission: "kv.write.any" },
136
140
  });
137
141
 
138
142
  const listKvHeader = route({
@@ -165,6 +169,7 @@ const putKvExplicit = route({
165
169
  params: z.object({ namespace: KvNamespaceSchema, key: KvKeySchema }),
166
170
  body: kvSetBodySchema,
167
171
  responses: RESPONSES_PUT,
172
+ rbac: { permission: "kv.write.any" },
168
173
  });
169
174
 
170
175
  const deleteKvExplicit = route({
@@ -179,6 +184,7 @@ const deleteKvExplicit = route({
179
184
  404: { description: "KV entry not found" },
180
185
  403: { description: "Caller may not write this namespace" },
181
186
  },
187
+ rbac: { permission: "kv.write.any" },
182
188
  });
183
189
 
184
190
  const incrKvExplicit = route({
@@ -190,6 +196,7 @@ const incrKvExplicit = route({
190
196
  params: z.object({ namespace: KvNamespaceSchema, key: KvKeySchema }),
191
197
  body: kvIncrBodySchema,
192
198
  responses: RESPONSES_PUT,
199
+ rbac: { permission: "kv.write.any" },
193
200
  });
194
201
 
195
202
  const listKvExplicit = route({
@@ -324,10 +331,20 @@ function authorizeWrite(
324
331
  return null;
325
332
  }
326
333
  if (namespace.startsWith("task:agent:")) {
327
- const target = namespace.slice("task:agent:".length);
328
- if (ctx.callerAgentId && target === ctx.callerAgentId) return null;
329
- if (ctx.isLead) return null;
330
- return { status: 403, message: "writes to another agent's namespace require lead" };
334
+ // A missing caller identity can never own a namespace nor be lead — same
335
+ // denial as before (no separate "agent not found" branch).
336
+ const allowed =
337
+ ctx.callerAgentId != null &&
338
+ can({
339
+ principal: { kind: "agent", agentId: ctx.callerAgentId, isLead: ctx.isLead },
340
+ verb: "kv.write.any",
341
+ resource: { kind: "kv-namespace", namespace },
342
+ source: "http",
343
+ }).allow;
344
+ if (!allowed) {
345
+ return { status: 403, message: "writes to another agent's namespace require lead" };
346
+ }
347
+ return null;
331
348
  }
332
349
  return null;
333
350
  }
@@ -1,5 +1,6 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import { z } from "zod";
3
+ import type { PermissionVerb } from "../rbac";
3
4
  import { jsonError, matchRoute, parseBody } from "./utils";
4
5
 
5
6
  // ─── Types ───────────────────────────────────────────────────────────────────
@@ -32,6 +33,14 @@ export interface RouteDef<
32
33
  apiKey?: boolean; // default true
33
34
  agentId?: boolean; // requires X-Agent-ID
34
35
  };
36
+ /**
37
+ * RBAC posture (DES-445). Required on every non-GET route — enforced by
38
+ * `bun run check:rbac-coverage` (CI). Either name the `PermissionVerb` the
39
+ * handler gates via `can()`, or document why no principal gate applies.
40
+ * Declarative in slice 1: the handler still calls `can()` itself; the role
41
+ * engine (increment 3) may enforce the declared verb centrally.
42
+ */
43
+ rbac?: { permission: PermissionVerb } | { ungated: string };
35
44
  }
36
45
 
37
46
  interface ParsedRequest<TParams, TQuery, TBody> {