@procedures/fs 0.7.0 → 0.8.1

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package",
3
3
  "name": "@procedures/fs",
4
- "version": "0.7.0",
4
+ "version": "0.8.1",
5
5
  "description": "File system procedures: read, write, copy, move, glob, exists, mkdir and JSON read and write, callable in process, over RPC or from Claude through MCP.",
6
6
  "keywords": [
7
7
  "filesystem",
@@ -37,6 +37,10 @@
37
37
  "./register": {
38
38
  "types": "./dist/register.d.ts",
39
39
  "import": "./dist/register.js"
40
+ },
41
+ "./root": {
42
+ "types": "./dist/root.d.ts",
43
+ "import": "./dist/root.js"
40
44
  }
41
45
  },
42
46
  "files": [
@@ -61,7 +65,7 @@
61
65
  "procedures": "./dist/register.js"
62
66
  },
63
67
  "dependencies": {
64
- "@procedures/client": "0.7.0",
68
+ "@procedures/client": "0.8.1",
65
69
  "zod": "^4.6.5"
66
70
  },
67
71
  "devDependencies": {
package/src/confine.ts ADDED
@@ -0,0 +1,246 @@
1
+ /**
2
+ * The root of the files for all the procedures of a registry (roadmap R33).
3
+ *
4
+ * `confineFsProcedures()` gives the `fs.*` procedures the handlers of the root. The other
5
+ * procedures reach files too: they read with `node:fs`, or they start a program. This file
6
+ * applies the rules that each procedure declares in `metadata.fsRoot` (`FsRootRules` of the core):
7
+ *
8
+ * - **confined**: the procedure names its path inputs. The host examines each of them before the
9
+ * handler starts. A working folder that the call does not give is the root. A path of `paths`
10
+ * starts at the root, and the handler gets it absolute. A path of `names` starts at the working
11
+ * folder, and the handler gets it as the caller gave it (a program resolves it the same way).
12
+ * - **program**: the procedure starts the program of its input (`shell.run`). The host refuses a
13
+ * call of a client. It accepts the nested call of a confined procedure that names the program
14
+ * procedure in `metadata.calls`: that caller chose the program and its arguments.
15
+ * - **refused**: the rules give the reason that the root cannot confine the procedure.
16
+ *
17
+ * A procedure without rules gets a decision from its effects. The decision fails closed: the host
18
+ * refuses the procedure when its effects are not known, when it reads or writes `fs`, or when it
19
+ * writes `process`. A data-driven procedure stays as it is: it reaches files only through its
20
+ * nested calls, and each nested call gets the rules of its own procedure.
21
+ *
22
+ * Each refusal is the error of a path outside the root: `FORBIDDEN` with the reason `outside-root`.
23
+ */
24
+
25
+ import {
26
+ callMatches,
27
+ declaredCalls,
28
+ effectsOf,
29
+ isDataDriven,
30
+ pathToKey,
31
+ scopeOf,
32
+ withScope,
33
+ type AnyProcedure,
34
+ type FsRootRules,
35
+ type ProcedureContext,
36
+ type ProcedurePath,
37
+ type ProcedureRegistry,
38
+ } from "@procedures/client";
39
+ import { inRoot, outsideRoot, resolveRoot, type ResolvedRoot } from "./root.js";
40
+
41
+ /** What a root does to a procedure. */
42
+ export type RootKind = "fs" | "confined" | "program" | "refused" | "unchanged";
43
+
44
+ /** The decision of a root for one procedure. */
45
+ export interface RootDecision {
46
+ readonly kind: RootKind;
47
+ /** For the kind `refused`: the reason. */
48
+ readonly reason?: string;
49
+ }
50
+
51
+ /** The resource name of the files of the host (CAPABILITIES-2026-10). */
52
+ const FILES = "fs";
53
+ /** The resource name of a procedure that starts any program. */
54
+ const PROGRAMS = "process";
55
+
56
+ /** The keys of the rules that `FsRootRules` has. */
57
+ const RULE_KEYS: ReadonlySet<string> = new Set(["cwd", "paths", "names", "required", "values", "refuse", "program"]);
58
+
59
+ /** The procedure key of a path, for the messages. */
60
+ const keyOf = (procedure: { readonly path: ProcedurePath }): string => pathToKey(procedure.path);
61
+
62
+ /** True for a list of names that are not empty. */
63
+ function isNameList(value: unknown): value is readonly string[] {
64
+ return Array.isArray(value) && value.every((name) => typeof name === "string" && name.length > 0);
65
+ }
66
+
67
+ /**
68
+ * This function gives the rules of a procedure, or undefined when it has none. It throws for
69
+ * rules that are not correct, because the root cannot apply them.
70
+ */
71
+ export function rootRulesOf(procedure: Pick<AnyProcedure, "path" | "metadata">): FsRootRules | undefined {
72
+ const rules = (procedure.metadata as { fsRoot?: unknown } | undefined)?.fsRoot;
73
+ if (rules === undefined) return undefined;
74
+ const invalid = (problem: string): Error => new Error(`The metadata.fsRoot of ${keyOf(procedure)} is not correct: ${problem}`);
75
+ if (rules === null || typeof rules !== "object" || Array.isArray(rules)) throw invalid("it must be an object");
76
+ const record = rules as Record<string, unknown>;
77
+ for (const key of Object.keys(record)) {
78
+ if (!RULE_KEYS.has(key)) throw invalid(`it has the unknown key ${key}`);
79
+ }
80
+ if (record["cwd"] !== undefined && (typeof record["cwd"] !== "string" || record["cwd"] === "")) throw invalid("cwd must be the name of an input");
81
+ for (const key of ["paths", "names", "required", "values"] as const) {
82
+ if (record[key] !== undefined && !isNameList(record[key])) throw invalid(`${key} must be a list of texts`);
83
+ }
84
+ if (record["refuse"] !== undefined && (typeof record["refuse"] !== "string" || record["refuse"] === "")) throw invalid("refuse must be the reason, as text");
85
+ if (record["program"] !== undefined && typeof record["program"] !== "boolean") throw invalid("program must be true or false");
86
+ const typed = rules as FsRootRules;
87
+ if (typed.refuse !== undefined && Object.keys(record).length > 1) throw invalid("refuse cannot go with other rules");
88
+ const paths = new Set(typed.paths ?? []);
89
+ for (const name of typed.required ?? []) {
90
+ if (!paths.has(name)) throw invalid(`the required input ${name} is not in paths`);
91
+ }
92
+ const inputs = [...(typed.cwd !== undefined ? [typed.cwd] : []), ...(typed.paths ?? []), ...(typed.names ?? [])];
93
+ if (new Set(inputs).size !== inputs.length) throw invalid("an input is in more than one of cwd, paths and names");
94
+ return typed;
95
+ }
96
+
97
+ /**
98
+ * This function tells what a root does to a procedure, without a root. It throws for rules that
99
+ * are not correct. `confineToRoot()` uses the same decision.
100
+ */
101
+ export function rootDecisionOf(procedure: Pick<AnyProcedure, "path" | "metadata" | "handler">): RootDecision {
102
+ if (procedure.path[0] === FILES) return { kind: "fs" };
103
+ const rules = rootRulesOf(procedure);
104
+ if (rules !== undefined) {
105
+ if (rules.refuse !== undefined) return { kind: "refused", reason: rules.refuse };
106
+ return { kind: rules.program === true ? "program" : "confined" };
107
+ }
108
+ // A data-driven procedure runs the refs of its caller: each nested call gets its own decision
109
+ if (isDataDriven(procedure)) return { kind: "unchanged" };
110
+ const effects = effectsOf(procedure);
111
+ if (effects?.reads === undefined || effects.writes === undefined) {
112
+ return { kind: "refused", reason: "it declares no effects and no metadata.fsRoot, so its files are not known" };
113
+ }
114
+ if (effects.reads.includes(FILES) || effects.writes.includes(FILES)) {
115
+ return { kind: "refused", reason: "it reads or writes files, and it declares no metadata.fsRoot" };
116
+ }
117
+ if (effects.writes.includes(PROGRAMS)) {
118
+ return { kind: "refused", reason: "it starts a program, and it declares no metadata.fsRoot" };
119
+ }
120
+ return { kind: "unchanged" };
121
+ }
122
+
123
+ /** What `confineToRoot()` did. Each list holds the keys of the procedures. */
124
+ export interface ConfinedRegistry {
125
+ /** The absolute path of the root. */
126
+ root: string;
127
+ /** The `fs.*` procedures: they have the handlers of `createFsModule({ root })`. */
128
+ fs: string[];
129
+ /** The procedures whose path inputs the root examines. */
130
+ confined: string[];
131
+ /** The procedures that start the program of their input: only as a nested call of a confined procedure. */
132
+ programs: string[];
133
+ /** The procedures that the root refuses. */
134
+ refused: string[];
135
+ /** The procedures that reach no file: the root does not change them. */
136
+ unchanged: string[];
137
+ }
138
+
139
+ /** The values of a path input: a text, or a list of texts. Another value cannot be examined. */
140
+ function pathValues(value: unknown, name: string, at: ProcedurePath): string[] {
141
+ if (typeof value === "string") return [value];
142
+ if (Array.isArray(value) && value.every((item) => typeof item === "string")) return value as string[];
143
+ throw outsideRoot(at, `the input ${name} is not a path or a list of paths, so the root cannot examine it`);
144
+ }
145
+
146
+ /**
147
+ * This function gives the input with each path in the root. The working folder that the call does
148
+ * not give is the root. Each path of `paths` starts at the root and becomes absolute. A path of
149
+ * `names` starts at the working folder, and it stays as it is.
150
+ */
151
+ async function confinedInput(input: unknown, rules: FsRootRules, root: ResolvedRoot, at: ProcedurePath): Promise<unknown> {
152
+ const examines = rules.cwd !== undefined || (rules.paths?.length ?? 0) > 0 || (rules.names?.length ?? 0) > 0;
153
+ if (!examines) return input;
154
+ if (input === null || typeof input !== "object" || Array.isArray(input)) {
155
+ throw outsideRoot(at, "the input is not an object, so the root cannot examine its paths");
156
+ }
157
+ const given = input as Record<string, unknown>;
158
+ const next: Record<string, unknown> = { ...given };
159
+ const kept = new Set(rules.values ?? []);
160
+ let base = root.folder;
161
+ if (rules.cwd !== undefined) {
162
+ const value = given[rules.cwd];
163
+ if (value !== undefined && value !== null && typeof value !== "string") {
164
+ throw outsideRoot(at, `the input ${rules.cwd} is not a folder, so the root cannot examine it`);
165
+ }
166
+ base = await inRoot(root, typeof value === "string" ? value : ".", at);
167
+ next[rules.cwd] = base;
168
+ }
169
+ for (const name of rules.paths ?? []) {
170
+ const value = given[name];
171
+ if (value === undefined || value === null) {
172
+ if (rules.required?.includes(name)) {
173
+ throw outsideRoot(at, `the input ${name} has no value, and its default is outside the root ${root.folder}: give a path in the root`);
174
+ }
175
+ continue;
176
+ }
177
+ const absolute: string[] = [];
178
+ for (const path of pathValues(value, name, at)) absolute.push(kept.has(path) ? path : await inRoot(root, path, at));
179
+ next[name] = typeof value === "string" ? absolute[0] : absolute;
180
+ }
181
+ for (const name of rules.names ?? []) {
182
+ const value = given[name];
183
+ if (value === undefined || value === null) continue;
184
+ for (const path of pathValues(value, name, at)) if (!kept.has(path)) await inRoot(root, path, at, base);
185
+ }
186
+ // A runner reads the scope of its raw input: the copy keeps it (DOC-10)
187
+ return withScope(next, scopeOf(input));
188
+ }
189
+
190
+ /** The handler of a procedure under a root. */
191
+ type Handler = NonNullable<AnyProcedure["handler"]>;
192
+
193
+ /**
194
+ * This function applies the decision of each procedure of the registry, except `fs.*`, to a root
195
+ * that exists. It replaces the handler of each confined, program and refused procedure, and it
196
+ * adds no procedure. `confineToRoot()` of the module starts it after the `fs.*` procedures.
197
+ */
198
+ export function confineOtherProcedures(registry: ProcedureRegistry, folder: string): Omit<ConfinedRegistry, "root" | "fs"> {
199
+ const result: Omit<ConfinedRegistry, "root" | "fs"> = { confined: [], programs: [], refused: [], unchanged: [] };
200
+ // All decisions first: a rule that is not correct throws before the registry changes
201
+ const decisions = registry
202
+ .getAll()
203
+ .filter((procedure) => procedure.path[0] !== FILES)
204
+ .map((procedure) => ({ procedure, decision: rootDecisionOf(procedure) }));
205
+ const confinedProcedures = new WeakSet<AnyProcedure>();
206
+ const root = (): Promise<ResolvedRoot> => resolveRoot(folder);
207
+
208
+ for (const { procedure, decision } of decisions) {
209
+ const key = keyOf(procedure);
210
+ const original = procedure.handler;
211
+ if (decision.kind === "unchanged" || original === undefined) {
212
+ result.unchanged.push(key);
213
+ continue;
214
+ }
215
+ const rules = rootRulesOf(procedure) ?? {};
216
+ let handler: Handler;
217
+ if (decision.kind === "refused") {
218
+ const message = `the root ${folder} cannot confine this procedure: ${decision.reason ?? "no reason"}`;
219
+ handler = (async () => {
220
+ throw outsideRoot(procedure.path, message);
221
+ }) as Handler;
222
+ result.refused.push(key);
223
+ } else if (decision.kind === "program") {
224
+ handler = (async (input: unknown, ctx: ProcedureContext | undefined) => {
225
+ const caller = ctx?.caller;
226
+ const named = caller !== undefined && (declaredCalls(caller) ?? []).some((entry) => callMatches(entry, key));
227
+ if (caller === undefined || !confinedProcedures.has(caller) || !named) {
228
+ const from = caller === undefined ? "a client" : keyOf(caller);
229
+ throw outsideRoot(
230
+ procedure.path,
231
+ `this procedure starts the program of its input. Under the root ${folder}, it runs only as the nested call of a confined procedure that names it in metadata.calls, not for ${from}`,
232
+ );
233
+ }
234
+ return original(await confinedInput(input, rules, await root(), procedure.path), ctx as ProcedureContext);
235
+ }) as Handler;
236
+ result.programs.push(key);
237
+ } else {
238
+ handler = (async (input: unknown, ctx: ProcedureContext) =>
239
+ original(await confinedInput(input, rules, await root(), procedure.path), ctx)) as Handler;
240
+ result.confined.push(key);
241
+ }
242
+ registry.register({ ...procedure, handler }, { override: true });
243
+ if (decision.kind === "confined") confinedProcedures.add(registry.get(procedure.path)!);
244
+ }
245
+ return result;
246
+ }
package/src/module.ts CHANGED
@@ -7,13 +7,15 @@
7
7
  * This file is referenced by package.json's client.procedures field.
8
8
  *
9
9
  * `fsModule` operates on each path. `createFsModule({ root })` gives the same procedures, and
10
- * each path of their input must be in the root (roadmap R33, `root.ts`).
10
+ * each path of their input must be in the root (roadmap R33, `root.ts`). `confineToRoot()` gives
11
+ * a root to all the procedures of a registry (`confine.ts`).
11
12
  */
12
13
 
13
14
  import { createProcedure, zodAdapter, defineModule, type AnyProcedure, type Effects, type ProcedureModule, PROCEDURE_REGISTRY, registerModules, type ProcedureRegistry } from "@procedures/client";
14
15
  import { statSync } from "node:fs";
15
16
  import { resolve } from "node:path";
16
17
  import { HANDLER_NAMES, rootedHandlers } from "./handlers.js";
18
+ import { confineOtherProcedures, rootDecisionOf, type ConfinedRegistry } from "./confine.js";
17
19
  import { read } from "./procedures/fs/read.js";
18
20
  import { write } from "./procedures/fs/write.js";
19
21
  import { exists } from "./procedures/fs/exists.js";
@@ -325,6 +327,9 @@ const fsWatchProcedure = createProcedure()
325
327
  // Registration
326
328
  // =============================================================================
327
329
 
330
+ // The root for all the procedures of a registry (roadmap R33)
331
+ export { rootDecisionOf, rootRulesOf, type ConfinedRegistry, type RootDecision, type RootKind } from "./confine.js";
332
+
328
333
  /**
329
334
  * The module of this package, for `createRegistry()`: its procedures as data. Importing this
330
335
  * file registers nothing. (`register.ts` registers the same procedures in the global registry.)
@@ -395,8 +400,19 @@ export interface ConfinedFs {
395
400
  * A nested call of another procedure (`lib.scan` calls `fs.readdir`) finds the procedure in the
396
401
  * registry, so the root confines that call too. The root must be a folder that exists. An `fs.*`
397
402
  * procedure that this package does not have is an error, because the root cannot confine it.
403
+ *
404
+ * This function changes only the `fs.*` procedures. A host uses `confineToRoot()`: it also keeps
405
+ * the paths of the other procedures in the root, or it refuses them.
398
406
  */
399
407
  export function confineFsProcedures(registry: ProcedureRegistry, root: string): ConfinedFs {
408
+ const folder = rootFolder(root);
409
+ const rooted = rootedFsProcedures(registry, folder);
410
+ for (const procedure of rooted) registry.register(procedure, { override: true });
411
+ return { root: folder, procedures: rooted.map((procedure) => procedure.path.join(".")) };
412
+ }
413
+
414
+ /** The absolute path of a root. It throws when the root does not exist or is not a folder. */
415
+ function rootFolder(root: string): string {
400
416
  const folder = resolve(root);
401
417
  let isFolder: boolean;
402
418
  try {
@@ -405,19 +421,43 @@ export function confineFsProcedures(registry: ProcedureRegistry, root: string):
405
421
  throw new Error(`The root of the file procedures does not exist: ${folder}`);
406
422
  }
407
423
  if (!isFolder) throw new Error(`The root of the file procedures is not a folder: ${folder}`);
424
+ return folder;
425
+ }
426
+
427
+ /** The procedures with a root for the `fs.*` procedures of a registry. An unknown `fs.*` procedure is an error. */
428
+ function rootedFsProcedures(registry: ProcedureRegistry, folder: string): AnyProcedure[] {
408
429
  const rooted = new Map(createFsModule({ root: folder }).procedures.map((procedure) => [procedure.path.join("."), procedure]));
409
430
  const present = registry.getAll().filter((procedure) => procedure.path[0] === "fs");
410
- for (const procedure of present) {
411
- const key = procedure.path.join(".");
412
- if (!rooted.has(key)) throw new Error(`The root of the file procedures cannot confine ${key}: @procedures/fs has no such procedure`);
413
- }
414
- const procedures: string[] = [];
415
- for (const procedure of present) {
431
+ return present.map((procedure) => {
416
432
  const key = procedure.path.join(".");
417
- registry.register(rooted.get(key)!, { override: true });
418
- procedures.push(key);
419
- }
420
- return { root: folder, procedures };
433
+ const replacement = rooted.get(key);
434
+ if (!replacement) throw new Error(`The root of the file procedures cannot confine ${key}: @procedures/fs has no such procedure`);
435
+ return replacement;
436
+ });
437
+ }
438
+
439
+ /**
440
+ * This function confines each procedure of a registry to a root (roadmap R33). A host starts it
441
+ * after the last registration: `mark --fs-root`, `procedure-server --fs-root` and `MCP_FS_ROOT`.
442
+ *
443
+ * - Each `fs.*` procedure gets the handler of `createFsModule({ root })`, as with
444
+ * `confineFsProcedures()`.
445
+ * - Each other procedure gets the decision of `rootDecisionOf()`: the host examines its path
446
+ * inputs (`metadata.fsRoot`), or it refuses the procedure. A procedure that reaches no file
447
+ * stays as it is.
448
+ *
449
+ * The function adds no procedure, and the names of the tools do not change. It changes nothing
450
+ * when it throws: for a root that does not exist, an unknown `fs.*` procedure, or rules that are
451
+ * not correct.
452
+ */
453
+ export function confineToRoot(registry: ProcedureRegistry, root: string): ConfinedRegistry {
454
+ const folder = rootFolder(root);
455
+ const rooted = rootedFsProcedures(registry, folder);
456
+ // Each decision before a change: rules that are not correct throw here
457
+ for (const procedure of registry.getAll()) rootDecisionOf(procedure);
458
+ for (const procedure of rooted) registry.register(procedure, { override: true });
459
+ const others = confineOtherProcedures(registry, folder);
460
+ return { root: folder, fs: rooted.map((procedure) => procedure.path.join(".")), ...others };
421
461
  }
422
462
 
423
463
  /**
package/src/root.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  /**
2
2
  * The root of the file procedures: one folder that confines each path (roadmap R33).
3
3
  *
4
+ * This file is the `./root` entry of the package: the rules without the procedures. Another
5
+ * package that confines its own paths imports them, for example `@procedures/archive`.
6
+ *
4
7
  * `createFsModule({ root })` sends each path of an input through `inRoot()`:
5
8
  * - A relative path starts at the root, not at the working folder of the process.
6
9
  * - A path outside the root gives `FORBIDDEN` with the reason `outside-root`. This includes a
@@ -88,14 +91,17 @@ export interface ResolvedRoot {
88
91
  real: string;
89
92
  }
90
93
 
91
- /** This function gives the absolute path and the real path of a root. The root must exist. */
92
- export async function resolveRoot(root: string): Promise<ResolvedRoot> {
94
+ /**
95
+ * This function gives the absolute path and the real path of a root. The root must exist. The
96
+ * error of a missing root names `owner`, for example "the archive procedures".
97
+ */
98
+ export async function resolveRoot(root: string, owner = "the file procedures"): Promise<ResolvedRoot> {
93
99
  const folder = resolve(root);
94
100
  try {
95
101
  return { folder, real: await realpath(folder) };
96
102
  } catch (error) {
97
103
  if (!isMissing(error)) throw error;
98
- const missing = new Error(`The root of the file procedures does not exist: ${folder}`) as NodeJS.ErrnoException;
104
+ const missing = new Error(`The root of ${owner} does not exist: ${folder}`) as NodeJS.ErrnoException;
99
105
  missing.code = "ENOENT";
100
106
  throw missing;
101
107
  }
@@ -120,12 +126,12 @@ export async function realPathIsInside(root: ResolvedRoot, absolute: string): Pr
120
126
  }
121
127
 
122
128
  /**
123
- * This function gives the absolute path of `path` in the root. A relative path starts at the
124
- * root. The real path decides: a path whose real path is outside the real path of the root gives
125
- * `FORBIDDEN` with the reason `outside-root`.
129
+ * This function gives the absolute path of `path` in the root. A relative path starts at `base`,
130
+ * and the default `base` is the root. The real path decides: a path whose real path is outside the
131
+ * real path of the root gives `FORBIDDEN` with the reason `outside-root`.
126
132
  */
127
- export async function inRoot(root: ResolvedRoot, path: string, procedure: ProcedurePath): Promise<string> {
128
- const absolute = resolve(root.folder, path);
133
+ export async function inRoot(root: ResolvedRoot, path: string, procedure: ProcedurePath, base: string = root.folder): Promise<string> {
134
+ const absolute = resolve(base, path);
129
135
  const real = await realPathOf(absolute);
130
136
  // A path counts as inside by its given form or by its real form. On Windows, the two forms of a
131
137
  // folder can differ by more than the links: a short 8.3 name (RUNNER~1) has a long real name.