@coreplane/switchboard 0.0.0 → 1.18.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 (131) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +18 -1
  3. package/dist/assets/.dockerignore +27 -0
  4. package/dist/assets/.env.example +33 -0
  5. package/dist/assets/Dockerfile +111 -0
  6. package/dist/assets/config/config.example.yaml +359 -0
  7. package/dist/assets/deploy/bin/build-stamp.d.mts +15 -0
  8. package/dist/assets/deploy/bin/build-stamp.mjs +98 -0
  9. package/dist/assets/deploy/bin/cf-logs +32 -0
  10. package/dist/assets/deploy/cloudflare/package.json +29 -0
  11. package/dist/assets/deploy/cloudflare/preflight.mjs +243 -0
  12. package/dist/assets/deploy/cloudflare/tsconfig.json +18 -0
  13. package/dist/assets/deploy/cloudflare/worker.ts +382 -0
  14. package/dist/assets/deploy/cloudflare/wrangler.template.jsonc +67 -0
  15. package/dist/assets/deploy/cloudflare/write-build.d.mts +7 -0
  16. package/dist/assets/deploy/cloudflare/write-build.mjs +53 -0
  17. package/dist/assets/deploy/cloudflare-docs/package.json +18 -0
  18. package/dist/assets/deploy/cloudflare-docs/wrangler.template.jsonc +30 -0
  19. package/dist/assets/deploy/cloudflare-memory/package.json +25 -0
  20. package/dist/assets/deploy/cloudflare-memory/tsconfig.json +17 -0
  21. package/dist/assets/deploy/cloudflare-memory/worker.ts +2635 -0
  22. package/dist/assets/deploy/cloudflare-memory/wrangler.template.jsonc +50 -0
  23. package/dist/assets/deploy/cloudflare-resident/Dockerfile +91 -0
  24. package/dist/assets/deploy/cloudflare-resident/gc.ts +287 -0
  25. package/dist/assets/deploy/cloudflare-resident/node-async-hooks.d.ts +11 -0
  26. package/dist/assets/deploy/cloudflare-resident/package.json +29 -0
  27. package/dist/assets/deploy/cloudflare-resident/preflight.mjs +224 -0
  28. package/dist/assets/deploy/cloudflare-resident/tsconfig.json +19 -0
  29. package/dist/assets/deploy/cloudflare-resident/worker.ts +6637 -0
  30. package/dist/assets/deploy/cloudflare-resident/wrangler.template.jsonc +120 -0
  31. package/dist/assets/deploy/cloudflare-sandbox/Dockerfile +67 -0
  32. package/dist/assets/deploy/cloudflare-sandbox/docker-wrapper.sh +37 -0
  33. package/dist/assets/deploy/cloudflare-sandbox/package.json +26 -0
  34. package/dist/assets/deploy/cloudflare-sandbox/tsconfig.json +20 -0
  35. package/dist/assets/deploy/cloudflare-sandbox/worker.ts +410 -0
  36. package/dist/assets/deploy/cloudflare-sandbox/wrangler.template.jsonc +67 -0
  37. package/dist/assets/deploy/profile.example.json +13 -0
  38. package/dist/assets/deploy/secrets.manifest.json +108 -0
  39. package/dist/assets/docker-entrypoint.sh +15 -0
  40. package/dist/assets/package-lock.json +18407 -0
  41. package/dist/assets/package.json +104 -0
  42. package/dist/assets/project.json +219 -0
  43. package/dist/assets/source.json +5 -0
  44. package/dist/assets/src/core/authz/actor.ts +100 -0
  45. package/dist/assets/src/core/authz/authorize.ts +169 -0
  46. package/dist/assets/src/core/authz/grants.ts +347 -0
  47. package/dist/assets/src/core/authz/policy.ts +281 -0
  48. package/dist/assets/src/core/authz/resource.ts +147 -0
  49. package/dist/assets/src/core/authz/types.ts +164 -0
  50. package/dist/assets/src/core/drain.ts +54 -0
  51. package/dist/assets/src/core/ingressTokens.ts +64 -0
  52. package/dist/assets/src/core/memory/engine.ts +115 -0
  53. package/dist/assets/src/core/memory/scorer.ts +147 -0
  54. package/dist/assets/src/core/memory/types.ts +120 -0
  55. package/dist/assets/src/core/normalizeSpans.ts +299 -0
  56. package/dist/assets/src/core/prDescriptionTypes.ts +54 -0
  57. package/dist/assets/src/core/redact.ts +113 -0
  58. package/dist/assets/src/core/runEvents.ts +537 -0
  59. package/dist/assets/src/core/runFriction.ts +665 -0
  60. package/dist/assets/src/core/runLedger/decisions.ts +126 -0
  61. package/dist/assets/src/core/runLedger/types.ts +177 -0
  62. package/dist/assets/src/core/runRecord.ts +627 -0
  63. package/dist/assets/src/core/runShape.ts +61 -0
  64. package/dist/assets/src/core/schedules.ts +452 -0
  65. package/dist/assets/src/core/time/formatDuration.ts +61 -0
  66. package/dist/assets/src/core/trace/attrs.ts +203 -0
  67. package/dist/assets/src/core/trace/classify.ts +49 -0
  68. package/dist/assets/src/core/trace/clock.ts +6 -0
  69. package/dist/assets/src/core/trace/context.ts +9 -0
  70. package/dist/assets/src/core/trace/ids.ts +23 -0
  71. package/dist/assets/src/core/trace/partition.ts +235 -0
  72. package/dist/assets/src/core/trace/sinks.ts +68 -0
  73. package/dist/assets/src/core/trace/streamSpans.ts +163 -0
  74. package/dist/assets/src/core/trace/traceparent.ts +29 -0
  75. package/dist/assets/src/core/trace/tracer.ts +247 -0
  76. package/dist/assets/src/core/trace/types.ts +125 -0
  77. package/dist/assets/src/core/trace/workerTrace.ts +97 -0
  78. package/dist/assets/src/deploy/buildStamp.ts +93 -0
  79. package/dist/assets/src/deploy/liveGate.ts +203 -0
  80. package/dist/assets/src/deploy/profile.ts +162 -0
  81. package/dist/assets/src/deploy/restart.ts +393 -0
  82. package/dist/assets/src/effort.ts +17 -0
  83. package/dist/assets/src/execution/bashTimeout.ts +78 -0
  84. package/dist/assets/src/execution/bindingPurge.ts +43 -0
  85. package/dist/assets/src/execution/residentBackupTransfer.ts +50 -0
  86. package/dist/assets/src/execution/residentCleanliness.ts +95 -0
  87. package/dist/assets/src/execution/residentCredentials.ts +81 -0
  88. package/dist/assets/src/execution/residentDepCache.ts +321 -0
  89. package/dist/assets/src/execution/residentDepsStore.ts +326 -0
  90. package/dist/assets/src/execution/residentDetach.ts +48 -0
  91. package/dist/assets/src/execution/residentDisk.ts +107 -0
  92. package/dist/assets/src/execution/residentDiskBudget.ts +448 -0
  93. package/dist/assets/src/execution/residentExecWrap.ts +100 -0
  94. package/dist/assets/src/execution/residentHead.ts +85 -0
  95. package/dist/assets/src/execution/residentReadonly.ts +72 -0
  96. package/dist/assets/src/execution/residentRefresh.ts +429 -0
  97. package/dist/assets/src/execution/residentRestoreExtract.ts +130 -0
  98. package/dist/assets/src/execution/residentState.ts +47 -0
  99. package/dist/assets/src/execution/residentStepReport.ts +98 -0
  100. package/dist/assets/src/execution/residentStepTrace.ts +97 -0
  101. package/dist/assets/src/execution/residentSteps.ts +99 -0
  102. package/dist/assets/src/execution/residentText.ts +83 -0
  103. package/dist/assets/src/execution/residentTrace.ts +119 -0
  104. package/dist/assets/src/execution/sandboxEnv.ts +42 -0
  105. package/dist/assets/src/execution/sandboxErrors.ts +159 -0
  106. package/dist/assets/src/execution/sandboxKeepalive.ts +118 -0
  107. package/dist/assets/src/execution/shellQuote.ts +8 -0
  108. package/dist/assets/src/mcp/registry.ts +242 -0
  109. package/dist/assets/src/providers/types.ts +152 -0
  110. package/dist/assets/web/dist/.vite/manifest.json +176 -0
  111. package/dist/assets/web/dist/assets/AppShell-Bk2gbvet.js +1 -0
  112. package/dist/assets/web/dist/assets/CostsPage-CTZcMYYx.js +1 -0
  113. package/dist/assets/web/dist/assets/NotFoundPage-C-BuaSm8.js +1 -0
  114. package/dist/assets/web/dist/assets/ResidentDetailPage-D3shEnzl.js +1 -0
  115. package/dist/assets/web/dist/assets/ResidentsIndexPage-DWIubQ05.js +1 -0
  116. package/dist/assets/web/dist/assets/RunRoutePage-BMjuE-oX.js +126 -0
  117. package/dist/assets/web/dist/assets/RunRoutePage-XVFj0XDc.css +1 -0
  118. package/dist/assets/web/dist/assets/RunsIndexPage-C3_jYIo0.js +1 -0
  119. package/dist/assets/web/dist/assets/RunsTabs-C4krAL9o.js +1 -0
  120. package/dist/assets/web/dist/assets/ScheduledPage-g1W58mtN.js +1 -0
  121. package/dist/assets/web/dist/assets/StatusDot-DcPRw3zu.js +1 -0
  122. package/dist/assets/web/dist/assets/Tooltip-DJUkMYjo.js +1 -0
  123. package/dist/assets/web/dist/assets/favicon-DL1rdWJt.js +1 -0
  124. package/dist/assets/web/dist/assets/localIso-L06jV29p.js +1 -0
  125. package/dist/assets/web/dist/assets/main-BsBGUyMH.css +2 -0
  126. package/dist/assets/web/dist/assets/main-CyM5f4JC.js +28 -0
  127. package/dist/assets/web/dist/assets/residentDiskBudget-BMBKlYRH.js +1 -0
  128. package/dist/assets/web/dist/assets/seed-BglCRKLA.js +6 -0
  129. package/dist/assets/web/dist/assets/wallClock-Ckv3sKoR.js +1 -0
  130. package/dist/cli.js +34494 -0
  131. package/package.json +43 -10
@@ -0,0 +1,347 @@
1
+ import { z } from "zod";
2
+ import { hasAction } from "./authorize.js";
3
+ import { NO_GRANTS, type Grants, type GrantSet } from "./types.js";
4
+
5
+ // Grants: WHAT an actor may do, from config's one shape —
6
+ // the native `grants` block (one entry per platform-namespaced actor id, or
7
+ // `<ns>:*` for every actor authenticated on a surface) — plus `restrict`, which
8
+ // names the agents and repos that are CLOSED unless a grant covers them.
9
+ // Everything not restricted is open to everyone who can reach the bot; a grant
10
+ // only ever adds. Pure: no I/O, no decisions — nothing here says whether an
11
+ // action is allowed; that is `authorize`.
12
+
13
+ /** Every grant, on every axis. What admins and the local CLI hold. */
14
+ export const ALL_GRANTS: Grants = Object.freeze({ actions: "all", channels: "all", repos: "all" });
15
+
16
+ /** The namespaces a native `grants` key may use (invariant 4). `cli:` is not
17
+ * configurable (the local CLI always holds everything) and `agent:` actors
18
+ * derive their grants from their principal, so neither is listed. */
19
+ export const GRANT_ACTOR_PREFIXES = ["slack", "http", "mcp", "access", "schedule"] as const;
20
+
21
+ /** The surfaces whose every authenticated actor may be granted at once with one
22
+ * `<ns>:*` entry: who may authenticate there is decided elsewhere (Cloudflare
23
+ * Access admits the org, Slack the workspace, the token maps the credentials),
24
+ * so "everyone on this surface" is a set an operator already trusts. Not here:
25
+ * `schedule:` (a schedule is an individually named job the registry declares),
26
+ * `access:svc:` (a service token is a named credential, not a browser session —
27
+ * `access:*` never reaches one), and the unconfigurable `cli:` and `agent:`. */
28
+ export const SURFACE_GRANT_PREFIXES = ["slack", "http", "mcp", "access"] as const;
29
+
30
+ /** The `<ns>:*` key for the surface `actorId` authenticated on — `slack:*` for
31
+ * `slack:U…`, `access:*` for a browser `access:<sub>` — or undefined when its
32
+ * namespace has none (`access:svc:`, `schedule:`, `cli:`, `agent:`, unknown). */
33
+ export function surfaceKeyFor(actorId: string): string | undefined {
34
+ if (actorId.startsWith("access:svc:")) return undefined;
35
+ const colon = actorId.indexOf(":");
36
+ if (colon <= 0 || colon === actorId.length - 1) return undefined;
37
+ const ns = actorId.slice(0, colon);
38
+ return (SURFACE_GRANT_PREFIXES as readonly string[]).includes(ns) ? `${ns}:*` : undefined;
39
+ }
40
+
41
+ /** The action that lets an actor run agent `<name>` — checked only for a
42
+ * RESTRICTED agent (`restrict.agents`); every other agent is open. */
43
+ export function agentRunAction(agent: string): string {
44
+ return `agent:run:${agent}`;
45
+ }
46
+
47
+ /** The actions of the commands the `open` chat gate admitted before they became
48
+ * policy rows: what EVERY Slack user holds. A command group not listed here
49
+ * is closed to chat users until config grants it (fail-closed).
50
+ * `config:write` is not here: `config set channel` is held where `grants` say
51
+ * so (admins through `actions: all`) and nowhere else. */
52
+ export const CHAT_OPEN_ACTIONS: readonly string[] = [
53
+ "help:read",
54
+ "status:read",
55
+ "config:read",
56
+ "repo:read",
57
+ "friction:read",
58
+ "memory:read",
59
+ "mcp:read",
60
+ "schedule:read",
61
+ "memory:write",
62
+ "mcp:write",
63
+ ];
64
+
65
+ /** What an Access browser session holds implicitly: every registered
66
+ * group's read — never a write, never an exec. */
67
+ export function browserReadActions(commandGroups: readonly string[]): Set<string> {
68
+ return new Set(commandGroups.map((g) => `${g}:read`));
69
+ }
70
+
71
+ // ---- the native `grants` block ----------------------------------------------
72
+
73
+ /** One axis as config spells it: a list of names, or the explicit word "all". */
74
+ export type GrantListConfig = readonly string[] | "all";
75
+
76
+ /** One actor's entry. An ABSENT axis is the empty set (fail-closed). */
77
+ export interface GrantsEntryConfig {
78
+ actions?: GrantListConfig;
79
+ channels?: GrantListConfig;
80
+ repos?: GrantListConfig;
81
+ }
82
+
83
+ /** `grants:` in config.yaml — actor id → entry. */
84
+ export type GrantsConfig = Record<string, GrantsEntryConfig>;
85
+
86
+ const grantList = z.union([z.literal("all"), z.array(z.string().min(1))]);
87
+ const grantsEntrySchema = z
88
+ .object({ actions: grantList.optional(), channels: grantList.optional(), repos: grantList.optional() })
89
+ .strict();
90
+
91
+ export type ParsedGrantsConfig = { ok: true; grants: Map<string, Grants> } | { ok: false; errors: string[] };
92
+
93
+ function toSet(v: GrantListConfig | undefined): GrantSet {
94
+ if (v === "all") return "all";
95
+ return new Set(v ?? []);
96
+ }
97
+
98
+ function hasKnownPrefix(actorId: string): boolean {
99
+ const colon = actorId.indexOf(":");
100
+ if (colon <= 0 || colon === actorId.length - 1) return false;
101
+ return (GRANT_ACTOR_PREFIXES as readonly string[]).includes(actorId.slice(0, colon));
102
+ }
103
+
104
+ /** Validate a raw `grants` block and build its table. Every problem names the
105
+ * actor id (and the axis) it is about; nothing is silently dropped or widened.
106
+ * `*` is only ever a whole surface (`surfaceKeyFor`): a partial subject
107
+ * (`slack:U*`) would be a pattern the lookup cannot honour, and `schedule:*`,
108
+ * `access:svc:*`, `agent:*`, `cli:*` name namespaces no surface entry covers. */
109
+ export function parseGrantsConfig(raw: unknown): ParsedGrantsConfig {
110
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw))
111
+ return { ok: false, errors: ["grants must be a mapping of actor id → { actions, channels, repos }"] };
112
+ const errors: string[] = [];
113
+ const grants = new Map<string, Grants>();
114
+ for (const [actorId, entry] of Object.entries(raw as Record<string, unknown>)) {
115
+ const where = `grants["${actorId}"]`;
116
+ if (actorId.includes("*") && surfaceKeyFor(actorId) !== actorId) {
117
+ errors.push(
118
+ `${where}: "*" only ever stands for a whole surface — one of ${SURFACE_GRANT_PREFIXES.map((p) => `${p}:*`).join(", ")} (every actor authenticated there); a subject is never a pattern, and schedule:, access:svc:, agent: and cli: ids are named one by one`,
119
+ );
120
+ continue;
121
+ }
122
+ if (!hasKnownPrefix(actorId)) {
123
+ errors.push(
124
+ `${where}: actor ids are platform-namespaced — one of ${GRANT_ACTOR_PREFIXES.map((p) => `${p}:`).join(", ")} followed by the subject`,
125
+ );
126
+ continue;
127
+ }
128
+ const parsed = grantsEntrySchema.safeParse(entry);
129
+ if (!parsed.success) {
130
+ for (const issue of parsed.error.issues) {
131
+ const axis = issue.path.length > 0 ? `.${issue.path.map(String).join(".")}` : "";
132
+ errors.push(
133
+ issue.code === "unrecognized_keys"
134
+ ? `${where}: unknown field ${issue.keys.join(", ")} (expected actions, channels, repos)`
135
+ : `${where}${axis}: expected "all" or a list of non-empty names`,
136
+ );
137
+ }
138
+ continue;
139
+ }
140
+ grants.set(actorId, {
141
+ actions: toSet(parsed.data.actions),
142
+ channels: toSet(parsed.data.channels),
143
+ repos: toSet(parsed.data.repos),
144
+ });
145
+ }
146
+ return errors.length > 0 ? { ok: false, errors } : { ok: true, grants };
147
+ }
148
+
149
+ // ---- `restrict`: what is closed unless granted -------------------------------------
150
+
151
+ /** `restrict:` in config.yaml. An agent listed here runs only for an actor whose
152
+ * grants hold `agent:run:<name>` (or `all`); a repo listed here (an `owner/name`
153
+ * slug) is used only by an actor whose `repos` axis names it (or `all`).
154
+ * Everything unlisted is open to everyone who can reach the bot. The lock and
155
+ * the allowlist are kept apart: listing a grant never takes anything from
156
+ * anyone else. */
157
+ export interface RestrictConfig {
158
+ agents?: readonly string[];
159
+ repos?: readonly string[];
160
+ }
161
+
162
+ /** The parsed block: repo slugs lowercased once, since every lookup is by a
163
+ * lowercased slug (`parseSlug`/`slugOf`/`repoResourceId`). */
164
+ export interface Restriction {
165
+ agents: ReadonlySet<string>;
166
+ repos: ReadonlySet<string>;
167
+ }
168
+
169
+ export const NO_RESTRICTION: Restriction = Object.freeze({ agents: new Set<string>(), repos: new Set<string>() });
170
+
171
+ const REPO_SLUG_RE = /^[\w.-]+\/[\w.-]+$/;
172
+ const restrictSchema = z
173
+ .object({ agents: z.array(z.string().min(1)).optional(), repos: z.array(z.string().min(1)).optional() })
174
+ .strict();
175
+
176
+ export type ParsedRestrictConfig = { ok: true; restrict: Restriction } | { ok: false; errors: string[] };
177
+
178
+ /** Validate a raw `restrict` block: agents must be registered names (a typo
179
+ * would otherwise restrict nothing, silently), repos must be `owner/name`. */
180
+ export function parseRestrictConfig(raw: unknown, agentNames: readonly string[]): ParsedRestrictConfig {
181
+ if (raw === undefined) return { ok: true, restrict: NO_RESTRICTION };
182
+ const parsed = restrictSchema.safeParse(raw);
183
+ if (!parsed.success) {
184
+ return {
185
+ ok: false,
186
+ errors: parsed.error.issues.map((issue) =>
187
+ issue.code === "unrecognized_keys"
188
+ ? `restrict: unknown field ${issue.keys.join(", ")} (expected agents, repos)`
189
+ : `restrict${issue.path.length > 0 ? `.${issue.path.map(String).join(".")}` : ""}: expected a list of non-empty names`,
190
+ ),
191
+ };
192
+ }
193
+ const errors: string[] = [];
194
+ for (const agent of parsed.data.agents ?? []) {
195
+ if (!agentNames.includes(agent))
196
+ errors.push(`restrict.agents: "${agent}" is not a registered agent (${agentNames.join(", ")})`);
197
+ }
198
+ for (const repo of parsed.data.repos ?? []) {
199
+ if (!REPO_SLUG_RE.test(repo)) errors.push(`restrict.repos: "${repo}" is not an owner/name slug`);
200
+ }
201
+ if (errors.length > 0) return { ok: false, errors };
202
+ return {
203
+ ok: true,
204
+ restrict: {
205
+ agents: new Set(parsed.data.agents ?? []),
206
+ repos: new Set((parsed.data.repos ?? []).map((r) => r.toLowerCase())),
207
+ },
208
+ };
209
+ }
210
+
211
+ /** Whether `set` covers `name` — `all`, or the name itself. */
212
+ export function covers(set: GrantSet, name: string): boolean {
213
+ return set === "all" || set.has(name);
214
+ }
215
+
216
+ // ---- the table: native entries, registry defaults, baselines -----------------------
217
+
218
+ /** Everything grants can come from. Every field optional: a deployment may have
219
+ * no `grants` block at all (then every actor is `NO_GRANTS` beyond its baseline). */
220
+ export interface GrantsSource {
221
+ /** The native block, already parsed (`parseGrantsConfig`). */
222
+ grants?: ReadonlyMap<string, Grants>;
223
+ /** The parsed `restrict` block; absent = nothing restricted. */
224
+ restrict?: Restriction;
225
+ /** The registered agents; absent = none (no agent is open to everyone). */
226
+ agentNames?: readonly string[];
227
+ /** The registered command groups; absent = none (a browser session holds nothing). */
228
+ commandGroups?: readonly string[];
229
+ /** The schedule registry's declared actors (`RunAction.actor`): each
230
+ * schedule's grants as the registry states them. The floor for a
231
+ * `schedule:<name>` id — a native `grants` entry for the same id replaces
232
+ * them (config decides). */
233
+ schedules?: readonly { readonly id: string; readonly grants: Grants }[];
234
+ }
235
+
236
+ export interface GrantsTable {
237
+ /** Every actor the config or the schedule registry names, with its baseline unioned in. Never a `*` key. */
238
+ grants: Map<string, Grants>;
239
+ /** The `<ns>:*` entries by key: what every actor authenticated on that surface
240
+ * holds beyond its baseline, unioned into each of them at lookup — never
241
+ * replacing an actor's own entry, never listed as an actor (a surface is not
242
+ * someone `adminsHint` can name). */
243
+ surfaces: Map<string, Grants>;
244
+ /** What every `slack:` user holds, listed or not: the open chat commands and `agent:run:<name>` for every unrestricted agent. */
245
+ everyone: Grants;
246
+ /** What every Access browser session (`access:<sub>`, never `access:svc:`) holds: each registered group's read. */
247
+ browserReads: Grants;
248
+ restrict: Restriction;
249
+ }
250
+
251
+ /** The baseline an actor id inherits by its namespace, listed or not: a `slack:`
252
+ * user holds what `everyone` does; an Access browser session holds every
253
+ * `<group>:read`. Every other namespace (`schedule:`, `access:svc:`, `http:`,
254
+ * `mcp:`) is a credential or a job that holds exactly what names it — an
255
+ * unlisted one is `NO_GRANTS` (fail-closed). */
256
+ export function namespaceBaseline(actorId: string, table: Pick<GrantsTable, "everyone" | "browserReads">): Grants {
257
+ if (actorId.startsWith("slack:")) return table.everyone;
258
+ if (actorId.startsWith("access:") && !actorId.startsWith("access:svc:")) return table.browserReads;
259
+ return NO_GRANTS;
260
+ }
261
+
262
+ /** The whole table — what `ConfigStore` builds once at load. A native `slack:`
263
+ * or browser entry ADDS to its namespace's baseline (a grant never takes the
264
+ * open commands away); every other entry is exactly what it declares. A
265
+ * `<ns>:*` entry is kept apart as a surface entry (`grantsIn` unions it in). A
266
+ * schedule's registry-declared grants are its floor, replaced whole by a native
267
+ * entry for the same `schedule:<name>` (the registry is a default, not a
268
+ * second config shape). */
269
+ export function grantsTable(source: GrantsSource): GrantsTable {
270
+ const restrict = source.restrict ?? NO_RESTRICTION;
271
+ const openAgents = (source.agentNames ?? []).filter((a) => !restrict.agents.has(a)).map(agentRunAction);
272
+ const baselines = {
273
+ everyone: { ...NO_GRANTS, actions: new Set([...CHAT_OPEN_ACTIONS, ...openAgents]) },
274
+ browserReads: { ...NO_GRANTS, actions: browserReadActions(source.commandGroups ?? []) },
275
+ };
276
+ const grants = new Map<string, Grants>();
277
+ const surfaces = new Map<string, Grants>();
278
+ for (const [id, g] of source.grants ?? []) {
279
+ if (surfaceKeyFor(id) === id) surfaces.set(id, g);
280
+ else grants.set(id, unionGrants(g, namespaceBaseline(id, baselines)));
281
+ }
282
+ for (const schedule of source.schedules ?? []) {
283
+ if (source.grants?.has(schedule.id)) continue;
284
+ grants.set(schedule.id, schedule.grants);
285
+ }
286
+ return { grants, surfaces, ...baselines, restrict };
287
+ }
288
+
289
+ /** One actor's grants from a built table: the UNION of its entry (baseline
290
+ * included; else the baseline its namespace inherits) and its surface's `*`
291
+ * entry — a personal entry never narrows what everyone on the surface holds.
292
+ * Nothing on any axis → `NO_GRANTS` (fail-closed); an id no surface owns has
293
+ * no `*` entry to inherit. */
294
+ export function grantsIn(table: GrantsTable, actorId: string): Grants {
295
+ const own = table.grants.get(actorId) ?? namespaceBaseline(actorId, table);
296
+ const surfaceKey = surfaceKeyFor(actorId);
297
+ const surface = surfaceKey === undefined ? undefined : table.surfaces.get(surfaceKey);
298
+ const effective = surface === undefined ? own : unionGrants(own, surface);
299
+ return isEmpty(effective) ? NO_GRANTS : effective;
300
+ }
301
+
302
+ /** `grantsIn` over a table built on the spot — for callers without a `ConfigStore`. */
303
+ export function grantsFor(actorId: string, source: GrantsSource): Grants {
304
+ return grantsIn(grantsTable(source), actorId);
305
+ }
306
+
307
+ /** Whether `actor` may run `agent`: every agent is open unless `restrict.agents`
308
+ * names it, and then only for a holder of `agent:run:<name>` — literally, through
309
+ * the `agent:run:*` wildcard, or `all` (the same `hasAction` the policy table reads). */
310
+ export function mayRunAgent(table: Pick<GrantsTable, "restrict">, actorGrants: Grants, agent: string): boolean {
311
+ return !table.restrict.agents.has(agent) || hasAction(actorGrants.actions, agentRunAction(agent));
312
+ }
313
+
314
+ /** Whether `actor` may use repo `slug`: every repo is open unless `restrict.repos`
315
+ * names it, and then only for a holder whose `repos` axis names it (or `all`).
316
+ * Compared lowercased on both sides — slugs are case-insensitive on GitHub. */
317
+ export function mayUseRepo(table: Pick<GrantsTable, "restrict">, actorGrants: Grants, slug: string): boolean {
318
+ const lower = slug.toLowerCase();
319
+ if (!table.restrict.repos.has(lower)) return true;
320
+ if (actorGrants.repos === "all") return true;
321
+ for (const r of actorGrants.repos) if (r.toLowerCase() === lower) return true;
322
+ return false;
323
+ }
324
+
325
+ function unionSet(a: GrantSet, b: GrantSet): GrantSet {
326
+ if (a === "all" || b === "all") return "all";
327
+ return new Set([...a, ...b]);
328
+ }
329
+
330
+ function unionGrants(a: Grants, b: Grants): Grants {
331
+ return {
332
+ actions: unionSet(a.actions, b.actions),
333
+ channels: unionSet(a.channels, b.channels),
334
+ repos: unionSet(a.repos, b.repos),
335
+ };
336
+ }
337
+
338
+ function isEmpty(g: Grants): boolean {
339
+ return (
340
+ g.actions !== "all" &&
341
+ g.actions.size === 0 &&
342
+ g.channels !== "all" &&
343
+ g.channels.size === 0 &&
344
+ g.repos !== "all" &&
345
+ g.repos.size === 0
346
+ );
347
+ }
@@ -0,0 +1,281 @@
1
+ // THE policy table (docs/decisions/0007-authorization-policy-table.md). Data, not code.
2
+ //
3
+ // Every gate Switchboard had — command chat gates, machine token scopes, the
4
+ // machine-caller channel pin, `canRunAgent` / `canUseRepo` / `canManageRepos`
5
+ // / `canEditChannelConfig` — is a row here, written in the closed condition
6
+ // vocabulary of `types.ts`. Rows for one (action, target) OR; conditions
7
+ // inside a row AND; no row → deny. `validatePolicy` runs at module load so a
8
+ // row that reads an attribute its resource cannot carry, or names a condition
9
+ // outside the vocabulary, fails the import — the table is closed by
10
+ // construction, not by review.
11
+ //
12
+ // COMMANDS. `CommandRegistry.invoke` asks `authorize(caller.actor,
13
+ // cmd.action, resource)` for every command on every surface, where the
14
+ // resource is `command { id }` unless the definition resolves one from the
15
+ // input (`repo.test|build` → `agent { coding }`). One rule shape covers what
16
+ // three mechanisms used to decide: the grant admits the command — a Slack user
17
+ // holds the grants of the commands the `open` chat gate admitted
18
+ // (`CHAT_OPEN_ACTIONS` in grants.ts), an Access browser session every
19
+ // `<group>:read`, an admin everything, a token or service token exactly its
20
+ // scopes — so a `dispatch`-only token is refused on every registry command and
21
+ // a `runs:write` token on `friction:write` by the same row that lets an
22
+ // operator through. Where a handler's refusal depends on the DATA (the
23
+ // `channel` scope of `config set`, the tier of `mcp add`), the handler asks the
24
+ // table about that resource (`config-scope`) and keeps its own reply text.
25
+ //
26
+ // Grant placeholders: a `has-grant` grant may name a resource attribute in
27
+ // braces (`agent:run:{name}`); it is filled from the resource before the
28
+ // lookup, and the validator checks the attribute exists on that target.
29
+
30
+ import {
31
+ CHANNEL_VISIBILITIES,
32
+ RESOURCE_KINDS,
33
+ RESOURCE_TYPES,
34
+ TARGET_ATTRIBUTES,
35
+ targetOf,
36
+ type AttributeName,
37
+ type Target,
38
+ } from "./resource.js";
39
+ import type { ActorKind, Condition, Rule } from "./types.js";
40
+
41
+ const grant = (g: string): Condition => ({ kind: "has-grant", grant: g });
42
+ const MEMBER_OF: Condition = { kind: "member-of" };
43
+ const IS_SELF: Condition = { kind: "is-self" };
44
+ const OWNER_OF: Condition = { kind: "owner-of" };
45
+ const ALL_CHANNELS: Condition = { kind: "all-channels" };
46
+
47
+ export const POLICY: readonly Rule[] = [
48
+ // ── runs ─────────────────────────────────────────────────────────────────
49
+ // A run is readable by a member of its channel, by anyone who sees every
50
+ // channel, or by the user it belongs to.
51
+ { action: "runs:read", resource: "run", when: [MEMBER_OF] },
52
+ { action: "runs:read", resource: "run", when: [ALL_CHANNELS] },
53
+ { action: "runs:read", resource: "run", when: [IS_SELF] },
54
+ // Stopping a run needs the write grant AND visibility of the run.
55
+ { action: "runs:write", resource: "run", when: [grant("runs:write"), MEMBER_OF] },
56
+ { action: "runs:write", resource: "run", when: [grant("runs:write"), ALL_CHANNELS] },
57
+ // List-shaped `runs.*`: the grant admits the command; the store predicate narrows the rows.
58
+ { action: "runs:read", resource: "command", when: [grant("runs:read")] },
59
+ { action: "runs:write", resource: "command", when: [grant("runs:write")] },
60
+
61
+ // ── review ───────────────────────────────────────────────────────────────
62
+ // `review abridge` spends one Opus-class call and rewrites a stored record:
63
+ // the grant admits the command (admins through `all`, operators by name;
64
+ // never a baseline). Which run it may touch is the `runs:read` point read
65
+ // the handler makes, like every `runs.*` command.
66
+ { action: "review:write", resource: "command", when: [grant("review:write")] },
67
+
68
+ // ── friction ─────────────────────────────────────────────────────────────
69
+ // `friction report` is what every Slack user holds (CHAT_OPEN_ACTIONS); what
70
+ // it reports is the store predicate's job. A token needs the grant.
71
+ { action: "friction:read", resource: "command", when: [grant("friction:read")] },
72
+ // `friction propose` files issues: the repo-management gate, now the `friction:write` grant.
73
+ { action: "friction:write", resource: "command", when: [grant("friction:write")] },
74
+
75
+ // ── repos ────────────────────────────────────────────────────────────────
76
+ { action: "repo:read", resource: "command", when: [grant("repo:read")] },
77
+ { action: "repo:write", resource: "repo", when: [grant("repo:write")] },
78
+ { action: "repo:write", resource: "command", when: [grant("repo:write")] },
79
+ // `repo test|build` run the repo's onboarded command as the coding agent with
80
+ // zero model turns: admitted by the right to run that agent (the `agentRun`
81
+ // chat gate) or by the exec grant a token was minted with; `write` never
82
+ // implies `exec`. The per-repo allowlist is the handler's own check.
83
+ { action: "repo:exec", resource: "agent", when: [grant("agent:run:{name}")] },
84
+ { action: "repo:exec", resource: "agent", when: [grant("repo:exec")] },
85
+ // The exec grant on a repo the actor may use (not yet asked by a command).
86
+ { action: "repo:exec", resource: "repo", when: [grant("repo:exec"), OWNER_OF] },
87
+ // Binding a run to a repo; open-when-absent today → grants.repos = "all".
88
+ { action: "repo:use", resource: "repo", when: [OWNER_OF] },
89
+
90
+ // ── config ───────────────────────────────────────────────────────────────
91
+ // `config show`: the read grant.
92
+ { action: "config:read", resource: "command", when: [grant("config:read")] },
93
+ // `config set|clear|instructions`: a person always has their own scope to
94
+ // write (`me`), whichever list names them; a credential needs the grant. The
95
+ // `channel` scope is the handler's question about `config-scope/channel`.
96
+ { action: "config:write", resource: "command", actorKinds: ["user"], when: [] },
97
+ { action: "config:write", resource: "command", when: [grant("config:write")] },
98
+ // Channel config: the `config:write` grant (held only where `grants` say so:
99
+ // admins through `all`, anyone granted it by name; never a baseline). Membership
100
+ // is NOT a condition
101
+ // here: a chat user may target another channel with `--channel`, and no
102
+ // adapter proves channel membership yet (the channel directory's `isMember`
103
+ // is where that fact will come from).
104
+ { action: "config:write", resource: "config-scope", resourceKind: "channel", when: [grant("config:write")] },
105
+ // A user edits only their own scope.
106
+ { action: "config:write", resource: "config-scope", resourceKind: "user", when: [IS_SELF] },
107
+
108
+ // ── agents ───────────────────────────────────────────────────────────────
109
+ // `agent:run:*` covers every agent through wildcard coverage (grants.ts).
110
+ { action: "agent:run", resource: "agent", when: [grant("agent:run:{name}")] },
111
+
112
+ // ── help / schedules / deploy / env / setup ──────────────────────────────────────
113
+ { action: "help:read", resource: "command", when: [grant("help:read")] },
114
+ { action: "status:read", resource: "command", when: [grant("status:read")] },
115
+ { action: "schedule:read", resource: "command", when: [grant("schedule:read")] },
116
+ { action: "deploy:read", resource: "command", when: [grant("deploy:read")] },
117
+ { action: "deploy:write", resource: "command", when: [grant("deploy:write")] },
118
+ { action: "env:write", resource: "command", when: [grant("env:write")] },
119
+ { action: "setup:write", resource: "command", when: [grant("setup:write")] },
120
+
121
+ // ── mcp (external MCP servers live in the three config tiers) ────────────
122
+ { action: "mcp:read", resource: "command", when: [grant("mcp:read")] },
123
+ { action: "mcp:write", resource: "command", when: [grant("mcp:write")] },
124
+ // A CHANNEL's servers: the channel-config right for a person; a credential
125
+ // an admin minted with `mcp:write` manages any tier it can name.
126
+ { action: "mcp:write", resource: "config-scope", resourceKind: "channel", when: [grant("config:write")] },
127
+ {
128
+ action: "mcp:write",
129
+ resource: "config-scope",
130
+ resourceKind: "channel",
131
+ actorKinds: ["service"],
132
+ when: [grant("mcp:write")],
133
+ },
134
+ // ORG-wide servers reach the coding/review agents: the repo-management right
135
+ // (`repo:write`, fail-closed) for a person; `mcp:write` for a credential.
136
+ { action: "mcp:write", resource: "config-scope", resourceKind: "org", when: [grant("repo:write")] },
137
+ {
138
+ action: "mcp:write",
139
+ resource: "config-scope",
140
+ resourceKind: "org",
141
+ actorKinds: ["service"],
142
+ when: [grant("mcp:write")],
143
+ },
144
+
145
+ // ── memory ───────────────────────────────────────────────────────────────
146
+ // `memory list` / `memory forget`: the grant admits the command; which
147
+ // records a caller may reach is the handler's scope-key check (invariant 4),
148
+ // and forgetting a SHARED record is the repo-management right (`repo:write`).
149
+ { action: "memory:read", resource: "command", when: [grant("memory:read")] },
150
+ { action: "memory:write", resource: "command", when: [grant("memory:write")] },
151
+ // Reads are unchanged: org is shared; the rest are relations.
152
+ { action: "memory:read", resource: "memory-scope", resourceKind: "org", when: [] },
153
+ { action: "memory:read", resource: "memory-scope", resourceKind: "user", when: [IS_SELF] },
154
+ { action: "memory:read", resource: "memory-scope", resourceKind: "channel", when: [MEMBER_OF] },
155
+ { action: "memory:read", resource: "memory-scope", resourceKind: "repo", when: [OWNER_OF] },
156
+ // Writes: a fact from a private/dm/unknown origin has NO org row.
157
+ {
158
+ action: "memory:write",
159
+ resource: "memory-scope",
160
+ resourceKind: "org",
161
+ originVisibility: ["public", "machine"],
162
+ when: [],
163
+ },
164
+ { action: "memory:write", resource: "memory-scope", resourceKind: "user", when: [IS_SELF] },
165
+ { action: "memory:write", resource: "memory-scope", resourceKind: "channel", when: [MEMBER_OF] },
166
+ { action: "memory:write", resource: "memory-scope", resourceKind: "repo", when: [OWNER_OF] },
167
+
168
+ // ── schedules ────────────────────────────────────────────────────────────
169
+ // Only the schedule shim's actor fires a schedule.
170
+ { action: "schedule:fire", resource: "command", actorKinds: ["schedule"], when: [] },
171
+ ];
172
+
173
+ export const ACTOR_KINDS: readonly ActorKind[] = ["user", "service", "schedule", "agent"];
174
+
175
+ export const CONDITION_KINDS: readonly Condition["kind"][] = [
176
+ "has-grant",
177
+ "member-of",
178
+ "is-self",
179
+ "owner-of",
180
+ "all-channels",
181
+ ];
182
+
183
+ const REQUIRED_ATTRIBUTE: Readonly<Partial<Record<Condition["kind"], AttributeName>>> = {
184
+ "member-of": "channelId",
185
+ "is-self": "userId",
186
+ "owner-of": "repo",
187
+ };
188
+
189
+ const PLACEHOLDER = /\{([^{}]*)\}/g;
190
+
191
+ /** Attribute names a grant string references (`agent:run:{name}` → `["name"]`). */
192
+ export function grantPlaceholders(grantName: string): string[] {
193
+ return [...grantName.matchAll(PLACEHOLDER)].map((m) => m[1]!);
194
+ }
195
+
196
+ /** Fill a grant's placeholders from the resource; `undefined` when an attribute is missing. */
197
+ export function resolveGrant(
198
+ grantName: string,
199
+ attributes: Readonly<Partial<Record<AttributeName, string>>>,
200
+ ): string | undefined {
201
+ let missing = false;
202
+ const resolved = grantName.replace(PLACEHOLDER, (_m, key: string) => {
203
+ const value = attributes[key as AttributeName];
204
+ if (value === undefined) missing = true;
205
+ return value ?? "";
206
+ });
207
+ return missing ? undefined : resolved;
208
+ }
209
+
210
+ export function ruleTarget(rule: Rule): Target | undefined {
211
+ return targetOf(rule.resource, rule.resourceKind);
212
+ }
213
+
214
+ class PolicyError extends Error {
215
+ constructor(message: string, rule: unknown) {
216
+ super(`authz policy: ${message} (rule ${describeRule(rule)})`);
217
+ this.name = "PolicyError";
218
+ }
219
+ }
220
+
221
+ function describeRule(rule: unknown): string {
222
+ if (typeof rule !== "object" || rule === null) return String(rule);
223
+ const r = rule as Partial<Rule>;
224
+ return `${String(r.action)} on ${String(r.resource)}${r.resourceKind ? `/${String(r.resourceKind)}` : ""}`;
225
+ }
226
+
227
+ /** Refuse a table the evaluators could not both decide and compile.
228
+ * Called on `POLICY` at module load; exported so a test can feed it a bad table. */
229
+ export function validatePolicy(rules: readonly Rule[]): void {
230
+ if (!Array.isArray(rules)) throw new PolicyError("table is not an array", rules);
231
+ for (const rule of rules as readonly unknown[]) {
232
+ if (typeof rule !== "object" || rule === null) throw new PolicyError("row is not an object", rule);
233
+ const r = rule as Rule;
234
+ if (typeof r.action !== "string" || r.action.length === 0)
235
+ throw new PolicyError("action must be a non-empty string", rule);
236
+ if (!RESOURCE_TYPES.includes(r.resource))
237
+ throw new PolicyError(`unknown resource type ${String(r.resource)}`, rule);
238
+ // The distributed `Rule` type already ties `resourceKind` to its resource; these
239
+ // runtime checks stay because a table may arrive untyped (config, a test, a future loader).
240
+ const kinds: readonly string[] | undefined = RESOURCE_KINDS[r.resource];
241
+ if (kinds && r.resourceKind === undefined)
242
+ throw new PolicyError(`${r.resource} rows must name a resourceKind (${kinds.join("|")})`, rule);
243
+ if (!kinds && r.resourceKind !== undefined)
244
+ throw new PolicyError(`${r.resource} is not kinded; resourceKind is not allowed`, rule);
245
+ const target = ruleTarget(r);
246
+ if (!target) throw new PolicyError(`unknown resourceKind ${String(r.resourceKind)}`, rule);
247
+ if (r.actorKinds !== undefined) {
248
+ if (!Array.isArray(r.actorKinds) || r.actorKinds.length === 0)
249
+ throw new PolicyError("actorKinds must be a non-empty array", rule);
250
+ for (const kind of r.actorKinds)
251
+ if (!ACTOR_KINDS.includes(kind)) throw new PolicyError(`unknown actor kind ${String(kind)}`, rule);
252
+ }
253
+ if (r.originVisibility !== undefined) {
254
+ if (!Array.isArray(r.originVisibility) || r.originVisibility.length === 0)
255
+ throw new PolicyError("originVisibility must be a non-empty array", rule);
256
+ for (const v of r.originVisibility)
257
+ if (!CHANNEL_VISIBILITIES.includes(v)) throw new PolicyError(`unknown visibility ${String(v)}`, rule);
258
+ }
259
+ if (!Array.isArray(r.when)) throw new PolicyError("when must be an array", rule);
260
+ const carried = TARGET_ATTRIBUTES[target];
261
+ for (const condition of r.when as readonly unknown[]) {
262
+ if (typeof condition !== "object" || condition === null)
263
+ throw new PolicyError("condition is not an object", rule);
264
+ const c = condition as Condition;
265
+ if (!CONDITION_KINDS.includes(c.kind)) throw new PolicyError(`unknown condition ${String(c.kind)}`, rule);
266
+ const needs = REQUIRED_ATTRIBUTE[c.kind];
267
+ if (needs && !carried.includes(needs))
268
+ throw new PolicyError(`${c.kind} needs ${needs}, which ${target} cannot carry`, rule);
269
+ if (c.kind === "has-grant") {
270
+ if (typeof c.grant !== "string" || c.grant.length === 0)
271
+ throw new PolicyError("has-grant needs a grant name", rule);
272
+ for (const attribute of grantPlaceholders(c.grant)) {
273
+ if (!carried.includes(attribute as AttributeName))
274
+ throw new PolicyError(`grant placeholder {${attribute}} is not an attribute of ${target}`, rule);
275
+ }
276
+ }
277
+ }
278
+ }
279
+ }
280
+
281
+ validatePolicy(POLICY);