machine-bridge-mcp 2.0.0 → 3.0.0-beta.9

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 (143) hide show
  1. package/CHANGELOG.md +101 -0
  2. package/CONTRIBUTING.md +20 -25
  3. package/README.md +21 -14
  4. package/SECURITY.md +147 -123
  5. package/browser-extension/manifest.json +3 -2
  6. package/browser-extension/service-worker.js +31 -12
  7. package/docs/ARCHITECTURE.md +27 -13
  8. package/docs/AUDIT.md +85 -2
  9. package/docs/CLIENTS.md +2 -2
  10. package/docs/ENGINEERING.md +9 -3
  11. package/docs/GETTING_STARTED.md +10 -9
  12. package/docs/LOCAL_AUTHORIZATION.md +115 -72
  13. package/docs/LOGGING.md +12 -3
  14. package/docs/MANAGED_JOBS.md +24 -29
  15. package/docs/MULTI_ACCOUNT.md +98 -37
  16. package/docs/OPERATIONS.md +33 -22
  17. package/docs/OVERVIEW.md +7 -7
  18. package/docs/PROJECT_STANDARDS.md +9 -5
  19. package/docs/RELEASING.md +171 -64
  20. package/docs/TESTING.md +19 -7
  21. package/docs/THREAT_MODEL.md +138 -67
  22. package/docs/TOOL_REFERENCE.md +2 -2
  23. package/docs/UPGRADING.md +102 -18
  24. package/native/macos/MachineBridgeTrustBroker.swift +233 -0
  25. package/package.json +36 -5
  26. package/scripts/candidate-runtime-store.mjs +38 -0
  27. package/scripts/check-plan.mjs +19 -0
  28. package/scripts/check-runner.mjs +4 -1
  29. package/scripts/commit-message-check.mjs +5 -2
  30. package/scripts/coverage-check.mjs +30 -2
  31. package/scripts/github-push.mjs +11 -0
  32. package/scripts/github-release.mjs +38 -7
  33. package/scripts/install-published-prerelease.mjs +167 -0
  34. package/scripts/local-release-acceptance.mjs +23 -12
  35. package/scripts/npm-publication-policy.mjs +16 -0
  36. package/scripts/prerelease-activation.mjs +79 -0
  37. package/scripts/privacy-check.mjs +7 -5
  38. package/scripts/promotion-digest.mjs +121 -0
  39. package/scripts/publish-npm.mjs +51 -0
  40. package/scripts/published-release.mjs +80 -0
  41. package/scripts/release-acceptance.mjs +30 -1
  42. package/scripts/release-candidate-manifest.mjs +40 -0
  43. package/scripts/release-channel.mjs +106 -0
  44. package/scripts/release-impact-check.mjs +12 -22
  45. package/scripts/release-soak.mjs +237 -0
  46. package/scripts/start-release-candidate.mjs +113 -7
  47. package/src/local/account-access.mjs +6 -0
  48. package/src/local/account-admin.mjs +34 -16
  49. package/src/local/agent-context.mjs +45 -42
  50. package/src/local/app-automation.mjs +9 -4
  51. package/src/local/authority-context.mjs +106 -0
  52. package/src/local/browser-bridge-http.mjs +4 -1
  53. package/src/local/browser-bridge.mjs +14 -9
  54. package/src/local/browser-broker-routes.mjs +4 -0
  55. package/src/local/browser-broker-server.mjs +5 -4
  56. package/src/local/browser-extension-identity.mjs +50 -0
  57. package/src/local/browser-extension-protocol.mjs +9 -4
  58. package/src/local/browser-operation-service.mjs +7 -0
  59. package/src/local/browser-pairing-http.mjs +36 -0
  60. package/src/local/browser-pairing-store.mjs +56 -47
  61. package/src/local/cli-account-admin.mjs +41 -7
  62. package/src/local/cli-activate.mjs +90 -0
  63. package/src/local/cli-approval.mjs +14 -58
  64. package/src/local/cli-local-admin.mjs +5 -15
  65. package/src/local/cli-options.mjs +11 -7
  66. package/src/local/cli-service.mjs +35 -5
  67. package/src/local/cli.mjs +112 -58
  68. package/src/local/daemon-process.mjs +6 -0
  69. package/src/local/delegated-process-sandbox.mjs +147 -0
  70. package/src/local/device-identity.mjs +157 -48
  71. package/src/local/device-root-provider.mjs +87 -0
  72. package/src/local/errors.mjs +25 -3
  73. package/src/local/git-service.mjs +16 -15
  74. package/src/local/job-runner.mjs +35 -27
  75. package/src/local/log.mjs +14 -1
  76. package/src/local/macos-trust-broker.mjs +320 -0
  77. package/src/local/managed-job-lock.mjs +18 -0
  78. package/src/local/managed-job-projection.mjs +9 -1
  79. package/src/local/managed-job-runner.mjs +13 -1
  80. package/src/local/managed-job-storage.mjs +2 -1
  81. package/src/local/managed-job-terminal.mjs +138 -0
  82. package/src/local/managed-jobs.mjs +88 -32
  83. package/src/local/operation-authorization.mjs +75 -239
  84. package/src/local/operation-risk.mjs +28 -0
  85. package/src/local/operation-state-lock.mjs +7 -86
  86. package/src/local/owner-state-lock.mjs +104 -0
  87. package/src/local/policy.mjs +19 -0
  88. package/src/local/process-execution.mjs +21 -9
  89. package/src/local/process-sessions.mjs +27 -17
  90. package/src/local/process-tracker.mjs +30 -6
  91. package/src/local/process-tree-ownership.mjs +59 -0
  92. package/src/local/process-tree.mjs +22 -18
  93. package/src/local/relay-connection.mjs +47 -35
  94. package/src/local/relay-diagnostics.mjs +65 -0
  95. package/src/local/remote-configuration.mjs +48 -0
  96. package/src/local/runtime-activation.mjs +141 -0
  97. package/src/local/runtime-diagnostics.mjs +21 -0
  98. package/src/local/runtime-relay.mjs +9 -8
  99. package/src/local/runtime-reporting.mjs +8 -3
  100. package/src/local/runtime-tool-handlers.mjs +8 -8
  101. package/src/local/runtime.mjs +73 -39
  102. package/src/local/secure-file.mjs +5 -1
  103. package/src/local/security-audit-log.mjs +204 -0
  104. package/src/local/service-convergence.mjs +17 -1
  105. package/src/local/service-ownership.mjs +17 -0
  106. package/src/local/service-restart-handoff.mjs +50 -0
  107. package/src/local/service-restart-scheduler.mjs +49 -0
  108. package/src/local/service-status.mjs +47 -0
  109. package/src/local/service.mjs +142 -43
  110. package/src/local/state-inventory.mjs +21 -3
  111. package/src/local/state.mjs +155 -17
  112. package/src/local/stdio.mjs +5 -0
  113. package/src/local/tool-executor.mjs +39 -6
  114. package/src/local/tool-result-boundary.mjs +51 -0
  115. package/src/local/tools.mjs +1 -0
  116. package/src/local/trusted-executable.mjs +53 -0
  117. package/src/local/trusted-git-executable.mjs +34 -0
  118. package/src/local/trusted-github-cli.mjs +24 -0
  119. package/src/local/windows-service.mjs +20 -0
  120. package/src/local/worker-deployment.mjs +3 -4
  121. package/src/local/worker-secret-file.mjs +2 -2
  122. package/src/local/workspace-file-service.mjs +34 -31
  123. package/src/shared/admin-auth.d.mts +2 -1
  124. package/src/shared/admin-auth.mjs +3 -3
  125. package/src/shared/device-session-auth.d.mts +20 -0
  126. package/src/shared/device-session-auth.mjs +51 -0
  127. package/src/shared/server-metadata.json +1 -1
  128. package/src/shared/tool-catalog.json +2 -2
  129. package/src/worker/account-admin.ts +23 -22
  130. package/src/worker/authority.ts +9 -2
  131. package/src/worker/daemon-auth.ts +45 -67
  132. package/src/worker/daemon-sockets.ts +12 -2
  133. package/src/worker/device-session-verifier.ts +129 -0
  134. package/src/worker/dpop.ts +151 -0
  135. package/src/worker/http.ts +47 -16
  136. package/src/worker/index.ts +53 -16
  137. package/src/worker/nonce-store.ts +1 -5
  138. package/src/worker/oauth-client-admin.ts +45 -0
  139. package/src/worker/oauth-controller.ts +55 -6
  140. package/src/worker/oauth-refresh-families.ts +21 -7
  141. package/src/worker/oauth-state.ts +6 -0
  142. package/src/worker/oauth-tokens.ts +28 -8
  143. package/src/worker/websocket-protocol.ts +8 -2
@@ -1,4 +1,3 @@
1
- import { randomBytes } from "node:crypto";
2
1
  import { existsSync } from "node:fs";
3
2
  import path from "node:path";
4
3
  import { BridgeError } from "./errors.mjs";
@@ -7,12 +6,8 @@ import { replaceFileAtomicallySync } from "./exclusive-file.mjs";
7
6
  import { withOperationStateLock } from "./operation-state-lock.mjs";
8
7
  import { ensureOwnerOnlyDirectorySync, readBoundedRegularFileSync } from "./secure-file.mjs";
9
8
 
10
- const SCHEMA_VERSION = 1;
9
+ const SCHEMA_VERSION = 2;
11
10
  const MAX_STATE_BYTES = 256 * 1024;
12
- const MAX_PENDING = 100;
13
- const MAX_LEASES = 256;
14
- const PENDING_TTL_SECONDS = 10 * 60;
15
- const DEFAULT_LEASE_SECONDS = 60 * 60;
16
11
  const MAX_LEASE_SECONDS = 12 * 60 * 60;
17
12
  const FULL_MAX_LEASE_SECONDS = 8 * 60 * 60;
18
13
 
@@ -26,131 +21,93 @@ export class OperationAuthorizer {
26
21
  this.root = options.root ? path.resolve(String(options.root)) : "";
27
22
  this.resolveExistingPath = options.resolveExistingPath;
28
23
  this.resolveWritePath = options.resolveWritePath;
24
+ this.protectedRoots = Array.isArray(options.protectedRoots) ? options.protectedRoots.filter(Boolean).map((value) => path.resolve(String(value))) : [];
29
25
  this.now = typeof options.now === "function" ? options.now : Date.now;
30
- this.queue = Promise.resolve();
31
26
  }
32
27
 
33
28
  async authorize(operation) {
34
- if (operation?.context?.origin !== "relay") return { allowed: true, source: "local" };
35
- const authorization = operation.request?.authorization;
36
- const accountId = String(authorization?.account_id || "");
37
- const clientId = String(authorization?.client_id || "");
38
- const role = String(authorization?.role || "").trim().toLowerCase();
39
- if (!/^acct_[A-Za-z0-9_-]{20,96}$/.test(accountId) || !/^mcp_client_[A-Za-z0-9_-]{43}$/.test(clientId)) {
40
- throw new BridgeError("authorization_denied", "relay operation is missing authenticated client identity");
29
+ if (operation?.context?.origin !== "relay") {
30
+ return { allowed: true, source: "local", category: "local operation", scopes: [], targetHash: "" };
41
31
  }
42
- if (role === "owner") {
43
- return {
44
- allowed: true,
45
- source: "authenticated-owner",
46
- accountId,
47
- clientId,
48
- };
32
+ const authority = operation.context?.authority;
33
+ const principal = authority?.principal;
34
+ if (!authority || principal?.kind !== "account") {
35
+ throw new BridgeError("authorization_denied", "relay operation is missing effective authority context");
49
36
  }
50
37
  const requirement = await classifyOperation(operation.tool, operation.args, {
51
38
  workspace: this.workspace,
52
- resolveExistingPath: this.resolveExistingPath,
53
- resolveWritePath: this.resolveWritePath,
39
+ resolveExistingPath: (value) => this.resolveExistingPath(value, operation.context),
40
+ resolveWritePath: (value) => this.resolveWritePath(value, operation.context),
54
41
  });
55
- if (!requirement) return { allowed: true, source: "automatic" };
56
- if (!this.root) throw new BridgeError("authorization_denied", "local operation approval storage is unavailable");
57
-
58
- const requiredScopes = normalizeScopes(requirement.scopes || [requirement.scope]);
59
- const matches = matchingLeases(this.root, { accountId, clientId, scopes: requiredScopes }, this.now());
60
- const missingScopes = requiredScopes.filter((scope) => !matches.byScope.has(scope));
61
- if (!missingScopes.length) {
62
- const leaseIds = [...new Set(matches.leases.map((lease) => lease.id))];
42
+ if (requirement && requirement.canonicalTargets?.some((target) => this.protectedRoots.some((root) => isWithinRoot(root, target)))) {
43
+ throw new BridgeError("authorization_denied", "Machine Bridge control-plane state cannot be accessed through generic path-based remote tools", {
44
+ details: { reason: "control_plane_state_protected", tool: operation.tool },
45
+ });
46
+ }
47
+ if (!requirement) {
63
48
  return {
64
49
  allowed: true,
65
- source: "lease",
66
- leaseId: leaseIds[0],
67
- leaseIds,
68
- scope: requiredScopes[0],
69
- scopes: requiredScopes,
50
+ source: authority.owner ? "trusted-owner" : "role-ceiling",
51
+ category: "ordinary operation",
52
+ scopes: [],
53
+ targetHash: "",
70
54
  };
71
55
  }
72
-
73
- const pending = await this.enqueue(() => recordPendingApproval(this.root, {
74
- accountId,
75
- clientId,
76
- tool: operation.tool,
77
- scopes: missingScopes,
56
+ assertRequirementWithinAuthority(requirement, authority, operation.tool);
57
+ return {
58
+ allowed: true,
59
+ source: authority.owner ? "trusted-owner" : "role-ceiling",
78
60
  category: requirement.category,
61
+ scopes: normalizeScopes(requirement.scopes || [requirement.scope]),
79
62
  targetHash: requirement.targetHash,
80
- }, this.now()));
81
- const scopeText = missingScopes.join(", ");
82
- throw new BridgeError(
83
- "authorization_denied",
84
- `local approval required for ${requirement.category} (${scopeText}); approve the missing scopes with: machine-mcp approval approve ${pending.id} --duration 1h; or open an explicit temporary full window with: machine-mcp approval approve ${pending.id} --full`,
85
- {
86
- retryable: true,
87
- details: {
88
- reason: "local_approval_required",
89
- approval_id: pending.id,
90
- scope: missingScopes[0],
91
- scopes: missingScopes,
92
- required_scopes: requiredScopes,
93
- expires_at: pending.expires_at,
94
- approve_command: `machine-mcp approval approve ${pending.id} --duration 1h`,
95
- full_command: `machine-mcp approval approve ${pending.id} --full`,
96
- },
97
- },
98
- );
99
- }
100
-
101
- enqueue(callback) {
102
- const result = this.queue.then(callback, callback);
103
- this.queue = result.then(() => undefined, () => undefined);
104
- return result;
63
+ };
105
64
  }
106
65
  }
107
66
 
108
- export function listOperationApprovals(root, now = Date.now()) {
109
- return {
110
- leases: currentLeases(root, now),
111
- pending: currentPending(root, now),
112
- };
113
- }
114
-
115
- export async function approvePendingOperation(root, pendingId, duration = "1h", now = Date.now(), scopeOverride = "") {
116
- if (scopeOverride && scopeOverride !== "full") throw new Error("pending approval scopes may only be elevated to full");
117
- return withOperationStateLock(root, async () => {
118
- const pendingState = readPendingState(root);
119
- const leaseState = readLeaseState(root);
120
- const current = epochSeconds(now);
121
- const pending = pendingState.pending.find((entry) => entry.id === String(pendingId || "") && entry.expires_at > current);
122
- if (!pending) throw new Error("pending approval was not found or has expired");
123
- const scopes = scopeOverride ? [scopeOverride] : pending.scopes;
124
- let lease = leaseState.leases.find((entry) => entry.source_approval_id === pending.id && entry.expires_at > current);
125
- if (!lease) {
126
- lease = appendLease(leaseState, {
127
- accountId: pending.account_id,
128
- clientId: pending.client_id,
129
- scopes,
130
- duration,
131
- sourceApprovalId: pending.id,
132
- }, now);
133
- writeJson(leasePath(root), leaseState);
134
- }
135
- pendingState.pending = pendingState.pending.filter((entry) => entry.id !== pending.id && entry.expires_at > current);
136
- writeJson(pendingPath(root), pendingState);
137
- return lease;
138
- });
67
+ function isWithinRoot(root, target) {
68
+ const relative = path.relative(path.resolve(root), path.resolve(target));
69
+ return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
139
70
  }
140
71
 
141
- export async function grantOperationLease(root, { accountId = "*", clientId, scope, duration = "1h" }, now = Date.now()) {
142
- if (!/^mcp_client_[A-Za-z0-9_-]{43}$/.test(String(clientId || "")) && clientId !== "*") {
143
- throw new Error("approval grant requires a valid OAuth client id or *");
72
+ function assertRequirementWithinAuthority(requirement, authority, tool) {
73
+ const scopes = normalizeScopes(requirement.scopes || [requirement.scope]);
74
+ const policy = authority.effectivePolicy;
75
+ const role = authority.principal.role;
76
+ if (scopes.includes("full") && !authority.owner) {
77
+ throw new BridgeError("authorization_denied", "full authority is reserved for the owner account");
144
78
  }
145
- if (accountId !== "*" && !/^acct_[A-Za-z0-9_-]{20,96}$/.test(String(accountId || ""))) {
146
- throw new Error("approval account id is invalid");
79
+ if ((scopes.includes("external-read") || scopes.includes("external-write")) && !policy.unrestrictedPaths) {
80
+ throw new BridgeError("authorization_denied", `account role ${role} is confined to the selected workspace`, {
81
+ details: { reason: "account_role_path_ceiling", tool, role },
82
+ });
83
+ }
84
+ if ((scopes.includes("sensitive-read") || scopes.includes("sensitive-write") || scopes.includes("credential-operation")) && !authority.owner) {
85
+ throw new BridgeError("authorization_denied", `account role ${role} cannot access credential or persistence-sensitive targets`, {
86
+ details: { reason: "account_role_sensitive_ceiling", tool, role },
87
+ });
88
+ }
89
+ if (!authority.owner && (tool === "stage_job" || tool === "start_job")) {
90
+ throw new BridgeError("authorization_denied", `account role ${role} cannot create persistent execution plans`, {
91
+ details: { reason: "account_role_persistent_execution_ceiling", tool, role },
92
+ });
93
+ }
94
+ if (scopes.includes("shell") && !policy.allowExec) {
95
+ throw new BridgeError("authorization_denied", `account role ${role} cannot execute local processes`, {
96
+ details: { reason: "account_role_execution_ceiling", tool, role },
97
+ });
98
+ }
99
+ if ((scopes.includes("browser-session") || scopes.includes("application-control") || scopes.includes("data-export")) && !authority.owner) {
100
+ throw new BridgeError("authorization_denied", `account role ${role} cannot control the owner browser or desktop session`, {
101
+ details: { reason: "account_role_interactive_ceiling", tool, role },
102
+ });
147
103
  }
148
- return withOperationStateLock(root, async () => {
149
- const state = readLeaseState(root);
150
- const lease = appendLease(state, { accountId, clientId, scopes: [scope], duration }, now);
151
- writeJson(leasePath(root), state);
152
- return lease;
153
- });
104
+ }
105
+
106
+ // Legacy lease administration is retained only for migration and incident-response
107
+ // tooling. Runtime authorization no longer consumes leases and never asks a user to
108
+ // copy an approval ID into a terminal.
109
+ export function listOperationApprovals(root, now = Date.now()) {
110
+ return { leases: currentLeases(root, now), pending: [] };
154
111
  }
155
112
 
156
113
  export async function revokeOperationLease(root, leaseId, now = Date.now()) {
@@ -172,155 +129,39 @@ export async function clearOperationLeases(root) {
172
129
  });
173
130
  }
174
131
 
175
- export function parseApprovalDuration(value, scopes = "") {
176
- const text = String(value || "1h").trim().toLowerCase();
177
- const match = /^(\d+)(m|h)$/.exec(text);
178
- if (!match) throw new Error("approval duration must use minutes or hours, for example 30m or 2h");
179
- const seconds = Number(match[1]) * (match[2] === "h" ? 3600 : 60);
180
- const normalizedScopes = normalizeScopes(Array.isArray(scopes) ? scopes : [scopes]);
181
- const maximum = normalizedScopes.includes("full") ? FULL_MAX_LEASE_SECONDS : MAX_LEASE_SECONDS;
182
- if (!Number.isSafeInteger(seconds) || seconds < 60 || seconds > maximum) {
183
- throw new Error(`approval duration must be between 1m and ${maximum / 3600}h`);
184
- }
185
- return seconds;
186
- }
187
-
188
- async function recordPendingApproval(root, input, now) {
189
- return withOperationStateLock(root, async () => {
190
- const state = readPendingState(root);
191
- const current = epochSeconds(now);
192
- const scopes = normalizeScopes(input.scopes);
193
- state.pending = state.pending.filter((entry) => entry.expires_at > current);
194
- const existing = state.pending.find((entry) => (
195
- entry.account_id === input.accountId
196
- && entry.client_id === input.clientId
197
- && sameScopes(entry.scopes, scopes)
198
- && entry.target_hash === input.targetHash
199
- ));
200
- if (existing) return existing;
201
- const pending = {
202
- id: `approval_${randomBytes(18).toString("base64url")}`,
203
- account_id: input.accountId,
204
- client_id: input.clientId,
205
- tool: String(input.tool).slice(0, 128),
206
- scopes,
207
- category: String(input.category).slice(0, 160),
208
- target_hash: input.targetHash,
209
- created_at: current,
210
- expires_at: current + PENDING_TTL_SECONDS,
211
- };
212
- state.pending.push(pending);
213
- state.pending = state.pending.slice(-MAX_PENDING);
214
- writeJson(pendingPath(root), state);
215
- return pending;
216
- });
217
- }
218
-
219
- function appendLease(state, input, now) {
220
- const scopes = normalizeScopes(input.scopes);
221
- if (!scopes.length) throw new Error("capability lease requires at least one known scope");
222
- if (scopes.includes("full") && scopes.length !== 1) throw new Error("full capability lease cannot be combined with narrower scopes");
223
- const seconds = parseApprovalDuration(input.duration || `${DEFAULT_LEASE_SECONDS / 3600}h`, scopes);
224
- const current = epochSeconds(now);
225
- state.leases = state.leases.filter((lease) => lease.expires_at > current);
226
- if (state.leases.length >= MAX_LEASES) throw new Error(`operation capability leases exceed the maximum of ${MAX_LEASES}`);
227
- const lease = {
228
- id: `lease_${randomBytes(18).toString("base64url")}`,
229
- account_id: String(input.accountId || "*"),
230
- client_id: String(input.clientId || ""),
231
- scopes,
232
- created_at: current,
233
- expires_at: current + seconds,
234
- ...(input.sourceApprovalId ? { source_approval_id: String(input.sourceApprovalId) } : {}),
235
- };
236
- state.leases.push(lease);
237
- return lease;
238
- }
239
-
240
- function matchingLeases(root, input, now) {
241
- const current = epochSeconds(now);
242
- const candidates = currentLeases(root, now).filter((lease) => (
243
- (lease.account_id === "*" || lease.account_id === input.accountId)
244
- && (lease.client_id === "*" || lease.client_id === input.clientId)
245
- && lease.expires_at > current
246
- ));
247
- const byScope = new Map();
248
- for (const scope of input.scopes) {
249
- const lease = candidates.find((entry) => entry.scopes.includes("full") || entry.scopes.includes(scope));
250
- if (lease) byScope.set(scope, lease);
251
- }
252
- return { byScope, leases: [...byScope.values()] };
253
- }
254
-
255
132
  function currentLeases(root, now) {
256
133
  const current = epochSeconds(now);
257
134
  return readLeaseState(root).leases.filter((lease) => lease.expires_at > current);
258
135
  }
259
136
 
260
- function currentPending(root, now) {
261
- const current = epochSeconds(now);
262
- return readPendingState(root).pending.filter((entry) => entry.expires_at > current);
263
- }
264
-
265
137
  function readLeaseState(root) {
266
- return readState(leasePath(root), emptyLeaseState, "leases", validLease);
267
- }
268
-
269
- function readPendingState(root) {
270
- return readState(pendingPath(root), emptyPendingState, "pending", validPending);
271
- }
272
-
273
- function readState(file, empty, arrayKey, validEntry) {
274
- if (!existsSync(file)) return empty();
138
+ const file = leasePath(root);
139
+ if (!existsSync(file)) return emptyLeaseState();
275
140
  const raw = readBoundedRegularFileSync(file, MAX_STATE_BYTES, "operation approval state");
276
141
  let parsed;
277
142
  try { parsed = JSON.parse(raw.toString("utf8")); } catch (error) {
278
143
  throw new Error("operation approval state is not valid JSON", { cause: error });
279
144
  }
280
- if (
281
- !parsed
282
- || typeof parsed !== "object"
283
- || Array.isArray(parsed)
284
- || parsed.schemaVersion !== SCHEMA_VERSION
285
- || !Array.isArray(parsed[arrayKey])
286
- || !parsed[arrayKey].every(validEntry)
287
- ) {
145
+ if (parsed?.schemaVersion === 1 && Array.isArray(parsed.leases)) return emptyLeaseState();
146
+ if (!plainRecord(parsed) || parsed.schemaVersion !== SCHEMA_VERSION || !Array.isArray(parsed.leases) || !parsed.leases.every(validLease)) {
288
147
  throw new Error("operation approval state schema is invalid");
289
148
  }
290
149
  return parsed;
291
150
  }
292
151
 
293
152
  function validLease(value) {
294
- if (!plainRecord(value)) return false;
295
- if (!/^lease_[A-Za-z0-9_-]{24}$/.test(value.id)) return false;
153
+ if (!plainRecord(value) || !/^lease_[A-Za-z0-9_-]{24}$/.test(value.id)) return false;
296
154
  if (!validAccountBinding(value.account_id) || !validClientBinding(value.client_id)) return false;
155
+ if (value.account_id === "*" ? value.account_version !== 0 : !Number.isSafeInteger(value.account_version) || value.account_version <= 0) return false;
297
156
  if (!validScopes(value.scopes) || !validTimestampRange(value.created_at, value.expires_at)) return false;
298
157
  const maximum = value.scopes.includes("full") ? FULL_MAX_LEASE_SECONDS : MAX_LEASE_SECONDS;
299
- if (value.expires_at - value.created_at > maximum) return false;
300
- return value.source_approval_id === undefined || /^approval_[A-Za-z0-9_-]{24}$/.test(value.source_approval_id);
301
- }
302
-
303
- function validPending(value) {
304
- if (!plainRecord(value)) return false;
305
- return /^approval_[A-Za-z0-9_-]{24}$/.test(value.id)
306
- && validAccountBinding(value.account_id, false)
307
- && validClientBinding(value.client_id, false)
308
- && /^[a-z][a-z0-9_]{0,127}$/.test(value.tool)
309
- && validScopes(value.scopes, false)
310
- && typeof value.category === "string"
311
- && value.category.length > 0
312
- && value.category.length <= 160
313
- && !/[\u0000-\u001f\u007f]/.test(value.category)
314
- && /^[a-f0-9]{64}$/.test(value.target_hash)
315
- && validTimestampRange(value.created_at, value.expires_at)
316
- && value.expires_at - value.created_at === PENDING_TTL_SECONDS;
158
+ return value.expires_at - value.created_at <= maximum;
317
159
  }
318
160
 
319
- function validScopes(value, allowFull = true) {
161
+ function validScopes(value) {
320
162
  if (!Array.isArray(value) || value.length < 1 || value.length > OPERATION_APPROVAL_SCOPES.length) return false;
321
163
  if (value.some((scope) => !SCOPE_SET.has(scope))) return false;
322
164
  if (!sameScopes(value, normalizeScopes(value))) return false;
323
- if (!allowFull && value.includes("full")) return false;
324
165
  return !value.includes("full") || value.length === 1;
325
166
  }
326
167
 
@@ -342,10 +183,7 @@ function validClientBinding(value, wildcard = true) {
342
183
  }
343
184
 
344
185
  function validTimestampRange(createdAt, expiresAt) {
345
- return Number.isSafeInteger(createdAt)
346
- && Number.isSafeInteger(expiresAt)
347
- && createdAt > 0
348
- && expiresAt > createdAt;
186
+ return Number.isSafeInteger(createdAt) && Number.isSafeInteger(expiresAt) && createdAt > 0 && expiresAt > createdAt;
349
187
  }
350
188
 
351
189
  function plainRecord(value) {
@@ -360,7 +198,5 @@ function writeJson(file, value) {
360
198
  }
361
199
 
362
200
  function leasePath(root) { return path.join(path.resolve(root), "operation-leases.json"); }
363
- function pendingPath(root) { return path.join(path.resolve(root), "operation-pending.json"); }
364
201
  function emptyLeaseState() { return { schemaVersion: SCHEMA_VERSION, leases: [] }; }
365
- function emptyPendingState() { return { schemaVersion: SCHEMA_VERSION, pending: [] }; }
366
202
  function epochSeconds(value) { return Math.floor(Number(value) / 1000); }
@@ -55,7 +55,11 @@ export function reviewedOperationToolNames() {
55
55
 
56
56
  export async function classifyOperation(tool, args = {}, options = {}) {
57
57
  const name = String(tool || "");
58
+ if (name === "list_local_resources") return requirement("sensitive-read", "protected local resource inventory", name, {});
58
59
  if (SHELL_TOOLS.has(name)) return requirement("shell", "remote shell or process control", name, args);
60
+ if ((name === "stage_job" || name === "start_job") && jobUsesProtectedResources(args)) {
61
+ return requirement(["persistent-job", "sensitive-read"], "managed job using protected local resources", name, protectedJobProjection(args));
62
+ }
59
63
  if (PERSISTENT_TOOLS.has(name)) return requirement("persistent-job", "persistent managed job", name, args);
60
64
  if (name === "operate_local_application" && args.value_resource) {
61
65
  return requirement(["application-control", "data-export"], "desktop application control using protected local data", name, {
@@ -113,6 +117,19 @@ export async function classifyOperation(tool, args = {}, options = {}) {
113
117
  return null;
114
118
  }
115
119
 
120
+ function jobUsesProtectedResources(args) {
121
+ const steps = [...(Array.isArray(args?.steps) ? args.steps : []), ...(Array.isArray(args?.finally_steps) ? args.finally_steps : [])];
122
+ return steps.some((step) => Boolean(step?.stdin_resource) || (step?.env_resources && Object.keys(step.env_resources).length > 0));
123
+ }
124
+
125
+ function protectedJobProjection(args) {
126
+ const steps = [...(Array.isArray(args?.steps) ? args.steps : []), ...(Array.isArray(args?.finally_steps) ? args.finally_steps : [])];
127
+ return {
128
+ step_count: steps.length,
129
+ resource_reference_count: steps.reduce((count, step) => count + (step?.stdin_resource ? 1 : 0) + Object.keys(step?.env_resources || {}).length, 0),
130
+ };
131
+ }
132
+
116
133
  function requirement(scopes, category, tool, target) {
117
134
  const normalized = normalizeScopes(Array.isArray(scopes) ? scopes : [scopes]);
118
135
  if (!normalized.length) throw new Error("operation approval requirement is missing a scope");
@@ -121,9 +138,20 @@ function requirement(scopes, category, tool, target) {
121
138
  scopes: normalized,
122
139
  category,
123
140
  targetHash: createHash("sha256").update(JSON.stringify({ tool, target: redactTarget(target) })).digest("hex"),
141
+ canonicalTargets: canonicalTargetPaths(target),
124
142
  };
125
143
  }
126
144
 
145
+ function canonicalTargetPaths(target) {
146
+ if (!target || typeof target !== "object") return [];
147
+ const values = [];
148
+ if (typeof target.path === "string" && path.isAbsolute(target.path)) values.push(path.resolve(target.path));
149
+ if (Array.isArray(target.paths)) {
150
+ for (const value of target.paths) if (typeof value === "string" && path.isAbsolute(value)) values.push(path.resolve(value));
151
+ }
152
+ return [...new Set(values)];
153
+ }
154
+
127
155
  function normalizeScopes(scopes) {
128
156
  const requested = new Set(scopes.map((scope) => String(scope || "")));
129
157
  return OPERATION_APPROVAL_SCOPES.filter((scope) => requested.has(scope));
@@ -1,92 +1,13 @@
1
- import { randomBytes } from "node:crypto";
2
- import { existsSync } from "node:fs";
3
- import path from "node:path";
4
- import { createExclusiveFileSync, removeOwnedJsonFileSync } from "./exclusive-file.mjs";
5
- import { createMonotonicDeadline } from "./monotonic-deadline.mjs";
6
- import { currentProcessStartTimeMs, inspectProcessInstance } from "./process-identity.mjs";
7
- import { ensureOwnerOnlyDirectorySync, readBoundedRegularFileSync } from "./secure-file.mjs";
1
+ import { withOwnerStateLock } from "./owner-state-lock.mjs";
8
2
 
9
3
  const LOCK_PURPOSE = "operation-authorization";
10
4
  const LOCK_FILE = "operation-authorization.lock";
11
- const MAX_LOCK_BYTES = 8 * 1024;
12
- const DEFAULT_WAIT_MS = 5_000;
13
- const DEFAULT_POLL_MS = 25;
14
5
 
15
- export async function withOperationStateLock(root, callback, options = {}) {
16
- if (typeof callback !== "function") throw new TypeError("operation-state lock requires a callback");
17
- const requestedRoot = String(root || "").trim();
18
- if (!requestedRoot) throw new Error("operation-state lock root is missing");
19
- const directory = path.resolve(requestedRoot);
20
- ensureOwnerOnlyDirectorySync(directory);
21
- const lockPath = path.join(directory, LOCK_FILE);
22
- const timeoutMs = positiveInteger(options.timeoutMs, DEFAULT_WAIT_MS);
23
- const pollMs = positiveInteger(options.pollMs, DEFAULT_POLL_MS);
24
- const deadline = createMonotonicDeadline(timeoutMs);
25
- const owner = lockOwner();
26
-
27
- while (true) {
28
- try {
29
- createExclusiveFileSync(lockPath, `${JSON.stringify(owner, null, 2)}\n`, { mode: 0o600 });
30
- break;
31
- } catch (error) {
32
- if (error?.code !== "EEXIST") throw error;
33
- const existing = readLockOwner(lockPath);
34
- if (!existing) throw new Error("operation authorization lock is malformed; inspect the owner-only state directory");
35
- if (existing.purpose !== LOCK_PURPOSE) throw new Error("operation authorization lock contains mismatched purpose metadata");
36
- const identity = inspectProcessInstance(existing, { maxAgeMs: Number.POSITIVE_INFINITY });
37
- if (identity.reclaimable) {
38
- if (removeOwnedJsonFileSync(lockPath, { token: existing.token, purpose: LOCK_PURPOSE }, { maxBytes: MAX_LOCK_BYTES })) continue;
39
- }
40
- if (deadline.expired()) {
41
- const pid = Number.isInteger(existing.pid) ? `pid ${existing.pid}` : "unknown process";
42
- throw new Error(`operation authorization state is busy (${pid}); retry after the current approval operation finishes`);
43
- }
44
- await delay(Math.min(pollMs, Math.max(1, deadline.remainingMs())));
45
- }
46
- }
47
-
48
- let result;
49
- let callbackError;
50
- try {
51
- result = await callback();
52
- } catch (error) {
53
- callbackError = error;
54
- }
55
- const released = removeOwnedJsonFileSync(lockPath, { token: owner.token, purpose: LOCK_PURPOSE }, { maxBytes: MAX_LOCK_BYTES });
56
- if (!released) throw new Error("operation authorization lock changed before release; approval state may require inspection");
57
- if (callbackError) throw callbackError;
58
- return result;
59
- }
60
-
61
- function lockOwner() {
62
- return {
63
- pid: process.pid,
64
- token: randomBytes(16).toString("hex"),
6
+ export function withOperationStateLock(root, callback, options = {}) {
7
+ return withOwnerStateLock(root, callback, {
8
+ ...options,
65
9
  purpose: LOCK_PURPOSE,
66
- startedAt: new Date().toISOString(),
67
- processStartedAt: new Date(currentProcessStartTimeMs()).toISOString(),
68
- entryScript: String(process.argv[1] || "").slice(0, 4096),
69
- };
70
- }
71
-
72
- function readLockOwner(file) {
73
- if (!existsSync(file)) return null;
74
- let parsed;
75
- try { parsed = JSON.parse(readBoundedRegularFileSync(file, MAX_LOCK_BYTES, "operation authorization lock").toString("utf8")); } catch { return null; }
76
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
77
- if (!Number.isInteger(parsed.pid) || parsed.pid <= 0) return null;
78
- if (!/^[a-f0-9]{32}$/.test(String(parsed.token || ""))) return null;
79
- if (parsed.purpose !== LOCK_PURPOSE) return null;
80
- if (!Number.isFinite(Date.parse(String(parsed.startedAt || "")))) return null;
81
- if (!Number.isFinite(Date.parse(String(parsed.processStartedAt || "")))) return null;
82
- return parsed;
83
- }
84
-
85
- function positiveInteger(value, fallback) {
86
- const number = Number(value);
87
- return Number.isFinite(number) && number > 0 ? Math.floor(number) : fallback;
88
- }
89
-
90
- function delay(milliseconds) {
91
- return new Promise((resolvePromise) => { setTimeout(resolvePromise, milliseconds); });
10
+ fileName: LOCK_FILE,
11
+ label: "operation authorization",
12
+ });
92
13
  }
@@ -0,0 +1,104 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { existsSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { createExclusiveFileSync, removeOwnedJsonFileSync } from "./exclusive-file.mjs";
5
+ import { createMonotonicDeadline } from "./monotonic-deadline.mjs";
6
+ import { currentProcessStartTimeMs, inspectProcessInstance } from "./process-identity.mjs";
7
+ import { ensureOwnerOnlyDirectorySync, readBoundedRegularFileSync } from "./secure-file.mjs";
8
+
9
+ const MAX_LOCK_BYTES = 8 * 1024;
10
+ const DEFAULT_WAIT_MS = 5_000;
11
+ const DEFAULT_POLL_MS = 25;
12
+
13
+ export async function withOwnerStateLock(root, callback, options = {}) {
14
+ if (typeof callback !== "function") throw new TypeError("owner-state lock requires a callback");
15
+ const requestedRoot = String(root || "").trim();
16
+ if (!requestedRoot) throw new Error("owner-state lock root is missing");
17
+ const purpose = boundedIdentifier(options.purpose, "state");
18
+ const fileName = boundedFileName(options.fileName, `${purpose}.lock`);
19
+ const label = String(options.label || purpose).replace(/[\r\n\t\u0000-\u001f\u007f]/g, " ").trim().slice(0, 80) || purpose;
20
+ const directory = path.resolve(requestedRoot);
21
+ ensureOwnerOnlyDirectorySync(directory);
22
+ const lockPath = path.join(directory, fileName);
23
+ const timeoutMs = positiveInteger(options.timeoutMs, DEFAULT_WAIT_MS);
24
+ const pollMs = positiveInteger(options.pollMs, DEFAULT_POLL_MS);
25
+ const maxAgeMs = Number.isFinite(Number(options.maxAgeMs)) ? Math.max(1, Number(options.maxAgeMs)) : Number.POSITIVE_INFINITY;
26
+ const deadline = createMonotonicDeadline(timeoutMs);
27
+ const owner = lockOwner(purpose);
28
+
29
+ while (true) {
30
+ try {
31
+ createExclusiveFileSync(lockPath, `${JSON.stringify(owner, null, 2)}\n`, { mode: 0o600 });
32
+ break;
33
+ } catch (error) {
34
+ if (error?.code !== "EEXIST") throw error;
35
+ const existing = readLockOwner(lockPath, purpose);
36
+ if (!existing) throw new Error(`${label} lock is malformed; inspect the owner-only state directory`);
37
+ const identity = inspectProcessInstance(existing, { maxAgeMs });
38
+ if (identity.reclaimable) {
39
+ if (removeOwnedJsonFileSync(lockPath, { token: existing.token, purpose }, { maxBytes: MAX_LOCK_BYTES })) continue;
40
+ }
41
+ if (deadline.expired()) {
42
+ const pid = Number.isInteger(existing.pid) ? `pid ${existing.pid}` : "unknown process";
43
+ throw new Error(`${label} state is busy (${pid}); retry after the current operation finishes`);
44
+ }
45
+ await delay(Math.min(pollMs, Math.max(1, deadline.remainingMs())));
46
+ }
47
+ }
48
+
49
+ let result;
50
+ let callbackError;
51
+ try {
52
+ result = await callback();
53
+ } catch (error) {
54
+ callbackError = error;
55
+ }
56
+ const released = removeOwnedJsonFileSync(lockPath, { token: owner.token, purpose }, { maxBytes: MAX_LOCK_BYTES });
57
+ if (!released) throw new Error(`${label} lock changed before release; state may require inspection`);
58
+ if (callbackError) throw callbackError;
59
+ return result;
60
+ }
61
+
62
+ function lockOwner(purpose) {
63
+ return {
64
+ pid: process.pid,
65
+ token: randomBytes(16).toString("hex"),
66
+ purpose,
67
+ startedAt: new Date().toISOString(),
68
+ processStartedAt: new Date(currentProcessStartTimeMs()).toISOString(),
69
+ entryScript: String(process.argv[1] || "").slice(0, 4096),
70
+ };
71
+ }
72
+
73
+ function readLockOwner(file, purpose) {
74
+ if (!existsSync(file)) return null;
75
+ let parsed;
76
+ try { parsed = JSON.parse(readBoundedRegularFileSync(file, MAX_LOCK_BYTES, "owner-state lock").toString("utf8")); } catch { return null; }
77
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
78
+ if (!Number.isInteger(parsed.pid) || parsed.pid <= 0) return null;
79
+ if (!/^[a-f0-9]{32}$/.test(String(parsed.token || ""))) return null;
80
+ if (parsed.purpose !== purpose) return null;
81
+ if (!Number.isFinite(Date.parse(String(parsed.startedAt || "")))) return null;
82
+ if (!Number.isFinite(Date.parse(String(parsed.processStartedAt || "")))) return null;
83
+ return parsed;
84
+ }
85
+
86
+ function boundedIdentifier(value, fallback) {
87
+ const text = String(value || fallback).toLowerCase().replace(/[^a-z0-9._-]/g, "-").slice(0, 64);
88
+ return text || fallback;
89
+ }
90
+
91
+ function boundedFileName(value, fallback) {
92
+ const name = path.basename(String(value || fallback));
93
+ if (!/^[a-z0-9][a-z0-9._-]{0,95}\.lock$/.test(name)) throw new Error("owner-state lock filename is invalid");
94
+ return name;
95
+ }
96
+
97
+ function positiveInteger(value, fallback) {
98
+ const number = Number(value);
99
+ return Number.isFinite(number) && number > 0 ? Math.floor(number) : fallback;
100
+ }
101
+
102
+ function delay(milliseconds) {
103
+ return new Promise((resolvePromise) => { setTimeout(resolvePromise, milliseconds); });
104
+ }