'Yes, and save this' permission fails with '(number).slice is not a function' — a value that must be a string is a number; the rule is never persisted #9675
Description
Deterministic, reproduced on demand, with a checkable observable. cc_manual @ f6aac9e5c4, macOS, sandboxed HOME.
Steps: ask cc to create a file → permission dialog appears → choose "Yes, and save this" (the persist-the-rule option, not plain Yes).
ERROR (number).slice is not a function
at rB8.getLoadBalancerName (<anonymous>)
at jxY (<anonymous>)
at s (<anonymous>)
at Mu_ (<anonymous>)
at WN8.calculateLayout (<anonymous>)
at get destination (<anonymous>)
Observable confirming the effect: .claude.json is rewritten at that exact second (mtime matches, numStartups advanced) but the project's allowedTools remains [] — the permission rule is never persisted. Plain "Yes" works and the write succeeds; only the save-the-rule variant fails.
What the error says
.slice exists on strings and arrays, never on numbers, and the receiver is reported as (number). So a value that node holds as a string is a number under perry at the point where the permission rule is built (a rule string like Write(path) is the obvious candidate for slicing — tool name, path prefix, or rule parsing).
This is the same family as the tag-ambiguity bugs fixed earlier in this campaign (#9415: class refs printing as integers because 0x7FFE is shared between INT32 and class-ref; #9462's hole-leaks answering wrong-kind values). A string-typed value arriving as a number is the wrong-kind-decode signature, now on a live user path.
Likely relationship to #9674
#9674 recorded config keys (hasCompletedOnboarding, lastOnboardingVersion) vanishing from .claude.json with an unidentified trigger. This is a strong candidate for that trigger: an exception thrown during the config read-modify-write would abort it partway, and whatever the writer had already staged determines what survives. In this capture the onboarding keys happened to survive while allowedTools was lost — consistent with an aborted update rather than a clean write. Worth testing #9674 by hitting this path repeatedly.
Repro / verification bar
- Fresh project directory, ask for a file write, choose "Yes, and save this" → error,
allowedToolsstays[]. Compare against node: the rule should appear inallowedTools(e.g.Write(...)). - Reduce: instrument or bisect the rule-construction path to find which value is a number; then a fixture pinning that value's type. The minified frames here are unreliable (
getLoadBalancerName,WN8.calculateLayoutare feat(runtime): real function names in Error stacks — frame-pointer walk + the existing name registry (#9486) #9521 name-borrowing artifacts —WN8is the bundle's JS yoga replacement and cannot be on this path), so instrument rather than trust the trace. - Regression arms: plain "Yes" (must keep working), "Yes, allow all edits this session", and a second run confirming the persisted rule suppresses the dialog.
Severity: high — "don't ask me again" is unusable, and every use of it corrupts a config write.
Activity
proggeramlug commentedon Sep 4, 2026
Correction: this issue's key observable is wrong, and the bug did not reproduce under exhaustive testing.
projects[<cwd>].allowedToolsin.claude.jsonis LEGACY in cc 2.1.112 — nothing writes it.Ew()(bundle @3746869) only reads it; there is no write site in the bundle. Rules are persisted to.claude/settings.local.json, and under perry they land there correctly. So "the rule is never persisted" was a false negative from checking the wrong file. The only hard evidence in this issue is the error text itself.Did not reproduce, across every rule-persisting dialog in the bundle with the persist option selected, on a clean sandbox HOME and a clone of the real one, in the reporting cwd:
Writein-project / out-of-project / into.claude/(exercising theVL8.slice(0,-2)branch) / 300-line content;Read/Globoutside workspace;WebFetch;WebSearch;Bashtouch/mkdir/rm/chmod/pip/npm/curl; a 4-dialog session;/permissions→ add-rule → destination picker; and asettings.local.jsonalready holding backslash-escaped rules. Zero occurrences; every rule persisted.Also cleared: 45 receiver shapes for a dynamic
.slice(SSO lengths 0/1/5/6/40, Map/Set spreads, getters, class fields, closures,flatMap/Object.entries/JSON.parse, concat/template/replace/trim/split/join, destructuring defaults, optional chaining, plus cc's exactruleContent.slice(0,-2)/key.slice(-20)/name.slice(0,-6)) — all byte-identical to node under the exact compiler that built the reporting binary. Forced-evacuating GC and 2-byte-chunked SSE changed nothing.What is now pinned exactly: the
(number)label is emitted only bynative_call_method.rs:2414-2455— the dynamic dispatch tower. So codegen did not prove the receiver a string (a proven string routes throughlower_string_method.rs, which hard-codeskind="string"),is_int32() || is_number()was true,is_any_string()false, and the bare-heap-pointer recovery just above either didn't apply or its validation rejected the pointer. So the receiver was a genuine number or a stale/unrecoverable bare pointer — not an SSO short-string misread (disproved empirically).The one step that closes this:
--debug-symbolsputs the source location in the message, verified end-to-end on this compiler —MSG:(number).slice is not a functionwithat <file>:<line>instead of<anonymous>. Rebuilding cc once with that flag makes the next occurrence self-locating.Also worth checking when that line arrives:
saveConfigWithLock(bundle @3743200) doesV.slice(v)over areaddirSyncresult inside acatchthat only logs — a plausible home for a swallowed(number).slicethat would not block the write.A reusable offline harness (mock SSE API + PTY driver, no network, no token) is at
secret-tests/cc-permission-harness/and reproduces every cc permission dialog deterministically.fix(runtime): decide bare managed receivers at the dispatch tower's e…
fix(runtime): decide bare managed receivers at the dispatch tower's e…
fix(runtime): a boxed-primitive probe reads a bare word only when the…