machine-bridge-mcp 0.17.1 → 0.18.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.
Files changed (45) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +12 -13
  3. package/SECURITY.md +13 -13
  4. package/browser-extension/manifest.json +2 -2
  5. package/docs/ARCHITECTURE.md +5 -5
  6. package/docs/AUDIT.md +3 -3
  7. package/docs/CLIENTS.md +2 -2
  8. package/docs/GETTING_STARTED.md +13 -9
  9. package/docs/LOGGING.md +2 -2
  10. package/docs/MANAGED_JOBS.md +1 -1
  11. package/docs/MULTI_ACCOUNT.md +75 -358
  12. package/docs/OPERATIONS.md +4 -4
  13. package/docs/POLICY_REFERENCE.md +1 -1
  14. package/docs/PRIVACY.md +1 -1
  15. package/docs/PROJECT_STANDARDS.md +1 -1
  16. package/docs/TESTING.md +4 -4
  17. package/package.json +4 -3
  18. package/src/local/account-access.mjs +37 -0
  19. package/src/local/account-admin.mjs +105 -0
  20. package/src/local/bounded-output.mjs +50 -0
  21. package/src/local/call-registry.mjs +10 -0
  22. package/src/local/cli-account-admin.mjs +79 -0
  23. package/src/local/cli-options.mjs +9 -4
  24. package/src/local/cli-policy.mjs +7 -28
  25. package/src/local/cli.mjs +48 -78
  26. package/src/local/daemon-process.mjs +3 -2
  27. package/src/local/errors.mjs +0 -14
  28. package/src/local/log.mjs +1 -1
  29. package/src/local/managed-jobs.mjs +1 -3
  30. package/src/local/process-execution.mjs +90 -60
  31. package/src/local/relay-connection.mjs +11 -3
  32. package/src/local/runtime.mjs +27 -2
  33. package/src/local/service-convergence.mjs +20 -0
  34. package/src/local/service.mjs +12 -29
  35. package/src/local/shell.mjs +17 -29
  36. package/src/local/state-inventory.mjs +2 -2
  37. package/src/local/state.mjs +30 -85
  38. package/src/local/tool-executor.mjs +8 -2
  39. package/src/shared/access-contract.json +23 -0
  40. package/src/shared/policy-contract.json +30 -7
  41. package/src/worker/access.ts +46 -0
  42. package/src/worker/account-admin.ts +103 -0
  43. package/src/worker/index.ts +75 -48
  44. package/src/worker/oauth-state.ts +184 -2
  45. package/src/worker/tool-catalog.ts +13 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "machine-bridge-mcp",
3
- "version": "0.17.1",
3
+ "version": "0.18.1",
4
4
  "description": "Cross-client MCP bridge for local agent context, structured browser and application automation, files, Git, processes, resources, and durable jobs over stdio or OAuth relay.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -43,7 +43,7 @@
43
43
  "worker:types": "node scripts/generate-worker-types.mjs",
44
44
  "typecheck": "npm run worker:types && tsc -p tsconfig.json --noEmit",
45
45
  "syntax": "node scripts/syntax-check.mjs",
46
- "check": "npm run privacy:check && npm run privacy:test && npm run release-impact:check && npm run release-impact:test && npm run release-state:test && npm run release-ci:test && npm run network-retry:test && npm run relay:test && npm run secure-file:test && npm run architecture:test && npm run markdown:test && npm run project-metadata:test && npm run numbers:test && npm run records:test && npm run state-inventory:test && npm run commit-message:test && npm run policy:test && npm run policy-docs:check && npm run tool-docs:check && npm run runtime-infrastructure:test && npm run runtime-handlers:test && npm run cli-entrypoint:test && npm run lifecycle:test && npm run logging-structure:test && npm run coverage:test && npm run worker-runtime-infrastructure:test && npm run typecheck && npm run syntax && npm run process-lock:test && npm run service-lifecycle:test && npm run service-platform:test && npm run catalog:test && npm run agent-context:test && npm run capability-ranking:test && npm run browser-command:test && npm run browser-devtools-input:test && npm run browser-service-worker:test && npm run browser-page-automation:test && npm run browser-bridge:test && npm run app-automation:test && npm run self-test && npm run atomic-fs:test && npm run package:test && npm run ssh-key:test && npm run full-access:test && npm run managed-jobs:test && npm run stdio:integration-test && npm run worker:integration-test",
46
+ "check": "npm run privacy:check && npm run privacy:test && npm run release-impact:check && npm run release-impact:test && npm run release-state:test && npm run release-ci:test && npm run network-retry:test && npm run relay:test && npm run secure-file:test && npm run architecture:test && npm run markdown:test && npm run project-metadata:test && npm run numbers:test && npm run records:test && npm run state-inventory:test && npm run commit-message:test && npm run policy:test && npm run account:test && npm run policy-docs:check && npm run tool-docs:check && npm run runtime-infrastructure:test && npm run runtime-handlers:test && npm run cli-entrypoint:test && npm run lifecycle:test && npm run logging-structure:test && npm run coverage:test && npm run worker-runtime-infrastructure:test && npm run typecheck && npm run syntax && npm run process-lock:test && npm run service-lifecycle:test && npm run service-platform:test && npm run catalog:test && npm run agent-context:test && npm run capability-ranking:test && npm run browser-command:test && npm run browser-devtools-input:test && npm run browser-service-worker:test && npm run browser-page-automation:test && npm run browser-bridge:test && npm run app-automation:test && npm run self-test && npm run atomic-fs:test && npm run package:test && npm run ssh-key:test && npm run full-access:test && npm run managed-jobs:test && npm run stdio:integration-test && npm run worker:integration-test",
47
47
  "worker:dry-run": "wrangler deploy --dry-run",
48
48
  "worker:integration-test": "node tests/worker-integration-test.mjs",
49
49
  "release:check": "node scripts/github-release.mjs --check",
@@ -98,7 +98,8 @@
98
98
  "commit-message:check": "node scripts/commit-message-check.mjs",
99
99
  "commit-message:test": "node tests/commit-message-test.mjs",
100
100
  "tool-docs:generate": "node scripts/generate-tool-reference.mjs",
101
- "tool-docs:check": "node scripts/generate-tool-reference.mjs --check"
101
+ "tool-docs:check": "node scripts/generate-tool-reference.mjs --check",
102
+ "account:test": "node tests/account-admin-test.mjs"
102
103
  },
103
104
  "dependencies": {
104
105
  "https-proxy-agent": "9.1.0",
@@ -0,0 +1,37 @@
1
+ import accessContract from "../shared/access-contract.json" with { type: "json" };
2
+ import { BridgeError } from "./errors.mjs";
3
+ import { policyProfile, toolNamesForPolicy, assertToolAllowed } from "./policy.mjs";
4
+
5
+ export const ACCOUNT_ACCESS_REVISION = Number(accessContract.revision);
6
+ export const ACCOUNT_ROLES = Object.freeze(Object.fromEntries(
7
+ Object.entries(accessContract.roles).map(([name, value]) => [name, Object.freeze({ ...value })]),
8
+ ));
9
+ export const DEFAULT_ACCOUNT_ROLE = String(accessContract.defaultRole);
10
+ export const OWNER_ACCOUNT_ROLE = String(accessContract.ownerRole);
11
+
12
+ export function normalizeAccountRole(value) {
13
+ const role = String(value || "").trim().toLowerCase();
14
+ if (!ACCOUNT_ROLES[role]) throw new BridgeError("invalid_request", `unknown account role: ${role}`);
15
+ return role;
16
+ }
17
+
18
+ export function accountRolePolicy(role) {
19
+ const normalized = normalizeAccountRole(role);
20
+ return policyProfile(ACCOUNT_ROLES[normalized].profile, "explicit");
21
+ }
22
+
23
+ export function accountRoleToolNames(role) {
24
+ return toolNamesForPolicy(accountRolePolicy(role));
25
+ }
26
+
27
+ export class AccountAccessGate {
28
+ assert(role, tool) {
29
+ const normalized = normalizeAccountRole(role);
30
+ assertToolAllowed(accountRolePolicy(normalized), tool);
31
+ return normalized;
32
+ }
33
+
34
+ names(role) {
35
+ return accountRoleToolNames(role);
36
+ }
37
+ }
@@ -0,0 +1,105 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { ACCOUNT_ROLES, normalizeAccountRole } from "./account-access.mjs";
3
+ import { BridgeError } from "./errors.mjs";
4
+
5
+ const REQUEST_TIMEOUT_MS = 15_000;
6
+
7
+ export function generateAccountPassword() {
8
+ return `account_password_${randomBytes(32).toString("base64url")}`;
9
+ }
10
+
11
+ export class AccountAdminClient {
12
+ constructor({ workerUrl, adminSecret, fetchImpl = fetch }) {
13
+ this.workerUrl = normalizeWorkerUrl(workerUrl);
14
+ if (typeof adminSecret !== "string" || adminSecret.length < 24) throw new BridgeError("invalid_request", "account administration secret is missing");
15
+ this.adminSecret = adminSecret;
16
+ this.fetchImpl = fetchImpl;
17
+ }
18
+
19
+ list() {
20
+ return this.request("GET", "/admin/accounts");
21
+ }
22
+
23
+ create({ name, role, password, displayName = "" }) {
24
+ return this.request("POST", "/admin/accounts", {
25
+ name: normalizeAccountName(name),
26
+ role: normalizeAccountRole(role),
27
+ password: String(password || ""),
28
+ ...(displayName ? { display_name: String(displayName) } : {}),
29
+ });
30
+ }
31
+
32
+ update({ accountId, role, active, displayName }) {
33
+ return this.request("PATCH", "/admin/accounts", {
34
+ account_id: requiredAccountId(accountId),
35
+ ...(role === undefined ? {} : { role: normalizeAccountRole(role) }),
36
+ ...(active === undefined ? {} : { active: Boolean(active) }),
37
+ ...(displayName === undefined ? {} : { display_name: String(displayName) }),
38
+ });
39
+ }
40
+
41
+ rotatePassword({ accountId, password }) {
42
+ return this.request("POST", "/admin/accounts/rotate-password", {
43
+ account_id: requiredAccountId(accountId),
44
+ password: String(password || ""),
45
+ });
46
+ }
47
+
48
+ remove({ accountId }) {
49
+ return this.request("DELETE", "/admin/accounts", { account_id: requiredAccountId(accountId) });
50
+ }
51
+
52
+ async find(target) {
53
+ const result = await this.list();
54
+ const value = String(target || "").trim().toLowerCase();
55
+ const matches = result.accounts.filter((account) => account.account_id === target || account.name === value);
56
+ if (matches.length !== 1) throw new BridgeError(matches.length ? "conflict" : "not_found", matches.length ? "account target is ambiguous" : "account was not found");
57
+ return matches[0];
58
+ }
59
+
60
+ async request(method, pathname, body) {
61
+ const response = await this.fetchImpl(`${this.workerUrl}${pathname}`, {
62
+ method,
63
+ headers: {
64
+ authorization: `Bearer ${this.adminSecret}`,
65
+ ...(body === undefined ? {} : { "content-type": "application/json" }),
66
+ },
67
+ body: body === undefined ? undefined : JSON.stringify(body),
68
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
69
+ cache: "no-store",
70
+ }).catch((error) => {
71
+ throw new BridgeError("network_error", "account administration request failed", { cause: error, retryable: true });
72
+ });
73
+ if (response.status === 204) return { removed: true };
74
+ const payload = await response.json().catch(() => ({}));
75
+ if (!response.ok) {
76
+ const message = typeof payload.message === "string" ? payload.message : typeof payload.error === "string" ? payload.error : `account administration failed (${response.status})`;
77
+ throw new BridgeError(response.status === 404 ? "not_found" : response.status === 409 ? "conflict" : response.status === 401 ? "authentication_failed" : "invalid_request", message);
78
+ }
79
+ return payload;
80
+ }
81
+ }
82
+
83
+ export function accountRoleNames() {
84
+ return Object.keys(ACCOUNT_ROLES);
85
+ }
86
+
87
+ function normalizeWorkerUrl(value) {
88
+ const url = new URL(String(value || ""));
89
+ if (url.protocol !== "https:" || url.username || url.password || url.pathname !== "/" || url.search || url.hash) {
90
+ throw new BridgeError("invalid_request", "Worker URL must be a credential-free HTTPS origin");
91
+ }
92
+ return url.origin;
93
+ }
94
+
95
+ function normalizeAccountName(value) {
96
+ const name = String(value || "").trim().toLowerCase();
97
+ if (!/^[a-z0-9](?:[a-z0-9._-]{1,62}[a-z0-9])?$/.test(name)) throw new BridgeError("invalid_request", "account name must contain 3-64 lowercase letters, digits, dots, underscores, or hyphens");
98
+ return name;
99
+ }
100
+
101
+ function requiredAccountId(value) {
102
+ const id = String(value || "");
103
+ if (!/^acct_[A-Za-z0-9_-]{20,96}$/.test(id)) throw new BridgeError("invalid_request", "account id is invalid");
104
+ return id;
105
+ }
@@ -0,0 +1,50 @@
1
+ export class BoundedOutput {
2
+ constructor(maximumBytes, options = {}) {
3
+ const maximum = Number(maximumBytes);
4
+ if (!Number.isFinite(maximum) || maximum < 1) throw new Error("maximum output bytes must be positive");
5
+ this.maximum = Math.floor(maximum);
6
+ this.headLimit = Math.max(1, Math.min(this.maximum, Math.floor(Number(options.headBytes) || this.maximum / 3)));
7
+ this.tailLimit = Math.max(0, this.maximum - this.headLimit);
8
+ this.totalBytes = 0;
9
+ this.full = Buffer.alloc(0);
10
+ this.head = Buffer.alloc(0);
11
+ this.tail = Buffer.alloc(0);
12
+ }
13
+
14
+ append(chunk) {
15
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk ?? ""));
16
+ if (!bytes.length) return;
17
+ this.totalBytes += bytes.length;
18
+
19
+ if (this.full) {
20
+ const combined = Buffer.concat([this.full, bytes]);
21
+ if (combined.length <= this.maximum) {
22
+ this.full = combined;
23
+ return;
24
+ }
25
+ this.full = null;
26
+ this.head = combined.subarray(0, this.headLimit);
27
+ this.tail = this.tailLimit ? combined.subarray(Math.max(this.headLimit, combined.length - this.tailLimit)) : Buffer.alloc(0);
28
+ return;
29
+ }
30
+
31
+ if (this.head.length < this.headLimit) {
32
+ const missing = this.headLimit - this.head.length;
33
+ this.head = Buffer.concat([this.head, bytes.subarray(0, missing)]);
34
+ }
35
+ if (this.tailLimit) {
36
+ const combinedTail = Buffer.concat([this.tail, bytes]);
37
+ this.tail = combinedTail.subarray(Math.max(0, combinedTail.length - this.tailLimit));
38
+ }
39
+ }
40
+
41
+ get truncatedBytes() {
42
+ return Math.max(0, this.totalBytes - this.maximum);
43
+ }
44
+
45
+ text() {
46
+ if (this.full) return this.full.toString("utf8");
47
+ const marker = `\n\n[truncated ${this.truncatedBytes} bytes; preserved beginning and end]\n\n`;
48
+ return `${this.head.toString("utf8")}${marker}${this.tail.toString("utf8")}`;
49
+ }
50
+ }
@@ -62,6 +62,16 @@ export class CallRegistry {
62
62
  return true;
63
63
  }
64
64
 
65
+ cancelOrigin(origin, reason = "transport disconnected") {
66
+ const expected = String(origin || "");
67
+ let cancelled = 0;
68
+ for (const [id, record] of this.calls) {
69
+ if (record.origin !== expected) continue;
70
+ if (this.cancel(id, reason)) cancelled += 1;
71
+ }
72
+ return cancelled;
73
+ }
74
+
65
75
  cancelAll(reason = "runtime stopped") {
66
76
  for (const id of [...this.calls.keys()]) {
67
77
  this.cancel(id, reason);
@@ -0,0 +1,79 @@
1
+ import { AccountAdminClient, accountRoleNames, generateAccountPassword } from "./account-admin.mjs";
2
+ import { loadState } from "./state.mjs";
3
+
4
+ export function createAccountCommand({ chooseWorkspace, confirm }) {
5
+ if (typeof chooseWorkspace !== "function" || typeof confirm !== "function") {
6
+ throw new TypeError("account command requires chooseWorkspace and confirm dependencies");
7
+ }
8
+ return async function accountCommand(args) {
9
+ const workspace = await chooseWorkspace({ ...args, _: [] }, { promptOnFirstRun: false, save: false, allowPositional: false });
10
+ const state = loadState(workspace, { stateDir: args.stateDir });
11
+ const client = accountAdminClient(state);
12
+ const action = String(args._[0] || "list").toLowerCase();
13
+ const result = await performAccountAction({ action, args, client, confirm });
14
+ if (result === null) return;
15
+ if (args.json) console.log(JSON.stringify(result, null, 2));
16
+ else printAccountResult(action, result);
17
+ };
18
+ }
19
+
20
+ export function accountAdminClient(state) {
21
+ if (!state.worker?.url || !state.worker?.accountAdminSecret) {
22
+ throw new Error("account administration requires a deployed Worker; run machine-mcp first");
23
+ }
24
+ return new AccountAdminClient({ workerUrl: state.worker.url, adminSecret: state.worker.accountAdminSecret });
25
+ }
26
+
27
+ async function performAccountAction({ action, args, client, confirm }) {
28
+ if (action === "list") return client.list();
29
+ if (action === "add") {
30
+ const name = args._[1];
31
+ const role = args._[2];
32
+ if (!name || !role) throw new Error(`account add requires NAME and ROLE (${accountRoleNames().join(", ")})`);
33
+ const password = generateAccountPassword();
34
+ return { ...(await client.create({ name, role, password })), password };
35
+ }
36
+ if (action === "role") {
37
+ const account = await client.find(args._[1]);
38
+ const role = args._[2];
39
+ if (!role) throw new Error(`account role requires NAME_OR_ID and ROLE (${accountRoleNames().join(", ")})`);
40
+ return client.update({ accountId: account.account_id, role });
41
+ }
42
+ if (action === "enable" || action === "disable") {
43
+ const account = await client.find(args._[1]);
44
+ return client.update({ accountId: account.account_id, active: action === "enable" });
45
+ }
46
+ if (action === "rotate-password") {
47
+ const account = await client.find(args._[1]);
48
+ const password = generateAccountPassword();
49
+ return { ...(await client.rotatePassword({ accountId: account.account_id, password })), password };
50
+ }
51
+ if (action === "remove") {
52
+ const account = await client.find(args._[1]);
53
+ if (!args.yes && !(await confirm(`Remove account ${account.name}?`, false))) {
54
+ console.log("Account removal cancelled.");
55
+ return null;
56
+ }
57
+ return client.remove({ accountId: account.account_id });
58
+ }
59
+ throw new Error("Unknown account action. Use list, add, role, enable, disable, rotate-password, or remove.");
60
+ }
61
+
62
+ function printAccountResult(action, result) {
63
+ if (action === "list") {
64
+ if (!result.accounts.length) {
65
+ console.log("No accounts configured.");
66
+ return;
67
+ }
68
+ for (const account of result.accounts) {
69
+ console.log(`${account.name}\t${account.role}\t${account.active ? "active" : "disabled"}\t${account.account_id}`);
70
+ }
71
+ return;
72
+ }
73
+ if (result.account) console.log(`${result.account.name}: ${result.account.role}; ${result.account.active ? "active" : "disabled"}`);
74
+ if (result.password) {
75
+ process.stdout.write(`Password: ${result.password}\n`);
76
+ console.log("Save this password now; it is not stored locally or shown again.");
77
+ }
78
+ if (result.removed) console.log("Account removed.");
79
+ }
@@ -2,8 +2,7 @@ import { normalizeLogLevel } from "./log.mjs";
2
2
 
3
3
  const BOOLEAN_OPTIONS = new Set([
4
4
  "help", "version", "quiet", "json", "verbose", "rotateSecrets", "forceWorker",
5
- "daemonOnly", "noAutostart", "noPrintCredentials", "printMcpCredentials",
6
- "printCredentials", "noWrite", "noExec", "fullEnv", "unrestrictedPaths", "absolutePaths",
5
+ "daemonOnly", "noAutostart", "noWrite", "noExec", "fullEnv", "unrestrictedPaths", "absolutePaths",
7
6
  "yes", "keepWorker", "allowInsecurePermissions", "showPaths",
8
7
  ]);
9
8
  const VALUE_OPTIONS = new Set([
@@ -15,7 +14,7 @@ const LOG_FORMATS = new Set(["text", "json"]);
15
14
  const COMMAND_OPTIONS = {
16
15
  start: new Set([
17
16
  "workspace", "stateDir", "workerName", "quiet", "json", "verbose", "logLevel", "logFormat", "rotateSecrets", "forceWorker",
18
- "daemonOnly", "noAutostart", "noPrintCredentials", "printMcpCredentials", "printCredentials",
17
+ "daemonOnly", "noAutostart",
19
18
  "profile", "execMode", "noWrite", "noExec", "fullEnv", "unrestrictedPaths", "absolutePaths",
20
19
  ]),
21
20
  stdio: new Set(["workspace", "stateDir", "profile", "execMode", "noWrite", "noExec", "fullEnv", "unrestrictedPaths", "absolutePaths", "verbose", "quiet", "logLevel", "logFormat"]),
@@ -23,11 +22,12 @@ const COMMAND_OPTIONS = {
23
22
  status: new Set(["workspace", "stateDir"]),
24
23
  doctor: new Set(["workspace", "stateDir"]),
25
24
  "full-test": new Set(["workspace", "stateDir", "json"]),
26
- "rotate-secrets": new Set(["workspace", "stateDir", "workerName", "noPrintCredentials", "printMcpCredentials", "printCredentials", "quiet"]),
25
+ "rotate-secrets": new Set(["workspace", "stateDir", "workerName", "quiet"]),
27
26
  workspace: new Set(["workspace", "stateDir"]),
28
27
  service: new Set(["workspace", "stateDir", "quiet"]),
29
28
  autostart: new Set(["workspace", "stateDir", "quiet"]),
30
29
  resource: new Set(["workspace", "stateDir", "allowInsecurePermissions", "showPaths", "json"]),
30
+ account: new Set(["workspace", "stateDir", "json", "yes"]),
31
31
  browser: new Set(["workspace", "stateDir", "json"]),
32
32
  job: new Set(["workspace", "stateDir", "json", "yes"]),
33
33
  uninstall: new Set(["stateDir", "keepWorker", "yes"]),
@@ -40,6 +40,7 @@ const STATIC_POSITIONAL_RULES = Object.freeze({
40
40
  });
41
41
  const RESOURCE_POSITIONAL_LIMITS = Object.freeze({ add: 3, "generate-ssh-key": 3, remove: 2, check: 2 });
42
42
  const JOB_POSITIONAL_LIMITS = Object.freeze({ read: 2, inspect: 2, cancel: 2, approve: 2, submit: 2 });
43
+ const ACCOUNT_POSITIONAL_LIMITS = Object.freeze({ list: 1, add: 3, role: 3, enable: 2, disable: 2, "rotate-password": 2, remove: 2 });
43
44
  const ACTION_POSITIONAL_RULES = Object.freeze({
44
45
  workspace(args) {
45
46
  const action = String(args._[0] || "show");
@@ -54,6 +55,10 @@ const ACTION_POSITIONAL_RULES = Object.freeze({
54
55
  const action = String(args._[0] || "list");
55
56
  return { max: RESOURCE_POSITIONAL_LIMITS[action] ?? 1, tooMany: `resource ${action} received too many positional arguments` };
56
57
  },
58
+ account(args) {
59
+ const action = String(args._[0] || "list");
60
+ return { max: ACCOUNT_POSITIONAL_LIMITS[action] ?? 1, tooMany: `account ${action} received too many positional arguments` };
61
+ },
57
62
  browser(args) {
58
63
  const action = String(args._[0] || "status");
59
64
  return { max: 1, tooMany: `browser ${action} received too many positional arguments` };
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  DEFAULT_POLICY_PROFILE,
3
3
  DEFAULT_POLICY_REVISION,
4
+ POLICY_ORIGINS,
4
5
  POLICY_PROFILES,
5
6
  normalizePolicy,
6
7
  policyProfile,
@@ -9,9 +10,7 @@ import {
9
10
  const POLICY_OVERRIDE_KEYS = Object.freeze(["execMode", "noWrite", "noExec", "fullEnv", "unrestrictedPaths", "absolutePaths"]);
10
11
 
11
12
  export function resolvePolicy(args = {}, stored = {}) {
12
- const hasStored = stored && typeof stored === "object" && (
13
- typeof stored.allowWrite === "boolean" || typeof stored.allowExec === "boolean" || typeof stored.execMode === "string"
14
- );
13
+ const hasStored = stored && typeof stored === "object" && typeof stored.profile === "string";
15
14
  const explicitKeys = ["profile", ...POLICY_OVERRIDE_KEYS];
16
15
  const hasExplicit = explicitKeys.some((key) => Object.prototype.hasOwnProperty.call(args, key));
17
16
  const base = { ...selectPolicyBase(args, stored, hasStored) };
@@ -26,8 +25,6 @@ export function resolvePolicy(args = {}, stored = {}) {
26
25
  }
27
26
 
28
27
  function policyState(policy) {
29
- // Capability fields retain the canonical immutable contract. The CLI owns a
30
- // sealed persistence record with exactly one writable metadata field.
31
28
  const normalized = normalizePolicy(policy);
32
29
  const state = {};
33
30
  for (const [key, value] of Object.entries(normalized)) {
@@ -43,8 +40,11 @@ function selectPolicyBase(args, stored, hasStored) {
43
40
  if (!POLICY_PROFILES[profile]) throw new Error(`--profile must be one of: ${Object.keys(POLICY_PROFILES).join(", ")}`);
44
41
  return policyProfile(profile, "explicit");
45
42
  }
46
- if (hasStored) return migrateLegacyPolicy(stored);
47
- return policyProfile(DEFAULT_POLICY_PROFILE, "default");
43
+ if (!hasStored) return policyProfile(DEFAULT_POLICY_PROFILE, "default");
44
+ if (stored.revision !== DEFAULT_POLICY_REVISION || !POLICY_ORIGINS.has(stored.origin)) {
45
+ throw new Error("stored policy schema is obsolete; remove the state root and initialize the current version");
46
+ }
47
+ return normalizePolicy(stored);
48
48
  }
49
49
 
50
50
  function applyPolicyOverrides(policy, args) {
@@ -66,24 +66,3 @@ function applyPolicyOverrides(policy, args) {
66
66
  function applyBooleanOverride(args, key, apply) {
67
67
  if (typeof args[key] === "boolean") apply(args[key]);
68
68
  }
69
-
70
- function migrateLegacyPolicy(stored = {}) {
71
- if (stored.origin === "default" && Number(stored.revision || 0) < DEFAULT_POLICY_REVISION) {
72
- return policyProfile(DEFAULT_POLICY_PROFILE, "default");
73
- }
74
- if (stored.origin === "migrated" && Number(stored.revision || 0) < DEFAULT_POLICY_REVISION) {
75
- return policyProfile(DEFAULT_POLICY_PROFILE, "migrated");
76
- }
77
- if (stored.origin) return normalizePolicy(stored);
78
- const normalized = normalizePolicy(stored);
79
- const looksLikeLegacyImplicitDefault = (
80
- normalized.profile === "custom"
81
- && normalized.allowWrite === true
82
- && normalized.execMode === "shell"
83
- && normalized.unrestrictedPaths === false
84
- && normalized.minimalEnv === true
85
- && normalized.exposeAbsolutePaths === false
86
- );
87
- if (looksLikeLegacyImplicitDefault) return policyProfile("full", "migrated");
88
- return normalizePolicy({ ...normalized, origin: "legacy-preserved", revision: DEFAULT_POLICY_REVISION });
89
- }