machine-bridge-mcp 0.15.0 → 0.16.1
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/CHANGELOG.md +27 -0
- package/README.md +12 -2
- package/SECURITY.md +7 -0
- package/browser-extension/manifest.json +2 -2
- package/docs/ARCHITECTURE.md +5 -3
- package/docs/AUDIT.md +12 -1
- package/docs/LOGGING.md +7 -1
- package/docs/OPERATIONS.md +9 -2
- package/docs/POLICY_REFERENCE.md +88 -0
- package/docs/TESTING.md +7 -0
- package/package.json +14 -3
- package/scripts/coverage-check.mjs +108 -0
- package/scripts/generate-policy-reference.mjs +95 -0
- package/src/local/agent-context.mjs +1 -103
- package/src/local/app-automation.mjs +7 -11
- package/src/local/browser-bridge.mjs +26 -156
- package/src/local/browser-extension-protocol.mjs +75 -0
- package/src/local/browser-pairing-store.mjs +83 -0
- package/src/local/call-registry.mjs +113 -0
- package/src/local/capability-ranking.mjs +103 -0
- package/src/local/cli-local-admin.mjs +308 -0
- package/src/local/cli-options.mjs +151 -0
- package/src/local/cli-policy.mjs +89 -0
- package/src/local/cli.mjs +16 -521
- package/src/local/errors.mjs +122 -0
- package/src/local/git-service.mjs +88 -0
- package/src/local/lifecycle.mjs +64 -0
- package/src/local/log.mjs +50 -19
- package/src/local/managed-job-plan.mjs +235 -0
- package/src/local/managed-jobs.mjs +16 -220
- package/src/local/observability.mjs +83 -0
- package/src/local/policy.mjs +148 -0
- package/src/local/process-execution.mjs +153 -0
- package/src/local/process-sessions.mjs +10 -20
- package/src/local/process-tracker.mjs +55 -0
- package/src/local/runtime.mjs +154 -672
- package/src/local/service.mjs +1 -0
- package/src/local/stdio.mjs +3 -11
- package/src/local/tool-executor.mjs +102 -0
- package/src/local/tools.mjs +21 -104
- package/src/local/workspace-file-service.mjs +451 -0
- package/src/shared/policy-contract.json +54 -0
- package/src/shared/tool-catalog.json +4 -4
- 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
|
-
|
|
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
|
+
}
|