Files
waggle-os/docs/plans/VERIFIER-VERDICT-2026-07-10.md
Oleg Maslov 0c3e2ead3b
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled
moving
2026-09-02 10:10:29 +02:00

202 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Adversarial Verifier Verdict — Self-Evolution Arc (steals #2 + #3 v1)
**Date:** 2026-07-10 · **Auditor:** Fable adversarial verifier (read-only) · **Branch:** `feat/steals-2-3`
**Under audit:** `d07fb03b` (Wave 2), `ce9f67ea` (Wave 1), plan `docs/plans/SELF-EVOLUTION-ARC-2026-07-10.md`
## VERDICT: REJECTED → **APPROVED** after re-audit of `a4bcfe28` (see "Re-audit" at end)
*(Original first-pass verdict below, retained for the record.)*
One HIGH finding blocks approval. The core trust boundary (no *autonomous* disk/skill/memory
write when disabled or unapproved) **holds and is well-tested** — the rejection is about the
*quality of the human approval* the whole design leans on, plus follow-ups. The fix is small and
frontend-only; this is a cheap bounce, not a redesign.
---
## What is CORRECT and safe (verified, not assumed)
- **Default OFF.** `self-evolution.json` absent/corrupt/array/partial ⇒ merged onto `DEFAULT_CONFIG`
with `enabled:false`; `tick()` returns 0 before enumerating (`idle-watcher.ts:120-131,214`).
Tested: absent, corrupt, disabled, partial-merge all assert disabled/no-op.
- **VITEST guard + onClose stop** wired (`index.ts:2239-2244`). Single-flight `ticking` guard tested.
- **Watcher → reviewer wiring is fail-safe.** `runReviewTurn` always sends `proposeHeld:true` and
**no `autonomy`** ⇒ chat defaults `autonomyLevel:'normal'` (`chat.ts:564`, `chat-client.ts:76-82`).
- **create_skill is gated at normal** (`confirmation.ts:16-26` `ALWAYS_CONFIRM`) ⇒ it reaches the
`proposeHeldTurn` intercept (`chat.ts:1171-1189`) and is **held**, never executed inline. The
intercept runs **before** the grant-store and auto-approve checks, so a saved "Always allow"
cannot leak a headless write. Any *other* gated tool in a review turn is `cancel:true` denied.
- **Reviewer's effective tool pool = reads + create_skill only.** `applyPersonaToolFilter` allowlist
= `tools[] ALWAYS_AVAILABLE_TOOLS`, then `disallowedTools` strips the re-adds. `save_memory`,
`delete_skill`, `install_capability`, `acquire_capability`, `add_task`, `correct_knowledge`,
`spawn_agent`, `bash`, file/git writes, `execute_step`, `compose_workflow` are all explicitly
disallowed and **stripped** — locked by `session-reviewer-persona.test.ts:39-56`. Connectors carry
dynamic names in neither `tools[]` nor `ALWAYS_AVAILABLE`, so the allowlist already excludes them.
The non-create_skill survivors are all reads (jailed to workspace `files/`), harmless to run inline.
- **User manually selecting `session-reviewer` in normal chat** is safe: no `proposeHeld`, so
create_skill takes the normal interactive approval path — the user approves their own write. Not
surfaced in `persona-tier.ts` (no ref) so it is not offered in the PersonaSwitcher/onboarding.
- **Held execution is defended twice:** `enqueueHeldAction` scans args + refuses critical/non-proposable;
`executeHeldAction` re-scans + re-checks critical at execute time + atomic idempotent claim gate;
runs the REAL create_skill tool through the sanctioned, backup-protected `writeSkill`
(`skill-write-service.ts:168-200`, `invalidName` blocks path traversal, `stashBackup` before overwrite).
- **Anti-nag gate** namespaces dedupeKeys (`prompt_optimization:<ws>`, `monthly_assessment`,
`self-evolution:<session>`) — no cross-source collision; corrupt/array store recovers to "notify";
LRU-capped at 200. The "Action awaiting your approval" notification is emitted **without** a
dedupeKey, so a real proposal notification is never suppressed. Tests are thorough and real.
- **Memory-write lint** correctly routes symptoms to `recordCapabilityGap` + cancels; conservative
empty/non-string ⇒ allow; sentence-scoped to avoid cross-sentence combination.
---
## FINDINGS
### [HIGH] F1 — ApprovalsApp shows the human NOTHING about a held `create_skill`'s content
`apps/web/src/components/os/apps/ApprovalsApp.tsx:62-74` (`summarizeInput`) + `:244-251`.
`summarizeInput` only surfaces `to/recipient`, `path/file_path/target_workspace_id`, `command`,
`query`. A `create_skill` held action's args are `{name, content}`**none of those fields match**,
so `inputSummary` is the empty string. The card then renders:
- `toolName`: `create_skill`
- `Automation:` line: `describeToolUse('create_skill') = "Creating skill: <name>..."` (`chat-helpers.ts:158-159`), CSS-`truncate`d
- **the skill body (`input.content`) is never shown, and there is no expand/inspect affordance.**
`/api/approval/pending` DOES return the full `input` (`approval.ts:82-92`, `safeParseArgs`), so the
data is present — the FE simply doesn't render it.
**Why HIGH.** The entire safety story of steal #3 v1 is "review-before-apply — every apply goes
through ApprovalsApp." A skill is a *persistence primitive*: once approved it is written to disk and
injected into every future agent turn's prompt. The reviewer is fed an attacker-influenceable
session transcript (see F-note below), and its one write is exactly this. A human clicking
"Approve & run" sees only a skill name and a truncated verb phrase — they cannot see the instructions
they are persisting. That converts the human control from "review" into "approve-blind" for the one
vehicle this arc adds. (`write_file` held actions at least show the target path via
`summarizeInput`; `create_skill` shows neither location nor content.)
**Mitigations that keep this out of CRITICAL:** default OFF; `enqueueHeldAction` injection-scans the
args (overt payloads refused); `redactSkillContent` on write; human approval still required (no
autonomous write). But a benign-looking-but-adversarial skill body evades the injection scanner.
**Required fix (FE-only, small):** in ApprovalsApp render the held `create_skill` `input.name` and a
content preview with an expander (or a "view full skill" panel) so the approver sees the exact bytes
that `writeSkill` will persist. Optionally extend `summarizeInput` to surface `input.name` for
`create_skill`. Add a test asserting a held create_skill card exposes its content.
### [MEDIUM] F2 — memory-write lint false-positives lose legitimate preference/dependence memories
`packages/agent/src/memory-write-lint.ts:34-61,93-106`. The classifier fires on *(capability noun AND
failure verb in the same sentence)*, and `FAILURE_VERB` includes bare `cannot|can't`. Realistic
LEGIT memories are misclassified as `capability_symptom` and **cancelled**:
- `"User's main tool is Figma; they cannot stand Sketch."``tool` + `cannot` ⇒ blocked (it's a preference).
- `"User can't work without their Jira integration."``integration` + `can't` ⇒ blocked (it's a dependence, positive).
- `"Our API is down for maintenance this weekend."``API`+`is down` ⇒ blocked (a legit business fact).
The module's own docstring promises "User preferences / opinions about tools MUST pass" — these
violate that stated constraint, and CLAUDE.md §coding-style values not losing data. Because
save_memory is the sink (cognify bypasses the hook per the declared v1 gap), the loss is bounded to
explicit agent save_memory calls, and the agent receives a cancel reason it could act on — hence
MEDIUM, not HIGH. **Fix/follow-up:** require the failure to predicate on the *capability* (not the
user) — e.g. exclude when the subject of the failure verb is a person/pronoun, or drop bare
`cannot/can't` from the generic-noun path and keep it only for the connection-failure path.
### [MEDIUM] F3 — the `proposeHeldTurn` intercept branch itself is not directly tested
The plan promised an "end-to-end route test with mocked loopback (reviewer proposes → held action
exists → approve → skill written with backup)." The two halves exist —
`session-reviewer-persona.test.ts` (tool filter) and `held-action-executor.test.ts:153-165`
(create_skill held → executes via sanctioned path) — but the security-critical `chat.ts:1171-1189`
branch (convert a live gated create_skill into `enqueueHeldAction`, and `cancel:true`-deny every
*other* gated tool during a review turn) has **no direct test**. The branch is simple and composes
tested pieces, so this is a coverage gap, not a known break. **Follow-up:** add a route test with
`proposeHeld:true` asserting (a) a create_skill call becomes a held row and (b) a second gated tool
(e.g. write_file) is denied with no side effect.
### [LOW] F4 — meta lazy-backfill elsewhere can trigger a spurious re-review
`idle-watcher.ts:169-177` keys the fired-set on `sessionId:mtimeMs`. The watcher itself is pure, but
if any *other* subsystem calls `readSessionMeta` (which lazily backfills a title/summary and writes
the file — the documented side effect the watcher avoids) on an already-reviewed idle session, its
mtime advances, the fired key changes, and the session **re-fires one review with no new user
content**. Bounded by `maxReviewsPerDay`. Cost/nuisance only. Follow-up: key on last-message content
hash or line count instead of mtime, or persist the fired-set.
### [LOW] F5 — daily cap + fired-set are RAM-only (declared)
`dayCount`/`firedKeys` reset on restart (`idle-watcher.ts:87-89`), so a crash-loop could exceed the
intended 5/day and a restart could re-fire recently-reviewed sessions. Declared as an accepted v1
deviation; acceptable given default-OFF and small cap. Note only.
### [LOW] F6 — NotificationGate load→save is non-atomic
`notification-gate.ts:86-91` reads the whole store then writes it; concurrent emits can race and lose
a fingerprint update. Worst case is one duplicate/missed suppression — never a security effect. Note only.
---
## Declared deviations — assessment
- **Transcript embedded in the review message (vs "reviewer has read tools + workspace binding").**
Sound: `read_file` is jailed to workspace `files/` (`resolveSafe`) so the reviewer cannot open the
session JSONL; embedding is the reliable path. The embedded transcript passes through
`scanForInjection(message,'user_input')` at `chat.ts:617` — a ≥0.7 payload blocks the whole review
(fail-safe), a weaker one can steer the reviewer but its only write is the held, human-approved
create_skill. Acceptable, and it is what makes F1 the load-bearing control.
- **RAM daily cap / fired-set** — acceptable (F5).
- **Persona 22→23** — session-reviewer added, excluded from onboarding + PersonaSwitcher; create_skill
still gates even if selected. Acceptable.
## Path to APPROVED
Fix **F1** (render held create_skill content in ApprovalsApp + a test). F2/F3 are strongly
recommended before shipping self-evolution to users but can be listed follow-ups. F4-F6 are notes.
---
# Re-audit — fix commit `a4bcfe28` (2026-07-10)
## FINAL VERDICT: APPROVED (zero CRITICAL, zero HIGH)
Re-read the full diff of `a4bcfe28` and ran every affected suite myself from the worktree.
### F1 (HIGH) — CLOSED
`ApprovalsApp.tsx`: new `SkillPreview` renders the skill **name** up front + an
expander showing the **exact bytes** `writeSkill` will persist, for any held action
carrying `{name, content}` (`:251-253` gate, `:289-291` render). Content is rendered
as text inside `<pre>{content}</pre>` — React-escaped, no `dangerouslySetInnerHTML`,
so no XSS from an attacker-shaped skill body. Not shown for `send_email` (no
`{name,content}`). The approver can no longer approve blind. Locked by
`ApprovalsApp.test.tsx` (3/3): name visible, content collapsed-by-default then
expandable to exact bytes then collapsible, absent for send_email, generic for any
`{name,content}`.
### F2 (MEDIUM) — CLOSED
`memory-write-lint.ts`: bare `cannot/can't/could not/unable to` now fires ONLY when
immediately followed by a capability verb (`connect|authenticate|access|load|run|…`),
and `is down` carries a `(?!\s+for\s+maintenance)` lookahead. Verified against the
three cited false-positives — all now `allow`: "cannot **stand** Sketch",
"can't **work** without their Jira integration", "our API is down **for maintenance**".
Real symptoms still caught ("failed to authenticate", "keeps timing out", "unavailable",
"cannot **connect**"). Residual is a few exotic-verb false-negatives — the *safe*
direction (memory kept). 35/35 lint tests pass.
### F3 (MEDIUM) — CLOSED
Intercept extracted to `held-action-executor.ts::decideReviewTurnTool()`; `chat.ts:1172`
rewired to call it; `enqueueHeldAction`/`isProposableTool` imports removed with no
dangling references (grep clean). Extraction is behavior-preserving (proposable→held+step,
non-proposable→deny step, always `cancel:true`). Now unit-tested at the exact break point
(`held-action-executor.test.ts` +3): create_skill → held row created & NOT written inline;
`bash` (non-proposable) → denied, no row; injection-tripping create_skill → refused, no row.
### Regression check — the previously-sound boundary is intact
The persona tool-filter, `proposeHeld`-before-grant-store ordering, gated-`create_skill`→held
path, and default-OFF watcher are all unchanged by this commit. No new autonomous-write path.
### Verification I ran (worktree root, not the main checkout)
- vitest: memory-write-lint **35/35**, idle-watcher **17/17**, notification-gate **14/14**,
session-reviewer-persona **2/2**, held-action-executor **17/17**, ApprovalsApp **3/3**.
- tsc `--noEmit`: packages/agent **0**, packages/server **0**, apps/web **0**.
### Remaining residuals (declared LOW — do not block)
F4 (mtime-keyed fired-set → possible spurious re-review), F5 (RAM daily-cap/fired-set reset
on restart), F6 (non-atomic NotificationGate save). Acceptable for v1; track as follow-ups.