Skip to content

fix(types): always send PermissionUpdate.destination - #1330

Open
v0ropaev wants to merge 2 commits into
anthropics:mainfrom
v0ropaev:fix/permission-update-destination
Open

v0ropaev wants to merge 2 commits into
anthropics:mainfrom
v0ropaev:fix/permission-update-destination

Conversation

@v0ropaev

Copy link
Copy Markdown

What is wrong

PermissionUpdate.destination defaults to None and to_dict() only writes the key when it is not None, so an update built without one goes on the wire without it:

>>> PermissionUpdate(type="setMode", mode="plan").to_dict()
{'type': 'setMode', 'mode': 'plan'}
>>> PermissionUpdate(type="addDirectories", directories=["/tmp/x"]).to_dict()
{'type': 'addDirectories', 'directories': ['/tmp/x']}

The CLI's control-protocol schema has destination on every variant with no .optional(). From the bundled binary at 2.1.284, which is what _cli_version.py pins:

type:R("addRules"),rules:A(jo()),behavior:Go(),destination:lt()}),
u({type:R("replaceRules"),...,destination:lt()}),
u({type:R("setMode"),mode:UA(()=>ne()),destination:lt()}),
u({type:R("addDirectories"),directories:A(o()),destination:lt()}), ...

lt=f(()=>z(["userSettings","projectSettings","localSettings","session","cliArg"]))

An entry that fails that schema makes the CLI drop the whole updatedPermissions array, and the only sign is a warn-level line, Malformed updatedPermissions from SDK host ignored. So a PermissionResultAllow(updated_permissions=[...]) built in Python never lands, and can_use_tool is asked for the same tool on the next call.

The TypeScript SDK declares the field non-optional on all six variants (sdk.d.ts, PermissionUpdate), and its PermissionUpdateDestination carries five values:

export declare type PermissionUpdateDestination = 'userSettings' | 'projectSettings' | 'localSettings' | 'session' | 'cliArg';

This SDK has four. cliArg cannot be expressed at all.

Related: #1308 is the same array being dropped, through a different field (ruleContent: null). That one is not touched here, and the two are independent: the repro in #1308 sets destination explicitly on the CLI's own suggestions, which is why it gets as far as the ruleContent check.

What this changes

destination becomes required with a default of "session", and to_dict always emits it. "session" is the conservative choice: it holds for the current run and writes nothing to a settings file the caller did not name. The field stays last in the dataclass, so no positional call breaks.

from_dict fills in "session" when an inbound entry omits the key, so a round trip through the SDK produces something the CLI accepts.

cliArg joins the destination literal.

Test Plan

Three new tests in tests/test_types.py, all failing before the change:

  • every variant puts destination on the wire
  • from_dict without the key gives "session", and that survives to_dict
  • cliArg is in the literal and round-trips
pytest tests/          1590 passed, 6 skipped
ruff check             All checks passed
ruff format --check    63 files already formatted
mypy src/claude_agent_sdk/types.py   Success: no issues found

The existing round-trip tests all supply a destination, so they are unaffected.

destination defaults to None and to_dict() leaves the key out when it is,
but the CLI's control-protocol schema has it on every variant with no
.optional(). An entry without it makes the CLI drop the whole
updatedPermissions array, with nothing but a warn-level log, so the update
never lands and can_use_tool is asked for the same tool again.

    PermissionUpdate(type='setMode', mode='plan').to_dict()
    -> {'type': 'setMode', 'mode': 'plan'}

The TypeScript SDK declares the field non-optional on all six variants
(sdk.d.ts, PermissionUpdate), so this is also a parity gap. Default it to
'session', which holds for the run and writes nothing to a settings file
the caller did not name, and always put it on the wire.

Also add 'cliArg' to PermissionUpdateDestination. The CLI's enum and
PermissionUpdateDestination in sdk.d.ts both carry five values; this one
had four.
@sigley

sigley commented Oct 7, 2026

Copy link
Copy Markdown

One backward-compatibility edge remains on exact head de7a1fb.

Before this PR, destination is explicitly typed as optional, so existing callers can legally construct PermissionUpdate(..., destination=None). The new dataclass annotation/default no longer advertises None, but Python still accepts it at runtime. In that path to_dict() now emits:

{"destination": null, ...}

rather than applying the new session default. That recreates the same wire-level failure mode this PR is fixing: the CLI schema requires a concrete destination value and can drop the update.

from_dict() already normalizes a missing/falsy destination with or "session"; the direct constructor path does not. Could to_dict() also coalesce an explicit None to session (or otherwise preserve the old None input contract), with a regression for PermissionUpdate(type="setMode", mode="plan", destination=None)? That would make the fix robust for existing typed callers as well as omitted arguments.

Giving the field a default fixes the omitted-argument path, but destination
was annotated Optional before, so a caller written against that signature
can still pass None. to_dict() then wrote

    {"type": "setMode", "mode": "plan", "destination": null}

and a null fails the CLI's schema exactly like a missing key does, so the
updatedPermissions array gets dropped again. from_dict() already normalised
a falsy destination; the direct constructor did not. Coalesce in to_dict()
so both paths agree, with a regression over the None case.
@v0ropaev

v0ropaev commented Oct 7, 2026

Copy link
Copy Markdown
Author

Good catch, that path was open. Pushed 9bd55e1.

to_dict() now coalesces, so PermissionUpdate(type="setMode", mode="plan", destination=None) sends "session" instead of null. Needed a cast to keep mypy quiet, since the annotation no longer admits None and it called the or branch unreachable, and the comment next to it says why the branch is there anyway. from_dict() was already doing this with or "session", so the two paths agree now.

Regression test is test_explicit_none_destination_still_sends_session. It fails on de7a1fb with assert None == "session". Full suite 1591 passed, 6 skipped, ruff and mypy clean.

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants