Stop lowering hook handlers that call ctx.api.sudo() into bodies that cannot run it - #14044

Merged
hotlong merged 2 commits into
mainfrom
claude/issue-14010-hook-runas
Sep 1, 2026
Merged

Stop lowering hook handlers that call ctx.api.sudo() into bodies that cannot run it#14044
hotlong merged 2 commits into
mainfrom
claude/issue-14010-hook-runas

Conversation

@hotlong

Copy link
Copy Markdown
Contributor

Part of #14010 — this lands the implementation-side half only. The card's primary
ask (a declared elevation knob for hooks) is not addressed here and is left to
this repo's contract review chain; see "What this deliberately does not do".

The defect

ScopedContext.sudo() is real in-process and is not marshalled into the QuickJS
sandbox. The VM's ctx.api carries object() and the transaction surface, and
nothing else — now pinned exhaustively in quickjs-runner.test.ts, with a host
double that does carry sudo, so the pin proves the member stops at the VM
boundary rather than being missing upstream.

Every consumer of that fact had it backwards:

ConsumerClaimedActual
content/docs/automation/hook-bodies.mdx readonly tablectx.api.sudo().object('x').update(...)"Lands."TypeError
hook-api-update-readonly-field (severity error, gates the build)hint: "make the elevation explicit with ctx.api.sudo()"TypeError
hook-api-update-readonly-when-fieldhint: the own-hook stamp is not a workaround — "sudo() is"TypeError

Both lint rules read L2 (language:'js') body sources and nothing else
(extractHookBodyWriteSet parses hooks[i].body.source), so a gating rule was
prescribing a dead call to 100% of its own population.

The path that makes this expensive is the ordinary authoring path. An author writes
an inline handler, tests it the way the docs teach — hook.handler(ctx) called
natively, against the in-process ScopedContext, where sudo() exists — and the
suite is green. objectstack build then lowers that same source into an L2 body,
and production gets TypeError: ctx.api.sudo is not a function. Under a hook's
default onError: 'abort' that aborts the triggering write, so it surfaces as
an unrelated save being refused. Green tests, dead feature.

Measured here, and pinned: QuickJS reports it as TypeError: not a function
naming neither the member nor the receiver. Production's one diagnostic identifies
nothing, which is the argument for catching this at build time instead.

The fix

packages/cli.sudo( joins FORBIDDEN_PATTERNS in extractHookBody, at
the exact boundary where the two runtimes diverge. This is a repair, not only a
refusal: lowerCallables already registers the callable and ships it through the
.mjs bundle when extraction throws, so the handler keeps running in-process,
where sudo() is real
, and the build declines only the body that cannot run it.
The reason is printed; --strict-body, which demands a body for every callable,
makes it a hard failure — correctly, since a body needing elevation cannot be one.

Same family as the crypto.hash retirement (#4391), whose note sits three lines
below in the same file: a member advertised ahead of its implementation, where the
build-time inference was the amplifier rather than the safety net. That precedent
also settles what not to do here — crypto.hash was removed rather than
implemented because implementing it widened the sandbox's security surface, and
sudo is that case squarely.

packages/lint — both hints now name the own-hook stamp and say plainly that
sudo() is not reachable from a body. Findings, severities and exclusions are
unchanged; only the advice moved. Two existing assertions asserted
hint).toContain('sudo'), which still passes against the corrected text while
meaning the opposite — they now pin the direction, not the substring.

content/docs — the readonly table records what actually happens.

Verification

Union re-run at 0b51c61, the final commit:

packages/cli extract-hook-body 21 passed (21)
packages/lint readonly-hook-writes, readonly-action-writes,
hook-body-writes 109 passed (109)
packages/runtime quickjs-runner, script-runner 64 passed (64)

typecheck green for all three packages on a built closure (the closure matters:
before turbo run build, all three reported TS2307 piles for unbuilt workspace
dist/*.d.ts, which say nothing about this diff). check:type-check-debt
re-measured 29 ledger entries, 1531 raw errors, none above its recorded number
the gate that does see test files even where typecheck excludes them.

Repo gates run locally, all green: nul-bytes, corpus-claim-drift, doc-anchors,
doc-authoring, docs-single-h1, docs-redirects, docs-audit-scope, role-word,
changeset-gate-self-tests, objectui-changeset, pm-half-states,
cli-test-child-env, cross-package-test-inputs, test-source-alias,
skill-identifier-liveness, check-adr-0087-registration,
check-changeset-no-major.

Ablation — the new guard is proved load-bearing rather than assumed. With the
FORBIDDEN_PATTERNS entry deleted (mutation confirmed on disk: blob
6b76a59c to 8c1fe103, sudo mentions 7 to 4, surviving rx: anchors 0), exactly
the two new refusal tests go red and the other 19 stay green — so the pattern is
what refuses, and it has not widened into the majority case beside it. Restored by
git checkout HEAD -- ABSOLUTE_PATH, verified by git diff HEAD empty and the disk
blob back to the HEAD blob. No rebuild leg was needed: the test imports
../src/utils/extract-hook-body.js directly, so vitest reads source and no dist/
sits between the mutation and the measurement.

What this deliberately does not do

A hook still has no declared elevation knob — no hook-side runAs the way
FlowSchema has one — so "this column is computed by automation and never
hand-written" stays inexpressible whenever the maintaining write is cross-object.
That is a contract accept-surface change (HookSchema is strictObject and today
rejects runAs as unrecognized), and it is not a mechanical port of the flow key:
a hook's ctx.api is buildHookApi(opCtx.context), inheriting isSystem from
whatever triggered it, so FlowSchema's .default('user') would de-elevate
every hook fired by a system write and, under flow semantics, refuse a run with no
trigger user. The default alone is a real fork with a live regression in one branch.
Left to the review chain rather than guessed at; the options are written up on the
card.

Worth noting for whoever picks that up: the only elevation route that exists today
is the bundled in-process handler, and that route is on the L3 deprecation path
(--strict-body now, "Phase 3" later, per extract-hook-body.ts). So the knob is
not a convenience — it is the replacement for a capability scheduled to disappear.

This PR stays draft. It is filed from a downstream app's PM seat
(steedos-labs/hotcrm-heimao), which does not hold triage or merge authority here:
no domain:* label is set, and merge is this repo's call.


Generated by Claude Code

ScopedContext.sudo() is real in-process and is not marshalled into the
QuickJS sandbox, so a handler that calls it passes a native
hook.handler(ctx) test and TypeErrors once the build lowers it into a
body -- aborting the triggering write under the default onError:'abort'.
- cli: `.sudo(` joins FORBIDDEN_PATTERNS, so lowerCallables keeps the
callable bundled (in-process, where sudo() is real) instead of emitting
a body that cannot run it.
- lint: the two readonly-hook-write hints prescribed ctx.api.sudo() to a
population that is entirely L2 bodies; they now name the own-hook stamp
and say the elevation is not reachable from a body.
- runtime: pin the VM-side ctx.api surface exhaustively, with a host
double that DOES carry sudo, so the absence is a recorded fact.
- docs: the readonly table claimed the sudo() row lands.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
The sandbox error is `TypeError: not a function` -- it names neither the
member nor the receiver, so production's one diagnostic identifies nothing.
That blindness is the argument for refusing at build time, and the test now
records it rather than the message it would have been nicer to get.
Also make the CLI reverse-test's assertion quote-agnostic: the test file is
itself bundled, and esbuild rewrites the literal before String(fn) runs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 anchor(s) derived from 2 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 26 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 5e2c04da7db38c4db0b138fad8b9be5b4ef308fcpackageMentionDocs.

Which tree this was computed on

This run read content/docs from db2fdd66b4dbb0523687675ca59c07f1bb3dd929 — the merge of head 0b51c6139d822ab268860aa68511f4b5fc334147 into base 5e2c04da7db38c4db0b138fad8b9be5b4ef308fc, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin db2fdd66b4dbb0523687675ca59c07f1bb3dd929 && git checkout db2fdd66b4dbb0523687675ca59c07f1bb3dd929
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5e2c04da7db38c4db0b138fad8b9be5b4ef308fc 0b51c6139d822ab268860aa68511f4b5fc334147 && git checkout -B drift-repro 5e2c04da7db38c4db0b138fad8b9be5b4ef308fc && git merge --no-ff 0b51c6139d822ab268860aa68511f4b5fc334147
node scripts/docs-audit/affected-docs.mjs --json 5e2c04da7db38c4db0b138fad8b9be5b4ef308fc

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

CI 全绿,交接给本仓分诊席。 head 0b51c61 上 10 个工作流全部 success,含最慢的两条(Lint & Type Check 01:09、CI 01:15)。

保持 draft,我不转 ready 也不合。这是下游 steedos-labs/hotcrm-heimao 的 PM 席按维护者直接指令派发的,不是本仓 spec/CLI 车道的产出;落它由你们裁。

这是 #14010 的哪一半

半 B(实现面)。 主诉 —— 给钩子一个可声明的提权旋钮(runAsHookSchema)—— 不在本 PR 内,我已在 #14010 上把三条路的代价摆全并交回。那张卡上有两条对决策有用的事实,都是本次派发查出来的:

  1. 我原来要求「与 FlowSchema 同形」,照字面做会造成回归 —— 钩子的 ctx.apibuildHookApi(opCtx.context),继承触发者的 isSystem.default('user') 会去提权每一个由系统写入触发的钩子,而 runAs:'user' 却解析不出触发用户的运行其数据操作会被拒绝,那里面包括平台自己的内部钩子。
  2. 钩子今天唯一的提权路径(打包成进程内 handler)正在被废弃extract-hook-body.ts 头:现在 --strict-body,将来 Phase 3)。所以「先不做」不是推迟,是移除决定。

本 PR 的范围为什么比"沙箱侧"宽

派发词只点了沙箱侧,实际多改了 lint 与文档两处。理由写在正文里,这里复述一句:那两处是陷阱的来源 —— hook-api-update-readonly-field 是 severity error、卡构建的规则,它的提示在向 100% 的适用人群规定 ctx.api.sudo(),而那个调用在 body 里必然 TypeError;文档表格同一行标着 "Lands."。留着它们,构建拒绝与规则建议就自相矛盾。

修法遵循本仓已记录的先例(#4391crypto.hash:先于实现被广告出去的沙箱成员被移除而非实现,因为实现它会扩大沙箱的安全面),不是新发明的判断。

两处自我更正,值得评审时留意

head 上那个 follow-up commit 修的是测试自己的两个错,不是实现:

  1. 第一版断言期待错误信息里出现 sudo它不会 —— QuickJS 发的是 TypeError: not a function,既不点成员也不点接收者。所以生产环境唯一那条诊断什么都指认不出来 —— 而这恰恰是"应当在构建期拒绝"的论据,测试现在记录的是实际发生的事,不是希望收到的信息。
  2. CLI 那条反证测试的断言改成了不认引号:测试文件自身也会被打包,esbuild 在 String(fn) 跑之前就重写了字面量的引号风格。写死引号的断言会随打包器行为漂移。

无重叠

#14035#14011 契约散文)不重叠:那张只改 spec 散文加一个测试,本张改 CLI / lint / runtime / 文档。


Generated by Claude Code

@hotlong
hotlong marked this pull request as ready for review September 1, 2026 01:56
@hotlong
hotlong enabled auto-merge September 1, 2026 01:56
@hotlong
hotlong added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 8b04c75Sep 1, 2026
35 checks passed
@hotlong
hotlong deleted the claude/issue-14010-hook-runas branch September 1, 2026 02:20
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@hotlong@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Stop lowering hook handlers that call ctx.api.sudo() into bodies that cannot run it - #14044

Merged
hotlong merged 2 commits into
mainfrom
claude/issue-14010-hook-runas
Sep 1, 2026
Merged

Stop lowering hook handlers that call ctx.api.sudo() into bodies that cannot run it#14044
hotlong merged 2 commits into
mainfrom
claude/issue-14010-hook-runas

Conversation

@hotlong

Copy link
Copy Markdown
Contributor

Part of #14010 — this lands the implementation-side half only. The card's primary
ask (a declared elevation knob for hooks) is not addressed here and is left to
this repo's contract review chain; see "What this deliberately does not do".

The defect

ScopedContext.sudo() is real in-process and is not marshalled into the QuickJS
sandbox. The VM's ctx.api carries object() and the transaction surface, and
nothing else — now pinned exhaustively in quickjs-runner.test.ts, with a host
double that does carry sudo, so the pin proves the member stops at the VM
boundary rather than being missing upstream.

Every consumer of that fact had it backwards:

ConsumerClaimedActual
content/docs/automation/hook-bodies.mdx readonly tablectx.api.sudo().object('x').update(...)"Lands."TypeError
hook-api-update-readonly-field (severity error, gates the build)hint: "make the elevation explicit with ctx.api.sudo()"TypeError
hook-api-update-readonly-when-fieldhint: the own-hook stamp is not a workaround — "sudo() is"TypeError

Both lint rules read L2 (language:'js') body sources and nothing else
(extractHookBodyWriteSet parses hooks[i].body.source), so a gating rule was
prescribing a dead call to 100% of its own population.

The path that makes this expensive is the ordinary authoring path. An author writes
an inline handler, tests it the way the docs teach — hook.handler(ctx) called
natively, against the in-process ScopedContext, where sudo() exists — and the
suite is green. objectstack build then lowers that same source into an L2 body,
and production gets TypeError: ctx.api.sudo is not a function. Under a hook's
default onError: 'abort' that aborts the triggering write, so it surfaces as
an unrelated save being refused. Green tests, dead feature.

Measured here, and pinned: QuickJS reports it as TypeError: not a function
naming neither the member nor the receiver. Production's one diagnostic identifies
nothing, which is the argument for catching this at build time instead.

The fix

packages/cli.sudo( joins FORBIDDEN_PATTERNS in extractHookBody, at
the exact boundary where the two runtimes diverge. This is a repair, not only a
refusal: lowerCallables already registers the callable and ships it through the
.mjs bundle when extraction throws, so the handler keeps running in-process,
where sudo() is real
, and the build declines only the body that cannot run it.
The reason is printed; --strict-body, which demands a body for every callable,
makes it a hard failure — correctly, since a body needing elevation cannot be one.

Same family as the crypto.hash retirement (#4391), whose note sits three lines
below in the same file: a member advertised ahead of its implementation, where the
build-time inference was the amplifier rather than the safety net. That precedent
also settles what not to do here — crypto.hash was removed rather than
implemented because implementing it widened the sandbox's security surface, and
sudo is that case squarely.

packages/lint — both hints now name the own-hook stamp and say plainly that
sudo() is not reachable from a body. Findings, severities and exclusions are
unchanged; only the advice moved. Two existing assertions asserted
hint).toContain('sudo'), which still passes against the corrected text while
meaning the opposite — they now pin the direction, not the substring.

content/docs — the readonly table records what actually happens.

Verification

Union re-run at 0b51c61, the final commit:

packages/cli extract-hook-body 21 passed (21)
packages/lint readonly-hook-writes, readonly-action-writes,
hook-body-writes 109 passed (109)
packages/runtime quickjs-runner, script-runner 64 passed (64)

typecheck green for all three packages on a built closure (the closure matters:
before turbo run build, all three reported TS2307 piles for unbuilt workspace
dist/*.d.ts, which say nothing about this diff). check:type-check-debt
re-measured 29 ledger entries, 1531 raw errors, none above its recorded number
the gate that does see test files even where typecheck excludes them.

Repo gates run locally, all green: nul-bytes, corpus-claim-drift, doc-anchors,
doc-authoring, docs-single-h1, docs-redirects, docs-audit-scope, role-word,
changeset-gate-self-tests, objectui-changeset, pm-half-states,
cli-test-child-env, cross-package-test-inputs, test-source-alias,
skill-identifier-liveness, check-adr-0087-registration,
check-changeset-no-major.

Ablation — the new guard is proved load-bearing rather than assumed. With the
FORBIDDEN_PATTERNS entry deleted (mutation confirmed on disk: blob
6b76a59c to 8c1fe103, sudo mentions 7 to 4, surviving rx: anchors 0), exactly
the two new refusal tests go red and the other 19 stay green — so the pattern is
what refuses, and it has not widened into the majority case beside it. Restored by
git checkout HEAD -- ABSOLUTE_PATH, verified by git diff HEAD empty and the disk
blob back to the HEAD blob. No rebuild leg was needed: the test imports
../src/utils/extract-hook-body.js directly, so vitest reads source and no dist/
sits between the mutation and the measurement.

What this deliberately does not do

A hook still has no declared elevation knob — no hook-side runAs the way
FlowSchema has one — so "this column is computed by automation and never
hand-written" stays inexpressible whenever the maintaining write is cross-object.
That is a contract accept-surface change (HookSchema is strictObject and today
rejects runAs as unrecognized), and it is not a mechanical port of the flow key:
a hook's ctx.api is buildHookApi(opCtx.context), inheriting isSystem from
whatever triggered it, so FlowSchema's .default('user') would de-elevate
every hook fired by a system write and, under flow semantics, refuse a run with no
trigger user. The default alone is a real fork with a live regression in one branch.
Left to the review chain rather than guessed at; the options are written up on the
card.

Worth noting for whoever picks that up: the only elevation route that exists today
is the bundled in-process handler, and that route is on the L3 deprecation path
(--strict-body now, "Phase 3" later, per extract-hook-body.ts). So the knob is
not a convenience — it is the replacement for a capability scheduled to disappear.

This PR stays draft. It is filed from a downstream app's PM seat
(steedos-labs/hotcrm-heimao), which does not hold triage or merge authority here:
no domain:* label is set, and merge is this repo's call.


Generated by Claude Code

ScopedContext.sudo() is real in-process and is not marshalled into the
QuickJS sandbox, so a handler that calls it passes a native
hook.handler(ctx) test and TypeErrors once the build lowers it into a
body -- aborting the triggering write under the default onError:'abort'.
- cli: `.sudo(` joins FORBIDDEN_PATTERNS, so lowerCallables keeps the
callable bundled (in-process, where sudo() is real) instead of emitting
a body that cannot run it.
- lint: the two readonly-hook-write hints prescribed ctx.api.sudo() to a
population that is entirely L2 bodies; they now name the own-hook stamp
and say the elevation is not reachable from a body.
- runtime: pin the VM-side ctx.api surface exhaustively, with a host
double that DOES carry sudo, so the absence is a recorded fact.
- docs: the readonly table claimed the sudo() row lands.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
The sandbox error is `TypeError: not a function` -- it names neither the
member nor the receiver, so production's one diagnostic identifies nothing.
That blindness is the argument for refusing at build time, and the test now
records it rather than the message it would have been nicer to get.
Also make the CLI reverse-test's assertion quote-agnostic: the test file is
itself bundled, and esbuild rewrites the literal before String(fn) runs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 anchor(s) derived from 2 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 26 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 5e2c04da7db38c4db0b138fad8b9be5b4ef308fcpackageMentionDocs.

Which tree this was computed on

This run read content/docs from db2fdd66b4dbb0523687675ca59c07f1bb3dd929 — the merge of head 0b51c6139d822ab268860aa68511f4b5fc334147 into base 5e2c04da7db38c4db0b138fad8b9be5b4ef308fc, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin db2fdd66b4dbb0523687675ca59c07f1bb3dd929 && git checkout db2fdd66b4dbb0523687675ca59c07f1bb3dd929
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5e2c04da7db38c4db0b138fad8b9be5b4ef308fc 0b51c6139d822ab268860aa68511f4b5fc334147 && git checkout -B drift-repro 5e2c04da7db38c4db0b138fad8b9be5b4ef308fc && git merge --no-ff 0b51c6139d822ab268860aa68511f4b5fc334147
node scripts/docs-audit/affected-docs.mjs --json 5e2c04da7db38c4db0b138fad8b9be5b4ef308fc

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

CI 全绿,交接给本仓分诊席。 head 0b51c61 上 10 个工作流全部 success,含最慢的两条(Lint & Type Check 01:09、CI 01:15)。

保持 draft,我不转 ready 也不合。这是下游 steedos-labs/hotcrm-heimao 的 PM 席按维护者直接指令派发的,不是本仓 spec/CLI 车道的产出;落它由你们裁。

这是 #14010 的哪一半

半 B(实现面)。 主诉 —— 给钩子一个可声明的提权旋钮(runAsHookSchema)—— 不在本 PR 内,我已在 #14010 上把三条路的代价摆全并交回。那张卡上有两条对决策有用的事实,都是本次派发查出来的:

  1. 我原来要求「与 FlowSchema 同形」,照字面做会造成回归 —— 钩子的 ctx.apibuildHookApi(opCtx.context),继承触发者的 isSystem.default('user') 会去提权每一个由系统写入触发的钩子,而 runAs:'user' 却解析不出触发用户的运行其数据操作会被拒绝,那里面包括平台自己的内部钩子。
  2. 钩子今天唯一的提权路径(打包成进程内 handler)正在被废弃extract-hook-body.ts 头:现在 --strict-body,将来 Phase 3)。所以「先不做」不是推迟,是移除决定。

本 PR 的范围为什么比"沙箱侧"宽

派发词只点了沙箱侧,实际多改了 lint 与文档两处。理由写在正文里,这里复述一句:那两处是陷阱的来源 —— hook-api-update-readonly-field 是 severity error、卡构建的规则,它的提示在向 100% 的适用人群规定 ctx.api.sudo(),而那个调用在 body 里必然 TypeError;文档表格同一行标着 "Lands."。留着它们,构建拒绝与规则建议就自相矛盾。

修法遵循本仓已记录的先例(#4391crypto.hash:先于实现被广告出去的沙箱成员被移除而非实现,因为实现它会扩大沙箱的安全面),不是新发明的判断。

两处自我更正,值得评审时留意

head 上那个 follow-up commit 修的是测试自己的两个错,不是实现:

  1. 第一版断言期待错误信息里出现 sudo它不会 —— QuickJS 发的是 TypeError: not a function,既不点成员也不点接收者。所以生产环境唯一那条诊断什么都指认不出来 —— 而这恰恰是"应当在构建期拒绝"的论据,测试现在记录的是实际发生的事,不是希望收到的信息。
  2. CLI 那条反证测试的断言改成了不认引号:测试文件自身也会被打包,esbuild 在 String(fn) 跑之前就重写了字面量的引号风格。写死引号的断言会随打包器行为漂移。

无重叠

#14035#14011 契约散文)不重叠:那张只改 spec 散文加一个测试,本张改 CLI / lint / runtime / 文档。


Generated by Claude Code

@hotlong
hotlong marked this pull request as ready for review September 1, 2026 01:56
@hotlong
hotlong enabled auto-merge September 1, 2026 01:56
@hotlong
hotlong added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 8b04c75Sep 1, 2026
35 checks passed
@hotlong
hotlong deleted the claude/issue-14010-hook-runas branch September 1, 2026 02:20
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@hotlong@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Stop lowering hook handlers that call ctx.api.sudo() into bodies that cannot run it - #14044

Merged
hotlong merged 2 commits into
mainfrom
claude/issue-14010-hook-runas
Sep 1, 2026
Merged

Stop lowering hook handlers that call ctx.api.sudo() into bodies that cannot run it#14044
hotlong merged 2 commits into
mainfrom
claude/issue-14010-hook-runas

Conversation

@hotlong

Copy link
Copy Markdown
Contributor

Part of #14010 — this lands the implementation-side half only. The card's primary
ask (a declared elevation knob for hooks) is not addressed here and is left to
this repo's contract review chain; see "What this deliberately does not do".

The defect

ScopedContext.sudo() is real in-process and is not marshalled into the QuickJS
sandbox. The VM's ctx.api carries object() and the transaction surface, and
nothing else — now pinned exhaustively in quickjs-runner.test.ts, with a host
double that does carry sudo, so the pin proves the member stops at the VM
boundary rather than being missing upstream.

Every consumer of that fact had it backwards:

ConsumerClaimedActual
content/docs/automation/hook-bodies.mdx readonly tablectx.api.sudo().object('x').update(...)"Lands."TypeError
hook-api-update-readonly-field (severity error, gates the build)hint: "make the elevation explicit with ctx.api.sudo()"TypeError
hook-api-update-readonly-when-fieldhint: the own-hook stamp is not a workaround — "sudo() is"TypeError

Both lint rules read L2 (language:'js') body sources and nothing else
(extractHookBodyWriteSet parses hooks[i].body.source), so a gating rule was
prescribing a dead call to 100% of its own population.

The path that makes this expensive is the ordinary authoring path. An author writes
an inline handler, tests it the way the docs teach — hook.handler(ctx) called
natively, against the in-process ScopedContext, where sudo() exists — and the
suite is green. objectstack build then lowers that same source into an L2 body,
and production gets TypeError: ctx.api.sudo is not a function. Under a hook's
default onError: 'abort' that aborts the triggering write, so it surfaces as
an unrelated save being refused. Green tests, dead feature.

Measured here, and pinned: QuickJS reports it as TypeError: not a function
naming neither the member nor the receiver. Production's one diagnostic identifies
nothing, which is the argument for catching this at build time instead.

The fix

packages/cli.sudo( joins FORBIDDEN_PATTERNS in extractHookBody, at
the exact boundary where the two runtimes diverge. This is a repair, not only a
refusal: lowerCallables already registers the callable and ships it through the
.mjs bundle when extraction throws, so the handler keeps running in-process,
where sudo() is real
, and the build declines only the body that cannot run it.
The reason is printed; --strict-body, which demands a body for every callable,
makes it a hard failure — correctly, since a body needing elevation cannot be one.

Same family as the crypto.hash retirement (#4391), whose note sits three lines
below in the same file: a member advertised ahead of its implementation, where the
build-time inference was the amplifier rather than the safety net. That precedent
also settles what not to do here — crypto.hash was removed rather than
implemented because implementing it widened the sandbox's security surface, and
sudo is that case squarely.

packages/lint — both hints now name the own-hook stamp and say plainly that
sudo() is not reachable from a body. Findings, severities and exclusions are
unchanged; only the advice moved. Two existing assertions asserted
hint).toContain('sudo'), which still passes against the corrected text while
meaning the opposite — they now pin the direction, not the substring.

content/docs — the readonly table records what actually happens.

Verification

Union re-run at 0b51c61, the final commit:

packages/cli extract-hook-body 21 passed (21)
packages/lint readonly-hook-writes, readonly-action-writes,
hook-body-writes 109 passed (109)
packages/runtime quickjs-runner, script-runner 64 passed (64)

typecheck green for all three packages on a built closure (the closure matters:
before turbo run build, all three reported TS2307 piles for unbuilt workspace
dist/*.d.ts, which say nothing about this diff). check:type-check-debt
re-measured 29 ledger entries, 1531 raw errors, none above its recorded number
the gate that does see test files even where typecheck excludes them.

Repo gates run locally, all green: nul-bytes, corpus-claim-drift, doc-anchors,
doc-authoring, docs-single-h1, docs-redirects, docs-audit-scope, role-word,
changeset-gate-self-tests, objectui-changeset, pm-half-states,
cli-test-child-env, cross-package-test-inputs, test-source-alias,
skill-identifier-liveness, check-adr-0087-registration,
check-changeset-no-major.

Ablation — the new guard is proved load-bearing rather than assumed. With the
FORBIDDEN_PATTERNS entry deleted (mutation confirmed on disk: blob
6b76a59c to 8c1fe103, sudo mentions 7 to 4, surviving rx: anchors 0), exactly
the two new refusal tests go red and the other 19 stay green — so the pattern is
what refuses, and it has not widened into the majority case beside it. Restored by
git checkout HEAD -- ABSOLUTE_PATH, verified by git diff HEAD empty and the disk
blob back to the HEAD blob. No rebuild leg was needed: the test imports
../src/utils/extract-hook-body.js directly, so vitest reads source and no dist/
sits between the mutation and the measurement.

What this deliberately does not do

A hook still has no declared elevation knob — no hook-side runAs the way
FlowSchema has one — so "this column is computed by automation and never
hand-written" stays inexpressible whenever the maintaining write is cross-object.
That is a contract accept-surface change (HookSchema is strictObject and today
rejects runAs as unrecognized), and it is not a mechanical port of the flow key:
a hook's ctx.api is buildHookApi(opCtx.context), inheriting isSystem from
whatever triggered it, so FlowSchema's .default('user') would de-elevate
every hook fired by a system write and, under flow semantics, refuse a run with no
trigger user. The default alone is a real fork with a live regression in one branch.
Left to the review chain rather than guessed at; the options are written up on the
card.

Worth noting for whoever picks that up: the only elevation route that exists today
is the bundled in-process handler, and that route is on the L3 deprecation path
(--strict-body now, "Phase 3" later, per extract-hook-body.ts). So the knob is
not a convenience — it is the replacement for a capability scheduled to disappear.

This PR stays draft. It is filed from a downstream app's PM seat
(steedos-labs/hotcrm-heimao), which does not hold triage or merge authority here:
no domain:* label is set, and merge is this repo's call.


Generated by Claude Code

ScopedContext.sudo() is real in-process and is not marshalled into the
QuickJS sandbox, so a handler that calls it passes a native
hook.handler(ctx) test and TypeErrors once the build lowers it into a
body -- aborting the triggering write under the default onError:'abort'.
- cli: `.sudo(` joins FORBIDDEN_PATTERNS, so lowerCallables keeps the
callable bundled (in-process, where sudo() is real) instead of emitting
a body that cannot run it.
- lint: the two readonly-hook-write hints prescribed ctx.api.sudo() to a
population that is entirely L2 bodies; they now name the own-hook stamp
and say the elevation is not reachable from a body.
- runtime: pin the VM-side ctx.api surface exhaustively, with a host
double that DOES carry sudo, so the absence is a recorded fact.
- docs: the readonly table claimed the sudo() row lands.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
The sandbox error is `TypeError: not a function` -- it names neither the
member nor the receiver, so production's one diagnostic identifies nothing.
That blindness is the argument for refusing at build time, and the test now
records it rather than the message it would have been nicer to get.
Also make the CLI reverse-test's assertion quote-agnostic: the test file is
itself bundled, and esbuild rewrites the literal before String(fn) runs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 anchor(s) derived from 2 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 26 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 5e2c04da7db38c4db0b138fad8b9be5b4ef308fcpackageMentionDocs.

Which tree this was computed on

This run read content/docs from db2fdd66b4dbb0523687675ca59c07f1bb3dd929 — the merge of head 0b51c6139d822ab268860aa68511f4b5fc334147 into base 5e2c04da7db38c4db0b138fad8b9be5b4ef308fc, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin db2fdd66b4dbb0523687675ca59c07f1bb3dd929 && git checkout db2fdd66b4dbb0523687675ca59c07f1bb3dd929
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5e2c04da7db38c4db0b138fad8b9be5b4ef308fc 0b51c6139d822ab268860aa68511f4b5fc334147 && git checkout -B drift-repro 5e2c04da7db38c4db0b138fad8b9be5b4ef308fc && git merge --no-ff 0b51c6139d822ab268860aa68511f4b5fc334147
node scripts/docs-audit/affected-docs.mjs --json 5e2c04da7db38c4db0b138fad8b9be5b4ef308fc

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

CI 全绿,交接给本仓分诊席。 head 0b51c61 上 10 个工作流全部 success,含最慢的两条(Lint & Type Check 01:09、CI 01:15)。

保持 draft,我不转 ready 也不合。这是下游 steedos-labs/hotcrm-heimao 的 PM 席按维护者直接指令派发的,不是本仓 spec/CLI 车道的产出;落它由你们裁。

这是 #14010 的哪一半

半 B(实现面)。 主诉 —— 给钩子一个可声明的提权旋钮(runAsHookSchema)—— 不在本 PR 内,我已在 #14010 上把三条路的代价摆全并交回。那张卡上有两条对决策有用的事实,都是本次派发查出来的:

  1. 我原来要求「与 FlowSchema 同形」,照字面做会造成回归 —— 钩子的 ctx.apibuildHookApi(opCtx.context),继承触发者的 isSystem.default('user') 会去提权每一个由系统写入触发的钩子,而 runAs:'user' 却解析不出触发用户的运行其数据操作会被拒绝,那里面包括平台自己的内部钩子。
  2. 钩子今天唯一的提权路径(打包成进程内 handler)正在被废弃extract-hook-body.ts 头:现在 --strict-body,将来 Phase 3)。所以「先不做」不是推迟,是移除决定。

本 PR 的范围为什么比"沙箱侧"宽

派发词只点了沙箱侧,实际多改了 lint 与文档两处。理由写在正文里,这里复述一句:那两处是陷阱的来源 —— hook-api-update-readonly-field 是 severity error、卡构建的规则,它的提示在向 100% 的适用人群规定 ctx.api.sudo(),而那个调用在 body 里必然 TypeError;文档表格同一行标着 "Lands."。留着它们,构建拒绝与规则建议就自相矛盾。

修法遵循本仓已记录的先例(#4391crypto.hash:先于实现被广告出去的沙箱成员被移除而非实现,因为实现它会扩大沙箱的安全面),不是新发明的判断。

两处自我更正,值得评审时留意

head 上那个 follow-up commit 修的是测试自己的两个错,不是实现:

  1. 第一版断言期待错误信息里出现 sudo它不会 —— QuickJS 发的是 TypeError: not a function,既不点成员也不点接收者。所以生产环境唯一那条诊断什么都指认不出来 —— 而这恰恰是"应当在构建期拒绝"的论据,测试现在记录的是实际发生的事,不是希望收到的信息。
  2. CLI 那条反证测试的断言改成了不认引号:测试文件自身也会被打包,esbuild 在 String(fn) 跑之前就重写了字面量的引号风格。写死引号的断言会随打包器行为漂移。

无重叠

#14035#14011 契约散文)不重叠:那张只改 spec 散文加一个测试,本张改 CLI / lint / runtime / 文档。


Generated by Claude Code

@hotlong
hotlong marked this pull request as ready for review September 1, 2026 01:56
@hotlong
hotlong enabled auto-merge September 1, 2026 01:56
@hotlong
hotlong added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 8b04c75Sep 1, 2026
35 checks passed
@hotlong
hotlong deleted the claude/issue-14010-hook-runas branch September 1, 2026 02:20
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@hotlong@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Stop lowering hook handlers that call ctx.api.sudo() into bodies that cannot run it - #14044

Merged
hotlong merged 2 commits into
mainfrom
claude/issue-14010-hook-runas
Sep 1, 2026
Merged

Stop lowering hook handlers that call ctx.api.sudo() into bodies that cannot run it#14044
hotlong merged 2 commits into
mainfrom
claude/issue-14010-hook-runas

Conversation

@hotlong

Copy link
Copy Markdown
Contributor

Part of #14010 — this lands the implementation-side half only. The card's primary
ask (a declared elevation knob for hooks) is not addressed here and is left to
this repo's contract review chain; see "What this deliberately does not do".

The defect

ScopedContext.sudo() is real in-process and is not marshalled into the QuickJS
sandbox. The VM's ctx.api carries object() and the transaction surface, and
nothing else — now pinned exhaustively in quickjs-runner.test.ts, with a host
double that does carry sudo, so the pin proves the member stops at the VM
boundary rather than being missing upstream.

Every consumer of that fact had it backwards:

ConsumerClaimedActual
content/docs/automation/hook-bodies.mdx readonly tablectx.api.sudo().object('x').update(...)"Lands."TypeError
hook-api-update-readonly-field (severity error, gates the build)hint: "make the elevation explicit with ctx.api.sudo()"TypeError
hook-api-update-readonly-when-fieldhint: the own-hook stamp is not a workaround — "sudo() is"TypeError

Both lint rules read L2 (language:'js') body sources and nothing else
(extractHookBodyWriteSet parses hooks[i].body.source), so a gating rule was
prescribing a dead call to 100% of its own population.

The path that makes this expensive is the ordinary authoring path. An author writes
an inline handler, tests it the way the docs teach — hook.handler(ctx) called
natively, against the in-process ScopedContext, where sudo() exists — and the
suite is green. objectstack build then lowers that same source into an L2 body,
and production gets TypeError: ctx.api.sudo is not a function. Under a hook's
default onError: 'abort' that aborts the triggering write, so it surfaces as
an unrelated save being refused. Green tests, dead feature.

Measured here, and pinned: QuickJS reports it as TypeError: not a function
naming neither the member nor the receiver. Production's one diagnostic identifies
nothing, which is the argument for catching this at build time instead.

The fix

packages/cli.sudo( joins FORBIDDEN_PATTERNS in extractHookBody, at
the exact boundary where the two runtimes diverge. This is a repair, not only a
refusal: lowerCallables already registers the callable and ships it through the
.mjs bundle when extraction throws, so the handler keeps running in-process,
where sudo() is real
, and the build declines only the body that cannot run it.
The reason is printed; --strict-body, which demands a body for every callable,
makes it a hard failure — correctly, since a body needing elevation cannot be one.

Same family as the crypto.hash retirement (#4391), whose note sits three lines
below in the same file: a member advertised ahead of its implementation, where the
build-time inference was the amplifier rather than the safety net. That precedent
also settles what not to do here — crypto.hash was removed rather than
implemented because implementing it widened the sandbox's security surface, and
sudo is that case squarely.

packages/lint — both hints now name the own-hook stamp and say plainly that
sudo() is not reachable from a body. Findings, severities and exclusions are
unchanged; only the advice moved. Two existing assertions asserted
hint).toContain('sudo'), which still passes against the corrected text while
meaning the opposite — they now pin the direction, not the substring.

content/docs — the readonly table records what actually happens.

Verification

Union re-run at 0b51c61, the final commit:

packages/cli extract-hook-body 21 passed (21)
packages/lint readonly-hook-writes, readonly-action-writes,
hook-body-writes 109 passed (109)
packages/runtime quickjs-runner, script-runner 64 passed (64)

typecheck green for all three packages on a built closure (the closure matters:
before turbo run build, all three reported TS2307 piles for unbuilt workspace
dist/*.d.ts, which say nothing about this diff). check:type-check-debt
re-measured 29 ledger entries, 1531 raw errors, none above its recorded number
the gate that does see test files even where typecheck excludes them.

Repo gates run locally, all green: nul-bytes, corpus-claim-drift, doc-anchors,
doc-authoring, docs-single-h1, docs-redirects, docs-audit-scope, role-word,
changeset-gate-self-tests, objectui-changeset, pm-half-states,
cli-test-child-env, cross-package-test-inputs, test-source-alias,
skill-identifier-liveness, check-adr-0087-registration,
check-changeset-no-major.

Ablation — the new guard is proved load-bearing rather than assumed. With the
FORBIDDEN_PATTERNS entry deleted (mutation confirmed on disk: blob
6b76a59c to 8c1fe103, sudo mentions 7 to 4, surviving rx: anchors 0), exactly
the two new refusal tests go red and the other 19 stay green — so the pattern is
what refuses, and it has not widened into the majority case beside it. Restored by
git checkout HEAD -- ABSOLUTE_PATH, verified by git diff HEAD empty and the disk
blob back to the HEAD blob. No rebuild leg was needed: the test imports
../src/utils/extract-hook-body.js directly, so vitest reads source and no dist/
sits between the mutation and the measurement.

What this deliberately does not do

A hook still has no declared elevation knob — no hook-side runAs the way
FlowSchema has one — so "this column is computed by automation and never
hand-written" stays inexpressible whenever the maintaining write is cross-object.
That is a contract accept-surface change (HookSchema is strictObject and today
rejects runAs as unrecognized), and it is not a mechanical port of the flow key:
a hook's ctx.api is buildHookApi(opCtx.context), inheriting isSystem from
whatever triggered it, so FlowSchema's .default('user') would de-elevate
every hook fired by a system write and, under flow semantics, refuse a run with no
trigger user. The default alone is a real fork with a live regression in one branch.
Left to the review chain rather than guessed at; the options are written up on the
card.

Worth noting for whoever picks that up: the only elevation route that exists today
is the bundled in-process handler, and that route is on the L3 deprecation path
(--strict-body now, "Phase 3" later, per extract-hook-body.ts). So the knob is
not a convenience — it is the replacement for a capability scheduled to disappear.

This PR stays draft. It is filed from a downstream app's PM seat
(steedos-labs/hotcrm-heimao), which does not hold triage or merge authority here:
no domain:* label is set, and merge is this repo's call.


Generated by Claude Code

ScopedContext.sudo() is real in-process and is not marshalled into the
QuickJS sandbox, so a handler that calls it passes a native
hook.handler(ctx) test and TypeErrors once the build lowers it into a
body -- aborting the triggering write under the default onError:'abort'.
- cli: `.sudo(` joins FORBIDDEN_PATTERNS, so lowerCallables keeps the
callable bundled (in-process, where sudo() is real) instead of emitting
a body that cannot run it.
- lint: the two readonly-hook-write hints prescribed ctx.api.sudo() to a
population that is entirely L2 bodies; they now name the own-hook stamp
and say the elevation is not reachable from a body.
- runtime: pin the VM-side ctx.api surface exhaustively, with a host
double that DOES carry sudo, so the absence is a recorded fact.
- docs: the readonly table claimed the sudo() row lands.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
The sandbox error is `TypeError: not a function` -- it names neither the
member nor the receiver, so production's one diagnostic identifies nothing.
That blindness is the argument for refusing at build time, and the test now
records it rather than the message it would have been nicer to get.
Also make the CLI reverse-test's assertion quote-agnostic: the test file is
itself bundled, and esbuild rewrites the literal before String(fn) runs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 anchor(s) derived from 2 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 26 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 5e2c04da7db38c4db0b138fad8b9be5b4ef308fcpackageMentionDocs.

Which tree this was computed on

This run read content/docs from db2fdd66b4dbb0523687675ca59c07f1bb3dd929 — the merge of head 0b51c6139d822ab268860aa68511f4b5fc334147 into base 5e2c04da7db38c4db0b138fad8b9be5b4ef308fc, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin db2fdd66b4dbb0523687675ca59c07f1bb3dd929 && git checkout db2fdd66b4dbb0523687675ca59c07f1bb3dd929
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5e2c04da7db38c4db0b138fad8b9be5b4ef308fc 0b51c6139d822ab268860aa68511f4b5fc334147 && git checkout -B drift-repro 5e2c04da7db38c4db0b138fad8b9be5b4ef308fc && git merge --no-ff 0b51c6139d822ab268860aa68511f4b5fc334147
node scripts/docs-audit/affected-docs.mjs --json 5e2c04da7db38c4db0b138fad8b9be5b4ef308fc

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

CI 全绿,交接给本仓分诊席。 head 0b51c61 上 10 个工作流全部 success,含最慢的两条(Lint & Type Check 01:09、CI 01:15)。

保持 draft,我不转 ready 也不合。这是下游 steedos-labs/hotcrm-heimao 的 PM 席按维护者直接指令派发的,不是本仓 spec/CLI 车道的产出;落它由你们裁。

这是 #14010 的哪一半

半 B(实现面)。 主诉 —— 给钩子一个可声明的提权旋钮(runAsHookSchema)—— 不在本 PR 内,我已在 #14010 上把三条路的代价摆全并交回。那张卡上有两条对决策有用的事实,都是本次派发查出来的:

  1. 我原来要求「与 FlowSchema 同形」,照字面做会造成回归 —— 钩子的 ctx.apibuildHookApi(opCtx.context),继承触发者的 isSystem.default('user') 会去提权每一个由系统写入触发的钩子,而 runAs:'user' 却解析不出触发用户的运行其数据操作会被拒绝,那里面包括平台自己的内部钩子。
  2. 钩子今天唯一的提权路径(打包成进程内 handler)正在被废弃extract-hook-body.ts 头:现在 --strict-body,将来 Phase 3)。所以「先不做」不是推迟,是移除决定。

本 PR 的范围为什么比"沙箱侧"宽

派发词只点了沙箱侧,实际多改了 lint 与文档两处。理由写在正文里,这里复述一句:那两处是陷阱的来源 —— hook-api-update-readonly-field 是 severity error、卡构建的规则,它的提示在向 100% 的适用人群规定 ctx.api.sudo(),而那个调用在 body 里必然 TypeError;文档表格同一行标着 "Lands."。留着它们,构建拒绝与规则建议就自相矛盾。

修法遵循本仓已记录的先例(#4391crypto.hash:先于实现被广告出去的沙箱成员被移除而非实现,因为实现它会扩大沙箱的安全面),不是新发明的判断。

两处自我更正,值得评审时留意

head 上那个 follow-up commit 修的是测试自己的两个错,不是实现:

  1. 第一版断言期待错误信息里出现 sudo它不会 —— QuickJS 发的是 TypeError: not a function,既不点成员也不点接收者。所以生产环境唯一那条诊断什么都指认不出来 —— 而这恰恰是"应当在构建期拒绝"的论据,测试现在记录的是实际发生的事,不是希望收到的信息。
  2. CLI 那条反证测试的断言改成了不认引号:测试文件自身也会被打包,esbuild 在 String(fn) 跑之前就重写了字面量的引号风格。写死引号的断言会随打包器行为漂移。

无重叠

#14035#14011 契约散文)不重叠:那张只改 spec 散文加一个测试,本张改 CLI / lint / runtime / 文档。


Generated by Claude Code

@hotlong
hotlong marked this pull request as ready for review September 1, 2026 01:56
@hotlong
hotlong enabled auto-merge September 1, 2026 01:56
@hotlong
hotlong added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 8b04c75Sep 1, 2026
35 checks passed
@hotlong
hotlong deleted the claude/issue-14010-hook-runas branch September 1, 2026 02:20
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@hotlong@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Stop lowering hook handlers that call ctx.api.sudo() into bodies that cannot run it - #14044

Merged
hotlong merged 2 commits into
mainfrom
claude/issue-14010-hook-runas
Sep 1, 2026
Merged

Stop lowering hook handlers that call ctx.api.sudo() into bodies that cannot run it#14044
hotlong merged 2 commits into
mainfrom
claude/issue-14010-hook-runas

Conversation

@hotlong

Copy link
Copy Markdown
Contributor

Part of #14010 — this lands the implementation-side half only. The card's primary
ask (a declared elevation knob for hooks) is not addressed here and is left to
this repo's contract review chain; see "What this deliberately does not do".

The defect

ScopedContext.sudo() is real in-process and is not marshalled into the QuickJS
sandbox. The VM's ctx.api carries object() and the transaction surface, and
nothing else — now pinned exhaustively in quickjs-runner.test.ts, with a host
double that does carry sudo, so the pin proves the member stops at the VM
boundary rather than being missing upstream.

Every consumer of that fact had it backwards:

ConsumerClaimedActual
content/docs/automation/hook-bodies.mdx readonly tablectx.api.sudo().object('x').update(...)"Lands."TypeError
hook-api-update-readonly-field (severity error, gates the build)hint: "make the elevation explicit with ctx.api.sudo()"TypeError
hook-api-update-readonly-when-fieldhint: the own-hook stamp is not a workaround — "sudo() is"TypeError

Both lint rules read L2 (language:'js') body sources and nothing else
(extractHookBodyWriteSet parses hooks[i].body.source), so a gating rule was
prescribing a dead call to 100% of its own population.

The path that makes this expensive is the ordinary authoring path. An author writes
an inline handler, tests it the way the docs teach — hook.handler(ctx) called
natively, against the in-process ScopedContext, where sudo() exists — and the
suite is green. objectstack build then lowers that same source into an L2 body,
and production gets TypeError: ctx.api.sudo is not a function. Under a hook's
default onError: 'abort' that aborts the triggering write, so it surfaces as
an unrelated save being refused. Green tests, dead feature.

Measured here, and pinned: QuickJS reports it as TypeError: not a function
naming neither the member nor the receiver. Production's one diagnostic identifies
nothing, which is the argument for catching this at build time instead.

The fix

packages/cli.sudo( joins FORBIDDEN_PATTERNS in extractHookBody, at
the exact boundary where the two runtimes diverge. This is a repair, not only a
refusal: lowerCallables already registers the callable and ships it through the
.mjs bundle when extraction throws, so the handler keeps running in-process,
where sudo() is real
, and the build declines only the body that cannot run it.
The reason is printed; --strict-body, which demands a body for every callable,
makes it a hard failure — correctly, since a body needing elevation cannot be one.

Same family as the crypto.hash retirement (#4391), whose note sits three lines
below in the same file: a member advertised ahead of its implementation, where the
build-time inference was the amplifier rather than the safety net. That precedent
also settles what not to do here — crypto.hash was removed rather than
implemented because implementing it widened the sandbox's security surface, and
sudo is that case squarely.

packages/lint — both hints now name the own-hook stamp and say plainly that
sudo() is not reachable from a body. Findings, severities and exclusions are
unchanged; only the advice moved. Two existing assertions asserted
hint).toContain('sudo'), which still passes against the corrected text while
meaning the opposite — they now pin the direction, not the substring.

content/docs — the readonly table records what actually happens.

Verification

Union re-run at 0b51c61, the final commit:

packages/cli extract-hook-body 21 passed (21)
packages/lint readonly-hook-writes, readonly-action-writes,
hook-body-writes 109 passed (109)
packages/runtime quickjs-runner, script-runner 64 passed (64)

typecheck green for all three packages on a built closure (the closure matters:
before turbo run build, all three reported TS2307 piles for unbuilt workspace
dist/*.d.ts, which say nothing about this diff). check:type-check-debt
re-measured 29 ledger entries, 1531 raw errors, none above its recorded number
the gate that does see test files even where typecheck excludes them.

Repo gates run locally, all green: nul-bytes, corpus-claim-drift, doc-anchors,
doc-authoring, docs-single-h1, docs-redirects, docs-audit-scope, role-word,
changeset-gate-self-tests, objectui-changeset, pm-half-states,
cli-test-child-env, cross-package-test-inputs, test-source-alias,
skill-identifier-liveness, check-adr-0087-registration,
check-changeset-no-major.

Ablation — the new guard is proved load-bearing rather than assumed. With the
FORBIDDEN_PATTERNS entry deleted (mutation confirmed on disk: blob
6b76a59c to 8c1fe103, sudo mentions 7 to 4, surviving rx: anchors 0), exactly
the two new refusal tests go red and the other 19 stay green — so the pattern is
what refuses, and it has not widened into the majority case beside it. Restored by
git checkout HEAD -- ABSOLUTE_PATH, verified by git diff HEAD empty and the disk
blob back to the HEAD blob. No rebuild leg was needed: the test imports
../src/utils/extract-hook-body.js directly, so vitest reads source and no dist/
sits between the mutation and the measurement.

What this deliberately does not do

A hook still has no declared elevation knob — no hook-side runAs the way
FlowSchema has one — so "this column is computed by automation and never
hand-written" stays inexpressible whenever the maintaining write is cross-object.
That is a contract accept-surface change (HookSchema is strictObject and today
rejects runAs as unrecognized), and it is not a mechanical port of the flow key:
a hook's ctx.api is buildHookApi(opCtx.context), inheriting isSystem from
whatever triggered it, so FlowSchema's .default('user') would de-elevate
every hook fired by a system write and, under flow semantics, refuse a run with no
trigger user. The default alone is a real fork with a live regression in one branch.
Left to the review chain rather than guessed at; the options are written up on the
card.

Worth noting for whoever picks that up: the only elevation route that exists today
is the bundled in-process handler, and that route is on the L3 deprecation path
(--strict-body now, "Phase 3" later, per extract-hook-body.ts). So the knob is
not a convenience — it is the replacement for a capability scheduled to disappear.

This PR stays draft. It is filed from a downstream app's PM seat
(steedos-labs/hotcrm-heimao), which does not hold triage or merge authority here:
no domain:* label is set, and merge is this repo's call.


Generated by Claude Code

ScopedContext.sudo() is real in-process and is not marshalled into the
QuickJS sandbox, so a handler that calls it passes a native
hook.handler(ctx) test and TypeErrors once the build lowers it into a
body -- aborting the triggering write under the default onError:'abort'.
- cli: `.sudo(` joins FORBIDDEN_PATTERNS, so lowerCallables keeps the
callable bundled (in-process, where sudo() is real) instead of emitting
a body that cannot run it.
- lint: the two readonly-hook-write hints prescribed ctx.api.sudo() to a
population that is entirely L2 bodies; they now name the own-hook stamp
and say the elevation is not reachable from a body.
- runtime: pin the VM-side ctx.api surface exhaustively, with a host
double that DOES carry sudo, so the absence is a recorded fact.
- docs: the readonly table claimed the sudo() row lands.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
The sandbox error is `TypeError: not a function` -- it names neither the
member nor the receiver, so production's one diagnostic identifies nothing.
That blindness is the argument for refusing at build time, and the test now
records it rather than the message it would have been nicer to get.
Also make the CLI reverse-test's assertion quote-agnostic: the test file is
itself bundled, and esbuild rewrites the literal before String(fn) runs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 anchor(s) derived from 2 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 26 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 5e2c04da7db38c4db0b138fad8b9be5b4ef308fcpackageMentionDocs.

Which tree this was computed on

This run read content/docs from db2fdd66b4dbb0523687675ca59c07f1bb3dd929 — the merge of head 0b51c6139d822ab268860aa68511f4b5fc334147 into base 5e2c04da7db38c4db0b138fad8b9be5b4ef308fc, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin db2fdd66b4dbb0523687675ca59c07f1bb3dd929 && git checkout db2fdd66b4dbb0523687675ca59c07f1bb3dd929
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5e2c04da7db38c4db0b138fad8b9be5b4ef308fc 0b51c6139d822ab268860aa68511f4b5fc334147 && git checkout -B drift-repro 5e2c04da7db38c4db0b138fad8b9be5b4ef308fc && git merge --no-ff 0b51c6139d822ab268860aa68511f4b5fc334147
node scripts/docs-audit/affected-docs.mjs --json 5e2c04da7db38c4db0b138fad8b9be5b4ef308fc

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

CI 全绿,交接给本仓分诊席。 head 0b51c61 上 10 个工作流全部 success,含最慢的两条(Lint & Type Check 01:09、CI 01:15)。

保持 draft,我不转 ready 也不合。这是下游 steedos-labs/hotcrm-heimao 的 PM 席按维护者直接指令派发的,不是本仓 spec/CLI 车道的产出;落它由你们裁。

这是 #14010 的哪一半

半 B(实现面)。 主诉 —— 给钩子一个可声明的提权旋钮(runAsHookSchema)—— 不在本 PR 内,我已在 #14010 上把三条路的代价摆全并交回。那张卡上有两条对决策有用的事实,都是本次派发查出来的:

  1. 我原来要求「与 FlowSchema 同形」,照字面做会造成回归 —— 钩子的 ctx.apibuildHookApi(opCtx.context),继承触发者的 isSystem.default('user') 会去提权每一个由系统写入触发的钩子,而 runAs:'user' 却解析不出触发用户的运行其数据操作会被拒绝,那里面包括平台自己的内部钩子。
  2. 钩子今天唯一的提权路径(打包成进程内 handler)正在被废弃extract-hook-body.ts 头:现在 --strict-body,将来 Phase 3)。所以「先不做」不是推迟,是移除决定。

本 PR 的范围为什么比"沙箱侧"宽

派发词只点了沙箱侧,实际多改了 lint 与文档两处。理由写在正文里,这里复述一句:那两处是陷阱的来源 —— hook-api-update-readonly-field 是 severity error、卡构建的规则,它的提示在向 100% 的适用人群规定 ctx.api.sudo(),而那个调用在 body 里必然 TypeError;文档表格同一行标着 "Lands."。留着它们,构建拒绝与规则建议就自相矛盾。

修法遵循本仓已记录的先例(#4391crypto.hash:先于实现被广告出去的沙箱成员被移除而非实现,因为实现它会扩大沙箱的安全面),不是新发明的判断。

两处自我更正,值得评审时留意

head 上那个 follow-up commit 修的是测试自己的两个错,不是实现:

  1. 第一版断言期待错误信息里出现 sudo它不会 —— QuickJS 发的是 TypeError: not a function,既不点成员也不点接收者。所以生产环境唯一那条诊断什么都指认不出来 —— 而这恰恰是"应当在构建期拒绝"的论据,测试现在记录的是实际发生的事,不是希望收到的信息。
  2. CLI 那条反证测试的断言改成了不认引号:测试文件自身也会被打包,esbuild 在 String(fn) 跑之前就重写了字面量的引号风格。写死引号的断言会随打包器行为漂移。

无重叠

#14035#14011 契约散文)不重叠:那张只改 spec 散文加一个测试,本张改 CLI / lint / runtime / 文档。


Generated by Claude Code

@hotlong
hotlong marked this pull request as ready for review September 1, 2026 01:56
@hotlong
hotlong enabled auto-merge September 1, 2026 01:56
@hotlong
hotlong added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 8b04c75Sep 1, 2026
35 checks passed
@hotlong
hotlong deleted the claude/issue-14010-hook-runas branch September 1, 2026 02:20
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@hotlong@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Stop lowering hook handlers that call ctx.api.sudo() into bodies that cannot run it - #14044

Merged
hotlong merged 2 commits into
mainfrom
claude/issue-14010-hook-runas
Sep 1, 2026
Merged

Stop lowering hook handlers that call ctx.api.sudo() into bodies that cannot run it#14044
hotlong merged 2 commits into
mainfrom
claude/issue-14010-hook-runas

Conversation

@hotlong

Copy link
Copy Markdown
Contributor

Part of #14010 — this lands the implementation-side half only. The card's primary
ask (a declared elevation knob for hooks) is not addressed here and is left to
this repo's contract review chain; see "What this deliberately does not do".

The defect

ScopedContext.sudo() is real in-process and is not marshalled into the QuickJS
sandbox. The VM's ctx.api carries object() and the transaction surface, and
nothing else — now pinned exhaustively in quickjs-runner.test.ts, with a host
double that does carry sudo, so the pin proves the member stops at the VM
boundary rather than being missing upstream.

Every consumer of that fact had it backwards:

ConsumerClaimedActual
content/docs/automation/hook-bodies.mdx readonly tablectx.api.sudo().object('x').update(...)"Lands."TypeError
hook-api-update-readonly-field (severity error, gates the build)hint: "make the elevation explicit with ctx.api.sudo()"TypeError
hook-api-update-readonly-when-fieldhint: the own-hook stamp is not a workaround — "sudo() is"TypeError

Both lint rules read L2 (language:'js') body sources and nothing else
(extractHookBodyWriteSet parses hooks[i].body.source), so a gating rule was
prescribing a dead call to 100% of its own population.

The path that makes this expensive is the ordinary authoring path. An author writes
an inline handler, tests it the way the docs teach — hook.handler(ctx) called
natively, against the in-process ScopedContext, where sudo() exists — and the
suite is green. objectstack build then lowers that same source into an L2 body,
and production gets TypeError: ctx.api.sudo is not a function. Under a hook's
default onError: 'abort' that aborts the triggering write, so it surfaces as
an unrelated save being refused. Green tests, dead feature.

Measured here, and pinned: QuickJS reports it as TypeError: not a function
naming neither the member nor the receiver. Production's one diagnostic identifies
nothing, which is the argument for catching this at build time instead.

The fix

packages/cli.sudo( joins FORBIDDEN_PATTERNS in extractHookBody, at
the exact boundary where the two runtimes diverge. This is a repair, not only a
refusal: lowerCallables already registers the callable and ships it through the
.mjs bundle when extraction throws, so the handler keeps running in-process,
where sudo() is real
, and the build declines only the body that cannot run it.
The reason is printed; --strict-body, which demands a body for every callable,
makes it a hard failure — correctly, since a body needing elevation cannot be one.

Same family as the crypto.hash retirement (#4391), whose note sits three lines
below in the same file: a member advertised ahead of its implementation, where the
build-time inference was the amplifier rather than the safety net. That precedent
also settles what not to do here — crypto.hash was removed rather than
implemented because implementing it widened the sandbox's security surface, and
sudo is that case squarely.

packages/lint — both hints now name the own-hook stamp and say plainly that
sudo() is not reachable from a body. Findings, severities and exclusions are
unchanged; only the advice moved. Two existing assertions asserted
hint).toContain('sudo'), which still passes against the corrected text while
meaning the opposite — they now pin the direction, not the substring.

content/docs — the readonly table records what actually happens.

Verification

Union re-run at 0b51c61, the final commit:

packages/cli extract-hook-body 21 passed (21)
packages/lint readonly-hook-writes, readonly-action-writes,
hook-body-writes 109 passed (109)
packages/runtime quickjs-runner, script-runner 64 passed (64)

typecheck green for all three packages on a built closure (the closure matters:
before turbo run build, all three reported TS2307 piles for unbuilt workspace
dist/*.d.ts, which say nothing about this diff). check:type-check-debt
re-measured 29 ledger entries, 1531 raw errors, none above its recorded number
the gate that does see test files even where typecheck excludes them.

Repo gates run locally, all green: nul-bytes, corpus-claim-drift, doc-anchors,
doc-authoring, docs-single-h1, docs-redirects, docs-audit-scope, role-word,
changeset-gate-self-tests, objectui-changeset, pm-half-states,
cli-test-child-env, cross-package-test-inputs, test-source-alias,
skill-identifier-liveness, check-adr-0087-registration,
check-changeset-no-major.

Ablation — the new guard is proved load-bearing rather than assumed. With the
FORBIDDEN_PATTERNS entry deleted (mutation confirmed on disk: blob
6b76a59c to 8c1fe103, sudo mentions 7 to 4, surviving rx: anchors 0), exactly
the two new refusal tests go red and the other 19 stay green — so the pattern is
what refuses, and it has not widened into the majority case beside it. Restored by
git checkout HEAD -- ABSOLUTE_PATH, verified by git diff HEAD empty and the disk
blob back to the HEAD blob. No rebuild leg was needed: the test imports
../src/utils/extract-hook-body.js directly, so vitest reads source and no dist/
sits between the mutation and the measurement.

What this deliberately does not do

A hook still has no declared elevation knob — no hook-side runAs the way
FlowSchema has one — so "this column is computed by automation and never
hand-written" stays inexpressible whenever the maintaining write is cross-object.
That is a contract accept-surface change (HookSchema is strictObject and today
rejects runAs as unrecognized), and it is not a mechanical port of the flow key:
a hook's ctx.api is buildHookApi(opCtx.context), inheriting isSystem from
whatever triggered it, so FlowSchema's .default('user') would de-elevate
every hook fired by a system write and, under flow semantics, refuse a run with no
trigger user. The default alone is a real fork with a live regression in one branch.
Left to the review chain rather than guessed at; the options are written up on the
card.

Worth noting for whoever picks that up: the only elevation route that exists today
is the bundled in-process handler, and that route is on the L3 deprecation path
(--strict-body now, "Phase 3" later, per extract-hook-body.ts). So the knob is
not a convenience — it is the replacement for a capability scheduled to disappear.

This PR stays draft. It is filed from a downstream app's PM seat
(steedos-labs/hotcrm-heimao), which does not hold triage or merge authority here:
no domain:* label is set, and merge is this repo's call.


Generated by Claude Code

ScopedContext.sudo() is real in-process and is not marshalled into the
QuickJS sandbox, so a handler that calls it passes a native
hook.handler(ctx) test and TypeErrors once the build lowers it into a
body -- aborting the triggering write under the default onError:'abort'.
- cli: `.sudo(` joins FORBIDDEN_PATTERNS, so lowerCallables keeps the
callable bundled (in-process, where sudo() is real) instead of emitting
a body that cannot run it.
- lint: the two readonly-hook-write hints prescribed ctx.api.sudo() to a
population that is entirely L2 bodies; they now name the own-hook stamp
and say the elevation is not reachable from a body.
- runtime: pin the VM-side ctx.api surface exhaustively, with a host
double that DOES carry sudo, so the absence is a recorded fact.
- docs: the readonly table claimed the sudo() row lands.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
The sandbox error is `TypeError: not a function` -- it names neither the
member nor the receiver, so production's one diagnostic identifies nothing.
That blindness is the argument for refusing at build time, and the test now
records it rather than the message it would have been nicer to get.
Also make the CLI reverse-test's assertion quote-agnostic: the test file is
itself bundled, and esbuild rewrites the literal before String(fn) runs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 anchor(s) derived from 2 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 26 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 5e2c04da7db38c4db0b138fad8b9be5b4ef308fcpackageMentionDocs.

Which tree this was computed on

This run read content/docs from db2fdd66b4dbb0523687675ca59c07f1bb3dd929 — the merge of head 0b51c6139d822ab268860aa68511f4b5fc334147 into base 5e2c04da7db38c4db0b138fad8b9be5b4ef308fc, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin db2fdd66b4dbb0523687675ca59c07f1bb3dd929 && git checkout db2fdd66b4dbb0523687675ca59c07f1bb3dd929
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5e2c04da7db38c4db0b138fad8b9be5b4ef308fc 0b51c6139d822ab268860aa68511f4b5fc334147 && git checkout -B drift-repro 5e2c04da7db38c4db0b138fad8b9be5b4ef308fc && git merge --no-ff 0b51c6139d822ab268860aa68511f4b5fc334147
node scripts/docs-audit/affected-docs.mjs --json 5e2c04da7db38c4db0b138fad8b9be5b4ef308fc

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

CI 全绿,交接给本仓分诊席。 head 0b51c61 上 10 个工作流全部 success,含最慢的两条(Lint & Type Check 01:09、CI 01:15)。

保持 draft,我不转 ready 也不合。这是下游 steedos-labs/hotcrm-heimao 的 PM 席按维护者直接指令派发的,不是本仓 spec/CLI 车道的产出;落它由你们裁。

这是 #14010 的哪一半

半 B(实现面)。 主诉 —— 给钩子一个可声明的提权旋钮(runAsHookSchema)—— 不在本 PR 内,我已在 #14010 上把三条路的代价摆全并交回。那张卡上有两条对决策有用的事实,都是本次派发查出来的:

  1. 我原来要求「与 FlowSchema 同形」,照字面做会造成回归 —— 钩子的 ctx.apibuildHookApi(opCtx.context),继承触发者的 isSystem.default('user') 会去提权每一个由系统写入触发的钩子,而 runAs:'user' 却解析不出触发用户的运行其数据操作会被拒绝,那里面包括平台自己的内部钩子。
  2. 钩子今天唯一的提权路径(打包成进程内 handler)正在被废弃extract-hook-body.ts 头:现在 --strict-body,将来 Phase 3)。所以「先不做」不是推迟,是移除决定。

本 PR 的范围为什么比"沙箱侧"宽

派发词只点了沙箱侧,实际多改了 lint 与文档两处。理由写在正文里,这里复述一句:那两处是陷阱的来源 —— hook-api-update-readonly-field 是 severity error、卡构建的规则,它的提示在向 100% 的适用人群规定 ctx.api.sudo(),而那个调用在 body 里必然 TypeError;文档表格同一行标着 "Lands."。留着它们,构建拒绝与规则建议就自相矛盾。

修法遵循本仓已记录的先例(#4391crypto.hash:先于实现被广告出去的沙箱成员被移除而非实现,因为实现它会扩大沙箱的安全面),不是新发明的判断。

两处自我更正,值得评审时留意

head 上那个 follow-up commit 修的是测试自己的两个错,不是实现:

  1. 第一版断言期待错误信息里出现 sudo它不会 —— QuickJS 发的是 TypeError: not a function,既不点成员也不点接收者。所以生产环境唯一那条诊断什么都指认不出来 —— 而这恰恰是"应当在构建期拒绝"的论据,测试现在记录的是实际发生的事,不是希望收到的信息。
  2. CLI 那条反证测试的断言改成了不认引号:测试文件自身也会被打包,esbuild 在 String(fn) 跑之前就重写了字面量的引号风格。写死引号的断言会随打包器行为漂移。

无重叠

#14035#14011 契约散文)不重叠:那张只改 spec 散文加一个测试,本张改 CLI / lint / runtime / 文档。


Generated by Claude Code

@hotlong
hotlong marked this pull request as ready for review September 1, 2026 01:56
@hotlong
hotlong enabled auto-merge September 1, 2026 01:56
@hotlong
hotlong added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 8b04c75Sep 1, 2026
35 checks passed
@hotlong
hotlong deleted the claude/issue-14010-hook-runas branch September 1, 2026 02:20
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@hotlong@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Stop lowering hook handlers that call ctx.api.sudo() into bodies that cannot run it - #14044

Merged
hotlong merged 2 commits into
mainfrom
claude/issue-14010-hook-runas
Sep 1, 2026
Merged

Stop lowering hook handlers that call ctx.api.sudo() into bodies that cannot run it#14044
hotlong merged 2 commits into
mainfrom
claude/issue-14010-hook-runas

Conversation

@hotlong

Copy link
Copy Markdown
Contributor

Part of #14010 — this lands the implementation-side half only. The card's primary
ask (a declared elevation knob for hooks) is not addressed here and is left to
this repo's contract review chain; see "What this deliberately does not do".

The defect

ScopedContext.sudo() is real in-process and is not marshalled into the QuickJS
sandbox. The VM's ctx.api carries object() and the transaction surface, and
nothing else — now pinned exhaustively in quickjs-runner.test.ts, with a host
double that does carry sudo, so the pin proves the member stops at the VM
boundary rather than being missing upstream.

Every consumer of that fact had it backwards:

ConsumerClaimedActual
content/docs/automation/hook-bodies.mdx readonly tablectx.api.sudo().object('x').update(...)"Lands."TypeError
hook-api-update-readonly-field (severity error, gates the build)hint: "make the elevation explicit with ctx.api.sudo()"TypeError
hook-api-update-readonly-when-fieldhint: the own-hook stamp is not a workaround — "sudo() is"TypeError

Both lint rules read L2 (language:'js') body sources and nothing else
(extractHookBodyWriteSet parses hooks[i].body.source), so a gating rule was
prescribing a dead call to 100% of its own population.

The path that makes this expensive is the ordinary authoring path. An author writes
an inline handler, tests it the way the docs teach — hook.handler(ctx) called
natively, against the in-process ScopedContext, where sudo() exists — and the
suite is green. objectstack build then lowers that same source into an L2 body,
and production gets TypeError: ctx.api.sudo is not a function. Under a hook's
default onError: 'abort' that aborts the triggering write, so it surfaces as
an unrelated save being refused. Green tests, dead feature.

Measured here, and pinned: QuickJS reports it as TypeError: not a function
naming neither the member nor the receiver. Production's one diagnostic identifies
nothing, which is the argument for catching this at build time instead.

The fix

packages/cli.sudo( joins FORBIDDEN_PATTERNS in extractHookBody, at
the exact boundary where the two runtimes diverge. This is a repair, not only a
refusal: lowerCallables already registers the callable and ships it through the
.mjs bundle when extraction throws, so the handler keeps running in-process,
where sudo() is real
, and the build declines only the body that cannot run it.
The reason is printed; --strict-body, which demands a body for every callable,
makes it a hard failure — correctly, since a body needing elevation cannot be one.

Same family as the crypto.hash retirement (#4391), whose note sits three lines
below in the same file: a member advertised ahead of its implementation, where the
build-time inference was the amplifier rather than the safety net. That precedent
also settles what not to do here — crypto.hash was removed rather than
implemented because implementing it widened the sandbox's security surface, and
sudo is that case squarely.

packages/lint — both hints now name the own-hook stamp and say plainly that
sudo() is not reachable from a body. Findings, severities and exclusions are
unchanged; only the advice moved. Two existing assertions asserted
hint).toContain('sudo'), which still passes against the corrected text while
meaning the opposite — they now pin the direction, not the substring.

content/docs — the readonly table records what actually happens.

Verification

Union re-run at 0b51c61, the final commit:

packages/cli extract-hook-body 21 passed (21)
packages/lint readonly-hook-writes, readonly-action-writes,
hook-body-writes 109 passed (109)
packages/runtime quickjs-runner, script-runner 64 passed (64)

typecheck green for all three packages on a built closure (the closure matters:
before turbo run build, all three reported TS2307 piles for unbuilt workspace
dist/*.d.ts, which say nothing about this diff). check:type-check-debt
re-measured 29 ledger entries, 1531 raw errors, none above its recorded number
the gate that does see test files even where typecheck excludes them.

Repo gates run locally, all green: nul-bytes, corpus-claim-drift, doc-anchors,
doc-authoring, docs-single-h1, docs-redirects, docs-audit-scope, role-word,
changeset-gate-self-tests, objectui-changeset, pm-half-states,
cli-test-child-env, cross-package-test-inputs, test-source-alias,
skill-identifier-liveness, check-adr-0087-registration,
check-changeset-no-major.

Ablation — the new guard is proved load-bearing rather than assumed. With the
FORBIDDEN_PATTERNS entry deleted (mutation confirmed on disk: blob
6b76a59c to 8c1fe103, sudo mentions 7 to 4, surviving rx: anchors 0), exactly
the two new refusal tests go red and the other 19 stay green — so the pattern is
what refuses, and it has not widened into the majority case beside it. Restored by
git checkout HEAD -- ABSOLUTE_PATH, verified by git diff HEAD empty and the disk
blob back to the HEAD blob. No rebuild leg was needed: the test imports
../src/utils/extract-hook-body.js directly, so vitest reads source and no dist/
sits between the mutation and the measurement.

What this deliberately does not do

A hook still has no declared elevation knob — no hook-side runAs the way
FlowSchema has one — so "this column is computed by automation and never
hand-written" stays inexpressible whenever the maintaining write is cross-object.
That is a contract accept-surface change (HookSchema is strictObject and today
rejects runAs as unrecognized), and it is not a mechanical port of the flow key:
a hook's ctx.api is buildHookApi(opCtx.context), inheriting isSystem from
whatever triggered it, so FlowSchema's .default('user') would de-elevate
every hook fired by a system write and, under flow semantics, refuse a run with no
trigger user. The default alone is a real fork with a live regression in one branch.
Left to the review chain rather than guessed at; the options are written up on the
card.

Worth noting for whoever picks that up: the only elevation route that exists today
is the bundled in-process handler, and that route is on the L3 deprecation path
(--strict-body now, "Phase 3" later, per extract-hook-body.ts). So the knob is
not a convenience — it is the replacement for a capability scheduled to disappear.

This PR stays draft. It is filed from a downstream app's PM seat
(steedos-labs/hotcrm-heimao), which does not hold triage or merge authority here:
no domain:* label is set, and merge is this repo's call.


Generated by Claude Code

ScopedContext.sudo() is real in-process and is not marshalled into the
QuickJS sandbox, so a handler that calls it passes a native
hook.handler(ctx) test and TypeErrors once the build lowers it into a
body -- aborting the triggering write under the default onError:'abort'.
- cli: `.sudo(` joins FORBIDDEN_PATTERNS, so lowerCallables keeps the
callable bundled (in-process, where sudo() is real) instead of emitting
a body that cannot run it.
- lint: the two readonly-hook-write hints prescribed ctx.api.sudo() to a
population that is entirely L2 bodies; they now name the own-hook stamp
and say the elevation is not reachable from a body.
- runtime: pin the VM-side ctx.api surface exhaustively, with a host
double that DOES carry sudo, so the absence is a recorded fact.
- docs: the readonly table claimed the sudo() row lands.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
The sandbox error is `TypeError: not a function` -- it names neither the
member nor the receiver, so production's one diagnostic identifies nothing.
That blindness is the argument for refusing at build time, and the test now
records it rather than the message it would have been nicer to get.
Also make the CLI reverse-test's assertion quote-agnostic: the test file is
itself bundled, and esbuild rewrites the literal before String(fn) runs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 anchor(s) derived from 2 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 26 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 5e2c04da7db38c4db0b138fad8b9be5b4ef308fcpackageMentionDocs.

Which tree this was computed on

This run read content/docs from db2fdd66b4dbb0523687675ca59c07f1bb3dd929 — the merge of head 0b51c6139d822ab268860aa68511f4b5fc334147 into base 5e2c04da7db38c4db0b138fad8b9be5b4ef308fc, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin db2fdd66b4dbb0523687675ca59c07f1bb3dd929 && git checkout db2fdd66b4dbb0523687675ca59c07f1bb3dd929
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5e2c04da7db38c4db0b138fad8b9be5b4ef308fc 0b51c6139d822ab268860aa68511f4b5fc334147 && git checkout -B drift-repro 5e2c04da7db38c4db0b138fad8b9be5b4ef308fc && git merge --no-ff 0b51c6139d822ab268860aa68511f4b5fc334147
node scripts/docs-audit/affected-docs.mjs --json 5e2c04da7db38c4db0b138fad8b9be5b4ef308fc

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

CI 全绿,交接给本仓分诊席。 head 0b51c61 上 10 个工作流全部 success,含最慢的两条(Lint & Type Check 01:09、CI 01:15)。

保持 draft,我不转 ready 也不合。这是下游 steedos-labs/hotcrm-heimao 的 PM 席按维护者直接指令派发的,不是本仓 spec/CLI 车道的产出;落它由你们裁。

这是 #14010 的哪一半

半 B(实现面)。 主诉 —— 给钩子一个可声明的提权旋钮(runAsHookSchema)—— 不在本 PR 内,我已在 #14010 上把三条路的代价摆全并交回。那张卡上有两条对决策有用的事实,都是本次派发查出来的:

  1. 我原来要求「与 FlowSchema 同形」,照字面做会造成回归 —— 钩子的 ctx.apibuildHookApi(opCtx.context),继承触发者的 isSystem.default('user') 会去提权每一个由系统写入触发的钩子,而 runAs:'user' 却解析不出触发用户的运行其数据操作会被拒绝,那里面包括平台自己的内部钩子。
  2. 钩子今天唯一的提权路径(打包成进程内 handler)正在被废弃extract-hook-body.ts 头:现在 --strict-body,将来 Phase 3)。所以「先不做」不是推迟,是移除决定。

本 PR 的范围为什么比"沙箱侧"宽

派发词只点了沙箱侧,实际多改了 lint 与文档两处。理由写在正文里,这里复述一句:那两处是陷阱的来源 —— hook-api-update-readonly-field 是 severity error、卡构建的规则,它的提示在向 100% 的适用人群规定 ctx.api.sudo(),而那个调用在 body 里必然 TypeError;文档表格同一行标着 "Lands."。留着它们,构建拒绝与规则建议就自相矛盾。

修法遵循本仓已记录的先例(#4391crypto.hash:先于实现被广告出去的沙箱成员被移除而非实现,因为实现它会扩大沙箱的安全面),不是新发明的判断。

两处自我更正,值得评审时留意

head 上那个 follow-up commit 修的是测试自己的两个错,不是实现:

  1. 第一版断言期待错误信息里出现 sudo它不会 —— QuickJS 发的是 TypeError: not a function,既不点成员也不点接收者。所以生产环境唯一那条诊断什么都指认不出来 —— 而这恰恰是"应当在构建期拒绝"的论据,测试现在记录的是实际发生的事,不是希望收到的信息。
  2. CLI 那条反证测试的断言改成了不认引号:测试文件自身也会被打包,esbuild 在 String(fn) 跑之前就重写了字面量的引号风格。写死引号的断言会随打包器行为漂移。

无重叠

#14035#14011 契约散文)不重叠:那张只改 spec 散文加一个测试,本张改 CLI / lint / runtime / 文档。


Generated by Claude Code

@hotlong
hotlong marked this pull request as ready for review September 1, 2026 01:56
@hotlong
hotlong enabled auto-merge September 1, 2026 01:56
@hotlong
hotlong added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 8b04c75Sep 1, 2026
35 checks passed
@hotlong
hotlong deleted the claude/issue-14010-hook-runas branch September 1, 2026 02:20
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@hotlong@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Stop lowering hook handlers that call ctx.api.sudo() into bodies that cannot run it - #14044

Merged
hotlong merged 2 commits into
mainfrom
claude/issue-14010-hook-runas
Sep 1, 2026
Merged

Stop lowering hook handlers that call ctx.api.sudo() into bodies that cannot run it#14044
hotlong merged 2 commits into
mainfrom
claude/issue-14010-hook-runas

Conversation

@hotlong

Copy link
Copy Markdown
Contributor

Part of #14010 — this lands the implementation-side half only. The card's primary
ask (a declared elevation knob for hooks) is not addressed here and is left to
this repo's contract review chain; see "What this deliberately does not do".

The defect

ScopedContext.sudo() is real in-process and is not marshalled into the QuickJS
sandbox. The VM's ctx.api carries object() and the transaction surface, and
nothing else — now pinned exhaustively in quickjs-runner.test.ts, with a host
double that does carry sudo, so the pin proves the member stops at the VM
boundary rather than being missing upstream.

Every consumer of that fact had it backwards:

ConsumerClaimedActual
content/docs/automation/hook-bodies.mdx readonly tablectx.api.sudo().object('x').update(...)"Lands."TypeError
hook-api-update-readonly-field (severity error, gates the build)hint: "make the elevation explicit with ctx.api.sudo()"TypeError
hook-api-update-readonly-when-fieldhint: the own-hook stamp is not a workaround — "sudo() is"TypeError

Both lint rules read L2 (language:'js') body sources and nothing else
(extractHookBodyWriteSet parses hooks[i].body.source), so a gating rule was
prescribing a dead call to 100% of its own population.

The path that makes this expensive is the ordinary authoring path. An author writes
an inline handler, tests it the way the docs teach — hook.handler(ctx) called
natively, against the in-process ScopedContext, where sudo() exists — and the
suite is green. objectstack build then lowers that same source into an L2 body,
and production gets TypeError: ctx.api.sudo is not a function. Under a hook's
default onError: 'abort' that aborts the triggering write, so it surfaces as
an unrelated save being refused. Green tests, dead feature.

Measured here, and pinned: QuickJS reports it as TypeError: not a function
naming neither the member nor the receiver. Production's one diagnostic identifies
nothing, which is the argument for catching this at build time instead.

The fix

packages/cli.sudo( joins FORBIDDEN_PATTERNS in extractHookBody, at
the exact boundary where the two runtimes diverge. This is a repair, not only a
refusal: lowerCallables already registers the callable and ships it through the
.mjs bundle when extraction throws, so the handler keeps running in-process,
where sudo() is real
, and the build declines only the body that cannot run it.
The reason is printed; --strict-body, which demands a body for every callable,
makes it a hard failure — correctly, since a body needing elevation cannot be one.

Same family as the crypto.hash retirement (#4391), whose note sits three lines
below in the same file: a member advertised ahead of its implementation, where the
build-time inference was the amplifier rather than the safety net. That precedent
also settles what not to do here — crypto.hash was removed rather than
implemented because implementing it widened the sandbox's security surface, and
sudo is that case squarely.

packages/lint — both hints now name the own-hook stamp and say plainly that
sudo() is not reachable from a body. Findings, severities and exclusions are
unchanged; only the advice moved. Two existing assertions asserted
hint).toContain('sudo'), which still passes against the corrected text while
meaning the opposite — they now pin the direction, not the substring.

content/docs — the readonly table records what actually happens.

Verification

Union re-run at 0b51c61, the final commit:

packages/cli extract-hook-body 21 passed (21)
packages/lint readonly-hook-writes, readonly-action-writes,
hook-body-writes 109 passed (109)
packages/runtime quickjs-runner, script-runner 64 passed (64)

typecheck green for all three packages on a built closure (the closure matters:
before turbo run build, all three reported TS2307 piles for unbuilt workspace
dist/*.d.ts, which say nothing about this diff). check:type-check-debt
re-measured 29 ledger entries, 1531 raw errors, none above its recorded number
the gate that does see test files even where typecheck excludes them.

Repo gates run locally, all green: nul-bytes, corpus-claim-drift, doc-anchors,
doc-authoring, docs-single-h1, docs-redirects, docs-audit-scope, role-word,
changeset-gate-self-tests, objectui-changeset, pm-half-states,
cli-test-child-env, cross-package-test-inputs, test-source-alias,
skill-identifier-liveness, check-adr-0087-registration,
check-changeset-no-major.

Ablation — the new guard is proved load-bearing rather than assumed. With the
FORBIDDEN_PATTERNS entry deleted (mutation confirmed on disk: blob
6b76a59c to 8c1fe103, sudo mentions 7 to 4, surviving rx: anchors 0), exactly
the two new refusal tests go red and the other 19 stay green — so the pattern is
what refuses, and it has not widened into the majority case beside it. Restored by
git checkout HEAD -- ABSOLUTE_PATH, verified by git diff HEAD empty and the disk
blob back to the HEAD blob. No rebuild leg was needed: the test imports
../src/utils/extract-hook-body.js directly, so vitest reads source and no dist/
sits between the mutation and the measurement.

What this deliberately does not do

A hook still has no declared elevation knob — no hook-side runAs the way
FlowSchema has one — so "this column is computed by automation and never
hand-written" stays inexpressible whenever the maintaining write is cross-object.
That is a contract accept-surface change (HookSchema is strictObject and today
rejects runAs as unrecognized), and it is not a mechanical port of the flow key:
a hook's ctx.api is buildHookApi(opCtx.context), inheriting isSystem from
whatever triggered it, so FlowSchema's .default('user') would de-elevate
every hook fired by a system write and, under flow semantics, refuse a run with no
trigger user. The default alone is a real fork with a live regression in one branch.
Left to the review chain rather than guessed at; the options are written up on the
card.

Worth noting for whoever picks that up: the only elevation route that exists today
is the bundled in-process handler, and that route is on the L3 deprecation path
(--strict-body now, "Phase 3" later, per extract-hook-body.ts). So the knob is
not a convenience — it is the replacement for a capability scheduled to disappear.

This PR stays draft. It is filed from a downstream app's PM seat
(steedos-labs/hotcrm-heimao), which does not hold triage or merge authority here:
no domain:* label is set, and merge is this repo's call.


Generated by Claude Code

ScopedContext.sudo() is real in-process and is not marshalled into the
QuickJS sandbox, so a handler that calls it passes a native
hook.handler(ctx) test and TypeErrors once the build lowers it into a
body -- aborting the triggering write under the default onError:'abort'.
- cli: `.sudo(` joins FORBIDDEN_PATTERNS, so lowerCallables keeps the
callable bundled (in-process, where sudo() is real) instead of emitting
a body that cannot run it.
- lint: the two readonly-hook-write hints prescribed ctx.api.sudo() to a
population that is entirely L2 bodies; they now name the own-hook stamp
and say the elevation is not reachable from a body.
- runtime: pin the VM-side ctx.api surface exhaustively, with a host
double that DOES carry sudo, so the absence is a recorded fact.
- docs: the readonly table claimed the sudo() row lands.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
The sandbox error is `TypeError: not a function` -- it names neither the
member nor the receiver, so production's one diagnostic identifies nothing.
That blindness is the argument for refusing at build time, and the test now
records it rather than the message it would have been nicer to get.
Also make the CLI reverse-test's assertion quote-agnostic: the test file is
itself bundled, and esbuild rewrites the literal before String(fn) runs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 anchor(s) derived from 2 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 26 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 5e2c04da7db38c4db0b138fad8b9be5b4ef308fcpackageMentionDocs.

Which tree this was computed on

This run read content/docs from db2fdd66b4dbb0523687675ca59c07f1bb3dd929 — the merge of head 0b51c6139d822ab268860aa68511f4b5fc334147 into base 5e2c04da7db38c4db0b138fad8b9be5b4ef308fc, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin db2fdd66b4dbb0523687675ca59c07f1bb3dd929 && git checkout db2fdd66b4dbb0523687675ca59c07f1bb3dd929
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5e2c04da7db38c4db0b138fad8b9be5b4ef308fc 0b51c6139d822ab268860aa68511f4b5fc334147 && git checkout -B drift-repro 5e2c04da7db38c4db0b138fad8b9be5b4ef308fc && git merge --no-ff 0b51c6139d822ab268860aa68511f4b5fc334147
node scripts/docs-audit/affected-docs.mjs --json 5e2c04da7db38c4db0b138fad8b9be5b4ef308fc

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@hotlongClaude

Copy link
Copy Markdown
ContributorAuthor

CI 全绿,交接给本仓分诊席。 head 0b51c61 上 10 个工作流全部 success,含最慢的两条(Lint & Type Check 01:09、CI 01:15)。

保持 draft,我不转 ready 也不合。这是下游 steedos-labs/hotcrm-heimao 的 PM 席按维护者直接指令派发的,不是本仓 spec/CLI 车道的产出;落它由你们裁。

这是 #14010 的哪一半

半 B(实现面)。 主诉 —— 给钩子一个可声明的提权旋钮(runAsHookSchema)—— 不在本 PR 内,我已在 #14010 上把三条路的代价摆全并交回。那张卡上有两条对决策有用的事实,都是本次派发查出来的:

  1. 我原来要求「与 FlowSchema 同形」,照字面做会造成回归 —— 钩子的 ctx.apibuildHookApi(opCtx.context),继承触发者的 isSystem.default('user') 会去提权每一个由系统写入触发的钩子,而 runAs:'user' 却解析不出触发用户的运行其数据操作会被拒绝,那里面包括平台自己的内部钩子。
  2. 钩子今天唯一的提权路径(打包成进程内 handler)正在被废弃extract-hook-body.ts 头:现在 --strict-body,将来 Phase 3)。所以「先不做」不是推迟,是移除决定。

本 PR 的范围为什么比"沙箱侧"宽

派发词只点了沙箱侧,实际多改了 lint 与文档两处。理由写在正文里,这里复述一句:那两处是陷阱的来源 —— hook-api-update-readonly-field 是 severity error、卡构建的规则,它的提示在向 100% 的适用人群规定 ctx.api.sudo(),而那个调用在 body 里必然 TypeError;文档表格同一行标着 "Lands."。留着它们,构建拒绝与规则建议就自相矛盾。

修法遵循本仓已记录的先例(#4391crypto.hash:先于实现被广告出去的沙箱成员被移除而非实现,因为实现它会扩大沙箱的安全面),不是新发明的判断。

两处自我更正,值得评审时留意

head 上那个 follow-up commit 修的是测试自己的两个错,不是实现:

  1. 第一版断言期待错误信息里出现 sudo它不会 —— QuickJS 发的是 TypeError: not a function,既不点成员也不点接收者。所以生产环境唯一那条诊断什么都指认不出来 —— 而这恰恰是"应当在构建期拒绝"的论据,测试现在记录的是实际发生的事,不是希望收到的信息。
  2. CLI 那条反证测试的断言改成了不认引号:测试文件自身也会被打包,esbuild 在 String(fn) 跑之前就重写了字面量的引号风格。写死引号的断言会随打包器行为漂移。

无重叠

#14035#14011 契约散文)不重叠:那张只改 spec 散文加一个测试,本张改 CLI / lint / runtime / 文档。


Generated by Claude Code

@hotlong
hotlong marked this pull request as ready for review September 1, 2026 01:56
@hotlong
hotlong enabled auto-merge September 1, 2026 01:56
@hotlong
hotlong added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 8b04c75Sep 1, 2026
35 checks passed
@hotlong
hotlong deleted the claude/issue-14010-hook-runas branch September 1, 2026 02:20
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@hotlong@claude