Part 6: Sequential Patch Edits (description_edits) & Whole-Document Quick-Action Defense in MCP save_work_item
Series Context: Part 6 of 10 in GitLab Core Engineering: Architecture, MCP & CI/CD Deep Dives. Featured MR: gitlab-org/gitlab!264862 (Merged into master) · Work Item: #635910 Why Sending Full Markdown Descr
Series Context: Part 6 of 10 in GitLab Core Engineering: Architecture, MCP & CI/CD Deep Dives.
Featured MR:gitlab-org/gitlab!264862(Merged intomaster) · Work Item:#635910
Why Sending Full Markdown Descriptions Over MCP Does Not Scale
When an AI agent fixes a typo or updates a single status checkbox in a GitLab issue or epic containing tables and implementation checklists, forcing it to resend the entire description string via save_work_item creates two real problems:
- Token waste & truncation risk: Large GitLab issues run thousands of tokens. Re-generating the whole body risks subtly rewriting unrelated paragraphs.
- Concurrent edit clobbering: Full-body overwrites silently erase edits another engineer made to a different section moments earlier.
In !264862, we added an update-only description_edits array to save_work_item:
{
"project_id": "gitlab-org/gitlab",
"work_item_iid": 635910,
"description_edits": [
{ "old_str": "- [ ] Verify CI", "new_str": "- [x] Verify CI" },
{ "old_str": "Status: In review", "new_str": "Status: Merged" }
]
}
Implementing surgical text patching inside a Markdown engine that executes /quick_actions turns out to have fascinating Ruby and security subtleties.
Strict Uniqueness, Ordering & Ruby's sub Trap
Look at apply_single_description_edit inside app/services/mcp/tools/work_items/update_work_item_tool.rb:
def apply_single_description_edit(content, edit)
edit_hash = edit.is_a?(Hash) ? edit.with_indifferent_access : {}
unless edit_hash.key?(:old_str) && edit_hash.key?(:new_str)
raise ArgumentError, 'Provide both old_str and new_str for each edit in description_edits'
end
old_str = edit_hash[:old_str].to_s
new_str = edit_hash[:new_str].to_s
raise ArgumentError, 'old_str cannot be empty in description_edits' if old_str.empty?
occurrences = content.scan(old_str).length
if occurrences == 0
raise ArgumentError, "old_str was not found in description: #{old_str.truncate(60).inspect}"
elsif occurrences > 1
msg = [
"old_str is ambiguous in description (#{occurrences} matches found);",
"provide more surrounding context: #{old_str.truncate(60).inspect}"
].join(' ')
raise ArgumentError, msg
end
content.sub(old_str) { new_str }
end
Three design choices here protect callers and maintainers alike:
-
Sequential
reducepipeline:
new_description = edits.reduce(current_description) do |content, edit|
apply_single_description_edit(content, edit)
end
Edits apply left-to-right against the output of the previous edit. If edit #2 touches text updated by edit #1, it works deterministically—and if edit #1 introduces ambiguity, edit #2 fails loudly before mutating anything.
-
Exact-once match enforcement (
occurrences == 1): Ifold_strappears twice (occurrences > 1), guessing first vs all silently corrupts documents. Failing with"provide more surrounding context"gives an AI agent the exact hint needed to expandold_stron the next call. Also noticecontent.scan(old_str)treats aStringold_strliterally (no regex interpretation!). -
Ruby's
sub(old_str) { new_str }block form: If you writecontent.sub(old_str, new_str), Ruby interprets\&,\1,\+, and\\insidenew_stras regex backreferences! Writingcontent.sub(old_str) { new_str }passesnew_strverbatim, even whennew_strcontains shell backslashes or code snippets.
We also guarded the boundaries cleanly:
- Rejected
description_editsinsideCreateWorkItemTool::UPDATE_ONLY_PARAMS(you cannot patch a record being created). - Enforced mutual exclusivity with
description:"Provide either description or description_edits, not both". - Re-checked total character length against
Concerns::Constants::MARKDOWN_TEXT_MAX_LENGTH(1_048_576) after all sequential expansions complete.
Security Deep Dive: Why Quick-Action Validation Must Diff the Whole Document
GitLab MCP tools forbid injecting slash quick actions (/close, /spend, /award, /label) inside descriptions so prompt-injected issues cannot silently execute commands.
If we had naively checked validate_no_quick_actions!(edit[:new_str]) on each replacement snippet alone, two injection attacks would have bypassed it completely:
- Deletion smuggling: Existing description:
Please don't /close this issue
Attacker sends { "old_str": "Please don't ", "new_str": "" }.
Neither old_str nor new_str starts with /! Yet after deletion, the line begins with /close this issue—executing a quick action!
-
Newline smuggling:
Same existing line:
"Please don't /close this issue". Attacker sends{ "old_str": "don't ", "new_str": "don't\n" }. Still no/anywhere innew_str! Yet injecting a newline pushes/closeto column 0 of the next line.
Meanwhile, legitimate issues often already contain lines starting with / (like /etc/gitlab.rb), so blindly rejecting any new_description containing a leading / would freeze future unrelated edits to the issue!
Look at how validate_description_edits_quick_actions! threads that needle cleanly:
def validate_description_edits_quick_actions!(current_description, new_description)
original_actions = current_description.lines.grep(Concerns::ContentValidation::QUICK_ACTION_PATTERN).map(&:strip)
new_actions = new_description.lines.grep(Concerns::ContentValidation::QUICK_ACTION_PATTERN).map(&:strip)
return if (new_actions - original_actions).empty?
raise ArgumentError, 'Quick actions (commands starting with /) are not allowed in description'
end
By comparing the multiset of leading-slash lines after all edits against the original document, both deletion smuggling and newline smuggling fail immediately, while untouched lines like /etc/gitlab.rb remain editable.
Bonus Cleanup: Eliminating Duplicate Work Item Queries Across UrlParser & BaseTool
Notice apply_description_edits needed the WorkItem record (to read item.description), whereas UpdateWorkItemTool previously only called resolve_work_item_id (returning a Global ID string gid://gitlab/WorkItem/123).
Instead of running a second query (::WorkItem.find_by_id), we refactored UrlParser and BaseTool:
- Extracted
UrlParser#find_work_item_from_url(url)returning theWorkItemmodel directly, and hadresolve_work_item_from_url(url)delegate.to_global_id.to_s. - Renamed
BaseTool#resolve_work_item_from_params->find_work_item_from_params, memoized@resolved_work_iteminBaseTool#resolve_work_item, and hadresolve_work_item_idreturnresolve_work_item.to_global_id.to_s. - Verified with
expect(::WorkItem).not_to receive(:find_by_id)inbase_tool_spec.rbthat resolving both the description and Global ID performs zero extra database lookups.
Takeaways for GitLab Engineers
-
Always use block form
String#sub(target) { replacement }when applying user- or LLM-supplied patches—non-blocksub(a, b)corrupts\&and\1sequences. -
Validate line-anchored invariants (like
/quick_actions) against the post-patch document diff, never isolated edit chunks. Deletions and\ninsertions can shift existing substrings into column 0. -
Share model resolution cleanly: Return the ActiveRecord instance once (
resolve_work_item) and derive.to_global_id.to_sfrom it rather than issuing duplicate DB queries.
Series Navigation
-
Part 1: Taming
EE::Project& ExtractingAi::Catalog(!269060) -
Part 2: Decoupling
EE::GroupRouting viaAi::DuoSettings::Paths(!269059) -
Part 3: Escaping Non-ASCII Git Refs in Workspaces — AppSec CWE-78 & CWE-116 (
!262103) -
Part 4: Exposing Grape Routes to MCP —
list_pipeline_schedules& Auth Traps (!264856) -
Part 5: Vacuous RSpec Mocks & Live Upstream Flag Rebases (
!269061) -
Part 6 (This Article): Sequential
description_edits& Quick-Action Defense insave_work_item(!264862) -
Part 7: Self-Correcting MCP Errors & JSON Schema Fixes (
!264852,!264853,!264864) -
Part 8: Consolidating
SavePipelineService& Active Polling Hints (!264858,!264863) -
Part 9: Surviving
Dangerfile, Commit Trailers & Predictive CI Pipelines -
Part 10: Security Cleanups, Organization
empty?& Review Etiquette (!261178,!261030,!264859)
Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.