@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.
Files changed (65) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/package.json +4 -4
  3. package/src/bridge/http-server.ts +18 -5
  4. package/src/bridge/permission-check.ts +76 -0
  5. package/src/bridge/protocol.ts +7 -0
  6. package/src/kernels/jl/kernel.ts +3 -0
  7. package/src/kernels/js/context-manager.ts +26 -8
  8. package/src/kernels/js/inline-worker-entry.js +1 -23
  9. package/src/kernels/js/inline-worker.ts +7 -2
  10. package/src/kernels/js/interpose-bun.js +220 -0
  11. package/src/kernels/js/interpose-capabilities.js +29 -0
  12. package/src/kernels/js/interpose-code.js +8 -0
  13. package/src/kernels/js/interpose-fs.js +124 -0
  14. package/src/kernels/js/interpose-modules.js +107 -0
  15. package/src/kernels/js/interpose-paths.js +80 -0
  16. package/src/kernels/js/interpose-process.js +97 -0
  17. package/src/kernels/js/interpose-resources.js +59 -0
  18. package/src/kernels/js/interpose-sqlite.js +104 -0
  19. package/src/kernels/js/interpose.js +86 -0
  20. package/src/kernels/js/kernel-contract.ts +31 -0
  21. package/src/kernels/js/kernel-tools-pump.js +4 -2
  22. package/src/kernels/js/local-module-loader.ts +9 -4
  23. package/src/kernels/js/permission-authority.d.mts +10 -0
  24. package/src/kernels/js/permission-authority.mjs +11 -0
  25. package/src/kernels/js/permission-core.md +114 -0
  26. package/src/kernels/js/permission-host.ts +388 -0
  27. package/src/kernels/js/permission-runtime.js +205 -0
  28. package/src/kernels/js/permission-sessions.ts +46 -0
  29. package/src/kernels/js/permission-slots.d.mts +51 -0
  30. package/src/kernels/js/permission-slots.mjs +133 -0
  31. package/src/kernels/js/permission-worker-entry.js +1 -0
  32. package/src/kernels/js/permission-worker-frames.ts +55 -0
  33. package/src/kernels/js/process-tree.js +9 -1
  34. package/src/kernels/js/rewrite-imports.ts +44 -0
  35. package/src/kernels/js/windows-tree-kill.js +10 -2
  36. package/src/kernels/js/worker-core.d.ts +2 -0
  37. package/src/kernels/js/worker-core.js +5 -2
  38. package/src/kernels/js/worker-cwd.js +14 -8
  39. package/src/kernels/js/worker-entry.js +1 -23
  40. package/src/kernels/js/worker-file-helpers.js +58 -0
  41. package/src/kernels/js/worker-host.ts +24 -6
  42. package/src/kernels/js/worker-package-resolve.js +10 -2
  43. package/src/kernels/js/worker-runtime.js +5 -65
  44. package/src/kernels/js/worker-slot.ts +5 -1
  45. package/src/kernels/js/worker-startup.ts +7 -5
  46. package/src/kernels/js/worker-webview.js +17 -16
  47. package/src/kernels/py/interpose.py +765 -0
  48. package/src/kernels/py/kernel-contract.ts +4 -0
  49. package/src/kernels/py/kernel-tools-host.ts +5 -1
  50. package/src/kernels/py/kernel.ts +45 -5
  51. package/src/kernels/py/path_identity.py +162 -0
  52. package/src/kernels/py/path_identity_posix.py +74 -0
  53. package/src/kernels/py/path_identity_windows.py +93 -0
  54. package/src/kernels/py/permission-core.md +103 -0
  55. package/src/kernels/py/permission-host.ts +198 -0
  56. package/src/kernels/py/permission_bridge.py +145 -0
  57. package/src/kernels/py/prelude.py +40 -5
  58. package/src/kernels/py/transport.ts +15 -8
  59. package/src/kernels/rb/kernel.ts +3 -0
  60. package/src/kernels/shared/invocation-authority.ts +59 -0
  61. package/src/kernels/shared/kernel-tools-pump.ts +22 -1
  62. package/src/kernels/shared/path-identity.d.mts +42 -0
  63. package/src/kernels/shared/path-identity.mjs +149 -0
  64. package/src/kernels/shared/subprocess-contract.ts +2 -0
  65. 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": "".join(traceback.format_exception(type(exc), exc, exc.__traceback__)),
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="".join(traceback.format_exception(type(exc), exc, exc.__traceback__)))
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 open("/proc/self/status", encoding="ascii", errors="replace") as status:
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
- type: "init",
261
- sessionId,
262
- connection,
263
- ...(memory === undefined ? {} : { memory }),
264
- ...(kernelGeneration === undefined ? {} : { kernelGeneration }),
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 {
@@ -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(post: (message: HostToKernelMessage) => void, isOpen: () => boolean) {
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(