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

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 +109 -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 +87 -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
@@ -6,7 +6,7 @@ import { isRelayReadyContext } from "./relay-connection.mjs";
6
6
  import { ProcessSessionManager } from "./process-sessions.mjs";
7
7
  import { MAX_CONCURRENT_TOOL_CALLS } from "./execution-limits.mjs";
8
8
  export { MAX_COMMAND_BYTES } from "./process-contract.mjs";
9
- import { normalizePolicy, PolicyGate } from "./tools.mjs";
9
+ import { normalizePolicy, PolicyGate, toolNamesForPolicy } from "./tools.mjs";
10
10
  import { publicError } from "./errors.mjs";
11
11
  import { ProcessTracker } from "./process-tracker.mjs";
12
12
  import { CallRegistry } from "./call-registry.mjs";
@@ -14,6 +14,7 @@ import { RuntimeObservability } from "./observability.mjs";
14
14
  import { ToolExecutor } from "./tool-executor.mjs";
15
15
  import { boundedErrorMessage, ProcessExecutionService } from "./process-execution.mjs";
16
16
  import { GitService } from "./git-service.mjs";
17
+ import { createTrustedGitResolver } from "./trusted-git-executable.mjs";
17
18
  import { LifecycleController } from "./lifecycle.mjs";
18
19
  import { MAX_WRITE_BYTES, sha256, WorkspaceFileService } from "./workspace-file-service.mjs";
19
20
  export { MAX_WRITE_BYTES, sha256 } from "./workspace-file-service.mjs";
@@ -32,6 +33,9 @@ import { buildProjectOverview, buildRuntimeInfo } from "./runtime-reporting.mjs"
32
33
  import { diagnoseRuntime as runRuntimeDiagnostics } from "./runtime-diagnostics.mjs";
33
34
  import { bindRuntimeToolHandlers, runtimeToolHandlerNames as registeredRuntimeToolHandlerNames } from "./runtime-tool-handlers.mjs";
34
35
  import { OperationAuthorizer } from "./operation-authorization.mjs";
36
+ import { SecurityAuditLog } from "./security-audit-log.mjs";
37
+ import { delegatedProcessIsolationStatus } from "./delegated-process-sandbox.mjs";
38
+ import { policyForContext } from "./authority-context.mjs";
35
39
  import { createRuntimeRelayConnection, normalizeRelayResumeCalls, normalizeRelayToolCall } from "./runtime-relay.mjs";
36
40
  import { RelayCallRecovery } from "./relay-call-recovery.mjs";
37
41
  import { assertContainedPath, createRuntimeDir, redactRuntimeErrorMessage, stateRootFromProfileStatePath } from "./runtime-paths.mjs";
@@ -47,7 +51,7 @@ export function runtimeToolHandlerNames() {
47
51
  }
48
52
 
49
53
  export class LocalRuntime {
50
- constructor({ workerUrl = "", deviceIdentity = null, expectedRelayVersion = "", workspace, policy, logger = console, onSuperseded = null, onFatal = null, jobRoot = "", approvalRoot = "", resources = {}, resourceStatePath = "", browserStateRoot = "", agentHome = process.env.HOME || process.env.USERPROFILE || "", codexHome = process.env.CODEX_HOME || "", recoverJobs = true, applicationAutomation = {} }) {
54
+ constructor({ workerUrl = "", deviceIdentity = null, expectedRelayVersion = "", workspace, policy, logger = console, onSuperseded = null, onFatal = null, jobRoot = "", approvalRoot = "", resources = {}, resourceStatePath = "", browserStateRoot = "", agentHome = process.env.HOME || process.env.USERPROFILE || "", codexHome = process.env.CODEX_HOME || "", recoverJobs = true, applicationAutomation = {}, deviceRootStatus = null, resolveGitExecutable = null }) {
51
55
  const remoteWorkerUrl = workerUrl ? String(workerUrl) : "";
52
56
  this.workspaceInput = resolve(workspace || process.cwd());
53
57
  this.workspace = realpathSync.native ? realpathSync.native(this.workspaceInput) : realpathSync(this.workspaceInput);
@@ -58,6 +62,7 @@ export class LocalRuntime {
58
62
  this.logger = logger;
59
63
  this.onSuperseded = typeof onSuperseded === "function" ? onSuperseded : null;
60
64
  this.resourceStatePath = resourceStatePath ? resolve(resourceStatePath) : "";
65
+ this.deviceRootStatus = deviceRootStatus && typeof deviceRootStatus === "object" ? Object.freeze({ ...deviceRootStatus }) : null;
61
66
  this.processTracker = new ProcessTracker();
62
67
  this.lifecycle = new LifecycleController("local runtime");
63
68
  this.observability = new RuntimeObservability();
@@ -80,13 +85,15 @@ export class LocalRuntime {
80
85
  this.mutationQueue = Promise.resolve();
81
86
  this.capabilityObserver = new CapabilityObserver();
82
87
  this.runtimeDir = createRuntimeDir();
88
+ this.resolveGitExecutable = createTrustedGitResolver({ resolve: resolveGitExecutable, workspace: this.workspace, stateRoot: browserStateRoot, runtimeDir: this.runtimeDir, home: agentHome });
83
89
  this.workspaceFileService = new WorkspaceFileService({
84
90
  workspace: this.workspace,
85
91
  policy: this.policy,
86
92
  policyGate: this.policyGate,
87
- resolveExistingPath: (value) => this.resolveExistingPath(value),
88
- resolveWritePath: (value) => this.resolveWritePath(value),
89
- displayPath: (value) => this.displayPath(value),
93
+ policyForContext: (context) => this.effectivePolicy(context),
94
+ resolveExistingPath: (value, context) => this.resolveExistingPath(value, context),
95
+ resolveWritePath: (value, context) => this.resolveWritePath(value, context),
96
+ displayPath: (value, context) => this.displayPath(value, context),
90
97
  throwIfCancelled: (context) => this.throwIfCancelled(context),
91
98
  withMutationLock: (operation) => this.withMutationLock(operation),
92
99
  });
@@ -96,6 +103,7 @@ export class LocalRuntime {
96
103
  workspace: this.workspace,
97
104
  policy: this.policy,
98
105
  authorizeTool: (tool) => this.policyGate.assert(tool),
106
+ policyForContext: (context) => this.effectivePolicy(context),
99
107
  resources,
100
108
  resourceStatePath,
101
109
  stateRoot: browserStateRoot,
@@ -106,21 +114,23 @@ export class LocalRuntime {
106
114
  workspace: this.workspace,
107
115
  policy: this.policy,
108
116
  authorizeTool: (tool) => this.policyGate.assert(tool),
117
+ policyForContext: (context) => this.effectivePolicy(context),
109
118
  runtimeDir: this.runtimeDir,
110
119
  processTracker: this.processTracker,
111
- resolveCwd: async (input) => {
112
- const cwd = await this.resolveExistingPath(input);
120
+ resolveCwd: async (input, context) => {
121
+ const cwd = await this.resolveExistingPath(input, context);
113
122
  if (!(await stat(cwd)).isDirectory()) throw new Error("cwd is not a directory");
114
123
  return cwd;
115
124
  },
116
- displayPath: (value) => this.displayPath(value),
125
+ displayPath: (value, context) => this.displayPath(value, context),
117
126
  throwIfCancelled: (context) => this.throwIfCancelled(context),
118
127
  });
119
128
  this.agentContextManager = new AgentContextManager({
120
129
  workspace: this.workspace,
121
130
  policy: this.policy,
122
- displayPath: (value) => this.displayPath(value),
123
- resolveExistingPath: (value) => this.resolveExistingPath(value),
131
+ policyForContext: (context) => this.effectivePolicy(context),
132
+ displayPath: (value, context) => this.displayPath(value, context),
133
+ resolveExistingPath: (value, context) => this.resolveExistingPath(value, context),
124
134
  throwIfCancelled: (context) => this.throwIfCancelled(context),
125
135
  home: agentHome,
126
136
  codexHome,
@@ -129,18 +139,20 @@ export class LocalRuntime {
129
139
  workspace: this.workspace,
130
140
  policy: this.policy,
131
141
  policyGate: this.policyGate,
142
+ policyForContext: (context) => this.effectivePolicy(context),
132
143
  runtimeDir: this.runtimeDir,
133
144
  processTracker: this.processTracker,
134
- resolveExistingPath: (value) => this.resolveExistingPath(value),
145
+ resolveExistingPath: (value, context) => this.resolveExistingPath(value, context),
135
146
  resolveLocalCommand: (args, context) => this.agentContextManager.resolveLocalCommand(args, context),
136
- displayPath: (value) => this.displayPath(value),
147
+ displayPath: (value, context) => this.displayPath(value, context),
137
148
  throwIfCancelled: (context) => this.throwIfCancelled(context),
138
- retainCompletedOutput: (value) => this.processSessionManager.retainCompletedOutput(value),
149
+ retainCompletedOutput: (value, context) => this.processSessionManager.retainCompletedOutput(value, context),
139
150
  });
140
151
  this.gitService = new GitService({
141
- resolveExistingPath: (value) => this.resolveExistingPath(value),
142
- displayPath: (value) => this.displayPath(value),
143
- runProcess: (...args) => this.processExecutionService.run(...args),
152
+ resolveExistingPath: (value, context) => this.resolveExistingPath(value, context),
153
+ displayPath: (value, context) => this.displayPath(value, context),
154
+ runInternalProcess: (...args) => this.processExecutionService.runFixedInternal(...args),
155
+ gitExecutable: () => this.resolveGitExecutable(),
144
156
  maximumBytes: MAX_WRITE_BYTES,
145
157
  });
146
158
  const runProcess = (cmd, argv, timeoutMs, allowFailure, maxOutputBytes, context, cwd, stdin) => this.runProcess(cmd, argv, timeoutMs, allowFailure, maxOutputBytes, context, cwd, stdin);
@@ -150,16 +162,18 @@ export class LocalRuntime {
150
162
  ...applicationAutomation,
151
163
  policy: this.policy,
152
164
  authorizeTool: (tool) => this.policyGate.assert(tool),
153
- displayPath: (value) => this.displayPath(value),
165
+ displayPath: (value, context) => this.displayPath(value, context),
154
166
  runProcess,
155
167
  readResourceText,
156
168
  throwIfCancelled: (context) => this.throwIfCancelled(context),
157
169
  });
170
+ this.securityAudit = new SecurityAuditLog({ root: approvalRoot });
158
171
  this.operationAuthorizer = new OperationAuthorizer({
159
172
  workspace: this.workspace,
160
173
  root: approvalRoot,
161
- resolveExistingPath: (value) => this.resolveExistingPath(value),
162
- resolveWritePath: (value) => this.resolveWritePath(value),
174
+ resolveExistingPath: (value, context) => this.resolveExistingPath(value, context),
175
+ resolveWritePath: (value, context) => this.resolveWritePath(value, context),
176
+ protectedRoots: [approvalRoot, browserStateRoot],
163
177
  });
164
178
  this.browserBridgeManager = new BrowserBridgeManager({
165
179
  policy: this.policy,
@@ -178,8 +192,9 @@ export class LocalRuntime {
178
192
  operationAuthorizer: this.operationAuthorizer,
179
193
  callRegistry: this.callRegistry,
180
194
  observability: this.observability,
195
+ securityAudit: this.securityAudit,
181
196
  logger: this.logger,
182
- safeMessage: (error, args) => this.safeErrorMessage(error, args),
197
+ safeMessage: (error, args, context) => this.safeErrorMessage(error, args, context),
183
198
  slowMs: SLOW_TOOL_CALL_MS,
184
199
  });
185
200
  this.relay = createRuntimeRelayConnection(this, {
@@ -198,11 +213,11 @@ export class LocalRuntime {
198
213
 
199
214
  tools() { return this.policyGate.names().filter((name) => name !== "server_info"); }
200
215
 
201
- runtimeInfo() {
202
- return buildRuntimeInfo({
216
+ runtimeInfo(context = {}) {
217
+ const info = buildRuntimeInfo({
203
218
  workspace: this.workspace,
204
- displayPath: (value) => this.displayPath(value),
205
- policy: this.policy,
219
+ displayPath: (value) => this.displayPath(value, context),
220
+ policy: this.effectivePolicy(context),
206
221
  toolNames: this.tools(),
207
222
  capabilityObserver: this.capabilityObserver,
208
223
  observability: this.observability,
@@ -214,6 +229,16 @@ export class LocalRuntime {
214
229
  processSessionManager: this.processSessionManager,
215
230
  managedJobManager: this.managedJobManager,
216
231
  });
232
+ return {
233
+ ...info,
234
+ security_audit: this.securityAudit.snapshot(),
235
+ trust: {
236
+ device_root: this.deviceRootStatus,
237
+ daemon_session: { ephemeral: true, certificate_lifetime_seconds: 86400, reconnect_prompts: false },
238
+ delegated_process_isolation: delegatedProcessIsolationStatus(),
239
+ routine_operation_prompts: false,
240
+ },
241
+ };
217
242
  }
218
243
 
219
244
  async start() {
@@ -427,20 +452,24 @@ export class LocalRuntime {
427
452
  }
428
453
 
429
454
  async projectOverview(context = {}) {
455
+ const effectivePolicy = this.effectivePolicy(context);
430
456
  return buildProjectOverview({
431
457
  workspace: this.workspace,
432
- displayPath: (value) => this.displayPath(value),
433
- policy: this.policy,
434
- toolNames: this.tools(),
458
+ displayPath: (value) => this.displayPath(value, context),
459
+ policy: effectivePolicy,
460
+ toolNames: toolNamesForPolicy(effectivePolicy).filter((name) => name !== "server_info"),
461
+ daemonPolicy: this.policy,
462
+ daemonToolNames: this.tools(),
435
463
  capabilityObserver: this.capabilityObserver,
436
464
  listTopLevel: (callContext) => this.listDir(".", callContext),
437
- runProcess: (...args) => this.runProcess(...args),
438
- safeErrorMessage: (error) => this.safeErrorMessage(error),
465
+ runInternalProcess: (...args) => this.processExecutionService.runFixedInternal(...args),
466
+ gitExecutable: () => this.resolveGitExecutable(),
467
+ safeErrorMessage: (error) => this.safeErrorMessage(error, {}, context),
439
468
  throwIfCancelled: (callContext) => this.throwIfCancelled(callContext),
440
469
  }, context);
441
470
  }
442
471
 
443
- listRoots() { return this.workspaceFileService.listRoots(); }
472
+ listRoots(context = {}) { return this.workspaceFileService.listRoots(context); }
444
473
 
445
474
  listDir(pathValue, context = {}) { return this.workspaceFileService.listDir(pathValue, context); }
446
475
 
@@ -482,6 +511,7 @@ export class LocalRuntime {
482
511
  runProcess: (...args) => this.runProcess(...args),
483
512
  probeShell: (callContext) => this.processExecutionService.probeShell(callContext),
484
513
  managedJobManager: this.managedJobManager,
514
+ relayStatus: () => this.relay?.status?.() || null,
485
515
  throwIfCancelled: (callContext) => this.throwIfCancelled(callContext),
486
516
  }, context);
487
517
  }
@@ -577,6 +607,10 @@ export class LocalRuntime {
577
607
  return this.processExecutionService.run(cmd, args, timeoutMs, allowFailure, maxOutputBytes, context, cwd, stdin);
578
608
  }
579
609
 
610
+ effectivePolicy(context = {}) {
611
+ return policyForContext(context, this.policy);
612
+ }
613
+
580
614
  resolvePath(inputPath = ".") {
581
615
  const raw = String(inputPath || ".");
582
616
  if (raw.includes("\0")) throw new Error("path contains a NUL byte");
@@ -599,14 +633,14 @@ export class LocalRuntime {
599
633
  return this.workspaceCanonicalPromise;
600
634
  }
601
635
 
602
- async resolveExistingPath(inputPath = ".") {
636
+ async resolveExistingPath(inputPath = ".", context = {}) {
603
637
  const candidate = this.resolvePath(inputPath);
604
638
  const [workspace, canonical] = await Promise.all([this.canonicalWorkspace(), realpath(candidate)]);
605
- if (!this.policy.unrestrictedPaths) assertContainedPath(workspace, canonical);
639
+ if (!this.effectivePolicy(context).unrestrictedPaths) assertContainedPath(workspace, canonical);
606
640
  return canonical;
607
641
  }
608
642
 
609
- async resolveWritePath(inputPath = ".") {
643
+ async resolveWritePath(inputPath = ".", context = {}) {
610
644
  const candidate = this.resolvePath(inputPath);
611
645
  const candidateInfo = await lstat(candidate).catch(() => null);
612
646
  if (candidateInfo?.isSymbolicLink()) throw new Error("refusing to overwrite a symbolic link");
@@ -617,23 +651,23 @@ export class LocalRuntime {
617
651
  ancestor = parent;
618
652
  }
619
653
  const [workspace, canonicalAncestor] = await Promise.all([this.canonicalWorkspace(), realpath(ancestor)]);
620
- if (!this.policy.unrestrictedPaths) assertContainedPath(workspace, canonicalAncestor);
654
+ if (!this.effectivePolicy(context).unrestrictedPaths) assertContainedPath(workspace, canonicalAncestor);
621
655
  const suffix = relative(ancestor, candidate);
622
656
  return suffix ? resolve(canonicalAncestor, suffix) : canonicalAncestor;
623
657
  }
624
658
 
625
- displayPath(fullPath) {
659
+ displayPath(fullPath, context = {}) {
626
660
  const absolute = resolve(fullPath);
627
- if (this.policy.exposeAbsolutePaths) return absolute;
661
+ if (this.effectivePolicy(context).exposeAbsolutePaths) return absolute;
628
662
  const shown = relative(this.workspace, absolute);
629
663
  const insideWorkspace = shown === "" || (!shown.startsWith(`..${sep}`) && shown !== ".." && !isAbsolute(shown));
630
664
  if (insideWorkspace) return shown ? shown.split(sep).join("/") : ".";
631
665
  return `<external-path:${sha256(absolute).slice(0, 12)}>`;
632
666
  }
633
667
 
634
- safeErrorMessage(error, toolArgs = {}) {
668
+ safeErrorMessage(error, toolArgs = {}, context = {}) {
635
669
  const message = boundedErrorMessage(error);
636
- if (this.policy.exposeAbsolutePaths) return message;
670
+ if (this.effectivePolicy(context).exposeAbsolutePaths) return message;
637
671
  return redactRuntimeErrorMessage(message, {
638
672
  error,
639
673
  toolArgs,
@@ -641,7 +675,7 @@ export class LocalRuntime {
641
675
  workspaceInput: this.workspaceInput,
642
676
  runtimeDir: this.runtimeDir,
643
677
  home: process.env.HOME || process.env.USERPROFILE || "",
644
- displayPath: (value) => this.displayPath(value),
678
+ displayPath: (value) => this.displayPath(value, context),
645
679
  });
646
680
  }
647
681
 
@@ -16,6 +16,9 @@ export function openRegularFileSync(file, flags, options = {}) {
16
16
  try {
17
17
  const info = fstatSync(fd);
18
18
  if (!info.isFile()) throw new Error(`${label} is not a regular file`);
19
+ if (options.rejectMultipleLinks === true && Number(info.nlink) > 1) {
20
+ throw new Error(`${label} must not have multiple hard links`);
21
+ }
19
22
  if (options.verifyPathIdentity === true) {
20
23
  const pathInfo = lstatSync(file);
21
24
  if (pathInfo.isSymbolicLink() || !pathInfo.isFile()) throw new Error(`${label} must be a regular file and not a symbolic link`);
@@ -39,7 +42,7 @@ function withRegularFileSync(file, flags, options, callback) {
39
42
  }
40
43
 
41
44
  export function chmodRegularFileSync(file, mode, label = "path") {
42
- return withRegularFileSync(file, fsConstants.O_RDONLY, { label, chmod: mode }, () => undefined);
45
+ return withRegularFileSync(file, fsConstants.O_RDONLY, { label, chmod: mode, rejectMultipleLinks: true }, () => undefined);
43
46
  }
44
47
 
45
48
  export function readBoundedRegularFileSync(file, maxBytes, label = "path", options = {}) {
@@ -52,6 +55,7 @@ export function readBoundedRegularFileWithInfoSync(file, maxBytes, label = "path
52
55
  return withRegularFileSync(file, fsConstants.O_RDONLY, {
53
56
  label,
54
57
  verifyPathIdentity: options.verifyPathIdentity === true,
58
+ rejectMultipleLinks: options.rejectMultipleLinks === true,
55
59
  }, (fd, info) => {
56
60
  if (info.size > limit) throw new Error(`file exceeds ${limit} bytes`);
57
61
  options.afterOpen?.({ fd, info });
@@ -0,0 +1,204 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { existsSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { replaceFileAtomicallySync } from "./exclusive-file.mjs";
5
+ import { ensureOwnerOnlyDirectorySync, readBoundedRegularFileSync } from "./secure-file.mjs";
6
+ import { withOwnerStateLock } from "./owner-state-lock.mjs";
7
+
8
+ const SCHEMA_VERSION = 1;
9
+ const MAX_EVENTS = 4096;
10
+ const MAX_BYTES = 4 * 1024 * 1024;
11
+ const HASH_PATTERN = /^[a-f0-9]{64}$/;
12
+
13
+ export class SecurityAuditLog {
14
+ constructor({ root = "", now = Date.now } = {}) {
15
+ this.root = root ? path.resolve(root) : "";
16
+ this.file = this.root ? path.join(this.root, "security-audit.json") : "";
17
+ this.now = typeof now === "function" ? now : Date.now;
18
+ this.queue = Promise.resolve();
19
+ this.lastError = "";
20
+ if (this.file && existsSync(this.file)) {
21
+ try { this.readVerifiedState(); } catch (error) { this.lastError = errorClass(error); }
22
+ }
23
+ }
24
+
25
+ record(input = {}) {
26
+ if (!this.file) return Promise.resolve(false);
27
+ const operation = async () => {
28
+ try {
29
+ return await withOwnerStateLock(this.root, async () => {
30
+ const state = this.readVerifiedState();
31
+ const event = buildEvent(input, state, this.now());
32
+ state.events.push(event);
33
+ while (state.events.length > MAX_EVENTS) {
34
+ const removed = state.events.shift();
35
+ state.anchor = removed.hash;
36
+ }
37
+ writeState(this.file, state);
38
+ this.lastError = "";
39
+ return true;
40
+ }, {
41
+ purpose: "security-audit", fileName: "security-audit.lock", label: "security audit",
42
+ });
43
+ } catch (error) {
44
+ this.lastError = errorClass(error);
45
+ return false;
46
+ }
47
+ };
48
+ const result = this.queue.then(operation, operation);
49
+ this.queue = result.then(() => undefined, () => undefined);
50
+ return result;
51
+ }
52
+
53
+ snapshot() {
54
+ if (!this.file) return { enabled: false, healthy: true, retained: 0, maximum: MAX_EVENTS };
55
+ try {
56
+ const state = this.readVerifiedState();
57
+ return {
58
+ enabled: true,
59
+ healthy: !this.lastError,
60
+ retained: state.events.length,
61
+ maximum: MAX_EVENTS,
62
+ last_event_at: state.events.at(-1)?.timestamp || null,
63
+ last_error_class: this.lastError || null,
64
+ content_logged: false,
65
+ chain_verified: true,
66
+ };
67
+ } catch (error) {
68
+ return {
69
+ enabled: true,
70
+ healthy: false,
71
+ retained: 0,
72
+ maximum: MAX_EVENTS,
73
+ last_event_at: null,
74
+ last_error_class: errorClass(error),
75
+ content_logged: false,
76
+ chain_verified: false,
77
+ };
78
+ }
79
+ }
80
+
81
+ readVerifiedState() {
82
+ if (!existsSync(this.file)) return emptyState();
83
+ const raw = readBoundedRegularFileSync(this.file, MAX_BYTES, "security audit state");
84
+ let state;
85
+ try { state = JSON.parse(raw.toString("utf8")); } catch (error) {
86
+ throw new Error("security audit state is not valid JSON", { cause: error });
87
+ }
88
+ validateState(state);
89
+ let previous = state.anchor;
90
+ for (const event of state.events) {
91
+ if (event.previous_hash !== previous || event.hash !== eventHash(event)) {
92
+ throw new Error("security audit hash chain verification failed");
93
+ }
94
+ previous = event.hash;
95
+ }
96
+ return state;
97
+ }
98
+ }
99
+
100
+ function buildEvent(input, state, nowMs) {
101
+ const timestamp = new Date(Number(nowMs)).toISOString();
102
+ if (!Number.isFinite(Date.parse(timestamp))) throw new Error("security audit timestamp is invalid");
103
+ const principal = input.principal && typeof input.principal === "object" ? input.principal : {};
104
+ const event = {
105
+ sequence: state.next_sequence,
106
+ timestamp,
107
+ outcome: boundedToken(input.outcome, "unknown"),
108
+ tool: boundedToken(input.tool, "unknown"),
109
+ risk_category: boundedText(input.riskCategory, "ordinary operation", 160),
110
+ target_hash: HASH_PATTERN.test(String(input.targetHash || "")) ? String(input.targetHash) : null,
111
+ account_ref: principal.accountId ? privateReference(state.identity_salt, principal.accountId) : null,
112
+ client_ref: principal.clientId ? privateReference(state.identity_salt, principal.clientId) : null,
113
+ family_ref: principal.familyId ? privateReference(state.identity_salt, principal.familyId) : null,
114
+ account_version: Number.isSafeInteger(principal.accountVersion) ? principal.accountVersion : null,
115
+ role: boundedToken(principal.role, principal.kind === "local" ? "local" : "unknown"),
116
+ duration_ms: boundedNumber(input.durationMs),
117
+ input_bytes: boundedNumber(input.inputBytes),
118
+ output_bytes: boundedNumber(input.outputBytes),
119
+ error_code: input.errorCode ? boundedToken(input.errorCode, "unknown") : null,
120
+ previous_hash: state.events.at(-1)?.hash || state.anchor,
121
+ };
122
+ const completed = { ...event, hash: eventHash(event) };
123
+ state.next_sequence += 1;
124
+ return completed;
125
+ }
126
+
127
+ function emptyState() {
128
+ return {
129
+ schemaVersion: SCHEMA_VERSION,
130
+ identity_salt: randomBytes(32).toString("hex"),
131
+ anchor: randomBytes(32).toString("hex"),
132
+ next_sequence: 1,
133
+ events: [],
134
+ };
135
+ }
136
+
137
+ function validateState(state) {
138
+ if (!plainRecord(state) || state.schemaVersion !== SCHEMA_VERSION) throw new Error("security audit state schema is invalid");
139
+ if (!HASH_PATTERN.test(state.identity_salt) || !HASH_PATTERN.test(state.anchor)) throw new Error("security audit state identity is invalid");
140
+ if (!Number.isSafeInteger(state.next_sequence) || state.next_sequence < 1) throw new Error("security audit sequence is invalid");
141
+ if (!Array.isArray(state.events) || state.events.length > MAX_EVENTS || !state.events.every(validEvent)) {
142
+ throw new Error("security audit events are invalid");
143
+ }
144
+ if (state.events.length && state.next_sequence <= state.events.at(-1).sequence) throw new Error("security audit sequence did not advance");
145
+ }
146
+
147
+ function validEvent(event) {
148
+ return plainRecord(event)
149
+ && Number.isSafeInteger(event.sequence)
150
+ && event.sequence > 0
151
+ && Number.isFinite(Date.parse(String(event.timestamp || "")))
152
+ && typeof event.outcome === "string"
153
+ && typeof event.tool === "string"
154
+ && typeof event.risk_category === "string"
155
+ && (event.target_hash === null || HASH_PATTERN.test(event.target_hash))
156
+ && ["account_ref", "client_ref", "family_ref"].every((key) => event[key] === null || HASH_PATTERN.test(event[key]))
157
+ && (event.account_version === null || Number.isSafeInteger(event.account_version))
158
+ && typeof event.role === "string"
159
+ && Number.isFinite(event.duration_ms)
160
+ && Number.isFinite(event.input_bytes)
161
+ && Number.isFinite(event.output_bytes)
162
+ && (event.error_code === null || typeof event.error_code === "string")
163
+ && HASH_PATTERN.test(event.previous_hash)
164
+ && HASH_PATTERN.test(event.hash);
165
+ }
166
+
167
+ function eventHash(event) {
168
+ const value = { ...event };
169
+ delete value.hash;
170
+ return createHash("sha256").update(JSON.stringify(value)).digest("hex");
171
+ }
172
+
173
+ function privateReference(salt, value) {
174
+ return createHash("sha256").update(salt).update("\0").update(String(value)).digest("hex");
175
+ }
176
+
177
+ function writeState(file, state) {
178
+ ensureOwnerOnlyDirectorySync(path.dirname(file));
179
+ const content = `${JSON.stringify(state)}\n`;
180
+ if (Buffer.byteLength(content) > MAX_BYTES) throw new Error("security audit state exceeds its size limit");
181
+ replaceFileAtomicallySync(file, content, { mode: 0o600 });
182
+ }
183
+
184
+ function boundedToken(value, fallback) {
185
+ const text = String(value || fallback).replace(/[^A-Za-z0-9._:-]/g, "_").slice(0, 128);
186
+ return text || fallback;
187
+ }
188
+
189
+ function boundedText(value, fallback, maximum) {
190
+ return String(value || fallback).replace(/[\r\n\t\u0000-\u001f\u007f]/g, " ").trim().slice(0, maximum) || fallback;
191
+ }
192
+
193
+ function boundedNumber(value) {
194
+ const number = Number(value);
195
+ return Number.isFinite(number) && number >= 0 ? Math.min(Math.floor(number), Number.MAX_SAFE_INTEGER) : 0;
196
+ }
197
+
198
+ function errorClass(error) {
199
+ return String(error?.code || error?.name || "audit_error").replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 80);
200
+ }
201
+
202
+ function plainRecord(value) {
203
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
204
+ }
@@ -1,14 +1,30 @@
1
1
  const DEFAULT_ATTEMPTS = 20;
2
2
  const DEFAULT_DELAY_MS = 100;
3
3
 
4
+ export async function waitForActiveStatus(
5
+ readStatus,
6
+ { attempts = DEFAULT_ATTEMPTS, delayMs = DEFAULT_DELAY_MS, sleep = delay } = {},
7
+ ) {
8
+ return waitForStatus(readStatus, (status) => status?.active === true, { attempts, delayMs, sleep });
9
+ }
10
+
4
11
  export async function waitForInactiveStatus(
5
12
  readStatus,
6
13
  { attempts = DEFAULT_ATTEMPTS, delayMs = DEFAULT_DELAY_MS, sleep = delay } = {},
14
+ ) {
15
+ return waitForStatus(readStatus, (status) => status?.active !== true, { attempts, delayMs, sleep });
16
+ }
17
+
18
+ export async function waitForStatus(
19
+ readStatus,
20
+ predicate,
21
+ { attempts = DEFAULT_ATTEMPTS, delayMs = DEFAULT_DELAY_MS, sleep = delay } = {},
7
22
  ) {
8
23
  if (typeof readStatus !== "function") throw new TypeError("readStatus must be a function");
24
+ if (typeof predicate !== "function") throw new TypeError("predicate must be a function");
9
25
  const maximum = Number.isInteger(attempts) && attempts > 0 ? attempts : DEFAULT_ATTEMPTS;
10
26
  let status = await readStatus();
11
- for (let attempt = 1; status?.active === true && attempt < maximum; attempt += 1) {
27
+ for (let attempt = 1; !predicate(status) && attempt < maximum; attempt += 1) {
12
28
  await sleep(delayMs);
13
29
  status = await readStatus();
14
30
  }
@@ -0,0 +1,17 @@
1
+ export async function stopOwnedPlatformService({
2
+ state,
3
+ inspectWorkspaceDaemon,
4
+ ownsPlatformAutostart,
5
+ stopPlatformService,
6
+ } = {}) {
7
+ if (!state) throw new TypeError("service ownership check requires state");
8
+ if (typeof inspectWorkspaceDaemon !== "function") throw new TypeError("service ownership check requires inspectWorkspaceDaemon");
9
+ if (typeof ownsPlatformAutostart !== "function") throw new TypeError("service ownership check requires ownsPlatformAutostart");
10
+ if (typeof stopPlatformService !== "function") throw new TypeError("service ownership check requires stopPlatformService");
11
+ const daemon = inspectWorkspaceDaemon(state);
12
+ if (!ownsPlatformAutostart(daemon)) {
13
+ return { owned: false, stopped: false, daemon, provider: null };
14
+ }
15
+ const provider = await stopPlatformService();
16
+ return { owned: true, stopped: provider?.ok !== false, daemon, provider };
17
+ }
@@ -0,0 +1,50 @@
1
+ import { resolve } from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ import { restartAutostart } from "./service.mjs";
4
+ import { createLogger } from "./log.mjs";
5
+
6
+ const DEFAULT_DELAY_MS = 300;
7
+ const MAX_DELAY_MS = 5_000;
8
+
9
+ export async function runServiceRestartHandoff(options = {}) {
10
+ const delayMs = boundedDelay(options.delayMs ?? process.argv[2]);
11
+ const restart = typeof options.restartAutostart === "function" ? options.restartAutostart : restartAutostart;
12
+ const sleep = typeof options.sleep === "function" ? options.sleep : delay;
13
+ const logger = options.logger || createLogger({ component: "service-restart", level: "warn", format: "json", stderrOnly: true });
14
+ await sleep(delayMs);
15
+ const result = await restart({ logger });
16
+ if (result?.ok !== true) {
17
+ const error = new Error(`service restart handoff failed (${result?.reason || result?.provider || "unknown"})`);
18
+ error.result = result;
19
+ throw error;
20
+ }
21
+ return result;
22
+ }
23
+
24
+ export async function serviceRestartHandoffMain(options = {}) {
25
+ const run = typeof options.run === "function"
26
+ ? options.run
27
+ : () => runServiceRestartHandoff(options.handoffOptions);
28
+ const logger = options.logger || createLogger({ component: "service-restart", level: "error", format: "json", stderrOnly: true });
29
+ try {
30
+ await run();
31
+ return 0;
32
+ } catch (error) {
33
+ logger.error("service restart handoff failed", { error_class: error?.code || error?.name || "execution_failed" });
34
+ return 1;
35
+ }
36
+ }
37
+
38
+ function boundedDelay(value) {
39
+ const parsed = Number(value);
40
+ if (!Number.isFinite(parsed)) return DEFAULT_DELAY_MS;
41
+ return Math.min(MAX_DELAY_MS, Math.max(50, Math.floor(parsed)));
42
+ }
43
+
44
+ function delay(milliseconds) {
45
+ return new Promise((resolvePromise) => { setTimeout(resolvePromise, milliseconds); });
46
+ }
47
+
48
+ if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) {
49
+ process.exitCode = await serviceRestartHandoffMain();
50
+ }