Skip to content

[finding] ctx.log.debug is inferred, granted and documented, but the sandbox never installs it — a body calling it throws TypeError: not a function #7661

Closed

Description

@os-help

Found while fixing #7448 (PR #7660). Out of scope there — that card is the observability of ctx.log.{info,warn,error}; this is a fourth method that does not exist. Filed rather than folded in, per Prime Directive #10. Unassigned — recording only.

The gap

ctx.log.debug is declared on three surfaces and implemented on none.

Surface Says Anchor (main @ 245d1dc)
CLI capability extractor matches debug, grants the log capability packages/cli/src/utils/extract-hook-body.ts:56 — /ctx\.log\.(?:info|warn|error|debug)\b/
Docs lists ctx.log.info / warn / error / debug → log content/docs/automation/hook-bodies.mdx:317
Sandbox ScriptContext declares info / warn / error only packages/runtime/src/sandbox/script-runner.ts:243
QuickJS VM install installs info / warn / error only packages/runtime/src/sandbox/quickjs-runner.ts:639 — for (const level of ['info', 'warn', 'error'] as const)

So an author writes ctx.log.debug(…), the CLI reads it, infers ['log'] and emits a body whose declared capability is satisfied — and the call then throws inside the VM.

Measured

Hook body ctx.log.debug('hi') with capabilities: ['log'], run through hookBodyRunnerFactory + QuickJSScriptRunner with a logger that has a real debug method:

hook 'h' threw: TypeError: not a function

Not a silent drop — the hook fails. Under onError: 'abort' that aborts the write.

Why this is the crypto.hash shape again

script-runner.ts:249-258 documents the precedent in its own comment: crypto.hash carried a signature, a capability token and CLI build-time inference for a function the sandbox never installed, so the one call it typed threw inside the VM. It was removed rather than implemented (#4391, ADR-0049 enforce-or-remove). ctx.log.debug is the same shape one member over — and unlike hashing it has no security-surface argument against implementing it.

Decision needed (enforce-or-remove)

Enforcing looks right here (a body emitting debug-level diagnostics is exactly what --log-level debug is for), but it is a capability-surface call, not a bug fix, so it should be decided rather than assumed.

Dedup

Searched open issues for ctx.log, log.debug, capability log, extract-hook-body: no existing card. #7448 is the adjacent one and covers only the three installed methods.

Activity

  1. added
    findingRecorded observation — held, not dispatchable until the findings triage round grades it
    on Aug 11, 2026
  2. added
    domain:cliRetired 2026-10-22, alias of domain:server (cli surface); in-flight cards keep it until they land
    on Aug 11, 2026
  3. claude commented on Aug 11, 2026

    @claude
    Contributor

    Route repair (finding triage route A): domain:cli appended — routing only; the finding grade is unchanged and no ownership is taken.

    Landing site read, not guessed: whichever arm of the enforce-or-remove fork wins, the diff lands in domain:cli packages — enforce touches packages/runtime/src/sandbox/quickjs-runner.ts:639 (the ['info', 'warn', 'error'] install loop, verified on origin/main @ 6a9dec6) plus script-runner.ts's ScriptContext; remove touches packages/cli/src/utils/extract-hook-body.ts:56 (the extractor regex, verified — debug still in the alternation) plus the docs table. Both packages/runtime and packages/cli sit in the domain:cli row of the lane table.

    Dedup: #7448 (the ctx.log observability card, pm:dispatched) explicitly scopes this out and PR #7660 does not touch the debug member; no other open card names ctx.log.debug.

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  4. os-zhuang commented on Aug 11, 2026

    @os-zhuang
    Contributor

    Findings triage round, 2026-08-11 (PM session, maintainer-directed: 「跑一轮集中定级」).

    Graded: promote. ctx.log.debug is declared on three surfaces (extractor grants it, docs teach it) and installed on none — an author following the documentation gets TypeError: not a function. Declared = enforced, live author impact, S-M fix (install debug in the sandbox, or retire it from all three declaring surfaces in one pass — implementer measures which direction ADR posture prefers).

    finding → pm:queue (domain:cli).


    Generated by Claude Code

  5. added
    pm:queueReady for the PM dispatch loop
    and removed
    findingRecorded observation — held, not dispatchable until the findings triage round grades it
    on Aug 11, 2026
  6. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Aug 12, 2026
  7. self-assigned this
    on Aug 12, 2026
  8. hotlong commented on Aug 12, 2026

    @hotlong
    Contributor

    Claim: PM loop round 2 — domain:cli seat.

    Ruling: ENFORCE, on a named and falsifiable premise

    Taking this as a restore-invariant call (declared = enforced) rather than escalating: ADR-0049 governs, and the two arms are not evenly matched here. debug is already granted by the extractor and already taught by the docs, so the three surfaces disagree in the direction of promising more than we deliver — the class the invariant exists to close.

    Direction: enforce — install debug in the VM loop and declare it on ScriptContext['log'], so all four surfaces agree on four methods.

    The premise this ruling hangs on (verify BEFORE writing code): Logger.debug(message, meta) exists on the contract (packages/spec/src/contracts/logger.ts:15) and the logger the log capability is actually served from after #7448 really carries a callable debug. If either half is false — the contract lacks it, or the served logger would throw/no-op — ⛔ do not implement, and ⛔ do not silently switch to the remove arm. Report the fork with the measurement; the direction becomes a maintainer call at that point.

    Why not the remove arm by default: the cited precedent (crypto.hash, #4391) was removed because implementing it meant opening a security surface. Emitting a debug-level diagnostic from a hook body has no such argument, and --log-level debug is exactly what such a body is for. That asymmetry is the whole reason this is rulable rather than escalatable — if the dev's measurement contradicts it, the ruling loses its basis and the fork comes back.

    Pin the consequence: the reproduction in this card — a hook body calling ctx.log.debug('hi') with capabilities: ['log'], run through hookBodyRunnerFactory + QuickJSScriptRunner — must go from TypeError: not a function to a delivered debug record. Assert the record reaches the logger, not merely that no throw occurred: a debug installed as a no-op would pass a throw-only test and still not be a real fourth method.


    Generated by Claude Code

  9. hotlong commented on Aug 12, 2026

    @hotlong
    Contributor

    Dispatch-mode correction to the claim above: mode:subagent → mode:cloud.

    Cloud dispatch was unavailable when I wrote the claim and became available minutes later, so this card is dispatched the way #7341 requires (M and above default to a dedicated cloud container) rather than the shared-container fallback.

    • Cloud session: session_01CUymLqPDq3d7PgiwpZsbA2
    • Title: cli #7661 ctx.log.debug declared but never installed
    • Model: claude-opus-5, outcome_branch: claude/issue-7661-ctx-log-debug

    Everything else in the claim stands unchanged — the ENFORCE ruling, the two-part premise it hangs on (contract Logger.debug exists AND the served logger really carries a callable debug), the ⛔ do-not-silently-switch-to-remove clause, and the pin that the debug record must actually reach the logger rather than merely not throw.


    Generated by Claude Code

  10. hotlong commented on Aug 12, 2026

    @hotlong
    Contributor
    {
      "issue": 7661,
      "status": "done",
      "branch": "claude/issue-7661-ctx-log-debug",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/7908",
      "premise_still_valid": true,
      "summary": "ENFORCE arm implemented as ruled — ctx.log.debug is now installed in the QuickJS ctx.log bridge and declared on ScriptContext['log'], so the four surfaces (extractor, docs, ScriptContext, VM install) agree on four methods. BOTH halves of the premise were measured before any code was written and BOTH hold: (1) Logger.debug(message, meta) is declared on the contract at packages/spec/src/contracts/logger.ts:15; (2) the logger the 'log' capability is served from is opts.logger — the kernel's ctx.logger, passed by all four app-plugin.ts construction sites — which resolves to createLogger() -> ObjectLogger whose debug (packages/core/src/logger.ts:401) writes at level 'debug' through the real sink; it is not absent, not a throwing stub, not a no-op, and body-runner.ts:164 already calls opts.logger?.debug?.() on that same object (the '[BodyRunner] hook fired' line QA #7439 saw at --log-level debug). The gap is also still live on current main @ d91fad5: the extractor regex still carries debug in its alternation (extract-hook-body.ts:56), the docs table still teaches it (hook-bodies.mdx:317), and the install loop was still ['info','warn','error'] (quickjs-runner.ts:639). THIRD FILE, DECLARED: besides the two named files the fix also required packages/runtime/src/sandbox/body-runner.ts, which builds ScriptContext['log'] from the host logger (buildBodyLogSurface, added by #7448). installCtx forwards through the OPTIONAL call ctx.log?.[level]?.(…), so a VM-side debug with no host-side member is a silent drop rather than a throw — i.e. exactly the no-op that the card said must not pass. It is the third file in the same runtime/src/sandbox/** directory the claim reserved and nothing outside that directory was touched. Deliberately NOT touched: extract-hook-body.ts and the docs table (already correct under the enforce arm), runtime/src/domains/**, packages/rest/**.",
      "tests": "New pin: 4 tests in packages/runtime/src/sandbox/body-log-capability.test.ts (describe '[#7661] hook body ctx.log.debug is installed and delivers') — the card's own reproduction ctx.log.debug('hi') with capabilities:['log'] through hookBodyRunnerFactory + QuickJSScriptRunner asserting a DELIVERED debug record on the host logger (level + attribution), structured data crossing the VM boundary as a value, capability gating, and the no-logger warn-once branch. REVERSE-VERIFIED IN BOTH DIRECTIONS: (a) install reverted -> 4 failed | 5 passed, failing with the card's own \"SandboxError: hook 'h' threw: TypeError: not a function\"; (b) VM install kept but the host-side forward removed (the no-op arm) -> 2 failed | 7 passed with NOTHING throwing, failing on the delivery assertions ('expected +0 to be 1', 'expected undefined to be debug') — so the pin cannot be satisfied by a no-op debug. After the fix: 9 passed. Suites: sandbox 12 files/138 passed; full @objectstack/runtime 137 files/2090 passed. Build: pnpm --filter @objectstack/runtime... build success, and the full closure turbo build 70/70 successful. Gates (derived via scripts/pm/dispatch-gates.mjs): pnpm check:changeset-gate-self-tests OK (118+153+117 assertions), check-empty-changeset OK (1 declaring changeset), check-changeset-no-major OK, check-nul-bytes OK (7231 files). RATCHET: pnpm check:type-check-debt first reported @objectstack/runtime TEST_DEBT 227 -> 229 (+2); both were real errors caused by making debug required on ScriptContext['log'] (TS2741 in two pre-existing three-member ctx.log test doubles at capability-denial-is-a-fault.test.ts:95 and quickjs-runner.test.ts:114). They were FIXED, not ledgered — re-measured back to exactly 227 and the gate now reports OK with none above its recorded number. The debt ceiling was NOT raised.",
      "open_questions": [
        "Resolved by me, recorded not escalated: the declared file surface named only quickjs-runner.ts and script-runner.ts, but the consequence the card pinned (a DELIVERED record, not merely no throw) is unreachable without also forwarding debug in body-runner.ts's buildBodyLogSurface, because installCtx's forward is an optional call. I treated that as inside the claimed runtime/src/sandbox/** surface rather than a breach, and flagged it explicitly in the PR body.",
        "The action path gets debug for free (actionBodyRunnerFactory shares buildBodyLogSurface and the same installCtx), but the new pin only exercises the hook path plus the pre-existing action test for info. If the PM wants an action-path debug pin too it is a two-line addition; I judged the shared code path made it redundant.",
        "Not done, and I believe correctly so: no docs change. The docs table already lists debug and is now accurate for the first time. If a release-notes mention is wanted it belongs to the central release process, not this PR (CLAUDE.md forbids touching content/docs/releases/ in a code PR)."
      ],
      "out_of_scope_findings": []
    }

    Generated by Claude Code

  11. hotlong commented on Aug 12, 2026

    @hotlong
    Contributor

    ACCEPTED — PR #7908. Landing held on the gate jobs.

    Reviewed by the domain:cli seat (hotlong, session session_01B3Kurx8qufrDzNjk4rag7V) against the diff.

    The ruling's premise held and was measured, not assumed. Logger.debug(message, meta) was already on the contract, so the ENFORCE arm required nothing new of the host logger — which is exactly the condition the ruling hung on. Had it not held, the instruction was to report the fork rather than switch arms silently; that path was not needed.

    The find that makes this PR better than its brief. I scoped this to the QuickJS install loop and ScriptContext['log']. The dev also fixed buildBodyLogSurface's no-logger branch, which returned { info, warn, error } — and because installCtx forwards through ctx.log?.[level]?.(…), an optional call, a missing debug there is not a throw but a silent drop: the VM-side method exists, the body runs to completion, and the line goes nowhere. That is #7448's defect verbatim, one construction shape over. It is a third file beyond the two I named, and it is in scope in substance — same seam, same defect, and shipping without it would have made debug "installed" in one construction path and silently inert in the other. Declared in the PR body rather than slipped in.

    That gap was only visible because the pin asserts what the host logger received. A throw-only test would have gone green over it — which is the whole reason that clause was in the dispatch.

    Coverage checked, not skimmed: the card's own reproduction (ctx.log.debug('hi') → a delivered debug record, attributed to the emitting hook); structured data crossing the VM boundary as a value rather than "[object Object]"; debug gated behind the log capability exactly like the other three (so the fourth method is no cheaper to reach than the first three); and the warn-once "output is discarded" stub naming the hook. The two host-double updates (capability-denial-is-a-fault.test.ts, quickjs-runner.test.ts) are the type system correctly forcing doubles to match the widened seam — not test-weakening.

    Scope: packages/runtime/src/sandbox/** + changeset. No docs/adr/**, no skills paths, no content/docs/releases/. The extractor regex and the docs table are untouched — correct under ENFORCE, since both were already right.

    ⛔ Not flipped yet: ESLint and TypeScript Type Check must both read conclusion: success off the head commit first. ⚠️ Specific risk on this one — the sibling PR #7907 just failed that job because new packages/runtime test-layer type errors tripped the check:type-check-debt ratchet (227 → 231), and this PR also adds runtime test code. If it trips, the fix is to type the new tests correctly; ⛔ the ledger will not be raised to buy them.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

domain:cliRetired 2026-10-22, alias of domain:server (cli surface); in-flight cards keep it until they landpm:dispatchedDispatched to a dev agent by /pm-dispatch

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions