feat(code): managed_config.toml - #5604
Merged
Merged
Conversation
Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
Mason Daugherty (mdrxy)
marked this pull request as ready for review
August 18, 2026 19:26
Contributor
There was a problem hiding this comment.
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.
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.
managed_config.toml
… 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.
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.
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.
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.
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Related: #5549
Administrators can now enforce dcode settings through OS-managed
managed_config.tomlfiles.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.tomlon macOS,/etc/dcode/managed_config.tomlon Linux, and<ProgramData>/dcode/managed_config.tomlon 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_redactSkipping that key would leave YOLO one Shift+Tab away while
modewas pinned.dcode doctoranddcode configstay 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 pathshowsrejected).Behavior changes
[shell].allow_listis now read from~/.deepagents/config.tomlas well asDEEPAGENTS_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.[mcp].enabled_project_server_approvalsthat 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 theDEEPAGENTS_CODE_DANGEROUSLY_ENABLE_PROJECT_MCP_SERVERSbypass in force.resolve_scalar(option, toml_data=...)loads ambient managed policy whenmanaged_toml_datais omitted. Passmanaged_toml_data={}to keep a call hermetic.Out of scope (tracked follow-ups)
/configTUI andconfigCLI show which tier each value came from, building on the per-leaf provenance this PR's merge already recordsMade by Open SWE
References