---
name: Force ServiceNow Comments to Internal Work Notes
tags:
  - servicenow
  - force-internal-comments
  - comments
  - work-notes
  - ingress
  - soc2
  - finra
publishedAt: 2026-07-12
description: |
  # servicenow / force-internal-comments

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

  ```jsonc
  {
    "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)

  ```jsonc
  {
    "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)

  ```jsonc
  {
    "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.
direction: ingress
apps:
  - servicenow
industries: []
bundles:
  - soc2
experimental: true
schemaVersion: 1.0.0
minimumGatewayVersion: 1.0.0b24
---

```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
}
```
