Skip to content

fix(agents): a model-supplied id must not be a LIKE wildcard - #196

Merged
yetone merged 1 commit into
yetone:mainfrom
WhichPaths:fix/agent-cli-like-wildcards
Sep 5, 2026
Merged

yetone merged 1 commit into
yetone:mainfrom
WhichPaths:fix/agent-cli-like-wildcards

Conversation

@WhichPaths

Copy link
Copy Markdown
Collaborator

cumora memory delete % deletes every memory the agent has, and reports success.

Three commands interpolate their positional argument straight into a LIKE pattern:

// memory pin / memory delete
[me, `memory/%/${id}.md`],

// skills delete
[me, `skills/${name}/SKILL.md`, `skills/${name}/%`],

% and _ are wildcards to LIKE, and the argument comes from the model. There is no validation between parsed.positional[1] and the query.

Measured

Postgres 16, three memories for one agent, running exactly what the command issues:

 memories_before | 3
DELETE 3
 memories_after  | 0

_ is the quieter one — it matches exactly one character, so mem-aaaaaaaa-11_ resolves to a real memory the caller never named, and deleted mem-aaaaaaaa-11_ looks like it did what was asked.

Scope is the agent's own rows (agent_id = $1 is parameterised, and both statements are tenant-bound), so this is data loss inside one agent rather than a boundary crossing. That is also why it is easy to miss: nothing about it looks dangerous from outside the agent.

Escape, don't reject

Skill names are free text, so a shape check would be wrong for them. Postgres's default LIKE escape is a backslash, and the pattern arrives as a bound parameter, so escaping in TypeScript is sufficient and no ESCAPE clause is needed. I verified both directions against the database rather than assuming:

'…/mem-aaa.md' LIKE 'memory/%/\%.md'      → false   -- escaped % no longer matches
'…/mem-%.md'   LIKE 'memory/%/mem-\%.md'  → true    -- a literal % still matches itself
'…/mem_aaa.md' LIKE 'memory/%/mem\_aaa.md' → true
'…/memXaaa.md' LIKE 'memory/%/mem\_aaa.md' → false

The helper escapes the backslash itself too, so it cannot be used to escape something else.

The other half of the same workflow

memory list printed a truncated id:

`  ${pin}[${m.id.slice(0, 10)}] …`

while ids are minted 16 wide — mem- plus 12 characters of a UUID, e.g. mem-c2ad155a-56a, shown as mem-c2ad15. Both mutating commands resolve an exact file stem, so memory/%/mem-c2ad15.md cannot match memory/observation/mem-c2ad155a-56a.md:

UPDATE 0
DELETE 0
 still_there_after_delete | 1
 matches_full_id          | 1

cumora help documents memory listmemory pin <id> / memory delete <id> as the workflow, and it could not round-trip for any memory ever written. Fixed here because it is the same command family and the same round trip — an agent that copies what the tool printed is the intended use.

Verification

Six integration cases driving the real runCli. Four go red against the shipped code:

not ok 1 - `memory delete %` deletes nothing
not ok 2 - an underscore is a literal too
not ok 3 - `skills delete %` deletes nothing
not ok 6 - the id shown by `memory list` round-trips into `memory pin`
# pass 2  # fail 4

The two that pass either way are the guards against over-fixing: an exact id still pins and deletes, and a skill genuinely named 100% still deletes itself and leaves its sibling alone.

Unit suite 1102 pass / 0 fail; the existing agent-cli-side-effects integration suite stays green; tsc --noEmit, biome lint ., all three source guards clean.

Noted, not changed

memory list --kind builds memory/${k}/% from another unescaped argument (cli.ts:4037-4038). That one only widens a read, so it cannot destroy anything, and narrowing it changes what a listing returns — worth a separate look rather than folding into a data-loss fix.

`memory pin`, `memory delete` and `skills delete` interpolate their
positional argument straight into a LIKE pattern:

  path LIKE `memory/%/${id}.md`
  path LIKE `skills/${name}/%`

`%` and `_` are wildcards there, and the argument comes from the model.
`cumora memory delete %` becomes `path LIKE 'memory/%/%.md'` and removes
every memory the agent has, then reports "deleted %". Verified against
Postgres 16: three memories in, DELETE 3, none left. `_` is the quieter
version — one character wide, so `mem-aaaaaaaa-11_` hits a real id the
caller never named. `skills delete %` is the same shape one command over.

Escape rather than reject: skill names are free text, and Postgres's
default LIKE escape is a backslash reaching it as a bound parameter, so
no ESCAPE clause is needed. Confirmed both directions — an escaped `%`
stops matching arbitrary content, and a skill genuinely named "100%"
still deletes itself and nothing else.

Second defect in the same family, fixed here because it is the other
half of the documented workflow: `memory list` printed
`m.id.slice(0, 10)` while ids are minted 16 wide (`mem-` + 12 characters
of a UUID). So the token the listing showed could never resolve —
`memory/%/mem-c2ad15.md` does not match
`memory/observation/mem-c2ad155a-56a.md`. `cumora help` documents
list → pin/delete as the workflow, and it could not round-trip for any
memory ever written. Print the full id.

Four of the six integration cases go red against the shipped code. The
two that pass either way are the guards: an exact id still resolves, and
a literal `%` in a name still matches itself.
@yetone
yetone merged commit 3ebbfa5 into yetone:main Sep 5, 2026
7 checks passed
@yetone yetone mentioned this pull request Sep 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants