> ## Documentation Index
> Fetch the complete documentation index at: https://docs.endorlabs.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Triage policy violations

> Read the summary breakdowns and the per-event table to confirm enforcement is working and investigate any policy match.

export const YamlTable = ({children, data: propData, content}) => {
  const KV_RE = /^([A-Za-z][A-Za-z0-9_()/#\s-]+?):\s*(.+)$/;
  const INLINE_MD_RE = /(\[([^\]]+)\]\(([^)]+)\))|(`([^`]+)`)|(\*\*([^*]+)\*\*)|(\*([^*]+)\*)/g;
  const YES_RE = /^-yes-$/i;
  const NO_RE = /^-no-$/i;
  const LIMITED_RE = /^-(limited|partial)-$/i;
  const NA_RE = /^-(na|none)-$/i;
  const NA2_RE = /^-na2-$/i;
  const SIMPLE_TAG_RE = /(<br\s*\/?>)|(<p\s*\/?>)|(-note-)|(-warning-)/gi;
  const tryParseKV = trimmed => {
    const m = KV_RE.exec(trimmed);
    return m ? {
      key: m[1],
      value: m[2].trim()
    } : null;
  };
  const registerKey = (key, seenKeys, orderedKeys) => {
    if (!seenKeys.has(key)) {
      orderedKeys.push(key);
      seenKeys.add(key);
    }
  };
  const flushEntry = (currentEntry, entries) => {
    if (Object.keys(currentEntry).length > 0) entries.push(currentEntry);
  };
  const parseDashPrefixed = (lines, entries, orderedKeys, seenKeys) => {
    let currentEntry = {};
    let inEntry = false;
    for (const line of lines) {
      const trimmed = line.trim();
      if (trimmed.startsWith('- ')) {
        if (inEntry) entries.push(currentEntry);
        currentEntry = {};
        inEntry = true;
        const kv = tryParseKV(trimmed.substring(2).trim());
        if (kv) {
          registerKey(kv.key, seenKeys, orderedKeys);
          currentEntry[kv.key] = kv.value;
        }
      } else if (inEntry && trimmed !== '') {
        const kv = tryParseKV(trimmed);
        if (kv) {
          registerKey(kv.key, seenKeys, orderedKeys);
          currentEntry[kv.key] = kv.value;
        }
      }
    }
    flushEntry(currentEntry, entries);
  };
  const parseBlankSeparated = (lines, entries, orderedKeys, seenKeys) => {
    let currentEntry = {};
    let inEntry = false;
    for (const line of lines) {
      const trimmed = line.trim();
      if (trimmed === '') {
        if (inEntry) {
          flushEntry(currentEntry, entries);
          currentEntry = {};
          inEntry = false;
        }
        continue;
      }
      const kv = tryParseKV(trimmed);
      if (!kv) continue;
      const isNewEntry = !line.startsWith(' ') && !line.startsWith('\t');
      if (isNewEntry && inEntry && Object.keys(currentEntry).length > 0) {
        entries.push(currentEntry);
        currentEntry = {};
      }
      registerKey(kv.key, seenKeys, orderedKeys);
      currentEntry[kv.key] = kv.value;
      inEntry = true;
    }
    flushEntry(currentEntry, entries);
  };
  const normalizeEntries = (entries, orderedKeys) => entries.map(entry => {
    const filled = {};
    for (const key of orderedKeys) filled[key] = entry[key] || '';
    return filled;
  });
  const parseYamlTableContent = contentStr => {
    if (!contentStr) return [];
    const entries = [];
    const orderedKeys = [];
    const seenKeys = new Set();
    const lines = contentStr.split('\n');
    if (lines.some(line => line.trim().startsWith('- '))) {
      parseDashPrefixed(lines, entries, orderedKeys, seenKeys);
    } else {
      parseBlankSeparated(lines, entries, orderedKeys, seenKeys);
    }
    return normalizeEntries(entries, orderedKeys);
  };
  const processText = text => {
    if (!text) return text;
    const parts = [];
    let keyIndex = 0;
    let lastIndex = 0;
    let match;
    while ((match = INLINE_MD_RE.exec(text)) !== null) {
      if (match.index > lastIndex) parts.push(text.slice(lastIndex, match.index));
      if (match[1]) {
        parts.push(<a key={keyIndex++} href={match[3]}>{match[2]}</a>);
      } else if (match[4]) {
        parts.push(<code key={keyIndex++}>{match[5]}</code>);
      } else if (match[6]) {
        parts.push(<strong key={keyIndex++}>{match[7]}</strong>);
      } else if (match[8]) {
        parts.push(<em key={keyIndex++}>{match[9]}</em>);
      }
      lastIndex = match.index + match[0].length;
    }
    if (lastIndex < text.length) parts.push(text.slice(lastIndex));
    if (parts.length === 0) return text;
    const keyRef = {
      current: keyIndex
    };
    return expandHtmlTags(parts, keyRef);
  };
  const processBadges = text => {
    if (!text || typeof text !== 'string') return text;
    if (YES_RE.test(text)) return <span className="yt-badge-yes" role="img" aria-label="Supported" title="Supported">✓</span>;
    if (NO_RE.test(text)) return <span className="yt-badge-no" role="img" aria-label="Not supported" title="Not supported">✗</span>;
    if (LIMITED_RE.test(text)) return <span className="yt-badge-limited" role="img" aria-label="Partially supported" title="Partially supported">◐</span>;
    if (NA_RE.test(text) || NA2_RE.test(text)) return <span className="yt-sr-only" title="Not applicable">Not applicable</span>;
    return processText(text);
  };
  const cellClassName = text => {
    if (!text || typeof text !== 'string') return undefined;
    if (NA_RE.test(text)) return 'yt-cell-na';
    if (NA2_RE.test(text)) return 'yt-cell-na2';
    return undefined;
  };
  const expandSimpleTags = (str, keyRef) => {
    const result = [];
    let last = 0;
    SIMPLE_TAG_RE.lastIndex = 0;
    let m;
    while ((m = SIMPLE_TAG_RE.exec(str)) !== null) {
      if (m.index > last) result.push(str.slice(last, m.index));
      if (m[1]) {
        result.push(<br key={keyRef.current++} />);
      } else if (m[2]) {
        result.push(<br key={keyRef.current++} />, <br key={keyRef.current++} />);
      } else if (m[3]) {
        result.push(<span key={keyRef.current++} className="yt-badge-note" style={{
          fontWeight: 600
        }}>Note: </span>);
      } else if (m[4]) {
        result.push(<span key={keyRef.current++} className="yt-badge-warning" style={{
          fontWeight: 600
        }}>Warning: </span>);
      }
      last = m.index + m[0].length;
    }
    if (last < str.length) result.push(str.slice(last));
    return result;
  };
  const expandHtmlTags = (chunks, keyRef) => {
    const out = [];
    for (const chunk of chunks) {
      if (typeof chunk === 'string') {
        out.push(...expandSimpleTags(chunk, keyRef));
      } else {
        out.push(chunk);
      }
    }
    return out;
  };
  const extractText = node => {
    if (node === null || node === undefined) return '';
    if (typeof node === 'string') return node;
    if (typeof node === 'number') return String(node);
    if (typeof node === 'boolean') return '';
    if (Array.isArray(node)) return node.map(extractText).join('');
    if (node && typeof node === 'object' && node.type) {
      const props = node.props || ({});
      if (typeof props.children === 'string') return props.children;
      if (props.children) return extractText(props.children);
      return '';
    }
    return String(node || '');
  };
  const [mounted, setMounted] = useState(false);
  useEffect(() => {
    setMounted(true);
  }, []);
  const data = useMemo(() => {
    if (propData) return propData;
    if (content && typeof content === 'string') return parseYamlTableContent(content);
    if (!children) return [];
    if (typeof children === 'string') return parseYamlTableContent(children);
    const childrenArray = Array.isArray(children) ? children : [children];
    return parseYamlTableContent(childrenArray.map(extractText).join('').trim());
  }, [children, propData, content]);
  const columns = useMemo(() => {
    if (!data || data.length === 0) return [];
    const firstRow = data[0];
    if (!firstRow || typeof firstRow !== 'object') return [];
    return Object.keys(firstRow);
  }, [data]);
  if (!mounted) return null;
  if (!data || data.length === 0) return null;
  const rowKey = row => columns.map(c => row[c] || '').join('|');
  return <table>
      <thead>
        <tr>
          {columns.map(col => <th key={col}>{col.replaceAll('_', ' ')}</th>)}
        </tr>
      </thead>
      <tbody>
        {data.map(row => <tr key={rowKey(row)}>
            {columns.map(col => <td key={col} className={cellClassName(row[col])}>{processBadges(row[col])}</td>)}
          </tr>)}
      </tbody>
    </table>;
};

**Policy Violations** records every event that matched a policy on a developer's machine. Use the page to confirm enforcement is working, investigate a single incident, and plan the next policies you write.

## Open Policy Violations

Select **Agent Governance** from the left sidebar, then select **Policy Violations**.

The page opens with three summaries above a per-event table. Use **Time Range** at the top to scope every summary and the table to the same window.

<img src="https://mintcdn.com/endorlabs-b4795f4f/dPDQPDyDLWVGxIZu/images/agent-governance/policy-violations.webp?fit=max&auto=format&n=dPDQPDyDLWVGxIZu&q=85&s=565229e23c88462a27137ff4764007bb" alt="Policy Violations" width="1200" height="900" data-path="images/agent-governance/policy-violations.webp" />

## Summary at a glance

Above the violation table, the page shows three summaries:

* **Top 5 Policies**: A ranked breakdown of the busiest match-category and agent combinations.
* **Blocked & Alerted**: A breakdown of how violations resolved, with the total in the center.
* **Violations This Week**: The seven-day count, a comparison against last week, and a per-day breakdown.

<Note>
  **Top 5 Policies** ranks match-category and agent combinations, not individual policy names. Read the table to find specific policies.
</Note>

## Filter results

Use the filters above the table to narrow the rows.

* **Time Range** scopes to the last hour, day, week, or a custom window.
* **Category** scopes to a single activity type: **Dangerous Command**, **File Access**, **MCP Tool Call**, **MCP Server**, **Skill**, **Session**, **Malware**, or **Secret**.
* **Agent** scopes to one agent: **Cursor**, **Claude Code**, **Codex**, or **GitHub Copilot**.

For example, filtering by **Category: File Access** narrows the view to file-operation policy matches:

<img src="https://mintcdn.com/endorlabs-b4795f4f/dPDQPDyDLWVGxIZu/images/agent-governance/policy-violations-file-access.webp?fit=max&auto=format&n=dPDQPDyDLWVGxIZu&q=85&s=e27658b22b8939813c991508a6458209" alt="Policy Violations filtered to File Access" width="1200" height="900" data-path="images/agent-governance/policy-violations-file-access.webp" />

## Read a violation

Each row begins with an icon for the action the policy applied. Hover the icon to see whether the action was **Blocked**, **Alert**, or **Ask**.

The following table describes the rest of the columns.

<YamlTable>
  {`
    - Column: **Policy Name**
    What it shows: The policy name, with the match category on a second line beneath it. The categories are the same set the **Category** filter offers, from **Dangerous Command** through **Secret**.
    - Column: **Details**
    What it shows: A code snippet of the matching command, file path, or MCP tool name. For MCP tool calls, the originating server name appears in a smaller line beneath.
    - Column: **Agent**
    What it shows: The agent that produced the event, for example **Cursor**, **Claude Code**, **Codex**, or **GitHub Copilot**. Events without an agent identifier show **Unknown Agent**.
    - Column: **User**
    What it shows: The developer who triggered the event, when the agent's hook payload includes a user identifier. Cursor supplies one on most events. Claude Code, Codex, and GitHub Copilot derive one from the local environment when they can. The value can still be empty.
    - Column: **Last Active**
    What it shows: When the event occurred.
    `}
</YamlTable>

## Common triage workflows

### Confirm a new policy is working

After you create a policy, ask one developer to perform an action that matches the policy. Set **Time Range** to the last hour and **Agent** to that developer's agent. Confirm the violation appears with the expected **Blocked**, **Alert**, or **Ask** action.

### Investigate a single incident

Set **Time Range** to the suspected window. Browse the rows for the developer's agent and user. Hover the **Details** cell to read the full command, file path, or MCP tool name.

### Spot patterns to write new policies

Filter on **Category** to find clusters of unsanctioned activity. For example, several **MCP Tool Call** rows from the same MCP server are a signal to add an **MCP Server Access** policy. A wide spread of **Dangerous Command** rows like the one below points to a missing **Command Execution** policy.

<img src="https://mintcdn.com/endorlabs-b4795f4f/dPDQPDyDLWVGxIZu/images/agent-governance/policy-violations-dangerous-commands.webp?fit=max&auto=format&n=dPDQPDyDLWVGxIZu&q=85&s=ad5e738cc6b66cdd1893c993242f8a6f" alt="Policy Violations filtered to Dangerous Command" width="1200" height="900" data-path="images/agent-governance/policy-violations-dangerous-commands.webp" />

## When no violations match

**Policy Violations** shows **No Policy Violations found** when nothing matched the current filters.

If you expected matches to appear, work through the following checks:

* Widen **Time Range** in case the filter window is too narrow.
* Check hook health on the developer machines you expect to govern. See [Deploy hooks for Cursor](/agent-governance/cursor) or [Deploy hooks for Claude Code](/agent-governance/claude-code).
* Confirm a policy exists for the activity you expected to match. See [Write a policy](/agent-governance/policies) to create one.

## Next steps

Continue with the following pages:

* See [Read the Coding Agent Governance overview](/agent-governance/overview) for how violations trend across your fleet.
* See [Write a policy](/agent-governance/policies) to add or refine patterns based on what you find.
