@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.
- package/dist/{actions-eckb4g8r.js → actions-q4n7cz6e.js} +6 -6
- package/dist/{app-b05sgm02.js → app-es4nzc71.js} +3 -3
- package/dist/{assistant-7255sbyw.js → assistant-0x8ey0vs.js} +9 -9
- package/dist/{boot-reembed-y1bt3ttn.js → boot-reembed-69agkhv6.js} +4 -4
- package/dist/{boot-reembed-yv7awg4y.js → boot-reembed-p0ny05t8.js} +3 -3
- package/dist/{boot-scrub-logs-ddyaeqar.js → boot-scrub-logs-yybrkmvr.js} +2 -2
- package/dist/{cli-s9pj3c7z.js → cli-30bbaveh.js} +2 -2
- package/dist/{cli-r1btc895.js → cli-36ymmpmp.js} +2 -2
- package/dist/{cli-15598a4k.js → cli-4xyj9jtq.js} +1215 -235
- package/dist/{cli-52tbq17n.js → cli-bgef578c.js} +1 -1
- package/dist/{cli-76d1vtvq.js → cli-bgvskydy.js} +1 -1
- package/dist/{cli-pt7c7qxz.js → cli-bnzbn8j8.js} +3 -3
- package/dist/{cli-8f0dwca2.js → cli-c3frqf65.js} +4 -3
- package/dist/{cli-9p0qhead.js → cli-c9mtm09b.js} +1 -1
- package/dist/{cli-709t753c.js → cli-f3qa069v.js} +2 -2
- package/dist/{cli-94vzr3nq.js → cli-gtkqc144.js} +4 -4
- package/dist/{cli-7xkwgkrs.js → cli-h3k622d0.js} +1 -1
- package/dist/{cli-h9jx5mag.js → cli-hjhvekf4.js} +4 -4
- package/dist/{cli-tp7e60s3.js → cli-hsetkkd3.js} +2 -2
- package/dist/{cli-tch0ypp6.js → cli-mq580e06.js} +4 -4
- package/dist/{cli-951w7c79.js → cli-ncfgsqbd.js} +63 -7
- package/dist/{cli-30d2ct3k.js → cli-pwc5e83c.js} +3 -3
- package/dist/{cli-3r5y6ayj.js → cli-qwgtacd6.js} +2 -2
- package/dist/{cli-56s4ehzw.js → cli-r5g6ngtn.js} +1 -1
- package/dist/{cli-rp8pca98.js → cli-tbw13sx8.js} +1 -1
- package/dist/{cli-hm27prrc.js → cli-trmapn9k.js} +5 -5
- package/dist/{cli-0c3n0eba.js → cli-y4ycrnjc.js} +1 -1
- package/dist/{cli-a5e8n5hg.js → cli-y6mrptcn.js} +1 -1
- package/dist/{cli-tg0f2wmz.js → cli-z46pnksz.js} +5 -5
- package/dist/cli.js +11 -10
- package/dist/{commands-14g7taf4.js → commands-z5dbwta3.js} +2 -2
- package/dist/{db-3tge4jrn.js → db-bbgahh4z.js} +2 -2
- package/dist/{handlers-sy64egg6.js → handlers-6b44317z.js} +9 -9
- package/dist/{hook-rnkzw8vp.js → hook-sbp5fmps.js} +1 -1
- package/dist/{http-xx9xptcs.js → http-2xz43gz3.js} +354 -94
- package/dist/{index-zfx5p959.js → index-0gzmqj4k.js} +8 -8
- package/dist/{index-3wr6v2mm.js → index-5ghxn8s6.js} +7 -7
- package/dist/{index-brma88d4.js → index-nhat35em.js} +9 -9
- package/dist/{index-ha8e9xn2.js → index-vfedgdw6.js} +26 -15
- package/dist/{keepalive-rbz05zad.js → keepalive-62gfj91d.js} +4 -4
- package/dist/{lead-s790v7r0.js → lead-ag0akn0v.js} +17 -17
- package/dist/{maintenance-420rmadb.js → maintenance-xxrrzk5x.js} +4 -4
- package/dist/{onboard-jgqamqnw.js → onboard-64zyfcbs.js} +2 -2
- package/dist/{otel-impl-ch3n3783.js → otel-impl-b86qseaa.js} +1 -1
- package/dist/{pricing-refresh-rmtrby6h.js → pricing-refresh-cgtkffpa.js} +4 -4
- package/dist/{seed-pricing-1szjzfj1.js → seed-pricing-1r1y1xkq.js} +3 -3
- package/dist/{setup-8jfzkyzv.js → setup-mkcgkjf7.js} +2 -2
- package/dist/{worker-40vq0pm8.js → worker-351sze63.js} +17 -17
- package/openapi.json +314 -3
- package/package.json +4 -3
- package/src/be/db.ts +43 -3
- package/src/be/memory/providers/sqlite-store.ts +5 -1
- package/src/be/migrations/108_rbac_permission_audit.sql +26 -0
- package/src/be/rbac-audit.ts +218 -0
- package/src/http/all-routes.ts +58 -0
- package/src/http/config.ts +55 -0
- package/src/http/favorites.ts +5 -0
- package/src/http/fs.ts +27 -7
- package/src/http/index.ts +26 -0
- package/src/http/kv.ts +21 -4
- package/src/http/route-def.ts +9 -0
- package/src/http/schedules.ts +56 -22
- package/src/http/scripts.ts +33 -0
- package/src/http/workflows.ts +13 -2
- package/src/prompts/base-prompt.ts +7 -6
- package/src/prompts/session-templates.ts +38 -11
- package/src/rbac/can.ts +44 -0
- package/src/rbac/index.ts +13 -0
- package/src/rbac/legacy-policy.ts +185 -0
- package/src/rbac/permissions.ts +182 -0
- package/src/rbac/types.ts +45 -0
- package/src/scripts-runtime/sdk-allowlist.ts +1 -0
- package/src/scripts-runtime/swarm-sdk.ts +81 -0
- package/src/scripts-runtime/types/stdlib.d.ts +18 -2
- package/src/scripts-runtime/types/swarm-sdk.d.ts +18 -2
- package/src/server.ts +2 -0
- package/src/stdio.ts +43 -0
- package/src/tests/base-prompt.test.ts +7 -4
- package/src/tests/harness-provider-resolution.test.ts +5 -0
- package/src/tests/http-api-integration.test.ts +8 -1
- package/src/tests/prompt-template-session.test.ts +21 -8
- package/src/tests/rbac-audit.test.ts +345 -0
- package/src/tests/rbac-charact-http.test.ts +272 -0
- package/src/tests/rbac-charact-misc-tools.test.ts +428 -0
- package/src/tests/rbac-charact-skills.test.ts +492 -0
- package/src/tests/rbac-charact-slack.test.ts +283 -0
- package/src/tests/rbac-e2e-helpers.ts +305 -0
- package/src/tests/rbac-engine.test.ts +433 -0
- package/src/tests/rbac-lifecycle-e2e.test.ts +262 -0
- package/src/tests/rbac-wire-e2e.test.ts +574 -0
- package/src/tests/schedule-http-triage-tools.test.ts +121 -0
- package/src/tests/scheduled-tasks.test.ts +34 -0
- package/src/tests/scripts-http.test.ts +56 -7
- package/src/tests/swarm-config-reserved-keys.test.ts +17 -0
- package/src/tests/tool-annotations.test.ts +1 -0
- package/src/tests/update-schedule-mcp-tool.test.ts +57 -0
- package/src/tests/workflow-http-v2.test.ts +80 -0
- package/src/tools/cancel-task.ts +13 -3
- package/src/tools/context-diff.ts +12 -1
- package/src/tools/context-history.ts +12 -1
- package/src/tools/credential-bindings/tool.ts +12 -1
- package/src/tools/delete-channel.ts +12 -1
- package/src/tools/get-task-details.ts +1 -1
- package/src/tools/inject-learning.ts +12 -1
- package/src/tools/kv/kv-delete.ts +3 -18
- package/src/tools/kv/kv-incr.ts +3 -18
- package/src/tools/kv/kv-set.ts +3 -20
- package/src/tools/kv/kv-write-auth.ts +40 -0
- package/src/tools/manage-user.ts +12 -1
- package/src/tools/mcp-servers/mcp-server-create.ts +12 -1
- package/src/tools/mcp-servers/mcp-server-delete.ts +12 -1
- package/src/tools/mcp-servers/mcp-server-install.ts +12 -1
- package/src/tools/mcp-servers/mcp-server-uninstall.ts +12 -1
- package/src/tools/mcp-servers/mcp-server-update.ts +12 -1
- package/src/tools/memory-delete.ts +12 -4
- package/src/tools/register-kapso-number.ts +23 -2
- package/src/tools/schedules/create-schedule.ts +4 -3
- package/src/tools/schedules/index.ts +1 -0
- package/src/tools/schedules/list-schedules.ts +36 -2
- package/src/tools/schedules/patch-schedule.ts +405 -0
- package/src/tools/schedules/update-schedule.ts +2 -2
- package/src/tools/script-connections/tool.ts +12 -1
- package/src/tools/skills/skill-create.ts +12 -1
- package/src/tools/skills/skill-delete.ts +12 -1
- package/src/tools/skills/skill-install-remote.ts +12 -1
- package/src/tools/skills/skill-install.ts +12 -1
- package/src/tools/skills/skill-uninstall.ts +12 -1
- package/src/tools/skills/skill-update.ts +25 -2
- package/src/tools/slack-delete.ts +8 -1
- package/src/tools/slack-post.ts +8 -1
- package/src/tools/slack-read.ts +8 -1
- package/src/tools/slack-start-thread.ts +8 -1
- package/src/tools/slack-update.ts +8 -1
- package/src/tools/slack-upload-file.ts +8 -1
- package/src/tools/swarm-config/delete-config.ts +28 -1
- package/src/tools/swarm-config/get-config.ts +32 -5
- package/src/tools/swarm-config/list-config.ts +31 -5
- package/src/tools/swarm-config/set-config.ts +38 -1
- package/src/tools/task-action.ts +1 -1
- package/src/tools/task-tool-ctx.ts +19 -3
- package/src/tools/tool-config.ts +2 -1
- package/src/tools/update-profile.ts +8 -1
- package/src/tools/workflows/list-workflows.ts +14 -2
- package/templates/skills/swarm-scripts/SKILL.md +4 -9
- 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";
|
package/src/http/config.ts
CHANGED
|
@@ -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);
|
package/src/http/favorites.ts
CHANGED
|
@@ -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
|
-
|
|
439
|
-
if (auth?.kind === "
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
}
|
package/src/http/route-def.ts
CHANGED
|
@@ -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> {
|