P4b: skill noun + contrib/store (SQLite for budget/persona/skill) #5

Closed
steve wants to merge 6 commits from phase-4b-skill into phase-4-batteries
Owner

Continues P4 (stacked on #4). The skill half of the persona/skill pair, plus the second nested module that makes the seams durable.

Skill noun (skill/)

A clean redesign, not a faithful lift of mort's 60-method skills.Storage kitchen sink:

  • skill.go/skill_version.go/validate.go/inputs.go/schedule.go moved clean; the only host couplings severed: llms.IsTierName → model.IsTierName, DefaultChatbotInputName localized.
  • SkillStore is deliberately lean — skill lifecycle (CRUD + visibility) + versioning + scheduling only. The KV/file/quota sub-stores that were fused into mort's interface are the tools/ store seams; email/channel grants stay host concerns.
  • Skill.ToRunnable() lowers a skill into run.RunnableAgent (flat tool list, no palette); Memory default.

contrib/store/ — the second module (the architectural payoff)

A separate go.mod carrying modernc.org/sqlite (pure Go, no cgo), so the SQLite driver never enters the core go.sum. A static-binary host (gadfly) importing only the core stays static; a host wanting turnkey persistence imports this.

  • store.Open(dsn) → *DB, accessor-per-seam: db.Budget(), db.Personas(), db.Skills().
  • budget — atomic 7-day rollover inside a transaction (closes the in-memory store's TOCTOU window); conformance-tested against the same contract as budget.Memory.
  • persona / skill — JSON-blob + indexed columns, so the whole struct round-trips (no domain↔GORM↔DB field-loss footgun) while owner/name/webhook/visibility/schedule lookups stay indexed. Round-trip tests prove nested + map fields survive.

Verification

  • Core go.sum has 0 sqlite refs; contrib/store go.sum carries it (CI asserts both). New CI step builds + tests the nested module.
  • go build/vet/test -race ./... green in both modules.

Remaining P4 (P4c)

audit SQLite store + the critic / schedule / checkpoint batteries (the latter three are inert until the executor wires those run.Ports — a P2 follow-up).

🤖 Generated with Claude Code

Continues P4 (stacked on #4). The skill half of the persona/skill pair, plus the second nested module that makes the seams durable. ## Skill noun (`skill/`) A **clean redesign**, not a faithful lift of mort's 60-method `skills.Storage` kitchen sink: - `skill.go`/`skill_version.go`/`validate.go`/`inputs.go`/`schedule.go` moved clean; the only host couplings severed: `llms.IsTierName → model.IsTierName`, `DefaultChatbotInputName` localized. - **`SkillStore` is deliberately lean** — skill lifecycle (CRUD + visibility) + versioning + scheduling only. The KV/file/quota sub-stores that were fused into mort's interface are the `tools/` store seams; email/channel grants stay host concerns. - `Skill.ToRunnable()` lowers a skill into `run.RunnableAgent` (flat tool list, no palette); `Memory` default. ## `contrib/store/` — the second module (the architectural payoff) A **separate `go.mod`** carrying `modernc.org/sqlite` (pure Go, no cgo), so the SQLite driver **never enters the core go.sum**. A static-binary host (gadfly) importing only the core stays static; a host wanting turnkey persistence imports this. - `store.Open(dsn) → *DB`, accessor-per-seam: `db.Budget()`, `db.Personas()`, `db.Skills()`. - **budget** — atomic 7-day rollover inside a transaction (closes the in-memory store's TOCTOU window); conformance-tested against the same contract as `budget.Memory`. - **persona / skill** — JSON-blob + indexed columns, so the **whole struct round-trips** (no domain↔GORM↔DB field-loss footgun) while owner/name/webhook/visibility/schedule lookups stay indexed. Round-trip tests prove nested + map fields survive. ## Verification - Core go.sum has **0** sqlite refs; contrib/store go.sum carries it (CI asserts both). New CI step builds + tests the nested module. - `go build/vet/test -race ./...` green in both modules. ## Remaining P4 (P4c) audit SQLite store + the critic / schedule / checkpoint batteries (the latter three are inert until the executor wires those `run.Ports` — a P2 follow-up). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
steve added 4 commits 2026-06-27 02:47:58 +00:00
The skill half of the persona/skill pair, as a clean redesign (not a faithful
lift of mort's 60-method skills.Storage kitchen sink):
- skill.go/skill_version.go/validate.go/inputs.go/schedule.go moved clean; the
  only host couplings severed: llms.IsTierName -> model.IsTierName, and the
  chatbot DefaultChatbotInputName const localized.
- store.go: a DELIBERATELY LEAN SkillStore — skill lifecycle (CRUD + visibility)
  + versioning + scheduling ONLY. The KV/file/quota sub-stores that were fused
  into mort's interface are the tools/ store seams; email/channel grants stay
  host concerns.
- runnable.go: Skill.ToRunnable() lowers a skill into run.RunnableAgent (flat
  tool list, no palette — composition is a host concern); DueAt() helper.
- memory.go: NewMemory() — zero-dep in-process SkillStore (visibility filters,
  newest-first versions).

Tests: ToRunnable mapping, visibility (public/shared/private) listing, version
ordering + lookup. No mort dependency (go.mod tidy clean); core imports ZERO
from skill.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Establish the nested persistence module — the architectural reason the core
stays lean: a SEPARATE go.mod carrying modernc.org/sqlite (pure Go, no cgo), so
the SQLite driver NEVER enters the executus core go.sum. A static-binary host
(gadfly) importing only the core stays static; a host wanting turnkey
persistence imports contrib/store.

- sqlite.go: store.Open(dsn) -> *DB (one SQLite file), accessor-per-seam.
- budget_store.go: db.Budget() satisfies budget.BudgetStorage; Add() does the
  7-day window rollover atomically inside a transaction (concurrent Adds can't
  race the read-modify-write — the in-memory store's one weak spot).
- Conformance test: budget.NewDBBudget over the SQLite store passes the SAME
  rolling-window contract as the in-memory store.
- CI: a new step builds + tests contrib/store on its own AND asserts it carries
  the sqlite driver the core forbids (proof the split works). Verified: core
  go.sum has 0 sqlite refs; contrib/store go.sum has it.

persona/skill/audit SQLite stores follow next (same JSON-blob + indexed-columns
pattern, sidestepping the three-layer field-loss footgun).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
db.Personas() satisfies persona.Storage over SQLite. Each Agent is stored as a
JSON blob with extracted indexed columns (owner_id, name, webhook_secret,
chatbot_channel_filter, schedule, next_run_at) — so the WHOLE struct round-trips
(no domain<->GORM<->DB field-loss footgun) while the lookups stay indexable.

Test proves the round-trip preserves nested + map fields (SkillPalette,
StateReactEmoji), the owner/name + webhook + chatbot-filter queries, the
scheduled-due query, and MarkAgentScheduledRun clearing the due window.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
P4: contrib/store — skill SQLite store (lifecycle + versions)
executus CI / test (pull_request) Successful in 1m36s
Adversarial Review (Gadfly) / review (pull_request) Successful in 11m23s
954efde474
db.Skills() satisfies skill.SkillStore over SQLite, same JSON-blob + indexed
columns pattern. Versions live in their own table (each SkillVersion embeds a
full Skill snapshot as JSON), ordered newest-first by an append seq.

Test: round-trip (Tools, ExposeAsChatbotTool), visibility listing
(public/shared/private with SharedWith filtered in Go), chatbot-exposed,
newest-first versions + GetVersionByID, scheduled-due query + MarkScheduledRun.

contrib/store now covers budget + persona + skill; audit store next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

🪰 Gadfly review — glm-5.2:cloud (ollama-cloud)

Verdict: Minor issues — 3 reviewers: security, correctness, error-handling

🔒 Security — No material issues found

Verdict: No material issues found

Through the security lens (authn/authz, injection, secret leakage, unsafe deserialization, input validation) I traced every tainted-to-sink path in the new contrib/store/*.go and skill/*.go files. Findings:

  • SQL injection — none. All user/owner/version IDs flow through ? placeholders (contrib/store/budget_store.go:46, contrib/store/persona_store.go:56,61,109, contrib/store/skill_store.go:63,97,128). The two helpers that string-concatenate a WHERE clause (personaStore.getOne contrib/store/persona_store.go:82, skillStore.getOne/skillStore.query contrib/store/skill_store.go:106,144) take a where string, but I grepped every caller: all pass static literals ("id = ?", "owner_id = ? AND name = ?", "webhook_secret = ?", "visibility = ?", "chatbot = 1"). No user-tainted text reaches the concatenation. This is a safe-but-fragile pattern (a future caller passing a dynamic where would open an injection vector); as defense-in-depth, consider restricting where to an enum/allowlist rather than a free string. Verified by reading contrib/store/persona_store.go and contrib/store/skill_store.go in full.

  • Secret/credential storage — deliberate, not a leak. persona.Agent.WebhookSecret (and skill.Skill.WebhookSecret) are stored plaintext in both an indexed column and the JSON data blob. The skill.go doc comment explicitly justifies this (skill/skill.go ~line 270): the webhook handler must look up by secret on every POST, and the secret is a 64-hex-char random URL key, not a password. GetAgentByWebhookSecret correctly guards the empty case (contrib/store/persona_store.go:112-114). The SQLite = comparison isn't constant-time, but with ~256 bits of entropy the timing channel is infeasible to exploit. Not a finding.

  • Unsafe deserialization — none. json.Unmarshal targets concrete domain structs (persona.Agent, skill.Skill, skill.SkillVersion), never interface{}/any with method sets, so there's no gadget surface. Verified at contrib/store/persona_store.go:53, contrib/store/skill_store.go:96,232.

  • Authz gaps — by design, not a finding. The store performs no ownership/permission checks (Delete, Save, Get accept any ID/owner). The PR description explicitly scopes authz as a host concern, and the lean SkillStore interface mirrors that. The kernel contract is "trust the caller," consistent with the other batteries.

  • DSN handling — fine. store.Open (contrib/store/sqlite.go:34) passes the DSN straight to sql.Open("sqlite", dsn). modernc's DSN is file:…?_pragma=… with no credential component, so the %q formatting in the error paths cannot leak secrets. The DSN is host config, not user input.

  • Input validation — delegated correctly. The store does not call skill.Validate; validation lives in skill/validate.go and is the save-path's job. No store method accepts a struct and silently persists invariant-violating state that the kernel would then trust — the round-trip tests demonstrate the struct is what the host handed in.

Nothing in my lane rises to a blocking or even materially exploitable issue. The single defense-in-depth nit (free-form where string in two unexported helpers) is worth tightening but is not a present vulnerability.

🎯 Correctness — Minor issues

VERDICT: Minor issues

Reviewed through the Correctness lens. Verified against the checked-out code: budget/budget.go (DBBudget.Check/Commit + 7*24*time.Hour window), budget/storage.go (budgetWindow const), budget/memory.go, persona/agent.go (NextRunAt *time.Time), run/agent.go (RunnableAgent fields), model/tiers.go (IsTierName), and the new skill/ and contrib/store/ files.

Findings:

  • contrib/store/skill_store.go:199-204AppendVersion seq assignment is non-atomic, unlike the Memory store. seq = SELECT COALESCE(MAX(seq),0)+1 ... is read in one statement, then the INSERT runs in a separate statement with no transaction and no UNIQUE constraint on (skill_id, seq) (only a non-unique index at line 48). Two concurrent AppendVersion calls for the same skill_id can both compute the same seq and both insert successfully, producing duplicate seq values. ListVersionsBySkill orders by ORDER BY seq DESC (line 211), so the "newest-first" ordering contract documented at line 45 and asserted in TestSQLiteSkillStore would break under concurrency. The in-memory Memory.AppendVersion (skill/memory.go) is safe because it appends under m.mu.Lock(). Fix: wrap the MAX+INSERT in a BeginTx/commit (matching budgetStore.Add's pattern), or add UNIQUE(skill_id, seq) and retry on conflict. Verified by reading both stores.

  • contrib/store/budget_store.go:50-93 — transaction isolation relies on SQLite write serialization, not an explicit row lock. The Add reads via tx.QueryRowContext then upserts, but uses BeginTx(ctx, nil) (deferred). The atomic-vs-TOCTOU claim (the stated purpose per the PR description and CLAUDE.md) holds only because modernc/SQLite serializes writers on a single DB file; a deferred txn lets two concurrent Adds both read the pre-increment row before either writes. In practice SQLite's write lock will make one fail with SQLITE_BUSY rather than silently corrupt, but the busy_timeout pragma noted in sqlite.go's doc example is not actually set in Open (sqlite.go:31-43), so a busy caller gets an immediate error instead of waiting. This is borderline concurrency rather than pure logic; flagged because it undercuts the "closes the in-memory store's TOCTOU window" claim. Verified: Open sets no pragmas.

  • contrib/store/budget_store.go — the conformance test does not exercise the updatedAt/window-start consistency the way Check reads it. Check (budget.go:133) treats the window as expired when b.now().Sub(bud.WindowStart) < 7*24*time.Hour is false, using bud.WindowStart from Get. Add stores window_start as now.Unix() on rollover and Get reconstructs via time.Unix(ws,0).UTC(). These are consistent (I re-derived: unix-second round-trip is lossless for second-resolution time.Time), so no defect here — noting only that the test never asserts row.WindowStart post-rollover, so a future off-by-one in the rollover comparison (>= vs >) at line 78 could slip through. Verified the >= 7*24*time.Hour matches budget.memory.go:47 and budgetWindow.

No material issues in: ToRunnable field mapping (run/agent.go confirms LowLevelTools/SkillPalette/SubAgentPalette shapes, deliberately empty palettes), ParseSchedule shorthand expansion, ScheduleMinInterval probing, effectiveCommandParams/ResolveCommandInputs empty-schema inflation, ListSharedWith slice-aliasing (range copies the header; overwriting the backing array during iteration is safe), or the JSON-blob round-trip approach for persona/skill.

🧯 Error handling & edge cases — Minor issues

Verdict: Minor issues

  • contrib/store/skill_store.go:201 — swallowed Scan error + non-atomic seq computation.

    var seq int64
    _ = s.db.QueryRowContext(ctx, `SELECT COALESCE(MAX(seq),0)+1 FROM skill_versions WHERE skill_id = ?`, sv.SkillID).Scan(&seq)
    

    Two problems, both in the error/edge-case lane:

    1. The _ = discards every scan failure, not just sql.ErrNoRows (which can't happen here anyway — the query always returns one row thanks to COALESCE). On a real failure (context cancellation, a corrupt/locked DB, driver error) seq stays 0 and the subsequent INSERT proceeds silently with a bogus sequence number instead of surfacing the error. The caller gets nil and believes the version was appended correctly.
    2. MAX(seq)+1 is computed outside any transaction, unlike budgetStore.Add which deliberately wraps its read-modify-write in BeginTx to close the TOCTOU window. Two concurrent AppendVersion calls for the same skill_id compute the same seq and both insert successfully — the index idx_skill_versions_skill ON skill_versions(skill_id, seq) (skill_store.go:48) is non-unique, so neither insert fails. Result: duplicate seq values, which breaks the ORDER BY seq DESC newest-first ordering that ListVersionsBySkill (skill_store.go:211) relies on.

    Suggested fix: wrap the select+insert in a transaction (matching budgetStore.Add's pattern) and propagate the scan error.

  • contrib/store/skill_store.go:184 and contrib/store/persona_store.go:159MarkScheduledRun/MarkAgentScheduledRun are read-modify-write without a transaction. Get then mutate then Save. Concurrent calls (or a concurrent Save from elsewhere) race and the later write wins, silently losing the LastScheduledRunAt/NextRunAt update. The errors themselves are propagated correctly, so this is borderline; flagged only because the lost-update path produces no error at all — the caller sees success. Lower priority than the AppendVersion finding since scheduling collisions are less destructive than version-order corruption. Left as a note rather than a blocking claim.

Everything else checked is sound: scanAgents/scanSkills/ListVersionsBySkill all defer rows.Close() and return rows.Err(); getOne/GetVersionByID map sql.ErrNoRowsErrNotFound; budgetStore.Add correctly defers tx.Rollback() and wraps each step; Get returns (nil, nil) on no-row.

Automated adversarial review by Gadfly. Advisory only — does not block merge. · ⏱️ reviewed in 7m 36s

<!-- gadfly-review:ollama:glm-5.2:cloud --> ### 🪰 Gadfly review — `glm-5.2:cloud` (ollama-cloud) **Verdict: Minor issues** — 3 reviewers: security, correctness, error-handling <details><summary><b>🔒 Security</b> — No material issues found</summary> **Verdict: No material issues found** Through the security lens (authn/authz, injection, secret leakage, unsafe deserialization, input validation) I traced every tainted-to-sink path in the new `contrib/store/*.go` and `skill/*.go` files. Findings: - **SQL injection — none.** All user/owner/version IDs flow through `?` placeholders (`contrib/store/budget_store.go:46`, `contrib/store/persona_store.go:56,61,109`, `contrib/store/skill_store.go:63,97,128`). The two helpers that string-concatenate a WHERE clause (`personaStore.getOne` `contrib/store/persona_store.go:82`, `skillStore.getOne`/`skillStore.query` `contrib/store/skill_store.go:106,144`) take a `where` string, but I grepped every caller: all pass static literals (`"id = ?"`, `"owner_id = ? AND name = ?"`, `"webhook_secret = ?"`, `"visibility = ?"`, `"chatbot = 1"`). No user-tainted text reaches the concatenation. This is a safe-but-fragile pattern (a future caller passing a dynamic `where` would open an injection vector); as defense-in-depth, consider restricting `where` to an enum/allowlist rather than a free string. Verified by reading `contrib/store/persona_store.go` and `contrib/store/skill_store.go` in full. - **Secret/credential storage — deliberate, not a leak.** `persona.Agent.WebhookSecret` (and `skill.Skill.WebhookSecret`) are stored plaintext in both an indexed column and the JSON `data` blob. The `skill.go` doc comment explicitly justifies this (`skill/skill.go` ~line 270): the webhook handler must look up by secret on every POST, and the secret is a 64-hex-char random URL key, not a password. `GetAgentByWebhookSecret` correctly guards the empty case (`contrib/store/persona_store.go:112-114`). The SQLite `=` comparison isn't constant-time, but with ~256 bits of entropy the timing channel is infeasible to exploit. Not a finding. - **Unsafe deserialization — none.** `json.Unmarshal` targets concrete domain structs (`persona.Agent`, `skill.Skill`, `skill.SkillVersion`), never `interface{}`/`any` with method sets, so there's no gadget surface. Verified at `contrib/store/persona_store.go:53`, `contrib/store/skill_store.go:96,232`. - **Authz gaps — by design, not a finding.** The store performs no ownership/permission checks (`Delete`, `Save`, `Get` accept any ID/owner). The PR description explicitly scopes authz as a host concern, and the lean `SkillStore` interface mirrors that. The kernel contract is "trust the caller," consistent with the other batteries. - **DSN handling — fine.** `store.Open` (`contrib/store/sqlite.go:34`) passes the DSN straight to `sql.Open("sqlite", dsn)`. modernc's DSN is `file:…?_pragma=…` with no credential component, so the `%q` formatting in the error paths cannot leak secrets. The DSN is host config, not user input. - **Input validation — delegated correctly.** The store does not call `skill.Validate`; validation lives in `skill/validate.go` and is the save-path's job. No store method accepts a struct and silently persists invariant-violating state that the kernel would then trust — the round-trip tests demonstrate the struct is what the host handed in. Nothing in my lane rises to a blocking or even materially exploitable issue. The single defense-in-depth nit (free-form `where` string in two unexported helpers) is worth tightening but is not a present vulnerability. </details> <details><summary><b>🎯 Correctness</b> — Minor issues</summary> **VERDICT: Minor issues** Reviewed through the Correctness lens. Verified against the checked-out code: `budget/budget.go` (DBBudget.Check/Commit + `7*24*time.Hour` window), `budget/storage.go` (`budgetWindow` const), `budget/memory.go`, `persona/agent.go` (`NextRunAt *time.Time`), `run/agent.go` (`RunnableAgent` fields), `model/tiers.go` (`IsTierName`), and the new `skill/` and `contrib/store/` files. Findings: - **`contrib/store/skill_store.go:199-204` — `AppendVersion` seq assignment is non-atomic, unlike the Memory store.** `seq = SELECT COALESCE(MAX(seq),0)+1 ...` is read in one statement, then the INSERT runs in a separate statement with no transaction and no `UNIQUE` constraint on `(skill_id, seq)` (only a non-unique index at line 48). Two concurrent `AppendVersion` calls for the same `skill_id` can both compute the same `seq` and both insert successfully, producing duplicate `seq` values. `ListVersionsBySkill` orders by `ORDER BY seq DESC` (line 211), so the "newest-first" ordering contract documented at line 45 and asserted in `TestSQLiteSkillStore` would break under concurrency. The in-memory `Memory.AppendVersion` (skill/memory.go) is safe because it appends under `m.mu.Lock()`. Fix: wrap the MAX+INSERT in a `BeginTx`/commit (matching `budgetStore.Add`'s pattern), or add `UNIQUE(skill_id, seq)` and retry on conflict. Verified by reading both stores. - **`contrib/store/budget_store.go:50-93` — transaction isolation relies on SQLite write serialization, not an explicit row lock.** The `Add` reads via `tx.QueryRowContext` then upserts, but uses `BeginTx(ctx, nil)` (deferred). The atomic-vs-TOCTOU claim (the stated purpose per the PR description and CLAUDE.md) holds only because modernc/SQLite serializes writers on a single DB file; a deferred txn lets two concurrent `Add`s both read the pre-increment row before either writes. In practice SQLite's write lock will make one fail with `SQLITE_BUSY` rather than silently corrupt, but the `busy_timeout` pragma noted in `sqlite.go`'s doc example is not actually set in `Open` (sqlite.go:31-43), so a busy caller gets an immediate error instead of waiting. This is borderline concurrency rather than pure logic; flagged because it undercuts the "closes the in-memory store's TOCTOU window" claim. Verified: `Open` sets no pragmas. - **`contrib/store/budget_store.go` — the conformance test does not exercise the `updatedAt`/window-start consistency the way `Check` reads it.** `Check` (budget.go:133) treats the window as expired when `b.now().Sub(bud.WindowStart) < 7*24*time.Hour` is false, using `bud.WindowStart` from `Get`. `Add` stores `window_start` as `now.Unix()` on rollover and `Get` reconstructs via `time.Unix(ws,0).UTC()`. These are consistent (I re-derived: unix-second round-trip is lossless for second-resolution `time.Time`), so no defect here — noting only that the test never asserts `row.WindowStart` post-rollover, so a future off-by-one in the rollover comparison (`>=` vs `>`) at line 78 could slip through. Verified the `>= 7*24*time.Hour` matches `budget.memory.go:47` and `budgetWindow`. No material issues in: `ToRunnable` field mapping (run/agent.go confirms `LowLevelTools`/`SkillPalette`/`SubAgentPalette` shapes, deliberately empty palettes), `ParseSchedule` shorthand expansion, `ScheduleMinInterval` probing, `effectiveCommandParams`/`ResolveCommandInputs` empty-schema inflation, `ListSharedWith` slice-aliasing (range copies the header; overwriting the backing array during iteration is safe), or the JSON-blob round-trip approach for persona/skill. </details> <details><summary><b>🧯 Error handling & edge cases</b> — Minor issues</summary> ## Verdict: Minor issues - **`contrib/store/skill_store.go:201` — swallowed Scan error + non-atomic `seq` computation.** ```go var seq int64 _ = s.db.QueryRowContext(ctx, `SELECT COALESCE(MAX(seq),0)+1 FROM skill_versions WHERE skill_id = ?`, sv.SkillID).Scan(&seq) ``` Two problems, both in the error/edge-case lane: 1. The `_ =` discards *every* scan failure, not just `sql.ErrNoRows` (which can't happen here anyway — the query always returns one row thanks to `COALESCE`). On a real failure (context cancellation, a corrupt/locked DB, driver error) `seq` stays `0` and the subsequent `INSERT` proceeds silently with a bogus sequence number instead of surfacing the error. The caller gets `nil` and believes the version was appended correctly. 2. `MAX(seq)+1` is computed outside any transaction, unlike `budgetStore.Add` which deliberately wraps its read-modify-write in `BeginTx` to close the TOCTOU window. Two concurrent `AppendVersion` calls for the same `skill_id` compute the same `seq` and both insert successfully — the index `idx_skill_versions_skill ON skill_versions(skill_id, seq)` (skill_store.go:48) is **non-unique**, so neither insert fails. Result: duplicate `seq` values, which breaks the `ORDER BY seq DESC` newest-first ordering that `ListVersionsBySkill` (skill_store.go:211) relies on. Suggested fix: wrap the select+insert in a transaction (matching `budgetStore.Add`'s pattern) and propagate the scan error. - **`contrib/store/skill_store.go:184` and `contrib/store/persona_store.go:159` — `MarkScheduledRun`/`MarkAgentScheduledRun` are read-modify-write without a transaction.** `Get` then mutate then `Save`. Concurrent calls (or a concurrent `Save` from elsewhere) race and the later write wins, silently losing the `LastScheduledRunAt`/`NextRunAt` update. The errors themselves *are* propagated correctly, so this is borderline; flagged only because the lost-update path produces no error at all — the caller sees success. Lower priority than the `AppendVersion` finding since scheduling collisions are less destructive than version-order corruption. Left as a note rather than a blocking claim. Everything else checked is sound: `scanAgents`/`scanSkills`/`ListVersionsBySkill` all `defer rows.Close()` and return `rows.Err()`; `getOne`/`GetVersionByID` map `sql.ErrNoRows` → `ErrNotFound`; `budgetStore.Add` correctly defers `tx.Rollback()` and wraps each step; `Get` returns `(nil, nil)` on no-row. </details> <sub>Automated adversarial review by Gadfly. Advisory only — does not block merge. · ⏱️ reviewed in 7m 36s</sub>

🪰 Gadfly review — deepseek-v4-flash:cloud (ollama-cloud)

Verdict: Blocking issues found — 3 reviewers: security, correctness, error-handling

🔒 Security — Minor issues

Now I have all the information needed to verify each finding. Let me compile my analysis.

Finding 1 verification: The webhook_secret column is stored as plaintext (line 31 of persona_store.go) and looked up directly via WHERE webhook_secret = ? (line 115). The code comment in skill/skill.go (lines 170-176) explicitly acknowledges this design. However, the draft claims the column is "indexed" — it is NOT; there is no index on webhook_secret (only indexes on owner_id, (owner_id, name), and (schedule, next_run_at)). The draft's line numbers (38-39) point to the idx_agents_owner_name and idx_agents_sched indexes, not the webhook_secret column. The core concern (plaintext secret in DB) is valid and documented. Keep with corrections.

Finding 2 verification: SQL() at sqlite.go:47 returns *sql.DB directly. Confirmed. Keep.

Finding 3 verification: skill_store.go:201 discards the error with _ = ...Scan(&seq). If the query fails, seq stays 0, corrupting version ordering. Confirmed. Keep.

Finding 4 verification: MarkAgentScheduledRun (persona_store.go:159-166) and MarkScheduledRun (skill_store.go:184-192) both do read-modify-write outside a transaction. The draft's line numbers are off (says 152-166 and 230-240) but the substance is correct. Keep.

All four findings survive. The verdict remains "Minor issues."


🔒 Security — Verdict: Minor issues

Findings

1. contrib/store/persona_store.go:31,115 — Webhook secret stored in plaintext, enabling direct lookup

The webhook_secret column is stored as plaintext. GetAgentByWebhookSecret (line 115) does a direct WHERE webhook_secret = ? lookup. The code comment in skill/skill.go (lines 170-176) acknowledges this and justifies it as a performance trade-off (O(1) lookup vs. O(n) hash comparison). The secret is 64 hex chars (256 bits), which is strong enough as a bearer token. (Note: the column is not indexed — there is no index on webhook_secret — so the lookup is a sequential scan, not an indexed lookup.)

Impact: If the database is compromised, every webhook secret is immediately readable. An attacker with read access to the DB can enumerate all webhook secrets and invoke any skill's webhook endpoint without additional authentication. The documented mitigation (secret rotation via RegenerateWebhookSecret) is reactive, not preventive.

Suggested fix: If the lookup-by-secret performance requirement is real, store a SHA-256 hash of the secret in an indexed column and keep the full secret only in the JSON data blob. The webhook handler hashes the incoming secret and looks up by hash — still O(1), still indexed, but the plaintext secret is never in an indexed column. (The full secret in the JSON blob is still readable on DB compromise, but that's a separate, harder-to-exploit concern since it requires parsing every row's JSON.)

2. contrib/store/sqlite.go:47SQL() method exposes raw *sql.DB, enabling arbitrary SQL execution

The SQL() accessor returns the underlying *sql.DB directly. Any code that holds a *DB reference can execute arbitrary SQL against the database, bypassing all seam abstractions.

Impact: If a *DB handle is passed to untrusted code (e.g., a plugin, a user-authored skill tool that receives the DB handle), that code can read, modify, or destroy any table. This is a deliberate API escape hatch, but it widens the trust boundary beyond the seam interfaces.

Suggested fix: Either remove SQL() (callers who need raw access can use database/sql directly with their own DSN), or add a prominent doc comment warning that holding a *DB grants full SQL access and should never be passed to untrusted code.

3. contrib/store/skill_store.go:201 — Error from seq query silently discarded in AppendVersion

var seq int64
_ = s.db.QueryRowContext(ctx, `SELECT COALESCE(MAX(seq),0)+1 FROM skill_versions WHERE skill_id = ?`, sv.SkillID).Scan(&seq)

If the query fails (e.g., database connection lost, context cancelled), seq stays at 0 and the version is inserted with seq=0, corrupting the version ordering for that skill.

Impact: Not directly a security vulnerability, but it can silently corrupt version history — a skill's versions could appear in the wrong order or be lost behind a seq=0 entry. This undermines the pinning and rollback mechanisms that depend on correct ordering.

Suggested fix: Check the error from Scan and return it:

if err := s.db.QueryRowContext(...).Scan(&seq); err != nil {
    return fmt.Errorf("skillStore.AppendVersion: seq: %w", err)
}

4. contrib/store/persona_store.go:159-166 and contrib/store/skill_store.go:184-192MarkAgentScheduledRun / MarkScheduledRun have a TOCTOU race (read-then-write outside a transaction)

Both methods read the current entity, modify two fields, and save it back — but not inside a transaction. Two concurrent scheduler ticks for the same skill/agent can race: both read the old state, both modify it, and the second Save overwrites the first's LastScheduledRunAt/NextRunAt values.

Impact: A scheduled skill could be double-executed (both ticks see next_run_at <= now() and both proceed to run) or a tick's bookkeeping update could be silently lost. In a scheduler context this is a correctness issue with security-adjacent consequences (resource exhaustion from double-runs, missed audit trail).

Suggested fix: Wrap the read-modify-write in a transaction with SELECT ... FOR UPDATE (SQLite doesn't support FOR UPDATE, but wrapping in a transaction with an immediate upsert pattern works). Alternatively, use a single UPDATE ... SET last_scheduled_run_at=?, next_run_at=? WHERE id=? AND next_run_at=? conditional update that returns RowsAffected to detect the race.

🎯 Correctness — Minor issues

🎯 Correctness — Verdict: Minor issues

1. AppendVersion seq computation has a TOCTOU race — duplicate seq values possible

contrib/store/skill_store.go:199-201

The seq value is computed as SELECT COALESCE(MAX(seq),0)+1 in a standalone query, then used in a separate INSERT — neither wrapped in a transaction. Two concurrent AppendVersion calls for the same skill_id can both read the same MAX(seq) and compute the same seq+1. The table has no unique constraint on (skill_id, seq), so both INSERTs succeed with identical seq values. ListVersionsBySkill orders by seq DESC, and ties produce undefined ordering — the "newest-first" contract breaks silently.

Verified by: reading the code at lines 199-207; confirming no transaction wraps the pair; checking the skill_versions schema (no unique constraint on (skill_id, seq)).

Suggested fix: Wrap the SELECT + INSERT in a transaction. The seq column should also get a UNIQUE(skill_id, seq) constraint as a safety net.


2. MarkScheduledRun has a TOCTOU race (both skill and persona stores)

contrib/store/skill_store.go:184-191 and contrib/store/persona_store.go:159-166

Both MarkScheduledRun implementations do a read (Get/GetAgent) then a write (Save/SaveAgent) outside a transaction. Between the read and write, another concurrent caller could modify the same row, and the second write silently overwrites the first's changes. The budget store's Add correctly uses a transaction for its read-modify-write, but these two scheduling methods do not.

Verified by: reading both methods; confirming no BeginTx/Commit wrapping; comparing with budgetStore.Add which correctly uses a transaction.

Suggested fix: Wrap the read + modify + write in a transaction. For the skill store, use tx.QueryRowContext + tx.ExecContext instead of the connection-level Get/Save. For the persona store, do the same with the agent's fields.


3. No material issues found in the budget store's rolling-window logic

The Add method correctly uses >= 7*24*time.Hour for rollover (matching the budgetWindow constant in budget/storage.go:33), the transaction wrapping is correct, and the upsert properly resets counters on rollover. The test confirms the 7-day boundary. No correctness problem here.

🧯 Error handling & edge cases — Blocking issues found

Corrected Review

Verdict: Blocking issues found

Blocking

  1. contrib/store/skill_store.go:201 — Silently discarded error from seq query in AppendVersion
    The line _ = s.db.QueryRowContext(ctx, …).Scan(&seq) discards any error from the query. If the context is cancelled, the database is unreachable, or any other error occurs, seq stays 0 and the insert proceeds with seq=0, corrupting version ordering for that skill.
    Fix: Check the error and return it:

    if err := s.db.QueryRowContext(ctx, ).Scan(&seq); err != nil {
        return fmt.Errorf("skillStore.AppendVersion: seq: %w", err)
    }
    
  2. contrib/store/skill_store.go:194-207 — TOCTOU race on seq generation in AppendVersion
    The SELECT COALESCE(MAX(seq),0)+1 and the subsequent INSERT are not in a transaction. Two concurrent AppendVersion calls for the same skill can both read the same MAX(seq) value (e.g., 5), then both insert with seq=6, producing duplicate seq values and breaking the "newest-first" ordering guarantee.
    Fix: Wrap the SELECT + INSERT in a transaction (BeginTx / Commit / Rollback), exactly as budgetStore.Add does for its own read-modify-write.

  3. contrib/store/persona_store.go:159-166 and contrib/store/skill_store.go:184-192 — TOCTOU race in MarkAgentScheduledRun / MarkScheduledRun
    Both methods read the entity via Get/GetAgent, modify fields in Go, then call Save/SaveAgent — all outside a transaction. A concurrent save (e.g., another scheduled run completing, or an admin editing the skill) between the read and write will silently overwrite the other update.
    Fix: Use a transaction: read the row inside the transaction, modify, and upsert — or use an atomic UPDATE … SET last_scheduled_run_at=?, next_run_at=? WHERE id=? that doesn't need a read-modify-write at all.

Minor

  1. contrib/store/budget_store.go:59-99 — No validation of secondsUsed in Add
    The method accepts secondsUsed float64 without checking for NaN or +Inf values. The caller (DBBudget.Commit) guards against <= 0 (catching negatives and -Inf), but NaN slips through (NaN comparisons are always false) and would corrupt the seconds_used column (NaN propagates through addition, making the row unrecoverable without manual SQL).
    Fix: Add a guard at the top of Add:

    if math.IsNaN(secondsUsed) || math.IsInf(secondsUsed, 0) {
        return fmt.Errorf("budgetStore.Add: invalid secondsUsed %v", secondsUsed)
    }
    
  2. contrib/store/skill_store.go:194-208AppendVersion has no validation that sv.SkillID is non-empty
    An empty SkillID would insert orphaned version rows that are never returned by ListVersionsBySkill (which filters on skill_id = ?). The Memory implementation has the same gap.
    Fix: Add a check at the top of AppendVersion:

    if sv.SkillID == "" {
        return fmt.Errorf("skillStore.AppendVersion: skill_id is required")
    }
    

Automated adversarial review by Gadfly. Advisory only — does not block merge. · ⏱️ reviewed in 11m 22s

<!-- gadfly-review:ollama:deepseek-v4-flash:cloud --> ### 🪰 Gadfly review — `deepseek-v4-flash:cloud` (ollama-cloud) **Verdict: Blocking issues found** — 3 reviewers: security, correctness, error-handling <details><summary><b>🔒 Security</b> — Minor issues</summary> Now I have all the information needed to verify each finding. Let me compile my analysis. **Finding 1 verification**: The `webhook_secret` column is stored as plaintext (line 31 of `persona_store.go`) and looked up directly via `WHERE webhook_secret = ?` (line 115). The code comment in `skill/skill.go` (lines 170-176) explicitly acknowledges this design. However, the draft claims the column is "indexed" — it is NOT; there is no index on `webhook_secret` (only indexes on `owner_id`, `(owner_id, name)`, and `(schedule, next_run_at)`). The draft's line numbers (38-39) point to the `idx_agents_owner_name` and `idx_agents_sched` indexes, not the webhook_secret column. The core concern (plaintext secret in DB) is valid and documented. **Keep with corrections.** **Finding 2 verification**: `SQL()` at `sqlite.go:47` returns `*sql.DB` directly. Confirmed. **Keep.** **Finding 3 verification**: `skill_store.go:201` discards the error with `_ = ...Scan(&seq)`. If the query fails, `seq` stays 0, corrupting version ordering. Confirmed. **Keep.** **Finding 4 verification**: `MarkAgentScheduledRun` (persona_store.go:159-166) and `MarkScheduledRun` (skill_store.go:184-192) both do read-modify-write outside a transaction. The draft's line numbers are off (says 152-166 and 230-240) but the substance is correct. **Keep.** All four findings survive. The verdict remains "Minor issues." --- ## 🔒 Security — Verdict: Minor issues ### Findings **1. `contrib/store/persona_store.go:31,115` — Webhook secret stored in plaintext, enabling direct lookup** The `webhook_secret` column is stored as plaintext. `GetAgentByWebhookSecret` (line 115) does a direct `WHERE webhook_secret = ?` lookup. The code comment in `skill/skill.go` (lines 170-176) acknowledges this and justifies it as a performance trade-off (O(1) lookup vs. O(n) hash comparison). The secret is 64 hex chars (256 bits), which is strong enough as a bearer token. (Note: the column is *not* indexed — there is no index on `webhook_secret` — so the lookup is a sequential scan, not an indexed lookup.) **Impact:** If the database is compromised, every webhook secret is immediately readable. An attacker with read access to the DB can enumerate all webhook secrets and invoke any skill's webhook endpoint without additional authentication. The documented mitigation (secret rotation via `RegenerateWebhookSecret`) is reactive, not preventive. **Suggested fix:** If the lookup-by-secret performance requirement is real, store a SHA-256 hash of the secret in an indexed column and keep the full secret only in the JSON `data` blob. The webhook handler hashes the incoming secret and looks up by hash — still O(1), still indexed, but the plaintext secret is never in an indexed column. (The full secret in the JSON blob is still readable on DB compromise, but that's a separate, harder-to-exploit concern since it requires parsing every row's JSON.) **2. `contrib/store/sqlite.go:47` — `SQL()` method exposes raw `*sql.DB`, enabling arbitrary SQL execution** The `SQL()` accessor returns the underlying `*sql.DB` directly. Any code that holds a `*DB` reference can execute arbitrary SQL against the database, bypassing all seam abstractions. **Impact:** If a `*DB` handle is passed to untrusted code (e.g., a plugin, a user-authored skill tool that receives the DB handle), that code can read, modify, or destroy any table. This is a deliberate API escape hatch, but it widens the trust boundary beyond the seam interfaces. **Suggested fix:** Either remove `SQL()` (callers who need raw access can use `database/sql` directly with their own DSN), or add a prominent doc comment warning that holding a `*DB` grants full SQL access and should never be passed to untrusted code. **3. `contrib/store/skill_store.go:201` — Error from seq query silently discarded in `AppendVersion`** ```go var seq int64 _ = s.db.QueryRowContext(ctx, `SELECT COALESCE(MAX(seq),0)+1 FROM skill_versions WHERE skill_id = ?`, sv.SkillID).Scan(&seq) ``` If the query fails (e.g., database connection lost, context cancelled), `seq` stays at `0` and the version is inserted with `seq=0`, corrupting the version ordering for that skill. **Impact:** Not directly a security vulnerability, but it can silently corrupt version history — a skill's versions could appear in the wrong order or be lost behind a `seq=0` entry. This undermines the pinning and rollback mechanisms that depend on correct ordering. **Suggested fix:** Check the error from `Scan` and return it: ```go if err := s.db.QueryRowContext(...).Scan(&seq); err != nil { return fmt.Errorf("skillStore.AppendVersion: seq: %w", err) } ``` **4. `contrib/store/persona_store.go:159-166` and `contrib/store/skill_store.go:184-192` — `MarkAgentScheduledRun` / `MarkScheduledRun` have a TOCTOU race (read-then-write outside a transaction)** Both methods read the current entity, modify two fields, and save it back — but not inside a transaction. Two concurrent scheduler ticks for the same skill/agent can race: both read the old state, both modify it, and the second `Save` overwrites the first's `LastScheduledRunAt`/`NextRunAt` values. **Impact:** A scheduled skill could be double-executed (both ticks see `next_run_at <= now()` and both proceed to run) or a tick's bookkeeping update could be silently lost. In a scheduler context this is a correctness issue with security-adjacent consequences (resource exhaustion from double-runs, missed audit trail). **Suggested fix:** Wrap the read-modify-write in a transaction with `SELECT ... FOR UPDATE` (SQLite doesn't support `FOR UPDATE`, but wrapping in a transaction with an immediate upsert pattern works). Alternatively, use a single `UPDATE ... SET last_scheduled_run_at=?, next_run_at=? WHERE id=? AND next_run_at=?` conditional update that returns `RowsAffected` to detect the race. </details> <details><summary><b>🎯 Correctness</b> — Minor issues</summary> ## 🎯 Correctness — Verdict: **Minor issues** ### 1. `AppendVersion` seq computation has a TOCTOU race — duplicate `seq` values possible **`contrib/store/skill_store.go:199-201`** The `seq` value is computed as `SELECT COALESCE(MAX(seq),0)+1` in a standalone query, then used in a separate `INSERT` — neither wrapped in a transaction. Two concurrent `AppendVersion` calls for the same `skill_id` can both read the same `MAX(seq)` and compute the same `seq+1`. The table has no unique constraint on `(skill_id, seq)`, so both INSERTs succeed with identical `seq` values. `ListVersionsBySkill` orders by `seq DESC`, and ties produce undefined ordering — the "newest-first" contract breaks silently. **Verified by:** reading the code at lines 199-207; confirming no transaction wraps the pair; checking the `skill_versions` schema (no unique constraint on `(skill_id, seq)`). **Suggested fix:** Wrap the SELECT + INSERT in a transaction. The `seq` column should also get a `UNIQUE(skill_id, seq)` constraint as a safety net. --- ### 2. `MarkScheduledRun` has a TOCTOU race (both skill and persona stores) **`contrib/store/skill_store.go:184-191`** and **`contrib/store/persona_store.go:159-166`** Both `MarkScheduledRun` implementations do a read (`Get`/`GetAgent`) then a write (`Save`/`SaveAgent`) **outside a transaction**. Between the read and write, another concurrent caller could modify the same row, and the second write silently overwrites the first's changes. The budget store's `Add` correctly uses a transaction for its read-modify-write, but these two scheduling methods do not. **Verified by:** reading both methods; confirming no `BeginTx`/`Commit` wrapping; comparing with `budgetStore.Add` which correctly uses a transaction. **Suggested fix:** Wrap the read + modify + write in a transaction. For the skill store, use `tx.QueryRowContext` + `tx.ExecContext` instead of the connection-level `Get`/`Save`. For the persona store, do the same with the agent's fields. --- ### 3. No material issues found in the budget store's rolling-window logic The `Add` method correctly uses `>= 7*24*time.Hour` for rollover (matching the `budgetWindow` constant in `budget/storage.go:33`), the transaction wrapping is correct, and the upsert properly resets counters on rollover. The test confirms the 7-day boundary. No correctness problem here. </details> <details><summary><b>🧯 Error handling & edge cases</b> — Blocking issues found</summary> ## Corrected Review **Verdict: Blocking issues found** ### Blocking 1. **`contrib/store/skill_store.go:201` — Silently discarded error from seq query in `AppendVersion`** The line `_ = s.db.QueryRowContext(ctx, …).Scan(&seq)` discards any error from the query. If the context is cancelled, the database is unreachable, or any other error occurs, `seq` stays `0` and the insert proceeds with `seq=0`, corrupting version ordering for that skill. **Fix:** Check the error and return it: ```go if err := s.db.QueryRowContext(ctx, …).Scan(&seq); err != nil { return fmt.Errorf("skillStore.AppendVersion: seq: %w", err) } ``` 2. **`contrib/store/skill_store.go:194-207` — TOCTOU race on seq generation in `AppendVersion`** The `SELECT COALESCE(MAX(seq),0)+1` and the subsequent `INSERT` are **not** in a transaction. Two concurrent `AppendVersion` calls for the same skill can both read the same `MAX(seq)` value (e.g., 5), then both insert with `seq=6`, producing duplicate seq values and breaking the "newest-first" ordering guarantee. **Fix:** Wrap the SELECT + INSERT in a transaction (`BeginTx` / `Commit` / `Rollback`), exactly as `budgetStore.Add` does for its own read-modify-write. 3. **`contrib/store/persona_store.go:159-166` and `contrib/store/skill_store.go:184-192` — TOCTOU race in `MarkAgentScheduledRun` / `MarkScheduledRun`** Both methods read the entity via `Get`/`GetAgent`, modify fields in Go, then call `Save`/`SaveAgent` — all outside a transaction. A concurrent save (e.g., another scheduled run completing, or an admin editing the skill) between the read and write will silently overwrite the other update. **Fix:** Use a transaction: read the row inside the transaction, modify, and upsert — or use an atomic `UPDATE … SET last_scheduled_run_at=?, next_run_at=? WHERE id=?` that doesn't need a read-modify-write at all. ### Minor 4. **`contrib/store/budget_store.go:59-99` — No validation of `secondsUsed` in `Add`** The method accepts `secondsUsed float64` without checking for `NaN` or `+Inf` values. The caller (`DBBudget.Commit`) guards against `<= 0` (catching negatives and `-Inf`), but `NaN` slips through (NaN comparisons are always false) and would corrupt the `seconds_used` column (NaN propagates through addition, making the row unrecoverable without manual SQL). **Fix:** Add a guard at the top of `Add`: ```go if math.IsNaN(secondsUsed) || math.IsInf(secondsUsed, 0) { return fmt.Errorf("budgetStore.Add: invalid secondsUsed %v", secondsUsed) } ``` 5. **`contrib/store/skill_store.go:194-208` — `AppendVersion` has no validation that `sv.SkillID` is non-empty** An empty `SkillID` would insert orphaned version rows that are never returned by `ListVersionsBySkill` (which filters on `skill_id = ?`). The `Memory` implementation has the same gap. **Fix:** Add a check at the top of `AppendVersion`: ```go if sv.SkillID == "" { return fmt.Errorf("skillStore.AppendVersion: skill_id is required") } ``` </details> <sub>Automated adversarial review by Gadfly. Advisory only — does not block merge. · ⏱️ reviewed in 11m 22s</sub>

🪰 Gadfly review — minimax-m3:cloud (ollama-cloud)

Verdict: Blocking issues found — 3 reviewers: security, correctness, error-handling

🔒 Security — Minor issues

VERDICT: Minor issues

  • Plaintext webhook secret storage (skill/skill.go:164-177) — Confirmed against the actual code. The comment at lines 171-176 explicitly states: "Why store the secret directly (not a hash): the webhook handler must look up the skill by the secret on every POST, which would require comparing every stored hash against the supplied secret — a per-call O(n_skills) operation. The secret is treated as a long random URL key (like a paste UUID); compromise is mitigated via RegenerateWebhookSecret rotation, not via storage hashing." The new contrib/store/skill_store.go (lines 55-80) persists the entire *skill.Skill as a JSON blob in data, carrying the plaintext secret into the new SQLite-backed store as well. The design rationale is documented, but the reviewer is correct that any DB dump/replica/backup/SQLi leaks every webhook secret in cleartext, and that a SHA-256 indexed lookup with constant-time compare on ≤K rows would preserve the indexed-lookup property while removing plaintext-at-rest. Severity minor because the trade-off is explicit and the secret is high-entropy. Note: this code is pre-existing (the diff doesn't touch skill/skill.go), but the new SQLite persistence inherits the same exposure.

  • No DSN validation in contrib/store/sqlite.go:28-41 — Confirmed. Open(dsn) passes the string straight to sql.Open("sqlite", dsn) with no sanitization or path canonicalization, and the doc at lines 28-30 says only "Open opens (creating if absent) a SQLite database at dsn …" without explicitly marking the DSN as trusted operator input. All current call sites pass host-controlled literals (:memory: in tests, a fixed file:executus.db?... in the package example), so this isn't exploitable today, but a one-sentence doc clarification ("DSN is trusted operator input; do not construct from untrusted strings") would harden the contract against future config that mixes untrusted input. Severity minor.

🎯 Correctness — Blocking issues found

Review — Lens: Correctness

VERDICT: Blocking issues found

The diff is largely a clean lift, but the SQLite SkillStore.AppendVersion implementation has a real concurrency bug that breaks the interface's "newest-first ordering key" contract, and two related read-modify-write methods have lost-update hazards.

Findings

  • contrib/store/skill_store.go:194-208AppendVersion races on seq. The seq key is computed with SELECT COALESCE(MAX(seq),0)+1 and then INSERTed in two separate statements, NOT wrapped in a transaction, and the skill_versions table has no UNIQUE(skill_id, seq) constraint:

    // seq = current max+1 for this skill (newest-first ordering key).
    var seq int64
    _ = s.db.QueryRowContext(ctx, `SELECT COALESCE(MAX(seq),0)+1 FROM skill_versions WHERE skill_id = ?`, sv.SkillID).Scan(&seq)
    if _, err := s.db.ExecContext(ctx,
        `INSERT INTO skill_versions (id, skill_id, version, seq, data) VALUES (?, ?, ?, ?, ?)`,
        sv.ID, sv.SkillID, sv.Version, seq, string(blob)); err != nil {
    

    Concurrent AppendVersion calls for the same skill_id will read the same MAX and both INSERT with the same seq. SQLite serializes the writes, both succeed (no UNIQUE constraint), and ORDER BY seq DESC becomes non-deterministic — violating the contract that ListVersionsBySkill returns a deterministic newest-first ordering (the code comment explicitly labels seq the "newest-first ordering key"; skill/skill_test.go:52 enforces this invariant). The in-memory default holds a mutex in skill/memory.go:139-140 so it doesn't have this bug; the new SQLite path does. Fix: wrap the SELECT+INSERT in a BEGIN IMMEDIATE transaction, OR add a UNIQUE(skill_id, seq) constraint and retry on collision, OR derive seq from a monotonic counter.

  • contrib/store/persona_store.go:159-166 and contrib/store/skill_store.go:184-192Mark*ScheduledRun lost-update. Both perform a non-transactional Get* → mutate → Save*:

    a, err := s.GetAgent(ctx, agentID)
    ...
    a.LastScheduledRunAt = &ranAt; a.NextRunAt = &nextAt
    return s.SaveAgent(ctx, a)
    

    Concurrent invocations for the same agent/skill silently lose updates to LastScheduledRunAt / NextRunAt. The interface contract at persona/storage.go:110 explicitly states MarkAgentScheduledRun "atomically updates" those fields — the SQLite implementation violates it. The in-memory default holds m.mu.Lock() (see persona/memory.go:111-112 and skill/memory.go:127-128) so this is a regression vs the default. Fix: wrap Get + field-write + Save in a transaction, or perform an UPDATE … WHERE id=? conditional update. The PR description highlights closing the budget store's TOCTOU window but the persona/skill analogues still have one.

The seq race in AppendVersion is the most severe issue — it can produce wrong ordering under any concurrent save — and the lost-update hazards are real but lower-severity follow-ups.

🧯 Error handling & edge cases — Minor issues

VERDICT: Minor issues

Lens: Error handling & edge cases. Verified by reading contrib/store/{sqlite,budget_store,persona_store,skill_store}.go, skill/memory.go, skill/skill.go, skill/skill_version.go, skill/store.go, budget/{budget,memory,storage}.go, and persona/agent.go.

Findings

  • skill/memory.go:34Memory.Save is a shallow copy. cp := *s copies scalar fields but s.Tools, s.Tags, s.SharedWith, s.InputSchema, and s.StateReactEmoji (confirmed present on skill.Skill at lines 52/58/54/290) continue to alias the caller's slice/map headers and backing arrays. A caller that mutates s.StateReactEmoji[...] or appends to s.Tools after Save returns will silently mutate the stored skill. Memory.Get (line 48) returns a fresh cp := *s copy, creating an asymmetric expectation. Fix: deep-copy the slice/map fields in Save.

  • skill/memory.go:160Memory.GetVersionByID returns a pointer to the stored entry. return &sv exposes the internal byVerID map value, so a caller can mutate it (sv.Version = "..."). GetVersionByID is the only method on the SkillStore interface that returns a pointer to the stored map value rather than a fresh copy. Fix: return a pointer to a copy.

  • contrib/store/skill_store.go:200-201AppendVersion has a seq-assignment race. SELECT COALESCE(MAX(seq),0)+1 is issued outside any transaction, then a separate INSERT. Two concurrent calls can both observe the same MAX, then both insert with the same seq, breaking the documented "newest-first" ordering invariant. Fix: wrap both in a transaction (the budgetStore.Add pattern in the same package already shows the right shape).

  • contrib/store/skill_store.go:200 — ignored Scan error. _ = s.db.QueryRowContext(...).Scan(&seq) swallows any scan error, leaving seq at 0 and proceeding to insert with a duplicate seq. Combined with the race above, this lets the corruption stay invisible. Fix: check the error and return wrapped.

  • contrib/store/persona_store.go:46SaveAgent does not nil-check a. json.Marshal((*persona.Agent)(nil)) returns "null", which is stored in data; subsequent GetAgent calls unmarshal "null" into a zero Agent (silently losing the row's "exists" semantics). Same edge case in contrib/store/skill_store.go:55 (Save) and AppendVersion (sv zero-value stored as "null"). Fix: reject nil/zero receivers with an explicit error before marshalling.

  • contrib/store/sqlite.go:34Open swallows the Close error after Ping failure. sqldb.Close() return value is discarded. Standard Go idiom; the handle can't leak past the function return so impact is nil. Minor only.

  • contrib/store/budget_store.go:59Add does not validate secondsUsed. A negative or NaN value flows into the REAL column unchecked; the in-memory store (budget/memory.go:35) likewise does not reject this. Negative SecondsUsed would never trigger ErrBudgetExceeded because budget.DBBudget.Check (line 135) uses bud.SecondsUsed >= cap, and a negative value can't reach the cap. The BudgetStorage interface contract (budget/storage.go:20) should make this explicit. Fix: reject secondsUsed < 0 || math.IsNaN(secondsUsed) at the store boundary.

  • contrib/store/skill_store.go:157-172ListSharedWith aliasing. out := shared[:0] reuses the backing array of the slice returned by s.query. Currently safe because shared is a local not retained anywhere, but the pattern is fragile — a future change that keeps a reference to shared would silently corrupt it. Minor; flag only.

No issues with defer tx.Rollback() (the //nolint:errcheck comment in budget_store.go:64 documents the idiom), the (nil, nil) Get contract for unknown users, sql.ErrNoRows mapping to persona.ErrNotFound / skill.ErrNotFound, the in-memory mutex usage, or the transaction boundary in budgetStore.Add itself — those are all correct. The window-rollover boundary (>= 7d in Add vs < 7d in Check) is consistent and intentional so that the comparison window is closed at the boundary.

Automated adversarial review by Gadfly. Advisory only — does not block merge. · ⏱️ reviewed in 7m 53s

<!-- gadfly-review:ollama:minimax-m3:cloud --> ### 🪰 Gadfly review — `minimax-m3:cloud` (ollama-cloud) **Verdict: Blocking issues found** — 3 reviewers: security, correctness, error-handling <details><summary><b>🔒 Security</b> — Minor issues</summary> VERDICT: Minor issues - **Plaintext webhook secret storage (`skill/skill.go:164-177`)** — Confirmed against the actual code. The comment at lines 171-176 explicitly states: *"Why store the secret directly (not a hash): the webhook handler must look up the skill by the secret on every POST, which would require comparing every stored hash against the supplied secret — a per-call O(n_skills) operation. The secret is treated as a long random URL key (like a paste UUID); compromise is mitigated via RegenerateWebhookSecret rotation, not via storage hashing."* The new `contrib/store/skill_store.go` (lines 55-80) persists the entire `*skill.Skill` as a JSON blob in `data`, carrying the plaintext secret into the new SQLite-backed store as well. The design rationale is documented, but the reviewer is correct that any DB dump/replica/backup/SQLi leaks every webhook secret in cleartext, and that a SHA-256 indexed lookup with constant-time compare on ≤K rows would preserve the indexed-lookup property while removing plaintext-at-rest. Severity minor because the trade-off is explicit and the secret is high-entropy. Note: this code is pre-existing (the diff doesn't touch `skill/skill.go`), but the new SQLite persistence inherits the same exposure. - **No DSN validation in `contrib/store/sqlite.go:28-41`** — Confirmed. `Open(dsn)` passes the string straight to `sql.Open("sqlite", dsn)` with no sanitization or path canonicalization, and the doc at lines 28-30 says only *"Open opens (creating if absent) a SQLite database at dsn …"* without explicitly marking the DSN as trusted operator input. All current call sites pass host-controlled literals (`:memory:` in tests, a fixed `file:executus.db?...` in the package example), so this isn't exploitable today, but a one-sentence doc clarification ("DSN is trusted operator input; do not construct from untrusted strings") would harden the contract against future config that mixes untrusted input. Severity minor. </details> <details><summary><b>🎯 Correctness</b> — Blocking issues found</summary> # Review — Lens: Correctness **VERDICT: Blocking issues found** The diff is largely a clean lift, but the SQLite `SkillStore.AppendVersion` implementation has a real concurrency bug that breaks the interface's "newest-first ordering key" contract, and two related read-modify-write methods have lost-update hazards. ## Findings - **`contrib/store/skill_store.go:194-208` — `AppendVersion` races on `seq`.** The seq key is computed with `SELECT COALESCE(MAX(seq),0)+1` and then INSERTed in two separate statements, NOT wrapped in a transaction, and the `skill_versions` table has no `UNIQUE(skill_id, seq)` constraint: ```go // seq = current max+1 for this skill (newest-first ordering key). var seq int64 _ = s.db.QueryRowContext(ctx, `SELECT COALESCE(MAX(seq),0)+1 FROM skill_versions WHERE skill_id = ?`, sv.SkillID).Scan(&seq) if _, err := s.db.ExecContext(ctx, `INSERT INTO skill_versions (id, skill_id, version, seq, data) VALUES (?, ?, ?, ?, ?)`, sv.ID, sv.SkillID, sv.Version, seq, string(blob)); err != nil { ``` Concurrent `AppendVersion` calls for the same `skill_id` will read the same `MAX` and both INSERT with the same `seq`. SQLite serializes the writes, both succeed (no UNIQUE constraint), and `ORDER BY seq DESC` becomes non-deterministic — violating the contract that `ListVersionsBySkill` returns a deterministic newest-first ordering (the code comment explicitly labels `seq` the "newest-first ordering key"; `skill/skill_test.go:52` enforces this invariant). The in-memory default holds a mutex in `skill/memory.go:139-140` so it doesn't have this bug; the new SQLite path does. **Fix:** wrap the SELECT+INSERT in a `BEGIN IMMEDIATE` transaction, OR add a `UNIQUE(skill_id, seq)` constraint and retry on collision, OR derive seq from a monotonic counter. - **`contrib/store/persona_store.go:159-166` and `contrib/store/skill_store.go:184-192` — `Mark*ScheduledRun` lost-update.** Both perform a non-transactional `Get* → mutate → Save*`: ```go a, err := s.GetAgent(ctx, agentID) ... a.LastScheduledRunAt = &ranAt; a.NextRunAt = &nextAt return s.SaveAgent(ctx, a) ``` Concurrent invocations for the same agent/skill silently lose updates to `LastScheduledRunAt` / `NextRunAt`. The interface contract at `persona/storage.go:110` explicitly states `MarkAgentScheduledRun` "atomically updates" those fields — the SQLite implementation violates it. The in-memory default holds `m.mu.Lock()` (see `persona/memory.go:111-112` and `skill/memory.go:127-128`) so this is a regression vs the default. **Fix:** wrap `Get + field-write + Save` in a transaction, or perform an `UPDATE … WHERE id=?` conditional update. The PR description highlights closing the budget store's TOCTOU window but the persona/skill analogues still have one. The seq race in `AppendVersion` is the most severe issue — it can produce wrong ordering under any concurrent save — and the lost-update hazards are real but lower-severity follow-ups. </details> <details><summary><b>🧯 Error handling & edge cases</b> — Minor issues</summary> ## VERDICT: Minor issues Lens: Error handling & edge cases. Verified by reading `contrib/store/{sqlite,budget_store,persona_store,skill_store}.go`, `skill/memory.go`, `skill/skill.go`, `skill/skill_version.go`, `skill/store.go`, `budget/{budget,memory,storage}.go`, and `persona/agent.go`. ### Findings - **`skill/memory.go:34` — `Memory.Save` is a shallow copy.** `cp := *s` copies scalar fields but `s.Tools`, `s.Tags`, `s.SharedWith`, `s.InputSchema`, and `s.StateReactEmoji` (confirmed present on `skill.Skill` at lines 52/58/54/290) continue to alias the caller's slice/map headers and backing arrays. A caller that mutates `s.StateReactEmoji[...]` or `append`s to `s.Tools` after `Save` returns will silently mutate the stored skill. `Memory.Get` (line 48) returns a fresh `cp := *s` copy, creating an asymmetric expectation. Fix: deep-copy the slice/map fields in `Save`. - **`skill/memory.go:160` — `Memory.GetVersionByID` returns a pointer to the stored entry.** `return &sv` exposes the internal `byVerID` map value, so a caller can mutate it (`sv.Version = "..."`). `GetVersionByID` is the only method on the `SkillStore` interface that returns a pointer to the stored map value rather than a fresh copy. Fix: return a pointer to a copy. - **`contrib/store/skill_store.go:200-201` — `AppendVersion` has a seq-assignment race.** `SELECT COALESCE(MAX(seq),0)+1` is issued outside any transaction, then a separate `INSERT`. Two concurrent calls can both observe the same `MAX`, then both insert with the same `seq`, breaking the documented "newest-first" ordering invariant. Fix: wrap both in a transaction (the `budgetStore.Add` pattern in the same package already shows the right shape). - **`contrib/store/skill_store.go:200` — ignored `Scan` error.** `_ = s.db.QueryRowContext(...).Scan(&seq)` swallows any scan error, leaving `seq` at 0 and proceeding to insert with a duplicate seq. Combined with the race above, this lets the corruption stay invisible. Fix: check the error and return wrapped. - **`contrib/store/persona_store.go:46` — `SaveAgent` does not nil-check `a`.** `json.Marshal((*persona.Agent)(nil))` returns `"null"`, which is stored in `data`; subsequent `GetAgent` calls unmarshal `"null"` into a zero `Agent` (silently losing the row's "exists" semantics). Same edge case in `contrib/store/skill_store.go:55` (`Save`) and `AppendVersion` (`sv` zero-value stored as `"null"`). Fix: reject nil/zero receivers with an explicit error before marshalling. - **`contrib/store/sqlite.go:34` — `Open` swallows the `Close` error after `Ping` failure.** `sqldb.Close()` return value is discarded. Standard Go idiom; the handle can't leak past the function return so impact is nil. Minor only. - **`contrib/store/budget_store.go:59` — `Add` does not validate `secondsUsed`.** A negative or `NaN` value flows into the `REAL` column unchecked; the in-memory store (`budget/memory.go:35`) likewise does not reject this. Negative `SecondsUsed` would never trigger `ErrBudgetExceeded` because `budget.DBBudget.Check` (line 135) uses `bud.SecondsUsed >= cap`, and a negative value can't reach the cap. The `BudgetStorage` interface contract (`budget/storage.go:20`) should make this explicit. Fix: reject `secondsUsed < 0 || math.IsNaN(secondsUsed)` at the store boundary. - **`contrib/store/skill_store.go:157-172` — `ListSharedWith` aliasing.** `out := shared[:0]` reuses the backing array of the slice returned by `s.query`. Currently safe because `shared` is a local not retained anywhere, but the pattern is fragile — a future change that keeps a reference to `shared` would silently corrupt it. Minor; flag only. No issues with `defer tx.Rollback()` (the `//nolint:errcheck` comment in `budget_store.go:64` documents the idiom), the `(nil, nil)` Get contract for unknown users, `sql.ErrNoRows` mapping to `persona.ErrNotFound` / `skill.ErrNotFound`, the in-memory mutex usage, or the transaction boundary in `budgetStore.Add` itself — those are all correct. The window-rollover boundary (`>= 7d` in `Add` vs `< 7d` in `Check`) is consistent and intentional so that the comparison window is closed at the boundary. </details> <sub>Automated adversarial review by Gadfly. Advisory only — does not block merge. · ⏱️ reviewed in 7m 53s</sub>
steve added 1 commit 2026-06-27 02:50:23 +00:00
P4: contrib/store — audit SQLite store (run history complete)
executus CI / test (pull_request) Successful in 1m41s
b194a9621d
db.Audit() satisfies audit.Storage (all 17 methods) over SQLite: one indexed
row per run (+ a JSON inputs blob), one row per log event. Filter/list/walk
queries are indexed on the columns they filter (skill_id, caller_id,
parent_run_id, started_at); WalkParentChain follows parent_run_id with a
seen-set guard; LastRunBySkills is a grouped MAX.

Test covers run start/finish round-trip (inputs map + token roll-up), log
append + ordered read, parent/child + ancestor-chain walks, caller listing,
TopLevelOnly filter, and the last-run-per-skill map.

contrib/store now backs ALL four store seams — budget + persona + skill + audit
— so a host gets turnkey durable persistence (run history, budgets, agents,
skills) with zero store code. Core go.sum still has 0 sqlite refs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
steve added 1 commit 2026-06-27 03:37:40 +00:00
fix: address verified gadfly P5/#5 findings (contrib/store concurrency)
executus CI / test (pull_request) Successful in 1m34s
c8a87f1733
All 3 cloud models converged on real concurrency bugs in the SQLite stores:

- AppendVersion (HIGH): the seq key was `SELECT MAX(seq)+1` then INSERT in two
  un-transacted statements with a NON-unique index, AND the Scan error was
  swallowed (seq stayed 0 on failure). Concurrent appends could both land the
  same seq, silently breaking newest-first ordering. Now: one transaction, the
  Scan error is propagated, the (skill_id, seq) index is UNIQUE (the loser of a
  race fails loudly), and an empty SkillID is rejected.
- MarkScheduledRun / MarkAgentScheduledRun (all 3): replaced the Get→mutate→Save
  read-modify-write (lost-update window) with a single atomic UPDATE using
  json_set, so a concurrent Mark/edit can't clobber it. json_set keeps the JSON
  blob's NextRunAt/LastScheduledRunAt consistent with the indexed column;
  RFC3339Nano matches Go's time encoding so the blob still round-trips (tested).
- Open: actually applies PRAGMA busy_timeout=5000 (the doc advertised it but it
  was never set) — a contended writer waits instead of erroring SQLITE_BUSY.
- budgetStore.Add: rejects NaN/Inf secondsUsed (would irrecoverably poison the
  column).

Triaged-but-kept: plaintext webhook secret (documented design, high-entropy URL
key, pre-existing); SQL()/free-form `where` helpers (no untrusted input reaches
them — defense-in-depth notes only).

Core go.sum still free of host/DB deps; contrib/store green (incl. a json_set
blob-round-trip test).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
steve closed this pull request 2026-06-27 04:16:08 +00:00
steve deleted branch phase-4b-skill 2026-06-27 04:16:08 +00:00
executus CI / test (pull_request) Successful in 1m34s

Pull request closed

Sign in to join this conversation.
No Reviewers
No Label
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: steve/executus#5