@code-yeongyu/senpi-codemode 2026.10.27-1 → 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 +46 -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
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { closeSync, ftruncateSync, mkdtempSync, openSync, rmSync, writeSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { registerPermissionCheck } from "../../bridge/permission-check.ts";
|
|
5
|
+
import type { HostToKernelMessage } from "../../bridge/protocol.ts";
|
|
6
|
+
import type { CellAuthorityToken } from "../js/kernel-contract.ts";
|
|
7
|
+
import type { CellDecision, CellOperation } from "../js/permission-slots.mjs";
|
|
8
|
+
import type { KernelToolsInvokeOptions, KernelToolsInvokeRequest } from "../js/kernel-tools-types.ts";
|
|
9
|
+
import { registerPermissionKernel, unregisterPermissionKernel } from "../js/permission-sessions.ts";
|
|
10
|
+
import { invocationSession } from "../shared/invocation-authority.ts";
|
|
11
|
+
import type { PythonKernelStartOptions } from "./kernel-contract.ts";
|
|
12
|
+
|
|
13
|
+
const size = 1024 * 1024;
|
|
14
|
+
type BoundToken = { token: CellAuthorityToken; offset: number; release: () => void; abort: AbortController };
|
|
15
|
+
|
|
16
|
+
/** Python maps this host-owned file read-only. Words are published with one aligned eight-byte pwrite. */
|
|
17
|
+
export class PythonPermissionHost {
|
|
18
|
+
readonly kernelId = crypto.randomUUID();
|
|
19
|
+
readonly #root = mkdtempSync(join(tmpdir(), "senpi-py-authority-"));
|
|
20
|
+
readonly #path = join(this.#root, "words");
|
|
21
|
+
readonly #fd = openSync(this.#path, "wx+", 0o600);
|
|
22
|
+
readonly #options: PythonKernelStartOptions;
|
|
23
|
+
readonly #session;
|
|
24
|
+
readonly #retire: () => Promise<void>;
|
|
25
|
+
readonly #tokens = new Map<string, BoundToken>();
|
|
26
|
+
readonly #runs = new Map<string, string>();
|
|
27
|
+
readonly #invocations = new Map<string, string>();
|
|
28
|
+
readonly #invokerBindings = new Map<string, number>();
|
|
29
|
+
#offset = 8;
|
|
30
|
+
#revoked = false;
|
|
31
|
+
#disposed = false;
|
|
32
|
+
|
|
33
|
+
constructor(options: PythonKernelStartOptions, retire: () => Promise<void>) {
|
|
34
|
+
this.#options = options;
|
|
35
|
+
this.#retire = retire;
|
|
36
|
+
ftruncateSync(this.#fd, size);
|
|
37
|
+
this.#session = registerPermissionKernel(options.sessionId, this);
|
|
38
|
+
this.#publishKernel();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
#state() {
|
|
42
|
+
const state = this.#options.permissions?.state() ?? { epoch: 0, active: false, rules: [] };
|
|
43
|
+
if (!Number.isInteger(state.epoch) || state.epoch < 0 || state.epoch >= 2 ** 29)
|
|
44
|
+
throw new Error("Invalid permission epoch");
|
|
45
|
+
return state;
|
|
46
|
+
}
|
|
47
|
+
#store(offset: number, word: number): void {
|
|
48
|
+
const bytes = Buffer.alloc(8);
|
|
49
|
+
bytes.writeBigUInt64LE(BigInt(word));
|
|
50
|
+
if (writeSync(this.#fd, bytes, 0, 8, offset) !== 8) throw new Error("Permission authority publication failed");
|
|
51
|
+
}
|
|
52
|
+
#publishKernel(): void {
|
|
53
|
+
const state = this.#state();
|
|
54
|
+
this.#store(0, state.epoch * 4 + (this.#revoked ? 0 : 2) + Number(state.active));
|
|
55
|
+
}
|
|
56
|
+
async admit(): Promise<void> {
|
|
57
|
+
await this.#session.pending;
|
|
58
|
+
if (this.#session.dead || this.#disposed)
|
|
59
|
+
throw new Error("Permission authority unavailable; restart the session");
|
|
60
|
+
}
|
|
61
|
+
bootstrap() {
|
|
62
|
+
// A replacement is started only after the prior interpreter's exit was observed.
|
|
63
|
+
this.interrupt();
|
|
64
|
+
this.#offset = 8;
|
|
65
|
+
this.#revoked = false;
|
|
66
|
+
this.#publishKernel();
|
|
67
|
+
return { path: this.#path, size, rules: this.#state().rules, runtime: process.versions.bun ? "bun" : "node" };
|
|
68
|
+
}
|
|
69
|
+
#mint(options?: AbortSignal | KernelToolsInvokeOptions): BoundToken {
|
|
70
|
+
if (this.#revoked || this.#session.dead || this.#disposed) throw new Error("Permission authority unavailable");
|
|
71
|
+
const session = invocationSession(
|
|
72
|
+
this.#options.sessionId,
|
|
73
|
+
{ cwd: this.#options.cwd, state: this.#state() },
|
|
74
|
+
options,
|
|
75
|
+
this.#options.permissions?.resolveSession,
|
|
76
|
+
);
|
|
77
|
+
if (this.#offset >= size) throw new Error("Python permission token capacity exhausted; reset the kernel");
|
|
78
|
+
const authority = new SharedArrayBuffer(4);
|
|
79
|
+
const word = session.state.epoch * 4 + 2 + Number(session.state.active);
|
|
80
|
+
Atomics.store(new Int32Array(authority), 0, word);
|
|
81
|
+
const abort = new AbortController();
|
|
82
|
+
const token: CellAuthorityToken = {
|
|
83
|
+
id: crypto.randomUUID(),
|
|
84
|
+
sessionId: session.sessionId,
|
|
85
|
+
cwd: session.cwd,
|
|
86
|
+
authority,
|
|
87
|
+
rules: session.state.rules,
|
|
88
|
+
signal: abort.signal,
|
|
89
|
+
};
|
|
90
|
+
const offset = this.#offset;
|
|
91
|
+
this.#offset += 8;
|
|
92
|
+
this.#store(offset, word);
|
|
93
|
+
const release = registerPermissionCheck(token.id, (call) => this.#decide(token, call));
|
|
94
|
+
const bound = { token, offset, release, abort };
|
|
95
|
+
if (session.sessionId !== this.#options.sessionId) {
|
|
96
|
+
const binding = registerPermissionKernel(session.sessionId, this);
|
|
97
|
+
if (binding.dead || binding.pending) {
|
|
98
|
+
release();
|
|
99
|
+
if (!this.#invokerBindings.has(session.sessionId)) unregisterPermissionKernel(session.sessionId, this);
|
|
100
|
+
throw new Error("Permission authority unavailable");
|
|
101
|
+
}
|
|
102
|
+
this.#invokerBindings.set(session.sessionId, (this.#invokerBindings.get(session.sessionId) ?? 0) + 1);
|
|
103
|
+
}
|
|
104
|
+
this.#tokens.set(token.id, bound);
|
|
105
|
+
return bound;
|
|
106
|
+
}
|
|
107
|
+
async #decide(token: CellAuthorityToken, call: CellOperation): Promise<CellDecision> {
|
|
108
|
+
if (this.#revoked || this.#session.dead || !this.#tokens.has(token.id) || token.signal?.aborted)
|
|
109
|
+
throw new Error("Permission authority unavailable");
|
|
110
|
+
const decide = this.#options.permissions?.decide;
|
|
111
|
+
if (!decide) throw new Error("Cell decider unavailable");
|
|
112
|
+
const cancellation = Promise.withResolvers<never>();
|
|
113
|
+
const cancel = () => cancellation.reject(new Error("Permission authority revoked"));
|
|
114
|
+
token.signal?.addEventListener("abort", cancel, { once: true });
|
|
115
|
+
try {
|
|
116
|
+
const answer = await Promise.race([decide(token, call), cancellation.promise]);
|
|
117
|
+
if (!this.#tokens.has(token.id) || token.signal?.aborted) throw new Error("Permission authority revoked");
|
|
118
|
+
return answer;
|
|
119
|
+
} finally {
|
|
120
|
+
token.signal?.removeEventListener("abort", cancel);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
frame(message: HostToKernelMessage): HostToKernelMessage {
|
|
124
|
+
let id: string | undefined;
|
|
125
|
+
if (message.type === "run") {
|
|
126
|
+
id = this.#mint().token.id;
|
|
127
|
+
this.#runs.set(message.cellId, id);
|
|
128
|
+
} else if (message.type === "kernel-tool-invoke") id = this.#invocations.get(message.call_id);
|
|
129
|
+
else if (message.type === "interrupt" || message.type === "close") this.interrupt();
|
|
130
|
+
const bound = id === undefined ? undefined : this.#tokens.get(id);
|
|
131
|
+
return bound
|
|
132
|
+
? Object.assign({}, message, {
|
|
133
|
+
authority: {
|
|
134
|
+
id,
|
|
135
|
+
offset: bound.offset,
|
|
136
|
+
rules: bound.token.rules,
|
|
137
|
+
active: Boolean(Atomics.load(new Int32Array(bound.token.authority), 0) & 1),
|
|
138
|
+
},
|
|
139
|
+
})
|
|
140
|
+
: message;
|
|
141
|
+
}
|
|
142
|
+
invocation(request: KernelToolsInvokeRequest, options?: AbortSignal | KernelToolsInvokeOptions): () => void {
|
|
143
|
+
const bound = this.#mint(options);
|
|
144
|
+
this.#invocations.set(request.call_id, bound.token.id);
|
|
145
|
+
const signal = options && ("aborted" in options ? options : options.signal);
|
|
146
|
+
const cancel = () => this.#release(bound.token.id);
|
|
147
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
148
|
+
if (signal?.aborted) cancel();
|
|
149
|
+
return () => {
|
|
150
|
+
signal?.removeEventListener("abort", cancel);
|
|
151
|
+
this.#invocations.delete(request.call_id);
|
|
152
|
+
cancel();
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
settle(cellId: string): void {
|
|
156
|
+
const id = this.#runs.get(cellId);
|
|
157
|
+
this.#runs.delete(cellId);
|
|
158
|
+
if (id) this.#release(id);
|
|
159
|
+
}
|
|
160
|
+
#release(id: string): void {
|
|
161
|
+
const bound = this.#tokens.get(id);
|
|
162
|
+
if (!bound) return;
|
|
163
|
+
const word = Atomics.and(new Int32Array(bound.token.authority), 0, ~2) & ~2;
|
|
164
|
+
this.#store(bound.offset, word);
|
|
165
|
+
bound.abort.abort();
|
|
166
|
+
bound.release();
|
|
167
|
+
this.#tokens.delete(id);
|
|
168
|
+
const count = this.#invokerBindings.get(bound.token.sessionId);
|
|
169
|
+
if (count === 1) {
|
|
170
|
+
this.#invokerBindings.delete(bound.token.sessionId);
|
|
171
|
+
unregisterPermissionKernel(bound.token.sessionId, this);
|
|
172
|
+
} else if (count !== undefined) this.#invokerBindings.set(bound.token.sessionId, count - 1);
|
|
173
|
+
}
|
|
174
|
+
interrupt(): void {
|
|
175
|
+
for (const id of this.#tokens.keys()) this.#release(id);
|
|
176
|
+
}
|
|
177
|
+
revoke(): void {
|
|
178
|
+
this.#revoked = true;
|
|
179
|
+
this.#publishKernel();
|
|
180
|
+
this.interrupt();
|
|
181
|
+
}
|
|
182
|
+
async retire(): Promise<void> {
|
|
183
|
+
try {
|
|
184
|
+
await this.#retire();
|
|
185
|
+
} catch {
|
|
186
|
+
this.#session.dead = true;
|
|
187
|
+
this.#options.onRevocationFailed?.({ sessionId: this.#options.sessionId, kernelId: this.kernelId });
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
dispose(): void {
|
|
191
|
+
if (this.#disposed) return;
|
|
192
|
+
this.revoke();
|
|
193
|
+
this.#disposed = true;
|
|
194
|
+
unregisterPermissionKernel(this.#options.sessionId, this);
|
|
195
|
+
closeSync(this.#fd);
|
|
196
|
+
rmSync(this.#root, { recursive: true, force: true });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Synchronous, invocation-scoped permission checks over the existing loopback bridge."""
|
|
2
|
+
import contextvars
|
|
3
|
+
import builtins
|
|
4
|
+
import json
|
|
5
|
+
import mmap
|
|
6
|
+
import os
|
|
7
|
+
import socket
|
|
8
|
+
import _socket
|
|
9
|
+
import struct
|
|
10
|
+
import time
|
|
11
|
+
import importlib
|
|
12
|
+
import importlib.util
|
|
13
|
+
from typing import NoReturn
|
|
14
|
+
|
|
15
|
+
__builtins__ = vars(builtins).copy()
|
|
16
|
+
|
|
17
|
+
_interpose_path = os.path.join(os.path.dirname(__file__), "interpose.py")
|
|
18
|
+
_spec = importlib.util.spec_from_file_location("_senpi_interpose", _interpose_path)
|
|
19
|
+
if _spec is None or _spec.loader is None:
|
|
20
|
+
raise RuntimeError("Python permission interposition asset is unavailable")
|
|
21
|
+
_module = importlib.util.module_from_spec(_spec)
|
|
22
|
+
# Engine assets are source-only; loading them must never populate the checkout.
|
|
23
|
+
with open(_interpose_path, "rb") as _source:
|
|
24
|
+
exec(compile(_source.read(), _interpose_path, "exec"), _module.__dict__)
|
|
25
|
+
install = _module.install
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class CellPermissionError(RuntimeError):
|
|
29
|
+
code = "cell_permission_denied"
|
|
30
|
+
|
|
31
|
+
def __init__(self, reason, rule=None):
|
|
32
|
+
self.permissionRule = rule
|
|
33
|
+
identity = (f"{rule['permission']} {rule['pattern']} ({rule['layer']})"
|
|
34
|
+
if rule else "permission authority unavailable")
|
|
35
|
+
super().__init__(f"Cell operation refused: {identity}: {reason}")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def initialize(config, port):
|
|
39
|
+
# The mapping and captured I/O never enter the user namespace.
|
|
40
|
+
with open(config["path"], "rb") as file:
|
|
41
|
+
words = mmap.mmap(file.fileno(), config["size"], access=mmap.ACCESS_READ)
|
|
42
|
+
context = contextvars.ContextVar("senpi_permission", default=None)
|
|
43
|
+
getcwd = os.getcwd
|
|
44
|
+
unpack_from = struct.unpack_from
|
|
45
|
+
random_bytes = os.urandom
|
|
46
|
+
# The C scanner/encoder retain their dependencies too: merely capturing
|
|
47
|
+
# json.loads/dumps still leaves mutable decoder/encoder method lookups.
|
|
48
|
+
encode = getattr(json.encoder, "c_make_encoder")({}, None, json.encoder.encode_basestring_ascii,
|
|
49
|
+
None, ": ", ", ", False, False, True)
|
|
50
|
+
decode = getattr(json.JSONDecoder(), "scan_once")
|
|
51
|
+
monotonic = time.monotonic
|
|
52
|
+
socket_type = _socket.socket
|
|
53
|
+
family, stream = socket.AF_INET, socket.SOCK_STREAM
|
|
54
|
+
connect, sendall, recv = socket_type.connect, socket_type.sendall, socket_type.recv
|
|
55
|
+
settimeout, close = socket_type.settimeout, socket_type.close
|
|
56
|
+
|
|
57
|
+
def rule():
|
|
58
|
+
token = context.get()
|
|
59
|
+
rules = token.get("rules", []) if token else config.get("rules", [])
|
|
60
|
+
return rules[0] if rules else None
|
|
61
|
+
|
|
62
|
+
def refuse(reason, deciding_rule=None) -> NoReturn:
|
|
63
|
+
raise CellPermissionError(reason, deciding_rule or rule())
|
|
64
|
+
|
|
65
|
+
def enforcing():
|
|
66
|
+
token = context.get()
|
|
67
|
+
offset = token["offset"] if token else 0
|
|
68
|
+
word = unpack_from("<Q", words, offset)[0]
|
|
69
|
+
if not word & 2:
|
|
70
|
+
# Settled inactive contexts retain ordinary background I/O, but a
|
|
71
|
+
# dead/enforcing kernel can never be revived by that expired token.
|
|
72
|
+
kernel_word = unpack_from("<Q", words, 0)[0]
|
|
73
|
+
if word & 1 or kernel_word & 3 != 2:
|
|
74
|
+
refuse("authority revoked")
|
|
75
|
+
return False
|
|
76
|
+
if token is None and word & 1:
|
|
77
|
+
refuse("operation has no invocation authority")
|
|
78
|
+
return bool(word & 1)
|
|
79
|
+
|
|
80
|
+
def check(tool, input, cwd=None):
|
|
81
|
+
if not enforcing():
|
|
82
|
+
return
|
|
83
|
+
token = context.get()
|
|
84
|
+
if token is None:
|
|
85
|
+
refuse("operation has no invocation authority")
|
|
86
|
+
nonce = random_bytes(16).hex()
|
|
87
|
+
payload = "".join(encode({"token": token["id"], "nonce": nonce,
|
|
88
|
+
"call": {"tool": tool, "input": input, "cwd": cwd or getcwd()}}, 0)).encode()
|
|
89
|
+
deadline = monotonic() + 2.0
|
|
90
|
+
sock = None
|
|
91
|
+
try:
|
|
92
|
+
sock = socket_type(family, stream)
|
|
93
|
+
if sock is not None:
|
|
94
|
+
def remaining():
|
|
95
|
+
seconds = deadline - monotonic()
|
|
96
|
+
if seconds <= 0:
|
|
97
|
+
raise TimeoutError("permission deadline")
|
|
98
|
+
settimeout(sock, seconds)
|
|
99
|
+
remaining()
|
|
100
|
+
connect(sock, ("127.0.0.1", port))
|
|
101
|
+
remaining()
|
|
102
|
+
sendall(sock, b"POST /permission_check HTTP/1.1\r\nHost: 127.0.0.1\r\n"
|
|
103
|
+
b"Connection: close\r\nContent-Type: application/json\r\nContent-Length: " +
|
|
104
|
+
str(len(payload)).encode() + b"\r\n\r\n" + payload)
|
|
105
|
+
response = bytearray()
|
|
106
|
+
while True:
|
|
107
|
+
remaining()
|
|
108
|
+
chunk = recv(sock, 16384)
|
|
109
|
+
if not chunk:
|
|
110
|
+
break
|
|
111
|
+
response.extend(chunk)
|
|
112
|
+
if len(response) > 32768:
|
|
113
|
+
raise ValueError("oversized permission response")
|
|
114
|
+
header, body = bytes(response).split(b"\r\n\r\n", 1)
|
|
115
|
+
if not header.startswith(b"HTTP/1.1 200 "):
|
|
116
|
+
raise ValueError("permission HTTP failure")
|
|
117
|
+
# Node sends chunked responses unless Content-Length is set by the bridge.
|
|
118
|
+
text = body.decode().strip()
|
|
119
|
+
reply, end = decode(text, 0)
|
|
120
|
+
if end != len(text):
|
|
121
|
+
raise ValueError("trailing permission response")
|
|
122
|
+
value = reply["value"]
|
|
123
|
+
if reply.get("ok") is not True or value.get("nonce") != nonce:
|
|
124
|
+
raise ValueError("permission nonce mismatch")
|
|
125
|
+
decision = value["decision"]
|
|
126
|
+
enforcing() # A late allow cannot cross revocation.
|
|
127
|
+
if decision.get("action") == "pass":
|
|
128
|
+
return
|
|
129
|
+
deciding_rule = decision.get("rule")
|
|
130
|
+
if decision.get("action") == "deny" and isinstance(deciding_rule, dict) and all(
|
|
131
|
+
isinstance(deciding_rule.get(key), str) for key in ("permission", "pattern", "layer")
|
|
132
|
+
):
|
|
133
|
+
refuse(str(input), deciding_rule)
|
|
134
|
+
raise ValueError("invalid permission decision")
|
|
135
|
+
except CellPermissionError:
|
|
136
|
+
raise
|
|
137
|
+
except BaseException as exc:
|
|
138
|
+
# KeyboardInterrupt must leave the parked check immediately, never wait for the host.
|
|
139
|
+
refuse(f"permission check failed ({type(exc).__name__})")
|
|
140
|
+
finally:
|
|
141
|
+
if sock is not None:
|
|
142
|
+
close(sock)
|
|
143
|
+
|
|
144
|
+
install(check, enforcing, refuse, config["runtime"])
|
|
145
|
+
return context
|
|
@@ -14,6 +14,7 @@ import base64
|
|
|
14
14
|
import codecs
|
|
15
15
|
import contextlib
|
|
16
16
|
import importlib
|
|
17
|
+
import importlib.util
|
|
17
18
|
import gc
|
|
18
19
|
import inspect
|
|
19
20
|
import io
|
|
@@ -42,6 +43,17 @@ from typing import Iterator
|
|
|
42
43
|
from threading import Lock, Thread
|
|
43
44
|
from typing import Any, Callable, Union
|
|
44
45
|
from urllib.parse import unquote
|
|
46
|
+
# Resolve the loopback bridge's lazy codec before user authority exists.
|
|
47
|
+
codecs.lookup("idna")
|
|
48
|
+
_permission_path = Path(__file__).with_name("permission_bridge.py")
|
|
49
|
+
_permission_spec = importlib.util.spec_from_file_location(
|
|
50
|
+
"_senpi_permission_bridge", _permission_path
|
|
51
|
+
)
|
|
52
|
+
if _permission_spec is None or _permission_spec.loader is None:
|
|
53
|
+
raise RuntimeError("Python permission bridge asset is unavailable")
|
|
54
|
+
_permission_module = importlib.util.module_from_spec(_permission_spec)
|
|
55
|
+
exec(compile(_permission_path.read_bytes(), str(_permission_path), "exec"), _permission_module.__dict__)
|
|
56
|
+
initialize_permissions = _permission_module.initialize
|
|
45
57
|
|
|
46
58
|
sys.__stdout__.write('{"type":"status","event":{"op":"kernel-startup","stage":"runtime-init"}}\n')
|
|
47
59
|
sys.__stdout__.flush()
|
|
@@ -51,6 +63,7 @@ USER_NS: dict[str, Any] = {"__name__": "__main__", "__doc__": None, "__builtins_
|
|
|
51
63
|
LOOP = asyncio.new_event_loop()
|
|
52
64
|
asyncio.set_event_loop(LOOP)
|
|
53
65
|
EMIT_LOCK = Lock()
|
|
66
|
+
permission_context = contextvars.ContextVar("senpi_unconfigured_permission", default=None)
|
|
54
67
|
|
|
55
68
|
# Mirrors src/bridge/reserved.ts; this standalone subprocess asset cannot import TypeScript.
|
|
56
69
|
RESERVED_AGENT_TOOL = "__agent__"
|
|
@@ -103,10 +116,21 @@ def emit(frame: dict[str, Any]) -> None:
|
|
|
103
116
|
|
|
104
117
|
|
|
105
118
|
def bridge_error(exc: BaseException) -> dict[str, str]:
|
|
119
|
+
authority = permission_context.get()
|
|
120
|
+
if (authority and authority.get("active")) or isinstance(exc, KeyboardInterrupt) or getattr(exc, "code", None) == "cell_permission_denied":
|
|
121
|
+
# Formatting an exception must not issue a second cell-requested read
|
|
122
|
+
# (linecache opens source files). Keep the real frames without source I/O.
|
|
123
|
+
frames = [f' File "{frame.f_code.co_filename}", line {line}, in {frame.f_code.co_name}\n'
|
|
124
|
+
for frame, line in traceback.walk_tb(exc.__traceback__)]
|
|
125
|
+
stack = "Traceback (most recent call last):\n" + "".join(frames) + f"{type(exc).__name__}: {exc}\n"
|
|
126
|
+
else:
|
|
127
|
+
stack = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
|
|
106
128
|
return {
|
|
107
129
|
"name": type(exc).__name__,
|
|
108
130
|
"message": str(exc),
|
|
109
|
-
"stack":
|
|
131
|
+
"stack": stack,
|
|
132
|
+
**({"code": getattr(exc, "code")} if getattr(exc, "code", None) else {}),
|
|
133
|
+
**({"permissionRule": getattr(exc, "permissionRule")} if getattr(exc, "permissionRule", None) else {}),
|
|
110
134
|
}
|
|
111
135
|
|
|
112
136
|
|
|
@@ -910,6 +934,7 @@ class KernelToolRunner:
|
|
|
910
934
|
|
|
911
935
|
def _run(self, invocation: Invocation, message: dict[str, Any]) -> None:
|
|
912
936
|
output = io.StringIO()
|
|
937
|
+
permission_scope = permission_context.set(message.get("authority"))
|
|
913
938
|
try:
|
|
914
939
|
entry = self._registry.resolve(message)
|
|
915
940
|
args = validate_args(entry.input_schema, message.get("args"))
|
|
@@ -935,8 +960,9 @@ class KernelToolRunner:
|
|
|
935
960
|
self._reply_error(invocation, exc.code, str(exc), output, exc.details)
|
|
936
961
|
except BaseException as exc: # noqa: BROAD_EXCEPT_OK — the invocation boundary serializes the tool's own failure.
|
|
937
962
|
self._reply_error(invocation, "kernel_tool_failed", f"{type(exc).__name__}: {exc}", output,
|
|
938
|
-
stack=
|
|
963
|
+
stack=bridge_error(exc)["stack"])
|
|
939
964
|
finally:
|
|
965
|
+
permission_context.reset(permission_scope)
|
|
940
966
|
with self._lock:
|
|
941
967
|
self._invocations.pop(invocation.request_id, None)
|
|
942
968
|
|
|
@@ -1964,9 +1990,10 @@ def _footprint_reader() -> Callable[[], tuple[int, bool]] | None:
|
|
|
1964
1990
|
if darwin() is not None:
|
|
1965
1991
|
return darwin
|
|
1966
1992
|
if sys.platform.startswith("linux") and os.path.exists("/proc/self/status"):
|
|
1993
|
+
open_status = open
|
|
1967
1994
|
|
|
1968
1995
|
def linux() -> tuple[int, bool] | None:
|
|
1969
|
-
with
|
|
1996
|
+
with open_status("/proc/self/status", encoding="ascii", errors="replace") as status:
|
|
1970
1997
|
for line in status:
|
|
1971
1998
|
if line.startswith("RssAnon:"):
|
|
1972
1999
|
return int(line.split()[1]) * 1024, False
|
|
@@ -2234,7 +2261,8 @@ def _enter_source_file(source_file: str | None) -> Callable[[], None]:
|
|
|
2234
2261
|
|
|
2235
2262
|
|
|
2236
2263
|
def run_cell(
|
|
2237
|
-
cell_id: str, code: str, preludes: Any = None, source_file: str | None = None, cell_token: str | None = None
|
|
2264
|
+
cell_id: str, code: str, preludes: Any = None, source_file: str | None = None, cell_token: str | None = None,
|
|
2265
|
+
authority: Any = None,
|
|
2238
2266
|
) -> None:
|
|
2239
2267
|
start = time.monotonic()
|
|
2240
2268
|
stdout = io.StringIO()
|
|
@@ -2245,6 +2273,7 @@ def run_cell(
|
|
|
2245
2273
|
result: dict[str, Any]
|
|
2246
2274
|
leave_source_file: Callable[[], None] = lambda: None
|
|
2247
2275
|
cell_scope = CURRENT_CELL_TOKEN.set(cell_token)
|
|
2276
|
+
permission_scope = permission_context.set(authority)
|
|
2248
2277
|
try:
|
|
2249
2278
|
KERNEL_TOOL_TOKEN.acquire()
|
|
2250
2279
|
with capture_streams(KERNEL_TOOL_STREAMS, (stdout, stderr), cell=True):
|
|
@@ -2277,6 +2306,7 @@ def run_cell(
|
|
|
2277
2306
|
# Liveness and revocation first: nothing the cell installed (an import finder, a path hook) can skip them.
|
|
2278
2307
|
KERNEL_TOOL_TOKEN.release()
|
|
2279
2308
|
CURRENT_CELL_TOKEN.reset(cell_scope)
|
|
2309
|
+
permission_context.reset(permission_scope)
|
|
2280
2310
|
try:
|
|
2281
2311
|
leave_source_file()
|
|
2282
2312
|
except BaseException as exc: # noqa: BROAD_EXCEPT_OK — user code can break the restore; it must not wedge the kernel.
|
|
@@ -2300,7 +2330,7 @@ def elapsed(start: float) -> int:
|
|
|
2300
2330
|
|
|
2301
2331
|
|
|
2302
2332
|
def _handle_message(message: dict[str, Any]) -> bool:
|
|
2303
|
-
global CONNECTION
|
|
2333
|
+
global CONNECTION, permission_context
|
|
2304
2334
|
message_type = message.get("type")
|
|
2305
2335
|
if message_type == "init":
|
|
2306
2336
|
connection = message.get("connection")
|
|
@@ -2312,6 +2342,10 @@ def _handle_message(message: dict[str, Any]) -> bool:
|
|
|
2312
2342
|
if isinstance(generation, int) and not isinstance(generation, bool):
|
|
2313
2343
|
KERNEL_TOOL_REGISTRY.generation = generation
|
|
2314
2344
|
KERNEL_MEMORY.configure(message.get("memory"))
|
|
2345
|
+
if KERNEL_MEMORY.thresholds is not None:
|
|
2346
|
+
KERNEL_MEMORY._footprint()
|
|
2347
|
+
if message.get("permission") is not None:
|
|
2348
|
+
permission_context = initialize_permissions(message["permission"], connection["port"])
|
|
2315
2349
|
emit({"type": "ready"})
|
|
2316
2350
|
return True
|
|
2317
2351
|
if message_type == "run":
|
|
@@ -2326,6 +2360,7 @@ def _handle_message(message: dict[str, Any]) -> bool:
|
|
|
2326
2360
|
message.get("preludes"),
|
|
2327
2361
|
source_file if isinstance(source_file, str) and source_file else None,
|
|
2328
2362
|
token if isinstance(token, str) else None,
|
|
2363
|
+
message.get("authority"),
|
|
2329
2364
|
)
|
|
2330
2365
|
return True
|
|
2331
2366
|
if message_type == "close":
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
waitForExit,
|
|
28
28
|
} from "./process.ts";
|
|
29
29
|
import { PythonStartup, type PythonStartupStage } from "./startup.ts";
|
|
30
|
+
import type { PythonPermissionHost } from "./permission-host.ts";
|
|
30
31
|
|
|
31
32
|
export type PythonTransportResult = Extract<KernelToHostMessage, { type: "result" }>;
|
|
32
33
|
|
|
@@ -41,6 +42,7 @@ export interface PythonTransportRunInput {
|
|
|
41
42
|
}
|
|
42
43
|
|
|
43
44
|
export interface PythonTransportOptions {
|
|
45
|
+
readonly permission?: PythonPermissionHost;
|
|
44
46
|
readonly interpreterPath: string;
|
|
45
47
|
readonly sessionId: string;
|
|
46
48
|
readonly cwd: string;
|
|
@@ -256,18 +258,23 @@ export class PythonKernelTransport {
|
|
|
256
258
|
this.#onPipeError(error instanceof Error ? error : new Error(String(error))),
|
|
257
259
|
);
|
|
258
260
|
const { sessionId, connection, memory, kernelGeneration } = this.#options;
|
|
259
|
-
this.#write(
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
261
|
+
this.#write(
|
|
262
|
+
Object.assign(
|
|
263
|
+
{
|
|
264
|
+
type: "init",
|
|
265
|
+
sessionId,
|
|
266
|
+
connection,
|
|
267
|
+
...(memory === undefined ? {} : { memory }),
|
|
268
|
+
...(kernelGeneration === undefined ? {} : { kernelGeneration }),
|
|
269
|
+
} as const,
|
|
270
|
+
this.#options.permission ? { permission: this.#options.permission.bootstrap() } : {},
|
|
271
|
+
),
|
|
272
|
+
);
|
|
266
273
|
await startup.ready;
|
|
267
274
|
}
|
|
268
275
|
|
|
269
276
|
#write(message: HostToKernelMessage): void {
|
|
270
|
-
this.#child.stdin.write(encodeBridgeFrame(message));
|
|
277
|
+
this.#child.stdin.write(encodeBridgeFrame(this.#options.permission?.frame(message) ?? message));
|
|
271
278
|
}
|
|
272
279
|
|
|
273
280
|
#onStdout(chunk: string): void {
|
package/src/kernels/rb/kernel.ts
CHANGED
|
@@ -2,11 +2,13 @@ import { join } from "node:path";
|
|
|
2
2
|
import type { BridgeConnectionConfig, KernelToHostMessage } from "../../bridge/protocol.ts";
|
|
3
3
|
import type { SessionEnvironment } from "../session-env.ts";
|
|
4
4
|
import type { KernelLifecycle } from "../shared/kernel-death.ts";
|
|
5
|
+
import type { InvocationPermissions } from "../shared/invocation-authority.ts";
|
|
5
6
|
import { readProcessGroupCpuTime } from "../shared/process-group-cpu.ts";
|
|
6
7
|
import { type CodemodeRuntimeAssetEnvironment, requireCodemodeRuntimeAsset } from "../shared/runtime-asset.ts";
|
|
7
8
|
import { SubprocessKernel, type SubprocessKernelMemory, type SubprocessSpawn } from "../shared/subprocess-kernel.ts";
|
|
8
9
|
|
|
9
10
|
export interface RubyKernelStartOptions extends KernelLifecycle {
|
|
11
|
+
readonly permissions?: InvocationPermissions;
|
|
10
12
|
readonly cwd: string;
|
|
11
13
|
readonly sessionId: string;
|
|
12
14
|
readonly connection: BridgeConnectionConfig;
|
|
@@ -44,6 +46,7 @@ export class RubyKernel extends SubprocessKernel {
|
|
|
44
46
|
onMessage: options.onMessage,
|
|
45
47
|
memory: options.memory && { language: "rb", ...options.memory },
|
|
46
48
|
onDeath: options.onDeath,
|
|
49
|
+
permissions: options.permissions,
|
|
47
50
|
startup: { label: "Ruby", readGroupCpuTime: readProcessGroupCpuTime },
|
|
48
51
|
});
|
|
49
52
|
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { CellPermissionState } from "../js/kernel-contract.ts";
|
|
2
|
+
import type { KernelToolsInvokeOptions } from "../js/kernel-tools-types.ts";
|
|
3
|
+
|
|
4
|
+
export interface InvocationSession {
|
|
5
|
+
readonly cwd: string;
|
|
6
|
+
readonly state: CellPermissionState;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface InvocationPermissions {
|
|
10
|
+
readonly state: () => CellPermissionState;
|
|
11
|
+
/** Integration resolves live permission state even for an invoker that has never opened a kernel. */
|
|
12
|
+
readonly resolveSession?: (sessionId: string) => InvocationSession;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const sessions = new Map<string, Set<() => InvocationSession>>();
|
|
16
|
+
|
|
17
|
+
export function registerInvocationSession(sessionId: string, read: () => InvocationSession): () => void {
|
|
18
|
+
let entries = sessions.get(sessionId);
|
|
19
|
+
if (!entries) {
|
|
20
|
+
entries = new Set();
|
|
21
|
+
sessions.set(sessionId, entries);
|
|
22
|
+
}
|
|
23
|
+
entries.add(read);
|
|
24
|
+
return () => {
|
|
25
|
+
entries.delete(read);
|
|
26
|
+
if (entries.size === 0) sessions.delete(sessionId);
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function invocationSession(
|
|
31
|
+
ownerId: string,
|
|
32
|
+
owner: InvocationSession,
|
|
33
|
+
options?: AbortSignal | KernelToolsInvokeOptions,
|
|
34
|
+
resolveSession?: InvocationPermissions["resolveSession"],
|
|
35
|
+
): InvocationSession & { readonly sessionId: string } {
|
|
36
|
+
const invokerId = options && !("aborted" in options) ? options.invoker?.sessionId : undefined;
|
|
37
|
+
if (invokerId === undefined || invokerId === ownerId) return { sessionId: ownerId, ...owner };
|
|
38
|
+
const registered = [...(sessions.get(invokerId) ?? [])].map((read) => read());
|
|
39
|
+
const invoker = resolveSession?.(invokerId) ?? registered.find((session) => session.state.active) ?? registered[0];
|
|
40
|
+
for (const [enforcingSessionId, state] of [
|
|
41
|
+
[ownerId, owner.state],
|
|
42
|
+
[invokerId, invoker?.state],
|
|
43
|
+
] as const) {
|
|
44
|
+
if (!state?.active) continue;
|
|
45
|
+
const rule = state.rules[0];
|
|
46
|
+
throw Object.assign(
|
|
47
|
+
new Error(
|
|
48
|
+
rule
|
|
49
|
+
? `Cross-session kernel tool refused while session ${enforcingSessionId} enforces deny rules, including ${rule.permission} ${rule.pattern} (${rule.layer})`
|
|
50
|
+
: "Cross-session kernel tool refused: permission authority unavailable",
|
|
51
|
+
),
|
|
52
|
+
{ code: "cell_permission_denied", reason: "cross_session_authority", enforcingSessionId, rule },
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
sessionId: invokerId,
|
|
57
|
+
...(invoker ?? { cwd: owner.cwd, state: { epoch: 0, active: false, rules: [] } }),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -24,10 +24,19 @@ export class KernelToolHostPump {
|
|
|
24
24
|
readonly #waiters = new Map<string, Waiter>();
|
|
25
25
|
readonly #post: (message: HostToKernelMessage) => void;
|
|
26
26
|
readonly #isOpen: () => boolean;
|
|
27
|
+
readonly #invocation?: (
|
|
28
|
+
request: KernelToolsInvokeRequest,
|
|
29
|
+
options?: AbortSignal | KernelToolsInvokeOptions,
|
|
30
|
+
) => () => void;
|
|
27
31
|
|
|
28
|
-
constructor(
|
|
32
|
+
constructor(
|
|
33
|
+
post: (message: HostToKernelMessage) => void,
|
|
34
|
+
isOpen: () => boolean,
|
|
35
|
+
invocation?: (request: KernelToolsInvokeRequest, options?: AbortSignal | KernelToolsInvokeOptions) => () => void,
|
|
36
|
+
) {
|
|
29
37
|
this.#post = post;
|
|
30
38
|
this.#isOpen = isOpen;
|
|
39
|
+
this.#invocation = invocation;
|
|
31
40
|
}
|
|
32
41
|
|
|
33
42
|
consume(message: KernelToHostMessage): boolean {
|
|
@@ -70,6 +79,18 @@ export class KernelToolHostPump {
|
|
|
70
79
|
* the message it always did (#1731).
|
|
71
80
|
*/
|
|
72
81
|
async invoke(request: KernelToolsInvokeRequest, options?: AbortSignal | KernelToolsInvokeOptions): Promise<unknown> {
|
|
82
|
+
const release = this.#invocation?.(request, options);
|
|
83
|
+
try {
|
|
84
|
+
return await this.#invoke(request, options);
|
|
85
|
+
} finally {
|
|
86
|
+
release?.();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async #invoke(
|
|
91
|
+
request: KernelToolsInvokeRequest,
|
|
92
|
+
options?: AbortSignal | KernelToolsInvokeOptions,
|
|
93
|
+
): Promise<unknown> {
|
|
73
94
|
const { signal, scope } = normalizeInvokeOptions(options);
|
|
74
95
|
this.events.dispatchEvent(new Event("nestedInvoke"));
|
|
75
96
|
const reply = await this.#request(
|