machine-bridge-mcp 0.15.0 → 0.16.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 (44) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +12 -2
  3. package/SECURITY.md +7 -0
  4. package/browser-extension/manifest.json +2 -2
  5. package/docs/ARCHITECTURE.md +5 -3
  6. package/docs/AUDIT.md +12 -1
  7. package/docs/LOGGING.md +7 -1
  8. package/docs/OPERATIONS.md +9 -2
  9. package/docs/POLICY_REFERENCE.md +88 -0
  10. package/docs/TESTING.md +7 -0
  11. package/package.json +14 -3
  12. package/scripts/coverage-check.mjs +108 -0
  13. package/scripts/generate-policy-reference.mjs +95 -0
  14. package/src/local/agent-context.mjs +1 -103
  15. package/src/local/app-automation.mjs +7 -9
  16. package/src/local/browser-bridge.mjs +26 -156
  17. package/src/local/browser-extension-protocol.mjs +75 -0
  18. package/src/local/browser-pairing-store.mjs +83 -0
  19. package/src/local/call-registry.mjs +113 -0
  20. package/src/local/capability-ranking.mjs +103 -0
  21. package/src/local/cli-local-admin.mjs +308 -0
  22. package/src/local/cli-options.mjs +151 -0
  23. package/src/local/cli-policy.mjs +77 -0
  24. package/src/local/cli.mjs +16 -521
  25. package/src/local/errors.mjs +122 -0
  26. package/src/local/git-service.mjs +88 -0
  27. package/src/local/lifecycle.mjs +64 -0
  28. package/src/local/log.mjs +50 -19
  29. package/src/local/managed-job-plan.mjs +235 -0
  30. package/src/local/managed-jobs.mjs +16 -220
  31. package/src/local/observability.mjs +83 -0
  32. package/src/local/policy.mjs +148 -0
  33. package/src/local/process-execution.mjs +153 -0
  34. package/src/local/process-sessions.mjs +10 -20
  35. package/src/local/process-tracker.mjs +55 -0
  36. package/src/local/runtime.mjs +154 -672
  37. package/src/local/service.mjs +1 -0
  38. package/src/local/stdio.mjs +3 -11
  39. package/src/local/tool-executor.mjs +102 -0
  40. package/src/local/tools.mjs +21 -104
  41. package/src/local/workspace-file-service.mjs +451 -0
  42. package/src/shared/policy-contract.json +54 -0
  43. package/src/shared/tool-catalog.json +4 -4
  44. package/src/worker/index.ts +69 -524
@@ -7,30 +7,29 @@ import { assertStateMaintenanceAvailable, ensureOwnerOnlyDir, ownerOnlyFile } fr
7
7
  import { createExclusiveFileSync, replaceFileAtomicallySync } from "./exclusive-file.mjs";
8
8
  import { currentProcessStartTimeMs, inspectProcessInstance, processStartTimeMs } from "./process-identity.mjs";
9
9
  import { readBoundedRegularFileSync, readBoundedRegularFileWithInfoSync } from "./secure-file.mjs";
10
+ import { createToolAuthorizer } from "./policy.mjs";
11
+ import { BridgeError } from "./errors.mjs";
12
+ import { inspectResourceFile, normalizeResourceRegistry, publicResourceRegistry, validatePlan, validateResourceName } from "./managed-job-plan.mjs";
13
+ export { inspectResourceFile, publicResourceRegistry, validateResourceName } from "./managed-job-plan.mjs";
10
14
 
11
- const RESOURCE_NAME = /^[a-z][a-z0-9._-]{0,63}$/;
12
15
  const JOB_ID = /^job_[A-Za-z0-9_-]{24,}$/;
13
- const RESOURCE_TOKEN = /\{\{resource:([a-z][a-z0-9._-]{0,63})\}\}/g;
14
- const MAX_RESOURCE_BYTES = 1024 * 1024;
15
- const MAX_JOB_RESOURCE_BYTES = 8 * 1024 * 1024;
16
- const MAX_RESOURCES = 64;
17
16
  const MAX_JOBS = 50;
18
17
  const JOB_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
19
18
  const MAX_PLAN_BYTES = 1024 * 1024;
20
- const MAX_TEMPORARY_FILE_BYTES = 512 * 1024;
21
19
  const MAX_RECOVERY_ATTEMPTS = 3;
22
20
  const RUNNER_PATH = fileURLToPath(new URL("./job-runner.mjs", import.meta.url));
23
21
  const ACTIVE_JOB_STATES = new Set(["queued", "running", "cleaning", "interrupted"]);
24
22
  const PLAN_RETAINING_STATES = new Set(["staged", ...ACTIVE_JOB_STATES]);
25
23
 
26
24
  export class ManagedJobManager {
27
- constructor({ jobRoot, workspace, policy, resources = {}, resourceStatePath = "", stateRoot = "", logger = console, recover = true }) {
25
+ constructor({ jobRoot, workspace, policy, authorizeTool = null, resources = {}, resourceStatePath = "", stateRoot = "", logger = console, recover = true }) {
28
26
  const jobRootInput = resolve(jobRoot);
29
27
  ensureOwnerOnlyDir(jobRootInput);
30
28
  this.jobRoot = realpathSync.native ? realpathSync.native(jobRootInput) : realpathSync(jobRootInput);
31
29
  const workspaceInput = resolve(workspace);
32
30
  this.workspace = realpathSync.native ? realpathSync.native(workspaceInput) : realpathSync(workspaceInput);
33
31
  this.policy = policy;
32
+ this.authorizeTool = createToolAuthorizer(this.policy, authorizeTool);
34
33
  this.resources = normalizeResourceRegistry(resources);
35
34
  this.resourceStatePath = resourceStatePath ? resolve(resourceStatePath) : "";
36
35
  this.stateRoot = stateRoot ? resolve(stateRoot) : "";
@@ -62,6 +61,7 @@ export class ManagedJobManager {
62
61
  }
63
62
 
64
63
  listResources() {
64
+ this.authorizeTool("list_local_resources");
65
65
  this.assertMaintenanceAvailable();
66
66
  const resources = [];
67
67
  for (const [name, resource] of Object.entries(this.currentResources()).sort(([a], [b]) => a.localeCompare(b))) {
@@ -69,7 +69,9 @@ export class ManagedJobManager {
69
69
  const inspected = inspectResourceFile(resource.path, { allowInsecurePermissions: resource.allowInsecurePermissions === true });
70
70
  resources.push({ name, kind: "file", available: true, size: inspected.size, mode: inspected.mode });
71
71
  } catch (error) {
72
- resources.push({ name, kind: "file", available: false, error_class: resourceErrorClass(error) });
72
+ const errorClass = resourceErrorClass(error);
73
+ if (errorClass === "resource_unavailable") throw error;
74
+ resources.push({ name, kind: "file", available: false, error_class: errorClass });
73
75
  }
74
76
  }
75
77
  return { resources, count: resources.length, values_exposed: false, paths_exposed: false };
@@ -91,20 +93,20 @@ export class ManagedJobManager {
91
93
 
92
94
 
93
95
  stage(args = {}) {
96
+ this.authorizeTool("stage_job");
94
97
  this.assertMaintenanceAvailable();
95
- if (this.policy.allowWrite !== true) throw new Error("stage_job is disabled by daemon policy");
96
98
  return this.createJob(args, { launch: false });
97
99
  }
98
100
 
99
101
  start(args = {}) {
102
+ this.authorizeTool("start_job");
100
103
  this.assertMaintenanceAvailable();
101
- this.assertEnabled("start_job");
102
104
  return this.createJob(args, { launch: true });
103
105
  }
104
106
 
105
107
  approve(args = {}, { localOperator = false } = {}) {
108
+ if (!localOperator) throw new BridgeError("policy_denied", "job approval is a local-operator-only action");
106
109
  this.assertMaintenanceAvailable();
107
- if (!localOperator) this.assertEnabled("approve_job");
108
110
  const dir = this.jobDir(args.job_id);
109
111
  const transition = acquireJobTransitionLock(dir);
110
112
  if (!transition) throw new Error("job state is being modified by another process; retry after inspecting its current status");
@@ -213,6 +215,7 @@ export class ManagedJobManager {
213
215
 
214
216
 
215
217
  list(args = {}) {
218
+ this.authorizeTool("list_jobs");
216
219
  this.assertMaintenanceAvailable();
217
220
  this.prune();
218
221
  const limit = clampInt(args.limit, 20, 1, MAX_JOBS);
@@ -235,6 +238,7 @@ export class ManagedJobManager {
235
238
  }
236
239
 
237
240
  read(args = {}) {
241
+ this.authorizeTool("read_job");
238
242
  this.assertMaintenanceAvailable();
239
243
  const dir = this.jobDir(args.job_id);
240
244
  this.reconcileStatus(dir);
@@ -261,6 +265,7 @@ export class ManagedJobManager {
261
265
  }
262
266
 
263
267
  cancel(args = {}) {
268
+ this.authorizeTool("cancel_job");
264
269
  this.assertMaintenanceAvailable();
265
270
  const dir = this.jobDir(args.job_id);
266
271
  const transition = acquireJobTransitionLock(dir);
@@ -317,11 +322,6 @@ export class ManagedJobManager {
317
322
  if (this.stateRoot) assertStateMaintenanceAvailable(this.stateRoot);
318
323
  }
319
324
 
320
- assertEnabled(tool) {
321
- if (this.policy.execMode !== "direct" && this.policy.execMode !== "shell") {
322
- throw new Error(`${tool} is disabled by daemon policy`);
323
- }
324
- }
325
325
 
326
326
  jobDir(value) {
327
327
  const id = String(value || "");
@@ -460,210 +460,6 @@ export function loadManagedJobPlan(inputPath) {
460
460
  return value;
461
461
  }
462
462
 
463
- export function validateResourceName(value) {
464
- const name = String(value || "").trim();
465
- if (!RESOURCE_NAME.test(name)) throw new Error("resource name must match [a-z][a-z0-9._-]{0,63}");
466
- return name;
467
- }
468
-
469
- export function inspectResourceFile(inputPath, { allowInsecurePermissions = false, includeHash = false } = {}) {
470
- const path = resolve(String(inputPath || ""));
471
- const canonical = realpathFile(path);
472
- const { buffer: content, info } = readBoundedRegularFileWithInfoSync(canonical, MAX_RESOURCE_BYTES);
473
- if (process.platform !== "win32" && !allowInsecurePermissions && (info.mode & 0o077) !== 0) {
474
- throw new Error("resource file is readable by group or others; restrict permissions or use --allow-insecure-permissions");
475
- }
476
- return {
477
- kind: "file",
478
- path: canonical,
479
- pathAliases: normalizeResourcePathAliases([path, canonical]),
480
- size: info.size,
481
- mode: process.platform === "win32" ? null : `0${(info.mode & 0o777).toString(8)}`,
482
- updatedAt: new Date().toISOString(),
483
- allowInsecurePermissions: allowInsecurePermissions === true,
484
- ...(includeHash ? { sha256: createHash("sha256").update(content).digest("hex") } : {}),
485
- };
486
- }
487
-
488
- export function publicResourceRegistry(resources = {}, { includePaths = false } = {}) {
489
- const normalized = normalizeResourceRegistry(resources);
490
- return Object.fromEntries(Object.entries(normalized).map(([name, value]) => [name, {
491
- kind: value.kind,
492
- size: value.size ?? null,
493
- mode: value.mode ?? null,
494
- updatedAt: value.updatedAt ?? null,
495
- allowInsecurePermissions: value.allowInsecurePermissions === true,
496
- paths_exposed: includePaths,
497
- ...(includePaths ? { path: value.path } : {}),
498
- }]));
499
- }
500
-
501
- function validatePlan(args, context) {
502
- if (!args || typeof args !== "object" || Array.isArray(args)) throw new Error("job arguments must be an object");
503
- const allowed = new Set(["name", "steps", "finally_steps", "temporary_files"]);
504
- for (const key of Object.keys(args)) if (!allowed.has(key)) throw new Error(`job contains unknown field: ${key}`);
505
- const name = String(args.name || "managed job").trim().slice(0, 128) || "managed job";
506
- const steps = validateSteps(args.steps, "steps", context);
507
- const finallySteps = validateSteps(args.finally_steps || [], "finally_steps", context, true);
508
- const temporaryFiles = validateTemporaryFiles(args.temporary_files || []);
509
- if (!steps.length) throw new Error("steps must contain at least one step");
510
- return {
511
- version: 1,
512
- name,
513
- workspace: context.workspace,
514
- full_env: context.fullEnv,
515
- resources: referencedResources([...steps, ...finallySteps], context.resources),
516
- temporary_files: temporaryFiles,
517
- steps,
518
- finally_steps: finallySteps,
519
- };
520
- }
521
-
522
- function validateTemporaryFiles(value) {
523
- if (!Array.isArray(value) || value.length > 16) throw new Error("temporary_files must contain 0-16 files");
524
- const seen = new Set();
525
- let totalBytes = 0;
526
- return value.map((item, index) => {
527
- if (!item || typeof item !== "object" || Array.isArray(item)) throw new Error(`temporary_files[${index}] must be an object`);
528
- for (const key of Object.keys(item)) if (!["name", "content", "executable"].includes(key)) throw new Error(`temporary_files[${index}] contains unknown field: ${key}`);
529
- const name = validateResourceName(item.name);
530
- if (seen.has(name)) throw new Error(`duplicate temporary file name: ${name}`);
531
- seen.add(name);
532
- const content = boundedString(item.content, 256 * 1024, `temporary_files[${index}].content`);
533
- totalBytes += Buffer.byteLength(content);
534
- if (totalBytes > MAX_TEMPORARY_FILE_BYTES) throw new Error(`temporary file contents exceed ${MAX_TEMPORARY_FILE_BYTES} bytes`);
535
- return { name, content, executable: item.executable === true };
536
- });
537
- }
538
-
539
- function validateSteps(value, label, context, allowEmpty = false) {
540
- if (!Array.isArray(value) || (!allowEmpty && value.length === 0) || value.length > 16) {
541
- throw new Error(`${label} must contain ${allowEmpty ? "0-16" : "1-16"} steps`);
542
- }
543
- return value.map((input, index) => {
544
- if (!input || typeof input !== "object" || Array.isArray(input)) throw new Error(`${label}[${index}] must be an object`);
545
- const allowed = new Set(["name", "argv", "cwd", "env", "env_resources", "stdin", "stdin_resource", "timeout_seconds", "allow_failure", "capture_output"]);
546
- for (const key of Object.keys(input)) if (!allowed.has(key)) throw new Error(`${label}[${index}] contains unknown field: ${key}`);
547
- if (!Array.isArray(input.argv) || !input.argv.length || input.argv.length > 256) throw new Error(`${label}[${index}].argv must contain 1-256 strings`);
548
- const argv = input.argv.map((item) => boundedString(item, 16 * 1024, `${label}[${index}].argv`));
549
- if (Buffer.byteLength(JSON.stringify(argv)) > 64 * 1024) throw new Error(`${label}[${index}].argv exceeds 64 KiB`);
550
- const cwd = input.cwd === undefined ? context.workspace : resolveJobCwd(input.cwd, context.workspace, context.unrestrictedPaths);
551
- const env = validateEnv(input.env, `${label}[${index}].env`);
552
- const envResources = validateEnvResources(input.env_resources, `${label}[${index}].env_resources`);
553
- for (const key of Object.keys(envResources)) if (Object.prototype.hasOwnProperty.call(env, key)) throw new Error(`${label}[${index}] duplicates ${key} in env and env_resources`);
554
- const stdin = input.stdin === undefined ? null : boundedString(input.stdin, 256 * 1024, `${label}[${index}].stdin`);
555
- const stdinResource = input.stdin_resource === undefined ? null : validateResourceName(input.stdin_resource);
556
- if (stdin !== null && stdinResource !== null) throw new Error(`${label}[${index}] cannot combine stdin and stdin_resource`);
557
- return {
558
- name: String(input.name || basename(argv[0]) || `step ${index + 1}`).slice(0, 128),
559
- argv,
560
- cwd,
561
- env,
562
- env_resources: envResources,
563
- stdin,
564
- stdin_resource: stdinResource,
565
- timeout_seconds: clampInt(input.timeout_seconds, 600, 1, 3600),
566
- allow_failure: input.allow_failure === true,
567
- capture_output: input.capture_output === "discard" ? "discard" : "redacted",
568
- };
569
- });
570
- }
571
-
572
- function validateEnv(value, label) {
573
- if (value === undefined) return {};
574
- if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object`);
575
- const entries = Object.entries(value);
576
- if (entries.length > 64) throw new Error(`${label} has too many entries`);
577
- const out = {};
578
- for (const [key, raw] of entries) {
579
- if (!/^[A-Za-z_][A-Za-z0-9_]{0,127}$/.test(key)) throw new Error(`${label} contains invalid variable name: ${key}`);
580
- out[key] = boundedString(raw, 16 * 1024, `${label}.${key}`);
581
- }
582
- return out;
583
- }
584
-
585
- function validateEnvResources(value, label) {
586
- if (value === undefined) return {};
587
- if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object`);
588
- const entries = Object.entries(value);
589
- if (entries.length > 32) throw new Error(`${label} has too many entries`);
590
- const out = {};
591
- for (const [key, raw] of entries) {
592
- if (!/^[A-Za-z_][A-Za-z0-9_]{0,127}$/.test(key)) throw new Error(`${label} contains invalid variable name: ${key}`);
593
- out[key] = validateResourceName(raw);
594
- }
595
- return out;
596
- }
597
-
598
- function referencedResources(steps, registry) {
599
- const names = new Set();
600
- for (const step of steps) {
601
- if (step.stdin_resource) names.add(step.stdin_resource);
602
- for (const name of Object.values(step.env_resources || {})) names.add(name);
603
- for (const value of [...step.argv, ...Object.values(step.env)]) {
604
- for (const match of String(value).matchAll(RESOURCE_TOKEN)) names.add(match[1]);
605
- }
606
- }
607
- if (names.size > MAX_RESOURCES) throw new Error(`job references more than ${MAX_RESOURCES} local resources`);
608
- const out = {};
609
- let totalBytes = 0;
610
- for (const name of names) {
611
- const resource = registry[name];
612
- if (!resource) throw new Error(`unknown local resource: ${name}`);
613
- const inspected = inspectResourceFile(resource.path, { allowInsecurePermissions: resource.allowInsecurePermissions === true, includeHash: true });
614
- totalBytes += inspected.size;
615
- if (totalBytes > MAX_JOB_RESOURCE_BYTES) throw new Error(`job resources exceed ${MAX_JOB_RESOURCE_BYTES} bytes`);
616
- out[name] = {
617
- ...inspected,
618
- pathAliases: normalizeResourcePathAliases([...(resource.pathAliases || []), ...(inspected.pathAliases || [])]),
619
- allowInsecurePermissions: resource.allowInsecurePermissions === true,
620
- };
621
- }
622
- return out;
623
- }
624
-
625
- function normalizeResourceRegistry(resources) {
626
- const out = {};
627
- if (!resources || typeof resources !== "object" || Array.isArray(resources)) return out;
628
- for (const [rawName, rawValue] of Object.entries(resources).slice(0, MAX_RESOURCES)) {
629
- const name = validateResourceName(rawName);
630
- if (!rawValue || rawValue.kind !== "file" || typeof rawValue.path !== "string") continue;
631
- out[name] = {
632
- kind: "file",
633
- path: resolve(rawValue.path),
634
- pathAliases: normalizeResourcePathAliases([rawValue.path, ...(Array.isArray(rawValue.pathAliases) ? rawValue.pathAliases : [])]),
635
- size: Number.isFinite(Number(rawValue.size)) ? Number(rawValue.size) : null,
636
- mode: rawValue.mode ?? null,
637
- updatedAt: rawValue.updatedAt ?? null,
638
- allowInsecurePermissions: rawValue.allowInsecurePermissions === true,
639
- };
640
- }
641
- return out;
642
- }
643
-
644
- function normalizeResourcePathAliases(values) {
645
- const aliases = [];
646
- for (const value of values) {
647
- if (typeof value !== "string" || !value || value.includes("\0") || value.length > 4096) continue;
648
- const absolute = resolve(value);
649
- if (!aliases.includes(absolute)) aliases.push(absolute);
650
- if (aliases.length >= 8) break;
651
- }
652
- return aliases;
653
- }
654
-
655
- function resolveJobCwd(value, workspace, unrestrictedPaths) {
656
- const raw = boundedString(value, 4096, "cwd");
657
- const candidate = isAbsolute(raw) ? resolve(raw) : resolve(workspace, raw);
658
- const canonical = realpathSync.native ? realpathSync.native(candidate) : realpathSync(candidate);
659
- const info = statSync(canonical);
660
- if (!info.isDirectory()) throw new Error("managed job cwd is not a directory");
661
- if (!unrestrictedPaths) {
662
- const rel = relative(workspace, canonical);
663
- if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) throw new Error("managed job cwd is outside the configured workspace");
664
- }
665
- return canonical;
666
- }
667
463
 
668
464
  function failRunnerLaunch(dir, status, error) {
669
465
  const now = new Date().toISOString();
@@ -0,0 +1,83 @@
1
+ const DURATION_BUCKETS_MS = Object.freeze([100, 1000, 10_000, 30_000, 60_000]);
2
+ const MAX_TOOLS = 128;
3
+ const MAX_ERROR_CODES = 64;
4
+
5
+ export class RuntimeObservability {
6
+ constructor(options = {}) {
7
+ this.now = typeof options.now === "function" ? options.now : Date.now;
8
+ this.startedAt = this.now();
9
+ this.calls = { started: 0, completed: 0, failed: 0, cancelled: 0, timed_out: 0, slow: 0 };
10
+ this.byTool = new Map();
11
+ this.errors = new Map();
12
+ }
13
+
14
+ start(tool) {
15
+ this.calls.started += 1;
16
+ const metric = this.toolMetric(tool);
17
+ metric.started += 1;
18
+ metric.active += 1;
19
+ }
20
+
21
+ finish(tool, { status, durationMs, errorCode = "", slow = false } = {}) {
22
+ const metric = this.toolMetric(tool);
23
+ metric.active = Math.max(0, metric.active - 1);
24
+ metric.last_duration_ms = boundedDuration(durationMs);
25
+ metric.max_duration_ms = Math.max(metric.max_duration_ms, metric.last_duration_ms);
26
+ metric.total_duration_ms += metric.last_duration_ms;
27
+ metric.duration_buckets[bucketName(metric.last_duration_ms)] += 1;
28
+ if (status === "completed") { this.calls.completed += 1; metric.completed += 1; }
29
+ else {
30
+ this.calls.failed += 1;
31
+ metric.failed += 1;
32
+ if (status === "cancelled") this.calls.cancelled += 1;
33
+ if (status === "timeout") this.calls.timed_out += 1;
34
+ if (errorCode) this.incrementError(errorCode);
35
+ }
36
+ if (slow) { this.calls.slow += 1; metric.slow += 1; }
37
+ }
38
+
39
+ snapshot() {
40
+ return {
41
+ uptime_ms: Math.max(0, this.now() - this.startedAt),
42
+ calls: { ...this.calls },
43
+ active: [...this.byTool.values()].reduce((sum, metric) => sum + metric.active, 0),
44
+ errors: Object.fromEntries([...this.errors.entries()].sort(([a], [b]) => a.localeCompare(b))),
45
+ tools: Object.fromEntries([...this.byTool.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([name, metric]) => [name, {
46
+ ...metric,
47
+ duration_buckets: { ...metric.duration_buckets },
48
+ }])),
49
+ };
50
+ }
51
+
52
+ toolMetric(tool) {
53
+ const name = String(tool || "unknown").slice(0, 128) || "unknown";
54
+ if (!this.byTool.has(name)) {
55
+ if (this.byTool.size >= MAX_TOOLS) return this.toolMetric("<other>");
56
+ this.byTool.set(name, {
57
+ started: 0, completed: 0, failed: 0, active: 0, slow: 0,
58
+ total_duration_ms: 0, max_duration_ms: 0, last_duration_ms: 0,
59
+ duration_buckets: Object.fromEntries([...DURATION_BUCKETS_MS.map((value) => [`le_${value}`, 0]), ["gt_60000", 0]]),
60
+ });
61
+ }
62
+ return this.byTool.get(name);
63
+ }
64
+
65
+ incrementError(code) {
66
+ const key = String(code || "execution_failed").slice(0, 64);
67
+ if (!this.errors.has(key) && this.errors.size >= MAX_ERROR_CODES) {
68
+ this.errors.set("<other>", (this.errors.get("<other>") || 0) + 1);
69
+ return;
70
+ }
71
+ this.errors.set(key, (this.errors.get(key) || 0) + 1);
72
+ }
73
+ }
74
+
75
+ function bucketName(durationMs) {
76
+ for (const threshold of DURATION_BUCKETS_MS) if (durationMs <= threshold) return `le_${threshold}`;
77
+ return "gt_60000";
78
+ }
79
+
80
+ function boundedDuration(value) {
81
+ const number = Number(value);
82
+ return Number.isFinite(number) && number >= 0 ? Math.min(Math.floor(number), Number.MAX_SAFE_INTEGER) : 0;
83
+ }
@@ -0,0 +1,148 @@
1
+ import catalog from "../shared/tool-catalog.json" with { type: "json" };
2
+ import contract from "../shared/policy-contract.json" with { type: "json" };
3
+ import { BridgeError } from "./errors.mjs";
4
+
5
+ export const DEFAULT_POLICY_PROFILE = String(contract.defaultProfile);
6
+ export const DEFAULT_POLICY_REVISION = Number(contract.revision);
7
+ export const POLICY_PROFILES = Object.freeze(Object.fromEntries(
8
+ Object.entries(contract.profiles).map(([name, value]) => [name, Object.freeze({ ...value })]),
9
+ ));
10
+ export const POLICY_ORIGINS = Object.freeze(new Set(contract.origins.map(String)));
11
+ export const POLICY_AVAILABILITY = Object.freeze(Object.fromEntries(
12
+ Object.entries(contract.availability).map(([name, value]) => [name, Object.freeze({ ...value })]),
13
+ ));
14
+
15
+ const TOOLS = Object.freeze(catalog.map((tool) => Object.freeze({ ...tool })));
16
+ const TOOL_BY_NAME = new Map(TOOLS.map((tool) => [tool.name, tool]));
17
+
18
+ export function policyProfile(name, origin = "explicit") {
19
+ const profile = String(name || "").trim().toLowerCase();
20
+ if (!POLICY_PROFILES[profile]) throw new BridgeError("invalid_request", `unknown policy profile: ${profile}`);
21
+ return normalizePolicy({ ...POLICY_PROFILES[profile], origin, revision: DEFAULT_POLICY_REVISION });
22
+ }
23
+
24
+ export function normalizePolicy(policy = {}) {
25
+ const execMode = ["off", "direct", "shell"].includes(policy.execMode)
26
+ ? policy.execMode
27
+ : policy.allowExec === true
28
+ ? "shell"
29
+ : "off";
30
+ const origin = POLICY_ORIGINS.has(policy.origin) ? policy.origin : "custom";
31
+ const revision = Number.isInteger(policy.revision) && policy.revision > 0 ? policy.revision : DEFAULT_POLICY_REVISION;
32
+ const requestedProfile = typeof policy.profile === "string" && policy.profile ? policy.profile : "custom";
33
+ const normalized = {
34
+ profile: requestedProfile,
35
+ origin,
36
+ revision,
37
+ allowWrite: policy.allowWrite === true,
38
+ allowExec: execMode !== "off",
39
+ execMode,
40
+ unrestrictedPaths: policy.unrestrictedPaths === true,
41
+ minimalEnv: policy.minimalEnv !== false,
42
+ exposeAbsolutePaths: policy.exposeAbsolutePaths === true,
43
+ };
44
+ const canonical = POLICY_PROFILES[requestedProfile];
45
+ if (canonical) Object.assign(normalized, canonical, { allowExec: canonical.execMode !== "off" });
46
+ return Object.freeze(normalized);
47
+ }
48
+
49
+ export function policyCapabilitiesEqual(left, right) {
50
+ return left.allowWrite === right.allowWrite
51
+ && left.execMode === right.execMode
52
+ && left.unrestrictedPaths === right.unrestrictedPaths
53
+ && left.minimalEnv === right.minimalEnv
54
+ && left.exposeAbsolutePaths === right.exposeAbsolutePaths;
55
+ }
56
+
57
+ export function isCanonicalFullPolicy(policy = {}) {
58
+ return policyCapabilitiesEqual(normalizePolicy(policy), POLICY_PROFILES.full);
59
+ }
60
+
61
+ export function assertCanonicalFullPolicy(policy = {}) {
62
+ const normalized = normalizePolicy(policy);
63
+ if (normalized.profile !== "full" || !isCanonicalFullPolicy(normalized)) {
64
+ throw new BridgeError("policy_denied", "full profile invariant failed: full must enable writes, shell execution, unrestricted paths, full parent environment, and absolute paths");
65
+ }
66
+ if (toolNamesForPolicy(normalized).length !== TOOLS.length) {
67
+ throw new BridgeError("integrity_error", "full profile invariant failed: complete tool catalog is not exposed");
68
+ }
69
+ return normalized;
70
+ }
71
+
72
+ export function policyAllowsAvailability(policy, availability) {
73
+ const normalized = normalizePolicy(policy);
74
+ const requirements = POLICY_AVAILABILITY[availability];
75
+ if (!requirements) return false;
76
+ if (requirements.profile && normalized.profile !== requirements.profile) return false;
77
+ if (requirements.allowWrite === true && normalized.allowWrite !== true) return false;
78
+ if (Array.isArray(requirements.execModes) && !requirements.execModes.includes(normalized.execMode)) return false;
79
+ if (typeof requirements.unrestrictedPaths === "boolean" && normalized.unrestrictedPaths !== requirements.unrestrictedPaths) return false;
80
+ if (typeof requirements.minimalEnv === "boolean" && normalized.minimalEnv !== requirements.minimalEnv) return false;
81
+ if (typeof requirements.exposeAbsolutePaths === "boolean" && normalized.exposeAbsolutePaths !== requirements.exposeAbsolutePaths) return false;
82
+ return true;
83
+ }
84
+
85
+ export function toolDefinition(name) {
86
+ return TOOL_BY_NAME.get(String(name || "")) || null;
87
+ }
88
+
89
+ export function policyAllowsTool(policy, name) {
90
+ const tool = toolDefinition(name);
91
+ return Boolean(tool && policyAllowsAvailability(policy, tool.availability));
92
+ }
93
+
94
+ export function assertToolAllowed(policy, name) {
95
+ const tool = toolDefinition(name);
96
+ if (!tool) throw new BridgeError("not_found", `unknown tool: ${String(name || "")}`);
97
+ if (!policyAllowsAvailability(policy, tool.availability)) {
98
+ throw new BridgeError("policy_denied", `tool is disabled by the active policy: ${tool.name}`, {
99
+ details: { tool: tool.name, availability: tool.availability },
100
+ });
101
+ }
102
+ return tool;
103
+ }
104
+
105
+ export function toolsForPolicy(policy = {}) {
106
+ const normalized = normalizePolicy(policy);
107
+ return TOOLS.filter((tool) => policyAllowsAvailability(normalized, tool.availability))
108
+ .map(({ availability, ...tool }) => structuredClone(tool));
109
+ }
110
+
111
+ export function toolNamesForPolicy(policy = {}) {
112
+ const normalized = normalizePolicy(policy);
113
+ return TOOLS.filter((tool) => policyAllowsAvailability(normalized, tool.availability)).map((tool) => tool.name);
114
+ }
115
+
116
+ export function allToolNames() {
117
+ return TOOLS.map((tool) => tool.name);
118
+ }
119
+
120
+
121
+ export function createToolAuthorizer(policy, provided) {
122
+ if (typeof provided === "function") return provided;
123
+ const gate = new PolicyGate(policy);
124
+ return (tool) => gate.assert(tool);
125
+ }
126
+
127
+ export class PolicyGate {
128
+ constructor(policy) {
129
+ this.policy = normalizePolicy(policy);
130
+ this.allowedNames = new Set(toolNamesForPolicy(this.policy));
131
+ }
132
+
133
+ allows(name) {
134
+ return this.allowedNames.has(String(name || ""));
135
+ }
136
+
137
+ assert(name) {
138
+ return assertToolAllowed(this.policy, name);
139
+ }
140
+
141
+ names() {
142
+ return [...this.allowedNames];
143
+ }
144
+
145
+ definitions() {
146
+ return toolsForPolicy(this.policy);
147
+ }
148
+ }