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 +3 -1
- package/dist/cli/controls.js +2 -0
- package/dist/cli/protocol.d.ts +1 -0
- package/dist/cli/protocol.js +6 -0
- package/dist/cli/workspace-trust-prompt.d.ts +10 -0
- package/dist/cli/workspace-trust-prompt.js +53 -0
- package/dist/cli-runtime.d.ts +2 -0
- package/dist/cli-runtime.js +159 -25
- package/dist/core/resources.d.ts +5 -0
- package/dist/plugins/claude-plugin-mcpb.d.ts +2 -0
- package/dist/plugins/claude-plugin-mcpb.js +11 -2
- package/dist/plugins/claude-plugin-runtime.d.ts +2 -0
- package/dist/plugins/claude-plugin-runtime.js +99 -9
- package/dist/security/workspace-trust.d.ts +30 -0
- package/dist/security/workspace-trust.js +278 -0
- package/package.json +1 -1
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,
|
|
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,
|
package/dist/cli/controls.js
CHANGED
|
@@ -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,
|
package/dist/cli/protocol.d.ts
CHANGED
|
@@ -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;
|
package/dist/cli/protocol.js
CHANGED
|
@@ -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
|
package/dist/cli-runtime.d.ts
CHANGED
|
@@ -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;
|
package/dist/cli-runtime.js
CHANGED
|
@@ -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 [
|
|
1026
|
+
const [sharedResources, pluginResources] = await Promise.all([
|
|
976
1027
|
cli.safeMode || simpleMode
|
|
977
|
-
? []
|
|
978
|
-
:
|
|
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
|
-
|
|
1042
|
+
readOnlyExecutables: true,
|
|
987
1043
|
environment: runtimeEnvironment,
|
|
988
1044
|
}),
|
|
989
1045
|
]);
|
|
990
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
...
|
|
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
|
|
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 = [...
|
|
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:
|
|
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:
|
|
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
|
-
...
|
|
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(
|
|
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: ()
|
|
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: ()
|
|
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: ()
|
|
4731
|
+
eventSink: warningEventSink(io),
|
|
4598
4732
|
requireProvider: false,
|
|
4599
4733
|
exposeToolRegistry: true,
|
|
4600
4734
|
...(signal ? { signal } : {}),
|
package/dist/core/resources.d.ts
CHANGED
|
@@ -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 (
|
|
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(
|
|
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
|
-
|
|
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] =
|
|
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 && !
|
|
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) ||
|
|
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:
|
|
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
|