Skip to content

feat(code): managed_config.toml - #5604

Merged
Mason Daugherty (mdrxy) merged 49 commits into
mainfrom
mdrxy/code/managed-config
Aug 19, 2026
Merged

feat(code): managed_config.toml#5604
Mason Daugherty (mdrxy) merged 49 commits into
mainfrom
mdrxy/code/managed-config

Conversation

@mdrxy

@mdrxy Mason Daugherty (mdrxy) commented Aug 18, 2026

Copy link
Copy Markdown
Member

Related: #5549

Administrators can now enforce dcode settings through OS-managed managed_config.toml files.


Adds fixed-path, read-only managed TOML policy for dcode with managed-first precedence, typed merge/provenance, fail-closed startup enforcement, diagnostics, and shadow-aware user writes. Migrates structured runtime readers while preserving public APIs and explicit custom-path seams. Requires Python 3.12+ (landed in #5603).

The path is fixed per platform and never read from process environment variables, so an unprivileged user cannot redirect it: /Library/Application Support/dcode/managed_config.toml on macOS, /etc/dcode/managed_config.toml on Linux, and <ProgramData>/dcode/managed_config.toml on Windows, where ProgramData comes from the registry rather than %ProgramData%.

Enforced keys

A managed value the manifest rejects is ignored and the lower-precedence value stays in effect — except for the enforced keys, where ignoring it would grant a privilege or remove a boundary the policy declared. Those stop every command except config, doctor, auth path, and the help screens:

startup.mode, startup.yolo_switcher, shell.allow_list, skills.extra_allowed_dirs, interpreter.enable_interpreter, interpreter.ptc, interpreter.ptc_acknowledge_unsafe, models.auto_classifier, runtime.recursion_limit, sandboxes.default, tracing.langsmith_redact

# /Library/Application Support/dcode/managed_config.toml
[startup]
mode = "manual"
yolo_switcher = "false"   # quoted boolean; the manifest rejects it

$ dcode
Error: Managed config at /Library/Application Support/dcode/managed_config.toml rejects startup.yolo_switcher. Ask your administrator to correct the value.
$ echo $?
78

Skipping that key would leave YOLO one Shift+Tab away while mode was pinned.

dcode doctor and dcode config stay reachable and report both halves of exit 78: a file that cannot be parsed, and one that parses but declares a value that cannot be enforced (dcode config path shows rejected).

Why this is stricter than the PRD

The PRD specifies tolerant parsing (Claude Code precedent): an invalid entry is stripped with a warning and the rest of the file is enforced, with fail-closed behavior reserved for security deny-lists. This PR keeps that default for everything else but widens the fail-closed set to the enforced keys above. For those keys, stripping a rejected managed value is not a safe fallback: the effective config falls through to the user tier or the default, which is more permissive than the policy the admin declared (e.g. a malformed shell.allow_list would silently leave shell access unrestricted). Failing closed with exit 78 turns a silent privilege grant into a loud, fixable startup error. Cosmetic and non-privilege keys still follow the PRD's strip-with-warning rule.

Behavior changes

  • [shell].allow_list is now read from ~/.deepagents/config.toml as well as DEEPAGENTS_CODE_SHELL_ALLOW_LIST, so a managed file can enforce it. A user can therefore also persist shell auto-approval in their own file, which an exported variable could not do.
  • A managed [mcp].enabled_project_server_approvals that is not an array denies. The key being present means policy intends to narrow access, so treating a malformed value as absent would keep both the user's approvals and the DEEPAGENTS_CODE_DANGEROUSLY_ENABLE_PROJECT_MCP_SERVERS bypass in force.
  • Embedders: resolve_scalar(option, toml_data=...) loads ambient managed policy when managed_toml_data is omitted. Pass managed_toml_data={} to keep a call hermetic.
Out of scope (tracked follow-ups)
  • P1: /config TUI and config CLI show which tier each value came from, building on the per-leaf provenance this PR's merge already records
  • macOS managed-preferences (plist) provider — designed for, not built; would join the managed tier above the file per the PRD's conditional add-on

Made by Open SWE

References

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
@github-actions github-actions Bot added dcode Related to `deepagents-code` dependencies Pull requests that update a dependency file feature New feature/enhancement or request for one internal User is a member of the `langchain-ai` GitHub organization size: XL 1000+ LOC labels Aug 18, 2026
@mdrxy Mason Daugherty (mdrxy) changed the title feat: add managed configuration [closes #5549] feat(code): managed configuration Aug 18, 2026
@mdrxy
Mason Daugherty (mdrxy) marked this pull request as ready for review August 18, 2026 19:26

@open-swe open-swe Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Open SWE Review found 3 potential issues.

Open in WebView Open SWE trace

Comment thread libs/code/deepagents_code/mcp_disabled.py Outdated
Comment thread libs/code/deepagents_code/configuration/resolver.py Outdated
Comment thread libs/code/deepagents_code/config_manifest.py

@corridor-security corridor-security Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On Windows, the managed configuration path is resolved via the process-controlled ProgramData/PROGRAMDATA environment variables, allowing any unprivileged user to redirect or suppress administrator-enforced policy (startup mode, sandbox, shell allow-list, MCP trust) by simply setting these variables before launching dcode.

Comment thread libs/code/deepagents_code/configuration/paths.py Outdated
Load the managed deny set independently of user-config health in
get_disabled_servers. Previously a syntactically invalid or unreadable
user config.toml short-circuited to an empty set before the managed
union ran, so a user could bypass administrator-denied MCP servers by
breaking their own config file.
A user table at the same key as a managed scalar (e.g. a
`[threads.relative_time]` table against a managed `relative_time = false`)
was kept by the merge, after which typed readers rejected the table and fell
back to the built-in default — silently ignoring the managed value. Higher
scalars now replace lower tables that only hold scalar leaves; lower tables
with nested tables still survive a wrong-typed higher scalar so valid lower
subtrees are not discarded.
`_load_theme_preference` returned the default theme immediately when
`DEEPAGENTS_CODE_THEME` held an unknown theme, while the config-manifest
resolver used by `dcode config get display.theme` fell through to the
user's saved `config.toml` theme for the same value. The two paths now
agree: an invalid env value warns and falls through, so the reported
effective theme matches what the TUI actually starts with.
@mdrxy Mason Daugherty (mdrxy) changed the title feat(code): managed configuration feat(code): managed_config.toml Aug 18, 2026
… vars

%ProgramData% can be redefined by any unprivileged user in their own
shell, redirecting the managed-config lookup to a user-controlled path
— replacing admin policy with a crafted file, or dropping enforcement
entirely when the redirected location has no file. Read ProgramData
from HKLM\\...\\Shell Folders\\Common AppData instead, falling back to
the hardcoded C:/ProgramData default when the registry query fails.
The deep merge kept any user table that held a non-empty nested table, so
one extra level of nesting let a user defeat a managed scalar. A user
`[threads.relative_time.nested]` table beat a managed
`relative_time = false`: the typed reader then rejected the surviving
table and fell back to the built-in default, which silently voided the
policy. Every `ConfigSources.merged()` consumer inherited this, including
sandbox defaults, model policy, skills directories, and async subagents.

Depth is no longer consulted. `higher_leaf_is_valid` already keeps a
wrong-typed managed scalar from discarding a valid user subtree, so the
shape heuristic is only used when no validator is supplied.

Also single-source the union deny-list paths as `UNION_PATHS` and pass
them to the manifest merge, so a third deny list cannot get union
semantics in one place and replace in another, and compare `OptionKind`
by identity rather than by its string value.
A user owns `~/.deepagents/config.toml`, so raising on an unusable one
made a single invalid byte an unprivileged way to switch administrator
policy off. `_load_effective_config_data` raised `OSError` before the
managed layer was consulted, and eight readers then fell back to
built-in defaults; six other readers returned early for the same reason.
A corrupt user file now drops only the user layer, and the managed
values still apply. `SandboxConfig` keeps reporting the problem through
`parse_error` while applying managed providers.

Fail closed on managed MCP policy that cannot be read. The managed
branch discarded the malformed flag from `_toml_str_list` and only
warned for a non-table `[mcp]`, so an administrator typo produced an
empty deny set with no signal, while the same typo in the user file
failed closed. Both now set `read_error`.

Gate managed health before policy is applied, not ~620 lines later, so
early-exit subcommands cannot run unpoliced, and gate it on `/reload`
too, which now keeps the previous settings rather than silently
continuing with no policy. Log when an unreadable managed file disables
update checks, so "disabled by policy" and "policy unreadable" are
distinguishable.

Route the `shell.allow_list` TOML array through the same parser as the
string form. A managed `["all"]` permitted one literal command named
`all` instead of every command, and `["recommended"]` did not expand.

Edit the disabled-server set inside the writer's lock instead of
overwriting the file with a snapshot read before the lock, which
clobbered concurrent writes to sibling tables.

Report the parse detail and the remedy in `doctor`, and restore the
rationale comments whose code survived the refactor.
The README documented `%ProgramData%` resolution that the CLI
deliberately stopped using: the path comes from the registry, because
any user can redefine that variable. This contradicted both the code and
THREAT_MODEL.md.

State the rules the merge actually applies: a wrong-typed managed value
is skipped and the lower value stays in effect, a managed scalar
replaces a colliding user table at any depth, an unusable user file does
not disable policy, and an unusable managed file blocks `/reload` as
well as launch. All subcommands stay usable for recovery, not only the
four the README listed.
The managed path is fixed per platform, so the suite read whatever
policy the developer's machine had installed and unrelated tests changed
behavior. An autouse fixture points it at a missing file and clears the
process-wide snapshot around every test.

Cover the regressions directly: a managed scalar beats a colliding user
table at four nesting depths, a wrong-typed managed scalar still keeps
the user subtree, managed policy survives a corrupt user config, a
malformed or non-table managed `[mcp]` fails closed, and the
`shell.allow_list` array honors the same sentinels as the string form.

@open-swe open-swe Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Open SWE Review found 1 potential issue.

Open in WebView Open SWE trace

Comment thread libs/code/deepagents_code/config_manifest.py Outdated
Joining the array into the comma-separated string form and reparsing it destroyed element boundaries: managed `allow_list = ["my,tool"]` auto-approved two executables (`my` and `tool`) instead of the single listed command. Parse TOML array elements individually via a new `parse_shell_allow_list_items` helper that shares the `all`/`recommended` sentinel rules with the string parser.
A broken or unreadable managed_config.toml parsed to an empty table, and
several readers could not tell that apart from "no policy is set". Each
such reader silently dropped enforcement.

Keep the cached managed snapshot across a failed reload. `get_managed_snapshot`
now installs a refreshed snapshot only when it is usable, and returns the
failed load so health checks still see the error. `Settings._reload_values`
refreshes in place instead of clearing the cache first; clearing it left every
other reader with an empty managed table for the rest of the session, so a
policy push that broke the file disabled policy process-wide.

Fail closed on an unreadable MCP deny list. `_managed_disabled_servers` raises
instead of returning an empty set, which had re-enabled every server an
administrator denied. `mcp_tools` denies all servers, `is_server_disabled`
returns True, and `set_server_disabled` refuses a re-enable whose managed
shadow is unknown.

Gate every command on managed health except `config`, `doctor`, `auth path`,
and `help`, which are the tools needed to diagnose a broken file. Subcommands
previously ran with policy unenforced.

Reject invalid managed values for the privilege keys. `startup.mode`,
`shell.allow_list`, `interpreter.enable_interpreter`, `interpreter.ptc`, and
`runtime.recursion_limit` stop the launch with exit 78 instead of being
skipped; skipping left the user's `--yolo` or `--shell-allow-list all` in
force. `runtime.recursion_limit` is also range-checked before it displaces the
flag, because `resolve_scalar` applies no bounds and the flag outranks the
bounded resolver at agent-build time.

Rebase UNION_PATHS onto the option subtree through `union_paths_under`. The
absolute deny-list paths never matched a merge rooted at an option, so the set
was read and silently ignored.

Match `_option_provenance` to `ConfigSources.merged`. Without the validator
and the union set it attributed leaves to config.toml that managed policy
controls, in the output used to audit enforcement.

Warn in `dcode config` when the managed file exists but cannot be parsed, in
text and JSON. The table otherwise looked clean.

Recompute the MCP deny set inside the write lock, so a concurrent disable of a
different server is not lost. Copy the managed table in `ModelConfig.load`,
which retained a live sub-dict of the shared snapshot. Run the writer's
`mutate` callback outside the I/O handler, so a caller's closure bug no longer
reports as "could not update <path>". Log the Windows registry fallback, which
silently redirected the lookup to a hardcoded path. Name the environment
variable, not managed config, when `--auto-update` diverges.

Remove the unused precedence engine: ConfigResolver, MergeStrategy, the four
unused providers, Found/Unset/Invalid/ResolvedValue, TomlFileProvider.get_path,
and load_merged_config_toml. It had no production caller and its semantics
already differed from the shipping merge, so a later fix could land in the
engine that does not run.

Correct the docs. Managed values override eight named CLI destinations, not
every flag. A wrong-typed managed value is ignored except inside structured
tables and for the privilege keys. Five screens report a shadowed preference,
not the whole UI.
`--sandbox` declares `default="none"`, so an omitted flag never leaves
`args.sandbox` as `None`. `_apply_managed_sandbox` guarded only on `None`,
so a managed `[sandboxes].default` was assigned to every launch: a bare
`dcode` was pushed into a remote sandbox, an explicit `--sandbox none` was
overridden, and an unavailable managed backend exited 78 on a launch that
asked for no sandbox at all. The function documented the opposite.

Guard on both spellings of "no sandbox", matching
`_resolve_and_validate_sandbox`.

The regression tests could not catch this: `_managed_policy_args` built
`sandbox=None`, a value `parse_args` never produces, so
`test_managed_sandbox_default_does_not_force_a_sandbox` passed against the
broken behavior. The fixture now uses argparse's real default and the test
covers both spellings.
Two fail-opens in `load_mcp_server_trust_lists`, both of which turned a
managed policy that narrows access into one that widens it.

A wrong-typed `enabled_project_server_approvals` (a quoted string instead of
an array) left `managed_approvals_explicit` false. The key is present, so
policy means to replace the user's remembered approvals and drop the env
bypass; reading its presence as absence kept both in force. Now it denies and
records `read_error`, matching `disabled_project_servers` in the same block.

An unusable managed file left the same flag false, so
`DANGEROUSLY_ENABLE_PROJECT_MCP_SERVERS` grants returned — corrupting the
file converted a managed suppression into a permit. A deny list that cannot
be read denies everything.

The first change inverts a prior decision, pinned by
`test_wrong_typed_managed_mcp_allow_list_does_not_mask_env_grant`, that a
malformed grant key should be skipped rather than become lockdown. The skip
is what made a typo widen access, and `read_error` surfaces the cause, so
this is a visible failure rather than a silent lockdown. The test is rewritten
to pin the fail-closed behavior and says why it changed.
`ENFORCED_MANAGED_KEYS` omitted three settings matching its own stated spec,
so a value the manifest rejects fell through to the user's flag:

- `startup.yolo_switcher` — the sharpest gap. Policy could pin
  `startup.mode` while YOLO stayed one Shift+Tab away from the same file, and
  a quoted boolean is the likeliest way to write it wrong.
- `interpreter.ptc_acknowledge_unsafe` — gates exposing every tool to
  programmatic tool-calling; its sibling `interpreter.ptc` was already
  enforced.
- `tracing.langsmith_redact` — defaults to off, so a rejected managed value
  silently uploads unredacted traces.

Also pins the tuple to the manifest. `managed_policy_violations` skips a key
it cannot resolve, and skips silently, so a rename would have turned
enforcement into a no-op with every test green. Two tests now guard it: one
that each key resolves to an option with `toml_keys`, and one that each key
can really produce a violation — a `STRUCTURED` option always reports its
managed value as managed-sourced, so listing one would imply enforcement that
never fires.

`mcp.enabled_project_server_approvals` is deliberately not added for exactly
that reason; it is `STRUCTURED`, and the preceding commit fails it closed in
the reader instead.
`shell.allow_list` gained `toml_keys` in this branch, and
`Settings.from_environment` resolves it through `load_config_toml()`.
`_reload_values` was not updated: it parsed only the env var and then layered
managed policy with `toml_data={}`. So every `/reload` and accepted cwd switch
reset a user's `[shell].allow_list` to `None` and reported a change that never
happened, and `preview_reload_from_environment` showed the same phantom diff.

Resolve through the same manifest path the initial load uses, which also
subsumes the separate managed block. `skills.extra_allowed_dirs` in this
function already read its user layer.

Direction was fail-closed, so this was a lost preference rather than an
escalation.
`doctor`, the `dcode config` warning, and `dcode config path` all branched on
`ProviderStatus.usable`, which is `health in {OK, MISSING}`. A file that parses
but declares an unenforceable enforced key has health `OK`, so the
`ManagedPolicyError` half of exit 78 produced a green "Managed config ... (ok)"
row, a clean config table with no warning at all, and `ok` from `config path`.

A user whose launch was just refused was told managed config was fine, which
defeats the reason `config` and `doctor` are exempt from the startup gate.

All three now consult `managed_policy_violations()` and name the offending
keys. `doctor` already paid for that call inside `managed_config_status` and
discarded the result.
`ConfigSources.merged` passes `higher_leaf_is_valid=is_valid_managed_scalar`,
and `_option_provenance` passes a rebased copy with a comment saying it "must
match `ConfigSources.merged` exactly". The structured branch of `resolve_scalar`
passed neither.

That is not cosmetic: `_merge` gates the "managed scalar displaces a lower
table" rule on `higher_leaf_is_valid is None`, so this path kept a user table
that `merged` replaces, independent of manifest nesting. `dcode config --json
--verbose` takes `value` and `source` from `resolve_scalar` and `provenance`
from the validated merge, so one row could report the user's table as effective
while its provenance said managed policy owned that leaf — and the runtime,
which reads `merged`, used the managed value. The module docstring claims
introspection can never drift from what the app actually reads.

Pass the same rebased validator the CLI already builds.
Two bad entries reached `dcode config --json --verbose`, the output an
administrator uses to audit what policy enforces.

`_leaf_provenance` treated an empty table as a leaf and joined its path, so an
empty `lower` table at the root produced the key `""`. That is the common case,
not a corner: every merge on a machine with no user `config.toml` emitted it.

A lower empty table that the higher table filled left an entry for the table
itself, so provenance claimed a table was a user-controlled leaf alongside the
managed leaves inside it. The entry enters the recursion through
`lower_provenance`, which carries the parent's own path, and the level that
fills the table never removes it — reachable from a bare `[themes]` section
header plus managed content underneath.

An empty table the higher layer does not fill stays a leaf: it is the only
record that the user declared that section.
…sifier flag

`_apply_managed_runtime_policy` assigned a managed `models.auto_classifier`
onto `args.auto_classifier_model`, but ACP mode rejects that flag unless
`--auto-approve` was passed, and the function deliberately declines to set
`auto_approve` positively. So an ACP user with managed policy got
"Error: --auto-classifier-model requires --auto-approve in ACP mode", naming a
flag they never passed.

That is the same failure class the `startup.mode` block already avoids, and the
fix is the same: don't set the flag. `build_server_config` falls through to
`resolve_auto_classifier_model_with_source` when the flag is unset, and that
reads managed policy at top precedence, so the value still reaches the runtime.
The test asserts both halves.
…time

`load_thread_config` caches its result on the default path, guarded by an
`except (OSError, TOMLDecodeError)` that returns without caching. That guard is
now dead for `config_path is None`: `_load_effective_config_data` only raises
for an explicitly requested path, and on the default path it logs the bad user
file and returns managed-only data instead.

So an unreadable `config.toml` produced a defaults-only `ThreadConfig` that was
cached and kept being returned after the user repaired the file, because nothing
on the read path calls `invalidate_thread_config_cache`.

Add `_user_config_layer_usable` and skip the cache assignment when the user
layer did not parse. A companion helper rather than a wider return type from
`_load_effective_config_data`, which has eleven callers that unpack two values.
`import tomli_w` sat inside the `try` that follows `tempfile.mkstemp`, so on an
install without the writer dependency the cleanup handler unlinked the temp path
but never closed the descriptor — only `os.fdopen` takes ownership of it. The
pre-refactor code imported before `mkstemp`, so this was a new leak window;
twenty failed writes leaked twenty descriptors, measured.

Import before `mkstemp`, and close the descriptor explicitly if `os.fdopen`
itself fails. Deliberately not a blanket `os.close` in the existing handler:
once `os.fdopen` succeeds and its `with` block exits, the number is free for
another thread to reuse, and closing it again could close an unrelated file.
`read_error` was a single `str | None` assigned at seven sites. The user-layer
branches run first and the managed-layer branches run second, so the managed
message overwrote the user one: someone with a corrupt `config.toml` *and* a
corrupt managed file was told about the managed file only, and had no way to
learn their own file was also broken.

Accumulate into a list and join. `approvals` now keys off the list being
non-empty, which is the same condition it tested before.
`configuration/writer` builds `WriteResult.error` carefully — it carries the
path and the exception, and `__post_init__` enforces that a failure always has
one — and both consumers threw it away.

The `app.py` toasts logged it and showed a static message. In the TUI that
logger has no handler unless debug mode is on, so this was a net regression in
user-facing detail: the pre-refactor toast included the exception type. A user
whose home directory was read-only saw "Timestamps toggled for this session but
could not be saved." with no way to learn the cause.

`mcp_disabled._save_disabled_entry` returned a bare `bool`, so
`set_server_disabled` reported "could not write <path>" and dropped "Permission
denied", "No space left on device", and the missing-`tomli_w` case the writer
deliberately catches. It now returns the reason instead.
`THREAT_MODEL.md` claimed that inside a structured table "the merge runs without
the manifest validator, so a user nested table survives a colliding managed
scalar". That contradicted `ConfigSources.merged`, which every structured reader
uses and which does pass the validator, and it is doubly wrong now that
`resolve_scalar` passes one too. The parallel README bullet was already right;
both now say the same thing.

Also updates both files for the three keys added to `ENFORCED_MANAGED_KEYS`, and
corrects three narrower-than-the-code statements:

- Enforcement stops every command except the diagnostics, not just "the launch".
- `skills.extra_allowed_dirs` has no CLI flag, so "leaves the user's flag in
  force" now reads "flag or environment variable".
- A managed `[mcp].disabled_servers` may be a comma-separated string, which
  `_strict_entries` accepts; the README said only an array was valid.

Documents the managed allow-list fail-closed behavior and the `dcode config`
warning for a file that parses but cannot be enforced, both added earlier in
this branch.

@open-swe open-swe Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Open SWE Review found 1 potential issue.

Open in WebView Open SWE trace

Comment thread libs/code/deepagents_code/main.py Outdated
When managed config declares `models.auto_classifier`, clear the CLI flag
to `None` in `_apply_managed_runtime_policy` so `build_server_config`
falls through to the resolver instead of treating the flag as an explicit
override. The flag is cleared rather than assigned so ACP never names a
`--auto-classifier-model` the user did not pass (which exits 2 without
`--auto-approve`).

The fall-through resolver `resolve_auto_classifier_model_with_problem` now
resolves the managed tier before the blank-env veto, matching
`resolve_auto_classifier_model_with_source` so the runtime and `dcode
config` agree on which model reviews Auto-mode gated actions.
…d keys

Managed policy no longer goes silently inert when its path cannot be
resolved, and every key it declares is now either applied, enforced, or
named in the diagnostics.

---

Review of #5604 found three ways an administrator could believe policy
was in force while it was not, plus one inverted risk ordering.

## A guessed path is no longer a clean missing file

`_program_data_from_registry` falls back to `C:/ProgramData` when
`winreg` cannot be imported or `HKLM\...\Shell Folders` cannot be read.
On a host whose ProgramData is relocated — with an ACL-hardened registry
key, which is the kind of hardening an enterprise deploying policy
applies — the guessed path holds no file. That reported `MISSING`, which
`ProviderStatus.usable` admits, so the startup gate passed and every
reader saw an empty managed table. `startup.mode`, `shell.allow_list`,
and every MCP deny were inert, with exit code 0 and nothing on stderr.

`ProviderHealth.INDETERMINATE` now covers "the path is a guess", and
`usable` excludes it:

```console
$ dcode
Error: Managed config location could not be determined: ProgramData could not be read from the registry (PermissionError); looked under C:/ProgramData. Ask your administrator to verify the managed-config path.
$ echo $?
78
```

The reason no longer travels in a module global read after the fact.
`resolve_managed_path` returns a frozen `ResolvedManagedPath(path,
fallback)`, so the pairing cannot come apart — an explicit-path load
previously picked up whichever fallback reason a prior call had left
behind. `managed_config_path` stays for display and error messages, and
both entry points delegate to one private resolver so redirecting either
cannot change what the other computes.

## Diagnostics cannot report `ok` for a file that exits 78

`doctor.py` and `client/commands/config.py` paired
`managed_config_status(refresh=True)` with a bare
`managed_policy_violations()`. The second call read the *cached*
snapshot, and `get_managed_snapshot` deliberately declines to cache a
candidate it cannot enforce — so a refreshed status described the file on
disk while the violations came from the last enforceable snapshot:

```
prime (enforceable): {'startup': {'mode': 'manual'}}   # then edited to mode = "YOLO"
doctor row  -> Managed config | /…/managed_config.toml (ok) | ok = True
config path -> ok
real violations -> ('startup.mode',)
```

`managed_health()` now returns provider health, violations, and ignored
rejections from one snapshot, so the three surfaces cannot disagree.
`managed_policy_violations` takes its table as a required argument; the
default that read the cache is what allowed the divergence.

## An ignored managed value is no longer silent

Only `ENFORCED_MANAGED_KEYS` stops a launch. Every other rejected managed
value falls through to the user tier by design, announced through
`logger.warning` — which `install_log_buffer` makes unreachable from
stderr, because a handler on the package logger means `logging.lastResort`
never fires. An administrator who wrote `max_tokens = "8000"` got a clean
green table and no way to learn the value was dropped.

`managed_rejections()` scans the manifest for declared-but-unreadable
managed values, and `dcode doctor` and `dcode config` both name them,
separately from the keys that stop the launch:

```console
$ dcode doctor
Managed config  /Library/Application Support/dcode/managed_config.toml (ok) - ignores models.max_tokens
```

## Update settings fail closed on any policy error

`_managed_update_value` returned "managed config does not decide" for a
present-but-wrong-typed value, handing the choice back to the user's env
var and `config.toml` — while *deleting* the managed file correctly forced
auto-update off. An administrator who typed `auto_update = "false"` on a
locked-down fleet silently kept the permissive default. A present value
that cannot be read now takes the same branch as an unreadable file.
`is_auto_update_explicitly_set` also counts managed policy, so the
one-time migration notice no longer claims the implicit default is in
force on a machine where an administrator set the value.

## Other fixes from the same review

- `[effort]` joins `MANAGED_TABLE_PATHS`. It is a real section that
  `model_config` reads and writes with no manifest option, so the shape
  check never covered it and a managed `effort = "bad"` replaced the
  user's whole table.
- A deny list written as a comma-separated string now unions in the
  merge. `mcp_disabled._strict_entries` and `model_config._toml_str_list`
  both split it, so `resolve_scalar`, `merged()`, and the runtime gave
  three different answers for one file — a fail-open in the merged view
  whose provenance then credited `config.toml` for a leaf policy controls.
- Merge provenance is keyed by path tuple internally and joined only for
  display. TOML permits a quoted key containing dots, so a dotted string
  key let a user's `"a.b" = 1` drop a live sibling leaf from the audit
  view or mis-attribute it.
- `configuration.writer` refuses the managed path. `THREAT_MODEL.md`
  states read-only managed config as a security property; it held only
  because no caller passed that path.
- `Settings.from_environment` raises instead of silently resolving
  `shell.allow_list` or `skills.extra_allowed_dirs` from the environment
  alone when the manifest lookup fails — a fallback that, if reached,
  bypasses policy for two enforced keys.
- `_apply_managed_runtime_policy` asserts managed health instead of
  inferring it from an empty table, so a future entry point that skips
  the startup gate fails loudly rather than ignoring policy.
- `preview_reload_from_environment` no longer refreshes the process-wide
  snapshot: a dry run must not swap the policy every other reader sees.
  `_reload_values` also resolves the shell allow list from its `env`
  argument rather than letting an `os.environ` hit override it.
- The merged-source readers log when an unusable managed layer is dropped
  from the merge. They gated on the user layer's health only, so managed
  `[themes]`, `[models.providers]`, and `[sandboxes.providers]` could
  vanish with a warning about the user's file and nothing about policy.
- `sandboxes.default` no longer reads as a containment boundary. It names
  the backend for a sandboxed launch, so a launch that asked for none now
  prints a note instead of running on the host with a green `doctor` row.
- `TomlSnapshot` rejects data an unhealthy snapshot could not have read,
  and `WriteResult` rejects a success carrying an error detail.

## Comment rot this PR introduced

`mcp.disabled_servers` still said it "plays no part in the project-trust
security boundary", and its user-facing summary said "disabled by the
user". It is now in `UNION_PATHS` and drives a fail-closed path that
disables every MCP server. `model_config`'s trust-list comment
contradicted the line below it, `_save_theme_preference_result` and
`_save_terminal_theme_mapping_result` missed the managed-notice update
their three siblings got, and the `UNION_PATHS` docstring claimed an
invariant that two readers — `load_mcp_server_trust_lists` and
`get_disabled_servers` — do not honor, because they union name sets
rather than TOML tables.

`--recursion-limit` and `--shell-allow-list` help text no longer claim to
override a value managed policy can pin. Past-tense bug narration in
permanent docstrings is replaced by the invariant it protects; the
regression tests carry the history without rotting.

## One entry point for the managed-over-user merge

The merge was assembled by hand at three call sites, two carrying "must
match `ConfigSources.merged`" comments, with the union rule reimplemented
a fourth time. It had already drifted once: the site that omitted the
validator reported a user table as effective while its provenance
credited managed policy. `merge_managed_over_user()` is now the single
statement of that precedence, and `managed_decided(source)` replaces the
`== "managed config"` comparison the manifest docstring warned against —
which answers `False` for every combined label.

## Tests

`_managed_policy_args` started every revoked field at a user-set value.
`interpreter_tools=None` made the assertion that managed `interpreter.ptc`
clears it unfalsifiable: the field already held `None`, so a regression
that stopped clearing a user's `--interpreter-tools all` passed.

New coverage for the applied half of `startup.yolo_switcher`,
`tracing.langsmith_redact`, and `interpreter.ptc_acknowledge_unsafe`
(previously rejection-only); a negative control proving a benign managed
typo still launches and is still reported; the `[effort]` shape guard; the
writer's managed-path refusal; the quoted-dotted-key collision; both deny
list spellings; the `INDETERMINATE` gate; a `MANAGED_TABLE_PATHS` meta-test
matching the one for `ENFORCED_MANAGED_KEYS`; and the invariant that no
credential option reads managed policy.

`redirect_managed_config` replaces 45 in-test patches of one seam. Patching
`service.managed_config_path` alone no longer redirects a load, which is
the same class of partial-patch bug the fixture's comment already warned
about.

Also removed: a dead managed branch for credentials, unreachable because
no credential option has `toml_keys` (it cost 21 wasted `resolve_scalar`
calls per `dcode config` and implied policy could supply a credential);
`_nested_value`, which was `toml_lookup` without the shadowed-table
warning; and `get_config_sources(refresh_managed=...)`, which had no
callers. `_managed_table_paths` is now cached rather than rebuilt over 89+
options on every call, twice per startup.
`README.md` and `THREAT_MODEL.md` described protections that were either
broader or narrower than the code provides.

---

Corrections:

- The shape guarantee said a scalar at "any known configuration section"
  stops the launch. The check derives its paths from `MANAGED_TABLE_PATHS`
  plus the ancestors of manifest-backed options, so a section with no
  manifest option was not covered. `[effort]` is now guarded in code and
  the docs name the actual set.
- The structured-table list omitted `[ui.terminal_themes]` and
  `[threads.columns]`. Both are `STRUCTURED` with dedicated typed readers,
  so the documented "a wrong-typed managed leaf can displace a valid user
  leaf" behavior applies to them too. A reader auditing which managed
  leaves are unvalidated would have missed two tables.
- The enforced-key paragraph said the keys "stop every command". A key
  does not stop a command; an unenforceable declaration of one does.
- Two different exemption lists described the same gate. The first omitted
  `--version`. Both conditions raise through one gate, so the set is
  stated once and referenced.
- Windows fail-closed behavior for an unreadable ProgramData registry
  value is documented.
- "An empty managed list is a lockdown" was figurative and ambiguous about
  deny lists, which union — an empty managed deny list removes nothing.
  Both deny-list spellings are documented.
- Changelog voice ("is now read from…", "was needed before") replaced with
  the current rule, which does not go stale.
- macOS `/Library/Application Support` is group-writable by `admin` on a
  stock install, so any admin-group member can create the file and grant
  themselves policy. `THREAT_MODEL.md` already places ownership validation
  outside the CLI; TB12 now names this case, since deployments relying on
  that boundary must tighten the directory.
- TB12's 90-word enforced-key bullet is split into one condition per
  sentence.

@open-swe open-swe Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Open SWE Review found 1 potential issue.

Open in WebView Open SWE trace

Comment thread libs/code/deepagents_code/configuration/service.py
Guarding only `("effort",)` still passed a managed `[effort]` table whose
`by_model` was a scalar, and no manifest option supplies a type for that
path, so the merge replaced the user's `[effort.by_model]` table with the
managed scalar and `load_effort_for_model` returned `None` instead of the
user's stored preference.
@mdrxy
Mason Daugherty (mdrxy) merged commit d419122 into main Aug 19, 2026
73 checks passed
@mdrxy
Mason Daugherty (mdrxy) deleted the mdrxy/code/managed-config branch August 19, 2026 22:57
Mason Daugherty (mdrxy) pushed a commit that referenced this pull request Aug 20, 2026
> [!CAUTION]
> Merging this PR will automatically publish to **PyPI** and create a
**GitHub release**.

For the full release process, see
[`.github/RELEASING.md`](https://github.com/langchain-ai/deepagents/blob/main/.github/RELEASING.md).

---

_Release notes preview: keep this section in sync with the package
`CHANGELOG.md`. Publish reads the merged CHANGELOG via `release.yml`,
not this PR description — keep them aligned anyway so the PR stays an
accurate historical record for reviewers and anyone returning later._

---


##
[0.1.59](deepagents-code==0.1.58...deepagents-code==0.1.59)
(2026-08-20)

### Features

- Added support for `managed_config.toml` configuration
([#5604](#5604))
- Multi-select `ask_user` answers are now encoded as JSON arrays
([#5660](#5660))
- Made teardown usage stats configurable
([#5696](#5696))
- Footer pickers now open on click
([#5674](#5674))
- Replaced Gemini 3.6 Flash with Gemini 3.7 Flash
([#5681](#5681))

### Bug fixes

- Made tool argument validation errors recoverable
([#5659](#5659))
- Improved streaming performance for tool-call arguments to run in
linear time
([#5712](#5712))
- Fixed durable-mask config resolution with ranked resolver behavior
([#5672](#5672))
- Skipped background sync in Apple Terminal
([#5666](#5666))
- Hid thread IDs when tracing is disabled
([#5692](#5692))
- Kept installed providers visible in `/auth`
([#5689](#5689))
- Preloaded the auth UI before notification handoff
([#5697](#5697))
- Updated and clarified UI copy across Auto mode, YOLO hints, classifier
notices, `/tokens`, line-number toggles, review failures, onboarding
Tavily cancellation, and OpenAI subscription login labels
([#5685](#5685),
[#5694](#5694),
[#5684](#5684),
[#5687](#5687),
[#5680](#5680),
[#5688](#5688),
[#5686](#5686),
[#5691](#5691),
[#5693](#5693))
- Removed the `Muse Spark 1.1` recommendation
([#5683](#5683))

_End release notes preview._

---

> [!NOTE]
> A **community contributors** list and a **Special thanks** section
(crediting the users who filed the issues this release's PRs closed) are
appended to the GitHub release notes automatically at publish time (see
[Release
Pipeline](https://github.com/langchain-ai/deepagents/blob/main/.github/RELEASING.md#release-pipeline),
step 3).

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: langchain-oss-automated-triage[bot] <248757908+langchain-oss-automated-triage[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dcode Related to `deepagents-code` dependencies Pull requests that update a dependency file feature New feature/enhancement or request for one internal User is a member of the `langchain-ai` GitHub organization size: XL 1000+ LOC

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant