Skip to content

fix(dashboard): redact crew record strings at both roster serializers - #8465

Open
dwu96 wants to merge 1 commit into
mainfrom
fix/crew-record-redaction-8447
Open

fix(dashboard): redact crew record strings at both roster serializers#8465
dwu96 wants to merge 1 commit into
mainfrom
fix/crew-record-redaction-8447

Conversation

@dwu96

@dwu96 dwu96 commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Problem / Motivation

Free-text string fields on the crew/agent record — description, triggers, workspace, memory_store, model, session_color, … (KiroCrewAgentConfig, config/sections.py) — are agent-writable through config.json, and two dashboard endpoints echo that record back to the browser without any redaction pass:

  • GET /api/agents (agents.py::api_kirocrew_agents) builds each row as {"name": name, "scope": "global", **dataclasses.asdict(agent_cfg)} and spreads dataclasses.asdict(KiroCrewAgentConfig()) for project-scope rows — the spread ships every field the record has now and every field added later, unredacted.
  • GET /api/members (members.py::api_members) uses a deliberate explicit allowlist (name, slug, kiro_agent, workspace, memory_store, model) but applies no redaction to the values; its own docstring recorded the omission ("limited to what costs no IO and no redaction pass").

A short secret written into any such field — e.g. a 20-character AWS access key id, well under any preview truncation — reaches dashboard JSON verbatim.

Scope note: the issue as filed frames this around avatar.traits / _safe_avatar. Those symbols do not exist on main — they arrive with the still-open PR #7443. On main, avatar is a single str path field. This PR therefore implements the issue's recommended option (1), a field-generic chokepoint over the serialized record's string values, which covers the trait axes automatically the moment #7443 lands, with no second patch there. The issue's option (2) (harden _safe_avatar) is out of scope not merely unpreferred: _safe_avatar is not a function that exists on main.

Why it matters

This is the same defect class as #6189/#8132 (dashboard egress without the credential/exfiltration-URL chain), on a surface every dashboard page loads: the agent roster. An agent that can write config.json — which is the normal, supported way agents are defined — can park a credential or an exfiltration URL in a free-text field and have the dashboard serve it to the browser, bypassing the redaction posture the rest of the output surfaces enforce.

What changed (motivation → approach → change)

Symptom → root cause: both endpoints serialize the agent record straight into web.json_response with no redaction pass over the record's string values.

Approach: one field-generic serialization chokepoint per endpoint, not per-field patches — a per-field fix would silently stop covering the record the next time a field is added (exactly the situation #7443 is about to create). The in-tree precedent followed is _shared.py::_redact_memory_field, the shared recursive scrubber already used by memory.py and cron.py, which applies the exact chain members.py's own _sanitize uses: redact_exfiltration_urls then redact_credentials.

Change:

  • dashboard/handlers/_shared.py — new redact_record_strings(record) -> dict: applies the chain to every str value of a serialized record, recursing into nested dicts/lists by delegating to _redact_memory_field. Non-string values pass through untouched.
  • agents.py::api_kirocrew_agents — every row funnels through the helper at the response, after the usage sort (ordering logic untouched, and it reads the raw names). This single point covers BOTH row sources: the cfg.agents spread and the project-scope default rows.
  • members.py::api_members — the allowlisted record dict funnels through the helper at construction. The explicit allowlist stays — it is a deliberate network-boundary contract with its own comment; only the values are scrubbed. The transcript last_message preview keeps its existing pre-truncation _sanitize pass unchanged (already correct; not double-wrapped).
  • docs/system-specs/modules/security.md — new "Crew roster serializers" bullet stating exactly which two endpoints are covered (and explicitly that the wider config surface is not).
  • security_posture.py — comment-accuracy updates only on the existing NON_EGRESS_REDACTION_MODULES entries for _shared.py / agents.py / members.py (all three were already classified; the drift guard passes unchanged).

Deliberately NOT done in this PR: converting /api/agents' spread into an allowlist. members.py's comment argues for one, but that is a separate contract change with frontend ripple — filed as a sibling issue instead (see Related Issues).

Overlap note (sequencing): PR #7443 touches both handlers (it adds the avatar trait axes this issue was carved out of); this PR does not depend on it and does not reference its symbols — the field-generic chokepoint is what lets the two compose in either merge order. PR #7235 and #8307 append new endpoints to these files and do not rewrite either function changed here.

Tests

test/test_roster_record_redaction.py — all red-first against unmodified main (8 of 9 failed; the 9th is the allowlist-contract preservation test, which passes on main by design):

  • /api/agents: credential in description redacted; exfiltration URL in workspace redacted. The two planted shapes are chosen so each isolates one half of the chain (a bare AWS key id is invisible to the exfil half; a long-query URL with no credential marker is invisible to the credential half) — a fix wiring only one half fails the other test.
  • /api/agents project-scope rows: a poisoned-defaults stand-in proves the second row source goes through the chokepoint (today's defaults are benign constants, so this is the only way to observe that row source).
  • /api/members: credential in workspace redacted; exfiltration URL in memory_store redacted; allowlist contract preserved (non-allowlisted fields still do not ship at all).
  • Helper: nested dict/list values redacted (the guard that survives feat(crews): trait-by-trait custom ghost avatars for crew members #7443's nesting), non-string values pass through, both halves run.

Mutation checks — five mutants, each applied individually, each killed by exactly the expected tests, tree restored (cp-aside/cp-back, never git checkout), suite re-verified green after each restore:

Mutant Result
(a) drop redact_exfiltration_urls half 5 failed — every exfil assertion on both endpoints + helper
(b) drop redact_credentials half 5 failed — every credential assertion on both endpoints + helper
(c) revert the /api/members chokepoint (cover /api/agents only) 2 failed — exactly the two members endpoint tests
(d) redact only scope=="global" rows on /api/agents 1 failed — exactly the project-row test
(e) drop the nested-value recursion 1 failed — exactly the nested-value guard

Red-first + mutants together prove both directions: the tests catch the bug, and each guard is individually load-bearing.

Neighbors: test_members_dm_thread.py, test_api_agents_order.py, test_security_posture.py (incl. the redactor call-site drift guard), test_config_api.py, test_agents_endpoints_owner_auth.py — 169 passed.

Gates: scripts/local-gate.py --base origin/main — backend-only diff plan: full backend suite + 206 frontend guard specs. Backend (branch): 236 failed + 2 errors out of 84,773 passed — the known environmental baseline (see below). Frontend guards: 208 files / 5,702 tests, all passed (run under node 22 per the gate plan). black (26.3.1, CI pin) / isort / flake8 / mypy clean on every touched file; the test_security_posture.py redactor call-site drift guard passes unchanged.

Zero-regression proof: full backend suite on this branch vs a pristine git worktree at origin/main (dabd83e91, the same base commit): branch 236 failed + 2 errors, main 236 failed + 2 errors, and the sorted failing-test id sets are byte-identical in both directions (238 = 238 ids, empty symmetric difference). This repo has a known environmental failure baseline, so identical sets — not "0 failures" — is the proof.

Screenshots / video

Why no screenshot: the frontend change alters only the SAVE PAYLOAD contents (untouched redacted prefills are omitted from the PUT body) - no component, layout, or rendered pixel changes; the evidence is the redactedPrefillGuard unit tests and the backend round-trip tests.

Manual verification

N/A — unit coverage sufficient: both endpoints are exercised end-to-end through aiohttp TestClient (real routing, middleware, and JSON serialization), asserting on the raw response body.

Related Issues

Fixes #8447

Pattern harvest

Rule candidate: review-prompt
Pattern: "a dashboard endpoint that serializes an agent-writable record (dataclass spread OR allowlist) must funnel the serialized values through the credential + exfiltration-URL chain at one chokepoint; a spread without one ships every future field unredacted". Knowingly out-of-scope sibling surface: /api/agents' spread-vs-allowlist contract itself (filed separately, see Related Issues).

Checklist

  • At most two commits (one is the norm), with a Conventional Commits title (feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)
  • Existing tests pass and new tests added for new functionality
  • Self-review completed; code follows project style guidelines
  • Documentation updated (if applicable)
  • No secrets, credentials, or internal references in the diff

@dwu96
dwu96 requested a review from a team as a code owner September 4, 2026 14:08
@dwu96
dwu96 requested a review from cixuuz September 4, 2026 14:08
@github-actions github-actions Bot added the readiness: checking Automated validation is still running label Sep 4, 2026
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Design Review (Fable 5) — 🟡 CONCERNS

Design-level review of a56bb4be269671315c1bb792632caf65abe420de — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

Design-Verdict: CONCERNS

Sound redaction chokepoint, but the read-side fix spawns a three-layer write-back protocol, and the new key-redaction rule is deliberately withheld from sibling surfaces it equally indicts.

Watch

  • The redact_keys docstring argues "a key serializes into the JSON exactly like a value, so a serializer feeding a browser surface must cover both" — yet the flag defaults off for memory.py/cron.py, which serve agent-written nested dicts to the same dashboard JSON boundary (memory.py:338,1190,1207, cron.py:1568). By the PR's own rationale those surfaces now have a known, codified key-leak hole; either flip the default there or file the sibling issue so the fork in the shared scrubber doesn't ossify as two-tier posture.
  • Redacting the endpoint that is also the edit form's prefill source is what forced the manifest + client snapshot + dual server echo-strip stack, including a per-PUT extra KiroCrewConfig.load probe and a documented residual race for manifest-ignoring clients. It works and is well-pinned by tests, but redacted_fields is now permanent public API that every future edit client must honor to avoid marker write-back — humans should confirm they want that contract rather than a "redacted fields are served as absent" read shape before it ships.

[DESIGN-REVIEWED] a56bb4b

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

First Principles Review (Fable 5) — 🟡 CONCERNS

Premise-level review of a56bb4be269671315c1bb792632caf65abe420de — why this exists and whether the shipped surface is the smallest honest version. Updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

First-Principles-Verdict: CONCERNS

The redaction fix is derived and mechanism-level; the redacted_fields manifest it grew is one-consumer API surface a manifest-free client snapshot would replace.

What this change ships

Intent: stop agent-writable config strings (credentials/exfil URLs) reaching the browser via the two roster endpoints — a FIX.

  1. /api/agents rows arrive credential/exfil-scrubbed — justified (defect class Credential redaction corrupts commands meant to be pasted into a terminal (silent, no warning) #6189/Exfiltration-URL redaction silently rewrites pasteable dashboard chat text #8132, mechanism-level chokepoint reusing _redact_memory_field)
  2. default_agent string scrubbed too — justified
  3. /api/members allowlisted values scrubbed, allowlist kept — justified
  4. Every /api/agents row gains a redacted_fields key — one consumer, generalized
  5. PUT /api/agents/{name} now silently drops marker-echo fields — rides along, undeclared in "What changed"
  6. Every PUT now loads config twice (pre-lock echo probe) — rides along, undeclared
  7. Edit sheet omits untouched redacted prefills on save — rides along, derived (the fix itself causes the data loss it prevents)
  8. Dict keys now redacted (redact_keys) — speculative today: every KiroCrewAgentConfig field is scalar (config/sections.py:2733); covers feat(crews): trait-by-trait custom ghost avatars for crew members #7443 pre-emptively
  9. security.md bullet + posture comments — justified (same-commit spec mandate)

Watch

  • Items 5–7 are absent from the description's "What changed" list (visible in full before the 8000-byte truncation) — they exist only in test docstrings citing review rounds 2–5. They are derived, but a reader of the description doesn't learn the PUT contract changed.
  • The exact-echo rule is what forces the pre-lock probe (a second full config load per PUT). Counted omission-less PUT callers: 1 (ChatPage.tsx:5812), and it sends a single user-picked model — never a prefill echo. The pure-marker floor consults no stored value, so dropping the exact-echo rule deletes the probe and the double load; its residual (non-stale partial-marker echo from a hypothetical external client) is the same class the PR's own residual note already concedes for the stale case.

Subtractions

  • Drop redacted_fields and redact_record_strings_marked (_shared.py:104; 1 consumer counted: redactedPrefillGuard.ts — grepped redacted_fields across src/ and website/). The sheet already snapshots opening prefills; extend snapshotRedactedPrefills to all form fields and omit any field still equal to its opening prefill. Same race closed, no permanent schema property, and untouched-field clobber stops for every field, not just redacted ones.
  • Shrink _strip_redacted_echoes (agents.py:3172) to the pure-marker rule and delete the pre-lock KiroCrewConfig.load probe — pure-marker needs no stored value, so the strip becomes string checks before validation.

[FIRST-PRINCIPLES-REVIEWED] a56bb4b

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Opus 4.8 Review — ✅ no blocking findings

Reviewed a56bb4be269671315c1bb792632caf65abe420de — this comment is updated in place on each push.

Review details

No findings.

Both candidates require an agent name/slug/default_agent deliberately shaped like a credential (a 20-char AKIA…/ASIA… id or a 40-char high-entropy mixed-case run) for redaction to alter an identity field — a normal human-readable name (code-reviewer, alpha) contains no credential or exfiltration-URL shape and passes through redact_record_strings untouched. That input does not occur in practice, so prong (a) fails; and where it does occur, scrubbing a credential out of a value the endpoint ships to the browser is the feature's intended behavior, not a regression. Candidate 2 additionally concedes no default_agent write-back path exists in the diff, so its data-loss half is unreachable. Nothing survives falsification.

[OPUS-REVIEWED] a56bb4b

Verdict parsed from the review's SHA-scoped output markers for commit a56bb4be269671315c1bb792632caf65abe420de.

False positive or not applicable? A repository writer can comment:
/ai-review override fable a56bb4be269671315c1bb792632caf65abe420de: <one-sentence reason>

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

GPT 5.6 Review — 🔴 changes requested (blocking)

GPT 5.6 found at least one blocking issue that must be resolved before merging a56bb4be269671315c1bb792632caf65abe420de. 1 of 1 blocking finding(s) are security-class and were withheld from adjudication, so the blocking verdict stands.

This comment is updated in place on each push.

BLOCKING -- website/src/utils/redactedPrefillGuard.ts:62 -- Marker equality is mistaken for an untouched field (origin: validation)
if (redacted.has(key) && value === snapshot.prefills[key]) continue / if (_is_pure_marker(incoming)): continue
Explicit marker-valued edit -> save/API update -> field omitted while returning success -> intended value is silently lost.
Anchor: residual/crash-data-loss-corruption
Fix: Track per-field touched state and preserve explicit edits; remove the backend’s blanket pure-marker suppression.
[GPT-REVIEWED] a56bb4b
[BLOCK-MERGE] a56bb4b
False positive or not applicable? A repository writer can comment:
/ai-review override gpt a56bb4be269671315c1bb792632caf65abe420de: <one-sentence reason>

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Sep 4, 2026
@dwu96
dwu96 force-pushed the fix/crew-record-redaction-8447 branch from a4b8938 to cf50490 Compare September 4, 2026 14:23
@dwu96

dwu96 commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

span=a4416c88f899 — fixed in cf50490.

Finding (src/kiro_crew/dashboard/handlers/_shared.py): the chokepoint recursed into nested dict/list VALUES but shipped dict KEYS verbatim, so a credential-shaped key inside an agent-writable object-valued record field would reach dashboard JSON unredacted.

Fix: _redact_memory_field gains a keyword-only redact_keys opt-in that scrubs string dict keys through the same exfiltration-URL + credential chain at every level; redact_record_strings sets it, so both roster serializers now cover keys and values uniformly. The opt-in defaults off so the scrubber's existing memory/cron callers keep their exact behavior (pinned by test_memory_field_scrubber_keys_unchanged_by_default).

Evidence: red-first — test_nested_dict_keys_are_redacted fails on the prior head's implementation, passes on cf50490; mutation check — reverting the dict branch to values-only makes exactly that test red (1 failed, 10 passed), restore returns 11/11 green. security.md's roster-serializers bullet updated in the same commit to state keys are covered.

Fix: Recursively redact string dictionary keys as well as values.

[operator: dwu96]

@dwu96

dwu96 commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

span=d77e9c4049d0 — fixed in b390de7.

Finding (src/kiro_crew/dashboard/handlers/agents.py): the crew edit sheet prefills its form from the now-redacted GET /api/agents roster and PUTs the prefill fields back unconditionally on save (KiroCrewAgentsPage.tsx openEditupdateMut sends kiro_agent, workspace, memory_store, triggers, model, reasoning_effort, session_color every time), so saving an edit that never touched a redacted field would overwrite the real config.json value with the [REDACTED: ...] marker — silent data loss. Verified reachable end-to-end before fixing.

Fix: agents.py::_strip_redacted_echoesPUT /api/agents/{name} drops any body field whose incoming value is exactly the redacted echo of the CURRENTLY-stored value (marker present, differs from stored, equals _redact_memory_field(stored)). Run twice: on a pre-lock probe so a marker echo cannot 400 in the model/effort validations, and again against the locked authoritative record that the assignments read (closes the probe race). The match is deliberately exact, so a deliberately typed new value — including pasted text merely containing a marker over a benign stored value — never matches and writes normally; clearing to "" also writes normally. This is the backend-chokepoint form of the suggested "omit unless explicitly edited": it covers every client of the PUT, not only the sheet.

Evidence: red-first — test_redacted_echo_does_not_overwrite_config and test_mixed_save_writes_edits_and_preserves_echoes fail on cf50490 (config file ends up holding the marker), pass on b390de7 with the original value preserved byte-identical. Two mutants killed individually: removing the guard reds exactly the two echo tests; widening it to drop ANY marker-bearing input reds exactly test_marker_text_over_benign_value_is_written (the over-drop direction). 297 neighboring tests (agent CRUD round-trips, owner auth, posture drift guard) green. security.md updated in the same commit to document the write-back guard beside the roster chokepoint.

Fix: Mark redacted fields and omit them from updates unless explicitly edited.

[operator: dwu96]

@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention and removed readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running labels Sep 4, 2026
@dwu96
dwu96 force-pushed the fix/crew-record-redaction-8447 branch from b390de7 to 570247e Compare September 4, 2026 15:10
@dwu96
dwu96 requested a review from a team September 4, 2026 15:10
@dwu96

dwu96 commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

span=d77e9c4049d0 — fixed in 570247e.

Finding (src/kiro_crew/dashboard/handlers/agents.py, round 3 — the stale-save race residual in the round-2 exact-echo guard): between a sheet's GET and its save, a concurrent editor can store a NEW benign value; the stale sheet then echoes its marker prefill, which no longer exact-matches the redaction of the CURRENT stored value, so the round-2 guard let it through and the newer value was destroyed. Verified real red-first (test_stale_marker_echo_never_overwrites_a_newer_value fails on b390de7).

Fix — the suggested design adopted in full, plus a server-side floor:

  1. GET /api/agents rows now carry a redacted_fields manifest (_shared.redact_record_strings_marked) naming exactly the fields redaction changed (both row sources).
  2. The crew edit sheet omits an untouched redacted prefill from its PUT (website/src/utils/redactedPrefillGuard.ts::omitUntouchedRedactedPrefills, wired into KiroCrewAgentsPage.saveEdit; keyed on the manifest, never on marker text, so a user deliberately saving marker-looking text still writes). Client omission is what closes the race: only the client knows a field was never touched — a server-side comparison at save time cannot distinguish a stale echo from a deliberate edit after a concurrent write.
  3. Server floor for clients that do not implement omission: _strip_redacted_echoes now also refuses a PURE-marker value ([REDACTED: ...] and nothing else) that differs from the stored value — a bare marker is never a deliberate stored value, so this holds across the race window and the newer value wins.

Residual, stated rather than implied (also recorded in security.md): a stale echo of a PARTIALLY-redacted value (marker embedded in surviving text) from a client that ignores the manifest remains indistinguishable from a deliberate edit at the server; the shipped client cannot produce it.

Evidence: stale-race test red on b390de7, green on 570247e with the newer value preserved; manifest pinned at helper + endpoint + project-row level; mutants killed individually (drop pure-marker rule → exactly the stale-race test red; empty manifest → exactly the three manifest tests red); frontend omission pinned by 5 unit tests incl. edited/cleared/marker-paste/no-manifest branches; tsc + eslint clean; marker-over-benign paste and clear-to-empty behaviors preserved green. security.md updated in the same commit. No pixel delta — save-payload contents only; no-visual-delta waiver added to the PR body.

Fix: Mark redacted fields in GET responses and omit them from PUT unless explicitly edited.

[operator: dwu96]

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

UX Review (Fable 5) — 🟡 CONCERNS

UX-level review of a56bb4be269671315c1bb792632caf65abe420de — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

The frontend holds all the context I need: the guard is save-side only, and no visible affordance was added for a redacted prefill. Emitting the review.

UX-Verdict: CONCERNS

A [REDACTED: credential] prefill renders as an ordinary editable value — nothing tells the user that clearing it destroys the real hidden value.

Watch

  • Invisible mode on redacted fields. The edit sheet prefills e.g. the triggers textarea with [REDACTED: credential] (marker text from the server) with no hint, badge, or read-only treatment — the diff adds only the save-side guard. An untouched field silently preserves the hidden value, but the guard deliberately sends a cleared one ("keeps a redacted field the user cleared", redactedPrefillGuard.test.ts), so the natural reaction to marker-looking garbage — select-all, delete, save — writes '' over the real stored value with no undo, including on a redaction false positive. Low frequency (only rosters holding credential-shaped strings) × high impact (silent data loss) × persistent (every edit of that agent). Smallest fix: when editPrefillSnapshot marks a field, show one line under it — "Value hidden for security — leave as-is to keep it" — the snapshot the form already holds is exactly the render key.

Suggestions

  • If the redacted field is workspace or kiro_agent (rendered as selects), the marker is not in the options list — verify the ComboBox shows the marker rather than falling back to empty/first option, which would overwrite on save with no user intent.

[UX-REVIEWED] a56bb4b

Comment thread src/kiro_crew/dashboard/handlers/agents.py Fixed
@dwu96
dwu96 force-pushed the fix/crew-record-redaction-8447 branch from 570247e to 816c7b6 Compare September 4, 2026 15:37
@dwu96

dwu96 commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

span=3933f2677293 — fixed in 816c7b6.

Finding (website/src/utils/redactedPrefillGuard.ts, round 4 F1): the sheet's omission consulted the LIVE roster row (editingAgent), which a mid-edit roster refetch (WebSocket refresh, another save's query invalidation) can replace with a row whose manifest is clean — the form still holds the stale marker, the omission no longer recognises it, and the stale marker writes over the newer value. Verified real: editingAgent is re-derived from the live roster on every render.

Fix — the suggested tracking adopted: snapshotRedactedPrefills(row) captures the manifest AND the exact marker prefills at sheet-OPEN time (values copied out, so neither replacement nor in-place rewrite of the row can change what the form remembers); the snapshot lives in a ref set in openEdit, cleared in openCreate/closeSheet, and saveEdit's omission keys exclusively on it. Comparing the form value to the opening prefill is the per-field touched state: equal ⇒ never edited ⇒ omitted; any edit (including clearing) differs and writes.

Evidence: new unit test still omits the untouched prefill after a mid-edit roster refresh — the row is both replaced and rewritten in place after the snapshot; keyed on the live row the marker passes through (mutation check: a snapshot that holds live references instead of copies fails exactly this test, 5/6), keyed on the snapshot it is omitted (6/6 green on 816c7b6). tsc + eslint clean.

Fix: Track the opening manifest and per-field touched state; omit initially redacted fields unless explicitly edited.

[operator: dwu96]

@dwu96

dwu96 commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

span=d77e9c4049d0 — fixed in 816c7b6.

Finding (src/kiro_crew/dashboard/handlers/agents.py, round 4 F2, adjudicated against AUTOSDE no-blocking-call-on-event-loop): the round-2 pre-lock echo probe called KiroCrewConfig.load() synchronously inside the async PUT handler — file IO + validation on the gateway event loop, so slow storage would stall every dashboard request.

Fix: the probe load now runs off-loop via await asyncio.to_thread(KiroCrewConfig.load) — the exact shape members.py::api_members already uses for its config load.

Evidence: red-first — test_echo_probe_config_load_runs_off_the_event_loop records the thread each config load runs on and requires the handler's first load (the probe) to be off the event-loop thread; it fails on 570247e and passes on 816c7b6. Mutation check: reverting to the synchronous call makes exactly that test red. 52 neighboring tests (roster redaction + agent CRUD round-trips) green.

Fix: Load the probe with await asyncio.to_thread(KiroCrewConfig.load).

[operator: dwu96]

@dwu96
dwu96 force-pushed the fix/crew-record-redaction-8447 branch 2 times, most recently from 0768809 to 5e5c3d0 Compare September 4, 2026 15:59
@dwu96

dwu96 commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

span=d77e9c4049d0 — fixed in 5e5c3d0.

Finding (src/kiro_crew/dashboard/handlers/agents.py, round 5): the top-level default_agent string in the /api/agents response is the same class of agent-writable config value as the row fields (config.json's default_agent key), and it shipped raw beside the redacted rows.

Fix: default_agent now funnels through the same chain (_redact_memory_field — exfiltration-URL then credential redaction) at the response chokepoint. Same fail-safe posture already accepted for row names: a credential-shaped default alias renders as the marker and stops matching, which breaks only the pathological config.

Evidence: red-first — test_default_agent_is_redacted_too (credential-shaped default alias must not appear in the response body) fails on 0768809, passes on 5e5c3d0; mutation check — reverting default_agent to the raw value makes exactly that test red. 22/22 in the fix's test file green; security.md roster-serializers bullet updated in the same commit to name default_agent in the covered surface.

Fix: Apply the same redaction chain to default_agent.

[operator: dwu96]

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Sep 4, 2026
Free-text string fields on the crew/agent record (description, triggers,
workspace, memory_store, ...) are agent-writable through config.json and
were echoed to dashboard JSON verbatim by GET /api/agents (a full
dataclasses.asdict spread, both row sources) and GET /api/members (an
explicit allowlist with no redaction pass over the values). A short
credential (e.g. a 20-char AWS access key id) written into any such field
reached the browser unredacted.

Fix: one field-generic serialization chokepoint per endpoint.
_shared.redact_record_strings applies redact_exfiltration_urls then
redact_credentials (the members.py _sanitize order) to every string value
of a serialized record, recursing into nested dicts/lists by delegating to
_redact_memory_field, the shared recursive scrubber. /api/agents runs each
row through it after the usage sort (covering the cfg.agents spread AND the
project-scope default rows); /api/members runs the allowlisted record dict
through it at construction (the allowlist contract stays; only values are
scrubbed). The transcript last_message preview keeps its existing
pre-truncation _sanitize pass untouched.

Fixes #8447
@dwu96
dwu96 force-pushed the fix/crew-record-redaction-8447 branch from 5e5c3d0 to a56bb4b Compare September 4, 2026 16:20
@dwu96

dwu96 commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

span=34179bd30024 — fixed in a56bb4b.

Finding (src/kiro_crew/dashboard/handlers/agents.py::_is_pure_marker): exfil markers can carry an interior ] — a bracketed IPv6 host renders as [REDACTED: suspicious URL to [2001:db8::1]] — so the "]" not in text[:-1] clause false-negatived exactly there, and the stale-save floor did not fire for whole-value exfil-URL redactions with IPv6 hosts. Reproduced empirically before fixing: redact_exfiltration_urls("https://[2001:db8::1]/?d=" + "a"*210) yields precisely that marker.

Fix — the suggested first option adopted: the clause is dropped; _is_pure_marker anchors only on the "[REDACTED: " prefix, the "]" suffix, and non-empty inner text. The wider match errs toward NOT writing (the newer stored value survives), which is the fail-safe direction at this boundary; pasted text merely containing a marker alongside other leading text still writes normally (test_marker_text_over_benign_value_is_written stays green).

Evidence: red-first — test_stale_marker_with_interior_bracket_is_also_refused drives the full end-to-end race with the IPv6-shaped marker (seed a bracketed-IPv6 exfil URL, GET serves the interior-] marker, concurrent benign write, stale echo save) and fails on 5e5c3d0, passes on a56bb4b with the newer value preserved. Mutation check — restoring the interior-] clause makes exactly that test red (1 failed, 7 passed in the guard class). 23/23 in the fix's test file green; black/flake8/mypy clean.

Fix: drop the and "]" not in text[:-1] clause (anchor only on the "[REDACTED: " prefix / "]" suffix and non-empty inner text).

[operator: dwu96]

@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention and removed readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running labels Sep 4, 2026
@dwu96

dwu96 commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

span=3933f2677293 — needs-a-decision (maintainer ruling requested; lane-vs-lane conflict on an already-adjudicated span).

The round-6 finding asks to (a) replace the snapshot-equality omission with explicit per-field touched tracking and (b) remove the backend pure-marker suppression. Both halves recycle ground this PR adopted at GPT's own direction and that GPT stamped CLEAN one head earlier:

  • The snapshot-equality omission IS the round-3/round-4 named fix ("track the opening manifest and per-field touched state; omit initially redacted fields unless explicitly edited"): a form value equal to the opening prefill has, by definition, not been explicitly edited. GPT reviewed exactly this mechanism at head 5e5c3d0 and posted no block (first clean verdict of the drive).
  • The pure-marker floor is the round-3 residual closure (stale-save race for non-manifest clients). The ONLY change to it since GPT's clean pass is dropping the interior-] clause — the change the OPUS lane blocked head 5e5c3d0 for, with an empirically verified bracketed-IPv6 marker reproduction. Removing the floor, as this finding asks, reopens the exact data-loss hole both prior rounds established as real.

The residual the finding names is real but is a DESIGN TRADEOFF, not a one-sided defect: a value that is byte-identical to a bare redaction marker is indistinguishable from a stale echo by construction. The two lanes now demand opposites on it — Opus: refuse more such values (fail toward preserving the stored value at a security boundary); GPT: preserve them as explicit edits (fail toward accepting a marker-shaped write into config.json). The current code takes the Opus/fail-safe side: the only capability lost is deliberately storing a value that IS a bare [REDACTED: ...] marker, which is also the one shape the roster can never round-trip faithfully anyway.

Question for the maintainer: should PUT /api/agents/{name} (1) keep refusing bare-marker values (current, fail-safe, Opus-endorsed), or (2) accept them as explicit edits per this finding, reopening the stale-save overwrite for non-manifest clients? If (1), an /ai-review override gpt a56bb4be269671315c1bb792632caf65abe420de: pure-marker refusal is the adjudicated fail-safe posture; removal reopens the round-3 data-loss hole Opus verified clears this lane.

[operator: dwu96]

chenmingwei23 added a commit that referenced this pull request Sep 4, 2026
… it cannot show

api_kirocrew_agents built each roster row with
{"name": name, "scope": ..., **dataclasses.asdict(agent_cfg)}, so the
endpoint's response contract was "every field KiroCrewAgentConfig has now,
plus every field anyone adds later", automatically. handlers/members.py
made the opposite call for GET /api/members and documented why: the
response is a network-boundary contract, and a spread ships a future
field -- internal bookkeeping, a filesystem path, a credential-shaped one --
to the browser by omission.

KEY half: both row sources (the cfg.agents rows and the project-scope
rows) now go through one _agent_roster_row allowlist naming 11 keys, so
the two cannot drift into different key sets either. Three record fields
are withheld, each with no consumer anywhere in website/src: the two
per-agent watchdog windows and the deprecated, inert telegram_account.

VALUE half, two co-operating halves. Every record value is agent- or
package-writable -- an agent can edit config.json, and _do_agents_sync
copies description straight off a discovered agent spec -- so a value the
redactors would alter, or a non-string the loader let through, is replaced
WHOLESALE by _SENSITIVE_MASK, the sentinel _masked_config_dict already
uses for the same job on GET /api/config/kirocrew. Benign content is
byte-identical.

api_kirocrew_agent_update then drops any body field carrying that mask,
treating it as unchanged -- the remedy _masked_config_dict's docstring
prescribes verbatim. Without it the read half would destroy stored config:
the agents page seeds its edit sheet from a roster row and sends every
field on every save so that "" can clear a pin.

A FIXED sentinel rather than redacting in place is the load-bearing
choice. Recomputing the redaction to recognise the view breaks two ways a
sentinel does not: a second redaction chain over the same response (#8465)
produces a view the predicate no longer matches, and a stored value that
changes between the GET and the PUT makes the stale view read as a genuine
edit, writing redaction markers into the config. The sentinel depends on
neither. Cost, named: a value containing one credential-shaped token is
masked entirely rather than partially, the same trade the config endpoint
already makes.

Masking a non-string rather than coercing it to "" is what lets the write
rule PRESERVE it; an echoed "" would read as a genuine edit.

name is the single exemption, and only for the owner: it travels in the URL
rather than the body so the write rule cannot protect it, and masking it
would make /api/agents/{name} unaddressable. An app token cannot reach
those owner-gated routes, so name is masked there too. dict[str, str] is
now true rather than aspirational.

test_agents_roster_contract.py pins the exact key set at the endpoint for
both row sources, ratchets the allowlist against dataclasses.fields, and
covers both halves over HTTP including the stale-view case and that a
genuine edit still writes through.

Fixes #8454
chenmingwei23 added a commit that referenced this pull request Sep 4, 2026
… it cannot show

api_kirocrew_agents built each roster row with
{"name": name, "scope": ..., **dataclasses.asdict(agent_cfg)}, so the
endpoint's response contract was "every field KiroCrewAgentConfig has now,
plus every field anyone adds later", automatically. handlers/members.py
made the opposite call for GET /api/members and documented why: the
response is a network-boundary contract, and a spread ships a future
field -- internal bookkeeping, a filesystem path, a credential-shaped one --
to the browser by omission.

KEY half: both row sources (the cfg.agents rows and the project-scope
rows) now go through one _agent_roster_row allowlist naming 11 keys, so
the two cannot drift into different key sets either. Three record fields
are withheld, each with no consumer anywhere in website/src: the two
per-agent watchdog windows and the deprecated, inert telegram_account.

VALUE half, two co-operating halves. Every record value is agent- or
package-writable -- an agent can edit config.json, and _do_agents_sync
copies description straight off a discovered agent spec -- so a value the
redactors would alter, or a non-string the loader let through, is replaced
WHOLESALE by _SENSITIVE_MASK, the sentinel _masked_config_dict already
uses for the same job on GET /api/config/kirocrew. Benign content is
byte-identical.

api_kirocrew_agent_update then drops any body field carrying that mask,
treating it as unchanged -- the remedy _masked_config_dict's docstring
prescribes verbatim. Without it the read half would destroy stored config:
the agents page seeds its edit sheet from a roster row and sends every
field on every save so that "" can clear a pin.

A FIXED sentinel rather than redacting in place is the load-bearing
choice. Recomputing the redaction to recognise the view breaks two ways a
sentinel does not: a second redaction chain over the same response (#8465)
produces a view the predicate no longer matches, and a stored value that
changes between the GET and the PUT makes the stale view read as a genuine
edit, writing redaction markers into the config. The sentinel depends on
neither. Cost, named: a value containing one credential-shaped token is
masked entirely rather than partially, the same trade the config endpoint
already makes.

Masking a non-string rather than coercing it to "" is what lets the write
rule PRESERVE it; an echoed "" would read as a genuine edit.

name is the single exemption, and only for the owner: it travels in the URL
rather than the body so the write rule cannot protect it, and masking it
would make /api/agents/{name} unaddressable. An app token cannot reach
those owner-gated routes, so name is masked there too. dict[str, str] is
now true rather than aspirational.

test_agents_roster_contract.py pins the exact key set at the endpoint for
both row sources, ratchets the allowlist against dataclasses.fields, and
covers both halves over HTTP including the stale-view case and that a
genuine edit still writes through.

Fixes #8454
chenmingwei23 added a commit that referenced this pull request Sep 4, 2026
… it cannot show

api_kirocrew_agents built each roster row with
{"name": name, "scope": ..., **dataclasses.asdict(agent_cfg)}, so the
endpoint's response contract was "every field KiroCrewAgentConfig has now,
plus every field anyone adds later", automatically. handlers/members.py
made the opposite call for GET /api/members and documented why: the
response is a network-boundary contract, and a spread ships a future
field -- internal bookkeeping, a filesystem path, a credential-shaped one --
to the browser by omission.

KEY half: both row sources (the cfg.agents rows and the project-scope
rows) now go through one _agent_roster_row allowlist naming 11 keys, so
the two cannot drift into different key sets either. Three record fields
are withheld, each with no consumer anywhere in website/src: the two
per-agent watchdog windows and the deprecated, inert telegram_account.

VALUE half, two co-operating halves. Every record value is agent- or
package-writable -- an agent can edit config.json, and _do_agents_sync
copies description straight off a discovered agent spec -- so a value the
redactors would alter, or a non-string the loader let through, is replaced
WHOLESALE by _SENSITIVE_MASK, the sentinel _masked_config_dict already
uses for the same job on GET /api/config/kirocrew. Benign content is
byte-identical.

api_kirocrew_agent_update then drops any body field carrying that mask,
treating it as unchanged -- the remedy _masked_config_dict's docstring
prescribes verbatim. Without it the read half would destroy stored config:
the agents page seeds its edit sheet from a roster row and sends every
field on every save so that "" can clear a pin.

A FIXED sentinel rather than redacting in place is the load-bearing
choice. Recomputing the redaction to recognise the view breaks two ways a
sentinel does not: a second redaction chain over the same response (#8465)
produces a view the predicate no longer matches, and a stored value that
changes between the GET and the PUT makes the stale view read as a genuine
edit, writing redaction markers into the config. The sentinel depends on
neither. Cost, named: a value containing one credential-shaped token is
masked entirely rather than partially, the same trade the config endpoint
already makes.

Masking a non-string rather than coercing it to "" is what lets the write
rule PRESERVE it; an echoed "" would read as a genuine edit.

name is the single exemption, and only for the owner: it travels in the URL
rather than the body so the write rule cannot protect it, and masking it
would make /api/agents/{name} unaddressable. An app token cannot reach
those owner-gated routes, so name is masked there too. dict[str, str] is
now true rather than aspirational.

test_agents_roster_contract.py pins the exact key set at the endpoint for
both row sources, ratchets the allowlist against dataclasses.fields, and
covers both halves over HTTP including the stale-view case and that a
genuine edit still writes through.

Fixes #8454
chenmingwei23 added a commit that referenced this pull request Sep 4, 2026
… it cannot show

api_kirocrew_agents built each roster row with
{"name": name, "scope": ..., **dataclasses.asdict(agent_cfg)}, so the
endpoint's response contract was "every field KiroCrewAgentConfig has now,
plus every field anyone adds later", automatically. handlers/members.py
made the opposite call for GET /api/members and documented why: the
response is a network-boundary contract, and a spread ships a future
field -- internal bookkeeping, a filesystem path, a credential-shaped one --
to the browser by omission.

KEY half: both row sources (the cfg.agents rows and the project-scope
rows) now go through one _agent_roster_row allowlist naming 11 keys, so
the two cannot drift into different key sets either. Three record fields
are withheld, each with no consumer anywhere in website/src: the two
per-agent watchdog windows and the deprecated, inert telegram_account.

VALUE half, two co-operating halves. Every record value is agent- or
package-writable -- an agent can edit config.json, and _do_agents_sync
copies description straight off a discovered agent spec -- so a value the
redactors would alter, or a non-string the loader let through, is replaced
WHOLESALE by _SENSITIVE_MASK, the sentinel _masked_config_dict already
uses for the same job on GET /api/config/kirocrew. Benign content is
byte-identical.

api_kirocrew_agent_update then drops any body field carrying that mask,
treating it as unchanged -- the remedy _masked_config_dict's docstring
prescribes verbatim. Without it the read half would destroy stored config:
the agents page seeds its edit sheet from a roster row and sends every
field on every save so that "" can clear a pin.

A FIXED sentinel rather than redacting in place is the load-bearing
choice. Recomputing the redaction to recognise the view breaks two ways a
sentinel does not: a second redaction chain over the same response (#8465)
produces a view the predicate no longer matches, and a stored value that
changes between the GET and the PUT makes the stale view read as a genuine
edit, writing redaction markers into the config. The sentinel depends on
neither. Cost, named: a value containing one credential-shaped token is
masked entirely rather than partially, the same trade the config endpoint
already makes.

Masking a non-string rather than coercing it to "" is what lets the write
rule PRESERVE it; an echoed "" would read as a genuine edit.

name is the single exemption, and only for the owner: it travels in the URL
rather than the body so the write rule cannot protect it, and masking it
would make /api/agents/{name} unaddressable. An app token cannot reach
those owner-gated routes, so name is masked there too. dict[str, str] is
now true rather than aspirational.

test_agents_roster_contract.py pins the exact key set at the endpoint for
both row sources, ratchets the allowlist against dataclasses.fields, and
covers both halves over HTTP including the stale-view case and that a
genuine edit still writes through.

Fixes #8454
chenmingwei23 added a commit that referenced this pull request Sep 4, 2026
… it cannot show

api_kirocrew_agents built each roster row with
{"name": name, "scope": ..., **dataclasses.asdict(agent_cfg)}, so the
endpoint's response contract was "every field KiroCrewAgentConfig has now,
plus every field anyone adds later", automatically. handlers/members.py
made the opposite call for GET /api/members and documented why: the
response is a network-boundary contract, and a spread ships a future
field -- internal bookkeeping, a filesystem path, a credential-shaped one --
to the browser by omission.

KEY half: both row sources (the cfg.agents rows and the project-scope
rows) now go through one _agent_roster_row allowlist naming 11 keys, so
the two cannot drift into different key sets either. Three record fields
are withheld, each with no consumer anywhere in website/src: the two
per-agent watchdog windows and the deprecated, inert telegram_account.

VALUE half, two co-operating halves. Every record value is agent- or
package-writable -- an agent can edit config.json, and _do_agents_sync
copies description straight off a discovered agent spec -- so a value the
redactors would alter, or a non-string the loader let through, is replaced
WHOLESALE by _SENSITIVE_MASK, the sentinel _masked_config_dict already
uses for the same job on GET /api/config/kirocrew. Benign content is
byte-identical.

api_kirocrew_agent_update then drops any body field carrying that mask,
treating it as unchanged -- the remedy _masked_config_dict's docstring
prescribes verbatim. Without it the read half would destroy stored config:
the agents page seeds its edit sheet from a roster row and sends every
field on every save so that "" can clear a pin.

A FIXED sentinel rather than redacting in place is the load-bearing
choice. Recomputing the redaction to recognise the view breaks two ways a
sentinel does not: a second redaction chain over the same response (#8465)
produces a view the predicate no longer matches, and a stored value that
changes between the GET and the PUT makes the stale view read as a genuine
edit, writing redaction markers into the config. The sentinel depends on
neither. Cost, named: a value containing one credential-shaped token is
masked entirely rather than partially, the same trade the config endpoint
already makes.

Masking a non-string rather than coercing it to "" is what lets the write
rule PRESERVE it; an echoed "" would read as a genuine edit.

name is the single exemption, and only for the owner: it travels in the URL
rather than the body so the write rule cannot protect it, and masking it
would make /api/agents/{name} unaddressable. An app token cannot reach
those owner-gated routes, so name is masked there too. dict[str, str] is
now true rather than aspirational.

test_agents_roster_contract.py pins the exact key set at the endpoint for
both row sources, ratchets the allowlist against dataclasses.fields, and
covers both halves over HTTP including the stale-view case and that a
genuine edit still writes through.

Fixes #8454
@github-actions github-actions Bot added the merge conflict Branch has merge conflicts with its base — author must resolve before merge label Sep 5, 2026
chenmingwei23 added a commit that referenced this pull request Sep 5, 2026
… it cannot show

api_kirocrew_agents built each roster row with
{"name": name, "scope": ..., **dataclasses.asdict(agent_cfg)}, so the
endpoint's response contract was "every field KiroCrewAgentConfig has now,
plus every field anyone adds later", automatically. handlers/members.py
made the opposite call for GET /api/members and documented why: the
response is a network-boundary contract, and a spread ships a future
field -- internal bookkeeping, a filesystem path, a credential-shaped one --
to the browser by omission.

KEY half: both row sources (the cfg.agents rows and the project-scope
rows) now go through one _agent_roster_row allowlist naming 11 keys, so
the two cannot drift into different key sets either. Three record fields
are withheld, each with no consumer anywhere in website/src: the two
per-agent watchdog windows and the deprecated, inert telegram_account.

VALUE half, two co-operating halves. Every record value is agent- or
package-writable -- an agent can edit config.json, and _do_agents_sync
copies description straight off a discovered agent spec -- so a value the
redactors would alter, or a non-string the loader let through, is replaced
WHOLESALE by _SENSITIVE_MASK, the sentinel _masked_config_dict already
uses for the same job on GET /api/config/kirocrew. Benign content is
byte-identical.

api_kirocrew_agent_update then drops any body field carrying that mask,
treating it as unchanged -- the remedy _masked_config_dict's docstring
prescribes verbatim. Without it the read half would destroy stored config:
the agents page seeds its edit sheet from a roster row and sends every
field on every save so that "" can clear a pin.

A FIXED sentinel rather than redacting in place is the load-bearing
choice. Recomputing the redaction to recognise the view breaks two ways a
sentinel does not: a second redaction chain over the same response (#8465)
produces a view the predicate no longer matches, and a stored value that
changes between the GET and the PUT makes the stale view read as a genuine
edit, writing redaction markers into the config. The sentinel depends on
neither. Cost, named: a value containing one credential-shaped token is
masked entirely rather than partially, the same trade the config endpoint
already makes.

Masking a non-string rather than coercing it to "" is what lets the write
rule PRESERVE it; an echoed "" would read as a genuine edit.

name is the single exemption, and only for the owner: it travels in the URL
rather than the body so the write rule cannot protect it, and masking it
would make /api/agents/{name} unaddressable. An app token cannot reach
those owner-gated routes, so name is masked there too. dict[str, str] is
now true rather than aspirational.

test_agents_roster_contract.py pins the exact key set at the endpoint for
both row sources, ratchets the allowlist against dataclasses.fields, and
covers both halves over HTTP including the stale-view case and that a
genuine edit still writes through.

Fixes #8454
chenmingwei23 added a commit that referenced this pull request Sep 5, 2026
… it cannot show

api_kirocrew_agents built each roster row with
{"name": name, "scope": ..., **dataclasses.asdict(agent_cfg)}, so the
endpoint's response contract was "every field KiroCrewAgentConfig has now,
plus every field anyone adds later", automatically. handlers/members.py
made the opposite call for GET /api/members and documented why: the
response is a network-boundary contract, and a spread ships a future
field -- internal bookkeeping, a filesystem path, a credential-shaped one --
to the browser by omission.

KEY half: both row sources (the cfg.agents rows and the project-scope
rows) now go through one _agent_roster_row allowlist naming 11 keys, so
the two cannot drift into different key sets either. Three record fields
are withheld, each with no consumer anywhere in website/src: the two
per-agent watchdog windows and the deprecated, inert telegram_account.

VALUE half, two co-operating halves. Every record value is agent- or
package-writable -- an agent can edit config.json, and _do_agents_sync
copies description straight off a discovered agent spec -- so a value the
redactors would alter, or a non-string the loader let through, is replaced
WHOLESALE by _SENSITIVE_MASK, the sentinel _masked_config_dict already
uses for the same job on GET /api/config/kirocrew. Benign content is
byte-identical.

api_kirocrew_agent_update then drops any body field carrying that mask,
treating it as unchanged -- the remedy _masked_config_dict's docstring
prescribes verbatim. Without it the read half would destroy stored config:
the agents page seeds its edit sheet from a roster row and sends every
field on every save so that "" can clear a pin.

A FIXED sentinel rather than redacting in place is the load-bearing
choice. Recomputing the redaction to recognise the view breaks two ways a
sentinel does not: a second redaction chain over the same response (#8465)
produces a view the predicate no longer matches, and a stored value that
changes between the GET and the PUT makes the stale view read as a genuine
edit, writing redaction markers into the config. The sentinel depends on
neither. Cost, named: a value containing one credential-shaped token is
masked entirely rather than partially, the same trade the config endpoint
already makes.

Masking a non-string rather than coercing it to "" is what lets the write
rule PRESERVE it; an echoed "" would read as a genuine edit.

name is the single exemption, and only for the owner: it travels in the URL
rather than the body so the write rule cannot protect it, and masking it
would make /api/agents/{name} unaddressable. An app token cannot reach
those owner-gated routes, so name is masked there too. dict[str, str] is
now true rather than aspirational.

test_agents_roster_contract.py pins the exact key set at the endpoint for
both row sources, ratchets the allowlist against dataclasses.fields, and
covers both halves over HTTP including the stale-view case and that a
genuine edit still writes through.

Fixes #8454
chenmingwei23 added a commit that referenced this pull request Sep 5, 2026
… it cannot show

api_kirocrew_agents built each roster row with
{"name": name, "scope": ..., **dataclasses.asdict(agent_cfg)}, so the
endpoint's response contract was "every field KiroCrewAgentConfig has now,
plus every field anyone adds later", automatically. handlers/members.py
made the opposite call for GET /api/members and documented why: the
response is a network-boundary contract, and a spread ships a future
field -- internal bookkeeping, a filesystem path, a credential-shaped one --
to the browser by omission.

KEY half: both row sources (the cfg.agents rows and the project-scope
rows) now go through one _agent_roster_row allowlist naming 11 keys, so
the two cannot drift into different key sets either. Three record fields
are withheld, each with no consumer anywhere in website/src: the two
per-agent watchdog windows and the deprecated, inert telegram_account.

VALUE half, two co-operating halves. Every record value is agent- or
package-writable -- an agent can edit config.json, and _do_agents_sync
copies description straight off a discovered agent spec -- so a value the
redactors would alter, or a non-string the loader let through, is replaced
WHOLESALE by _SENSITIVE_MASK, the sentinel _masked_config_dict already
uses for the same job on GET /api/config/kirocrew. Benign content is
byte-identical.

api_kirocrew_agent_update then drops any body field carrying that mask,
treating it as unchanged -- the remedy _masked_config_dict's docstring
prescribes verbatim. Without it the read half would destroy stored config:
the agents page seeds its edit sheet from a roster row and sends every
field on every save so that "" can clear a pin.

A FIXED sentinel rather than redacting in place is the load-bearing
choice. Recomputing the redaction to recognise the view breaks two ways a
sentinel does not: a second redaction chain over the same response (#8465)
produces a view the predicate no longer matches, and a stored value that
changes between the GET and the PUT makes the stale view read as a genuine
edit, writing redaction markers into the config. The sentinel depends on
neither. Cost, named: a value containing one credential-shaped token is
masked entirely rather than partially, the same trade the config endpoint
already makes.

Masking a non-string rather than coercing it to "" is what lets the write
rule PRESERVE it; an echoed "" would read as a genuine edit.

name is the single exemption, and only for the owner: it travels in the URL
rather than the body so the write rule cannot protect it, and masking it
would make /api/agents/{name} unaddressable. An app token cannot reach
those owner-gated routes, so name is masked there too. dict[str, str] is
now true rather than aspirational.

test_agents_roster_contract.py pins the exact key set at the endpoint for
both row sources, ratchets the allowlist against dataclasses.fields, and
covers both halves over HTTP including the stale-view case and that a
genuine edit still writes through.

Fixes #8454
chenmingwei23 added a commit that referenced this pull request Sep 5, 2026
… it cannot show

api_kirocrew_agents built each roster row with
{"name": name, "scope": ..., **dataclasses.asdict(agent_cfg)}, so the
endpoint's response contract was "every field KiroCrewAgentConfig has now,
plus every field anyone adds later", automatically. handlers/members.py
made the opposite call for GET /api/members and documented why: the
response is a network-boundary contract, and a spread ships a future
field -- internal bookkeeping, a filesystem path, a credential-shaped one --
to the browser by omission.

KEY half: both row sources (the cfg.agents rows and the project-scope
rows) now go through one _agent_roster_row allowlist naming 11 keys, so
the two cannot drift into different key sets either. Three record fields
are withheld, each with no consumer anywhere in website/src: the two
per-agent watchdog windows and the deprecated, inert telegram_account.

VALUE half, two co-operating halves. Every record value is agent- or
package-writable -- an agent can edit config.json, and _do_agents_sync
copies description straight off a discovered agent spec -- so a value the
redactors would alter, or a non-string the loader let through, is replaced
WHOLESALE by _SENSITIVE_MASK, the sentinel _masked_config_dict already
uses for the same job on GET /api/config/kirocrew. Benign content is
byte-identical.

api_kirocrew_agent_update then drops any body field carrying that mask,
treating it as unchanged -- the remedy _masked_config_dict's docstring
prescribes verbatim. Without it the read half would destroy stored config:
the agents page seeds its edit sheet from a roster row and sends every
field on every save so that "" can clear a pin.

A FIXED sentinel rather than redacting in place is the load-bearing
choice. Recomputing the redaction to recognise the view breaks two ways a
sentinel does not: a second redaction chain over the same response (#8465)
produces a view the predicate no longer matches, and a stored value that
changes between the GET and the PUT makes the stale view read as a genuine
edit, writing redaction markers into the config. The sentinel depends on
neither. Cost, named: a value containing one credential-shaped token is
masked entirely rather than partially, the same trade the config endpoint
already makes.

Masking a non-string rather than coercing it to "" is what lets the write
rule PRESERVE it; an echoed "" would read as a genuine edit.

name is the single exemption, and only for the owner: it travels in the URL
rather than the body so the write rule cannot protect it, and masking it
would make /api/agents/{name} unaddressable. An app token cannot reach
those owner-gated routes, so name is masked there too. dict[str, str] is
now true rather than aspirational.

test_agents_roster_contract.py pins the exact key set at the endpoint for
both row sources, ratchets the allowlist against dataclasses.fields, and
covers both halves over HTTP including the stale-view case and that a
genuine edit still writes through.

Fixes #8454
bolichen97 pushed a commit that referenced this pull request Sep 6, 2026
… it cannot show (#8472)

api_kirocrew_agents built each roster row with
{"name": name, "scope": ..., **dataclasses.asdict(agent_cfg)}, so the
endpoint's response contract was "every field KiroCrewAgentConfig has now,
plus every field anyone adds later", automatically. handlers/members.py
made the opposite call for GET /api/members and documented why: the
response is a network-boundary contract, and a spread ships a future
field -- internal bookkeeping, a filesystem path, a credential-shaped one --
to the browser by omission.

KEY half: both row sources (the cfg.agents rows and the project-scope
rows) now go through one _agent_roster_row allowlist naming 11 keys, so
the two cannot drift into different key sets either. Three record fields
are withheld, each with no consumer anywhere in website/src: the two
per-agent watchdog windows and the deprecated, inert telegram_account.

VALUE half, two co-operating halves. Every record value is agent- or
package-writable -- an agent can edit config.json, and _do_agents_sync
copies description straight off a discovered agent spec -- so a value the
redactors would alter, or a non-string the loader let through, is replaced
WHOLESALE by _SENSITIVE_MASK, the sentinel _masked_config_dict already
uses for the same job on GET /api/config/kirocrew. Benign content is
byte-identical.

api_kirocrew_agent_update then drops any body field carrying that mask,
treating it as unchanged -- the remedy _masked_config_dict's docstring
prescribes verbatim. Without it the read half would destroy stored config:
the agents page seeds its edit sheet from a roster row and sends every
field on every save so that "" can clear a pin.

A FIXED sentinel rather than redacting in place is the load-bearing
choice. Recomputing the redaction to recognise the view breaks two ways a
sentinel does not: a second redaction chain over the same response (#8465)
produces a view the predicate no longer matches, and a stored value that
changes between the GET and the PUT makes the stale view read as a genuine
edit, writing redaction markers into the config. The sentinel depends on
neither. Cost, named: a value containing one credential-shaped token is
masked entirely rather than partially, the same trade the config endpoint
already makes.

Masking a non-string rather than coercing it to "" is what lets the write
rule PRESERVE it; an echoed "" would read as a genuine edit.

name is the single exemption, and only for the owner: it travels in the URL
rather than the body so the write rule cannot protect it, and masking it
would make /api/agents/{name} unaddressable. An app token cannot reach
those owner-gated routes, so name is masked there too. dict[str, str] is
now true rather than aspirational.

test_agents_roster_contract.py pins the exact key set at the endpoint for
both row sources, ratchets the allowlist against dataclasses.fields, and
covers both halves over HTTP including the stale-view case and that a
genuine edit still writes through.

Fixes #8454

Co-authored-by: gh-autofix#2887 <chenmingwei23@users.noreply.github.com>
@NicholasRBowers NicholasRBowers added the needs-pr-triage PR scanner: awaiting automated triage label Sep 8, 2026
@chenmingwei23 chenmingwei23 added needs-author-decision PR blocked on author input and removed needs-pr-triage PR scanner: awaiting automated triage labels Sep 8, 2026
@chenmingwei23

Copy link
Copy Markdown
Contributor

🤖 Kiro Crew [operator: chenmingwei23#de330d0c]: This PR has been inactive for 7+ days. I reviewed the blockers but they require your input:

  • GPT 5.6 posts a BLOCKING (security-class) finding on the write-back guard: it argues that _is_pure_marker / omitUntouchedRedactedPrefills suppression can drop an explicit marker-valued edit as if the field were untouched, and its remedy is to track per-field touched state and remove the backend blanket pure-marker suppression. That reverses the three-layer write-back design this PR deliberately adds (manifest + client omission + server marker-drop), so it is a design decision only you can make: accept GPT touched-state rewrite, or override with a reason if you consider the finding not reachable.
  • Design, First Principles and UX all posted advisory CONCERNS converging on the same write-back path (stale partial-marker echo residual, doctor asymmetry). Decide whether to address or disposition each.
  • The failing Backend Tests (test_job_sdk TestLiveness, test_dashboard_cron_to_chat) and the E2E fork-session / chat-streaming check are base-drift or flake, not in this diff; a rebase clears them, but only after the design direction above is settled. The branch is also CONFLICTING (dirty) and needs a rebase.

When you have addressed these, the pipeline will re-assess on its next cycle.

@bolichen97

Copy link
Copy Markdown
Collaborator

@dwu96 thank you for this, and please keep it open, but it needs a hard rescope. Audited at a56bb4b.

Already on main. Merged #8472 covers the GET /api/agents half. src/kiro_crew/dashboard/handlers/agents.py now builds each roster row through an explicit allowlist (_agent_roster_row), replaces every value the redactors would alter with a fixed sentinel (_roster_mask, non-strings included), recurses into avatar traits (_roster_avatar), and blocks the edit sheet's read-modify-write with _carries_mask. Its commit message names this PR and argues the fixed sentinel was chosen over in-place redaction, because a recomputed-equality write guard breaks under redaction-chain drift and under stale views, which is what review rounds 2 to 6 here were patching.

Still missing on main. src/kiro_crew/dashboard/handlers/members.py::api_members still ships workspace, memory_store, model and kiro_agent verbatim, and its docstring still reads "no redaction pass". The top-level default_agent on GET /api/agents is also still raw. Issue #8447 stays open for exactly those two.

Ask. Please narrow this PR to those two items and drop agents.py, the redacted_fields manifest, redact_record_strings_marked, website/src/utils/redactedPrefillGuard.ts and the KiroCrewAgentsPage / AgentSelector changes. A rebase is required either way: the branch is 459 commits behind and currently conflicted. Also note #8613 is adding fields to the same api_members row, and #8307 edits the agent PUT handler and the edit sheet, which matters only if you keep any agents-side scope.

Overlap with #8497 (cc @xuejinT): #8497 deletes the Session Color editor in website/src/pages/KiroCrewAgentsPage.tsx while deliberately keeping the session_color round-trip lines this PR edits. That is textual proximity, not a semantic conflict, and dropping the frontend scope above removes it entirely; #8497 owns that file's changes.

Posted from the 2026-09-08 open-PR relationship audit (read-only, one auditor per PR); reply here if any of this is wrong.

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

Labels

merge conflict Branch has merge conflicts with its base — author must resolve before merge needs-author-decision PR blocked on author input readiness: action required A blocking check or review needs attention

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Crew record strings (avatar traits, description, triggers) reach dashboard JSON without credential redaction

5 participants