@hachej/boring-governance 0.1.71 → 0.1.73
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/README.md +54 -1
- package/dist/front/index.d.ts +1 -0
- package/dist/front/index.js +3 -1
- package/dist/scripts/governance-access-matrix.d.ts +1 -0
- package/dist/scripts/governance-access-matrix.js +350 -0
- package/dist/server/index.d.ts +14 -9
- package/dist/server/index.js +446 -97
- package/package.json +18 -11
package/dist/server/index.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
// src/server/index.ts
|
|
2
|
+
import path3 from "path";
|
|
3
|
+
|
|
1
4
|
// src/server/loadPolicy.ts
|
|
2
5
|
import { readFile, stat } from "fs/promises";
|
|
3
6
|
import { parse } from "yaml";
|
|
@@ -16,25 +19,25 @@ function normalizePolicyEmail(value) {
|
|
|
16
19
|
function isRecord(value) {
|
|
17
20
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
18
21
|
}
|
|
19
|
-
function nonEmptyString(value,
|
|
22
|
+
function nonEmptyString(value, path4) {
|
|
20
23
|
if (typeof value !== "string" || value.trim().length === 0) {
|
|
21
|
-
fail(`${
|
|
24
|
+
fail(`${path4} must be a non-empty string`);
|
|
22
25
|
}
|
|
23
26
|
return value.trim();
|
|
24
27
|
}
|
|
25
|
-
function uuidString(value,
|
|
26
|
-
const id = nonEmptyString(value,
|
|
28
|
+
function uuidString(value, path4) {
|
|
29
|
+
const id = nonEmptyString(value, path4);
|
|
27
30
|
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)) {
|
|
28
|
-
fail(`${
|
|
31
|
+
fail(`${path4} must be a UUID workspace id`);
|
|
29
32
|
}
|
|
30
33
|
return id;
|
|
31
34
|
}
|
|
32
|
-
function finiteNumber(value,
|
|
35
|
+
function finiteNumber(value, path4, opts) {
|
|
33
36
|
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
34
|
-
fail(`${
|
|
37
|
+
fail(`${path4} must be a finite number`);
|
|
35
38
|
}
|
|
36
39
|
if (opts.allowZero ? value < opts.min : value <= opts.min) {
|
|
37
|
-
fail(`${
|
|
40
|
+
fail(`${path4} must be ${opts.allowZero ? ">=" : ">"} ${opts.min}`);
|
|
38
41
|
}
|
|
39
42
|
return value;
|
|
40
43
|
}
|
|
@@ -96,42 +99,42 @@ function hasTopLevelAlternation(pattern) {
|
|
|
96
99
|
}
|
|
97
100
|
return false;
|
|
98
101
|
}
|
|
99
|
-
function validateSegmentSafePattern(pattern,
|
|
102
|
+
function validateSegmentSafePattern(pattern, path4) {
|
|
100
103
|
const { prefix, consumed, unsafeEscape } = literalPathPrefix(pattern);
|
|
101
|
-
if (unsafeEscape) fail(`${
|
|
104
|
+
if (unsafeEscape) fail(`${path4} must use only literal regex escapes before the path-segment boundary`);
|
|
102
105
|
if (!prefix) {
|
|
103
106
|
const remainder2 = pattern.slice("^/".length);
|
|
104
107
|
if (remainder2.startsWith(".*") || remainder2.startsWith("$")) return;
|
|
105
|
-
fail(`${
|
|
108
|
+
fail(`${path4} must start with a literal path segment or ^/.*`);
|
|
106
109
|
}
|
|
107
110
|
const remainder = pattern.slice("^/".length + consumed);
|
|
108
111
|
if (prefix.endsWith("/")) {
|
|
109
|
-
if (/^[?*+{]/.test(remainder)) fail(`${
|
|
112
|
+
if (/^[?*+{]/.test(remainder)) fail(`${path4} must not make the path-segment boundary slash optional`);
|
|
110
113
|
return;
|
|
111
114
|
}
|
|
112
115
|
if (remainder.startsWith("$")) return;
|
|
113
116
|
for (const guard of ["(?:/|$)", "(?:$|/)", "(/|$)", "($|/)"]) {
|
|
114
117
|
if (!remainder.startsWith(guard)) continue;
|
|
115
118
|
if (/^[?*+{]/.test(remainder.slice(guard.length))) {
|
|
116
|
-
fail(`${
|
|
119
|
+
fail(`${path4} must not make the path-segment boundary guard optional`);
|
|
117
120
|
}
|
|
118
121
|
return;
|
|
119
122
|
}
|
|
120
|
-
fail(`${
|
|
123
|
+
fail(`${path4} must make literal path-prefix grants segment-safe with /, $, or (?:/|$)`);
|
|
121
124
|
}
|
|
122
|
-
function validateRegexPattern(value,
|
|
123
|
-
const pattern = nonEmptyString(value,
|
|
124
|
-
if (pattern.length > MAX_REGEX_LENGTH) fail(`${
|
|
125
|
-
if (!pattern.startsWith("^/")) fail(`${
|
|
126
|
-
if (hasTopLevelAlternation(pattern)) fail(`${
|
|
127
|
-
validateSegmentSafePattern(pattern,
|
|
125
|
+
function validateRegexPattern(value, path4) {
|
|
126
|
+
const pattern = nonEmptyString(value, path4);
|
|
127
|
+
if (pattern.length > MAX_REGEX_LENGTH) fail(`${path4} must be at most ${MAX_REGEX_LENGTH} characters`);
|
|
128
|
+
if (!pattern.startsWith("^/")) fail(`${path4} must start with ^/`);
|
|
129
|
+
if (hasTopLevelAlternation(pattern)) fail(`${path4} must not use top-level regex alternation; add separate allow rules instead`);
|
|
130
|
+
validateSegmentSafePattern(pattern, path4);
|
|
128
131
|
if (/\([^)]*[+*][^)]*\)[+*{]/.test(pattern)) {
|
|
129
|
-
fail(`${
|
|
132
|
+
fail(`${path4} contains an unsafe nested quantifier`);
|
|
130
133
|
}
|
|
131
134
|
try {
|
|
132
135
|
new RegExp(pattern);
|
|
133
136
|
} catch (error) {
|
|
134
|
-
fail(`${
|
|
137
|
+
fail(`${path4} must compile as a JavaScript RegExp: ${error instanceof Error ? error.message : String(error)}`);
|
|
135
138
|
}
|
|
136
139
|
return pattern;
|
|
137
140
|
}
|
|
@@ -139,11 +142,11 @@ function validateModels(value, userPath, defaultMonthlyModelBudgetEur) {
|
|
|
139
142
|
if (value === void 0) return [];
|
|
140
143
|
if (!Array.isArray(value)) fail(`${userPath}.models must be an array`);
|
|
141
144
|
return value.map((entry, index) => {
|
|
142
|
-
const
|
|
143
|
-
if (!isRecord(entry)) fail(`${
|
|
144
|
-
const provider = nonEmptyString(entry.provider, `${
|
|
145
|
-
const id = nonEmptyString(entry.id, `${
|
|
146
|
-
const monthlyBudgetEur = entry.monthlyBudgetEur === void 0 ? defaultMonthlyModelBudgetEur : finiteNumber(entry.monthlyBudgetEur, `${
|
|
145
|
+
const path4 = `${userPath}.models[${index}]`;
|
|
146
|
+
if (!isRecord(entry)) fail(`${path4} must be an object`);
|
|
147
|
+
const provider = nonEmptyString(entry.provider, `${path4}.provider`);
|
|
148
|
+
const id = nonEmptyString(entry.id, `${path4}.id`);
|
|
149
|
+
const monthlyBudgetEur = entry.monthlyBudgetEur === void 0 ? defaultMonthlyModelBudgetEur : finiteNumber(entry.monthlyBudgetEur, `${path4}.monthlyBudgetEur`, {
|
|
147
150
|
min: 0,
|
|
148
151
|
allowZero: true
|
|
149
152
|
});
|
|
@@ -155,6 +158,15 @@ function validateModels(value, userPath, defaultMonthlyModelBudgetEur) {
|
|
|
155
158
|
};
|
|
156
159
|
});
|
|
157
160
|
}
|
|
161
|
+
function validateUserBudgets(value, userPath) {
|
|
162
|
+
if (value === void 0) return { monthlyEur: null, monthlyMicros: null };
|
|
163
|
+
if (!isRecord(value)) fail(`${userPath}.budgets must be an object`);
|
|
164
|
+
const monthlyEur = value.monthlyEur === void 0 ? null : finiteNumber(value.monthlyEur, `${userPath}.budgets.monthlyEur`, { min: 0, allowZero: true });
|
|
165
|
+
return {
|
|
166
|
+
monthlyEur,
|
|
167
|
+
monthlyMicros: monthlyEur === null ? null : Math.round(monthlyEur * MICROS_PER_EUR)
|
|
168
|
+
};
|
|
169
|
+
}
|
|
158
170
|
function validateCompanyContext(value, userPath) {
|
|
159
171
|
if (value === void 0) return { allow: [] };
|
|
160
172
|
if (!isRecord(value)) fail(`${userPath}.companyContext must be an object`);
|
|
@@ -168,8 +180,8 @@ function validateCompanyContext(value, userPath) {
|
|
|
168
180
|
allow: allow.map((pattern, index) => validateRegexPattern(pattern, `${userPath}.companyContext.allow[${index}]`))
|
|
169
181
|
};
|
|
170
182
|
}
|
|
171
|
-
function validateRole(value,
|
|
172
|
-
if (value !== "admin" && value !== "user") fail(`${
|
|
183
|
+
function validateRole(value, path4) {
|
|
184
|
+
if (value !== "admin" && value !== "user") fail(`${path4} must be admin or user`);
|
|
173
185
|
return value;
|
|
174
186
|
}
|
|
175
187
|
function validateGovernancePolicy(input) {
|
|
@@ -184,16 +196,17 @@ function validateGovernancePolicy(input) {
|
|
|
184
196
|
if (!Array.isArray(candidate.users)) fail("users must be an array");
|
|
185
197
|
const usersByEmail = /* @__PURE__ */ new Map();
|
|
186
198
|
const users = candidate.users.map((entry, index) => {
|
|
187
|
-
const
|
|
188
|
-
if (!isRecord(entry)) fail(`${
|
|
189
|
-
const email = normalizePolicyEmail(nonEmptyString(entry.email, `${
|
|
190
|
-
if (!email.includes("@")) fail(`${
|
|
199
|
+
const path4 = `users[${index}]`;
|
|
200
|
+
if (!isRecord(entry)) fail(`${path4} must be an object`);
|
|
201
|
+
const email = normalizePolicyEmail(nonEmptyString(entry.email, `${path4}.email`));
|
|
202
|
+
if (!email.includes("@")) fail(`${path4}.email must be an email address`);
|
|
191
203
|
if (usersByEmail.has(email)) fail(`duplicate user email: ${email}`);
|
|
192
204
|
const user = {
|
|
193
205
|
email,
|
|
194
|
-
role: validateRole(entry.role, `${
|
|
195
|
-
|
|
196
|
-
|
|
206
|
+
role: validateRole(entry.role, `${path4}.role`),
|
|
207
|
+
budgets: validateUserBudgets(entry.budgets, path4),
|
|
208
|
+
models: validateModels(entry.models, path4, defaultMonthlyModelBudgetEur),
|
|
209
|
+
companyContext: validateCompanyContext(entry.companyContext, path4)
|
|
197
210
|
};
|
|
198
211
|
usersByEmail.set(email, user);
|
|
199
212
|
return user;
|
|
@@ -219,13 +232,13 @@ function validateGovernancePolicy(input) {
|
|
|
219
232
|
var MAX_POLICY_BYTES = 256 * 1024;
|
|
220
233
|
var GOVERNANCE_POLICY_PATH_ENV = "BORING_GOVERNANCE_POLICY_PATH";
|
|
221
234
|
var GOVERNANCE_DEV_EMAIL_VERIFICATION_OVERRIDE_ENV = "BORING_GOVERNANCE_ALLOW_UNVERIFIED_EMAIL_DEV";
|
|
222
|
-
function invalidResult(
|
|
235
|
+
function invalidResult(path4, error) {
|
|
223
236
|
return {
|
|
224
237
|
enabled: true,
|
|
225
238
|
policy: null,
|
|
226
239
|
status: {
|
|
227
240
|
state: "invalid",
|
|
228
|
-
path:
|
|
241
|
+
path: path4,
|
|
229
242
|
message: error instanceof Error ? error.message : String(error)
|
|
230
243
|
}
|
|
231
244
|
};
|
|
@@ -348,10 +361,21 @@ var GovernanceService = class {
|
|
|
348
361
|
const grant = userPolicy?.models.find((entry) => entry.provider === model.provider && entry.id === model.id);
|
|
349
362
|
return grant?.monthlyBudgetMicros ?? null;
|
|
350
363
|
}
|
|
364
|
+
userMonthlyBudgetMicros(user) {
|
|
365
|
+
if (!this.loaded.enabled) return null;
|
|
366
|
+
return this.userPolicy(user)?.budgets.monthlyMicros ?? null;
|
|
367
|
+
}
|
|
351
368
|
companyContextRules(user) {
|
|
352
369
|
if (!this.loaded.enabled) return [];
|
|
353
370
|
return this.userPolicy(user)?.companyContext.allow ?? [];
|
|
354
371
|
}
|
|
372
|
+
companyContextAccessForUser(user) {
|
|
373
|
+
if (!this.loaded.enabled) return "none";
|
|
374
|
+
const policy = this.userPolicy(user);
|
|
375
|
+
if (!policy) return "none";
|
|
376
|
+
if (policy.role === "admin") return "readwrite";
|
|
377
|
+
return policy.companyContext.allow.length > 0 ? "readonly" : "none";
|
|
378
|
+
}
|
|
355
379
|
companyContextWorkspaceId() {
|
|
356
380
|
return this.enabledPolicy()?.tenant.companyContextWorkspaceId ?? null;
|
|
357
381
|
}
|
|
@@ -382,6 +406,7 @@ var GovernanceService = class {
|
|
|
382
406
|
email: entry.email,
|
|
383
407
|
role: entry.role,
|
|
384
408
|
modelCount: entry.models.length,
|
|
409
|
+
monthlyBudgetEur: entry.budgets.monthlyEur,
|
|
385
410
|
contextRuleCount: entry.companyContext.allow.length
|
|
386
411
|
})),
|
|
387
412
|
models: policy.users.flatMap((entry) => entry.models.map((model) => ({ ...model, email: entry.email }))),
|
|
@@ -516,9 +541,7 @@ async function reconcileCompanyContextWorkspace(app, service) {
|
|
|
516
541
|
|
|
517
542
|
// src/server/metering.ts
|
|
518
543
|
import { ErrorCode } from "@hachej/boring-agent/shared";
|
|
519
|
-
import
|
|
520
|
-
var PostgresModelBudgetStore2 = coreServer.PostgresModelBudgetStore;
|
|
521
|
-
var ModelBudgetExceededError2 = coreServer.ModelBudgetExceededError;
|
|
544
|
+
import { ModelBudgetExceededError, PostgresBudgetReservationStore, UserBudgetExceededError } from "@hachej/boring-core/server";
|
|
522
545
|
var DEFAULT_HOLD_TTL_SECONDS = 60 * 60;
|
|
523
546
|
function authRequiredError() {
|
|
524
547
|
return Object.assign(new Error("authentication required"), { statusCode: 401, code: ErrorCode.enum.UNAUTHORIZED });
|
|
@@ -530,7 +553,7 @@ function policyDeniedError() {
|
|
|
530
553
|
return Object.assign(new Error("model is not allowed by governance policy"), { statusCode: 403, code: ErrorCode.enum.TOOL_INVALID_INPUT });
|
|
531
554
|
}
|
|
532
555
|
function budgetError(error) {
|
|
533
|
-
if (typeof
|
|
556
|
+
if (typeof ModelBudgetExceededError === "function" && error instanceof ModelBudgetExceededError) return error;
|
|
534
557
|
return error instanceof Error ? error : new Error(String(error));
|
|
535
558
|
}
|
|
536
559
|
function runKey(input) {
|
|
@@ -538,8 +561,8 @@ function runKey(input) {
|
|
|
538
561
|
}
|
|
539
562
|
function createGovernanceMeteringSink(options) {
|
|
540
563
|
let store;
|
|
541
|
-
const getStore = () => store ??= new
|
|
542
|
-
const
|
|
564
|
+
const getStore = () => store ??= new PostgresBudgetReservationStore(options.getDb(), { eligibleLegacySources: ["pi-chat", "pi-chat-fallback", "pi-chat-expired"] });
|
|
565
|
+
const admissionsByRun = /* @__PURE__ */ new Map();
|
|
543
566
|
const holdTtlSeconds = options.holdTtlSeconds ?? DEFAULT_HOLD_TTL_SECONDS;
|
|
544
567
|
async function reserveGovernance(input) {
|
|
545
568
|
if (!options.service.isEnabled()) return void 0;
|
|
@@ -552,35 +575,59 @@ function createGovernanceMeteringSink(options) {
|
|
|
552
575
|
} catch {
|
|
553
576
|
throw policyDeniedError();
|
|
554
577
|
}
|
|
555
|
-
const
|
|
556
|
-
if (
|
|
578
|
+
const modelBudgetMicros = options.service.monthlyBudgetMicros(user, { provider: input.model.provider, id: input.model.id });
|
|
579
|
+
if (modelBudgetMicros === null || modelBudgetMicros <= 0) throw new ModelBudgetExceededError(0, 0, modelBudgetMicros ?? 0, 0);
|
|
557
580
|
const policy = options.service.policy();
|
|
558
581
|
const holdMicros = policy?.tenant.perRunHoldMicros ?? 1e6;
|
|
559
|
-
const
|
|
582
|
+
const now = /* @__PURE__ */ new Date();
|
|
583
|
+
let userReservationInput;
|
|
584
|
+
const userBudgetMicros = options.service.userMonthlyBudgetMicros(user);
|
|
585
|
+
if (userBudgetMicros !== null) {
|
|
586
|
+
if (userBudgetMicros <= 0) throw new (UserBudgetExceededError ?? ModelBudgetExceededError)(0, 0, userBudgetMicros, 0);
|
|
587
|
+
userReservationInput = {
|
|
588
|
+
scope: "user",
|
|
589
|
+
userId: input.userId,
|
|
590
|
+
workspaceId: input.workspaceId,
|
|
591
|
+
sessionId: input.sessionId,
|
|
592
|
+
runId: input.runId,
|
|
593
|
+
budgetMicros: userBudgetMicros,
|
|
594
|
+
holdMicros,
|
|
595
|
+
ttlSeconds: holdTtlSeconds,
|
|
596
|
+
now
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
const modelReservationInput = {
|
|
600
|
+
scope: "model",
|
|
560
601
|
userId: input.userId,
|
|
561
602
|
workspaceId: input.workspaceId,
|
|
562
603
|
sessionId: input.sessionId,
|
|
563
604
|
runId: input.runId,
|
|
564
605
|
provider: input.model.provider,
|
|
565
606
|
model: input.model.id,
|
|
566
|
-
budgetMicros,
|
|
607
|
+
budgetMicros: modelBudgetMicros,
|
|
567
608
|
holdMicros,
|
|
568
|
-
ttlSeconds: holdTtlSeconds
|
|
569
|
-
|
|
609
|
+
ttlSeconds: holdTtlSeconds,
|
|
610
|
+
now
|
|
611
|
+
};
|
|
612
|
+
const admission = await getStore().reserveAdmission({ user: userReservationInput, model: modelReservationInput }).catch((error) => {
|
|
570
613
|
throw budgetError(error);
|
|
571
614
|
});
|
|
572
615
|
const key = runKey(input);
|
|
573
|
-
if (key)
|
|
574
|
-
return
|
|
616
|
+
if (key) admissionsByRun.set(key, admission);
|
|
617
|
+
return admission;
|
|
575
618
|
}
|
|
576
619
|
async function finishGovernance(input, action) {
|
|
577
620
|
if (!options.service.isEnabled()) return;
|
|
578
621
|
const key = runKey(input);
|
|
579
|
-
const
|
|
580
|
-
|
|
581
|
-
if (
|
|
582
|
-
|
|
583
|
-
|
|
622
|
+
const admission = key ? admissionsByRun.get(key) : void 0;
|
|
623
|
+
const status = action === "settle" ? "settled" : "released";
|
|
624
|
+
if (admission) {
|
|
625
|
+
await getStore().finishAdmission(admission, status);
|
|
626
|
+
if (key) admissionsByRun.delete(key);
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
if (!input.userId || !input.runId) return;
|
|
630
|
+
await getStore().finishRun({ runId: input.runId, userId: input.userId }, status);
|
|
584
631
|
}
|
|
585
632
|
function releaseConsumesBudget(input) {
|
|
586
633
|
return input.reason === "usage-write-failed" || input.reason === "fallback-hold-charge";
|
|
@@ -588,21 +635,24 @@ function createGovernanceMeteringSink(options) {
|
|
|
588
635
|
return {
|
|
589
636
|
isEnabled: () => options.service.isEnabled() || options.delegate.isEnabled?.() !== false,
|
|
590
637
|
async reserveRun(input) {
|
|
591
|
-
const
|
|
638
|
+
const admission = await reserveGovernance(input);
|
|
592
639
|
try {
|
|
593
640
|
return await options.delegate.reserveRun(input);
|
|
594
641
|
} catch (error) {
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
642
|
+
try {
|
|
643
|
+
if (admission) await getStore().releaseCreated(admission);
|
|
644
|
+
} catch {
|
|
645
|
+
} finally {
|
|
646
|
+
const key = runKey(input);
|
|
647
|
+
if (key) admissionsByRun.delete(key);
|
|
648
|
+
}
|
|
599
649
|
throw error;
|
|
600
650
|
}
|
|
601
651
|
},
|
|
602
652
|
recordUsage(input) {
|
|
603
653
|
const key = runKey(input);
|
|
604
|
-
const
|
|
605
|
-
return options.delegate.recordUsage(
|
|
654
|
+
const admission = key ? admissionsByRun.get(key) : void 0;
|
|
655
|
+
return options.delegate.recordUsage(admission ? { ...input, metadata: { ...input.metadata ?? {}, ...getStore().metadataForAdmission(admission) } } : input);
|
|
606
656
|
},
|
|
607
657
|
async settleRun(input) {
|
|
608
658
|
try {
|
|
@@ -627,10 +677,10 @@ function createGovernanceMeteringSink(options) {
|
|
|
627
677
|
}
|
|
628
678
|
|
|
629
679
|
// src/server/filesystemBindings.ts
|
|
630
|
-
import { constants } from "fs";
|
|
631
|
-
import { access, chmod, copyFile, lstat, mkdir, mkdtemp, readdir, realpath, rm, stat as
|
|
680
|
+
import { constants as constants2 } from "fs";
|
|
681
|
+
import { access, chmod, copyFile, lstat as lstat2, mkdir as mkdir2, mkdtemp, readdir as readdir2, realpath as realpath2, rm as rm2, stat as stat3 } from "fs/promises";
|
|
632
682
|
import os from "os";
|
|
633
|
-
import
|
|
683
|
+
import path2 from "path";
|
|
634
684
|
import { createLogger } from "@hachej/boring-agent/server";
|
|
635
685
|
import { ErrorCode as ErrorCode2 } from "@hachej/boring-agent/shared";
|
|
636
686
|
import {
|
|
@@ -638,6 +688,260 @@ import {
|
|
|
638
688
|
ScopedFilesystemRuntimeBindingManager,
|
|
639
689
|
createReadonlyProjectionOperations
|
|
640
690
|
} from "@hachej/boring-bash/server";
|
|
691
|
+
|
|
692
|
+
// src/server/companyContextStore.ts
|
|
693
|
+
import { constants } from "fs";
|
|
694
|
+
import { lstat, mkdir, open, readdir, readFile as readFile2, realpath, rename, rm, stat as stat2 } from "fs/promises";
|
|
695
|
+
import path from "path";
|
|
696
|
+
import { randomUUID } from "crypto";
|
|
697
|
+
import lockfile from "proper-lockfile";
|
|
698
|
+
var COMPANY_CONTEXT_CONFLICT_CODE = "conflict";
|
|
699
|
+
var CompanyContextConflictError = class extends Error {
|
|
700
|
+
constructor(details) {
|
|
701
|
+
super(details.currentMtimeMs === void 0 ? "file no longer exists" : "file has been modified since last read");
|
|
702
|
+
this.details = details;
|
|
703
|
+
this.name = "CompanyContextConflictError";
|
|
704
|
+
}
|
|
705
|
+
details;
|
|
706
|
+
statusCode = 409;
|
|
707
|
+
code = COMPANY_CONTEXT_CONFLICT_CODE;
|
|
708
|
+
};
|
|
709
|
+
var COMPANY_CONTEXT_STATE_DIR = ".boring-governance";
|
|
710
|
+
var mutationQueues = /* @__PURE__ */ new Map();
|
|
711
|
+
function normalizeCompanyPath(value) {
|
|
712
|
+
const normalized = value.replace(/\\/g, "/");
|
|
713
|
+
if (normalized.includes("\0")) throw Object.assign(new Error("invalid company path"), { code: "EPERM" });
|
|
714
|
+
const withRoot = normalized.startsWith("/") ? normalized : `/${normalized}`;
|
|
715
|
+
const parts = withRoot.split("/").filter(Boolean);
|
|
716
|
+
if (parts.some((part) => part === "." || part === ".." || part === COMPANY_CONTEXT_STATE_DIR)) {
|
|
717
|
+
throw Object.assign(new Error("company path traversal is not allowed"), { code: "EPERM" });
|
|
718
|
+
}
|
|
719
|
+
return `/${parts.join("/")}`;
|
|
720
|
+
}
|
|
721
|
+
function assertContained(root, candidate) {
|
|
722
|
+
const relative = path.relative(root, candidate);
|
|
723
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
724
|
+
throw Object.assign(new Error("path escapes company context root"), { code: "EPERM" });
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
async function inspectExistingPath(root, target, allowMissingLeaf) {
|
|
728
|
+
assertContained(root, target);
|
|
729
|
+
const relative = path.relative(root, target);
|
|
730
|
+
let current = root;
|
|
731
|
+
const parts = relative.split(path.sep).filter(Boolean);
|
|
732
|
+
for (let index = 0; index < parts.length; index += 1) {
|
|
733
|
+
current = path.join(current, parts[index]);
|
|
734
|
+
try {
|
|
735
|
+
const entry = await lstat(current);
|
|
736
|
+
if (entry.isSymbolicLink()) throw Object.assign(new Error("company context symlinks are not allowed"), { code: "EPERM" });
|
|
737
|
+
if (index < parts.length - 1 && !entry.isDirectory()) {
|
|
738
|
+
throw Object.assign(new Error("company context parent is not a directory"), { code: "ENOTDIR" });
|
|
739
|
+
}
|
|
740
|
+
} catch (error) {
|
|
741
|
+
if (error.code === "ENOENT" && allowMissingLeaf && index === parts.length - 1) return;
|
|
742
|
+
throw error;
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
async function acquireFilesystemLock(root) {
|
|
747
|
+
const stateRoot = path.join(root, COMPANY_CONTEXT_STATE_DIR);
|
|
748
|
+
const lockPath = path.join(stateRoot, "mutation.lock");
|
|
749
|
+
await mkdir(stateRoot, { recursive: true, mode: 448 });
|
|
750
|
+
const stateStat = await lstat(stateRoot);
|
|
751
|
+
if (stateStat.isSymbolicLink() || !stateStat.isDirectory()) {
|
|
752
|
+
throw Object.assign(new Error("unsafe company context state directory"), { code: "EPERM" });
|
|
753
|
+
}
|
|
754
|
+
return await lockfile.lock(root, {
|
|
755
|
+
lockfilePath: lockPath,
|
|
756
|
+
realpath: false,
|
|
757
|
+
stale: 3e4,
|
|
758
|
+
update: 5e3,
|
|
759
|
+
retries: { retries: 100, minTimeout: 25, maxTimeout: 100 }
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
async function withMutationLock(key, operation) {
|
|
763
|
+
const previous = mutationQueues.get(key) ?? Promise.resolve();
|
|
764
|
+
let releaseProcessLock;
|
|
765
|
+
const current = new Promise((resolve) => {
|
|
766
|
+
releaseProcessLock = resolve;
|
|
767
|
+
});
|
|
768
|
+
const queued = previous.then(() => current);
|
|
769
|
+
mutationQueues.set(key, queued);
|
|
770
|
+
await previous;
|
|
771
|
+
let releaseFilesystemLock = null;
|
|
772
|
+
try {
|
|
773
|
+
releaseFilesystemLock = await acquireFilesystemLock(key);
|
|
774
|
+
return await operation();
|
|
775
|
+
} finally {
|
|
776
|
+
await releaseFilesystemLock?.().catch(() => {
|
|
777
|
+
});
|
|
778
|
+
releaseProcessLock();
|
|
779
|
+
if (mutationQueues.get(key) === queued) mutationQueues.delete(key);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
var CompanyContextStore = class _CompanyContextStore {
|
|
783
|
+
constructor(root) {
|
|
784
|
+
this.root = root;
|
|
785
|
+
}
|
|
786
|
+
root;
|
|
787
|
+
static async open(root) {
|
|
788
|
+
const canonicalRoot = await realpath(path.resolve(root));
|
|
789
|
+
const rootStat = await stat2(canonicalRoot);
|
|
790
|
+
if (!rootStat.isDirectory()) throw new Error("company context root is not a directory");
|
|
791
|
+
return new _CompanyContextStore(canonicalRoot);
|
|
792
|
+
}
|
|
793
|
+
resolve(companyPath) {
|
|
794
|
+
const normalized = normalizeCompanyPath(companyPath);
|
|
795
|
+
const target = path.resolve(this.root, `.${normalized}`);
|
|
796
|
+
assertContained(this.root, target);
|
|
797
|
+
return { root: this.root, target };
|
|
798
|
+
}
|
|
799
|
+
async read(companyPath) {
|
|
800
|
+
const { target } = this.resolve(companyPath);
|
|
801
|
+
await inspectExistingPath(this.root, target, false);
|
|
802
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
803
|
+
const before = await stat2(target);
|
|
804
|
+
if (!before.isFile()) throw Object.assign(new Error("company context path is not a file"), { code: "EISDIR" });
|
|
805
|
+
const content = await readFile2(target, "utf8");
|
|
806
|
+
const after = await stat2(target);
|
|
807
|
+
if (before.mtimeMs === after.mtimeMs && before.size === after.size) return { content, mtimeMs: after.mtimeMs };
|
|
808
|
+
}
|
|
809
|
+
throw Object.assign(new Error("company context changed while reading"), { statusCode: 409, code: COMPANY_CONTEXT_CONFLICT_CODE });
|
|
810
|
+
}
|
|
811
|
+
async list(companyPath) {
|
|
812
|
+
const { target } = this.resolve(companyPath);
|
|
813
|
+
await inspectExistingPath(this.root, target, false);
|
|
814
|
+
const entries = await readdir(target, { withFileTypes: true });
|
|
815
|
+
return {
|
|
816
|
+
entries: entries.filter((entry) => !entry.isSymbolicLink() && entry.name !== COMPANY_CONTEXT_STATE_DIR).map((entry) => entry.name)
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
async walk(companyPath) {
|
|
820
|
+
const normalized = normalizeCompanyPath(companyPath);
|
|
821
|
+
const pending = [normalized];
|
|
822
|
+
const files = [];
|
|
823
|
+
while (pending.length > 0) {
|
|
824
|
+
const current = pending.shift();
|
|
825
|
+
const listed = await this.list(current);
|
|
826
|
+
for (const name of listed.entries) {
|
|
827
|
+
const child = current === "/" ? `/${name}` : `${current}/${name}`;
|
|
828
|
+
const childStat = await this.stat(child);
|
|
829
|
+
if (childStat.isDirectory) pending.push(child);
|
|
830
|
+
else files.push(child);
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
return files;
|
|
834
|
+
}
|
|
835
|
+
async find(companyPath, pattern, options = {}) {
|
|
836
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "\0").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]").replace(/\u0000/g, ".*");
|
|
837
|
+
const matcher = new RegExp(`^${escaped}$`);
|
|
838
|
+
const paths = (await this.walk(companyPath)).filter((entry) => matcher.test(path.posix.basename(entry)) || matcher.test(entry));
|
|
839
|
+
const offset = Math.max(0, options.offset ?? 0);
|
|
840
|
+
return { paths: paths.slice(offset, options.limit === void 0 ? void 0 : offset + Math.max(0, options.limit)) };
|
|
841
|
+
}
|
|
842
|
+
async grep(companyPath, pattern, options = {}) {
|
|
843
|
+
const matcher = new RegExp(pattern);
|
|
844
|
+
const matches = [];
|
|
845
|
+
for (const file of await this.walk(companyPath)) {
|
|
846
|
+
const { content } = await this.read(file);
|
|
847
|
+
for (const [index, line] of content.split(/\r?\n/).entries()) {
|
|
848
|
+
matcher.lastIndex = 0;
|
|
849
|
+
if (matcher.test(line)) matches.push({ path: file, line: index + 1, text: line });
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
const offset = Math.max(0, options.offset ?? 0);
|
|
853
|
+
return { matches: matches.slice(offset, options.limit === void 0 ? void 0 : offset + Math.max(0, options.limit)) };
|
|
854
|
+
}
|
|
855
|
+
async stat(companyPath) {
|
|
856
|
+
const { target } = this.resolve(companyPath);
|
|
857
|
+
await inspectExistingPath(this.root, target, false);
|
|
858
|
+
const entry = await stat2(target);
|
|
859
|
+
return { isDirectory: entry.isDirectory(), mtimeMs: entry.mtimeMs };
|
|
860
|
+
}
|
|
861
|
+
async write(companyPath, content, expectedMtimeMs) {
|
|
862
|
+
const { target } = this.resolve(companyPath);
|
|
863
|
+
return withMutationLock(this.root, async () => {
|
|
864
|
+
await inspectExistingPath(this.root, path.dirname(target), false);
|
|
865
|
+
let currentMtimeMs;
|
|
866
|
+
try {
|
|
867
|
+
await inspectExistingPath(this.root, target, false);
|
|
868
|
+
currentMtimeMs = (await stat2(target)).mtimeMs;
|
|
869
|
+
} catch (error) {
|
|
870
|
+
if (error.code !== "ENOENT") throw error;
|
|
871
|
+
}
|
|
872
|
+
if (expectedMtimeMs !== void 0 && currentMtimeMs !== expectedMtimeMs) {
|
|
873
|
+
throw new CompanyContextConflictError({ currentMtimeMs, expectedMtimeMs });
|
|
874
|
+
}
|
|
875
|
+
const temporary = path.join(path.dirname(target), `.${path.basename(target)}.${randomUUID()}.tmp`);
|
|
876
|
+
let promoted = false;
|
|
877
|
+
try {
|
|
878
|
+
const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 384);
|
|
879
|
+
try {
|
|
880
|
+
await handle.writeFile(content, "utf8");
|
|
881
|
+
await handle.sync();
|
|
882
|
+
} finally {
|
|
883
|
+
await handle.close();
|
|
884
|
+
}
|
|
885
|
+
await rename(temporary, target);
|
|
886
|
+
promoted = true;
|
|
887
|
+
return { mtimeMs: (await stat2(target)).mtimeMs };
|
|
888
|
+
} finally {
|
|
889
|
+
if (!promoted) await rm(temporary, { force: true }).catch(() => {
|
|
890
|
+
});
|
|
891
|
+
}
|
|
892
|
+
});
|
|
893
|
+
}
|
|
894
|
+
async delete(companyPath) {
|
|
895
|
+
const { target } = this.resolve(companyPath);
|
|
896
|
+
await withMutationLock(this.root, async () => {
|
|
897
|
+
await inspectExistingPath(this.root, target, false);
|
|
898
|
+
const entry = await lstat(target);
|
|
899
|
+
if (!entry.isFile()) throw Object.assign(new Error("company context path is not a file"), { code: "EPERM" });
|
|
900
|
+
await rm(target);
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
async mkdir(companyPath, recursive = false) {
|
|
904
|
+
const { target } = this.resolve(companyPath);
|
|
905
|
+
await withMutationLock(this.root, async () => {
|
|
906
|
+
if (!recursive) {
|
|
907
|
+
await inspectExistingPath(this.root, path.dirname(target), false);
|
|
908
|
+
await mkdir(target);
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
const relative = path.relative(this.root, target);
|
|
912
|
+
let current = this.root;
|
|
913
|
+
for (const segment of relative.split(path.sep).filter(Boolean)) {
|
|
914
|
+
current = path.join(current, segment);
|
|
915
|
+
try {
|
|
916
|
+
const entry = await lstat(current);
|
|
917
|
+
if (entry.isSymbolicLink() || !entry.isDirectory()) {
|
|
918
|
+
throw Object.assign(new Error("unsafe company context directory"), { code: "EPERM" });
|
|
919
|
+
}
|
|
920
|
+
} catch (error) {
|
|
921
|
+
if (error.code !== "ENOENT") throw error;
|
|
922
|
+
await mkdir(current);
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
});
|
|
926
|
+
}
|
|
927
|
+
async move(fromPath, toPath) {
|
|
928
|
+
const from = this.resolve(fromPath).target;
|
|
929
|
+
const to = this.resolve(toPath).target;
|
|
930
|
+
await withMutationLock(this.root, async () => {
|
|
931
|
+
await inspectExistingPath(this.root, from, false);
|
|
932
|
+
await inspectExistingPath(this.root, path.dirname(to), false);
|
|
933
|
+
try {
|
|
934
|
+
await inspectExistingPath(this.root, to, false);
|
|
935
|
+
throw Object.assign(new Error("destination already exists"), { code: "EEXIST" });
|
|
936
|
+
} catch (error) {
|
|
937
|
+
if (error.code !== "ENOENT") throw error;
|
|
938
|
+
}
|
|
939
|
+
await rename(from, to);
|
|
940
|
+
});
|
|
941
|
+
}
|
|
942
|
+
};
|
|
943
|
+
|
|
944
|
+
// src/server/filesystemBindings.ts
|
|
641
945
|
var COMPANY_CONTEXT_MOUNT_PATH = "/company_context";
|
|
642
946
|
var AGENT_MODE_ENV = "BORING_AGENT_MODE";
|
|
643
947
|
var AGENT_WORKSPACE_ROOT_ENV = "BORING_AGENT_WORKSPACE_ROOT";
|
|
@@ -648,7 +952,7 @@ var companyContextBindingFallbackLogger = {
|
|
|
648
952
|
companyContextBindingCanonicalLogger.error(message, fields);
|
|
649
953
|
}
|
|
650
954
|
};
|
|
651
|
-
function
|
|
955
|
+
function normalizeCompanyPath2(value) {
|
|
652
956
|
const normalized = value.replace(/\\/g, "/");
|
|
653
957
|
if (normalized.includes("\0")) throw new Error("invalid company path");
|
|
654
958
|
const withRoot = normalized.startsWith("/") ? normalized : `/${normalized}`;
|
|
@@ -657,17 +961,18 @@ function normalizeCompanyPath(value) {
|
|
|
657
961
|
return `/${parts.join("/")}`;
|
|
658
962
|
}
|
|
659
963
|
async function assertInsideRoot(root, candidate) {
|
|
660
|
-
const realRoot = await
|
|
661
|
-
const existing = await
|
|
662
|
-
const rel =
|
|
663
|
-
if (rel.startsWith("..") ||
|
|
964
|
+
const realRoot = await realpath2(root);
|
|
965
|
+
const existing = await realpath2(candidate);
|
|
966
|
+
const rel = path2.relative(realRoot, existing);
|
|
967
|
+
if (rel.startsWith("..") || path2.isAbsolute(rel)) throw new Error("path escapes company context root");
|
|
664
968
|
}
|
|
665
969
|
async function walkFiles(root, current = root) {
|
|
666
|
-
const entries = await
|
|
970
|
+
const entries = await readdir2(current, { withFileTypes: true });
|
|
667
971
|
const out = [];
|
|
668
972
|
for (const entry of entries) {
|
|
669
|
-
|
|
670
|
-
const
|
|
973
|
+
if (current === root && entry.name === COMPANY_CONTEXT_STATE_DIR) continue;
|
|
974
|
+
const absolutePath = path2.join(current, entry.name);
|
|
975
|
+
const entryStat = await lstat2(absolutePath);
|
|
671
976
|
if (entryStat.isSymbolicLink()) continue;
|
|
672
977
|
if (entry.isDirectory()) out.push(...await walkFiles(root, absolutePath));
|
|
673
978
|
else if (entry.isFile()) out.push(absolutePath);
|
|
@@ -675,9 +980,9 @@ async function walkFiles(root, current = root) {
|
|
|
675
980
|
return out;
|
|
676
981
|
}
|
|
677
982
|
async function makeProjectionReadonly(root, current = root) {
|
|
678
|
-
const entries = await
|
|
983
|
+
const entries = await readdir2(current, { withFileTypes: true });
|
|
679
984
|
for (const entry of entries) {
|
|
680
|
-
const absolutePath =
|
|
985
|
+
const absolutePath = path2.join(current, entry.name);
|
|
681
986
|
if (entry.isDirectory()) await makeProjectionReadonly(root, absolutePath);
|
|
682
987
|
else if (entry.isFile()) await chmod(absolutePath, 256);
|
|
683
988
|
}
|
|
@@ -686,9 +991,9 @@ async function makeProjectionReadonly(root, current = root) {
|
|
|
686
991
|
async function makeProjectionRemovable(current) {
|
|
687
992
|
await chmod(current, 448).catch(() => {
|
|
688
993
|
});
|
|
689
|
-
const entries = await
|
|
994
|
+
const entries = await readdir2(current, { withFileTypes: true }).catch(() => []);
|
|
690
995
|
for (const entry of entries) {
|
|
691
|
-
const absolutePath =
|
|
996
|
+
const absolutePath = path2.join(current, entry.name);
|
|
692
997
|
if (entry.isDirectory()) await makeProjectionRemovable(absolutePath);
|
|
693
998
|
else await chmod(absolutePath, 384).catch(() => {
|
|
694
999
|
});
|
|
@@ -730,7 +1035,7 @@ var RegexFilteredCompanyContextProvider = class {
|
|
|
730
1035
|
if (handle.lifecycle) handle.lifecycle.active = false;
|
|
731
1036
|
if (handle.projectionRoot) {
|
|
732
1037
|
await makeProjectionRemovable(handle.projectionRoot);
|
|
733
|
-
await
|
|
1038
|
+
await rm2(handle.projectionRoot, { recursive: true, force: true });
|
|
734
1039
|
}
|
|
735
1040
|
}
|
|
736
1041
|
async prepareBinding(_ctx, binding) {
|
|
@@ -738,17 +1043,17 @@ var RegexFilteredCompanyContextProvider = class {
|
|
|
738
1043
|
if (binding.access !== "readonly" || binding.projection !== "policy-filtered") {
|
|
739
1044
|
throw new Error("company_context policy mount only supports readonly policy-filtered bindings");
|
|
740
1045
|
}
|
|
741
|
-
await access(this.sourceRoot,
|
|
742
|
-
const projectionRoot = await mkdtemp(
|
|
1046
|
+
await access(this.sourceRoot, constants2.R_OK);
|
|
1047
|
+
const projectionRoot = await mkdtemp(path2.join(this.projectionRootParent, "boring-company-context-"));
|
|
743
1048
|
try {
|
|
744
1049
|
const files = await walkFiles(this.sourceRoot);
|
|
745
1050
|
for (const sourceFile of files) {
|
|
746
1051
|
await assertInsideRoot(this.sourceRoot, sourceFile);
|
|
747
|
-
const rel =
|
|
748
|
-
const companyPath =
|
|
1052
|
+
const rel = path2.relative(this.sourceRoot, sourceFile).split(path2.sep).join("/");
|
|
1053
|
+
const companyPath = normalizeCompanyPath2(rel);
|
|
749
1054
|
if (!this.allowedRules.some((rule) => rule.test(companyPath))) continue;
|
|
750
|
-
const destination =
|
|
751
|
-
await
|
|
1055
|
+
const destination = path2.join(projectionRoot, ...companyPath.slice(1).split("/"));
|
|
1056
|
+
await mkdir2(path2.dirname(destination), { recursive: true });
|
|
752
1057
|
await copyFile(sourceFile, destination);
|
|
753
1058
|
}
|
|
754
1059
|
await makeProjectionReadonly(projectionRoot);
|
|
@@ -762,7 +1067,7 @@ var RegexFilteredCompanyContextProvider = class {
|
|
|
762
1067
|
};
|
|
763
1068
|
} catch (error) {
|
|
764
1069
|
await makeProjectionRemovable(projectionRoot);
|
|
765
|
-
await
|
|
1070
|
+
await rm2(projectionRoot, { recursive: true, force: true });
|
|
766
1071
|
throw error;
|
|
767
1072
|
}
|
|
768
1073
|
}
|
|
@@ -779,10 +1084,10 @@ function asRuntimeOperations(ops) {
|
|
|
779
1084
|
}
|
|
780
1085
|
function createDefaultCompanyContextRootResolver(env = process.env) {
|
|
781
1086
|
const explicitSourceRoot = env[GOVERNANCE_COMPANY_CONTEXT_ROOT_ENV]?.trim();
|
|
782
|
-
if (explicitSourceRoot) return () =>
|
|
1087
|
+
if (explicitSourceRoot) return () => path2.resolve(explicitSourceRoot);
|
|
783
1088
|
if (env[AGENT_MODE_ENV]?.trim() === "vercel-sandbox") return void 0;
|
|
784
1089
|
const workspaceStorageRoot = env[AGENT_WORKSPACE_ROOT_ENV]?.trim() || process.cwd();
|
|
785
|
-
return (_ctx, companyContextWorkspaceId) =>
|
|
1090
|
+
return (_ctx, companyContextWorkspaceId) => path2.resolve(workspaceStorageRoot, companyContextWorkspaceId);
|
|
786
1091
|
}
|
|
787
1092
|
function logCompanyContextBindingError(ctx, reason, details = {}) {
|
|
788
1093
|
const fields = {
|
|
@@ -814,16 +1119,16 @@ async function resolveCompanyContextSourceRoot(ctx, companyContextWorkspaceId, r
|
|
|
814
1119
|
return null;
|
|
815
1120
|
}
|
|
816
1121
|
try {
|
|
817
|
-
const sourceRoot = await
|
|
818
|
-
const sourceStat = await
|
|
1122
|
+
const sourceRoot = await realpath2(path2.resolve(candidate));
|
|
1123
|
+
const sourceStat = await stat3(sourceRoot);
|
|
819
1124
|
if (!sourceStat.isDirectory()) {
|
|
820
1125
|
logCompanyContextBindingError(ctx, "source_root_not_directory", { companyContextWorkspaceId, sourceRoot });
|
|
821
1126
|
return null;
|
|
822
1127
|
}
|
|
823
|
-
await access(sourceRoot,
|
|
1128
|
+
await access(sourceRoot, constants2.R_OK);
|
|
824
1129
|
return sourceRoot;
|
|
825
1130
|
} catch (error) {
|
|
826
|
-
logCompanyContextBindingError(ctx, "source_root_unavailable", { companyContextWorkspaceId, sourceRoot:
|
|
1131
|
+
logCompanyContextBindingError(ctx, "source_root_unavailable", { companyContextWorkspaceId, sourceRoot: path2.resolve(candidate), err: error });
|
|
827
1132
|
return null;
|
|
828
1133
|
}
|
|
829
1134
|
}
|
|
@@ -832,12 +1137,44 @@ function createGovernanceFilesystemBindings(service, options = {}) {
|
|
|
832
1137
|
if (!service.isEnabled()) return void 0;
|
|
833
1138
|
const user = userFromContext(ctx);
|
|
834
1139
|
if (!user?.id) return [];
|
|
835
|
-
const
|
|
1140
|
+
const policyAccess = service.companyContextAccessForUser(user);
|
|
1141
|
+
const accessMode = policyAccess === "readwrite" && options.allowAdminMutations !== true ? "readonly" : policyAccess;
|
|
836
1142
|
const companyContextWorkspaceId = service.companyContextWorkspaceId();
|
|
837
|
-
if (!companyContextWorkspaceId ||
|
|
1143
|
+
if (!companyContextWorkspaceId || accessMode === "none") return [];
|
|
838
1144
|
if (ctx.workspaceId === companyContextWorkspaceId) return [];
|
|
839
1145
|
const sourceRoot = await resolveCompanyContextSourceRoot(ctx, companyContextWorkspaceId, options.resolveCompanyContextRoot);
|
|
840
1146
|
if (!sourceRoot) return [];
|
|
1147
|
+
if (accessMode === "readwrite") {
|
|
1148
|
+
const store = await CompanyContextStore.open(sourceRoot);
|
|
1149
|
+
const operations2 = {
|
|
1150
|
+
read: (descriptor) => store.read(descriptor.path),
|
|
1151
|
+
list: (descriptor) => store.list(descriptor.path),
|
|
1152
|
+
find: (descriptor, pattern, opOptions) => store.find(descriptor.path, pattern, opOptions),
|
|
1153
|
+
grep: (descriptor, pattern, opOptions) => store.grep(descriptor.path, pattern, opOptions),
|
|
1154
|
+
stat: async (descriptor) => {
|
|
1155
|
+
const result = await store.stat(descriptor.path);
|
|
1156
|
+
return { isDirectory: result.isDirectory, metadata: { mtimeMs: result.mtimeMs } };
|
|
1157
|
+
},
|
|
1158
|
+
write: (descriptor) => store.write(descriptor.path, descriptor.content, descriptor.expectedMtimeMs),
|
|
1159
|
+
delete: async (descriptor) => {
|
|
1160
|
+
await store.delete(descriptor.path);
|
|
1161
|
+
return {};
|
|
1162
|
+
},
|
|
1163
|
+
move: async (descriptor) => {
|
|
1164
|
+
await store.move(descriptor.from, descriptor.to);
|
|
1165
|
+
return {};
|
|
1166
|
+
},
|
|
1167
|
+
mkdir: async (descriptor) => {
|
|
1168
|
+
await store.mkdir(descriptor.path, descriptor.recursive);
|
|
1169
|
+
return {};
|
|
1170
|
+
},
|
|
1171
|
+
rejectMutation(operation) {
|
|
1172
|
+
throw new Error(`company_context ${operation} operation is unavailable`);
|
|
1173
|
+
}
|
|
1174
|
+
};
|
|
1175
|
+
return [{ filesystem: COMPANY_CONTEXT_FILESYSTEM_ID, access: "readwrite", operations: operations2 }];
|
|
1176
|
+
}
|
|
1177
|
+
const rules = service.companyContextRules(user);
|
|
841
1178
|
const projectionRootParent = options.projectionRootParent ?? os.tmpdir();
|
|
842
1179
|
const binding = {
|
|
843
1180
|
filesystem: COMPANY_CONTEXT_FILESYSTEM_ID,
|
|
@@ -885,6 +1222,15 @@ function createGovernanceFilesystemBindings(service, options = {}) {
|
|
|
885
1222
|
async function buildGovernanceService(options = {}) {
|
|
886
1223
|
return createGovernanceService(await loadGovernancePolicy(options));
|
|
887
1224
|
}
|
|
1225
|
+
function defaultAdminMutationsAllowed(options) {
|
|
1226
|
+
if (options.resolveCompanyContextRoot) return false;
|
|
1227
|
+
const companyRoot = process.env.BORING_GOVERNANCE_COMPANY_CONTEXT_ROOT?.trim();
|
|
1228
|
+
if (!companyRoot) return false;
|
|
1229
|
+
const workspaceRoot = path3.resolve(process.env.BORING_AGENT_WORKSPACE_ROOT?.trim() || process.cwd());
|
|
1230
|
+
const resolvedCompanyRoot = path3.resolve(companyRoot);
|
|
1231
|
+
const relative = path3.relative(workspaceRoot, resolvedCompanyRoot);
|
|
1232
|
+
return relative.startsWith("..") || path3.isAbsolute(relative);
|
|
1233
|
+
}
|
|
888
1234
|
async function createGovernance(config) {
|
|
889
1235
|
const service = await buildGovernanceService({ config });
|
|
890
1236
|
return {
|
|
@@ -894,8 +1240,9 @@ async function createGovernance(config) {
|
|
|
894
1240
|
filterModels: createGovernanceModelFilter(service),
|
|
895
1241
|
createMeteringSink: (delegate, getDb) => createGovernanceMeteringSink({ service, delegate, getDb }),
|
|
896
1242
|
getFilesystemBindings: (options = {}) => createGovernanceFilesystemBindings(service, {
|
|
897
|
-
|
|
898
|
-
|
|
1243
|
+
...options,
|
|
1244
|
+
resolveCompanyContextRoot: options.resolveCompanyContextRoot ?? createDefaultCompanyContextRootResolver(),
|
|
1245
|
+
allowAdminMutations: options.allowAdminMutations ?? defaultAdminMutationsAllowed(options)
|
|
899
1246
|
}),
|
|
900
1247
|
pi: { strictModelResolution: service.isEnabled() }
|
|
901
1248
|
};
|
|
@@ -909,6 +1256,8 @@ function createGovernanceModelFilter(service) {
|
|
|
909
1256
|
const user = governanceUserFromRequest(request);
|
|
910
1257
|
if (!service.isEnabled()) return { models, defaultModel };
|
|
911
1258
|
if (!user) return { models: [] };
|
|
1259
|
+
const aggregateBudgetMicros = service.userMonthlyBudgetMicros(user);
|
|
1260
|
+
if (aggregateBudgetMicros !== null && aggregateBudgetMicros <= 0) return { models: [], defaultModel: void 0 };
|
|
912
1261
|
const allowedModels = service.allowedModelsForUser(user, models).filter((model) => (service.monthlyBudgetMicros(user, model) ?? 0) > 0);
|
|
913
1262
|
const allowedDefault = defaultModel && allowedModels.some((model) => model.available && model.provider === defaultModel.provider && model.id === defaultModel.id) ? defaultModel : void 0;
|
|
914
1263
|
return { models: allowedModels, defaultModel: allowedDefault };
|