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 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)