Skip to content

fix(config): refuse a legacy placeholder over the native secret mask - #1500

Open
SebTardif wants to merge 27 commits into
openclaw:mainfrom
SebTardif:fix/f061-config-redaction-save
Open

SebTardif wants to merge 27 commits into
openclaw:mainfrom
SebTardif:fix/f061-config-redaction-save

Conversation

@SebTardif

@SebTardif SebTardif commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

What Problem This Solves

Fixes: a direct Config edit can put a legacy placeholder where the gateway shows its native secret mask. The gateway already restores __OPENCLAW_REDACTED__ before it writes. This client guard refuses that placeholder before the patch is sent, and an untouched mask is left out of the patch.

User Impact

User impact: a direct edit that replaces the native mask with a legacy placeholder such as *** is refused, and the draft stays. An unrelated Config save leaves __OPENCLAW_REDACTED__ out of the outgoing patch.

Why This Change Was Made

config.get replaces a stored secret with the exact mask __OPENCLAW_REDACTED__. The gateway restores that mask before it writes. It is not a legacy placeholder, so a Channels save still sends it when another channel shows the mask.

The Config editor omits the mask only on an untouched credential such as apiKey. A direct edit of that credential to a legacy placeholder is refused locally, the same as a parent edit. A SecretRef id is not a credential, so replacing that id still stays in the patch.

5fa003d1 had put the mask in the shared sentinel set. That blocked a Channels save and dropped a SecretRef id. fffc8472 separated those cases. 6ad6ed0f teaches the Config loaded-placeholder check to recognize the native mask without putting it back in the Channels set.

Evidence

Head 6ad6ed0f3b253d7577701ac95db040bc4b0acd68. A console called ConfigEditorModel.FindUneditedRedactionSentinel and ConfigEditorModel.OmitUntouchedRedactionSentinels on the document returned by config.get. Those are the two calls ConfigPage.OnSaveAsync makes after ApplyChanges.

The peer was a disposable OpenClaw 2026.9.6 (eb377ac) gateway run on loopback port 19242, with its own config file. It was not the installed gateway on port 18789. The product windows-wsl-gateway-e2e suite was not run. That process was stopped after the trace. Ports 18789 and 19241 were still listening.

Allowed save, edited path models.providers.custom.baseUrl. The outgoing document omitted apiKey. config.patch was sent with { raw, baseHash }.

MODE allow
loadedApiKeyIsMask True
blocked none
wouldSend True
sentHasApiKey False
base https://page.example
modelsLen 1
hashSet True
ok True
disk_matches_secret True
disk_is_legacy False
disk_base https://page.example
disk_models_len 1
disk_model_id_m1 True

Direct edit, edited path models.providers.custom.apiKey, replacement ***. No config.patch was sent. A later config.get still returned the gateway mask for that key, and the on-disk secret still matched the value seeded before the save. The secret is not printed here.

MODE direct
loadedApiKeyIsMask True
blocked models.providers.custom.apiKey
wouldSend False
sentHasApiKey True
sentApiKeyIsLegacy True
base https://page.example
modelsLen 1
disk_matches_secret True
disk_is_legacy False

disk_matches_secret compares the on-disk provider secret with the value seeded before the saves.

The same head was then opened in the isolated tray Config page against a new disposable OpenClaw 2026.9.6 gateway on loopback port 19244, auth mode none, paired in this session. The page subtitle named the disposable config file. The loaded provider showed Api Key as the gateway mask and Base Url https://old.example.

Allowed save: Base Url was set to https://page.example and Save Changes was clicked. The page said "Configuration saved and the gateway connection is back. Refreshing the latest config." The Base Url field read back https://page.example. The on-disk secret still matched, models length stayed 1, and the model id stayed m1.

Refused save: Api Key was set to *** and Save Changes was clicked. The page stayed on 1 unsaved change and showed:

Save failed
Save blocked. A redacted placeholder is still present at models.providers.custom.apiKey. The value was not sent.

The JSON diff kept the draft:

- "apiKey": "__OPENCLAW_REDACTED__",
+ "apiKey": "***",
  "baseUrl": "https://page.example",

The on-disk secret still matched and the base URL stayed https://page.example. The window was captured with PrintWindow. GitHub asset upload returned HTTP 400 Invalid name for request, so the image is not linked here. The status text above is what that window showed, including Connected, Unsaved changes, and Save Changes still enabled. A SecretRef id was not opened on the page.

Change Type

  • Bug fix
  • Feature
  • Refactor
  • Docs or instructions
  • Tests or validation
  • Security hardening
  • Chore or infrastructure

Scope

  • Tray or WinUI UX
  • Windows node capability
  • Local MCP or winnode
  • Gateway, connection, or pairing
  • Setup or onboarding
  • Permissions, privacy, or security
  • Tests, CI, or docs

Required proof pools

  • windows-winui-interactive: Config page save and local refusal. Verified for this head: isolated Config page on 6ad6ed0f, Save Changes for Base Url, then Save Changes for an Api Key placeholder. The refusal banner and the unsaved draft are quoted above. Not verified / blocked: the screenshot file was not uploaded (HTTP 400). A SecretRef id was not opened on the page.
  • windows-wsl-gateway-e2e: real gateway preservation of a stored secret. Verified for this head: config.get and one config.patch against disposable OpenClaw 2026.9.6 on port 19242, as quoted above. Not verified / blocked: the product WSL setup, pairing, and recovery suite was not run. Port 18789 was left untouched.

Validation

Worktree C:\Users\sebta\wt-wn-1500, OPENCLAW_REPO_ROOT set to that path. Windows x64.

  • ./build.ps1: passed. Shared, Cli, WinNodeCli, SetupEngine, and WinUI.
  • dotnet test .\tests\OpenClaw.Shared.Tests\OpenClaw.Shared.Tests.csproj --no-restore: passed, 4100 passed / 32 skipped / 0 failed (4132 total). That suite includes BuildPatch_AllowsNativeGatewayMaskInAnotherChannel.
  • dotnet test .\tests\OpenClaw.Tray.Tests\OpenClaw.Tray.Tests.csproj --no-restore on this tree: 3098 passed / 0 skipped / 5 failed (3103 total). The five failures are existing source-contract checks that search for CRLF in ConnectionPage.xaml.cs, diagnostics, local AI setup, and speech input. Those files are not in this commit. The working tree copies are LF. This is fix(tests): tray source checks fail when the checkout is LF #1518 on this host.
  • Focused tests on this head: GatewayRedactionMask_DirectEdit_RefusesLegacyPlaceholder, GatewayRedactionMask_DirectSecretRefIdEdit_KeepsTheReplacement, GatewayRedactionMask_ParentEdit_RefusesAndKeepsTheDraft, GatewayRedactionMask_SecretRefProviderEdit_KeepsTheMaskedId, and GatewayRedactionMask_UnrelatedEdit_OmitsApiKeyAndKeepsModels (5 passed).
  • Required CI on 6ad6ed0f was still starting when this body was written. Required CI on the parent fffc8472 had passed, including Core, Tray, UI, Setup, Network, and Revocation.

Real Behavior Proof

  • Environment tested: Windows x64, Ubuntu-24.04, disposable OpenClaw 2026.9.6 (eb377ac) on ws://127.0.0.1:19242.
  • PR head or commit tested: 6ad6ed0f3b253d7577701ac95db040bc4b0acd68.
  • Exact steps or command run: config.get and config.patch on port 19242, then the isolated Config page on port 19244. Save Changes after editing Base Url, then Save Changes after editing Api Key to ***.
  • Evidence after fix: the allowed page save stored https://page.example and kept the seeded secret. The refused page save showed the blocked path models.providers.custom.apiKey, kept the unsaved draft, and left the on-disk secret in place.
  • Observed result: the allowed page save stored the new base URL and left the seeded secret on disk. The refused page save did not send the legacy placeholder, and the draft stayed.
  • Screenshot or artifact links verified? No. The window was captured. Upload returned HTTP 400, so the link is not in this body. The visible status text is quoted above.
  • Not verified or blocked: no product windows-wsl-gateway-e2e suite. The installed gateway on port 18789 was not the peer. A SecretRef id was not opened on the page. The Channels sentinel set is unchanged, and the shared suite that covers it passed.

Security Impact

  • New permissions or capabilities? No
  • Secrets or tokens handling changed? Yes
  • New or changed network calls? No
  • Command or tool execution surface changed? No
  • Data access scope changed? No
  • If any answer is Yes, explain the risk and mitigation: an outgoing Config patch omits __OPENCLAW_REDACTED__ when that credential was not edited. The disposable gateway kept the stored secret after that omission. A direct edit that replaces the mask with a legacy placeholder is refused before a patch is sent.

Compatibility and Migration

  • Backward compatible? Yes
  • Config or environment changes? No
  • Migration needed? No
  • If yes, list the exact upgrade steps: N/A

Review Conversations

  • I replied to or resolved every bot review conversation addressed by this PR.
  • I left unresolved only conversations that still need maintainer judgment.

- Block config.patch when an unedited field still holds a gateway redaction sentinel
- Allow a field the user replaced
- Do not treat the substring redacted inside ordinary text as a sentinel

Signed-off-by: Sebastien Tardif <SebTardif@ncf.ca>
@clawsweeper

clawsweeper Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

🦞👀
ClawSweeper picked this up.

Pull request received. I will update this pull request when review starts.

ClawSweeper review complete

ClawSweeper finished reviewing this revision. The review result is being finalized.

View the workflow run.

@clawsweeper clawsweeper Bot added P1 Urgent regression or broken agent/channel workflow affecting real users now. merge-risk: 🚨 compatibility 🚨 Merging this PR could break existing users, config, migrations, defaults, or upgrades. merge-risk: 🚨 security-boundary 🚨 Merging this PR could weaken sandboxing, authorization, credentials, or sensitive data. rating: 🧂 unranked krab Not merge-ready due to missing proof or serious correctness/safety concerns. status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask. labels Sep 24, 2026
@clawsweeper

clawsweeper Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

Codex review: needs maintainer review before merge. Reviewed October 7, 2026, 4:53 AM ET / 08:53 UTC (Revision 34).

ClawSweeper review

What this changes

The Config editor refuses unsafe legacy secret-placeholder edits, preserves the draft, omits untouched credential masks, and adds translated feedback and regression tests.

Merge readiness

✅ Ready for maintainer review

This PR remains useful and is ready for normal landing review. Earlier findings and the latest wording request are resolved; current-head page and Gateway evidence supports the changed behavior, with no remaining actionable blocker identified.

Priority: P2
Reviewed head: 6ad6ed0f3b253d7577701ac95db040bc4b0acd68

Review scores

Measure Result What it means
Overall readiness 🐚 platinum hermit (4/6) A useful, covered patch with convincing exact-head runtime evidence and no remaining blocking finding.
Proof confidence 🦞 diamond lobster (5/6) Sufficient (live_output): At head 6ad6ed0, the actual isolated Config page and disposable Gateway exercised both changed save outcomes: an unrelated edit saved and reloaded while preserving existing secrets and model state, and a legacy-placeholder edit was refused with the draft retained. Copied UI diagnostics and disk-state observations provide sufficient proof. The stored format is unchanged, and existing-state preservation was demonstrated; the broader WSL setup suite was not claimed as executed.
Patch quality 🐚 platinum hermit (4/6) No actionable review findings were identified.

Verification

Check Result Evidence
Real behavior Verified Sufficient (live_output): At head 6ad6ed0, the actual isolated Config page and disposable Gateway exercised both changed save outcomes: an unrelated edit saved and reloaded while preserving existing secrets and model state, and a legacy-placeholder edit was refused with the draft retained. Copied UI diagnostics and disk-state observations provide sufficient proof. The stored format is unchanged, and existing-state preservation was demonstrated; the broader WSL setup suite was not claimed as executed.
Evidence reviewed 9 items Pinned introduction and save boundary: The merge-base-to-head diff introduces the local refusal and omission calls before PatchConfigDetailedAsync. Refusal returns without clearing pending changes; the existing finally block restores the page controls. The stale test merge was not used to infer removal of main behavior.
Earlier source findings resolved: The current model carries literal JSON baseline properties through recursion, matches existing rows by ID, records non-ID array locations directly, and recognizes a loaded native credential mask when refusing a direct legacy-placeholder replacement. Native masks remain outside the Channels sentinel set, and SecretRef IDs remain in outgoing patches.
Current-main necessity: Current main still sends ApplyChanges directly to PatchConfigDetailedAsync without this defensive placeholder guard. The latest supplied release, v2026.9.4, also lacks the added model behavior. No merged fixing PR was established; the merged Channels repair is an adjacent save owner.
Findings None None.
Security None None.

How this fits together

The Windows Config page loads configuration from the Gateway and stages local edits. Its save model prepares a patch before the Gateway validates and persists it.

flowchart TD
  A[Gateway configuration snapshot] --> B[Config page draft]
  C[User edits] --> B
  B --> D[Placeholder safety check]
  D -->|Unsafe edit| E[Show field and retain draft]
  D -->|Allowed edit| F[Omit untouched credential masks]
  F --> G[Gateway validates and saves]
Loading

Before merge

None.

Agent review details

Security

None.

Review metrics

Metric Value Why it matters
Production and test growth Production +687/-2 lines; tests +1323/-0 lines; localized resources +18 lines The production growth implements recursive placeholder handling across object and array edits, with substantially larger regression coverage.

Technical review

Best possible solution:

Keep the local defensive guard while preserving Gateway-native restoration, SecretRef validation, safe unrelated saves, and recoverable drafts.

Do we have a high-confidence way to reproduce the issue?

Yes. Current main sends a directly staged legacy placeholder without this local guard; the branch's exact-head real-page evidence demonstrates refusal before sending and retention of the draft. This review did not execute a current-main reproduction.

Is this the best way to solve the issue?

Yes. The guard acts at the existing save owner, preserves native Gateway semantics, and addresses the earlier container-edit regressions without adding configuration or a competing save path.

AGENTS.md: found and applied where relevant.

Codex review notes: model internal, reasoning medium; reviewed against 94006b3672a0.

Labels

Label changes:

No label changes.

Label justifications:

  • P2: This is bounded defensive Config-save hardening; the Gateway already restores its native mask.
  • rating: 🐚 platinum hermit: Overall readiness is 🐚 platinum hermit; proof is 🦞 diamond lobster and patch quality is 🐚 platinum hermit.
  • status: 👀 ready for maintainer look: ClawSweeper has no concrete contributor-facing blocker left for this PR. Sufficient (live_output): At head 6ad6ed0, the actual isolated Config page and disposable Gateway exercised both changed save outcomes: an unrelated edit saved and reloaded while preserving existing secrets and model state, and a legacy-placeholder edit was refused with the draft retained. Copied UI diagnostics and disk-state observations provide sufficient proof. The stored format is unchanged, and existing-state preservation was demonstrated; the broader WSL setup suite was not claimed as executed.
  • proof: sufficient: Contributor real behavior proof is sufficient. At head 6ad6ed0, the actual isolated Config page and disposable Gateway exercised both changed save outcomes: an unrelated edit saved and reloaded while preserving existing secrets and model state, and a legacy-placeholder edit was refused with the draft retained. Copied UI diagnostics and disk-state observations provide sufficient proof. The stored format is unchanged, and existing-state preservation was demonstrated; the broader WSL setup suite was not claimed as executed.

Evidence

What I checked:

  • Pinned introduction and save boundary: The merge-base-to-head diff introduces the local refusal and omission calls before PatchConfigDetailedAsync. Refusal returns without clearing pending changes; the existing finally block restores the page controls. The stale test merge was not used to infer removal of main behavior. (src/OpenClaw.Tray.WinUI/Pages/ConfigPage.xaml.cs:553, 6ad6ed0f3b25)
  • Earlier source findings resolved: The current model carries literal JSON baseline properties through recursion, matches existing rows by ID, records non-ID array locations directly, and recognizes a loaded native credential mask when refusing a direct legacy-placeholder replacement. Native masks remain outside the Channels sentinel set, and SecretRef IDs remain in outgoing patches. (src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs:219, 6ad6ed0f3b25)
  • Current-main necessity: Current main still sends ApplyChanges directly to PatchConfigDetailedAsync without this defensive placeholder guard. The latest supplied release, v2026.9.4, also lacks the added model behavior. No merged fixing PR was established; the merged Channels repair is an adjacent save owner. (src/OpenClaw.Tray.WinUI/Pages/ConfigPage.xaml.cs:552, 94006b3672a0)
  • Release source check: The latest supplied release model contains ApplyChanges and SetPath but no placeholder scanning or omission helpers. (src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs:56, 3c43751b2bac)
  • Affirmative Gateway dependency and patch contract: The PR explicitly depends on config.patch omission and native-mask restoration. At the tested Gateway revision, config.patch enables ID-keyed object-array merging, restores redacted values, and rejects destructive array replacement without explicit intent. (src/gateway/server-methods/config.ts:1106, eb377ac59e6c)
  • Native-mask and SecretRef semantics: Gateway restoration treats OPENCLAW_REDACTED as its native mask. SecretRef restoration rejects a source/provider change with a retained masked ID, supporting the branch's decision to leave that ID visible to Gateway validation. (src/config/redact-snapshot.ts:451, eb377ac59e6c)

Likely related people:

  • ranjeshj: Suggested for follow-up; no historical authorship or introduction is verified. (role: unverified routing candidate; confidence: low)
  • SebTardif: Suggested for follow-up; no historical authorship or introduction is verified. (role: unverified routing candidate; confidence: low)
  • shanselman: Suggested for follow-up; no historical authorship or introduction is verified. (role: unverified routing candidate; confidence: low)

Rating scale

Score Internal tier Crab rank Meaning
6/6 S 🦀 challenger crab Exceptional readiness
5/6 A 🦞 diamond lobster Very strong readiness
4/6 B 🐚 platinum hermit Good normal PR; ordinary maintainer review
3/6 C 🦐 gold shrimp Useful, but confidence is limited
2/6 D 🦪 silver shellfish Proof or implementation needs work
1/6 F 🧂 unranked krab Not merge-ready
N/A NA 🌊 off-meta tidepool Rating does not apply

Overall follows the weaker of proof and patch quality.
Shiny media proof means a screenshot, video, or linked artifact directly shows the changed behavior. Runtime, network, CSP, and security claims still need visible diagnostics.

Workflow

  • ClawSweeper keeps one durable marker-backed review comment per issue or PR.
  • Re-runs edit this comment so the latest verdict, findings, and automation markers stay together instead of adding duplicate bot comments.
  • A fresh review can be triggered by eligible @clawsweeper re-review comments, exact-item GitHub events, scheduled/background review runs, or manual workflow dispatch.
  • PR/issue authors and users with repository write access can comment @clawsweeper re-review or @clawsweeper re-run on an open PR or issue to request a fresh review only.
  • Maintainers can also comment @clawsweeper review to request a fresh review only.
  • Fresh-review commands do not start repair, autofix, rebase, CI repair, or automerge.
  • Maintainer-only repair and merge flows require explicit commands such as @clawsweeper autofix, @clawsweeper automerge, @clawsweeper fix ci, or @clawsweeper address review.
  • Maintainers can comment @clawsweeper explain to ask for more context, or @clawsweeper stop to stop active automation.

History

Review history (33 earlier review cycles; latest 8 shown)
  • reviewed 2026-10-01T21:48:25.480Z sha ffef70b :: needs real behavior proof before merge. :: [P2] [P2] Remove non-ID arrays through their literal JSON location
  • reviewed 2026-10-01T22:09:26.034Z sha 15d0ee4 :: needs real behavior proof before merge. :: none
  • reviewed 2026-10-06T18:50:40.786Z sha 5fa003d :: needs real behavior proof before merge. :: [P1] [P1] Preserve native sentinel support in Channels saves | [P1] [P1] Preserve SecretRef identity checks when filtering native masks
  • reviewed 2026-10-06T19:11:05.433Z sha 5fa003d :: needs real behavior proof before merge. :: [P1] [P1] Preserve native sentinel support in Channels saves | [P1] [P1] Preserve SecretRef identity checks when filtering native masks
  • reviewed 2026-10-06T19:42:59.793Z sha fffc847 :: needs real behavior proof before merge. :: none
  • reviewed 2026-10-06T20:22:01.595Z sha fffc847 :: needs real behavior proof before merge. :: [P2] [P2] Recognize native masks in the Config loaded-placeholder check
  • reviewed 2026-10-06T21:53:44.895Z sha 6ad6ed0 :: needs real behavior proof before merge. :: none
  • reviewed 2026-10-06T22:24:54.087Z sha 6ad6ed0 :: needs changes before merge. :: none

Signed-off-by: Sebastien Tardif <SebTardif@ncf.ca>
@shanselman

Copy link
Copy Markdown
Collaborator

Maintainer disposition at head 1bb4cc578731: useful defensive guard, but not yet safe to merge. The final-document scan exempts every edited path, even if the edited value is still a recognized redaction placeholder; that can send the placeholder as the new secret. The blocked path is also discarded, so the user sees a generic Gateway rejection instead of a local refusal with a recovery path. Please define the edited-placeholder policy narrowly, preserve supported Gateway sentinel semantics, show the blocked field without exposing its value, and prove both an unsafe refused Config Save and a safe allowed Save through the actual page/Gateway path. The current Gateway native sentinel already has server-side handling; we are not claiming it always corrupts a secret. We can help with a small patch on this PR rather than superseding it.

@karkarl

karkarl commented Sep 24, 2026

Copy link
Copy Markdown
Collaborator

Global triage: HOLD_FOR_AUTHOR. Take confidence 10%; recommendation confidence 95%; effort small-to-medium; risk high.

Reviewed exact head 1bb4cc578731. ConfigEditorModel.cs:114-118 skips sentinel checks for edited paths, so a prefilled non-IsSensitive secret field can be touched and restored to <redacted>, then persisted literally. Array edits are staged at foo.bar while scanning reports foo.bar[0], so edited arrays can remain permanently blocked. ConfigPage.xaml.cs:553-561 discards the blocked path and reports a false gateway rejection, leaving users without a recovery route.

Owner: SebTardif. Check final sentinel values regardless of edited status, normalize array paths, report the blocked path without its value, and add regression cases for edited sentinels, arrays, and non-sensitive TextBox round trips. Then prove one refused unsafe save and one allowed safe save through windows-winui-interactive plus windows-wsl-gateway-e2e.

…ited

Signed-off-by: Sebastien Tardif <SebTardif@ncf.ca>
@SebTardif

Copy link
Copy Markdown
Contributor Author

a8418f9 blocks a final <redacted> value even when that field is in the edited set. An array element still blocks when the edit key omitted the index (accounts.token versus accounts[0].token). The save status names that path and does not include the value.

Local filter FindUneditedRedactionSentinel: 5 passed. A live WinUI refused save and allowed save were not run.

@clawsweeper clawsweeper Bot removed the merge-risk: 🚨 security-boundary 🚨 Merging this PR could weaken sandboxing, authorization, credentials, or sensitive data. label Sep 24, 2026
Neighboring Config page status strings are translated in the same
change. This sentence follows that pattern in French, Dutch,
Brazilian Portuguese, and both Chinese locales. The {0} path
placeholder stays. The locale parity test passes.

Signed-off-by: Sebastien Tardif <SebTardif@ncf.ca>
@SebTardif

Copy link
Copy Markdown
Contributor Author

The locale test failed because ConfigPage_StatusSaveBlockedSentinelFormat was the English sentence in every language file. Neighboring Config page status strings are translated in the same change, and the deferred list is for brand names, punctuation, and documented English-first groups. 01d2f19c translates this sentence in French, Dutch, Brazilian Portuguese, Simplified Chinese, and Traditional Chinese. The {0} path placeholder is unchanged. Resources_AreTranslatedAllOrNoneAcrossNonEnglishLocales passed locally. The setup jobs remain the shared #1498 gateway restart refusal.

@clawsweeper clawsweeper Bot added rating: 🦪 silver shellfish Thin PR readiness signal; proof, validation, or implementation needs work. and removed rating: 🧂 unranked krab Not merge-ready due to missing proof or serious correctness/safety concerns. labels Sep 25, 2026
Config save no longer refuses the whole document because another
field still holds a gateway mask. Untouched masks are omitted from
the patch so a merge keeps the stored secret. A save is refused
only when the edited value is still a loaded placeholder, or when
the placeholder sits in an array that cannot be omitted. A literal
mask typed into a field that was not redacted is sent.

Tests: FindUneditedRedactionSentinel 6 passed. ./build.ps1 exit 0.
Shared 4099 passed, 32 skipped. Tray 3070 passed. Five source
contract tests still fail on an LF checkout because they expect
CRLF snippets. That mismatch is the separate line-ending issue.

Signed-off-by: Sebastien Tardif <SebTardif@ncf.ca>
An untouched array entry equal to a mask blocked every other
Config edit. A list such as labels keeps that text and no longer
blocks the save. A credential field inside an array, such as an
account token, still blocks until it is replaced.

Tests: FindUneditedRedactionSentinel 7 passed. ./build.ps1 exit 0.
Shared 4099 passed, 32 skipped. Tray 3071 passed. Five source
contract tests still fail on an LF checkout because they expect
CRLF snippets.

Signed-off-by: Sebastien Tardif <SebTardif@ncf.ca>
@clawsweeper clawsweeper Bot added the merge-risk: 🚨 security-boundary 🚨 Merging this PR could weaken sandboxing, authorization, credentials, or sensitive data. label Sep 27, 2026
An untouched accounts[0].apiKey redaction sentinel was not treated as a credential, so a later save could send the placeholder.

Signed-off-by: Sebastien Tardif <SebTardif@ncf.ca>
…ing the save

An unrelated Config save stopped when an array still held a redacted token, apiKey, or privateKey. Those untouched values are now left out of the patch.

Signed-off-by: Sebastien Tardif <SebTardif@ncf.ca>
@clawsweeper clawsweeper Bot removed the merge-risk: 🚨 security-boundary 🚨 Merging this PR could weaken sandboxing, authorization, credentials, or sensitive data. label Sep 28, 2026
…ithout ids

An untouched masked credential inside an array whose entries have no id cannot be stripped out of config.patch. Gateway replaces that array and rejects a patch that drops a stored entry. An indexed edit inside that array now stays on the machine and names the credential path. An unrelated edit omits the whole array, so the stored entries remain. Arrays whose entries all have an id still omit only the masked field.

Signed-off-by: Sebastien Tardif <SebTardif@ncf.ca>
@shanselman shanselman added the status: 🚢 actively landing A maintainer or agent is actively driving this item through implementation, validation, or merge. label Sep 30, 2026
@shanselman

Copy link
Copy Markdown
Collaborator

@SebTardif Holding head 5ddef72fa2a6112d3ee682ca2977ca96bc83c600. The normal suites pass here, but two production save-owner regressions remain. These were reproduced by compiling the real ConfigEditorModel.cs, not by the test-local Gateway merge simulation.

SchemaConfigEditor.RenderJsonObjectField stages a whole JsonElement under the object path. It does not produce a separate indexed edit key for each changed child.

  1. A parent object edit silently loses an account change. Load {"profiles":{"accounts":[{"token":"<redacted>","label":"desk"}]}}. Stage changes["profiles"] = {"accounts":[{"token":"<redacted>","label":"renamed"}]}. Run the exact page sequence ApplyChanges -> FindUneditedRedactionSentinel -> OmitUntouchedRedactionSentinels, with changes.Keys and the loaded document. The blocked path is null, and the outgoing document is {"profiles":{}}. The new label has disappeared. EditedPathInsideArray at lines 399-409 recognizes the array itself or indexed descendants, but not the edited ancestor. Please preserve the proposed edit or refuse it locally while retaining the draft. An allowed save must not discard it.

  2. A newly entered ordinary mask also disappears in a parent edit. Load {"metadata":{"label":"before"}}; stage changes["metadata"] = {"label":"***"}. The blocked path is null, and the outgoing document is {"metadata":{}}. This label was not loaded as a secret placeholder. The omission branch at lines 270-280 checks only exact edited leaves and does not establish that this value was an untouched loaded mask. Please retain explicit non-secret literals and use a fail-closed policy for genuinely ambiguous masked edits, without broadening the field-name heuristic or taking over the separate masking/complex-array UI policy.

For these same two inputs, the merge-base save owner (42c562f9, which sends ApplyChanges directly) retains both proposed values: 2 passed / 0 failed. This PR's owner is 0 passed / 2 failed. The comparison establishes introduced outgoing-document regressions, not that the earlier placeholder round trip was safe.

Please add regressions using the actual parent object/array keys emitted by the editor. Also cover unchanged IDs versus an ID rename/add/remove with retained masks before relying on ID-keyed omission. Then capture an allowed unrelated save/reload and a refused unsafe edit through the isolated Config page and real Gateway.

No Config page or Gateway was launched for this review. There was no real user data mutation, credential exposure, or proof-pool pass. No maintainer source patch, commit, push, or merge was made. The PR evidence section records exact-head validation and the source-only proof limits.

Both Codex and Claude independently flagged the whole-container literal-omission issue. The accepted source blocker was independently reproduced; their other suggestions were not blindly applied. In particular, current public upstream config.patch explicitly enables ID-keyed merging, so the claim that production arrays are always atomic was rejected. This source check is not a real Gateway save.

Final exact-head closeout: full build passed; Shared 4,099 passed / 32 skipped / 0 failed; Tray 3,083 passed / 0 failed; focused Config/localization 23 passed; focused connection owner 199 passed. An intermediate required Tray run had three access-denied reads in untouched source files; those three passed a focused rerun, then the full build and both required suites passed again. No source or ACL workaround was used.

@shanselman shanselman removed the status: 🚢 actively landing A maintainer or agent is actively driving this item through implementation, validation, or merge. label Sep 30, 2026
@clawsweeper clawsweeper Bot added rating: 🧂 unranked krab Not merge-ready due to missing proof or serious correctness/safety concerns. and removed rating: 🦪 silver shellfish Thin PR readiness signal; proof, validation, or implementation needs work. labels Sep 30, 2026
@shanselman

Copy link
Copy Markdown
Collaborator

@SebTardif Your head 6f85f83cc490a82374d5483febbffb92f8bcb081 fixes both September 30 acceptance cases. I replayed them through the production owner: the parent account edit is refused locally with the draft retained, and the ordinary nested *** value stays in the outgoing document. Those old findings are resolved.

The new loaded-value lookup introduces a separate literal-key regression. For a whole-parent JSON edit, a property such as auth.token or a provider key such as custom.openai is reparsed as a dotted path. The blocker compares the actual JSON key and allows the unchanged loaded mask, but the omission lookup fails and resends that legacy mask.

Three exact production-owner checks were prior 5ddef72f: 3 passed / 0 failed; your current 6f85f83c: 0 passed / 3 failed; the bounded local repair below: 3 passed / 0 failed. This establishes outgoing-patch behavior, not an actual Gateway persistence result.

I prepared a small local maintainer repair. It carries the corresponding baseline JsonElement through omission recursion, matching actual property keys just like the existing blocker. It removes the reparsing-only provenance helpers. The editor, Gateway API, field heuristics, native-sentinel behavior, array policies and distinct-ID semantics are unchanged.

The 16 added regression/control cases were 11 passed / 5 failed before, then 16 passed after. They cover normal/dotted/bracketed keys, parent provider edits, an existing ID-keyed row, ordinary changed literals, explicit credential replacement and missing baseline. The actual same-task Opus 5.5/Astra pair independently reviewed the same scanner-admitted patch: both judged the bounded repair correct with no introduced repair defect. Their inherited ID/protocol concerns are not silently converted into new persisted-data claims.

Local working-tree validation, not a published-head claim:

  • .\build.ps1: passed, all five projects.
  • dotnet test .\tests\OpenClaw.Shared.Tests\OpenClaw.Shared.Tests.csproj --no-restore: final run 4,099 passed / 32 skipped / 0 failed.
  • dotnet test .\tests\OpenClaw.Tray.Tests\OpenClaw.Tray.Tests.csproj --no-restore: final run 3,107 passed / 0 skipped / 0 failed.
  • Focused Config/localization: 47 passed. Focused Connection owners: 199 passed.
  • One intermediate Shared run had a file-in-use failure while deleting an untouched exec-approvals test's temporary directory. That exact test passed a bounded focused rerun, then the full build and both required suites passed again. No source or ACL workaround was used.

Please apply this patch to your original branch and update its head so we can verify fresh final-head CI. The patch applies cleanly to exact 6f85f83c source blobs and matches the validated local working tree. Nothing was committed or pushed to your branch.

Patch SHA-256: 8bbfd1ca48864dc00f6277b04b7d55005975af0ac062c56d3cd7d683ef82b042.

Separate remaining gate: no actual changed Config-page save/reload against a task-owned real Gateway was run. Your current evidence also explicitly records that absence. Source checks, the modelled Gateway test and green CI do not substitute for it. There is no claimed secret exposure or actual user-data mutation. We have not launched another fixture, used a real profile, or bypassed isolation controls.

The applyable two-file patch follows. It is limited to the provenance fix and its regression tests:

diff --git a/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs b/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs
index 59673ad3..53d519a0 100644
--- a/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs
+++ b/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs
@@ -141,7 +141,7 @@ internal static class ConfigEditorModel
 
         var edited = new HashSet<string>(editedPaths, StringComparer.OrdinalIgnoreCase);
         var arraysToDrop = new HashSet<string>(StringComparer.Ordinal);
-        RemoveUntouchedRedactionSentinels(node, "", edited, baseDocument, arraysToDrop);
+        RemoveUntouchedRedactionSentinels(node, "", edited, baseDocument, baseDocument, arraysToDrop);
         foreach (var arrayPath in arraysToDrop.OrderBy(static path => path.Length))
             RemovePropertyAtPath(node, arrayPath);
 
@@ -265,6 +265,7 @@ internal static class ConfigEditorModel
         JsonNode node,
         string path,
         HashSet<string> edited,
+        JsonElement baseDocument,
         JsonElement baseRoot,
         HashSet<string> arraysToDrop)
     {
@@ -274,6 +275,11 @@ internal static class ConfigEditorModel
             foreach (var property in obj)
             {
                 var childPath = string.IsNullOrEmpty(path) ? property.Key : $"{path}.{property.Key}";
+                // Keys can contain dots or brackets, so provenance follows the JSON tree, not display paths.
+                var childBase = default(JsonElement);
+                if (baseDocument.ValueKind == JsonValueKind.Object)
+                    baseDocument.TryGetProperty(property.Key, out childBase);
+
                 if (property.Value is JsonValue value &&
                     value.TryGetValue<string>(out var text) &&
                     ChannelConfigPatchBuilder.IsRedactionSentinel(text) &&
@@ -281,13 +287,14 @@ internal static class ConfigEditorModel
                     ShouldOmitUntouchedSentinel(childPath))
                 {
                     if (!IsCredentialPath(childPath) &&
-                        !LoadedStringIsRedactionSentinel(baseRoot, childPath))
+                        !(childBase.ValueKind == JsonValueKind.String &&
+                          ChannelConfigPatchBuilder.IsRedactionSentinel(childBase.GetString())))
                     {
                         continue;
                     }
 
                     if (HasAncestorEdit(edited, childPath) &&
-                        !UnchangedLoadedSentinel(baseRoot, childPath, text))
+                        !SameSentinel(childBase, text))
                     {
                         continue;
                     }
@@ -302,7 +309,7 @@ internal static class ConfigEditorModel
                 }
 
                 if (property.Value is not null)
-                    RemoveUntouchedRedactionSentinels(property.Value, childPath, edited, baseRoot, arraysToDrop);
+                    RemoveUntouchedRedactionSentinels(property.Value, childPath, edited, childBase, baseRoot, arraysToDrop);
             }
 
             foreach (var key in removals)
@@ -313,7 +320,12 @@ internal static class ConfigEditorModel
             for (var index = 0; index < array.Count; index++)
             {
                 if (array[index] is JsonNode item)
-                    RemoveUntouchedRedactionSentinels(item, $"{path}[{index}]", edited, baseRoot, arraysToDrop);
+                {
+                    var childBase = default(JsonElement);
+                    if (baseDocument.ValueKind == JsonValueKind.Array && index < baseDocument.GetArrayLength())
+                        childBase = baseDocument[index];
+                    RemoveUntouchedRedactionSentinels(item, $"{path}[{index}]", edited, childBase, baseRoot, arraysToDrop);
+                }
             }
         }
     }
@@ -456,51 +468,6 @@ internal static class ConfigEditorModel
         loaded.ValueKind == JsonValueKind.String &&
         string.Equals(loaded.GetString(), submitted, StringComparison.Ordinal);
 
-    private static bool UnchangedLoadedSentinel(JsonElement root, string path, string submitted)
-    {
-        if (!TryReadPath(root, path, out var element))
-            return false;
-
-        return SameSentinel(element, submitted);
-    }
-
-    private static bool LoadedStringIsRedactionSentinel(JsonElement root, string path)
-    {
-        if (!TryReadPath(root, path, out var element) ||
-            element.ValueKind != JsonValueKind.String)
-        {
-            return false;
-        }
-
-        return ChannelConfigPatchBuilder.IsRedactionSentinel(element.GetString());
-    }
-
-    private static bool TryReadPath(JsonElement root, string path, out JsonElement element)
-    {
-        element = root;
-        foreach (var segment in ParsePath(path))
-        {
-            if (element.ValueKind != JsonValueKind.Object ||
-                !element.TryGetProperty(segment.Name, out element))
-            {
-                return false;
-            }
-
-            if (segment.Index is not int arrayIndex)
-                continue;
-
-            if (element.ValueKind != JsonValueKind.Array ||
-                (uint)arrayIndex >= (uint)element.GetArrayLength())
-            {
-                return false;
-            }
-
-            element = element[arrayIndex];
-        }
-
-        return true;
-    }
-
     private static void RemovePropertyAtPath(JsonNode root, string propertyPath)
     {
         JsonNode? current = root;
diff --git a/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs b/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs
index 6bfc7e57..cfa3d93f 100644
--- a/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs
+++ b/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs
@@ -494,6 +494,146 @@ public class ConfigEditorModelTests
         Assert.Equal("*****", sent.GetProperty("metadata").GetProperty("token").GetString());
     }
 
+    [Theory]
+    [InlineData("token")]
+    [InlineData("auth.token")]
+    [InlineData("auth[legacy].token")]
+    public void ApplyChanges_ParentObjectEdit_OmitsLoadedMaskByLiteralPropertyKey(string key)
+    {
+        using var stored = JsonDocument.Parse(JsonSerializer.Serialize(new
+        {
+            metadata = new Dictionary<string, string> { [key] = "***", ["label"] = "before" },
+        }));
+        var replacement = JsonSerializer.SerializeToElement(
+            new Dictionary<string, string> { [key] = "***", ["label"] = "after" });
+        var changes = new Dictionary<string, object?> { ["metadata"] = replacement };
+        var updated = ConfigEditorModel.ApplyChanges(stored.RootElement, changes);
+
+        var blocked = ConfigEditorModel.FindUneditedRedactionSentinel(updated, changes.Keys, stored.RootElement);
+        var sent = ConfigEditorModel.OmitUntouchedRedactionSentinels(updated, changes.Keys, stored.RootElement);
+
+        Assert.Null(blocked);
+        Assert.False(sent.GetProperty("metadata").TryGetProperty(key, out _));
+        Assert.Equal("after", sent.GetProperty("metadata").GetProperty("label").GetString());
+    }
+
+    [Theory]
+    [InlineData("custom")]
+    [InlineData("custom.openai")]
+    [InlineData("custom[legacy]")]
+    public void ApplyChanges_ParentProviderEdit_OmitsLoadedMaskByLiteralProviderKey(string key)
+    {
+        using var stored = JsonDocument.Parse(JsonSerializer.Serialize(new
+        {
+            models = new
+            {
+                providers = new Dictionary<string, object>
+                {
+                    [key] = new { apiKey = "***", baseUrl = "https://example.invalid/before" },
+                },
+            },
+        }));
+        var replacement = JsonSerializer.SerializeToElement(new Dictionary<string, object>
+        {
+            [key] = new { apiKey = "***", baseUrl = "https://example.invalid/after" },
+        });
+        var changes = new Dictionary<string, object?> { ["models.providers"] = replacement };
+        var updated = ConfigEditorModel.ApplyChanges(stored.RootElement, changes);
+
+        var blocked = ConfigEditorModel.FindUneditedRedactionSentinel(updated, changes.Keys, stored.RootElement);
+        var sent = ConfigEditorModel.OmitUntouchedRedactionSentinels(updated, changes.Keys, stored.RootElement);
+
+        Assert.Null(blocked);
+        var provider = sent.GetProperty("models").GetProperty("providers").GetProperty(key);
+        Assert.False(provider.TryGetProperty("apiKey", out _));
+        Assert.Equal("https://example.invalid/after", provider.GetProperty("baseUrl").GetString());
+    }
+
+    [Theory]
+    [InlineData("label")]
+    [InlineData("display.label")]
+    [InlineData("display[legacy].label")]
+    public void ApplyChanges_ParentObjectEdit_KeepsChangedOrdinaryMaskByLiteralKey(string key)
+    {
+        using var stored = JsonDocument.Parse(JsonSerializer.Serialize(new
+        {
+            metadata = new Dictionary<string, string> { [key] = "***" },
+        }));
+        var replacement = JsonSerializer.SerializeToElement(
+            new Dictionary<string, string> { [key] = "*****" });
+        var changes = new Dictionary<string, object?> { ["metadata"] = replacement };
+        var updated = ConfigEditorModel.ApplyChanges(stored.RootElement, changes);
+
+        var blocked = ConfigEditorModel.FindUneditedRedactionSentinel(updated, changes.Keys, stored.RootElement);
+        var sent = ConfigEditorModel.OmitUntouchedRedactionSentinels(updated, changes.Keys, stored.RootElement);
+
+        Assert.Null(blocked);
+        Assert.Equal("*****", sent.GetProperty("metadata").GetProperty(key).GetString());
+    }
+
+    [Fact]
+    public void ApplyChanges_ArrayEdit_OmitsLoadedMaskByLiteralPropertyKey()
+    {
+        using var stored = JsonDocument.Parse("""
+        {"accounts":[{"id":"desk","auth.token":"***","label":"before"}]}
+        """);
+        using var replacement = JsonDocument.Parse("""
+        [{"id":"desk","auth.token":"***","label":"after"}]
+        """);
+        var changes = new Dictionary<string, object?> { ["accounts"] = replacement.RootElement.Clone() };
+        var updated = ConfigEditorModel.ApplyChanges(stored.RootElement, changes);
+
+        var blocked = ConfigEditorModel.FindUneditedRedactionSentinel(updated, changes.Keys, stored.RootElement);
+        var sent = ConfigEditorModel.OmitUntouchedRedactionSentinels(updated, changes.Keys, stored.RootElement);
+
+        Assert.Null(blocked);
+        Assert.False(sent.GetProperty("accounts")[0].TryGetProperty("auth.token", out _));
+        Assert.Equal("desk", sent.GetProperty("accounts")[0].GetProperty("id").GetString());
+        Assert.Equal("after", sent.GetProperty("accounts")[0].GetProperty("label").GetString());
+    }
+
+    [Theory]
+    [InlineData("auth.token")]
+    [InlineData("auth[legacy].token")]
+    public void ApplyChanges_ParentObjectEdit_KeepsExplicitCredentialReplacementByLiteralKey(string key)
+    {
+        using var stored = JsonDocument.Parse(JsonSerializer.Serialize(new
+        {
+            metadata = new Dictionary<string, string> { [key] = "***" },
+        }));
+        var replacement = JsonSerializer.SerializeToElement(
+            new Dictionary<string, string> { [key] = "test-auth-token" });
+        var changes = new Dictionary<string, object?> { ["metadata"] = replacement };
+        var updated = ConfigEditorModel.ApplyChanges(stored.RootElement, changes);
+
+        var blocked = ConfigEditorModel.FindUneditedRedactionSentinel(updated, changes.Keys, stored.RootElement);
+        var sent = ConfigEditorModel.OmitUntouchedRedactionSentinels(updated, changes.Keys, stored.RootElement);
+
+        Assert.Null(blocked);
+        Assert.Equal("test-auth-token", sent.GetProperty("metadata").GetProperty(key).GetString());
+    }
+
+    [Theory]
+    [InlineData("display.label", false)]
+    [InlineData("display[legacy].label", false)]
+    [InlineData("auth.token", true)]
+    [InlineData("auth[legacy].token", true)]
+    public void ApplyChanges_ParentObjectEdit_DistinguishesMissingBaselineLiteralAndCredential(
+        string key, bool credential)
+    {
+        using var stored = JsonDocument.Parse("""{"metadata":{}}""");
+        var replacement = JsonSerializer.SerializeToElement(
+            new Dictionary<string, string> { [key] = "***" });
+        var changes = new Dictionary<string, object?> { ["metadata"] = replacement };
+        var updated = ConfigEditorModel.ApplyChanges(stored.RootElement, changes);
+
+        var blocked = ConfigEditorModel.FindUneditedRedactionSentinel(updated, changes.Keys, stored.RootElement);
+        var sent = ConfigEditorModel.OmitUntouchedRedactionSentinels(updated, changes.Keys, stored.RootElement);
+
+        Assert.Equal(credential ? $"metadata.{key}" : null, blocked);
+        Assert.Equal("***", sent.GetProperty("metadata").GetProperty(key).GetString());
+    }
+
     [Fact]
     public void FindUneditedRedactionSentinel_AllowsLiteralMaskInANonSecretField()
     {

@shanselman shanselman removed the status: 🚢 actively landing A maintainer or agent is actively driving this item through implementation, validation, or merge. label Oct 1, 2026
A parent edit of a reordered account list compared the placeholder with the loaded row at the same index. Match the loaded row by id. An unchanged secret is omitted so the Gateway keeps it. A new id with a mask is refused and stays in the draft.

Signed-off-by: Sebastien Tardif <SebTardif@ncf.ca>
Signed-off-by: Sebastien Tardif <SebTardif@ncf.ca>
A real secret changed to a mask under an id-keyed row was omitted, so the Gateway kept the old secret. Refuse that edit and keep the draft. Look up loaded values by the JSON property name, so a provider key such as custom.openai is not split on the dot. When an outer id-keyed list is reordered, classify each row's nested array from that same id.

Signed-off-by: Sebastien Tardif <SebTardif@ncf.ca>
@clawsweeper clawsweeper Bot removed the merge-risk: 🚨 auth-provider 🚨 Merging this PR could break OAuth, tokens, provider routing, model choice, or credentials. label Oct 1, 2026
Signed-off-by: Sebastien Tardif <SebTardif@ncf.ca>
A provider key such as custom.openai was split on the dot while deciding whether its accounts array could drop a mask. The array boundary is now recorded while walking the real property, so an unchanged mask in that non-id array is refused and stays in the draft.

Signed-off-by: Sebastien Tardif <SebTardif@ncf.ca>
An unrelated edit queued custom.openai.accounts for removal, then the path splitter looked for a root property named custom and left the mask in the patch. Removal now uses the object and key found while walking. The accounts array is removed and the mask is not sent.

Signed-off-by: Sebastien Tardif <SebTardif@ncf.ca>
config.get replaces a stored secret with __OPENCLAW_REDACTED__. That
exact mask was not a local sentinel, so saving another field could
write the mask over the stored secret. An unrelated edit now omits
it. A parent edit that changes it is refused and the draft stays.

Checked with ./build.ps1, Shared tests (4100 passed, 32 skipped),
and Tray tests (3095 passed). Five Tray failures are existing
source-contract checks that look for CRLF in files this change does
not touch.

Signed-off-by: Sebastien Tardif <SebTardif@ncf.ca>
@clawsweeper clawsweeper Bot added merge-risk: 🚨 auth-provider 🚨 Merging this PR could break OAuth, tokens, provider routing, model choice, or credentials. rating: 🧂 unranked krab Not merge-ready due to missing proof or serious correctness/safety concerns. merge-risk: 🚨 security-boundary 🚨 Merging this PR could weaken sandboxing, authorization, credentials, or sensitive data. rating: 🦪 silver shellfish Thin PR readiness signal; proof, validation, or implementation needs work. and removed rating: 🦪 silver shellfish Thin PR readiness signal; proof, validation, or implementation needs work. rating: 🧂 unranked krab Not merge-ready due to missing proof or serious correctness/safety concerns. labels Oct 6, 2026
__OPENCLAW_REDACTED__ is the mask config.get already restores before
writing. Treating it as a legacy sentinel made a Channels save refuse
when another channel still showed that mask. Channels now sends the
mask through.

The Config editor still omits that mask on an untouched credential
such as apiKey. A SecretRef id keeps the mask in the patch, so a
provider change still reaches the gateway's identity check.

./build.ps1 passed. Shared tests: 4100 passed, 32 skipped. Tray tests: 3096 passed. Five failures are the existing CRLF source-contract checks in files this commit does not touch.

Signed-off-by: Sebastien Tardif <SebTardif@ncf.ca>
@clawsweeper clawsweeper Bot added rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. and removed rating: 🦪 silver shellfish Thin PR readiness signal; proof, validation, or implementation needs work. merge-risk: 🚨 compatibility 🚨 Merging this PR could break existing users, config, migrations, defaults, or upgrades. merge-risk: 🚨 security-boundary 🚨 Merging this PR could weaken sandboxing, authorization, credentials, or sensitive data. merge-risk: 🚨 auth-provider 🚨 Merging this PR could break OAuth, tokens, provider routing, model choice, or credentials. labels Oct 6, 2026
A credential loaded as __OPENCLAW_REDACTED__ and replaced with a legacy
placeholder is refused on the exact edited path, the same as a parent
edit. The mask stays out of the Channels sentinel set. A SecretRef id
can still be replaced.

Disposable OpenClaw 2026.9.6 on port 19242 accepted an unrelated baseUrl
save that omitted apiKey. The stored secret still matched. The direct
placeholder edit was not sent.

Signed-off-by: Sebastien Tardif <SebTardif@ncf.ca>
@clawsweeper clawsweeper Bot added proof: sufficient Contributor real behavior proof is sufficient. rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. and removed status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask. rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. labels Oct 6, 2026
@SebTardif SebTardif changed the title fix(config): saving config writes redacted secrets back over untouched fields fix(config): refuse a legacy placeholder over the native secret mask Oct 7, 2026
@karkarl

karkarl commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator

Global triage: HOLD_FOR_AUTHOR. Take confidence 20%; recommendation confidence 98%; effort medium; risk medium.

Reviewed exact head 6ad6ed0f3b25. GitHub currently reports CLEAN with 29 passing, 0 failing, and 0 pending checks. proof label is sufficient. Durable automated signal: Codex review: needs maintainer review before merge..

Author: resolve the current durable review findings, add focused regressions, and request a fresh exact-head review.

This is a triage classification, not a merge action. No branch changes were made.

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

P2 Normal priority bug or improvement with limited blast radius. proof: sufficient Contributor real behavior proof is sufficient. rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants