Kaynağa Gözat

fix(code-runtime-python): bound the open-merge hold by the ledger budget

The review's critical: the open-merge branch accumulated the held fragment
before any ledger check, so a forged open flood could grow host memory without
touching logBudget. The held fragment is now bounded by the exact-cost walk
(jsonStringCostUpTo against the remaining budget; the closing frame's admit()
still bills the merged entry once), and the open field is registered in the
README wire-contract section and the fd-3 protocol note (en + zh). A forged
open-flood case asserts truncation to the marker under a 64-byte budget.
Chinesezjc 2 hafta önce
ebeveyn
işleme
ea1d28a068

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.md
-2026-07-31-code-runtime-python-fd3-protocol.md: 671506aafbad1b03bc66ae137a58a7b11a836f79
-2026-07-31-code-runtime-python-fd3-protocol.zh.md: 1568f6366b4651fbb87fcfdfacdc274fa07b4d8e
+2026-07-31-code-runtime-python-fd3-protocol.md: 992628b8fcadc8e09bd52c4f862542e2cc9f1acc
+2026-07-31-code-runtime-python-fd3-protocol.zh.md: bd5e76171f860bc0ad05fa1980cda3da4f8c4dfe

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.md

@@ -24,7 +24,7 @@ The package ships the runtime alongside the protocol; it remains independently b
 
 ## Wire contract
 
-Frames are JSON-lines on fd 3, one object per line, leaving stdout/stderr free for the program's own output. Child → host: `boot-ack`, `call`, `log`, `done`. Host → child: `boot` (first frame), `run` (after `boot-ack`), and one `reply` per `call`. The `log` frame's `truncated` flag marks the frame that IS the child ledger's own truncation marker, so the host stops capturing at the same point the child did instead of inferring it from its own budget. `done.error.kind` is one of `exception`, `invalid-output`, `output-limit`; wall/CPU budgets, aborts, and substrate death are observed host-side, not carried as frames.
+Frames are JSON-lines on fd 3, one object per line, leaving stdout/stderr free for the program's own output. Child → host: `boot-ack`, `call`, `log`, `done`. Host → child: `boot` (first frame), `run` (after `boot-ack`), and one `reply` per `call`. The `log` frame's `truncated` flag marks the frame that IS the child ledger's own truncation marker, so the host stops capturing at the same point the child did instead of inferring it from its own budget. The `log` frame's `open` flag marks an unterminated line committed by an explicit flush: the host holds it (bounded by the ledger budget) and appends the next frame to the same entry, so an explicit flush followed by more text reads back as one line rather than a fake newline. `done.error.kind` is one of `exception`, `invalid-output`, `output-limit`; wall/CPU budgets, aborts, and substrate death are observed host-side, not carried as frames.
 
 ## Mirror alignment
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-31-code-runtime-python-fd3-protocol.zh.md

@@ -24,7 +24,7 @@ Status: implemented
 
 ## Wire contract
 
-帧是 fd 3 上的 JSON-lines,每行一个对象,让 stdout/stderr 空出给程序自己的输出。Child → host:`boot-ack`、`call`、`log`、`done`。Host → child:`boot`(首帧)、`run`(在 `boot-ack` 之后)、以及每个 `call` 对应一个 `reply`。`log` 帧的 `truncated` 标志标记那个本身就是子进程 ledger 截断标记的帧,使 host 在与子进程相同的点停止捕获,而不是从自己的预算去推断。`done.error.kind` 是 `exception`、`invalid-output`、`output-limit` 之一;wall/CPU 预算、abort、substrate 死亡都在 host 侧观测,不作为帧携带。
+帧是 fd 3 上的 JSON-lines,每行一个对象,让 stdout/stderr 空出给程序自己的输出。Child → host:`boot-ack`、`call`、`log`、`done`。Host → child:`boot`(首帧)、`run`(在 `boot-ack` 之后)、以及每个 `call` 对应一个 `reply`。`log` 帧的 `truncated` 标志标记那个本身就是子进程 ledger 截断标记的帧,使 host 在与子进程相同的点停止捕获,而不是从自己的预算去推断。`log` 帧的 `open` 标志标记由显式 flush 提交的未结束行:宿主持有它(受账本预算约束)并把下一个帧追加到同一条目,因此显式 flush 后接更多文本读回为一行而不是假换行。`done.error.kind` 是 `exception`、`invalid-output`、`output-limit` 之一;wall/CPU 预算、abort、substrate 死亡都在 host 侧观测,不作为帧携带。
 
 ## Mirror alignment
 

+ 2 - 2
packages/code-runtime/code-runtime-python/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/code-runtime/code-runtime-python/README.md
-README.md: c6ab48b5ccd4e386f9ad416dee64b090500594f6
-README.zh.md: 637de914212be9b7e6a3b63e443d7252c092acf2
+README.md: 97a4b6d4ccf51cf999dcef237aac1480a532bce6
+README.zh.md: 649ca8ddd4c1ad02f27b8131c60f22511801c1e9

+ 1 - 1
packages/code-runtime/code-runtime-python/README.md

@@ -33,7 +33,7 @@ The package's default export is the `PythonCodeRuntime` plugin. Its public surfa
 
 ### The wire
 
-Frames travel on the child's fd 3 as JSON-lines — one object per line — so stdout/stderr stay clear for the program's own output. Child → host: `boot-ack`, `call`, `log`, `done`. Host → child: `boot` (first frame, carrying every cap and the namespace declarations), `run` (after `boot-ack`, carrying only the program body), and one `reply` per `call`. A forged frame can carry both `value` and `error` on `done`, so a consumer must check `error` first and ignore `value` when it is set.
+Frames travel on the child's fd 3 as JSON-lines — one object per line — so stdout/stderr stay clear for the program's own output. Child → host: `boot-ack`, `call`, `log`, `done`. Host → child: `boot` (first frame, carrying every cap and the namespace declarations), `run` (after `boot-ack`, carrying only the program body), and one `reply` per `call`. A forged frame can carry both `value` and `error` on `done`, so a consumer must check `error` first and ignore `value` when it is set. A `log` frame's `open` flag marks an unterminated line committed by an explicit flush: the host appends the next log frame to the same entry, so `print('a', end='', flush=True); print('b')` reads back as one `'ab'` entry rather than a fake newline.
 
 ### What can go wrong
 

+ 1 - 1
packages/code-runtime/code-runtime-python/README.zh.md

@@ -33,7 +33,7 @@ kind: "package-reference"
 
 ### wire
 
-帧在子进程 fd 3 上以 JSON-lines 传输——每行一个对象——因此 stdout/stderr 留给程序自己的输出。子进程 → 宿主:`boot-ack`、`call`、`log`、`done`。宿主 → 子进程:`boot`(首帧,携带全部上限与命名空间声明)、`run`(`boot-ack` 之后,只携带程序体)与每个 `call` 一个 `reply`。伪造帧可在 `done` 上同时携带 `value` 与 `error`,因此消费方必须先检查 `error`,在它存在时忽略 `value`。
+帧在子进程 fd 3 上以 JSON-lines 传输——每行一个对象——因此 stdout/stderr 留给程序自己的输出。子进程 → 宿主:`boot-ack`、`call`、`log`、`done`。宿主 → 子进程:`boot`(首帧,携带全部上限与命名空间声明)、`run`(`boot-ack` 之后,只携带程序体)与每个 `call` 一个 `reply`。伪造帧可在 `done` 上同时携带 `value` 与 `error`,因此消费方必须先检查 `error`,在它存在时忽略 `value`。`log` 帧的 `open` 标志标记由显式 flush 提交的未结束行:宿主把下一个 log 帧追加到同一条目,因此 `print('a', end='', flush=True); print('b')` 读回为一条 `'ab'` 条目而不是假换行。
 
 ### 可能出错的地方
 

+ 17 - 3
packages/code-runtime/code-runtime-python/src/index.ts

@@ -1504,9 +1504,23 @@ export class PythonCodeRuntime extends CodeRuntime {
               // An explicit flush of an unterminated line: hold it so the next
               // frame appends to the SAME entry (print('a', end='', flush=True)
               // followed by print('b') reads back as one 'ab' entry, not a fake
-              // newline). The residual is admitted by finish() if the run ends
-              // with it still open.
-              openLog = (openLog ?? '') + message.text
+              // newline). The held fragment is BOUNDED by the ledger budget via
+              // the exact-cost walk (a forged open flood would otherwise grow
+              // openLog without touching logBudget — the same unbounded-retention
+              // attack the ledger exists to stop). The cost is NOT billed here:
+              // the closing frame's admit() bills the whole merged entry once.
+              if (!logsTruncated) {
+                const merged = (openLog ?? '') + message.text
+                if (jsonStringCostUpTo(merged, logBudget - 1) === undefined) {
+                  logsTruncated = true
+                  logs.push(logTruncationMarker(this.config.maxLogBytes))
+                  clearStray(strayOut)
+                  clearStray(strayErr)
+                  openLog = undefined
+                } else {
+                  openLog = merged
+                }
+              }
               return
             }
             admit((openLog ?? '') + message.text)

+ 20 - 0
packages/code-runtime/code-runtime-python/tests/runtime.spec.ts

@@ -1878,6 +1878,26 @@ describe('PythonCodeRuntime — programs and bindings', () => {
     expect(result.logs).toEqual(['committed'])
   }, 15_000)
 
+  it('bounds a forged open-frame flood against the log budget', async () => {
+    // The open hold must be bounded by the ledger: without the exact-cost check
+    // a forged open flood would grow the held fragment without touching
+    // logBudget — unbounded host retention under a small budget. The flood now
+    // truncates to the marker like any over-budget log traffic.
+    const { runtime } = await setup({ maxLogBytes: 64 })
+    const result = await runtime.run({
+      program: [
+        'import os',
+        // 2000 forged open frames, each under the frame parse cap.
+        'for _ in range(2000):',
+        "    os.write(3, b'{\"type\":\"log\",\"text\":\"a\",\"open\":true}\\n')",
+        'return "done"',
+      ].join('\n'),
+      bindings: [],
+    })
+    expect(result.error).toBeUndefined()
+    expect(result.logs).toEqual([logTruncationMarker(64)])
+  }, 15_000)
+
   it('keeps a float completion exact when the program mutates the decimal context', async () => {
     // The float encoder's Decimal(repr(value)).normalize() used the process
     // GLOBAL decimal context: a legitimate program setting