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 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 reduce pipeline:
new_description = edits.reduce(current_description) do |content, edit| apply_single_description_edit(content, edit) endEdits 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 a Stringold_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 issueAttacker sends{ "old_str": "Please don't ", "new_str": "" }. Neitherold_strnornew_strstarts 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 the WorkItem model 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 & Extracting Ai::Catalog (!269060)
- Part 2: Decoupling EE::Group Routing via Ai::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 in save_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)
Comments
No comments yet. Start the discussion.