codepiper 0.1.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 (149) hide show
  1. package/.env.example +28 -0
  2. package/CHANGELOG.md +10 -0
  3. package/LEGAL_NOTICE.md +39 -0
  4. package/LICENSE +21 -0
  5. package/README.md +524 -0
  6. package/package.json +90 -0
  7. package/packages/cli/package.json +13 -0
  8. package/packages/cli/src/commands/analytics.ts +157 -0
  9. package/packages/cli/src/commands/attach.ts +299 -0
  10. package/packages/cli/src/commands/audit.ts +50 -0
  11. package/packages/cli/src/commands/auth.ts +261 -0
  12. package/packages/cli/src/commands/daemon.ts +162 -0
  13. package/packages/cli/src/commands/doctor.ts +303 -0
  14. package/packages/cli/src/commands/env-set.ts +162 -0
  15. package/packages/cli/src/commands/hook-forward.ts +268 -0
  16. package/packages/cli/src/commands/keys.ts +77 -0
  17. package/packages/cli/src/commands/kill.ts +19 -0
  18. package/packages/cli/src/commands/logs.ts +419 -0
  19. package/packages/cli/src/commands/model.ts +172 -0
  20. package/packages/cli/src/commands/policy-set.ts +185 -0
  21. package/packages/cli/src/commands/policy.ts +227 -0
  22. package/packages/cli/src/commands/providers.ts +114 -0
  23. package/packages/cli/src/commands/resize.ts +34 -0
  24. package/packages/cli/src/commands/send.ts +184 -0
  25. package/packages/cli/src/commands/sessions.ts +202 -0
  26. package/packages/cli/src/commands/slash.ts +92 -0
  27. package/packages/cli/src/commands/start.ts +243 -0
  28. package/packages/cli/src/commands/stop.ts +19 -0
  29. package/packages/cli/src/commands/tail.ts +137 -0
  30. package/packages/cli/src/commands/workflow.ts +786 -0
  31. package/packages/cli/src/commands/workspace.ts +127 -0
  32. package/packages/cli/src/lib/api.ts +78 -0
  33. package/packages/cli/src/lib/args.ts +72 -0
  34. package/packages/cli/src/lib/format.ts +93 -0
  35. package/packages/cli/src/main.ts +563 -0
  36. package/packages/core/package.json +7 -0
  37. package/packages/core/src/config.ts +30 -0
  38. package/packages/core/src/errors.ts +38 -0
  39. package/packages/core/src/eventBus.ts +56 -0
  40. package/packages/core/src/eventBusAdapter.ts +143 -0
  41. package/packages/core/src/index.ts +10 -0
  42. package/packages/core/src/sqliteEventBus.ts +336 -0
  43. package/packages/core/src/types.ts +63 -0
  44. package/packages/daemon/package.json +11 -0
  45. package/packages/daemon/src/api/analyticsRoutes.ts +343 -0
  46. package/packages/daemon/src/api/authRoutes.ts +344 -0
  47. package/packages/daemon/src/api/bodyLimit.ts +133 -0
  48. package/packages/daemon/src/api/envSetRoutes.ts +170 -0
  49. package/packages/daemon/src/api/gitRoutes.ts +409 -0
  50. package/packages/daemon/src/api/hooks.ts +588 -0
  51. package/packages/daemon/src/api/inputPolicy.ts +249 -0
  52. package/packages/daemon/src/api/notificationRoutes.ts +532 -0
  53. package/packages/daemon/src/api/policyRoutes.ts +234 -0
  54. package/packages/daemon/src/api/policySetRoutes.ts +445 -0
  55. package/packages/daemon/src/api/routeUtils.ts +28 -0
  56. package/packages/daemon/src/api/routes.ts +1004 -0
  57. package/packages/daemon/src/api/server.ts +1388 -0
  58. package/packages/daemon/src/api/settingsRoutes.ts +367 -0
  59. package/packages/daemon/src/api/sqliteErrors.ts +47 -0
  60. package/packages/daemon/src/api/stt.ts +143 -0
  61. package/packages/daemon/src/api/terminalRoutes.ts +200 -0
  62. package/packages/daemon/src/api/validation.ts +287 -0
  63. package/packages/daemon/src/api/validationRoutes.ts +174 -0
  64. package/packages/daemon/src/api/workflowRoutes.ts +567 -0
  65. package/packages/daemon/src/api/workspaceRoutes.ts +151 -0
  66. package/packages/daemon/src/api/ws.ts +1588 -0
  67. package/packages/daemon/src/auth/apiRateLimiter.ts +73 -0
  68. package/packages/daemon/src/auth/authMiddleware.ts +305 -0
  69. package/packages/daemon/src/auth/authService.ts +496 -0
  70. package/packages/daemon/src/auth/rateLimiter.ts +137 -0
  71. package/packages/daemon/src/config/pricing.ts +79 -0
  72. package/packages/daemon/src/crypto/encryption.ts +196 -0
  73. package/packages/daemon/src/db/db.ts +2745 -0
  74. package/packages/daemon/src/db/index.ts +16 -0
  75. package/packages/daemon/src/db/migrations.ts +182 -0
  76. package/packages/daemon/src/db/policyDb.ts +349 -0
  77. package/packages/daemon/src/db/schema.sql +408 -0
  78. package/packages/daemon/src/db/workflowDb.ts +464 -0
  79. package/packages/daemon/src/git/gitUtils.ts +544 -0
  80. package/packages/daemon/src/index.ts +6 -0
  81. package/packages/daemon/src/main.ts +525 -0
  82. package/packages/daemon/src/notifications/pushNotifier.ts +369 -0
  83. package/packages/daemon/src/providers/codexAppServerScaffold.ts +49 -0
  84. package/packages/daemon/src/providers/registry.ts +111 -0
  85. package/packages/daemon/src/providers/types.ts +82 -0
  86. package/packages/daemon/src/sessions/auditLogger.ts +103 -0
  87. package/packages/daemon/src/sessions/policyEngine.ts +165 -0
  88. package/packages/daemon/src/sessions/policyMatcher.ts +114 -0
  89. package/packages/daemon/src/sessions/policyTypes.ts +94 -0
  90. package/packages/daemon/src/sessions/ptyProcess.ts +141 -0
  91. package/packages/daemon/src/sessions/sessionManager.ts +1770 -0
  92. package/packages/daemon/src/sessions/tmuxSession.ts +1073 -0
  93. package/packages/daemon/src/sessions/transcriptManager.ts +110 -0
  94. package/packages/daemon/src/sessions/transcriptParser.ts +149 -0
  95. package/packages/daemon/src/sessions/transcriptTailer.ts +214 -0
  96. package/packages/daemon/src/tracking/tokenTracker.ts +168 -0
  97. package/packages/daemon/src/workflows/contextManager.ts +83 -0
  98. package/packages/daemon/src/workflows/index.ts +31 -0
  99. package/packages/daemon/src/workflows/resultExtractor.ts +118 -0
  100. package/packages/daemon/src/workflows/waitConditionPoller.ts +131 -0
  101. package/packages/daemon/src/workflows/workflowParser.ts +217 -0
  102. package/packages/daemon/src/workflows/workflowRunner.ts +969 -0
  103. package/packages/daemon/src/workflows/workflowTypes.ts +188 -0
  104. package/packages/daemon/src/workflows/workflowValidator.ts +533 -0
  105. package/packages/providers/claude-code/package.json +11 -0
  106. package/packages/providers/claude-code/src/index.ts +7 -0
  107. package/packages/providers/claude-code/src/overlaySettings.ts +198 -0
  108. package/packages/providers/claude-code/src/provider.ts +311 -0
  109. package/packages/web/dist/android-chrome-192x192.png +0 -0
  110. package/packages/web/dist/android-chrome-512x512.png +0 -0
  111. package/packages/web/dist/apple-touch-icon.png +0 -0
  112. package/packages/web/dist/assets/AnalyticsPage-BIopKWRf.js +17 -0
  113. package/packages/web/dist/assets/PoliciesPage-CjdLN3dl.js +11 -0
  114. package/packages/web/dist/assets/SessionDetailPage-BtSA0V0M.js +179 -0
  115. package/packages/web/dist/assets/SettingsPage-Dbbz4Ca5.js +37 -0
  116. package/packages/web/dist/assets/WorkflowsPage-Dv6f3GgU.js +1 -0
  117. package/packages/web/dist/assets/chart-vendor-DlOHLaCG.js +49 -0
  118. package/packages/web/dist/assets/codicon-ngg6Pgfi.ttf +0 -0
  119. package/packages/web/dist/assets/css.worker-BvV5MPou.js +93 -0
  120. package/packages/web/dist/assets/editor.worker-CKy7Pnvo.js +26 -0
  121. package/packages/web/dist/assets/html.worker-BLJhxQJQ.js +470 -0
  122. package/packages/web/dist/assets/index-BbdhRfr2.css +1 -0
  123. package/packages/web/dist/assets/index-hgphORiw.js +204 -0
  124. package/packages/web/dist/assets/json.worker-usMZ-FED.js +58 -0
  125. package/packages/web/dist/assets/monaco-core-B_19GPAS.css +1 -0
  126. package/packages/web/dist/assets/monaco-core-DQ5Mk8AK.js +1234 -0
  127. package/packages/web/dist/assets/monaco-react-DfZNWvtW.js +11 -0
  128. package/packages/web/dist/assets/monacoSetup-DvBj52bT.js +1 -0
  129. package/packages/web/dist/assets/pencil-Dbczxz59.js +11 -0
  130. package/packages/web/dist/assets/react-vendor-B5MgMUHH.js +136 -0
  131. package/packages/web/dist/assets/refresh-cw-B0MGsYPL.js +6 -0
  132. package/packages/web/dist/assets/tabs-C8LsWiR5.js +1 -0
  133. package/packages/web/dist/assets/terminal-vendor-Cs8KPbV3.js +9 -0
  134. package/packages/web/dist/assets/terminal-vendor-LcAfv9l9.css +32 -0
  135. package/packages/web/dist/assets/trash-2-Btlg0d4l.js +6 -0
  136. package/packages/web/dist/assets/ts.worker-DGHjMaqB.js +67731 -0
  137. package/packages/web/dist/favicon.ico +0 -0
  138. package/packages/web/dist/icon.svg +1 -0
  139. package/packages/web/dist/index.html +29 -0
  140. package/packages/web/dist/manifest.json +29 -0
  141. package/packages/web/dist/og-image.png +0 -0
  142. package/packages/web/dist/originals/android-chrome-192x192.png +0 -0
  143. package/packages/web/dist/originals/android-chrome-512x512.png +0 -0
  144. package/packages/web/dist/originals/apple-touch-icon.png +0 -0
  145. package/packages/web/dist/originals/favicon.ico +0 -0
  146. package/packages/web/dist/piper.svg +1 -0
  147. package/packages/web/dist/sounds/codepiper-soft-chime.wav +0 -0
  148. package/packages/web/dist/sw.js +257 -0
  149. package/scripts/postinstall-link-workspaces.mjs +58 -0
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Policy API route handlers
3
+ */
4
+
5
+ import type { PolicyRule } from "../db/policyDb";
6
+ import { createPolicyDb } from "../db/policyDb";
7
+ import type { RouteContext } from "./routes";
8
+ import { jsonError, parseJsonBody } from "./routeUtils";
9
+ import {
10
+ getErrorMessage,
11
+ isSqliteForeignKeyConstraintError,
12
+ isSqliteUniqueConstraintError,
13
+ } from "./sqliteErrors";
14
+
15
+ /**
16
+ * GET /policies - List all policies
17
+ */
18
+ export async function handleListPolicies(req: Request, ctx: RouteContext): Promise<Response> {
19
+ const policyDb = createPolicyDb(ctx.db);
20
+
21
+ // Parse query parameters
22
+ const url = new URL(req.url);
23
+ const enabledParam = url.searchParams.get("enabled");
24
+ const sessionIdParam = url.searchParams.get("sessionId");
25
+
26
+ const options: any = {};
27
+
28
+ if (enabledParam !== null) {
29
+ options.enabled = enabledParam === "true";
30
+ }
31
+
32
+ if (sessionIdParam !== null) {
33
+ options.sessionId = sessionIdParam === "null" ? null : sessionIdParam;
34
+ }
35
+
36
+ const policies = policyDb.getPolicies(options);
37
+
38
+ return Response.json({ policies });
39
+ }
40
+
41
+ /**
42
+ * POST /policies - Create a new policy
43
+ */
44
+ export async function handleCreatePolicy(req: Request, ctx: RouteContext): Promise<Response> {
45
+ const parsed = await parseJsonBody<Record<string, unknown>>(req);
46
+ if (!parsed.ok) {
47
+ return parsed.response;
48
+ }
49
+ const body = parsed.body as any;
50
+
51
+ // Validate required fields
52
+ if (!body.id) {
53
+ return jsonError(400, "Missing required field: id");
54
+ }
55
+ if (!body.name) {
56
+ return jsonError(400, "Missing required field: name");
57
+ }
58
+ if (body.enabled === undefined) {
59
+ return jsonError(400, "Missing required field: enabled");
60
+ }
61
+ if (body.priority === undefined) {
62
+ return jsonError(400, "Missing required field: priority");
63
+ }
64
+ if (body.sessionId === undefined) {
65
+ return jsonError(400, "Missing required field: sessionId");
66
+ }
67
+ if (!body.rules) {
68
+ return jsonError(400, "Missing required field: rules");
69
+ }
70
+ if (!Array.isArray(body.rules)) {
71
+ return jsonError(400, "Field 'rules' must be an array");
72
+ }
73
+ if (typeof body.enabled !== "boolean") {
74
+ return jsonError(400, "Field 'enabled' must be a boolean");
75
+ }
76
+ if (
77
+ typeof body.priority !== "number" ||
78
+ !Number.isInteger(body.priority) ||
79
+ !Number.isFinite(body.priority)
80
+ ) {
81
+ return jsonError(400, "Field 'priority' must be an integer");
82
+ }
83
+ if (body.sessionId !== null && typeof body.sessionId !== "string") {
84
+ return jsonError(400, "Field 'sessionId' must be a string or null");
85
+ }
86
+ if (body.sessionId !== null && !ctx.db.getSession(body.sessionId)) {
87
+ return jsonError(404, `Session not found: ${body.sessionId}`);
88
+ }
89
+
90
+ try {
91
+ const policyDb = createPolicyDb(ctx.db);
92
+
93
+ policyDb.createPolicy({
94
+ id: body.id,
95
+ name: body.name,
96
+ description: body.description,
97
+ enabled: body.enabled,
98
+ priority: body.priority,
99
+ sessionId: body.sessionId,
100
+ rules: body.rules as PolicyRule[],
101
+ });
102
+
103
+ const policy = policyDb.getPolicy(body.id);
104
+
105
+ return Response.json({ policy }, { status: 201 });
106
+ } catch (error) {
107
+ if (isSqliteUniqueConstraintError(error)) {
108
+ return jsonError(409, `Policy already exists: ${body.id}`);
109
+ }
110
+ if (isSqliteForeignKeyConstraintError(error)) {
111
+ return jsonError(422, "Policy references a missing related record");
112
+ }
113
+ return jsonError(500, getErrorMessage(error));
114
+ }
115
+ }
116
+
117
+ /**
118
+ * GET /policies/:id - Get a specific policy
119
+ */
120
+ export async function handleGetPolicy(
121
+ _req: Request,
122
+ ctx: RouteContext,
123
+ policyId: string
124
+ ): Promise<Response> {
125
+ const policyDb = createPolicyDb(ctx.db);
126
+ const policy = policyDb.getPolicy(policyId);
127
+
128
+ if (!policy) {
129
+ return Response.json({ error: `Policy not found: ${policyId}` }, { status: 404 });
130
+ }
131
+
132
+ return Response.json({ policy });
133
+ }
134
+
135
+ /**
136
+ * PUT /policies/:id - Update a policy
137
+ */
138
+ export async function handleUpdatePolicy(
139
+ req: Request,
140
+ ctx: RouteContext,
141
+ policyId: string
142
+ ): Promise<Response> {
143
+ const parsed = await parseJsonBody<Record<string, unknown>>(req);
144
+ if (!parsed.ok) {
145
+ return parsed.response;
146
+ }
147
+ const body = parsed.body as any;
148
+
149
+ // Validate rules if provided
150
+ if (body.rules !== undefined && !Array.isArray(body.rules)) {
151
+ return jsonError(400, "Field 'rules' must be an array");
152
+ }
153
+ if (body.name !== undefined && typeof body.name !== "string") {
154
+ return jsonError(400, "Field 'name' must be a string");
155
+ }
156
+ if (
157
+ body.description !== undefined &&
158
+ body.description !== null &&
159
+ typeof body.description !== "string"
160
+ ) {
161
+ return jsonError(400, "Field 'description' must be a string or null");
162
+ }
163
+ if (body.enabled !== undefined && typeof body.enabled !== "boolean") {
164
+ return jsonError(400, "Field 'enabled' must be a boolean");
165
+ }
166
+ if (
167
+ body.priority !== undefined &&
168
+ (typeof body.priority !== "number" ||
169
+ !Number.isInteger(body.priority) ||
170
+ !Number.isFinite(body.priority))
171
+ ) {
172
+ return jsonError(400, "Field 'priority' must be an integer");
173
+ }
174
+
175
+ const policyDb = createPolicyDb(ctx.db);
176
+
177
+ // Check if policy exists
178
+ const existing = policyDb.getPolicy(policyId);
179
+ if (!existing) {
180
+ return jsonError(404, `Policy not found: ${policyId}`);
181
+ }
182
+
183
+ try {
184
+ const updates: any = {};
185
+
186
+ if (body.name !== undefined) {
187
+ updates.name = body.name;
188
+ }
189
+ if (body.description !== undefined) {
190
+ updates.description = body.description;
191
+ }
192
+ if (body.enabled !== undefined) {
193
+ updates.enabled = body.enabled;
194
+ }
195
+ if (body.priority !== undefined) {
196
+ updates.priority = body.priority;
197
+ }
198
+ if (body.rules !== undefined) {
199
+ updates.rules = body.rules;
200
+ }
201
+
202
+ policyDb.updatePolicy(policyId, updates);
203
+
204
+ const policy = policyDb.getPolicy(policyId);
205
+
206
+ return Response.json({ policy });
207
+ } catch (error) {
208
+ return jsonError(500, getErrorMessage(error));
209
+ }
210
+ }
211
+
212
+ /**
213
+ * DELETE /policies/:id - Delete a policy
214
+ */
215
+ export async function handleDeletePolicy(
216
+ _req: Request,
217
+ ctx: RouteContext,
218
+ policyId: string
219
+ ): Promise<Response> {
220
+ const policyDb = createPolicyDb(ctx.db);
221
+
222
+ // Check if policy exists
223
+ const existing = policyDb.getPolicy(policyId);
224
+ if (!existing) {
225
+ return jsonError(404, `Policy not found: ${policyId}`);
226
+ }
227
+
228
+ try {
229
+ policyDb.deletePolicy(policyId);
230
+ return Response.json({ success: true });
231
+ } catch (error) {
232
+ return jsonError(500, getErrorMessage(error));
233
+ }
234
+ }
@@ -0,0 +1,445 @@
1
+ /**
2
+ * Policy Set API route handlers
3
+ */
4
+
5
+ import type { RouteContext } from "./routes";
6
+ import { hasAnyDefinedField, jsonError, parseJsonBody } from "./routeUtils";
7
+ import {
8
+ getErrorMessage,
9
+ isSqliteForeignKeyConstraintError,
10
+ isSqliteUniqueConstraintError,
11
+ } from "./sqliteErrors";
12
+
13
+ const VALID_POLICY_DECISIONS = new Set(["allow", "deny", "ask"]);
14
+
15
+ function setContainsPolicy(ctx: RouteContext, setId: string, policyId: string): boolean {
16
+ return ctx.db.getPolicySetMembers(setId).some((policy) => policy.id === policyId);
17
+ }
18
+
19
+ function sessionHasPolicySet(ctx: RouteContext, sessionId: string, setId: string): boolean {
20
+ return ctx.db.getSessionPolicySets(sessionId).some((set) => set.id === setId);
21
+ }
22
+
23
+ /**
24
+ * GET /policy-sets - List all policy sets
25
+ */
26
+ export async function handleListPolicySets(_req: Request, ctx: RouteContext): Promise<Response> {
27
+ const policySets = ctx.db.listPolicySets();
28
+ return Response.json({ policySets });
29
+ }
30
+
31
+ /**
32
+ * POST /policy-sets - Create a new policy set
33
+ */
34
+ export async function handleCreatePolicySet(req: Request, ctx: RouteContext): Promise<Response> {
35
+ const parsed = await parseJsonBody(req);
36
+ if (!parsed.ok) {
37
+ return parsed.response;
38
+ }
39
+ const body = parsed.body as any;
40
+
41
+ if (!body.id) {
42
+ return jsonError(400, "Missing required field: id");
43
+ }
44
+ if (!body.name) {
45
+ return jsonError(400, "Missing required field: name");
46
+ }
47
+ if (typeof body.id !== "string") {
48
+ return jsonError(400, "Field 'id' must be a string");
49
+ }
50
+ if (typeof body.name !== "string") {
51
+ return jsonError(400, "Field 'name' must be a string");
52
+ }
53
+ if (
54
+ body.description !== undefined &&
55
+ body.description !== null &&
56
+ typeof body.description !== "string"
57
+ ) {
58
+ return jsonError(400, "Field 'description' must be a string or null");
59
+ }
60
+ if (body.isDefault !== undefined && typeof body.isDefault !== "boolean") {
61
+ return jsonError(400, "Field 'isDefault' must be a boolean");
62
+ }
63
+ if (body.policyIds !== undefined) {
64
+ if (!Array.isArray(body.policyIds)) {
65
+ return jsonError(400, "Field 'policyIds' must be an array of strings");
66
+ }
67
+
68
+ for (const policyId of body.policyIds) {
69
+ if (typeof policyId !== "string") {
70
+ return jsonError(400, "Field 'policyIds' must be an array of strings");
71
+ }
72
+ }
73
+
74
+ const missingPolicyIds = [...new Set<string>(body.policyIds)].filter(
75
+ (policyId) => !ctx.db.getPolicy(policyId)
76
+ );
77
+ if (missingPolicyIds.length > 0) {
78
+ return jsonError(422, "One or more referenced policies were not found", {
79
+ missingPolicyIds,
80
+ });
81
+ }
82
+ }
83
+
84
+ try {
85
+ ctx.db.createPolicySet({
86
+ id: body.id,
87
+ name: body.name,
88
+ description: body.description,
89
+ isDefault: body.isDefault ?? false,
90
+ policyIds: body.policyIds,
91
+ });
92
+
93
+ const policySet = ctx.db.getPolicySet(body.id);
94
+ const members = ctx.db.getPolicySetMembers(body.id);
95
+
96
+ return Response.json({ policySet: { ...policySet, policies: members } }, { status: 201 });
97
+ } catch (error) {
98
+ if (isSqliteUniqueConstraintError(error)) {
99
+ return jsonError(409, `Policy set already exists: ${body.id ?? body.name}`);
100
+ }
101
+ if (isSqliteForeignKeyConstraintError(error)) {
102
+ return jsonError(422, "Policy set references a missing related record");
103
+ }
104
+ return jsonError(500, getErrorMessage(error));
105
+ }
106
+ }
107
+
108
+ /**
109
+ * GET /policy-sets/:id - Get a specific policy set with member policies
110
+ */
111
+ export async function handleGetPolicySet(
112
+ _req: Request,
113
+ ctx: RouteContext,
114
+ setId: string
115
+ ): Promise<Response> {
116
+ const policySet = ctx.db.getPolicySet(setId);
117
+ if (!policySet) {
118
+ return Response.json({ error: `Policy set not found: ${setId}` }, { status: 404 });
119
+ }
120
+
121
+ const members = ctx.db.getPolicySetMembers(setId);
122
+ return Response.json({ policySet: { ...policySet, policies: members } });
123
+ }
124
+
125
+ /**
126
+ * PUT /policy-sets/:id - Update a policy set
127
+ */
128
+ export async function handleUpdatePolicySet(
129
+ req: Request,
130
+ ctx: RouteContext,
131
+ setId: string
132
+ ): Promise<Response> {
133
+ const parsed = await parseJsonBody(req);
134
+ if (!parsed.ok) {
135
+ return parsed.response;
136
+ }
137
+ const body = parsed.body as any;
138
+
139
+ const existing = ctx.db.getPolicySet(setId);
140
+ if (!existing) {
141
+ return jsonError(404, `Policy set not found: ${setId}`);
142
+ }
143
+ if (body.name !== undefined && typeof body.name !== "string") {
144
+ return jsonError(400, "Field 'name' must be a string");
145
+ }
146
+ if (
147
+ body.description !== undefined &&
148
+ body.description !== null &&
149
+ typeof body.description !== "string"
150
+ ) {
151
+ return jsonError(400, "Field 'description' must be a string or null");
152
+ }
153
+ if (body.isDefault !== undefined && typeof body.isDefault !== "boolean") {
154
+ return jsonError(400, "Field 'isDefault' must be a boolean");
155
+ }
156
+
157
+ const hasUpdates = hasAnyDefinedField(body, ["name", "description", "isDefault"]);
158
+ if (!hasUpdates) {
159
+ return jsonError(422, "At least one field must be provided: name, description, or isDefault");
160
+ }
161
+
162
+ try {
163
+ const updates: any = {};
164
+ if (body.name !== undefined) updates.name = body.name;
165
+ if (body.description !== undefined) updates.description = body.description;
166
+ if (body.isDefault !== undefined) updates.isDefault = body.isDefault;
167
+
168
+ ctx.db.updatePolicySet(setId, updates);
169
+
170
+ const policySet = ctx.db.getPolicySet(setId);
171
+ const members = ctx.db.getPolicySetMembers(setId);
172
+
173
+ return Response.json({ policySet: { ...policySet, policies: members } });
174
+ } catch (error) {
175
+ if (isSqliteUniqueConstraintError(error)) {
176
+ return jsonError(409, `Policy set name already exists: ${body.name}`);
177
+ }
178
+ return jsonError(500, getErrorMessage(error));
179
+ }
180
+ }
181
+
182
+ /**
183
+ * DELETE /policy-sets/:id - Delete a policy set
184
+ */
185
+ export async function handleDeletePolicySet(
186
+ _req: Request,
187
+ ctx: RouteContext,
188
+ setId: string
189
+ ): Promise<Response> {
190
+ const existing = ctx.db.getPolicySet(setId);
191
+ if (!existing) {
192
+ return jsonError(404, `Policy set not found: ${setId}`);
193
+ }
194
+
195
+ try {
196
+ ctx.db.deletePolicySet(setId);
197
+ return Response.json({ success: true });
198
+ } catch (error) {
199
+ return jsonError(500, getErrorMessage(error));
200
+ }
201
+ }
202
+
203
+ /**
204
+ * POST /policy-sets/:id/policies - Add a policy to a set
205
+ */
206
+ export async function handleAddPolicyToSet(
207
+ req: Request,
208
+ ctx: RouteContext,
209
+ setId: string
210
+ ): Promise<Response> {
211
+ const parsed = await parseJsonBody(req);
212
+ if (!parsed.ok) {
213
+ return parsed.response;
214
+ }
215
+ const body = parsed.body as any;
216
+
217
+ if (!body.policyId) {
218
+ return jsonError(400, "Missing required field: policyId");
219
+ }
220
+ if (typeof body.policyId !== "string") {
221
+ return jsonError(400, "Field 'policyId' must be a string");
222
+ }
223
+
224
+ const set = ctx.db.getPolicySet(setId);
225
+ if (!set) {
226
+ return jsonError(404, `Policy set not found: ${setId}`);
227
+ }
228
+
229
+ const policy = ctx.db.getPolicy(body.policyId);
230
+ if (!policy) {
231
+ return jsonError(404, `Policy not found: ${body.policyId}`);
232
+ }
233
+ if (setContainsPolicy(ctx, setId, body.policyId)) {
234
+ return jsonError(409, `Policy ${body.policyId} is already a member of set ${setId}`);
235
+ }
236
+
237
+ try {
238
+ ctx.db.addPolicyToSet(setId, body.policyId);
239
+ return Response.json({ success: true });
240
+ } catch (error) {
241
+ if (isSqliteForeignKeyConstraintError(error)) {
242
+ return jsonError(422, "Policy set membership references a missing related record");
243
+ }
244
+ return jsonError(500, getErrorMessage(error));
245
+ }
246
+ }
247
+
248
+ /**
249
+ * DELETE /policy-sets/:id/policies/:policyId - Remove a policy from a set
250
+ */
251
+ export async function handleRemovePolicyFromSet(
252
+ _req: Request,
253
+ ctx: RouteContext,
254
+ setId: string,
255
+ policyId: string
256
+ ): Promise<Response> {
257
+ const set = ctx.db.getPolicySet(setId);
258
+ if (!set) {
259
+ return jsonError(404, `Policy set not found: ${setId}`);
260
+ }
261
+
262
+ const policy = ctx.db.getPolicy(policyId);
263
+ if (!policy) {
264
+ return jsonError(404, `Policy not found: ${policyId}`);
265
+ }
266
+ if (!setContainsPolicy(ctx, setId, policyId)) {
267
+ return jsonError(404, `Policy ${policyId} is not a member of set ${setId}`);
268
+ }
269
+
270
+ try {
271
+ ctx.db.removePolicyFromSet(setId, policyId);
272
+ return Response.json({ success: true });
273
+ } catch (error) {
274
+ return jsonError(500, getErrorMessage(error));
275
+ }
276
+ }
277
+
278
+ /**
279
+ * GET /sessions/:id/policy-sets - Get policy sets applied to a session
280
+ */
281
+ export async function handleGetSessionPolicySets(
282
+ _req: Request,
283
+ ctx: RouteContext,
284
+ sessionId: string
285
+ ): Promise<Response> {
286
+ const session = ctx.db.getSession(sessionId);
287
+ if (!session) {
288
+ return jsonError(404, `Session not found: ${sessionId}`);
289
+ }
290
+
291
+ const policySets = ctx.db.getSessionPolicySets(sessionId);
292
+ return Response.json({ policySets });
293
+ }
294
+
295
+ /**
296
+ * POST /sessions/:id/policy-sets - Apply a policy set to a session
297
+ */
298
+ export async function handleApplyPolicySetToSession(
299
+ req: Request,
300
+ ctx: RouteContext,
301
+ sessionId: string
302
+ ): Promise<Response> {
303
+ const parsed = await parseJsonBody(req);
304
+ if (!parsed.ok) {
305
+ return parsed.response;
306
+ }
307
+ const body = parsed.body as any;
308
+
309
+ if (!body.policySetId) {
310
+ return jsonError(400, "Missing required field: policySetId");
311
+ }
312
+ if (typeof body.policySetId !== "string") {
313
+ return jsonError(400, "Field 'policySetId' must be a string");
314
+ }
315
+
316
+ const session = ctx.db.getSession(sessionId);
317
+ if (!session) {
318
+ return jsonError(404, `Session not found: ${sessionId}`);
319
+ }
320
+ const set = ctx.db.getPolicySet(body.policySetId);
321
+ if (!set) {
322
+ return jsonError(404, `Policy set not found: ${body.policySetId}`);
323
+ }
324
+ if (sessionHasPolicySet(ctx, sessionId, body.policySetId)) {
325
+ return jsonError(
326
+ 409,
327
+ `Policy set ${body.policySetId} is already applied to session ${sessionId}`
328
+ );
329
+ }
330
+
331
+ try {
332
+ ctx.db.applyPolicySetToSession(sessionId, body.policySetId);
333
+ return Response.json({ success: true });
334
+ } catch (error) {
335
+ if (isSqliteForeignKeyConstraintError(error)) {
336
+ return jsonError(422, "Session policy set references a missing related record");
337
+ }
338
+ return jsonError(500, getErrorMessage(error));
339
+ }
340
+ }
341
+
342
+ /**
343
+ * DELETE /sessions/:id/policy-sets/:setId - Remove a policy set from a session
344
+ */
345
+ export async function handleRemovePolicySetFromSession(
346
+ _req: Request,
347
+ ctx: RouteContext,
348
+ sessionId: string,
349
+ setId: string
350
+ ): Promise<Response> {
351
+ const session = ctx.db.getSession(sessionId);
352
+ if (!session) {
353
+ return jsonError(404, `Session not found: ${sessionId}`);
354
+ }
355
+
356
+ const set = ctx.db.getPolicySet(setId);
357
+ if (!set) {
358
+ return jsonError(404, `Policy set not found: ${setId}`);
359
+ }
360
+ if (!sessionHasPolicySet(ctx, sessionId, setId)) {
361
+ return jsonError(404, `Policy set ${setId} is not applied to session ${sessionId}`);
362
+ }
363
+
364
+ try {
365
+ ctx.db.removePolicySetFromSession(sessionId, setId);
366
+ return Response.json({ success: true });
367
+ } catch (error) {
368
+ return jsonError(500, getErrorMessage(error));
369
+ }
370
+ }
371
+
372
+ /**
373
+ * GET /sessions/:id/effective-policies - Get resolved policies for a session
374
+ */
375
+ export async function handleGetEffectivePolicies(
376
+ _req: Request,
377
+ ctx: RouteContext,
378
+ sessionId: string
379
+ ): Promise<Response> {
380
+ const session = ctx.db.getSession(sessionId);
381
+ if (!session) {
382
+ return jsonError(404, `Session not found: ${sessionId}`);
383
+ }
384
+
385
+ const policies = ctx.db.getEffectivePolicies(sessionId);
386
+ return Response.json({ policies });
387
+ }
388
+
389
+ /**
390
+ * GET /policy-decisions - List all policy decisions (audit log)
391
+ */
392
+ export async function handleListPolicyDecisions(
393
+ req: Request,
394
+ ctx: RouteContext
395
+ ): Promise<Response> {
396
+ const url = new URL(req.url);
397
+ const sessionId = url.searchParams.get("sessionId");
398
+ const decisionParam = url.searchParams.get("decision");
399
+ if (decisionParam !== null && !VALID_POLICY_DECISIONS.has(decisionParam)) {
400
+ return jsonError(422, "Query param 'decision' must be one of: allow, deny, ask");
401
+ }
402
+ const decision = decisionParam as "allow" | "deny" | "ask" | null;
403
+
404
+ const limitParam = url.searchParams.get("limit");
405
+ const limit = limitParam ? Number.parseInt(limitParam, 10) : 100;
406
+ if (!Number.isInteger(limit) || limit <= 0 || limit > 1000) {
407
+ return jsonError(422, "Query param 'limit' must be an integer between 1 and 1000");
408
+ }
409
+
410
+ if (sessionId) {
411
+ const decisions = ctx.db.getPolicyDecisionsBySessionId(sessionId, {
412
+ decision: decision || undefined,
413
+ limit,
414
+ });
415
+ return Response.json({ decisions });
416
+ }
417
+
418
+ // All decisions across sessions (query raw)
419
+ const bunDb = (ctx.db as any).db;
420
+ let sql = "SELECT * FROM policy_decisions WHERE 1=1";
421
+ const values: unknown[] = [];
422
+
423
+ if (decision) {
424
+ sql += " AND decision = ?";
425
+ values.push(decision);
426
+ }
427
+
428
+ sql += " ORDER BY id DESC LIMIT ?";
429
+ values.push(limit);
430
+
431
+ const rows = bunDb.prepare(sql).all(...values) as any[];
432
+ const decisions = rows.map((row: any) => ({
433
+ id: row.id,
434
+ sessionId: row.session_id,
435
+ eventId: row.event_id ?? undefined,
436
+ policyId: row.policy_id ?? undefined,
437
+ toolName: row.tool_name,
438
+ args: row.args_json ? JSON.parse(row.args_json) : undefined,
439
+ decision: row.decision,
440
+ reason: row.reason ?? undefined,
441
+ timestamp: new Date(row.timestamp),
442
+ }));
443
+
444
+ return Response.json({ decisions });
445
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Shared helpers for consistent API route validation and error responses.
3
+ */
4
+
5
+ export function jsonError(
6
+ status: number,
7
+ message: string,
8
+ extra?: Record<string, unknown>
9
+ ): Response {
10
+ return Response.json(extra ? { error: message, ...extra } : { error: message }, { status });
11
+ }
12
+
13
+ export type ParsedJsonBody<T> = { ok: true; body: T } | { ok: false; response: Response };
14
+
15
+ export async function parseJsonBody<T = any>(req: Request): Promise<ParsedJsonBody<T>> {
16
+ try {
17
+ return { ok: true, body: (await req.json()) as T };
18
+ } catch {
19
+ return { ok: false, response: jsonError(400, "Invalid JSON in request body") };
20
+ }
21
+ }
22
+
23
+ export function hasAnyDefinedField(
24
+ body: Record<string, unknown>,
25
+ fields: readonly string[]
26
+ ): boolean {
27
+ return fields.some((field) => body[field] !== undefined);
28
+ }