@code-yeongyu/senpi-codemode 2026.10.27-2 → 2026.10.30-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/CHANGELOG.md +34 -0
- package/package.json +4 -4
- package/src/bridge/http-server.ts +18 -5
- package/src/bridge/permission-check.ts +76 -0
- package/src/bridge/protocol.ts +7 -0
- package/src/kernels/jl/kernel.ts +3 -0
- package/src/kernels/js/context-manager.ts +26 -8
- package/src/kernels/js/inline-worker-entry.js +1 -23
- package/src/kernels/js/inline-worker.ts +7 -2
- package/src/kernels/js/interpose-bun.js +220 -0
- package/src/kernels/js/interpose-capabilities.js +29 -0
- package/src/kernels/js/interpose-code.js +8 -0
- package/src/kernels/js/interpose-fs.js +124 -0
- package/src/kernels/js/interpose-modules.js +107 -0
- package/src/kernels/js/interpose-paths.js +80 -0
- package/src/kernels/js/interpose-process.js +97 -0
- package/src/kernels/js/interpose-resources.js +59 -0
- package/src/kernels/js/interpose-sqlite.js +104 -0
- package/src/kernels/js/interpose.js +86 -0
- package/src/kernels/js/kernel-contract.ts +31 -0
- package/src/kernels/js/kernel-tools-pump.js +4 -2
- package/src/kernels/js/local-module-loader.ts +9 -4
- package/src/kernels/js/permission-authority.d.mts +10 -0
- package/src/kernels/js/permission-authority.mjs +11 -0
- package/src/kernels/js/permission-core.md +114 -0
- package/src/kernels/js/permission-host.ts +388 -0
- package/src/kernels/js/permission-runtime.js +205 -0
- package/src/kernels/js/permission-sessions.ts +46 -0
- package/src/kernels/js/permission-slots.d.mts +51 -0
- package/src/kernels/js/permission-slots.mjs +133 -0
- package/src/kernels/js/permission-worker-entry.js +1 -0
- package/src/kernels/js/permission-worker-frames.ts +55 -0
- package/src/kernels/js/process-tree.js +9 -1
- package/src/kernels/js/rewrite-imports.ts +44 -0
- package/src/kernels/js/windows-tree-kill.js +10 -2
- package/src/kernels/js/worker-core.d.ts +2 -0
- package/src/kernels/js/worker-core.js +5 -2
- package/src/kernels/js/worker-cwd.js +14 -8
- package/src/kernels/js/worker-entry.js +1 -23
- package/src/kernels/js/worker-file-helpers.js +58 -0
- package/src/kernels/js/worker-host.ts +24 -6
- package/src/kernels/js/worker-package-resolve.js +10 -2
- package/src/kernels/js/worker-runtime.js +5 -65
- package/src/kernels/js/worker-slot.ts +5 -1
- package/src/kernels/js/worker-startup.ts +7 -5
- package/src/kernels/js/worker-webview.js +17 -16
- package/src/kernels/py/interpose.py +765 -0
- package/src/kernels/py/kernel-contract.ts +4 -0
- package/src/kernels/py/kernel-tools-host.ts +5 -1
- package/src/kernels/py/kernel.ts +45 -5
- package/src/kernels/py/path_identity.py +162 -0
- package/src/kernels/py/path_identity_posix.py +74 -0
- package/src/kernels/py/path_identity_windows.py +93 -0
- package/src/kernels/py/permission-core.md +103 -0
- package/src/kernels/py/permission-host.ts +198 -0
- package/src/kernels/py/permission_bridge.py +145 -0
- package/src/kernels/py/prelude.py +40 -5
- package/src/kernels/py/transport.ts +15 -8
- package/src/kernels/rb/kernel.ts +3 -0
- package/src/kernels/shared/invocation-authority.ts +59 -0
- package/src/kernels/shared/kernel-tools-pump.ts +22 -1
- package/src/kernels/shared/path-identity.d.mts +42 -0
- package/src/kernels/shared/path-identity.mjs +149 -0
- package/src/kernels/shared/subprocess-contract.ts +2 -0
- package/src/kernels/shared/subprocess-kernel.ts +28 -0
|
@@ -3,6 +3,8 @@ import type { BridgeConnectionConfig, KernelToHostMessage } from "../../bridge/p
|
|
|
3
3
|
import type { EvalKernelRunInput } from "../../tool/types.ts";
|
|
4
4
|
import type { SessionEnvironment } from "../session-env.ts";
|
|
5
5
|
import type { KernelLifecycle } from "../shared/kernel-death.ts";
|
|
6
|
+
import type { InvocationPermissions } from "../shared/invocation-authority.ts";
|
|
7
|
+
import type { CellDecider, RevocationFailedEvent } from "../js/kernel-contract.ts";
|
|
6
8
|
import type { PeerKernelToolsDescribe } from "./kernel-tools-host.ts";
|
|
7
9
|
import type { KernelSpawnProcess, KillProcessGroup } from "./process.ts";
|
|
8
10
|
import type { PythonStartupStage } from "./startup.ts";
|
|
@@ -12,6 +14,8 @@ export interface PythonKernelStartOptions extends KernelLifecycle {
|
|
|
12
14
|
readonly interpreterPath: string;
|
|
13
15
|
readonly sessionId: string;
|
|
14
16
|
readonly cwd: string;
|
|
17
|
+
readonly permissions?: InvocationPermissions & { readonly decide?: CellDecider };
|
|
18
|
+
readonly onRevocationFailed?: (event: RevocationFailedEvent) => void;
|
|
15
19
|
readonly connection: BridgeConnectionConfig;
|
|
16
20
|
readonly env?: NodeJS.ProcessEnv;
|
|
17
21
|
/** Per-session PI_* values merged into the interpreter environment at spawn. */
|
|
@@ -37,8 +37,12 @@ export class PythonKernelTools {
|
|
|
37
37
|
readonly post: (message: HostToKernelMessage) => void;
|
|
38
38
|
readonly isOpen: () => boolean;
|
|
39
39
|
readonly peerDescribe?: PeerKernelToolsDescribe;
|
|
40
|
+
readonly invocation?: (
|
|
41
|
+
request: KernelToolsInvokeRequest,
|
|
42
|
+
options?: AbortSignal | KernelToolsInvokeOptions,
|
|
43
|
+
) => () => void;
|
|
40
44
|
}) {
|
|
41
|
-
this.#pump = new KernelToolHostPump(options.post, options.isOpen);
|
|
45
|
+
this.#pump = new KernelToolHostPump(options.post, options.isOpen, options.invocation);
|
|
42
46
|
this.#peerDescribe = options.peerDescribe;
|
|
43
47
|
}
|
|
44
48
|
|
package/src/kernels/py/kernel.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
KernelToolsInvokeRequest,
|
|
7
7
|
} from "../js/kernel-tools-types.ts";
|
|
8
8
|
import { inputAtStart } from "../shared/cell-source-at-start.ts";
|
|
9
|
+
import { invocationSession, registerInvocationSession } from "../shared/invocation-authority.ts";
|
|
9
10
|
import { KernelMemoryHost } from "../shared/kernel-memory-host.ts";
|
|
10
11
|
import { KernelPreludeTracker } from "../shared/kernel-prelude-plan.ts";
|
|
11
12
|
import { pythonDeathReason } from "./death-reason.ts";
|
|
@@ -14,6 +15,7 @@ import type { PendingRun, PythonKernelRunOptions, PythonKernelStartOptions, Resu
|
|
|
14
15
|
import { PythonKernelTools } from "./kernel-tools-host.ts";
|
|
15
16
|
import { pythonStartupCeilingMs, pythonStartupHangGuardMs } from "./startup.ts";
|
|
16
17
|
import { failedPythonResult, PythonKernelTransport } from "./transport.ts";
|
|
18
|
+
import { PythonPermissionHost } from "./permission-host.ts";
|
|
17
19
|
|
|
18
20
|
export type { PythonKernelRunOptions, PythonKernelStartOptions } from "./kernel-contract.ts";
|
|
19
21
|
export type { KernelChild, KernelSpawnOptions, KernelSpawnProcess } from "./process.ts";
|
|
@@ -47,21 +49,39 @@ export class PythonKernel {
|
|
|
47
49
|
#closed = false;
|
|
48
50
|
readonly #tools: PythonKernelTools;
|
|
49
51
|
#dead = false;
|
|
52
|
+
readonly #releaseSession: () => void;
|
|
53
|
+
readonly #permission: PythonPermissionHost;
|
|
50
54
|
|
|
51
55
|
private constructor(options: PythonKernelStartOptions) {
|
|
52
56
|
this.#options = options;
|
|
57
|
+
this.#releaseSession = registerInvocationSession(options.sessionId, () => ({
|
|
58
|
+
cwd: options.cwd,
|
|
59
|
+
state: options.permissions?.state() ?? { epoch: 0, active: false, rules: [] },
|
|
60
|
+
}));
|
|
53
61
|
this.#memory = options.memory === undefined ? null : new KernelMemoryHost("py", options.memory);
|
|
62
|
+
this.#permission = new PythonPermissionHost(options, async () => {
|
|
63
|
+
this.#tools.retire("kernel_tool_stale", "Python permission authority revoked");
|
|
64
|
+
this.#settleAllPending("Python permission authority revoked");
|
|
65
|
+
if (this.#transport) await this.#beginRetirement(this.#transport);
|
|
66
|
+
});
|
|
54
67
|
this.#tools = new PythonKernelTools({
|
|
55
68
|
post: (message) => this.#transport?.post(message),
|
|
56
69
|
isOpen: () => this.#transport !== null && !this.#closed,
|
|
70
|
+
invocation: (request, invokeOptions) => this.#permission.invocation(request, invokeOptions),
|
|
57
71
|
...(options.peerKernelToolsDescribe === undefined ? {} : { peerDescribe: options.peerKernelToolsDescribe }),
|
|
58
72
|
});
|
|
59
73
|
}
|
|
60
74
|
|
|
61
75
|
static async start(options: PythonKernelStartOptions): Promise<PythonKernel> {
|
|
62
76
|
const kernel = new PythonKernel(options);
|
|
63
|
-
|
|
64
|
-
|
|
77
|
+
try {
|
|
78
|
+
await kernel.#spawn(kernel.#generation);
|
|
79
|
+
return kernel;
|
|
80
|
+
} catch (error) {
|
|
81
|
+
kernel.#releaseSession();
|
|
82
|
+
kernel.#permission.dispose();
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
65
85
|
}
|
|
66
86
|
|
|
67
87
|
listKernelToolNames(): readonly string[] {
|
|
@@ -88,11 +108,21 @@ export class PythonKernel {
|
|
|
88
108
|
return this.#tools.describe(names);
|
|
89
109
|
}
|
|
90
110
|
|
|
91
|
-
invokeKernelTool(
|
|
111
|
+
async invokeKernelTool(
|
|
92
112
|
request: KernelToolsInvokeRequest,
|
|
93
113
|
options?: AbortSignal | KernelToolsInvokeOptions,
|
|
94
114
|
): Promise<unknown> {
|
|
95
|
-
|
|
115
|
+
await this.#permission.admit();
|
|
116
|
+
invocationSession(
|
|
117
|
+
this.#options.sessionId,
|
|
118
|
+
{
|
|
119
|
+
cwd: this.#options.cwd,
|
|
120
|
+
state: this.#options.permissions?.state() ?? { epoch: 0, active: false, rules: [] },
|
|
121
|
+
},
|
|
122
|
+
options,
|
|
123
|
+
this.#options.permissions?.resolveSession,
|
|
124
|
+
);
|
|
125
|
+
return await this.#tools.invoke(request, options);
|
|
96
126
|
}
|
|
97
127
|
|
|
98
128
|
run(input: PythonKernelRunOptions): Promise<ResultMessage> {
|
|
@@ -159,6 +189,7 @@ export class PythonKernel {
|
|
|
159
189
|
async reset(): Promise<void> {
|
|
160
190
|
if (this.#failure) throw this.#failure;
|
|
161
191
|
if (this.#closed) throw new Error("Python kernel is closed");
|
|
192
|
+
this.#permission.revoke();
|
|
162
193
|
const generation = ++this.#generation;
|
|
163
194
|
this.#tools.retire("kernel_tool_stale", "Python kernel was reset; its kernel tools are gone");
|
|
164
195
|
const prior = this.#starting;
|
|
@@ -184,7 +215,9 @@ export class PythonKernel {
|
|
|
184
215
|
|
|
185
216
|
async close(): Promise<void> {
|
|
186
217
|
if (this.#closePromise) return await this.#closePromise;
|
|
218
|
+
this.#permission.revoke();
|
|
187
219
|
this.#closed = true;
|
|
220
|
+
this.#releaseSession();
|
|
188
221
|
this.#generation += 1;
|
|
189
222
|
this.#tools.retire("tools_unavailable", "Python kernel is closed");
|
|
190
223
|
this.#settleAllPending("Python kernel closed");
|
|
@@ -193,7 +226,10 @@ export class PythonKernel {
|
|
|
193
226
|
await starting?.catch(() => undefined);
|
|
194
227
|
await this.#retirement?.catch(() => undefined);
|
|
195
228
|
const transport = this.#transport;
|
|
196
|
-
if (!transport)
|
|
229
|
+
if (!transport) {
|
|
230
|
+
this.#permission.dispose();
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
197
233
|
try {
|
|
198
234
|
await transport.close();
|
|
199
235
|
} catch (error) {
|
|
@@ -201,6 +237,7 @@ export class PythonKernel {
|
|
|
201
237
|
throw error;
|
|
202
238
|
}
|
|
203
239
|
if (this.#transport === transport) this.#transport = null;
|
|
240
|
+
this.#permission.dispose();
|
|
204
241
|
})();
|
|
205
242
|
return await this.#closePromise;
|
|
206
243
|
}
|
|
@@ -228,6 +265,7 @@ export class PythonKernel {
|
|
|
228
265
|
}
|
|
229
266
|
|
|
230
267
|
async #activateNext(): Promise<void> {
|
|
268
|
+
await this.#permission.admit();
|
|
231
269
|
await this.#ensureStarted();
|
|
232
270
|
if (this.#closed || this.#active || this.#retirement) return;
|
|
233
271
|
const pending = this.#queue.shift();
|
|
@@ -318,6 +356,7 @@ export class PythonKernel {
|
|
|
318
356
|
this.#lastMemory = undefined;
|
|
319
357
|
this.#transport = await PythonKernelTransport.start({
|
|
320
358
|
...this.#options,
|
|
359
|
+
permission: this.#permission,
|
|
321
360
|
onMessage: (message) => {
|
|
322
361
|
if (message.type === "result") return;
|
|
323
362
|
if (generation === this.#generation && this.#tools.consume(message)) return;
|
|
@@ -445,6 +484,7 @@ export class PythonKernel {
|
|
|
445
484
|
}
|
|
446
485
|
|
|
447
486
|
#removePending(pending: PendingRun): void {
|
|
487
|
+
this.#permission.settle(pending.input.cellId);
|
|
448
488
|
pending.hostAbort?.abort();
|
|
449
489
|
if (pending.timeoutTimer) clearTimeout(pending.timeoutTimer);
|
|
450
490
|
if (pending.escalationTimer) clearTimeout(pending.escalationTimer);
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Versioned twin of shared/path-identity.mjs, with captured native primitives."""
|
|
2
|
+
import builtins
|
|
3
|
+
import errno
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import unicodedata
|
|
7
|
+
|
|
8
|
+
__builtins__ = vars(builtins).copy()
|
|
9
|
+
PATH_IDENTITY_VERSION = 1
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class IdentityError(OSError):
|
|
13
|
+
def __init__(self, code):
|
|
14
|
+
super().__init__(code)
|
|
15
|
+
self.code = code
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def create_identity(runtime):
|
|
19
|
+
# Python's realpath is a component walker, not the native canonicalizer used
|
|
20
|
+
# by Node. In particular it retains requested case on case-insensitive disks.
|
|
21
|
+
platform = sys.platform
|
|
22
|
+
lstat, readlink = os.lstat, os.readlink
|
|
23
|
+
normalize, lower, upper = unicodedata.normalize, str.lower, str.upper
|
|
24
|
+
codes = dict(errno.errorcode)
|
|
25
|
+
fold_default = platform in ("darwin", "win32")
|
|
26
|
+
folds = {}
|
|
27
|
+
|
|
28
|
+
def failure(code):
|
|
29
|
+
return IdentityError(code)
|
|
30
|
+
|
|
31
|
+
def code_of(error):
|
|
32
|
+
number = getattr(error, "errno", None)
|
|
33
|
+
return getattr(error, "code", codes.get(number, "EUNKNOWN") if isinstance(number, int) else "EUNKNOWN")
|
|
34
|
+
|
|
35
|
+
def absolute(value):
|
|
36
|
+
return value.startswith("/")
|
|
37
|
+
|
|
38
|
+
def anchor(value):
|
|
39
|
+
if not absolute(value):
|
|
40
|
+
raise failure("ERELATIVE")
|
|
41
|
+
return value
|
|
42
|
+
|
|
43
|
+
def dirname(value):
|
|
44
|
+
# node:path.posix.dirname retains the separator preceding the last name.
|
|
45
|
+
end = len(value.rstrip("/"))
|
|
46
|
+
index = value.rfind("/", 0, end)
|
|
47
|
+
if index < 0:
|
|
48
|
+
return "."
|
|
49
|
+
return "/" if index == 0 else value[:index]
|
|
50
|
+
|
|
51
|
+
def basename(value):
|
|
52
|
+
return value.rstrip("/").rsplit("/", 1)[-1]
|
|
53
|
+
|
|
54
|
+
def join(*parts):
|
|
55
|
+
value = "/".join(part for part in parts if part)
|
|
56
|
+
names = []
|
|
57
|
+
for part in value.split("/"):
|
|
58
|
+
if part in ("", "."):
|
|
59
|
+
continue
|
|
60
|
+
if part == ".." and names and names[-1] != "..":
|
|
61
|
+
names.pop()
|
|
62
|
+
elif part != ".." or not value.startswith("/"):
|
|
63
|
+
names.append(part)
|
|
64
|
+
result = ("/" if value.startswith("/") else "") + "/".join(names)
|
|
65
|
+
return (result or ".") + ("/" if value.endswith("/") and result not in ("", "/") else "")
|
|
66
|
+
|
|
67
|
+
asset = os.path.join(os.path.dirname(__file__),
|
|
68
|
+
"path_identity_windows.py" if platform == "win32" else "path_identity_posix.py")
|
|
69
|
+
namespace = {"__builtins__": __builtins__}
|
|
70
|
+
try:
|
|
71
|
+
with open(asset, "rb") as source:
|
|
72
|
+
exec(compile(source.read(), asset, "exec"), namespace)
|
|
73
|
+
if platform == "win32":
|
|
74
|
+
realpath, anchor, dirname, basename, join, absolute = namespace["windows_primitives"](failure)
|
|
75
|
+
else:
|
|
76
|
+
realpath = namespace["native_realpath"](runtime, failure)
|
|
77
|
+
except (ImportError, OSError, AttributeError):
|
|
78
|
+
# No native primitives (e.g. a Python built without ctypes): send no canonical, so the host
|
|
79
|
+
# falls back exactly as it does for any request without one, and the kernel still starts.
|
|
80
|
+
return lambda executed: {}
|
|
81
|
+
|
|
82
|
+
def volume_fold(existing):
|
|
83
|
+
try:
|
|
84
|
+
device = lstat(existing).st_dev
|
|
85
|
+
except OSError:
|
|
86
|
+
return fold_default
|
|
87
|
+
if device in folds:
|
|
88
|
+
return folds[device]
|
|
89
|
+
probe = existing
|
|
90
|
+
while True:
|
|
91
|
+
chars = list(basename(probe))
|
|
92
|
+
index = next((i for i, char in enumerate(chars) if lower(char) != upper(char)), None)
|
|
93
|
+
if index is not None:
|
|
94
|
+
char = chars[index]
|
|
95
|
+
chars[index] = upper(char) if char == lower(char) else lower(char)
|
|
96
|
+
try:
|
|
97
|
+
original = lstat(probe)
|
|
98
|
+
variant = lstat(join(dirname(probe), "".join(chars)))
|
|
99
|
+
answer = (original.st_dev, original.st_ino) == (variant.st_dev, variant.st_ino)
|
|
100
|
+
except OSError:
|
|
101
|
+
answer = False
|
|
102
|
+
break
|
|
103
|
+
parent = dirname(probe)
|
|
104
|
+
if parent == probe:
|
|
105
|
+
answer = fold_default
|
|
106
|
+
break
|
|
107
|
+
probe = parent
|
|
108
|
+
try:
|
|
109
|
+
same = lstat(probe).st_dev == device
|
|
110
|
+
except OSError:
|
|
111
|
+
same = False
|
|
112
|
+
if not same:
|
|
113
|
+
answer = fold_default
|
|
114
|
+
break
|
|
115
|
+
folds[device] = answer
|
|
116
|
+
return answer
|
|
117
|
+
|
|
118
|
+
def canonical(executed):
|
|
119
|
+
existing, missing, hops, followed = anchor(executed), [], 0, False
|
|
120
|
+
while True:
|
|
121
|
+
try:
|
|
122
|
+
real = realpath(existing)
|
|
123
|
+
except OSError as error:
|
|
124
|
+
code = code_of(error)
|
|
125
|
+
if code not in ("ENOENT", "ENOTDIR"):
|
|
126
|
+
raise failure(code)
|
|
127
|
+
try:
|
|
128
|
+
info = lstat(existing)
|
|
129
|
+
except OSError as inner:
|
|
130
|
+
if code_of(inner) not in ("ENOENT", "ENOTDIR"):
|
|
131
|
+
raise failure(code_of(inner))
|
|
132
|
+
info = None
|
|
133
|
+
if info is not None and info.st_mode & 0o170000 == 0o120000:
|
|
134
|
+
hops += 1
|
|
135
|
+
if hops > 40:
|
|
136
|
+
raise failure("ELOOP")
|
|
137
|
+
target = readlink(existing)
|
|
138
|
+
existing = target if absolute(target) else join(dirname(existing), target)
|
|
139
|
+
followed = True
|
|
140
|
+
continue
|
|
141
|
+
if followed and missing:
|
|
142
|
+
raise failure("EDANGLING")
|
|
143
|
+
missing.insert(0, basename(existing))
|
|
144
|
+
parent = dirname(existing)
|
|
145
|
+
if parent == existing:
|
|
146
|
+
raise failure(code)
|
|
147
|
+
existing = parent
|
|
148
|
+
continue
|
|
149
|
+
path = join(real, *missing) if missing else real
|
|
150
|
+
return {"v": PATH_IDENTITY_VERSION, "path": normalize("NFC", path) if platform == "darwin" else path,
|
|
151
|
+
"foldCase": volume_fold(real)}
|
|
152
|
+
|
|
153
|
+
def identity(executed):
|
|
154
|
+
try:
|
|
155
|
+
return {"canonical": canonical(executed)}
|
|
156
|
+
except UnicodeError:
|
|
157
|
+
# A native filename that cannot travel as UTF-8 has no exact wire form.
|
|
158
|
+
return {}
|
|
159
|
+
except OSError as error:
|
|
160
|
+
code = code_of(error)
|
|
161
|
+
return {} if code == "EIDENTITY_UNSUPPORTED" else {"unresolvable": code}
|
|
162
|
+
return identity
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Native primitives used by Node/libuv and Bun's POSIX realpath."""
|
|
2
|
+
import ctypes
|
|
3
|
+
import errno
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def native_realpath(runtime, failure):
|
|
9
|
+
library = ctypes.CDLL(None, use_errno=True)
|
|
10
|
+
native = library.realpath
|
|
11
|
+
native.argtypes = [ctypes.c_char_p, ctypes.c_void_p]
|
|
12
|
+
native.restype = ctypes.c_void_p
|
|
13
|
+
free = library.free
|
|
14
|
+
free.argtypes = [ctypes.c_void_p]
|
|
15
|
+
free.restype = None
|
|
16
|
+
string_at, get_errno = ctypes.string_at, ctypes.get_errno
|
|
17
|
+
codes = dict(errno.errorcode)
|
|
18
|
+
|
|
19
|
+
def realpath(value):
|
|
20
|
+
if "\0" in value:
|
|
21
|
+
raise failure("ERR_INVALID_ARG_VALUE")
|
|
22
|
+
pointer = native(value.encode("utf-8"), None)
|
|
23
|
+
if not pointer:
|
|
24
|
+
raise failure(codes.get(get_errno(), "EUNKNOWN"))
|
|
25
|
+
try:
|
|
26
|
+
return string_at(pointer).decode("utf-8", "strict")
|
|
27
|
+
finally:
|
|
28
|
+
free(pointer)
|
|
29
|
+
|
|
30
|
+
if runtime != "bun":
|
|
31
|
+
return realpath
|
|
32
|
+
native_open, native_close, fstat, readlink = os.open, os.close, os.fstat, os.readlink
|
|
33
|
+
if sys.platform == "darwin":
|
|
34
|
+
# bun-v1.4.4/src/runtime/node/node_fs.rs: open + F_GETPATH.
|
|
35
|
+
fcntl = library.fcntl
|
|
36
|
+
# Variadic: Darwin arm64 passes the third argument on the stack.
|
|
37
|
+
fcntl.argtypes = [ctypes.c_int, ctypes.c_int]
|
|
38
|
+
fcntl.restype = ctypes.c_int
|
|
39
|
+
buffer = ctypes.create_string_buffer
|
|
40
|
+
open_flags = os.O_RDONLY | os.O_NONBLOCK | os.O_NOCTTY
|
|
41
|
+
|
|
42
|
+
def realpath(value):
|
|
43
|
+
if "\0" in value:
|
|
44
|
+
raise failure("ERR_INVALID_ARG_VALUE")
|
|
45
|
+
fd = native_open(value, open_flags)
|
|
46
|
+
try:
|
|
47
|
+
result = buffer(1024)
|
|
48
|
+
if fcntl(fd, 50, result) == -1: # Darwin F_GETPATH
|
|
49
|
+
code = codes.get(get_errno(), "EUNKNOWN")
|
|
50
|
+
raise failure("ENAMETOOLONG" if code == "ENOSPC" else code)
|
|
51
|
+
return result.value.decode("utf-8", "strict")
|
|
52
|
+
finally:
|
|
53
|
+
native_close(fd)
|
|
54
|
+
return realpath
|
|
55
|
+
if sys.platform == "linux":
|
|
56
|
+
path_only = os.O_PATH
|
|
57
|
+
|
|
58
|
+
def realpath(value):
|
|
59
|
+
if "\0" in value:
|
|
60
|
+
raise failure("ERR_INVALID_ARG_VALUE")
|
|
61
|
+
fd = native_open(value, path_only)
|
|
62
|
+
try:
|
|
63
|
+
result = readlink(f"/proc/self/fd/{fd}")
|
|
64
|
+
if result.endswith(" (deleted)") and fstat(fd).st_nlink == 0:
|
|
65
|
+
raise failure("ENOENT")
|
|
66
|
+
result.encode("utf-8", "strict")
|
|
67
|
+
return result
|
|
68
|
+
finally:
|
|
69
|
+
native_close(fd)
|
|
70
|
+
return realpath
|
|
71
|
+
|
|
72
|
+
def unsupported(value):
|
|
73
|
+
raise failure("EIDENTITY_UNSUPPORTED")
|
|
74
|
+
return unsupported
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Captured Win32 primitives matching libuv's fs__realpath_handle."""
|
|
2
|
+
import ctypes
|
|
3
|
+
import ntpath
|
|
4
|
+
import os
|
|
5
|
+
import types
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def windows_primitives(failure):
|
|
9
|
+
library = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
10
|
+
create = library.CreateFileW
|
|
11
|
+
create.argtypes = [ctypes.c_wchar_p, ctypes.c_uint32, ctypes.c_uint32, ctypes.c_void_p,
|
|
12
|
+
ctypes.c_uint32, ctypes.c_uint32, ctypes.c_void_p]
|
|
13
|
+
create.restype = ctypes.c_void_p
|
|
14
|
+
final = library.GetFinalPathNameByHandleW
|
|
15
|
+
final.argtypes = [ctypes.c_void_p, ctypes.c_wchar_p, ctypes.c_uint32, ctypes.c_uint32]
|
|
16
|
+
final.restype = ctypes.c_uint32
|
|
17
|
+
close = library.CloseHandle
|
|
18
|
+
close.argtypes = [ctypes.c_void_p]
|
|
19
|
+
close.restype = ctypes.c_int
|
|
20
|
+
invalid = ctypes.c_void_p(-1).value
|
|
21
|
+
get_error, buffer = ctypes.get_last_error, ctypes.create_unicode_buffer
|
|
22
|
+
# These pure lexical helpers share a private globals dictionary, not mutable
|
|
23
|
+
# ntpath/os module attributes a cell could replace.
|
|
24
|
+
namespace = {**vars(ntpath), "__builtins__": __builtins__,
|
|
25
|
+
"os": types.SimpleNamespace(fspath=os.fspath, getcwd=os.getcwd)}
|
|
26
|
+
for name, function in tuple(namespace.items()):
|
|
27
|
+
if isinstance(function, types.FunctionType) and function.__globals__ is vars(ntpath):
|
|
28
|
+
namespace[name] = types.FunctionType(function.__code__, namespace, name, function.__defaults__)
|
|
29
|
+
dirname, basename, normpath, splitdrive = (
|
|
30
|
+
types.FunctionType(getattr(ntpath, name).__code__, namespace, name)
|
|
31
|
+
for name in ("dirname", "basename", "normpath", "splitdrive")
|
|
32
|
+
)
|
|
33
|
+
getcwd = os.getcwd
|
|
34
|
+
|
|
35
|
+
def join(*parts):
|
|
36
|
+
# node:path.join concatenates before normalizing; ntpath.join instead
|
|
37
|
+
# discards earlier components when a later component contains a drive.
|
|
38
|
+
value = "\\".join(part for part in parts if part)
|
|
39
|
+
result = normpath(value)
|
|
40
|
+
if value.endswith(("\\", "/")) and not result.endswith("\\"):
|
|
41
|
+
result += "\\"
|
|
42
|
+
return result
|
|
43
|
+
|
|
44
|
+
def anchor(value):
|
|
45
|
+
value = value.replace("/", "\\")
|
|
46
|
+
# Drive-relative paths use per-drive process state in node:path.resolve;
|
|
47
|
+
# device namespaces have different lexical rules. Neither is approximated.
|
|
48
|
+
drive, tail = splitdrive(value)
|
|
49
|
+
if drive.startswith(("\\\\?", "\\\\.")):
|
|
50
|
+
raise failure("EIDENTITY_UNSUPPORTED")
|
|
51
|
+
if not drive and tail.startswith("\\"):
|
|
52
|
+
drive = splitdrive(getcwd())[0]
|
|
53
|
+
if drive.startswith("\\\\") and not tail:
|
|
54
|
+
tail = "\\"
|
|
55
|
+
if not drive or not tail.startswith("\\"):
|
|
56
|
+
raise failure("EIDENTITY_UNSUPPORTED")
|
|
57
|
+
value = normpath(drive + tail)
|
|
58
|
+
return "\\".join(part if i == 0 else part.rstrip(". ") for i, part in enumerate(value.split("\\")))
|
|
59
|
+
|
|
60
|
+
def native_error():
|
|
61
|
+
code = get_error()
|
|
62
|
+
# Known libuv translations. Other Win32 failures carry no identity
|
|
63
|
+
# instead of assuming Python's errno translation is the same.
|
|
64
|
+
return failure({2: "ENOENT", 3: "ENOENT", 267: "ENOTDIR"}.get(code, "EIDENTITY_UNSUPPORTED"))
|
|
65
|
+
|
|
66
|
+
def realpath(value):
|
|
67
|
+
if "\0" in value:
|
|
68
|
+
raise failure("ERR_INVALID_ARG_VALUE")
|
|
69
|
+
handle = create(value, 0, 0, None, 3, 0x80 | 0x02000000, None)
|
|
70
|
+
if handle == invalid:
|
|
71
|
+
raise native_error()
|
|
72
|
+
try:
|
|
73
|
+
size = final(handle, None, 0, 0)
|
|
74
|
+
if not size:
|
|
75
|
+
raise native_error()
|
|
76
|
+
result = buffer(size + 1)
|
|
77
|
+
length = final(handle, result, size, 0)
|
|
78
|
+
if not length or length >= size:
|
|
79
|
+
raise failure("EIDENTITY_UNSUPPORTED")
|
|
80
|
+
value = result.value
|
|
81
|
+
if value.startswith("\\\\?\\UNC\\"):
|
|
82
|
+
return "\\\\" + value[8:]
|
|
83
|
+
if value.startswith("\\\\?\\"):
|
|
84
|
+
return value[4:]
|
|
85
|
+
raise failure("EIDENTITY_UNSUPPORTED")
|
|
86
|
+
finally:
|
|
87
|
+
close(handle)
|
|
88
|
+
|
|
89
|
+
def absolute(value):
|
|
90
|
+
drive, tail = splitdrive(value)
|
|
91
|
+
return bool(tail.startswith(("\\", "/")) or drive.startswith("\\\\"))
|
|
92
|
+
|
|
93
|
+
return realpath, anchor, dirname, basename, join, absolute
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# Python kernel permissions
|
|
2
|
+
|
|
3
|
+
This is the Python portion of senpi#4309, stacked on the JavaScript permission
|
|
4
|
+
core and invoker-authority work. The integrating permission extension supplies
|
|
5
|
+
`PythonKernelStartOptions.permissions.state` and `decide`. This PR does not
|
|
6
|
+
replace the extension's rule matcher or connect configuration reload events.
|
|
7
|
+
|
|
8
|
+
## Transport and authority
|
|
9
|
+
|
|
10
|
+
Each cell and kernel-tool invocation receives a fresh host-minted token.
|
|
11
|
+
Python keeps that token in a `ContextVar`, including copied parallel contexts.
|
|
12
|
+
The host's existing invocation pump owns callback token lifetime; the existing
|
|
13
|
+
cross-session admission check runs before the callback body.
|
|
14
|
+
|
|
15
|
+
The existing loopback HTTP bridge serves `/permission_check`. This route uses
|
|
16
|
+
the invocation capability, not the owner's bridge bearer. Every operation opens
|
|
17
|
+
a new connection, sends a random nonce, and requires the same nonce in the reply.
|
|
18
|
+
Connect, send, and receive share one two-second deadline. Unknown tokens,
|
|
19
|
+
transport failures, invalid replies, host failures, cancellation, and revoked
|
|
20
|
+
tokens all refuse. The ordinary authenticated bridge routes are unchanged.
|
|
21
|
+
The shared `CellOperation` type adds `read` and `ls`; the JavaScript slot layout
|
|
22
|
+
and its state transitions are unchanged.
|
|
23
|
+
|
|
24
|
+
Python maps a host-owned authority file read-only. The host publishes each
|
|
25
|
+
epoch/alive/active word with one aligned eight-byte write, and never reuses a
|
|
26
|
+
token offset while its interpreter may retain a context. Wrapped operations
|
|
27
|
+
inspect the word before taking the inactive fast path and again after an allow.
|
|
28
|
+
No-token contexts consult the kernel word and cannot borrow an enforcing
|
|
29
|
+
invocation's authority.
|
|
30
|
+
An expired inactive token may continue ordinary background work only while the
|
|
31
|
+
kernel word remains alive and inactive. An expired enforcing token, or any dead
|
|
32
|
+
kernel word, still refuses. This keeps copied contexts and asyncio tasks working
|
|
33
|
+
across cells in sessions without deny rules.
|
|
34
|
+
|
|
35
|
+
`revokeSessionKernels` marks the words DEAD synchronously and cancels pending
|
|
36
|
+
checks before requesting retirement. Admission waits for the existing process
|
|
37
|
+
retirement path to observe exit. An abandoned retirement permanently fences
|
|
38
|
+
the host session and emits `onRevocationFailed`; the integrating extension must
|
|
39
|
+
also fence direct tools on that event.
|
|
40
|
+
|
|
41
|
+
## Runtime boundary
|
|
42
|
+
|
|
43
|
+
The prelude loads both permission modules by their own file locations before
|
|
44
|
+
user code, including when the prelude is loaded through `runpy`. Captured
|
|
45
|
+
originals, the descriptor grants, and the mapping stay in closures. Wrappers
|
|
46
|
+
do not publish `__wrapped__` references to originals.
|
|
47
|
+
Permission decisions use private copies of classification helpers, constants,
|
|
48
|
+
builtins, socket methods, and JSON codecs. Python helpers whose globals contain
|
|
49
|
+
dependencies get private globals dictionaries too. Rebinding public stdlib names
|
|
50
|
+
cannot change an authority word, forge a reply, or redirect a permission target.
|
|
51
|
+
The `posix`/`nt` aliases share the same wrappers as `os`.
|
|
52
|
+
|
|
53
|
+
Every path request carries `path`, the spelling as written, and
|
|
54
|
+
`executedPath`, the string the operating system receives (a relative spelling
|
|
55
|
+
joined onto the interpreter's current directory with `os.path.join`, never
|
|
56
|
+
lexically collapsed). `path_identity.py` mirrors the JavaScript kernel's
|
|
57
|
+
version-1 canonicalizer: native resolution of the deepest existing ancestor,
|
|
58
|
+
the same missing-tail and dangling-link rules, Darwin NFC normalization, and
|
|
59
|
+
a per-device case-folding probe. It sends `canonical: {v, path, foldCase}` or
|
|
60
|
+
the same `unresolvable` error code. The bridge validates the field types; the
|
|
61
|
+
permission service retains the shared version check and mount rewrite.
|
|
62
|
+
The current canonical keeps identity matching available when the host resolver
|
|
63
|
+
cannot start, just as for JavaScript cells.
|
|
64
|
+
|
|
65
|
+
Native primitives are captured before interposition. Node uses libc realpath
|
|
66
|
+
on POSIX; Bun uses descriptor paths on Darwin and Linux. Windows uses libuv's
|
|
67
|
+
CreateFileW/GetFinalPathNameByHandleW sequence. Drive-relative and device-namespace
|
|
68
|
+
Windows paths, unclassified Win32 errors, unsupported Bun platforms, and native
|
|
69
|
+
names that cannot be represented losslessly in UTF-8 omit canonical identity
|
|
70
|
+
rather than sending an approximation. They retain the existing host fallback;
|
|
71
|
+
no operation is refused merely because local canonical identity is unavailable.
|
|
72
|
+
An input spelling that is not valid UTF-8 remains uncheckable under the existing
|
|
73
|
+
wire contract.
|
|
74
|
+
|
|
75
|
+
File modes map to read, write, or both. Descriptor grants record paths and
|
|
76
|
+
modes; unknown descriptors and opposite-mode operations refuse. Retained file
|
|
77
|
+
objects check authority on use. SQLite connections compose their authorizer
|
|
78
|
+
with user authorizers, refuse ATTACH and extension loading, and separately
|
|
79
|
+
decide both backup endpoints.
|
|
80
|
+
|
|
81
|
+
Loader source and resource reads remain subject to read checks. The engine's
|
|
82
|
+
bytecode-cache writer is recognized by the original loader code objects in
|
|
83
|
+
its call chain, not by a runtime-directory prefix. Direct calls to `set_data`
|
|
84
|
+
still request write permission. Memory probes resolve before interposition and
|
|
85
|
+
keep their fixed engine I/O private. Under enforcement, exception formatting
|
|
86
|
+
reports the real traceback frames without issuing linecache source-file reads.
|
|
87
|
+
Engine permission assets are compiled directly from source without a bytecode
|
|
88
|
+
cache. Dynamic and builtin extension entry points, including `_imp`, are guarded.
|
|
89
|
+
Cells see live mapping/sequence views of import registries while enforcing:
|
|
90
|
+
ordinary reads continue, ordinary mutations carry the permission refusal, and
|
|
91
|
+
direct `dict`/`list` descriptors reject the views with `TypeError` before any
|
|
92
|
+
mutation. The `sys` dictionary view is read-only in that mode.
|
|
93
|
+
FileIO remains a subclassable raw-I/O adapter, but its MRO no longer exposes the
|
|
94
|
+
unguarded native file constructor.
|
|
95
|
+
|
|
96
|
+
Ruby and Julia refuse each enforcing cell before posting it to the interpreter,
|
|
97
|
+
including queued cells whose policy changed before execution. Inactive cells
|
|
98
|
+
retain their existing execution path.
|
|
99
|
+
|
|
100
|
+
This is API interposition, not a sandbox against deliberate introspection or
|
|
101
|
+
already-loaded native code.
|
|
102
|
+
|
|
103
|
+
Known limitation: reads performed by the runtime's native module resolver (bare packages, tsconfig aliases, package.json #imports, symlinked packages) are not covered by this work; tracked in #4380.
|