praxis-agent 0.40.0 → 0.42.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.
@@ -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.42.0",
4
4
  "description": "Local-first, single-user general agent for the command line.",
5
5
  "license": "MIT",
6
6
  "author": "wuqisen",