Skip to content

fix(safe-outputs): make create-work-item body field configurable - #1926

Merged
jamesadevine merged 29 commits into
mainfrom
copilot/fix-create-work-item-issue
Aug 20, 2026
Merged

fix(safe-outputs): make create-work-item body field configurable#1926
jamesadevine merged 29 commits into
mainfrom
copilot/fix-create-work-item-issue

Conversation

Copilot AI commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Summary

create-work-item always wrote agent-generated body content to System.Description, which makes Bug work items appear blank in processes whose Bug form renders Microsoft.VSTS.TCM.ReproSteps instead. It also escaped body HTML before storing the field as Markdown, producing unreadable content.

  • Work item body field
    • Added safe-outputs.create-work-item.description-field.
    • Defaults Bug to Microsoft.VSTS.TCM.ReproSteps.
    • Defaults all other work item types to System.Description.
    • Validates configured field refs before JSON Patch construction.
safe-outputs:
  create-work-item:
    work-item-type: Bug
    description-field: Microsoft.VSTS.TCM.ReproSteps
  • Body sanitization

    • Preserves ordinary inline HTML in Markdown descriptions.
    • Still strips active HTML constructs, event handlers, dangerous URL protocols, and unclosed unsafe tags.
  • Docs and coverage

    • Documented description-field and Markdown body semantics.
    • Added focused tests for default selection, custom field validation, MCP proposal output, executor patch payload, and sanitizer behavior.

Test plan

  • cargo test create_work_item
  • cargo test sanitize_markdown
  • cargo test validate_ado_work_item_field_ref

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
2 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

Copilot AI and others added 8 commits August 16, 2026 08:32
Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com>
Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com>
Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com>
Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com>
Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com>
Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com>
Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com>
Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com>
Copilot AI changed the title [WIP] Fix hardcoded System.Description in create-work-item fix(safe-outputs): make create-work-item body field configurable Aug 16, 2026
Copilot AI requested a review from jamesadevine August 16, 2026 08:53
@jamesadevine

Copy link
Copy Markdown
Collaborator

/review

@github-actions

github-actions Bot commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

PR Security Reviewer completed the security review.

🔒 Security review by PR Security Reviewer

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed for ado-aw's compiler-contract invariants (front-matter grammar, safe-output schema/newtype rules, generated-artifact drift, docs sync).

  • Front-matter grammar: description_field: Option<AdoWorkItemFieldRef> with #[serde(default)] is backward compatible — no codemod needed.
  • Newtype rule: correctly followed — description-field uses the new AdoWorkItemFieldRef validated newtype (src/secure.rs) backed by validate_ado_work_item_field_ref (src/validate.rs), not a raw String. This also protects the /multilineFieldsFormat/{description_field} JSON Patch path from Pointer-syntax corruption.
  • Docs sync: docs/front-matter.md and docs/safe-outputs.md were both updated with the new description-field option and defaulting behavior — good.
  • No generated-artifact drift: this PR only touches src/ and docs/; no ado-script bundles, IR/gate schema, or .lock.yml files are implicated, so no drift check applies here.

No merge-blocking compiler-contract issues found. This is a well-scoped, correctly-typed change.

🏗️ Compiler contract review by Compiler Contract Reviewer · auto · 41.4 AIC · ⌖ 2.3 AIC · ⊞ 12.8K
Comment /review to run again

@github-actions github-actions Bot mentioned this pull request Aug 16, 2026

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solid test coverage overall for the new description-field config and the HTML-preserving sanitize_markdown path (defaults, Bug override, invalid value rejection, executor E2E, sanitizer preserve/neutralize). Two gaps worth addressing before merge:

  1. No test covers description-field colliding with custom-fields or System.Title, which produces a duplicate JSON Patch path that ADO will likely reject at runtime — this failure mode is currently undetectable except in production.
  2. test_sanitize_markdown_neutralizes_active_html bundles ~15 distinct attack vectors into one test; a regression in any single vector is masked by the rest passing, making CI failures hard to localize.

Neither is merge-blocking on its own, but the first is a real correctness gap worth at least a regression test.

🧪 Test quality analysis by Test Quality Sentinel · auto · 60.8 AIC · ⌖ 2.84 AIC · ⊞ 11.3K
Comment /review to run again

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rust code quality review

No merge-blocking Rust defects found in src/mcp.rs, src/safe_outputs/create_work_item.rs, src/sanitize.rs, src/secure.rs, src/validate.rs.

💡 Notes
  • description_field_for correctly derives its fallback to the validated AdoWorkItemFieldRef type, and validate_ado_work_item_field_ref rejects .///~ characters that would otherwise corrupt the JSON Patch pointer path built in field_op — good defensive design.
  • Minor, non-blocking: strip_dangerous_html_tags and strip_event_handler_attrs_in_tags in src/sanitize.rs each re-implement an almost identical tag-scanning loop around html_tag_end; a shared helper that takes a per-tag transform closure would remove the duplication, but it's not a correctness issue and isn't worth a blocking comment.
  • The rust-critic sub-agent returned no output for this diff after repeated polling; I completed the review from my own pass only.
  • Error handling, casts, path handling, and concurrency all looked sound in the changed lines — anyhow::Result/.context() used consistently, no new unwrap()/expect() on agent-input paths, no lossy casts introduced.

🦀 Rust code quality review by Rust Code Quality Reviewer · auto · 79.3 AIC · ⌖ 2.23 AIC · ⊞ 11.4K
Comment /review to run again

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One merge-blocking finding: the new strip_dangerous_html_tags HTML sanitizer in src/sanitize.rs is single-pass and vulnerable to the classic nested-tag bypass (e.g. <scr<script>ipt>alert(1)</scr</script>ipt> reconstructs a live <script> tag after stripping). I verified this against a standalone extraction of the exact diff algorithm. Since sanitize_markdown is now used for work-item descriptions, and description-field routes Bug items to a Markdown-rendered field, this is a stored-XSS-class regression introduced by this PR (the old sanitize path fully HTML-escaped, so this preserving-HTML replacement needs to close this gap before merge). See inline comment for details and a suggested fix (loop the tag-stripping pass to a fixed point, or use a real tokenizer).

🔒 Security review by PR Security Reviewer · auto · 108.4 AIC · ⌖ 7.28 AIC · ⊞ 11.7K
Comment /review to run again

@jamesadevine

Copy link
Copy Markdown
Collaborator

@copilot implement suggestions and resolve blocking findings.

Copilot AI and others added 4 commits August 16, 2026 15:02
Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com>
Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com>
Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com>
Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com>
Copilot AI and others added 2 commits August 17, 2026 05:56
Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com>
Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com>

Copilot AI commented Aug 17, 2026

Copy link
Copy Markdown
Contributor Author

@copilot address final review comments

Addressed in 48b6a6f through a287f33. The follow-up strips slash-separated event-handler attributes, pre-caps Markdown before the expensive HTML pass, makes custom-field patch ordering deterministic, and adds the requested collision/folded-tag sanitizer coverage. cargo test sanitize_markdown, cargo test create_work_item, secret scanning, and CodeQL passed.

@jamesadevine

Copy link
Copy Markdown
Collaborator

/review

@github-actions

github-actions Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

PR Security Reviewer completed the security review.

Caution

agentic threat detected
Threat detection flagged this output in warn mode. Manual review is REQUIRED before any follow-up automation.

Details

Potential security threats were detected in the agent output.

Review the workflow run logs for details.

🔒 Security review by PR Security Reviewer

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solid test coverage overall: the new description-field config, patch-field collision validation, and the HTML-preserving markdown sanitizer all have targeted unit tests plus one mocked-HTTP executor test. Found a few gaps that would let regressions ship silently — none are blocking, all are advisory additions to widen coverage on the case-insensitive Bug-type match and the full collision matrix.

🧪 Test quality analysis by Test Quality Sentinel · auto · 65.7 AIC · ⌖ 2.65 AIC · ⊞ 11.3K
Comment /review to run again

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rust review — no blocking issues found

Reviewed src/mcp.rs, src/safe_outputs/create_work_item.rs, src/sanitize.rs, src/secure.rs, src/validate.rs.

  • AdoWorkItemFieldRef validation is strict and correctly guards against JSON Pointer path corruption in /fields/<ref>.
  • sorted_custom_fields fixes what would otherwise be nondeterministic HashMap iteration in generated JSON Patch output — good catch, and it's covered by test_sorted_custom_fields_orders_case_insensitively.
  • validate_patch_fields correctly detects field collisions (title/description/custom-fields) before use, avoiding silent data loss on the ADO patch document.
  • The new sanitize_markdown/dangerous-tag stripping logic in sanitize.rs (fixed-point loop over strip_dangerous_html_tags, event-handler-attribute stripping, protected fenced-code ranges) is thorough and has strong adversarial test coverage (nested/folded tag reconstruction, attribute-span folding, unclosed tags).
  • No unwrap()/expect() on user-reachable paths; error handling uses anyhow::ensure!/bail! with actionable messages throughout.
  • cargo build succeeds cleanly.

The Compiler Contract reviewer owns the front-matter/schema/docs side of this change; nothing further from a pure Rust-engineering standpoint.

Warning

Firewall blocked 1 domain

The following domain was blocked by the firewall during workflow execution:

  • spsprodeus21.vssps.visualstudio.com

To allow these domains, add them to the network.allowed list in your workflow frontmatter:

network:
  allowed:
    - defaults
    - "spsprodeus21.vssps.visualstudio.com"

See Network Configuration for more information.

🦀 Rust code quality review by Rust Code Quality Reviewer · auto · 66.5 AIC · ⌖ 5.98 AIC · ⊞ 11.4K
Comment /review to run again

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Compiler-contract review: front-matter grammar, safe-output docs, and the new AdoWorkItemFieldRef newtype all look correctly wired (properly uses validated_string!/src/validate.rs, not a raw String). Tests are thorough and pass locally (cargo test --bin ado-aw create_work_item). One finding posted inline: the new validate_patch_fields collision check only runs in the Stage 3 executor, not in the compile-time validate_work_item_assignment_outputs_config, so a misconfigured description-field/custom-fields collision is discovered only after a pipeline runs, not at ado-aw compile/lint time.

Warning

Firewall blocked 1 domain

The following domain was blocked by the firewall during workflow execution:

  • spsprodeus21.vssps.visualstudio.com

To allow these domains, add them to the network.allowed list in your workflow frontmatter:

network:
  allowed:
    - defaults
    - "spsprodeus21.vssps.visualstudio.com"

See Network Configuration for more information.

🏗️ Compiler contract review by Compiler Contract Reviewer · auto · 107.6 AIC · ⌖ 2.45 AIC · ⊞ 12.8K
Comment /review to run again

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

agentic threat detected
Threat detection flagged this output in warn mode. Manual review is REQUIRED before any follow-up automation.

Details

Potential security threats were detected in the agent output.

Review the workflow run logs for details.

Reviewed the sanitize/validate/create-work-item changes. The new AdoWorkItemFieldRef typed newtype, validate_ado_work_item_field_ref, and the config sanitization (SanitizeConfig impl covering all string fields including custom-field keys/values and artifact-link) look correctly scoped and don't weaken any existing control.

One finding worth blocking on: sanitize_markdown (new src/sanitize.rs function used by create-work-item descriptions) preserves raw HTML while still relying on the pre-existing sanitize_url_protocols, which does a literal substring search for (redacted) That check is bypassable with an embedded whitespace/control character in the scheme (java\tscript:), and because the surrounding HTML is no longer escaped to inert entities (unlike the original sanitize()path), this bypass is now live rather than latent — a malicious agent-authored work item description could smuggle a(redacted) URI into a rendered <a href>/<img src>. See inline comment for a reproduction and suggested fix (normalize/strip whitespace from the scheme before matching, or operate on parsed attribute values).

Everything else — token handling, custom-field key typing, patch-field duplicate-field validation, and the dangerous-tag/event-handler stripping logic — is sound.

🔒 Security review by PR Security Reviewer · auto · 87.8 AIC · ⌖ 11 AIC · ⊞ 11.7K
Comment /review to run again

@github-actions
github-actions Bot dismissed their stale review August 17, 2026 10:31

Superseded by updated review from same workflow.

@jamesadevine

Copy link
Copy Markdown
Collaborator

@copilot perform a comprehensive review of santization. Identify if there are crates we can leverage to reduce complexity and corner cases. Submit a full report comment on this pr.

Copilot AI commented Aug 19, 2026

Copy link
Copy Markdown
Contributor Author

@copilot perform a comprehensive review of santization. Identify if there are crates we can leverage to reduce complexity and corner cases. Submit a full report comment on this pr.

Sanitization review report:

Scope reviewed

  • src/sanitize.rs: sanitize(), sanitize_markdown(), dangerous HTML stripping, event-handler removal, URL protocol redaction, Markdown protected-range logic.
  • src/mcp.rs and src/safe_outputs/create_work_item.rs: create-work-item proposal/executor paths that call sanitize_markdown.
  • src/secure.rs / src/validate.rs: AdoWorkItemFieldRef validation used for description-field and custom fields.
  • Docs for create-work-item Markdown/inline HTML behavior.

Current state

  • The configurable body-field work is structurally sound: description-field and custom-fields keys deserialize through AdoWorkItemFieldRef, and duplicate patch fields are rejected before JSON Patch dispatch.
  • sanitize_markdown now has focused regression coverage for the review issues raised so far: dangerous tags, nested/folded tags, style tags, slash-separated event handlers, malformed quote handling, dangerous protocols, and fenced-code preservation.
  • Validation run: cargo test sanitize_markdown passed (13 tests).

Main concern
The current Markdown HTML sanitizer is bespoke. It has grown into a partial HTML tokenizer plus partial Markdown protected-range parser. That is the risky part: HTML5 tag/attribute parsing and Markdown code-span/fence rules have many edge cases, and the commit history on this PR already shows several rounds of corner-case fixes. I did not find a new concrete bypass in the covered cases, but I would not keep expanding this custom parser long term.

Residual risk areas

  • The policy is mostly deny-list based: it strips selected dangerous tags and on* attributes, but otherwise preserves arbitrary tags/attributes. A parser-backed allowlist would be easier to reason about.
  • URL protocol redaction is global string replacement, not attribute-value-aware. A proper sanitizer can validate decoded/normalized URL attributes against allowed schemes.
  • The Markdown protected-range logic intentionally handles fenced blocks and single-line inline code, but it is not a full CommonMark parser. A Markdown parser would reduce code-fence/code-span ambiguity.
  • Relying on Azure DevOps to sanitize preserved inline HTML is a useful final guard, but safe-output sanitization should still produce a conservative payload before ADO receives it.

Crates to leverage

  1. ammonia — best fit for HTML sanitization. It is a mature Rust HTML sanitizer built on HTML parsing, with configurable allowed tags, attributes, and URL schemes. This can replace the custom dangerous-tag stripping, event-handler regexes, and protocol redaction for HTML fragments.
  2. pulldown-cmark — good fit for identifying Markdown structure, especially code spans/fenced code, while retaining source offsets via parser offset APIs. Pairing this with ammonia would let us sanitize only outside code ranges and preserve literal code fences.
  3. comrak — heavier but full CommonMark/GFM parser/renderer. Good if we are willing to normalize Markdown or render to sanitized HTML, but more behavior-changing for an ADO field currently marked as Markdown.
  4. html_sanitizer / html-sanitizer — possible lower-level alternatives, but I would prefer ammonia unless we need a policy shape it cannot express.
  5. lol_html — not my recommendation here; it is a streaming rewriter, not a complete sanitizer policy by itself, and does not solve Markdown code-range handling.

Recommended follow-up design

  • Keep the transport sanitization already in sanitize_markdown: control characters, pipeline commands, mentions, bot triggers, XML comments, and content caps.
  • Replace neutralize_dangerous_html + sanitize_url_protocols for Markdown descriptions with an ammonia::Builder allowlist.
  • Use pulldown-cmark (or comrak if we want full GFM behavior) to identify Markdown code spans/fences and skip sanitizer transformations inside those source ranges.
  • Start with an explicit small allowlist for tags/attrs we want ADO Markdown descriptions to preserve, rather than preserving arbitrary inline HTML.
  • Port the current sanitizer regression tests unchanged first, then add crate-specific edge cases for decoded URL schemes, malformed tags, unquoted attributes, SVG/MathML, and code spans/fences.

Recommendation for this PR
No additional code change from me in this pass because the request was for a review/report, and the current targeted sanitizer tests pass. I recommend treating the crate-backed sanitizer migration as a follow-up hardening/refactor unless the maintainers want to block this PR on reducing the bespoke sanitizer surface now.

@jamesadevine
jamesadevine marked this pull request as ready for review August 20, 2026 05:50
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 1 pipeline(s).
1 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

PR Security Reviewer completed the security review.

🔒 Security review by PR Security Reviewer

@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Rust Code Quality Reviewer completed the Rust code quality review.

🦀 Rust code quality review by Rust Code Quality Reviewer

@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Test Quality Sentinel completed the test quality analysis.

🧪 Test quality analysis by Test Quality Sentinel

@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Compiler Contract Reviewer completed the compiler contract review.

Warning

Firewall blocked 1 domain

The following domain was blocked by the firewall during workflow execution:

  • spsprodeus21.vssps.visualstudio.com

To allow these domains, add them to the network.allowed list in your workflow frontmatter:

network:
  allowed:
    - defaults
    - "spsprodeus21.vssps.visualstudio.com"

See Network Configuration for more information.

🏗️ Compiler contract review by Compiler Contract Reviewer

@jamesadevine
jamesadevine merged commit 9ef8855 into main Aug 20, 2026
67 of 68 checks passed

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Compiler Contract Review

No compiler-contract issues found. Highlights:

  • description-field is Option<AdoWorkItemFieldRef> with a sane default fallback (description_field_for), so existing .md sources without the new key keep parsing — no codemod needed.
  • custom-fields keys were switched from raw String to the new AdoWorkItemFieldRef validated newtype (src/secure.rs), which is exactly the pattern this reviewer enforces for identifier-like config fields, and validate_ado_work_item_field_ref is unit-tested with both valid and invalid cases.
  • Since custom_fields is now HashMap<AdoWorkItemFieldRef, String>, #[derive(SanitizeConfig)] can no longer auto-generate the impl (its codegen only recognizes HashMap<String, String>), so the PR correctly drops the derive and hand-writes SanitizeConfig for CreateWorkItemConfig — consistent with the existing hand-written pattern in assign_github_issue_milestone.rs.
  • docs/front-matter.md and docs/safe-outputs.md were both updated for the new description-field option and description-format semantics.
  • sanitize_markdown is correctly scoped pub(crate) and used consistently from both mcp.rs (proposal write) and create_work_item.rs's SanitizeContent impl (pre-execution).

No lock-file, codegen, or documentation drift detected for this change.

Warning

Firewall blocked 1 domain

The following domain was blocked by the firewall during workflow execution:

  • spsprodeus21.vssps.visualstudio.com

To allow these domains, add them to the network.allowed list in your workflow frontmatter:

network:
  allowed:
    - defaults
    - "spsprodeus21.vssps.visualstudio.com"

See Network Configuration for more information.

🏗️ Compiler contract review by Compiler Contract Reviewer · auto · 61 AIC · ⌖ 2.7 AIC · ⊞ 12.7K
Comment /review to run again

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solid test coverage overall for the new description-field config and the sanitize_markdown HTML-preserving sanitizer — happy paths, defaults, validation errors, and several adversarial HTML/attribute-splitting cases are all exercised, and the executor test correctly asserts on the outgoing JSON patch body rather than internals.

Two gaps worth closing (see inline comments):

  1. No test covers a custom-fields collision against an explicitly configured description-field (only the two default-field collision cases are tested), so the "description-field" vs "default description-field" label branch in validate_patch_fields is unprotected.
  2. The new HTML-tag/event-handler stripping logic is only tested with lowercase tag/attribute names; no test asserts case-insensitive matching (<SCRIPT>, ONCLICK=), despite the implementation relying on to_ascii_lowercase() / (?i) for it.

Neither is blocking, but both are plausible regression vectors that current tests wouldn't catch.

🧪 Test quality analysis by Test Quality Sentinel · auto · 85.9 AIC · ⌖ 1.91 AIC · ⊞ 11.2K
Comment /review to run again

}

#[tokio::test]
async fn test_execute_rejects_duplicate_description_custom_field() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No test exercises description_field collisions with custom-fields when the field is explicitly configured (not the Bug/default case) — only the two default cases (test_patch_field_validation_rejects_custom_field_description_collision for Bug's default, and ..._default_system_description_collision for Task's default) are covered.

💡 Why this matters

description_field_for prefers config.description_field when set. If a user sets description-field: Custom.Body and also adds Custom.Body to custom-fields, the collision path uses the "description-field" label (line ~1046: let description_label = if config.description_field.is_some() { "description-field" } else { "default description-field" };). None of the existing tests hit this branch — a regression there (e.g. always reporting "default description-field") would ship silently, and the error-message wording is user-facing.

Suggested addition:

#[test]
fn test_patch_field_validation_rejects_custom_field_explicit_description_field_collision() {
    let mut config = CreateWorkItemConfig {
        description_field: Some(AdoWorkItemFieldRef::parse("Custom.Body").unwrap()),
        ..Default::default()
    };
    config.custom_fields.insert(
        AdoWorkItemFieldRef::parse("Custom.Body").unwrap(),
        "overridden".to_string(),
    );
    let error = validate_patch_fields(&config, description_field_for(&config), false)
        .unwrap_err()
        .to_string();
    assert_eq!(
        error,
        "custom-fields field 'Custom.Body' duplicates description-field field 'Custom.Body'"
    );
}

Comment thread src/sanitize.rs
let output = sanitize_markdown("<style>body{display:none}</style><p>safe</p>");

assert_eq!(output, "body{display:none}<p>safe</p>");
assert!(!output.contains("<style"));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The neutralize_dangerous_html/sanitize_url_protocols combo is only tested for the happy path (HTML preserved, dangerous tags stripped) — no test covers the case-insensitivity of tag/attribute matching (e.g. <SCRIPT>, <ScRiPt data-x=...>, ONCLICK=), even though is_dangerous_html_tag lowercases and the event-handler regexes use (?i).

💡 Why this matters

This is new, security-sensitive sanitization logic (XSS-style payload stripping) shipping in this PR. Case-insensitive tag/attribute matching is exactly the kind of thing that regresses silently if someone "simplifies" to_ascii_lowercase() out of is_dangerous_html_tag, since none of the current tests would catch it (they all use lowercase tag names). A single test asserting <SCRIPT> and onCLICK= are still stripped would close this gap.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR's sanitize_markdown preserves raw HTML instead of escaping it wholesale (as sanitize() did), which is the correct intent for readable work-item Markdown bodies, but it makes the pre-existing sanitize_url_protocols substring-match weakness directly exploitable via whitespace/control-char/entity obfuscation of dangerous URL schemes ((redacted) (redacted) etc.) inside preserved href/src attributes. See inline comment for a reproducible bypass verified against this branch. The rest of the diff (field-ref validation, dangerous-tag stripping, event-handler stripping, duplicate patch-field detection) looks sound.

🔒 Security review by PR Security Reviewer · auto · 95.3 AIC · ⌖ 2.32 AIC · ⊞ 11.7K
Comment /review to run again

Comment thread src/sanitize.rs
Comment on lines +75 to +85
/// Sanitize untrusted Markdown content while preserving ordinary inline HTML for
/// renderers that accept it natively.
pub(crate) fn sanitize_markdown(input: &str) -> String {
let mut s = remove_control_characters(input);
s = neutralize_pipeline_commands(&s);
s = neutralize_mentions(&s);
s = neutralize_bot_triggers(&s);
s = remove_xml_comments(&s);
s = enforce_content_limits(&s);
s = neutralize_dangerous_html(&s);
s = sanitize_url_protocols(&s);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High severity, high confidence: sanitize_url_protocols only does a literal, unescaped substring match for (redacted) which sanitize_markdownnow relies on as its *only* URL-protocol defense while simultaneously preserving raw HTML tags/attributes it previously escaped wholesale viaescape_html_tags`.

💡 Bypass details and fix

Because sanitize() used to run escape_html_tags first, an attacker-controlled <a href="(redacted) would render as harmless escaped text () regardless of whether the protocol string was caught. sanitize_markdownremoves that escaping safety net for tags not in theis_dangerous_html_tagdenylist (e.g., ), so the substring-match weakness in sanitize_url_protocolsbecomes directly exploitable: a work-item description authored by an untrusted agent can render a clickable/executing(redacted) (or `(redacted) URI in the ADO work item UI.

Verified locally (built from this PR's create_work_item.rs/sanitize.rs) that all of these bypass sanitize_markdown unmodified:

<a href="java\tscript:alert(1)">x</a>          // tab inside scheme
<a href="java script:alert(1)">x</a>           // space inside scheme
<a href="(redacted)       // HTML entity-encoded 'j'

Only the literal (unescaped) `(redacted) case is redacted; whitespace/control-char obfuscation and entity-encoding both evade the check.

Fix: normalize/decode the URL scheme before matching (strip control chars/whitespace within the scheme portion, and reject on href/src attribute values whose decoded scheme matches a denylist) rather than doing a naive substring search on the raw markup, since this function is now the last line of defense for HTML that renders live instead of being escaped.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: Request changes

One merge-blocking defect found; the rest of the change (typed AdoWorkItemFieldRef, patch-field collision validation, deterministic sorted_custom_fields, work-item description-field selection) is solid, well-tested, and handled error paths appropriately with anyhow/Result rather than panics.

Themes reviewed
  • Error handling: validate_ado_work_item_field_ref and AdoWorkItemFieldRef use anyhow::ensure!/Result correctly; no unwrap()/expect() on attacker-reachable paths in create_work_item.rs or validate.rs.
  • Correctness: sorted_custom_fields fixes prior HashMap iteration non-determinism for generated patch ordering — good. html_tag_end/strip_dangerous_html_tags/strip_event_handler_attrs_in_tags correctly operate on byte offsets validated as char boundaries (via char_indices/.len_utf8()), so no UTF-8 panics observed in testing.
  • Performance/DoS (blocking): see inline comment on src/sanitize.rs — the new HTML-neutralization fixed-point loop is quadratic on adversarial nested-tag input and can hang well within the existing byte-size cap.
  • Did not flag the rust-critic sub-agent's output separately since it produced no response in the available window; findings above are from my own pass plus a runtime probe.

🦀 Rust code quality review by Rust Code Quality Reviewer · auto · 88.5 AIC · ⌖ 2.89 AIC · ⊞ 11.4K
Comment /review to run again

Comment thread src/sanitize.rs
apply_outside_markdown_protected_ranges(input, neutralize_dangerous_html_fragment)
}

fn neutralize_dangerous_html_fragment(input: &str) -> String {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

neutralize_dangerous_html_fragment's fixed-point loop is quadratic (or worse) on adversarial input, giving agent-controlled work-item descriptions a cheap DoS vector against the compiler/executor process.

💡 Reproduction and fix

I ran a local probe (sanitize_markdown on a string of 5,000 repetitions of <scr followed by ipt>tail, i.e. "<scr".repeat(5000) + "ipt>tail", ~20KB — well under MAX_CONTENT_BYTES). The call did not return within 90 seconds. strip_dangerous_html_tags does a full O(n) scan per call, but folded/nested dangerous tags (e.g. <scr<script>...) only get one dangerous tag removed per iteration of the loop { ... } in neutralize_dangerous_html_fragment, so an input can force O(n) iterations of an O(n) scan — O(n2) total, and the existing test test_sanitize_markdown_strips_nested_dangerous_tags_to_fixed_point only exercises this at trivial size (<scr<script>ipt>...), so it never catches the blow-up.

Since sanitize_markdown runs on agent-provided description text at MCP-tool-call time (src/mcp.rs) before any size truncation from enforce_content_limits has capped realistic-but-large inputs, an agent (or content it echoes) can trigger multi-minute (or worse) hangs in the sanitizer with a payload well inside MAX_CONTENT_BYTES (512 KiB).

Fix options:

  • Bound the number of fixed-point iterations (e.g. cap at a small constant, or proportional to nesting depth actually observed) and bail out to conservative full-escaping if the cap is hit.
  • Replace the repeated full-string rescans with a single properly nested tag-boundary scan (track open < and dangerous-tag state without needing multiple whole-string passes).
  • Move enforce_content_limits (byte truncation) to run before neutralize_dangerous_html, and additionally impose a stricter pre-HTML-stripping length cap tuned to the O(n2) cost, so a single MCP call can't spend unbounded CPU regardless of the 512 KiB ceiling.

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.

[agent-issue]: create-work-item hardcodes System.Description, so Bug work items are filed blank

2 participants