security: validate operation names in FailImproveLoop#157
Open
garagon wants to merge 2 commits intogarrytan:masterfrom
Open
security: validate operation names in FailImproveLoop#157garagon wants to merge 2 commits intogarrytan:masterfrom
garagon wants to merge 2 commits intogarrytan:masterfrom
Conversation
FailImproveLoop uses its 'operation' parameter as a path segment under
~/.gbrain/fail-improve/ — getLogPath writes '{op}.jsonl', getCallCountPath
writes '{op}.counts.json', logImprovement writes '{op}/improvements.json'.
No current in-tree caller forwards a user-supplied value there, but the
class is exported and the method signatures type operation as plain
'string'. The first consumer that passes a request field (recipe name,
webhook slug, user-typed label) turns operation into an arbitrary-write
primitive — e.g. '../../../../../tmp/owned' escapes logDir entirely.
Fix: assertSafeOperation() rejects anything that isn't /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.
The charset matches what every in-tree caller already uses. Path
separators, parent-dir segments, null bytes, leading dots, and empty
strings all throw. Applied at every internal site that interpolates
operation into a filesystem path.
Tests (test/fail-improve.test.ts):
- Table-driven rejects for 10 payloads (traversal, absolute, null byte,
backslash, leading dot, empty, over-length).
- Positive list: 6 benign names that in-tree callers already use keep
working and write inside logDir.
- End-to-end: a unique-suffix sentinel payload that would traverse
out of logDir under the old code; assert the sibling file was never
created.
- logImprovement is covered separately since it takes its own operation
parameter.
Negative control: reverting src/core/fail-improve.ts turns 12 of these
tests red, confirming they catch the vulnerable behaviour.
The initial charset was ASCII-only (/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/).
That rejects legitimate names in non-Latin scripts — CJK (田中-enrich),
Cyrillic (иван_stats), Arabic (مَهَمَّة), Devanagari, Hebrew,
Latin-extended (mañana). Any caller that derives operation names from
entity pages, recipe titles, or user labels in those languages would
break.
Switch to Unicode classes:
/^[\p{L}\p{N}][\p{L}\p{N}\p{M}_-]{0,63}$/u
- \p{L} letter (any script)
- \p{N} number (any script)
- \p{M} combining mark — required for Arabic fatha/shadda, Hebrew
niqqud, Devanagari matras, Thai tone marks.
Still blocked: path separators, '..', null bytes, whitespace,
punctuation other than '_'/'-', and \p{C} (controls + format chars
like U+202E RTL override, U+200C/D ZW[N]J). Combining marks are NOT
in \p{C}, so the spoofing risk stays closed.
Tests added for the positive list (8 scripts) and for non-ASCII
attacks (CJK with slash, arabic with null, RTL override, etc.).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
FailImproveLoopis the new deterministic-first / LLM-fallback helper added in v0.10. Every call passes anoperationname (e.g."extract_mrr","rotation_test") that the class uses directly as a filesystem segment under~/.gbrain/fail-improve/— one JSONL file per operation, one counts file per operation, oneimprovements.jsonper operation dir.The method signatures take plain
stringforoperationand do no validation. Every current in-tree caller happens to pass a hard-coded identifier, butFailImproveLoopis exported and the first consumer that forwards a request field — a recipe name, a webhook slug, a user-typed label — turnsoperationinto an arbitrary filesystem-write primitive. Becausepath.joinpreserves../, a value like"../../../../../tmp/owned"lands outside~/.gbrain/fail-improve/entirely: anywhere the gbrain process has write permission. On a dev box that's$HOME; on a CI runner that can be/tmpor worse.This isn't a live exploit today — no caller passes user input there yet — but it's a latent gap one PR away from mattering, in code that exists specifically to chain into ingest pipelines (
skills/data-research,skills/meeting-ingestion) which do take external input.The vulnerable shape repeats across four internal sites:
This PR closes the door at the boundary, not at the caller — one validator runs before any path is constructed.
Fix
A private
assertSafeOperationthat accepts identifier-shaped names in any script:Applied at the four internal sites and at
logImprovement(public method). Every in-tree caller ("extract_mrr","rotation_test", etc.) passes, and so do names like田中-enrich,иван_stats,抽出_mrr,مَهَمَّة,mañana— which matters because gbrain skills enrich entity pages whose slugs are derived from user text in any language.Why Unicode letter/number/mark classes, not ASCII-only
An ASCII charset would reject every legitimate non-Latin name.
\p{L}\p{N}\p{M}is the tight union that\p{Z}(whitespace),\p{P}(punctuation other than the whitelisted_and-), and\p{C}(controls + format characters — U+202E RTL override, U+200C/D ZW[N]J, U+0000 NUL). The attack shapes stay closed.Why a regex allow-list, not
path.basenamebasename("../../../tmp/owned")returns"owned"— silently rewrites the caller's intent. A future reader of the code would have no hint the input was hostile.path.resolve+ prefix check works (that's what we used for R6-F007 oncheck-resolvable.ts), but there it made sense because the untrusted value is a full relative path. Hereoperationis semantically an identifier — it's never supposed to have slashes at all. The regex matches the actual shape.Reproduction
Before the fix, from a hypothetical future caller that forwards user input:
Sibling review
Grepped
src/for every callsite that forwards a caller-supplied string intopath.joinunder an app-owned directory:src/core/fail-improve.tsgetLogPathoperationsrc/core/fail-improve.tsgetCallCountPathoperationsrc/core/fail-improve.tslogImprovementoperationsrc/core/fail-improve.tsgetImprovementsoperationsrc/core/check-resolvable.tsskill.pathsrc/commands/doctor.ts:206skill.pathNo other in-tree callers of
FailImproveLoop— today it's only exercised by its test file, so no consumer needs to be updated.Test results
test/fail-improve.test.ts— 31 tests, all passing (15 pre-existing, 16 new).New regression tests
/invalid operation name/. Covers the ASCII attack surface (../../../tmp/owned,../owned,/tmp/owned,a/b,a\b,ok\0evil,.hidden,"",..., 65-char overflow) AND the non-ASCII one (田中/..,../田中,مَهَمَّة\0x,.иван,🚀_op,ok\u202eevilRTL override,has space). Confirms that adding Unicode support did not open a hole.../gbrain-escape-${Date.now()}-${rand}), assertsexecute()throws AND no sibling file was ever created next totempDir. Robust against leftover tmp pollution from prior runs.extract_mrr,rotation_test,op-1,ABC_123,a, 64-char max) + 8 non-ASCII (田中-enrich,extract_タスク,иван_stats,抽出_mrr,مَهَمَّة,mañana,任務1,Δ_delta). Each returns a result and places the JSONL file insidelogDir.logImprovementcoverage — separate check because it has its ownoperationparameter path.Negative control
Reverting
src/core/fail-improve.tsto master and rerunning the same test file fails 8 of the 46 tests — every injected traversal / null-byte / spoof payload goes through, confirming the pre-fix code really did write outsidelogDirand accept format characters.Full suite
What stayed in place
FailImproveLoop.execute(),logImprovement(), and every accessor keep the same signatures. The only behaviour change is a thrownErroron invalid input — which is the intended outcome for the hostile case and the reason no valid-path caller hits it.MAX_ENTRIES = 1000rotation, JSONL format, and directory layout are unchanged.src/, one file touched intest/.Files
How to verify