fix(config): mask agent description/triggers in config endpoint - #8768
fix(config): mask agent description/triggers in config endpoint#8768bolichen97 wants to merge 2 commits into
Conversation
Opus 4.8 Review — ✅ no blocking findingsReviewed Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsFINDING -- src/kiro_crew/config/loader.py:2525 -- “whose redaction recognizes only False positive or not applicable? A repository writer can comment: |
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS Load-boundary coercion fixes the root type-confusion; the view mask is zero-cost defense-in-depth (no frontend consumer reads Watch
[DESIGN-REVIEWED] 3b3d088 |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of All evidence is gathered. Composing the review. First-Principles-Verdict: CONCERNS The loader guard is a point patch: five sibling unguarded string fields on the same record ferry the identical nested-credential payload to the same browser view. What this change shipsIntent: stop agent-writable description/triggers text, including credential bytes hidden in them, from reaching the browser via the config endpoint — a FIX.
Watch
[FIRST-PRINCIPLES-REVIEWED] 3b3d088 |
`description` was the last free-text field on an agent record without the
`isinstance(..., str)` guard its siblings `model` and `triggers` already
carry. The schema validator notices the mismatch, logs it, and keeps the
value on the stated grounds that its consumer validates it -- but for this
field no consumer did.
So an object survived the load on a field declared `str` and travelled to
every reader, including the browser-facing config view, whose redaction
recognizes only `str` and therefore skipped it: a config.json holding
`"description": {"k": "AKIA..."}` reached GET /api/config/kirocrew with the
credential intact. config.json is hand- and agent-writable, so that input
is reachable rather than hypothetical.
Coercing at the load boundary closes it for every reader at once instead
of once per endpoint.
Agent `description` and `triggers` are writable by the agent itself and by an installed package, and neither is schema-sensitive, so the schema-driven walk in `_masked_config_dict` never reached them and GET /api/config/kirocrew returned both verbatim. Mask them on the deep-copied view, which is why the write/round-trip path (`to_dict()`/`save()`) keeps the real value; no write path on this endpoint accepts an `agents.*` key, so the sentinel cannot be read back and persisted. The guard is deny-by-default rather than `isinstance(val, str) and val`. That shape is the fail-open case the backend-security-controls rule names outright -- an `if x and y` guard a falsy or unmatched value slips through -- and here it would defeat the redaction entirely, because a value that is not a string is precisely the untrusted one and credential-shaped bytes nested in an object or a list would render as-is. Everything but an empty string is masked; "" alone renders unchanged so the UI shows no "set (hidden)" placeholder for a field nobody set, and an absent key is not conjured into one. The load path now coerces these shapes away upstream, but this view has to hold on its own instead of inheriting a guarantee from a distance, so the tests build the record on the dataclass directly. The rationale no longer claims the roster path (GET /api/agents) already masks these fields. It does not: it still spreads the record verbatim. Each route owns its own response, so closing this one neither repairs that one nor waits on it.
ae9bfe0 to
3b3d088
Compare
|
Closing as superseded by #8775, which merged 2026-09-06 06:10Z ( Why superseded rather than rebasedBoth PRs rewrite the same function —
The rebase conflicts on exactly those two hunks. Resolving it in this PR's favour would narrow field coverage from "every unguarded Retitling and keeping this PR for the remainder was considered and rejected: the title, body, The one part main does not carry, and why it is not being carried forward eitherThe remaining commit adds an The body says
Tracked instead as one item at the width the code actually has, with the two coherent end states written out: #9056. No merge and no approval on this PR — closing only. |
Pull request was closed
Fixes #8717.
Problem
GET /api/config/kirocrewreturned every agent'sdescriptionandtriggersverbatim._masked_config_dict(src/kiro_crew/dashboard/handlers/core.py) masks only values the schema markssensitive; those two are writable by the agent itself and by an installed package and are not schema-sensitive, so the schema-driven walk never reached them.Underneath that sat a second, sharper defect.
descriptionwas the last free-text field on an agent record without theisinstance(..., str)guard its siblingsmodelandtriggersalready carry inKiroCrewConfig.load(). The schema validator notices a type mismatch there, logs it, and keeps the value on the stated grounds that its consumer validates it — and for this field no consumer did. So aconfig.jsonholding{"agents": {"x": {"description": {"k": "AKIAIOSFODNN7EXAMPLE"}}}}loaded with
descriptionas adicton a field declaredstr, and any redaction guarding onisinstance(val, str)skipped it and passed the nested credential straight to the browser.config.jsonis hand- **and agent-**writable, so that input is reachable rather than hypothetical. Reproduced before the fix (CREDENTIAL LEAKS TO BROWSER? True) and after (False).Fix
Two layers, one per commit.
At the load boundary (
config/loader.py) — coerce a non-strdescriptionto"", exactly asmodelandtriggersalready are. This closes the hole for every reader at once rather than once per endpoint, and it is the layer no other open PR on #8717 touches.At the view boundary (
handlers/core.py) — maskdescription/triggerson everyagents.<name>record of thecopy.deepcopy(cfg.to_dict())view, deny-by-default:isinstance(val, str) and val. That is the fail-open shape thebackend-security-controlsrule names outright ("never writeif x and y and zguards where a falsy value silently skips the check"), and here it would defeat the redaction it performs — a value that is not a string is precisely the untrusted one. Everything but an empty string is masked.""alone renders unchanged, so the UI shows no "set (hidden)" placeholder for a field nobody set, and an absent key is no longer conjured into one.Mask cannot be persisted — verified, not assumed.
PUT /api/config/kirocrewaccepts onlybody["agent"](the singularagentsection);PATCHis a strict_EDITABLE_CONFIGallowlist that contains noagents.*path at all. So there is no route by which the sentinel is read back and written over the real value. Agent descriptions are still edited throughPATCH /api/agents/<name>, which reads its value from the roster route, so editing is unaffected.Scope, stated honestly
An earlier revision of this description claimed #8472 "already masks this same class of strings on the roster path". That was false and is retracted here and in the code comments it had reached: #8472 is open and unmerged, and
handlers/agents.pystill ships{"name": ..., **dataclasses.asdict(agent_cfg)}, soGET /api/agentsreturns both fields verbatim today. This PR does not close that route and does not depend on it being closed — each route owns its own response. Unifying both behind one rule, and settling whether that rule should be an unconditional mask or aredact_credentials-style scrub that preserves benign text, is #8717's remaining work, not something this PR decides.The issue's final paragraph (agent-sync credential-shaped-name surfacing / gateway logging / install-refusal surface) is labeled "NOT part of this issue" and is untouched.
Pattern harvest
Rule candidate: semgrep
Pattern: a redaction, masking, or scrub step whose guard is
isinstance(x, str)(orand-chained with a truthiness test) and whose false branch omits the value instead of redacting it.This is one instance of a two-part class, and both parts recurred here:
isinstancein a validator is a check; inside a redactor it is a bypass, because the untrusted input is exactly the one that fails the narrowing. The rule should flag a masking assignment reachable only from theisinstance(..., str)true-branch, which is mechanically detectable — the enclosing function names a mask/redact sentinel and the else-branch has no write.modelandtriggershonor it,descriptiondid not. Not semgrep-shaped, but it is checkable: every field the validator type-warns about should have a coercion at the load site. Worth anAGENTS.mdline so the next field added to a record inherits the guard by convention rather than by whoever remembers.The near-miss is what makes it worth a rule:
triggerswas guarded only becauseselect_crewcalls.strip()on it, i.e. because a crash forced the issue.descriptionhad no crashing consumer, so the same missing guard stayed silent and became a redaction bypass instead. A class of bug that only surfaces when something happens to crash is one a linter should be finding.Testing
pytest test/test_config_agent_string_masking.py test/test_config_extra_sections.py→ 12 passed (7 + 5).pytest test/test_config_loader.py→ 492 passed.pytest test/test_config_api.py test/test_config_patch.py test/test_config_roundtrip.py test/test_config_schema.py test/test_config.py test/test_config_rmw_preserves_settings.py test/test_config_module_boundaries.py test/test_agent_config_merge_on_write.py→ 240 passed, 1 skipped._masked_config_dict(test_connections_ui_flag.py,test_telegram.py) plustest_dashboard_handlers_core_coverage.py→ 481 passed.-k "select_crew or crew_select or agents_api or agent_roster or conductor"→ 655 passed, 1 skipped (the loader change reaches crew selection).flake8,isort,mypy --platform linux src/kiro_crew(1294 files), brand, harness-parity, changelog-history.dict/list/nested-dictand a scalar (7,False) in either field mask rather than pass through, the credential bytes are absent from the serialized response, an absent key stays absent, and at the loader an object/number/listdescriptioncollapses to""with the credential gone fromto_dict()entirely.Files changed
src/kiro_crew/config/loader.py—isinstance(..., str)coercion fordescription.src/kiro_crew/dashboard/handlers/core.py— deny-by-default masking loop; docstring rationale corrected.test/test_config_loader.py— loader coercion test beside the siblingtriggersone.test/test_config_agent_string_masking.py— 7 tests across both layers.