dtwo Policy Store

Force ServiceNow Comments to Internal Work Notes

Keeps agent-drafted ServiceNow comments off the customer/employee-visible journal by rewriting add comment calls to internal work notes.

Direction
ingress
Rego package
servicenow.ingress.force_internal_comments
App
servicenow
Bundle
soc2
Published
Minimum gateway
1.0.0b24
Schema version
1.0.0
Checksum
sha256:38a189825fffea11d7fd6921f02c6c47944f201426cac9d3a523c2ff0d8fd5c9

servicenowforce-internal-commentscommentswork-notesingresssoc2finra

What this policy does

Direction: ingress (tool_pre_invoke) Default: allow with transform (transform-only, no deny branch) Package: servicenow.ingress.force_internal_comments

What it does

Keeps agent-drafted ServiceNow comments off the customer/employee-visible journal by rewriting add_comment calls to internal work notes.

ServiceNow's add_comment tool takes an is_work_note boolean that defaults to false — a false (or omitted) value posts the text as an additional comment, which is visible to the end customer/employee on the record and in portal/notification emails. is_work_note: true posts the same text to the internal Work notes journal, visible only to fulfillers.

This policy:

  • Transforms add_comment to set is_work_note: true when the caller is not in the placeholder service-desk IdP group. The rewrite fires both when is_work_note is absent and when it is explicitly false (or any non-true value), so an agent cannot opt out of the internal-only posture simply by omitting the field or sending false.
  • Passes through unmodified for callers who are in the service-desk group — those users are expected to post customer-facing replies as part of their job.
  • Passes through unmodified any call that already sets is_work_note: true (nothing to fix) and every tool other than add_comment.

The check runs at ingress, before the call reaches the ServiceNow MCP server, so a would-be customer-visible comment is rewritten to an internal note before it is ever written to the record. This is a visibility control only — it never denies the comment, it only changes where the text lands.

Compliance alignment

  • SOC 2 C1.1 (Confidentiality) — supports maintaining and protecting confidential information on the MCP path: agent-drafted text is confined to the internal Work notes journal instead of the customer/employee-visible comment journal, so internal commentary is not published to a customer-facing channel unless it originates from a supervised service-desk role.
  • FINRA Rule 3110(a)/(b)(4), 3110.09 / 4511 — supports alignment with the supervision-and-retention-of-communications controls (Partial, MCP path) by keeping agent-generated text out of the customer-facing communication channel unless it originates from a supervised service-desk role; agent commentary is routed to the internal work-notes journal rather than being published to the customer as an unsupervised retail communication.
  • FINRA Rule 2210(b)(1) — supports alignment with the principal-pre-approval-of-retail-communications control (Partial, MCP path): by default an agent's text is confined to internal work notes, so it does not reach a customer as a retail communication without a supervised (service-desk) human in the loop.

Beyond the SOC 2 C1.1 confidentiality alignment above, this policy also supports the FINRA financial-services-communications controls (PF-26). FINRA has no bundle slug in this phase, so only the soc2 bundle tag is claimed.

Why ingress and not egress

Posting a comment is a write with an immediate, externally visible side effect — once add_comment reaches ServiceNow with is_work_note: false, the text is on the customer-visible journal and may already have been syndicated to notification emails. Egress redaction would only mask the response the agent sees, not the journal entry itself. Rewriting is_work_note at ingress, before the call executes, is the only placement that actually keeps the text off the customer-visible journal.

Tool name matching

Matches by suffix, case-insensitively:

  • *add_comment

add_comment is the verified tool name in both community servers that expose it — echelon-ai-labs/servicenow-mcp and michaelbuckner/servicenow-mcp (both verb_noun snake_case, no vendor prefix). The DTwo gateway prefixes tool names with the configured MCP server name (e.g. servicenow-mcp-add_comment), and that prefix is not standardized across deployments, so suffix matching keeps the policy portable. Verify the exact name your gateway sends with the dump-input debug technique before relying on this in production.

The suffix is tested against both input.resource.name (the canonical PARC field) and the legacy input.payload.name alias, each read through an object.get chain. Both are populated on tool hooks and carry the same value, so the second branch is defense-in-depth: it ensures that a call which arrived with an absent or empty resource.name still matches via payload.name rather than passing the comment through customer-visible (a fail-open leak on this visibility control), and it means a missing resource object cannot error the rule.

The official ServiceNow MCP Server (MCP Server Console) has no fixed tool inventory — tool names are instance-defined by the admin who publishes each skill/subflow/API. This policy does not attempt to match official-server CSM/case-comment tools; pair it with a per-tenant default-deny-unknown-tools policy on that server (see Composition).

Argument shape

Verified from echelon-ai-labs/servicenow-mcp (src/servicenow_mcp/tools/incident_tools.py):

  • add_comment: incident_id (req), comment (req), is_work_note (bool, default false → customer-visible journal entry).

All fields are read via object.get chains. is_work_note is treated as "needs rewrite" whenever it is not exactly the boolean true — an absent field, an explicit false, or any non-boolean value (e.g. the string "false") all resolve to a rewrite. The rewrite merges {"is_work_note": true} over the caller's args, so comment, incident_id, and any other supplied fields are preserved.

Examples

Transformed (non-service-desk caller, is_work_note omitted)

{
  "input": {
    "action": "tool_pre_invoke",
    "resource": { "name": "servicenow-mcp-add_comment", "type": "tool" },
    "subject": { "sub": "agent@example.com", "claims": { "groups": ["staff"] } },
    "payload": {
      "name": "servicenow-mcp-add_comment",
      "args": { "incident_id": "INC0010001", "comment": "Investigating now." }
    }
  }
}

allow = true; transform.transformed_payload becomes { "incident_id": "INC0010001", "comment": "Investigating now.", "is_work_note": true }.

Transformed (non-service-desk caller trying to opt out with is_work_note: false)

{
  "input": {
    "action": "tool_pre_invoke",
    "resource": { "name": "servicenow-mcp-add_comment", "type": "tool" },
    "subject": { "sub": "agent@example.com", "claims": { "groups": ["staff"] } },
    "payload": {
      "name": "servicenow-mcp-add_comment",
      "args": { "incident_id": "INC0010001", "comment": "hi", "is_work_note": false }
    }
  }
}

allow = true; is_work_note is forced from false to true.

Passed through (service-desk caller)

{
  "input": {
    "action": "tool_pre_invoke",
    "resource": { "name": "servicenow-mcp-add_comment", "type": "tool" },
    "subject": { "sub": "desk@example.com", "claims": { "groups": ["service-desk"] } },
    "payload": {
      "name": "servicenow-mcp-add_comment",
      "args": { "incident_id": "INC0010001", "comment": "Your issue is resolved." }
    }
  }
}

allow = true, no transform — a service-desk member may post a customer-visible reply.

Composition

This policy is single-purpose — it controls comment visibility only, not whether the caller may comment at all. Useful companions:

  • role-gate-writes (PF-12) — decides whether the caller may call add_comment (or any write) in the first place. This policy composes cleanly on top: role-gate-writes admits the write, force-internal-comments confines its visibility.
  • default-deny-unknown-tools (PF-28) — mandatory for the official ServiceNow MCP Server, whose CSM/case-comment tools are instance-named and not matched here.
  • A secrets/DLP ingress policy on the comment body if you also want to block credentials or PII from being written to the journal at all.

Known limitations

  • Group name is a placeholder — replace at import time. The exemption group ships as service-desk; replace it with your IdP's group name. Group names are placeholders — replace service-desk with your IdP's group name at import time. The groups claim is assumed to be an array of strings; if your IdP emits a single string or a namespaced claim, adapt is_service_desk. A caller with no groups claim (or no subject at all) is treated as not service-desk and is rewritten — the exemption fails closed toward the internal-only posture.
  • add_work_notes is left untouched — by design. michaelbuckner/servicenow-mcp exposes a separate add_work_notes tool that is already internal-only; it needs no rewrite and this policy does not match it.
  • update_incident and official-server CSM case tools are not covered. update_incident (echelon) can set work_notes and close_notes — those are internal journal fields, so no rewrite is needed there — but update_incident and the official ServiceNow MCP Server's CSM/case tools may expose other customer-visible fields (e.g. a customer-facing comments/additional comments field, case correspondence, or a public reply action) that this policy does not inspect or rewrite. If your deployment uses those surfaces, add companion policies for them; do not assume this single policy makes every ServiceNow write internal-only.
  • Generic / natural-language write tools are an uncovered escape hatch — pair with a default-deny policy. The michaelbuckner server ships natural_language_update (a write driven by free text, with no structured, inspectable is_work_note field diff) plus generic perform_query/update_script tools. An agent could post customer-visible comment text through natural_language_update (e.g. "add a comment to INC0010001 that the customer can see …") without ever invoking add_comment, and this policy would not intercept it — there is no boolean field to force. This is a residual bypass by design: a single-purpose visibility transform cannot safely rewrite an unstructured NL write. Do not deploy this policy on a server that exposes free-text/generic-write tools without also attaching default-deny-unknown-tools (PF-28) and/or a policy that denies natural_language_update/perform_query outright (see Composition).
  • Tool-name ordering divergence (noun_verb servers) is not matched. The suffix match add_comment covers the two verb_noun community servers (echelon, buckner) and their gateway-prefixed forms. Servers that order names noun_verb (e.g. LokiMCPUniverse's incident_create style) would expose a comment tool as something like comment_add, which endswith(…, "add_comment") does not catch. The landscape note does not verify that such a server actually ships a comment tool or an is_work_note-equivalent field, so — per the no-invented-tool-names rule — this policy does not add a speculative suffix. If your gateway front-ends a noun_verb server, confirm the real comment tool name with the dump-input debug technique and add its suffix to is_add_comment_call before relying on this policy there.
  • Non-boolean is_work_note values. A non-true value of any type triggers the rewrite (forced to true), which is the safe direction. A value that is already the boolean true is passed through untouched. A caller who supplies the flag under a differently-cased key (e.g. Is_Work_Note: false) does not suppress the rewrite: JSON keys are case-sensitive, so the lowercase is_work_note is absent, the rewrite fires, and object.union adds the canonical lowercase is_work_note: true (which the Python community servers read) alongside the caller's ignored mixed-case key.
  • Malformed (non-object) args are passed through unmodified. The rewrite only fires when args is a JSON object. If a caller sends args as a string or array, object.get(args, "is_work_note", …) is undefined, so needs_internal_rewrite never holds and no transform is applied — the malformed call passes through untouched. This is a residual fail-open, but a non-object args cannot carry a valid comment/incident_id through the echelon or michaelbuckner servers (both expect a dict), so such a call fails at the server rather than posting a customer-visible comment. If you want malformed writes rejected outright rather than passed through, pair this with role-gate-writes or a schema-validation ingress policy.
  • Visibility only, never a deny. This policy never blocks a comment; it only relocates the text to the internal journal. Pair it with role-gate-writes if some callers should not be able to comment at all.

Compliance note. This policy supports alignment with the cited framework controls on the MCP path only. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it.

Policy source (Rego)

package servicenow.ingress.force_internal_comments

# Transform-only policy: allow everything, and rewrite add_comment calls so
# agent-drafted text lands on the internal work-notes journal instead of the
# customer/employee-visible comment journal. Never denies.
default allow := true

# ---------------------------------------------------------------------------
# Configuration placeholder — replace at import time
# ---------------------------------------------------------------------------

# IdP group whose members may post customer-visible comments (their calls pass
# through unmodified). PLACEHOLDER: replace with your IdP's group name.
service_desk_group := "service-desk"

# ---------------------------------------------------------------------------
# Shared accessors — every possibly-missing field is read via object.get
# ---------------------------------------------------------------------------

args := object.get(object.get(input, "payload", {}), "args", {})

# add_comment tool. echelon-ai-labs and michaelbuckner both name it
# `add_comment` (verb_noun snake_case, no vendor prefix); the gateway prefixes
# the server name, so match by suffix for portability. Case-insensitive so a
# mixed-case tool name can't slip past. We match on resource.name OR the legacy
# payload.name alias (both are populated on tool hooks and carry the same value):
# a call that arrived with an absent/empty resource.name would otherwise miss the
# match and pass the comment through customer-visible — a fail-open leak. Reading
# both via object.get also means a missing `resource` object can't error the rule.
is_add_comment_call if {
    input.action == "tool_pre_invoke"
    endswith(lower(object.get(object.get(input, "resource", {}), "name", "")), "add_comment")
}

is_add_comment_call if {
    input.action == "tool_pre_invoke"
    endswith(lower(object.get(object.get(input, "payload", {}), "name", "")), "add_comment")
}

# True when the caller is in the service-desk exemption group. Missing claims /
# missing subject fail closed (no group -> not exempt -> comment is rewritten).
is_service_desk if {
    claims := object.get(object.get(input, "subject", {}), "claims", {})
    some g in object.get(claims, "groups", [])
    g == service_desk_group
}

# The comment is customer-visible unless is_work_note is exactly boolean true.
# Absent field (default false), explicit false, or any non-true value all mean
# the text would land on the visible journal and must be rewritten. An agent
# therefore cannot opt out by omitting is_work_note or sending false.
needs_internal_rewrite if {
    object.get(args, "is_work_note", false) != true
}

# ---------------------------------------------------------------------------
# Transform: force is_work_note := true for non-service-desk callers
# ---------------------------------------------------------------------------

transform := {"transformed_payload": object.union(args, {"is_work_note": true})} if {
    is_add_comment_call
    not is_service_desk
    needs_internal_rewrite
}

Canonical source: policy.md on GitHub · raw · raw on this site (.md)