@procedures/fs 0.6.0 → 0.8.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/CHANGELOG.md +27 -0
- package/README.md +18 -6
- package/dist/.tsbuildinfo +1 -1
- package/dist/confine.d.ts +64 -0
- package/dist/confine.d.ts.map +1 -0
- package/dist/confine.js +213 -0
- package/dist/confine.js.map +1 -0
- package/dist/module.d.ts +22 -1
- package/dist/module.d.ts.map +1 -1
- package/dist/module.js +50 -11
- package/dist/module.js.map +1 -1
- package/dist/root.d.ts +12 -6
- package/dist/root.d.ts.map +1 -1
- package/dist/root.js +14 -8
- package/dist/root.js.map +1 -1
- package/package.json +6 -2
- package/src/confine.ts +246 -0
- package/src/module.ts +51 -11
- package/src/root.ts +14 -8
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.
|
|
4
|
+
"version": "0.8.0",
|
|
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.
|
|
68
|
+
"@procedures/client": "0.8.0",
|
|
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
|
-
|
|
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
|
-
|
|
418
|
-
procedures
|
|
419
|
-
|
|
420
|
-
|
|
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
|
-
/**
|
|
92
|
-
|
|
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
|
|
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
|
|
124
|
-
* root. The real path decides: a path whose real path is outside the
|
|
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(
|
|
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.
|