praxis-agent 0.40.0 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -170,7 +170,9 @@ troubleshooting. Run `praxis --help` for the authoritative command surface.
170
170
  exclusions, violation reporting, and bare-repository control-file cleanup,
171
171
  safe-property Skill auto-allow, interactive
172
172
  workspace-directory add/remove controls, path confinement, credential
173
- redaction, and sanitized child processes.
173
+ redaction, sanitized child processes, and exact-fingerprint workspace trust
174
+ that blocks automatically discovered project/local hooks and MCP servers
175
+ until the canonical workspace configuration is explicitly accepted.
174
176
  - **Durable local work** — resumable sessions, full-history forks, file
175
177
  checkpoints, tasks, foreground/background subagents, top-level agents, and
176
178
  Claude-compatible main-thread agent definitions with native prompt, model,
@@ -7,6 +7,7 @@ export const DEFAULT_CLI_CONTROLS = {
7
7
  settingSources: undefined,
8
8
  safeMode: false,
9
9
  bare: false,
10
+ trustProject: false,
10
11
  systemPrompt: undefined,
11
12
  systemPromptFile: undefined,
12
13
  appendSystemPrompt: undefined,
@@ -180,6 +181,7 @@ export async function resolveCliControls(controls, cwd) {
180
181
  settingSources: controls.settingSources,
181
182
  safeMode: controls.safeMode,
182
183
  bare: controls.bare,
184
+ trustProject: controls.trustProject,
183
185
  systemPrompt,
184
186
  appendSystemPrompt,
185
187
  excludeDynamicSystemPromptSections: controls.excludeDynamicSystemPromptSections,
@@ -14,6 +14,7 @@ export interface CliControls {
14
14
  settingSources: readonly ('user' | 'project' | 'local')[] | undefined;
15
15
  safeMode: boolean;
16
16
  bare: boolean;
17
+ trustProject: boolean;
17
18
  systemPrompt: string | undefined;
18
19
  systemPromptFile: string | undefined;
19
20
  appendSystemPrompt: string | undefined;
@@ -359,6 +359,7 @@ export function parseCliInvocation(argv) {
359
359
  let autocompact;
360
360
  let safeMode = false;
361
361
  let bare = false;
362
+ let trustProject = false;
362
363
  let systemPrompt;
363
364
  let systemPromptFile;
364
365
  let appendSystemPrompt;
@@ -1082,6 +1083,10 @@ export function parseCliInvocation(argv) {
1082
1083
  bare = true;
1083
1084
  continue;
1084
1085
  }
1086
+ if (value === '--trust-project') {
1087
+ trustProject = true;
1088
+ continue;
1089
+ }
1085
1090
  if (value === '--init') {
1086
1091
  init = true;
1087
1092
  continue;
@@ -1343,6 +1348,7 @@ export function parseCliInvocation(argv) {
1343
1348
  settingSources,
1344
1349
  safeMode,
1345
1350
  bare,
1351
+ trustProject,
1346
1352
  systemPrompt,
1347
1353
  systemPromptFile,
1348
1354
  appendSystemPrompt,
@@ -0,0 +1,10 @@
1
+ import { type WorkspaceTrustInventory } from '../security/workspace-trust.js';
2
+ export interface WorkspaceTrustPromptIO {
3
+ input?: AsyncIterable<string>;
4
+ output?: (text: string) => void;
5
+ signal?: AbortSignal;
6
+ }
7
+ export declare function createWorkspaceTrustDecisionCache(decide: (inventory: WorkspaceTrustInventory) => boolean | Promise<boolean>): (inventory: WorkspaceTrustInventory) => Promise<boolean>;
8
+ export declare function safeWorkspaceTrustDisplayField(value: string): string;
9
+ export declare function promptWorkspaceTrust(inventory: WorkspaceTrustInventory, io?: WorkspaceTrustPromptIO): Promise<boolean>;
10
+ //# sourceMappingURL=workspace-trust-prompt.d.ts.map
@@ -0,0 +1,53 @@
1
+ import { workspaceTrustDecisionKey, } from '../security/workspace-trust.js';
2
+ export function createWorkspaceTrustDecisionCache(decide) {
3
+ const decisions = new Map();
4
+ return async (inventory) => {
5
+ const key = workspaceTrustDecisionKey(inventory);
6
+ const cached = decisions.get(key);
7
+ if (cached !== undefined)
8
+ return cached;
9
+ const decision = await decide(inventory);
10
+ decisions.set(key, decision);
11
+ return decision;
12
+ };
13
+ }
14
+ export function safeWorkspaceTrustDisplayField(value) {
15
+ return [...value]
16
+ .map((character) => {
17
+ return /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u.test(character) ? '?' : character;
18
+ })
19
+ .join('')
20
+ .slice(0, 512);
21
+ }
22
+ export async function promptWorkspaceTrust(inventory, io = {}) {
23
+ const output = io.output ?? ((text) => process.stderr.write(text));
24
+ if (io.signal?.aborted)
25
+ return false;
26
+ output(`Workspace executable resources found for ${safeWorkspaceTrustDisplayField(inventory.canonicalPath)}\n`);
27
+ for (const origin of inventory.origins)
28
+ output(` ${origin.kind} (${origin.scope}) ${safeWorkspaceTrustDisplayField(origin.path)}: ${safeWorkspaceTrustDisplayField(origin.label)}\n`);
29
+ output('Trust these workspace executables? [y/N] ');
30
+ const input = io.input ?? process.stdin;
31
+ const iterator = input[Symbol.asyncIterator]();
32
+ const abortedResult = Symbol('workspace-trust-prompt-aborted');
33
+ let abort;
34
+ try {
35
+ const aborted = new Promise((resolve) => {
36
+ abort = () => resolve(abortedResult);
37
+ io.signal?.addEventListener('abort', abort, { once: true });
38
+ if (io.signal?.aborted)
39
+ abort();
40
+ });
41
+ const result = await Promise.race([iterator.next(), aborted]);
42
+ if (result === abortedResult)
43
+ return false;
44
+ if (result.done)
45
+ return false;
46
+ return /^(?:y|yes)$/iu.test(String(result.value).trim());
47
+ }
48
+ finally {
49
+ if (abort)
50
+ io.signal?.removeEventListener('abort', abort);
51
+ }
52
+ }
53
+ //# sourceMappingURL=workspace-trust-prompt.js.map
@@ -11,6 +11,7 @@ import type { TuiSlashCommand } from './cli/tui/slash-commands.js';
11
11
  import { type TuiHookConfiguration } from './cli/tui/hook-settings.js';
12
12
  import { type PraxisRuntimeSettings } from './cli/tui/runtime-settings.js';
13
13
  import { type ClaudePermissionMode } from './permissions/claude-permission-resolver.js';
14
+ import { type WorkspaceTrustInventory } from './security/workspace-trust.js';
14
15
  import { type ClaudeMcpServerStatus, type ClaudeMcpToolInspection } from './mcp/claude-mcp-tools.js';
15
16
  import { authenticateMcpServer } from './mcp/claude-mcp-oauth.js';
16
17
  import { servePraxisMcpStdio } from './mcp/praxis-mcp-server.js';
@@ -138,6 +139,7 @@ export interface CliDependencies extends InteractiveServiceFactory {
138
139
  onElicitation?: (request: CliElicitationRequest) => Promise<CliElicitationResult>;
139
140
  askUser?: ClaudeInteractiveToolCallbacks['askUser'];
140
141
  approvePlan?: ClaudeInteractiveToolCallbacks['approvePlan'];
142
+ approveWorkspaceTrust?: (request: WorkspaceTrustInventory) => boolean | Promise<boolean>;
141
143
  }): Promise<SessionCommands>;
142
144
  createAutoModeCritic?(options: {
143
145
  model?: string;
@@ -33,6 +33,8 @@ import { createClaudeModelAutoClassifier, defaultClaudeAutoModeConfig, loadClaud
33
33
  import { ClaudeExtensionPermissionResolver, ClaudeExtensionToolRegistry, } from './extensions/claude-extension-tools.js';
34
34
  import { ClaudeExtensionCatalog } from './extensions/claude-extensions.js';
35
35
  import { ClaudeHookRunner } from './hooks/claude-hooks.js';
36
+ import { allowedWorkspaceHookSettings, allowedWorkspaceMcpResources, assessWorkspaceTrust, persistWorkspaceTrust, workspaceTrustDecisionKey, workspaceTrustInventory, } from './security/workspace-trust.js';
37
+ import { createWorkspaceTrustDecisionCache, promptWorkspaceTrust, safeWorkspaceTrustDisplayField, } from './cli/workspace-trust-prompt.js';
36
38
  import { ClaudeSessionEnvironment } from './hooks/claude-session-environment.js';
37
39
  import { ClaudeMcpToolRegistry, } from './mcp/claude-mcp-tools.js';
38
40
  import { ClaudeMcpManagement, filterDisabledMcpResources, mcpScope, } from './mcp/claude-mcp-management.js';
@@ -216,6 +218,7 @@ Options:
216
218
  --setting-sources <sources> user, project, local, or an empty list
217
219
  --safe-mode Disable shared customizations
218
220
  --bare Use only explicitly supplied context
221
+ --trust-project Trust current workspace executables
219
222
  --system-prompt <prompt> Set system prompt
220
223
  --append-system-prompt <prompt> Append system prompt
221
224
  --exclude-dynamic-system-prompt-sections
@@ -755,6 +758,12 @@ const consoleIO = {
755
758
  isTTY: Boolean(process.stdin.isTTY && process.stdout.isTTY),
756
759
  readStdinLines: () => process.stdin,
757
760
  };
761
+ function warningEventSink(io) {
762
+ return (event) => {
763
+ if (event.type === 'warning')
764
+ io.stderr(`${event.message}\n`);
765
+ };
766
+ }
758
767
  /**
759
768
  * Shared runtime model precedence used by every consumer (provider
760
769
  * construction, status/doctor output, and the interactive display):
@@ -789,7 +798,7 @@ export function resolveUnknownCostSidecarPath(dataPlane, configRoot) {
789
798
  assertNativeDataPlane(dataPlane);
790
799
  return join(configRoot, 'state', 'unknown-cost-sidecar.json');
791
800
  }
792
- const createDefaultService = async ({ eventSink, requireProvider, hooksOnly = false, approveRecovery, approveTool, agent, model: interactiveModel, effort: interactiveEffort, permissionMode: interactivePermissionMode, isSessionActionApproved, controls = DEFAULT_CLI_CONTROLS, interactive = false, sessionKind, signal, exposeToolRegistry = false, onElicitation, askUser, approvePlan, emitToolUseSummaries = false, cwd: requestedCwd, sandboxOriginalCwd, configRoot: requestedConfigRoot, environment, providerEnvironment: requestedProviderEnvironment, }) => {
801
+ const createDefaultService = async ({ eventSink, requireProvider, hooksOnly = false, approveRecovery, approveTool, agent, model: interactiveModel, effort: interactiveEffort, permissionMode: interactivePermissionMode, isSessionActionApproved, controls = DEFAULT_CLI_CONTROLS, interactive = false, sessionKind, signal, exposeToolRegistry = false, onElicitation, askUser, approvePlan, approveWorkspaceTrust, emitToolUseSummaries = false, cwd: requestedCwd, sandboxOriginalCwd, configRoot: requestedConfigRoot, environment, providerEnvironment: requestedProviderEnvironment, }) => {
793
802
  const runtimeEnvironment = requestedProviderEnvironment ?? process.env;
794
803
  const sandboxEnvironment = { ...runtimeEnvironment, ...environment };
795
804
  const claudeVersion = VERIFIED_CLAUDE_SCHEMA_VERSION;
@@ -971,11 +980,58 @@ const createDefaultService = async ({ eventSink, requireProvider, hooksOnly = fa
971
980
  }
972
981
  : {}),
973
982
  };
983
+ const warnedWorkspaceFingerprints = new Set();
984
+ let trustProjectRequestAvailable = cli.trustProject;
985
+ const authorizeWorkspaceExecutables = async (automaticSettings, automaticMcp, runtimeCwd) => {
986
+ if (cli.safeMode || simpleMode)
987
+ return true;
988
+ const trustRequested = trustProjectRequestAvailable;
989
+ trustProjectRequestAvailable = false;
990
+ const inventory = await workspaceTrustInventory({
991
+ cwd: runtimeCwd,
992
+ settings: automaticSettings,
993
+ mcp: automaticMcp,
994
+ });
995
+ const assessment = await assessWorkspaceTrust(inventory, claudeStatePath);
996
+ if (assessment.status !== 'untrusted')
997
+ return true;
998
+ let approved = trustRequested;
999
+ if (!approved && approveWorkspaceTrust) {
1000
+ try {
1001
+ approved = await approveWorkspaceTrust(assessment);
1002
+ }
1003
+ catch (error) {
1004
+ if (!(error instanceof Error &&
1005
+ (error.name === 'AbortError' || error.name === 'CancellationError'))) {
1006
+ throw error;
1007
+ }
1008
+ approved = false;
1009
+ }
1010
+ }
1011
+ if (approved) {
1012
+ await persistWorkspaceTrust(assessment, claudeStatePath);
1013
+ return true;
1014
+ }
1015
+ const warningKey = workspaceTrustDecisionKey(assessment);
1016
+ if (!warnedWorkspaceFingerprints.has(warningKey)) {
1017
+ warnedWorkspaceFingerprints.add(warningKey);
1018
+ runtimeEventSink({
1019
+ type: 'warning',
1020
+ message: `Workspace executable resources blocked for ${safeWorkspaceTrustDisplayField(assessment.canonicalPath)}; restart to review them interactively, or rerun with --trust-project to approve the current fingerprint.`,
1021
+ });
1022
+ }
1023
+ return false;
1024
+ };
974
1025
  const hookConfiguration = async () => {
975
- const [settings, pluginResources] = await Promise.all([
1026
+ const [sharedResources, pluginResources] = await Promise.all([
976
1027
  cli.safeMode || simpleMode
977
- ? []
978
- : loadNativeSettings({ root: configRoot, cwd }),
1028
+ ? Promise.resolve({ settings: [], mcp: [] })
1029
+ : loadNativeSharedResources({
1030
+ root: configRoot,
1031
+ cwd,
1032
+ environment: runtimeEnvironment,
1033
+ includeProjectMemory: false,
1034
+ }),
979
1035
  loadClaudePlugins({
980
1036
  configRoot,
981
1037
  cwd,
@@ -983,13 +1039,20 @@ const createDefaultService = async ({ eventSink, requireProvider, hooksOnly = fa
983
1039
  pluginUrls: cli.pluginUrls,
984
1040
  strictPluginDirectories: cli.pluginDirectories.length + cli.pluginUrls.length > 0,
985
1041
  loadInstalled: !cli.safeMode && !simpleMode,
986
- readOnlyHooks: true,
1042
+ readOnlyExecutables: true,
987
1043
  environment: runtimeEnvironment,
988
1044
  }),
989
1045
  ]);
990
- return projectTuiHooks([
991
- ...settings,
1046
+ const automaticSettings = [
1047
+ ...sharedResources.settings,
992
1048
  ...pluginResources.settings,
1049
+ ];
1050
+ const automaticMcp = cli.strictMcpConfig
1051
+ ? []
1052
+ : [...sharedResources.mcp, ...pluginResources.mcp];
1053
+ const trusted = await authorizeWorkspaceExecutables(automaticSettings, automaticMcp, cwd);
1054
+ return projectTuiHooks([
1055
+ ...allowedWorkspaceHookSettings(automaticSettings, trusted),
993
1056
  ...(cli.additionalSettings ? [cli.additionalSettings] : []),
994
1057
  ]);
995
1058
  };
@@ -1001,7 +1064,10 @@ const createDefaultService = async ({ eventSink, requireProvider, hooksOnly = fa
1001
1064
  const service = new ClaudeSessionService(options);
1002
1065
  if (!interactive)
1003
1066
  return service;
1004
- return Object.assign(service, { hookConfiguration });
1067
+ const initialHookConfiguration = await hookConfiguration();
1068
+ return Object.assign(service, {
1069
+ hookConfiguration: () => Promise.resolve(initialHookConfiguration),
1070
+ });
1005
1071
  }
1006
1072
  const toolProvider = provider ?? {
1007
1073
  model: 'praxis/provider',
@@ -1034,7 +1100,7 @@ const createDefaultService = async ({ eventSink, requireProvider, hooksOnly = fa
1034
1100
  const baseSettings = nativeSharedResourcesEnabled
1035
1101
  ? await loadNativeSettings({ root: configRoot, cwd })
1036
1102
  : [];
1037
- const pluginResources = await loadClaudePlugins({
1103
+ const pluginLoadOptions = {
1038
1104
  configRoot,
1039
1105
  cwd,
1040
1106
  pluginDirectories: cli.pluginDirectories,
@@ -1042,6 +1108,10 @@ const createDefaultService = async ({ eventSink, requireProvider, hooksOnly = fa
1042
1108
  strictPluginDirectories: cli.pluginDirectories.length + cli.pluginUrls.length > 0,
1043
1109
  loadInstalled: !cli.safeMode && !simpleMode,
1044
1110
  environment: runtimeEnvironment,
1111
+ };
1112
+ const executablePluginResources = await loadClaudePlugins({
1113
+ ...pluginLoadOptions,
1114
+ readOnlyExecutables: true,
1045
1115
  });
1046
1116
  const projectMemoryPolicy = cli.safeMode || simpleMode
1047
1117
  ? { enabled: false, extraction: false, recall: false }
@@ -1049,7 +1119,7 @@ const createDefaultService = async ({ eventSink, requireProvider, hooksOnly = fa
1049
1119
  dataPlane,
1050
1120
  settings: [
1051
1121
  ...baseSettings,
1052
- ...pluginResources.settings,
1122
+ ...executablePluginResources.settings,
1053
1123
  ...(cli.additionalSettings ? [cli.additionalSettings] : []),
1054
1124
  ],
1055
1125
  environment: runtimeEnvironment,
@@ -1071,7 +1141,42 @@ const createDefaultService = async ({ eventSink, requireProvider, hooksOnly = fa
1071
1141
  settings: [],
1072
1142
  mcp: [],
1073
1143
  };
1074
- const settings = [
1144
+ const trustSettings = [
1145
+ ...loadedResources.settings,
1146
+ ...executablePluginResources.settings,
1147
+ ];
1148
+ const trustMcp = cli.strictMcpConfig
1149
+ ? []
1150
+ : [...loadedResources.mcp, ...executablePluginResources.mcp];
1151
+ const workspaceExecutablesTrusted = await authorizeWorkspaceExecutables(trustSettings, trustMcp, cwd);
1152
+ const loadedPluginResources = await loadClaudePlugins({
1153
+ ...pluginLoadOptions,
1154
+ allowWorkspaceMcpb: workspaceExecutablesTrusted,
1155
+ });
1156
+ const materializedPluginMcp = new Map(loadedPluginResources.mcp.map((resource) => [resource.path, resource]));
1157
+ const pluginResources = {
1158
+ ...loadedPluginResources,
1159
+ settings: executablePluginResources.settings,
1160
+ mcp: executablePluginResources.mcp.flatMap((resource) => {
1161
+ if (resource.pluginExecutableSource?.kind !== 'mcpb')
1162
+ return [resource];
1163
+ const materialized = materializedPluginMcp.get(resource.path);
1164
+ if (!materialized)
1165
+ return [];
1166
+ if (materialized.pluginExecutableSource?.source !==
1167
+ resource.pluginExecutableSource.source ||
1168
+ materialized.pluginExecutableSource.fingerprint !==
1169
+ resource.pluginExecutableSource.fingerprint) {
1170
+ runtimeEventSink({
1171
+ type: 'warning',
1172
+ message: `Workspace MCPB source changed during trust preflight and was blocked: ${safeWorkspaceTrustDisplayField(resource.path)}. Restart to review the new fingerprint.`,
1173
+ });
1174
+ return [];
1175
+ }
1176
+ return [materialized];
1177
+ }),
1178
+ };
1179
+ const allSettings = [
1075
1180
  ...loadedResources.settings,
1076
1181
  ...pluginResources.settings,
1077
1182
  ...(cli.additionalSettings ? [cli.additionalSettings] : []),
@@ -1084,7 +1189,7 @@ const createDefaultService = async ({ eventSink, requireProvider, hooksOnly = fa
1084
1189
  });
1085
1190
  }
1086
1191
  }
1087
- const configuredAgent = [...settings]
1192
+ const configuredAgent = [...allSettings]
1088
1193
  .reverse()
1089
1194
  .map((resource) => resource.value &&
1090
1195
  typeof resource.value === 'object' &&
@@ -1093,6 +1198,21 @@ const createDefaultService = async ({ eventSink, requireProvider, hooksOnly = fa
1093
1198
  : undefined)
1094
1199
  .find((value) => typeof value === 'string' && value.length > 0);
1095
1200
  const selectedMainAgent = agent ?? configuredAgent;
1201
+ const automaticSettings = [
1202
+ ...loadedResources.settings,
1203
+ ...pluginResources.settings,
1204
+ ];
1205
+ const automaticMcp = cli.strictMcpConfig
1206
+ ? []
1207
+ : [...loadedResources.mcp, ...pluginResources.mcp];
1208
+ const hookSettings = [
1209
+ ...allowedWorkspaceHookSettings(automaticSettings, workspaceExecutablesTrusted),
1210
+ ...(cli.additionalSettings ? [cli.additionalSettings] : []),
1211
+ ];
1212
+ const executableMcp = [
1213
+ ...allowedWorkspaceMcpResources(automaticMcp, workspaceExecutablesTrusted),
1214
+ ...cli.mcpResources,
1215
+ ];
1096
1216
  const resources = {
1097
1217
  ...loadedResources,
1098
1218
  commands: [...loadedResources.commands, ...pluginResources.commands],
@@ -1102,10 +1222,8 @@ const createDefaultService = async ({ eventSink, requireProvider, hooksOnly = fa
1102
1222
  ...pluginResources.agents,
1103
1223
  ...cli.inlineAgents,
1104
1224
  ],
1105
- settings,
1106
- mcp: cli.strictMcpConfig
1107
- ? cli.mcpResources
1108
- : [...loadedResources.mcp, ...pluginResources.mcp, ...cli.mcpResources],
1225
+ settings: allSettings,
1226
+ mcp: executableMcp,
1109
1227
  };
1110
1228
  const extensions = new ClaudeExtensionCatalog(resources, {
1111
1229
  disableSlashCommands: cli.disableSlashCommands,
@@ -1174,7 +1292,7 @@ const createDefaultService = async ({ eventSink, requireProvider, hooksOnly = fa
1174
1292
  ...(exposePlanDirectory ? [resolve(configRoot, 'plans')] : []),
1175
1293
  ];
1176
1294
  const sandboxSettings = loadClaudeSandboxSettings({
1177
- resources: settings.filter((resource) => resource.plugin !== true),
1295
+ resources: allSettings.filter((resource) => resource.plugin !== true),
1178
1296
  cwd: workspace.cwd(),
1179
1297
  originalCwd: sandboxOriginalCwd ?? workspace.cwd(),
1180
1298
  configRoot,
@@ -1200,7 +1318,7 @@ const createDefaultService = async ({ eventSink, requireProvider, hooksOnly = fa
1200
1318
  cwd,
1201
1319
  cwdProvider: () => workspace.cwd(),
1202
1320
  configRoot,
1203
- settings,
1321
+ settings: allSettings,
1204
1322
  allowedTools: cli.allowedTools,
1205
1323
  disallowedTools: cli.disallowedTools,
1206
1324
  additionalDirectories: permissionAdditionalDirectories,
@@ -1275,9 +1393,14 @@ const createDefaultService = async ({ eventSink, requireProvider, hooksOnly = fa
1275
1393
  settings: [],
1276
1394
  mcp: [],
1277
1395
  };
1396
+ const refreshedSettings = [
1397
+ ...refreshed.settings,
1398
+ ...pluginResources.settings,
1399
+ ];
1400
+ const refreshedMcp = [...refreshed.mcp, ...pluginResources.mcp];
1401
+ const trusted = await authorizeWorkspaceExecutables(refreshedSettings, refreshedMcp, workspace.cwd());
1278
1402
  return runtimeMcpResources([
1279
- ...refreshed.mcp,
1280
- ...pluginResources.mcp,
1403
+ ...allowedWorkspaceMcpResources(refreshedMcp, trusted),
1281
1404
  ...cli.mcpResources,
1282
1405
  ]);
1283
1406
  },
@@ -1439,7 +1562,7 @@ const createDefaultService = async ({ eventSink, requireProvider, hooksOnly = fa
1439
1562
  const hooks = cli.safeMode || simpleMode
1440
1563
  ? undefined
1441
1564
  : new ClaudeHookRunner({
1442
- settings,
1565
+ settings: hookSettings,
1443
1566
  cwd,
1444
1567
  onEvent: (event) => runtimeEventSink({ type: 'hook', event }),
1445
1568
  sessionEnvironment: hookSessionEnvironment,
@@ -1771,7 +1894,7 @@ const createDefaultService = async ({ eventSink, requireProvider, hooksOnly = fa
1771
1894
  ? {}
1772
1895
  : { progressMessage: definition.progressMessage }),
1773
1896
  })),
1774
- hookConfiguration: async () => projectTuiHooks(settings),
1897
+ hookConfiguration: async () => projectTuiHooks(hookSettings),
1775
1898
  mcpInspect: () => service.mcpInspect(),
1776
1899
  mcpReconnect: (name) => service.mcpReconnect(name),
1777
1900
  mcpAuthenticate: (name) => service.mcpAuthenticate(name),
@@ -2051,6 +2174,14 @@ export function createDefaultDependencies(entrypoint = fileURLToPath(import.meta
2051
2174
  runInteractive: async ({ agent, controls, initialPrompt, resume, signal, }) => {
2052
2175
  const { runInteractive } = await import('./cli/interactive.js');
2053
2176
  const interactiveControls = controls ?? DEFAULT_CLI_CONTROLS;
2177
+ let workspaceTrustPreflightOpen = true;
2178
+ const cachedWorkspaceTrustDecision = createWorkspaceTrustDecisionCache((request) => promptWorkspaceTrust(request, {
2179
+ output: (text) => process.stderr.write(text),
2180
+ ...(signal ? { signal } : {}),
2181
+ }));
2182
+ const approveInteractiveWorkspaceTrust = (request) => workspaceTrustPreflightOpen
2183
+ ? cachedWorkspaceTrustDecision(request)
2184
+ : Promise.resolve(false);
2054
2185
  const initialAdditionalDirectories = interactiveControls.addDirectories.map((directory) => realpathSync(resolve(process.cwd(), directory)));
2055
2186
  const interactiveDataPlane = interactiveControls.dataPlane ?? resolveDataPlane();
2056
2187
  const { configRoot: interactiveConfigRoot, statePath: interactiveStatePath, } = resolveInteractiveRuntimeSettingsLocation(interactiveDataPlane);
@@ -2079,11 +2210,14 @@ export function createDefaultDependencies(entrypoint = fileURLToPath(import.meta
2079
2210
  ...(agent === undefined ? {} : { agent }),
2080
2211
  controls: {
2081
2212
  ...interactiveControls,
2213
+ trustProject: workspaceTrustPreflightOpen && interactiveControls.trustProject,
2082
2214
  addDirectories: options.additionalDirectories ??
2083
2215
  interactiveControls.addDirectories,
2084
2216
  },
2085
2217
  interactive: true,
2218
+ approveWorkspaceTrust: approveInteractiveWorkspaceTrust,
2086
2219
  });
2220
+ workspaceTrustPreflightOpen = false;
2087
2221
  if (resumePath !== undefined) {
2088
2222
  await commands.registerResumePath?.(resumePath);
2089
2223
  }
@@ -3491,7 +3625,7 @@ async function executeMcpCommand(args, invocation, io, dependencies, signal) {
3491
3625
  writeError: (message) => io.stderr(message),
3492
3626
  createToolRegistry: async () => {
3493
3627
  const service = await dependencies.createService({
3494
- eventSink: () => undefined,
3628
+ eventSink: warningEventSink(io),
3495
3629
  requireProvider: false,
3496
3630
  exposeToolRegistry: true,
3497
3631
  approveTool: async () => true,
@@ -4273,7 +4407,7 @@ async function executeTeamCommand(args, io, dependencies, signal) {
4273
4407
  dataPlane: 'native',
4274
4408
  };
4275
4409
  const service = await dependencies.createService({
4276
- eventSink: () => undefined,
4410
+ eventSink: warningEventSink(io),
4277
4411
  requireProvider: !['list', 'status', 'logs', 'attach'].includes(parsed.command),
4278
4412
  exposeToolRegistry: true,
4279
4413
  controls: nativeControls,
@@ -4594,7 +4728,7 @@ async function execute(argv, io, dependencies, signal) {
4594
4728
  }
4595
4729
  if (invocation.initOnly) {
4596
4730
  const lifecycleService = await dependencies.createService({
4597
- eventSink: () => undefined,
4731
+ eventSink: warningEventSink(io),
4598
4732
  requireProvider: false,
4599
4733
  exposeToolRegistry: true,
4600
4734
  ...(signal ? { signal } : {}),
@@ -13,6 +13,11 @@ export interface JsonResource {
13
13
  plugin?: true;
14
14
  pluginName?: string;
15
15
  pluginSource?: string;
16
+ pluginExecutableSource?: {
17
+ kind: 'mcpb';
18
+ source: string;
19
+ fingerprint: string;
20
+ };
16
21
  environment?: Readonly<Record<string, string>>;
17
22
  sensitiveValues?: readonly string[];
18
23
  }
@@ -1,4 +1,5 @@
1
1
  import { type DataPlane } from '../persistence/data-plane.js';
2
+ export declare const CLAUDE_PLUGIN_MCPB_ARCHIVE_BYTES: number;
2
3
  export type ClaudePluginMcpbUserValue = string | number | boolean | readonly string[];
3
4
  export interface ClaudePluginMcpbManifest {
4
5
  manifest_version: string;
@@ -60,6 +61,7 @@ export interface LoadClaudePluginMcpbOptions {
60
61
  signal?: AbortSignal;
61
62
  timeoutMs?: number;
62
63
  refresh?: boolean;
64
+ requireHttps?: boolean;
63
65
  limits?: ClaudePluginMcpbLimits;
64
66
  fetch?: typeof fetch;
65
67
  }
@@ -13,6 +13,7 @@ import manifestSchemaV04 from './mcpb-schemas/mcpb-manifest-v0.4.schema.json' wi
13
13
  import { ExclusiveFileLease, } from '../platform/exclusive-file-lease.js';
14
14
  import { resolveDataPlaneRoot, } from '../persistence/data-plane.js';
15
15
  const DEFAULT_ARCHIVE_BYTES = 512 * 1024 * 1024;
16
+ export const CLAUDE_PLUGIN_MCPB_ARCHIVE_BYTES = DEFAULT_ARCHIVE_BYTES;
16
17
  const DEFAULT_EXTRACTED_BYTES = 1024 * 1024 * 1024;
17
18
  const DEFAULT_FILE_BYTES = 512 * 1024 * 1024;
18
19
  const DEFAULT_FILES = 100_000;
@@ -540,9 +541,14 @@ async function fetchArchive(source, cached, options, signal, archiveLimit) {
540
541
  throw new Error('MCPB download redirect is missing Location');
541
542
  }
542
543
  const redirected = new URL(location, requestUrl);
543
- if (redirected.protocol !== 'http:' && redirected.protocol !== 'https:') {
544
+ if ((options.requireHttps && redirected.protocol !== 'https:') ||
545
+ (!options.requireHttps &&
546
+ redirected.protocol !== 'http:' &&
547
+ redirected.protocol !== 'https:')) {
544
548
  await response.body?.cancel();
545
- throw new Error('MCPB download redirect must use HTTP or HTTPS');
549
+ throw new Error(options.requireHttps
550
+ ? 'MCPB download redirect must use HTTPS'
551
+ : 'MCPB download redirect must use HTTP or HTTPS');
546
552
  }
547
553
  await response.body?.cancel();
548
554
  if (++redirects > 5) {
@@ -627,6 +633,9 @@ async function resolveMcpbSource(options, signal) {
627
633
  const pluginRoot = await realpath(resolve(options.pluginRoot));
628
634
  signal.throwIfAborted();
629
635
  const remote = /^https?:\/\//u.test(options.source);
636
+ if (options.requireHttps && remote && !/^https:\/\//u.test(options.source)) {
637
+ throw new Error('MCPB download source must use HTTPS');
638
+ }
630
639
  if (!remote && /^[a-z][a-z0-9+.-]*:\/\//iu.test(options.source))
631
640
  throw new Error('MCPB source URL must use HTTP or HTTPS');
632
641
  if (!['.mcpb', '.dxt'].some((extension) => options.source.endsWith(extension))) {
@@ -108,6 +108,8 @@ export declare function loadClaudePlugins(options: {
108
108
  pluginUrls?: readonly string[];
109
109
  loadInstalled?: boolean;
110
110
  readOnlyHooks?: boolean;
111
+ readOnlyExecutables?: boolean;
112
+ allowWorkspaceMcpb?: boolean;
111
113
  environment?: Readonly<Record<string, string | undefined>>;
112
114
  }): Promise<ClaudePluginResources>;
113
115
  export declare function validateClaudePlugin(path: string, options?: {
@@ -1,3 +1,5 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { createReadStream } from 'node:fs';
1
3
  import { cp, mkdir, readFile, readdir, realpath, rename, rm, stat, writeFile, } from 'node:fs/promises';
2
4
  import { execFile } from 'node:child_process';
3
5
  import { basename, dirname, extname, join, relative, resolve } from 'node:path';
@@ -5,8 +7,34 @@ import { promisify } from 'node:util';
5
7
  import { parse as parseYaml } from 'yaml';
6
8
  import { countTokens } from '@anthropic-ai/tokenizer';
7
9
  import { claudePluginDataPath, materializeClaudePluginSource, readClaudePluginOptions, readClaudePluginMcpServerOptions, readClaudeSkillsDirectoryPlugins, replaceClaudePluginDirectory, readClaudeInstalledPlugins, validateClaudePluginUserConfig, } from './claude-plugin-marketplace.js';
8
- import { loadClaudePluginMcpb } from './claude-plugin-mcpb.js';
10
+ import { CLAUDE_PLUGIN_MCPB_ARCHIVE_BYTES, loadClaudePluginMcpb, } from './claude-plugin-mcpb.js';
9
11
  const execFileAsync = promisify(execFile);
12
+ async function boundedFileSha256(path) {
13
+ const metadata = await stat(path);
14
+ if (!metadata.isFile()) {
15
+ throw new Error(`MCPB archive must be a regular file: ${path}`);
16
+ }
17
+ if (metadata.size > CLAUDE_PLUGIN_MCPB_ARCHIVE_BYTES) {
18
+ throw new Error(`MCPB archive exceeds ${CLAUDE_PLUGIN_MCPB_ARCHIVE_BYTES} bytes`);
19
+ }
20
+ const source = createReadStream(path);
21
+ const hash = createHash('sha256');
22
+ let bytes = 0;
23
+ try {
24
+ for await (const chunk of source) {
25
+ const value = chunk;
26
+ bytes += value.byteLength;
27
+ if (bytes > CLAUDE_PLUGIN_MCPB_ARCHIVE_BYTES) {
28
+ throw new Error(`MCPB archive exceeds ${CLAUDE_PLUGIN_MCPB_ARCHIVE_BYTES} bytes`);
29
+ }
30
+ hash.update(value);
31
+ }
32
+ return hash.digest('hex');
33
+ }
34
+ finally {
35
+ source.destroy();
36
+ }
37
+ }
10
38
  const PLUGIN_MANIFEST = join('.claude-plugin', 'plugin.json');
11
39
  const LEGACY_MANIFEST = 'plugin.json';
12
40
  const MAX_PLUGIN_FILES = 2_000;
@@ -139,6 +167,16 @@ function safePluginPath(pluginPath, candidate) {
139
167
  }
140
168
  return resolved;
141
169
  }
170
+ async function canonicalPluginPath(pluginPath, candidate) {
171
+ const [root, resolved] = await Promise.all([
172
+ realpath(pluginPath),
173
+ realpath(candidate),
174
+ ]);
175
+ if (resolved !== root && !resolved.startsWith(`${root}/`)) {
176
+ throw new Error(`Plugin path escapes plugin root: ${candidate}`);
177
+ }
178
+ return resolved;
179
+ }
142
180
  async function assertPluginPath(pluginPath, candidate) {
143
181
  let resolved;
144
182
  try {
@@ -462,7 +500,7 @@ async function loadLspServers(root, manifest, source, configRoot, environment, u
462
500
  }
463
501
  return [...definitions.values()];
464
502
  }
465
- async function loadPlugin(pluginPath, source, enabled, cwd, requireManifest = false, resourceScope, configRoot, environment = process.env, configId, readOnlyHooks = false, dataPlane) {
503
+ async function loadPlugin(pluginPath, source, enabled, cwd, requireManifest = false, resourceScope, configRoot, environment = process.env, configId, readOnlyHooks = false, readOnlyExecutables = false, allowWorkspaceMcpb = true, dataPlane) {
466
504
  const canonical = await realpath(pluginPath);
467
505
  if (!(await isDirectory(canonical)))
468
506
  throw new Error(`Plugin path is not a directory: ${pluginPath}`);
@@ -480,7 +518,8 @@ async function loadPlugin(pluginPath, source, enabled, cwd, requireManifest = fa
480
518
  : [])
481
519
  .filter((value) => value.length > 0);
482
520
  const pluginData = claudePluginDataPath(configRoot ?? canonical, source);
483
- if (configRoot !== undefined && !readOnlyHooks) {
521
+ const readOnlyComponents = readOnlyHooks || readOnlyExecutables;
522
+ if (configRoot !== undefined && !readOnlyComponents) {
484
523
  await mkdir(pluginData, { recursive: true });
485
524
  }
486
525
  const pluginEnvironment = pluginOptionEnvironment(canonical, pluginData, userConfig);
@@ -491,7 +530,7 @@ async function loadPlugin(pluginPath, source, enabled, cwd, requireManifest = fa
491
530
  environment: pluginEnvironment,
492
531
  ...(sensitiveValues.length === 0 ? {} : { sensitiveValues }),
493
532
  };
494
- const scope = resourceScope ?? scopeForPath(canonical, cwd);
533
+ const scope = resourceScope ?? scopeForPath(canonical, await realpath(resolve(cwd)));
495
534
  const commandDefinitions = isRecord(manifest.commands)
496
535
  ? manifest.commands
497
536
  : undefined;
@@ -506,7 +545,7 @@ async function loadPlugin(pluginPath, source, enabled, cwd, requireManifest = fa
506
545
  ? [join(canonical, 'skills'), canonical]
507
546
  : pathList(manifest.skills, 'skills').map((path) => safePluginPath(canonical, path));
508
547
  const agentsRoots = pathList(manifest.agents, 'agents').map((path) => safePluginPath(canonical, path));
509
- const [commands, skills, agents, lsp] = readOnlyHooks
548
+ const [commands, skills, agents, lsp] = readOnlyComponents
510
549
  ? [[], [], [], []]
511
550
  : await (async () => {
512
551
  const [commandFiles, skillFiles, agentFiles, loadedLsp] = await Promise.all([
@@ -524,7 +563,7 @@ async function loadPlugin(pluginPath, source, enabled, cwd, requireManifest = fa
524
563
  ]);
525
564
  return [...loadedText, loadedLsp];
526
565
  })();
527
- if (commandDefinitions && !readOnlyHooks) {
566
+ if (commandDefinitions && !readOnlyComponents) {
528
567
  for (const [commandName, definition] of Object.entries(commandDefinitions)) {
529
568
  if (definition.content !== undefined) {
530
569
  commands.push({
@@ -649,17 +688,61 @@ async function loadPlugin(pluginPath, source, enabled, cwd, requireManifest = fa
649
688
  if (typeof spec === 'string') {
650
689
  const urlReference = isUrlReference(spec);
651
690
  if ((urlReference &&
652
- (!/^https?:\/\//u.test(spec) || !isMcpbReference(spec))) ||
691
+ (!/^https?:\/\//u.test(spec) ||
692
+ !isMcpbReference(spec) ||
693
+ (scope !== 'user' && !/^https:\/\//u.test(spec)))) ||
653
694
  (!urlReference && isMcpbReference(spec) && !spec.startsWith('./'))) {
654
695
  mcpErrors.push(`Invalid plugin MCPB reference at index ${index}: expected ./file.mcpb, ./file.dxt, or an exact-suffix HTTP(S) URL`);
655
696
  continue;
656
697
  }
657
698
  if (isMcpbReference(spec)) {
699
+ let mcpbPath;
700
+ let sourceFingerprint;
701
+ try {
702
+ mcpbPath = urlReference
703
+ ? join(canonical, '.claude-plugin', `plugin-mcpb-${index}.json`)
704
+ : await canonicalPluginPath(canonical, safePluginPath(canonical, spec));
705
+ sourceFingerprint = createHash('sha256')
706
+ .update(urlReference ? spec : await boundedFileSha256(mcpbPath))
707
+ .digest('hex');
708
+ }
709
+ catch (error) {
710
+ mcpErrors.push(`Invalid plugin MCPB reference at index ${index}: ${error instanceof Error ? error.message : String(error)}`);
711
+ continue;
712
+ }
713
+ const pluginExecutableSource = {
714
+ kind: 'mcpb',
715
+ source: spec,
716
+ fingerprint: sourceFingerprint,
717
+ };
718
+ if (readOnlyExecutables) {
719
+ mcp.push({
720
+ path: mcpbPath,
721
+ scope,
722
+ plugin: true,
723
+ value: {
724
+ mcpServers: {
725
+ [`plugin:${manifest.name}:mcpb-${index}`]: {
726
+ type: 'mcpb-reference',
727
+ source: spec,
728
+ fingerprint: sourceFingerprint,
729
+ },
730
+ },
731
+ },
732
+ environment: pluginEnvironment,
733
+ pluginExecutableSource,
734
+ sensitiveValues,
735
+ });
736
+ continue;
737
+ }
738
+ if (scope !== 'user' && !allowWorkspaceMcpb)
739
+ continue;
658
740
  try {
659
741
  const loaded = await loadClaudePluginMcpb({
660
742
  pluginRoot: canonical,
661
743
  pluginData,
662
744
  source: spec,
745
+ requireHttps: scope !== 'user',
663
746
  ...(configRoot === undefined ? {} : { configRoot }),
664
747
  ...(dataPlane === undefined ? {} : { dataPlane }),
665
748
  environment,
@@ -667,8 +750,14 @@ async function loadPlugin(pluginPath, source, enabled, cwd, requireManifest = fa
667
750
  ? readClaudePluginMcpServerOptions(configRoot, cwd, configId, bundleManifest.name, bundleManifest.user_config, dataPlane)
668
751
  : {},
669
752
  });
753
+ if (!urlReference &&
754
+ createHash('sha256')
755
+ .update(await boundedFileSha256(mcpbPath))
756
+ .digest('hex') !== sourceFingerprint) {
757
+ throw new Error(`Plugin MCPB source changed while loading: ${mcpbPath}`);
758
+ }
670
759
  mcp.push({
671
- path: join(canonical, '.claude-plugin', `plugin-mcpb-${index}.json`),
760
+ path: mcpbPath,
672
761
  scope,
673
762
  plugin: true,
674
763
  value: {
@@ -677,6 +766,7 @@ async function loadPlugin(pluginPath, source, enabled, cwd, requireManifest = fa
677
766
  },
678
767
  },
679
768
  environment: pluginEnvironment,
769
+ pluginExecutableSource,
680
770
  sensitiveValues: [...sensitiveValues, ...loaded.sensitiveValues],
681
771
  });
682
772
  }
@@ -840,7 +930,7 @@ export async function loadClaudePlugins(options) {
840
930
  if (seen.has(canonical))
841
931
  return null;
842
932
  seen.add(canonical);
843
- return await loadPlugin(canonical, candidate.source, candidate.enabled, options.cwd, false, candidate.resourceScope, options.configRoot, options.environment ?? process.env, candidate.configId, options.readOnlyHooks, options.dataPlane);
933
+ return await loadPlugin(canonical, candidate.source, candidate.enabled, options.cwd, false, candidate.resourceScope, options.configRoot, options.environment ?? process.env, candidate.configId, options.readOnlyHooks, options.readOnlyExecutables, options.allowWorkspaceMcpb, options.dataPlane);
844
934
  }
845
935
  catch (error) {
846
936
  if (options.strictPluginDirectories &&
@@ -0,0 +1,30 @@
1
+ import type { JsonResource } from '../core/resources.js';
2
+ export interface WorkspaceExecutableOrigin {
3
+ readonly kind: 'hook' | 'mcp';
4
+ readonly scope: 'project' | 'local';
5
+ readonly path: string;
6
+ readonly label: string;
7
+ }
8
+ export interface WorkspaceTrustInventory {
9
+ readonly canonicalPath: string;
10
+ readonly fingerprint: string;
11
+ readonly origins: readonly WorkspaceExecutableOrigin[];
12
+ }
13
+ export type WorkspaceTrustStatus = 'not-required' | 'trusted' | 'untrusted';
14
+ export interface WorkspaceTrustAssessment extends WorkspaceTrustInventory {
15
+ readonly status: WorkspaceTrustStatus;
16
+ }
17
+ export declare function canonicalizeWorkspaceTrust(value: unknown, ancestors?: Set<object>): string;
18
+ export declare function hasWorkspaceHooks(resource: JsonResource): boolean;
19
+ export declare function hasWorkspaceMcpServers(resource: JsonResource): boolean;
20
+ export declare function allowedWorkspaceHookSettings(resources: readonly JsonResource[], trusted: boolean): JsonResource[];
21
+ export declare function allowedWorkspaceMcpResources(resources: readonly JsonResource[], trusted: boolean): JsonResource[];
22
+ export declare function workspaceTrustInventory(options: {
23
+ cwd: string;
24
+ settings?: readonly JsonResource[];
25
+ mcp?: readonly JsonResource[];
26
+ }): Promise<WorkspaceTrustInventory>;
27
+ export declare function workspaceTrustDecisionKey(inventory: Pick<WorkspaceTrustInventory, 'canonicalPath' | 'fingerprint'>): string;
28
+ export declare function assessWorkspaceTrust(inventory: WorkspaceTrustInventory, statePath: string): Promise<WorkspaceTrustAssessment>;
29
+ export declare function persistWorkspaceTrust(assessment: WorkspaceTrustAssessment, statePath: string): Promise<void>;
30
+ //# sourceMappingURL=workspace-trust.d.ts.map
@@ -0,0 +1,278 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { constants } from 'node:fs';
3
+ import { open, realpath } from 'node:fs/promises';
4
+ import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
5
+ import { setTimeout as sleep } from 'node:timers/promises';
6
+ import { writeFileAtomically } from '../platform/atomic-write.js';
7
+ import { ExclusiveFileLease } from '../platform/exclusive-file-lease.js';
8
+ const MISSING_FINGERPRINT = 'missing';
9
+ const TRUST_VERSION = 1;
10
+ const FINGERPRINT_PATTERN = /^[a-f0-9]{64}$/u;
11
+ function isRecord(value) {
12
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
13
+ }
14
+ export function canonicalizeWorkspaceTrust(value, ancestors = new Set()) {
15
+ if (value === null || typeof value === 'string' || typeof value === 'boolean')
16
+ return JSON.stringify(value);
17
+ if (typeof value === 'number') {
18
+ if (!Number.isFinite(value))
19
+ throw new TypeError('Workspace trust config contains a non-JSON number');
20
+ return JSON.stringify(value);
21
+ }
22
+ if (typeof value !== 'object')
23
+ throw new TypeError('Workspace trust config contains a non-JSON value');
24
+ if (ancestors.has(value))
25
+ throw new TypeError('Workspace trust config contains a cycle');
26
+ ancestors.add(value);
27
+ try {
28
+ if (Array.isArray(value)) {
29
+ return `[${value
30
+ .map((item) => canonicalizeWorkspaceTrust(item, ancestors))
31
+ .join(',')}]`;
32
+ }
33
+ return `{${Object.keys(value)
34
+ .sort()
35
+ .map((key) => `${JSON.stringify(key)}:${canonicalizeWorkspaceTrust(value[key], ancestors)}`)
36
+ .join(',')}}`;
37
+ }
38
+ finally {
39
+ ancestors.delete(value);
40
+ }
41
+ }
42
+ function executableMap(resource, kind) {
43
+ if (!isRecord(resource.value))
44
+ return null;
45
+ const key = kind === 'hook' ? 'hooks' : 'mcpServers';
46
+ const value = resource.value[key];
47
+ return isRecord(value) && Object.keys(value).length > 0 ? value : null;
48
+ }
49
+ export function hasWorkspaceHooks(resource) {
50
+ return executableMap(resource, 'hook') !== null;
51
+ }
52
+ export function hasWorkspaceMcpServers(resource) {
53
+ return executableMap(resource, 'mcp') !== null;
54
+ }
55
+ export function allowedWorkspaceHookSettings(resources, trusted) {
56
+ if (trusted)
57
+ return [...resources];
58
+ return resources.filter((resource) => resource.scope === 'user' || !hasWorkspaceHooks(resource));
59
+ }
60
+ export function allowedWorkspaceMcpResources(resources, trusted) {
61
+ if (trusted)
62
+ return [...resources];
63
+ return resources.filter((resource) => resource.scope === 'user' || !hasWorkspaceMcpServers(resource));
64
+ }
65
+ async function resolvedResourcePath(canonicalPath, requestedWorkspace, path) {
66
+ if (path.startsWith('<'))
67
+ return path;
68
+ const absolute = isAbsolute(path)
69
+ ? resolve(path)
70
+ : resolve(requestedWorkspace, path);
71
+ const workspaceRelative = relative(requestedWorkspace, absolute);
72
+ if (workspaceRelative === '' ||
73
+ (!workspaceRelative.startsWith('..') && !isAbsolute(workspaceRelative))) {
74
+ const canonicalSource = resolve(canonicalPath, workspaceRelative);
75
+ try {
76
+ return await realpath(canonicalSource);
77
+ }
78
+ catch (error) {
79
+ if (error.code === 'ENOENT') {
80
+ return canonicalSource;
81
+ }
82
+ throw error;
83
+ }
84
+ }
85
+ try {
86
+ return await realpath(absolute);
87
+ }
88
+ catch (error) {
89
+ if (error.code === 'ENOENT')
90
+ return absolute;
91
+ throw error;
92
+ }
93
+ }
94
+ export async function workspaceTrustInventory(options) {
95
+ const canonicalPath = await realpath(options.cwd);
96
+ const requestedWorkspace = resolve(options.cwd);
97
+ const origins = [];
98
+ const fingerprintEntries = [];
99
+ const collect = async (resources, kind) => {
100
+ for (const resource of resources) {
101
+ if (resource.scope === 'user')
102
+ continue;
103
+ const entries = executableMap(resource, kind);
104
+ if (!entries)
105
+ continue;
106
+ const path = await resolvedResourcePath(canonicalPath, requestedWorkspace, resource.path);
107
+ for (const [label, config] of Object.entries(entries)) {
108
+ const entry = {
109
+ kind,
110
+ scope: resource.scope,
111
+ path,
112
+ label,
113
+ config,
114
+ ...(kind === 'hook' && resource.environment !== undefined
115
+ ? { environment: resource.environment }
116
+ : {}),
117
+ };
118
+ fingerprintEntries.push(entry);
119
+ origins.push({ kind, scope: resource.scope, path, label });
120
+ }
121
+ }
122
+ };
123
+ await collect(options.settings ?? [], 'hook');
124
+ await collect(options.mcp ?? [], 'mcp');
125
+ const sortedEntries = fingerprintEntries
126
+ .map((entry) => canonicalizeWorkspaceTrust(entry))
127
+ .sort();
128
+ origins.sort((left, right) => canonicalizeWorkspaceTrust(left).localeCompare(canonicalizeWorkspaceTrust(right)));
129
+ return {
130
+ canonicalPath,
131
+ fingerprint: createHash('sha256')
132
+ .update(`[${sortedEntries.join(',')}]`)
133
+ .digest('hex'),
134
+ origins,
135
+ };
136
+ }
137
+ export function workspaceTrustDecisionKey(inventory) {
138
+ return `${inventory.canonicalPath}\0${inventory.fingerprint}`;
139
+ }
140
+ function validateInventory(inventory) {
141
+ if (!inventory.canonicalPath ||
142
+ !isAbsolute(inventory.canonicalPath) ||
143
+ resolve(inventory.canonicalPath) !== inventory.canonicalPath) {
144
+ throw new TypeError('Workspace trust requires a canonical absolute path');
145
+ }
146
+ if (!FINGERPRINT_PATTERN.test(inventory.fingerprint))
147
+ throw new TypeError('Workspace trust fingerprint must be lowercase SHA-256');
148
+ for (const origin of inventory.origins) {
149
+ if ((origin.kind !== 'hook' && origin.kind !== 'mcp') ||
150
+ (origin.scope !== 'project' && origin.scope !== 'local') ||
151
+ origin.path.length === 0 ||
152
+ origin.label.length === 0) {
153
+ throw new TypeError('Workspace trust inventory contains an invalid origin');
154
+ }
155
+ }
156
+ }
157
+ async function readState(path) {
158
+ let handle;
159
+ try {
160
+ handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
161
+ }
162
+ catch (error) {
163
+ const code = error.code;
164
+ if (code === 'ENOENT')
165
+ return { content: null, fingerprint: MISSING_FINGERPRINT };
166
+ if (code === 'ELOOP')
167
+ throw new Error(`Workspace trust state must be a regular file: ${path}`, {
168
+ cause: error,
169
+ });
170
+ throw error;
171
+ }
172
+ try {
173
+ if (!(await handle.stat()).isFile())
174
+ throw new Error(`Workspace trust state must be a regular file: ${path}`);
175
+ const content = await handle.readFile('utf8');
176
+ return {
177
+ content,
178
+ fingerprint: createHash('sha256').update(content).digest('hex'),
179
+ };
180
+ }
181
+ finally {
182
+ await handle.close();
183
+ }
184
+ }
185
+ function parseStateRoot(content, path) {
186
+ if (content === null)
187
+ return {};
188
+ let value;
189
+ try {
190
+ value = JSON.parse(content);
191
+ }
192
+ catch (error) {
193
+ throw new Error(`Invalid workspace trust state: ${path}`, { cause: error });
194
+ }
195
+ if (!isRecord(value))
196
+ throw new Error(`Workspace trust state root must be an object: ${path}`);
197
+ return value;
198
+ }
199
+ function projectState(root, canonicalPath, statePath) {
200
+ if (root.projects !== undefined && !isRecord(root.projects))
201
+ throw new Error(`Workspace trust projects must be an object: ${statePath}`);
202
+ if (isRecord(root.projects)) {
203
+ for (const [path, project] of Object.entries(root.projects)) {
204
+ if (!isRecord(project)) {
205
+ throw new Error(`Workspace trust project entry must be an object: ${statePath} (${path})`);
206
+ }
207
+ }
208
+ }
209
+ const project = isRecord(root.projects)
210
+ ? root.projects[canonicalPath]
211
+ : undefined;
212
+ return isRecord(project) ? project : undefined;
213
+ }
214
+ function matchesRecord(value, fingerprint) {
215
+ return (isRecord(value) &&
216
+ value.version === TRUST_VERSION &&
217
+ value.fingerprint === fingerprint &&
218
+ typeof value.acceptedAt === 'string' &&
219
+ Number.isFinite(Date.parse(value.acceptedAt)));
220
+ }
221
+ export async function assessWorkspaceTrust(inventory, statePath) {
222
+ validateInventory(inventory);
223
+ if (inventory.origins.length === 0)
224
+ return { ...inventory, status: 'not-required' };
225
+ const root = parseStateRoot((await readState(statePath)).content, statePath);
226
+ const project = projectState(root, inventory.canonicalPath, statePath);
227
+ return {
228
+ ...inventory,
229
+ status: matchesRecord(project?.workspaceTrust, inventory.fingerprint)
230
+ ? 'trusted'
231
+ : 'untrusted',
232
+ };
233
+ }
234
+ async function acquireStateLease(statePath) {
235
+ const lease = new ExclusiveFileLease(join(dirname(statePath), '.praxis-state.lock'));
236
+ for (let attempt = 0; attempt < 400; attempt += 1) {
237
+ const handle = await lease.tryAcquire();
238
+ if (handle)
239
+ return handle;
240
+ await sleep(5);
241
+ }
242
+ throw new Error(`Timed out acquiring workspace trust lock: ${statePath}`);
243
+ }
244
+ export async function persistWorkspaceTrust(assessment, statePath) {
245
+ validateInventory(assessment);
246
+ if (assessment.status === 'not-required' || assessment.origins.length === 0) {
247
+ throw new TypeError('Cannot persist an empty workspace trust assessment');
248
+ }
249
+ const lease = await acquireStateLease(statePath);
250
+ try {
251
+ for (let attempt = 0; attempt < 3; attempt += 1) {
252
+ const current = await readState(statePath);
253
+ const root = parseStateRoot(current.content, statePath);
254
+ const existingProject = projectState(root, assessment.canonicalPath, statePath);
255
+ const projects = isRecord(root.projects) ? { ...root.projects } : {};
256
+ projects[assessment.canonicalPath] = {
257
+ ...(existingProject ?? {}),
258
+ workspaceTrust: {
259
+ version: TRUST_VERSION,
260
+ fingerprint: assessment.fingerprint,
261
+ acceptedAt: new Date().toISOString(),
262
+ },
263
+ };
264
+ const next = { ...root, projects };
265
+ const committed = await writeFileAtomically(statePath, `${JSON.stringify(next, null, 2)}\n`, {
266
+ mode: 0o600,
267
+ beforeCommit: async () => (await readState(statePath)).fingerprint === current.fingerprint,
268
+ });
269
+ if (committed)
270
+ return;
271
+ }
272
+ throw new Error(`Workspace trust state changed concurrently: ${statePath}`);
273
+ }
274
+ finally {
275
+ await lease.release();
276
+ }
277
+ }
278
+ //# sourceMappingURL=workspace-trust.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "praxis-agent",
3
- "version": "0.40.0",
3
+ "version": "0.41.0",
4
4
  "description": "Local-first, single-user general agent for the command line.",
5
5
  "license": "MIT",
6
6
  "author": "wuqisen",