Skip to content

Commit 191d307

Browse files
committed
feat: US-079 - Add write-side fs permission and custom checker tests
1 parent e70e19e commit 191d307

3 files changed

Lines changed: 107 additions & 32 deletions

File tree

packages/secure-exec-core/src/shared/errors.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,16 @@ export function createSystemError(
2323
}
2424

2525
/** Create a permission-denied error matching Node's EACCES format. */
26-
export function createEaccesError(op: string, path?: string): SystemError {
26+
export function createEaccesError(
27+
op: string,
28+
path?: string,
29+
reason?: string,
30+
): SystemError {
2731
const suffix = path ? ` '${path}'` : "";
32+
const reasonSuffix = reason ? `: ${reason}` : "";
2833
return createSystemError(
2934
"EACCES",
30-
`EACCES: permission denied, ${op}${suffix}`,
35+
`EACCES: permission denied, ${op}${suffix}${reasonSuffix}`,
3136
{ path, syscall: op },
3237
);
3338
}

packages/secure-exec-core/src/shared/permissions.ts

Lines changed: 30 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,14 @@ import type {
2020
function checkPermission<T>(
2121
check: ((request: T) => { allow: boolean; reason?: string }) | undefined,
2222
request: T,
23-
onDenied: (request: T) => Error,
23+
onDenied: (request: T, reason?: string) => Error,
2424
): void {
2525
if (!check) {
2626
throw onDenied(request);
2727
}
2828
const decision = check(request);
2929
if (!decision?.allow) {
30-
throw onDenied(request);
30+
throw onDenied(request, decision?.reason);
3131
}
3232
}
3333

@@ -107,164 +107,164 @@ export function wrapFileSystem(
107107
checkPermission(
108108
permissions?.fs,
109109
{ op: "read", path },
110-
(req) => createEaccesError(fsOpToSyscall(req.op), req.path),
110+
(req, reason) => createEaccesError(fsOpToSyscall(req.op), req.path, reason),
111111
);
112112
return fs.readFile(path);
113113
},
114114
readTextFile: async (path) => {
115115
checkPermission(
116116
permissions?.fs,
117117
{ op: "read", path },
118-
(req) => createEaccesError(fsOpToSyscall(req.op), req.path),
118+
(req, reason) => createEaccesError(fsOpToSyscall(req.op), req.path, reason),
119119
);
120120
return fs.readTextFile(path);
121121
},
122122
readDir: async (path) => {
123123
checkPermission(
124124
permissions?.fs,
125125
{ op: "readdir", path },
126-
(req) => createEaccesError(fsOpToSyscall(req.op), req.path),
126+
(req, reason) => createEaccesError(fsOpToSyscall(req.op), req.path, reason),
127127
);
128128
return fs.readDir(path);
129129
},
130130
readDirWithTypes: async (path) => {
131131
checkPermission(
132132
permissions?.fs,
133133
{ op: "readdir", path },
134-
(req) => createEaccesError(fsOpToSyscall(req.op), req.path),
134+
(req, reason) => createEaccesError(fsOpToSyscall(req.op), req.path, reason),
135135
);
136136
return fs.readDirWithTypes(path);
137137
},
138138
writeFile: async (path, content) => {
139139
checkPermission(
140140
permissions?.fs,
141141
{ op: "write", path },
142-
(req) => createEaccesError(fsOpToSyscall(req.op), req.path),
142+
(req, reason) => createEaccesError(fsOpToSyscall(req.op), req.path, reason),
143143
);
144144
return fs.writeFile(path, content);
145145
},
146146
createDir: async (path) => {
147147
checkPermission(
148148
permissions?.fs,
149149
{ op: "createDir", path },
150-
(req) => createEaccesError(fsOpToSyscall(req.op), req.path),
150+
(req, reason) => createEaccesError(fsOpToSyscall(req.op), req.path, reason),
151151
);
152152
return fs.createDir(path);
153153
},
154154
mkdir: async (path) => {
155155
checkPermission(
156156
permissions?.fs,
157157
{ op: "mkdir", path },
158-
(req) => createEaccesError(fsOpToSyscall(req.op), req.path),
158+
(req, reason) => createEaccesError(fsOpToSyscall(req.op), req.path, reason),
159159
);
160160
return fs.mkdir(path);
161161
},
162162
exists: async (path) => {
163163
checkPermission(
164164
permissions?.fs,
165165
{ op: "exists", path },
166-
(req) => createEaccesError(fsOpToSyscall(req.op), req.path),
166+
(req, reason) => createEaccesError(fsOpToSyscall(req.op), req.path, reason),
167167
);
168168
return fs.exists(path);
169169
},
170170
stat: async (path) => {
171171
checkPermission(
172172
permissions?.fs,
173173
{ op: "stat", path },
174-
(req) => createEaccesError(fsOpToSyscall(req.op), req.path),
174+
(req, reason) => createEaccesError(fsOpToSyscall(req.op), req.path, reason),
175175
);
176176
return fs.stat(path);
177177
},
178178
removeFile: async (path) => {
179179
checkPermission(
180180
permissions?.fs,
181181
{ op: "rm", path },
182-
(req) => createEaccesError(fsOpToSyscall(req.op), req.path),
182+
(req, reason) => createEaccesError(fsOpToSyscall(req.op), req.path, reason),
183183
);
184184
return fs.removeFile(path);
185185
},
186186
removeDir: async (path) => {
187187
checkPermission(
188188
permissions?.fs,
189189
{ op: "rm", path },
190-
(req) => createEaccesError(fsOpToSyscall(req.op), req.path),
190+
(req, reason) => createEaccesError(fsOpToSyscall(req.op), req.path, reason),
191191
);
192192
return fs.removeDir(path);
193193
},
194194
rename: async (oldPath, newPath) => {
195195
checkPermission(
196196
permissions?.fs,
197197
{ op: "rename", path: oldPath },
198-
(req) => createEaccesError(fsOpToSyscall(req.op), req.path),
198+
(req, reason) => createEaccesError(fsOpToSyscall(req.op), req.path, reason),
199199
);
200200
checkPermission(
201201
permissions?.fs,
202202
{ op: "rename", path: newPath },
203-
(req) => createEaccesError(fsOpToSyscall(req.op), req.path),
203+
(req, reason) => createEaccesError(fsOpToSyscall(req.op), req.path, reason),
204204
);
205205
return fs.rename(oldPath, newPath);
206206
},
207207
symlink: async (target, linkPath) => {
208208
checkPermission(
209209
permissions?.fs,
210210
{ op: "symlink", path: linkPath },
211-
(req) => createEaccesError(fsOpToSyscall(req.op), req.path),
211+
(req, reason) => createEaccesError(fsOpToSyscall(req.op), req.path, reason),
212212
);
213213
return fs.symlink(target, linkPath);
214214
},
215215
readlink: async (path) => {
216216
checkPermission(
217217
permissions?.fs,
218218
{ op: "readlink", path },
219-
(req) => createEaccesError(fsOpToSyscall(req.op), req.path),
219+
(req, reason) => createEaccesError(fsOpToSyscall(req.op), req.path, reason),
220220
);
221221
return fs.readlink(path);
222222
},
223223
lstat: async (path) => {
224224
checkPermission(
225225
permissions?.fs,
226226
{ op: "stat", path },
227-
(req) => createEaccesError(fsOpToSyscall(req.op), req.path),
227+
(req, reason) => createEaccesError(fsOpToSyscall(req.op), req.path, reason),
228228
);
229229
return fs.lstat(path);
230230
},
231231
link: async (oldPath, newPath) => {
232232
checkPermission(
233233
permissions?.fs,
234234
{ op: "link", path: newPath },
235-
(req) => createEaccesError(fsOpToSyscall(req.op), req.path),
235+
(req, reason) => createEaccesError(fsOpToSyscall(req.op), req.path, reason),
236236
);
237237
return fs.link(oldPath, newPath);
238238
},
239239
chmod: async (path, mode) => {
240240
checkPermission(
241241
permissions?.fs,
242242
{ op: "chmod", path },
243-
(req) => createEaccesError(fsOpToSyscall(req.op), req.path),
243+
(req, reason) => createEaccesError(fsOpToSyscall(req.op), req.path, reason),
244244
);
245245
return fs.chmod(path, mode);
246246
},
247247
chown: async (path, uid, gid) => {
248248
checkPermission(
249249
permissions?.fs,
250250
{ op: "chown", path },
251-
(req) => createEaccesError(fsOpToSyscall(req.op), req.path),
251+
(req, reason) => createEaccesError(fsOpToSyscall(req.op), req.path, reason),
252252
);
253253
return fs.chown(path, uid, gid);
254254
},
255255
utimes: async (path, atime, mtime) => {
256256
checkPermission(
257257
permissions?.fs,
258258
{ op: "utimes", path },
259-
(req) => createEaccesError(fsOpToSyscall(req.op), req.path),
259+
(req, reason) => createEaccesError(fsOpToSyscall(req.op), req.path, reason),
260260
);
261261
return fs.utimes(path, atime, mtime);
262262
},
263263
truncate: async (path, length) => {
264264
checkPermission(
265265
permissions?.fs,
266266
{ op: "truncate", path },
267-
(req) => createEaccesError(fsOpToSyscall(req.op), req.path),
267+
(req, reason) => createEaccesError(fsOpToSyscall(req.op), req.path, reason),
268268
);
269269
return fs.truncate(path, length);
270270
},
@@ -293,7 +293,7 @@ export function wrapNetworkAdapter(
293293
: `http://0.0.0.0:${options.port ?? 3000}`,
294294
method: "LISTEN",
295295
},
296-
(req) => createEaccesError("listen", req.url),
296+
(req, reason) => createEaccesError("listen", req.url, reason),
297297
);
298298
return adapter.httpServerListen!(options);
299299
}
@@ -307,23 +307,23 @@ export function wrapNetworkAdapter(
307307
checkPermission(
308308
permissions?.network,
309309
{ op: "fetch", url, method: options?.method },
310-
(req) => createEaccesError("connect", req.url),
310+
(req, reason) => createEaccesError("connect", req.url, reason),
311311
);
312312
return adapter.fetch(url, options);
313313
},
314314
dnsLookup: async (hostname) => {
315315
checkPermission(
316316
permissions?.network,
317317
{ op: "dns", hostname },
318-
(req) => createEaccesError("connect", req.hostname),
318+
(req, reason) => createEaccesError("connect", req.hostname, reason),
319319
);
320320
return adapter.dnsLookup(hostname);
321321
},
322322
httpRequest: async (url, options) => {
323323
checkPermission(
324324
permissions?.network,
325325
{ op: "http", url, method: options?.method },
326-
(req) => createEaccesError("connect", req.url),
326+
(req, reason) => createEaccesError("connect", req.url, reason),
327327
);
328328
return adapter.httpRequest(url, options);
329329
},
@@ -340,7 +340,7 @@ export function wrapCommandExecutor(
340340
checkPermission(
341341
permissions?.childProcess,
342342
{ command, args, cwd: options.cwd, env: options.env },
343-
(req) => createEaccesError("spawn", req.command),
343+
(req, reason) => createEaccesError("spawn", req.command, reason),
344344
);
345345
return executor.spawn(command, args, options);
346346
},
@@ -351,8 +351,8 @@ export function envAccessAllowed(
351351
permissions: Permissions | undefined,
352352
request: EnvAccessRequest,
353353
): void {
354-
checkPermission(permissions?.env, request, (req) =>
355-
createEaccesError("access", req.key),
354+
checkPermission(permissions?.env, request, (req, reason) =>
355+
createEaccesError("access", req.key, reason),
356356
);
357357
}
358358

packages/secure-exec/tests/permissions.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,3 +203,73 @@ describe("allow helpers", () => {
203203
expectEacces(envThrown, "access", "HIDDEN");
204204
});
205205
});
206+
207+
describe("permissions deny-by-default write-side", () => {
208+
it("denies writeFile when fs checker is missing", async () => {
209+
const guardedFs = wrapFileSystem(baseFs);
210+
let thrown: unknown;
211+
try {
212+
await guardedFs.writeFile("/data.bin", new Uint8Array([1, 2]));
213+
} catch (error) {
214+
thrown = error;
215+
}
216+
expectEacces(thrown, "write", "/data.bin");
217+
});
218+
219+
it("denies createDir when fs checker is missing", async () => {
220+
const guardedFs = wrapFileSystem(baseFs);
221+
let thrown: unknown;
222+
try {
223+
await guardedFs.createDir("/newdir");
224+
} catch (error) {
225+
thrown = error;
226+
}
227+
expectEacces(thrown, "mkdir", "/newdir");
228+
});
229+
230+
it("denies removeFile when fs checker is missing", async () => {
231+
const guardedFs = wrapFileSystem(baseFs);
232+
let thrown: unknown;
233+
try {
234+
await guardedFs.removeFile("/secret.txt");
235+
} catch (error) {
236+
thrown = error;
237+
}
238+
expectEacces(thrown, "unlink", "/secret.txt");
239+
});
240+
});
241+
242+
describe("custom permission checker", () => {
243+
it("fs checker returning { allow: false, reason } produces EACCES with reason", async () => {
244+
const permissions: Permissions = {
245+
fs: () => ({ allow: false, reason: "policy" }),
246+
};
247+
const guardedFs = wrapFileSystem(baseFs, permissions);
248+
let thrown: unknown;
249+
try {
250+
await guardedFs.writeFile("/blocked.txt", new Uint8Array([1]));
251+
} catch (error) {
252+
thrown = error;
253+
}
254+
expect(thrown).toMatchObject({ code: "EACCES", syscall: "write" });
255+
expect((thrown as Error).message).toContain("policy");
256+
});
257+
258+
it("childProcess checker receives cwd parameter in request", () => {
259+
const captured: { command: string; args: string[]; cwd?: string }[] = [];
260+
const permissions: Permissions = {
261+
childProcess: (req) => {
262+
captured.push({
263+
command: req.command,
264+
args: req.args,
265+
cwd: req.cwd,
266+
});
267+
return { allow: true };
268+
},
269+
};
270+
const guardedExecutor = wrapCommandExecutor(baseExecutor, permissions);
271+
guardedExecutor.spawn("node", ["-e", "1"], { cwd: "/app" });
272+
expect(captured).toHaveLength(1);
273+
expect(captured[0].cwd).toBe("/app");
274+
});
275+
});

0 commit comments

Comments
 (0)