Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/risk-results-for-agent.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"server": minor
"dashboard": minor
---

Adds `risk.results.listForAgent` — a redacted variant of `risk.results.list` for AI assistant / MCP consumption. The new endpoint returns the same fields as `listRiskResults` but replaces the `match` field with `match_redacted`, an opaque token of the form `<redacted len=N sha=XXXXXXXX>` where `N` is the byte length and `XXXXXXXX` is the first 8 hex characters of `sha256(match)`. Identical secrets produce identical fingerprints so agents can dedupe leak counts without ever seeing secret content.

`shadow_mcp` findings pass `match` through verbatim because the value is a server URL or stdio command identifier (already shown unmasked in the dashboard), and exact byte positions are coarsened to a single `position_known` boolean to remove reconstruction signals.

The dashboard's AI Insights sidebar gains risk-aware suggestions on the Security Overview and Policy Center pages, plus a system-prompt rule that bars the assistant from echoing `match_redacted` values verbatim.
254 changes: 254 additions & 0 deletions .speakeasy/out.openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19850,6 +19850,176 @@ paths:
x-speakeasy-name-override: list
x-speakeasy-react-hook:
name: RiskListResults
/rpc/risk.results.listForAgent:
get:
description: List risk analysis results with the `match` field redacted to an opaque length+sha256-prefix fingerprint. Matches the payload and pagination semantics of listRiskResults. Designed for AI assistant / MCP consumption so secret content (gitleaks captures, presidio entities, prompt-injection payloads) never reaches the model context. For shadow_mcp findings the `match` value — a non-sensitive server URL or command identifier — is passed through verbatim.
operationId: listRiskResultsForAgent
parameters:
- allowEmptyValue: true
description: Optional policy ID to filter by.
in: query
name: policy_id
schema:
description: Optional policy ID to filter by.
format: uuid
type: string
- allowEmptyValue: true
description: Optional chat ID to filter by.
in: query
name: chat_id
schema:
description: Optional chat ID to filter by.
format: uuid
type: string
- allowEmptyValue: true
description: Optional rule category key to filter by (e.g. secrets, pii, financial).
in: query
name: category
schema:
description: Optional rule category key to filter by (e.g. secrets, pii, financial).
type: string
- allowEmptyValue: true
description: Optional rule identifier substring to filter by (case-insensitive, e.g. 'secret' matches all 'secret.*' rules).
in: query
name: rule_id
schema:
description: Optional rule identifier substring to filter by (case-insensitive, e.g. 'secret' matches all 'secret.*' rules).
type: string
- allowEmptyValue: true
description: If true, collapse results to one row per (policy_id, rule_id, match), keeping the most recent occurrence. Useful when the same secret is detected many times within a single message body.
in: query
name: unique_match
schema:
description: If true, collapse results to one row per (policy_id, rule_id, match), keeping the most recent occurrence. Useful when the same secret is detected many times within a single message body.
type: boolean
- allowEmptyValue: true
description: Filter results to messages created at or after this timestamp (ISO 8601).
in: query
name: from
schema:
description: Filter results to messages created at or after this timestamp (ISO 8601).
format: date-time
type: string
- allowEmptyValue: true
description: Filter results to messages created strictly before this timestamp (ISO 8601).
in: query
name: to
schema:
description: Filter results to messages created strictly before this timestamp (ISO 8601).
format: date-time
type: string
- allowEmptyValue: true
description: Cursor to fetch the next page of results.
in: query
name: cursor
schema:
description: Cursor to fetch the next page of results.
type: string
- allowEmptyValue: true
description: Maximum number of results to return per page.
in: query
name: limit
schema:
description: Maximum number of results to return per page.
format: int64
maximum: 200
minimum: 1
type: integer
- allowEmptyValue: true
description: API Key header
in: header
name: Gram-Key
schema:
description: API Key header
type: string
- allowEmptyValue: true
description: Session header
in: header
name: Gram-Session
schema:
description: Session header
type: string
- allowEmptyValue: true
description: project header
in: header
name: Gram-Project
schema:
description: project header
type: string
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/ListRiskResultsForAgentResult'
description: OK response.
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
description: 'bad_request: request is invalid'
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
description: 'unauthorized: unauthorized access'
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
description: 'forbidden: permission denied'
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
description: 'not_found: resource not found'
"409":
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
description: 'conflict: resource already exists'
"415":
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
description: 'unsupported_media: unsupported media type'
"422":
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
description: 'invalid: request contains one or more invalidation fields'
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
description: 'unexpected: an unexpected error occurred'
"502":
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
description: 'gateway_error: an unexpected error occurred'
security:
- apikey_header_Gram-Key: []
project_slug_header_Gram-Project: []
- project_slug_header_Gram-Project: []
session_header_Gram-Session: []
- {}
summary: listRiskResultsForAgent risk
tags:
- risk
x-speakeasy-group: risk.results
x-speakeasy-name-override: listForAgent
x-speakeasy-react-hook:
name: RiskListResultsForAgent
/rpc/slack-apps.configure:
post:
description: Store Slack credentials (client ID, client secret, signing secret) for an app.
Expand Down Expand Up @@ -32383,6 +32553,24 @@ components:
description: Cursor for the next page of results.
required:
- chats
ListRiskResultsForAgentResult:
type: object
properties:
next_cursor:
type: string
description: Cursor for the next page of results.
results:
type: array
items:
$ref: '#/components/schemas/RiskResultRedacted'
description: The list of risk results with match content redacted to opaque fingerprints.
total_count:
type: integer
description: Total number of findings across all enabled policies.
format: int64
required:
- results
- total_count
ListRiskResultsResult:
type: object
properties:
Expand Down Expand Up @@ -35347,6 +35535,72 @@ components:
- chat_message_id
- source
- created_at
RiskResultRedacted:
type: object
properties:
chat_id:
type: string
description: The chat session containing the message.
format: uuid
chat_message_id:
type: string
description: The chat message that was scanned.
format: uuid
chat_title:
type: string
description: Title of the chat session.
confidence:
type: number
description: Confidence score for this finding.
format: double
created_at:
type: string
description: When this result was created.
format: date-time
description:
type: string
description: Human-readable description of the finding.
id:
type: string
description: The result ID.
format: uuid
match_redacted:
type: string
description: Opaque fingerprint of the original match in the form `<redacted len=N sha=XXXXXXXX>` where N is the byte length of the original match and XXXXXXXX is the first 8 hex characters of sha256(match). For shadow_mcp findings the original match value (a non-sensitive server URL or command identifier) is passed through verbatim.
policy_id:
type: string
description: The risk policy ID.
format: uuid
policy_version:
type: integer
description: Policy version when this result was produced.
format: int64
position_known:
type: boolean
description: Whether the original finding carried byte-position information within the source message. Exact positions are intentionally not exposed to avoid reconstruction attacks.
rule_id:
type: string
description: The matched rule identifier.
source:
type: string
description: Detection source (e.g. gitleaks, presidio, shadow_mcp).
tags:
type: array
items:
type: string
description: Tags from the detection rule.
user_id:
type: string
description: The user who owns the chat session.
required:
- id
- policy_id
- policy_version
- chat_message_id
- source
- created_at
- match_redacted
- position_known
RiskRuleBreakdownEntry:
type: object
properties:
Expand Down
10 changes: 6 additions & 4 deletions .speakeasy/workflow.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 8 additions & 2 deletions client/dashboard/src/components/insights-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,7 @@ export function InsightsProvider({
const sidebarWidth = `min(${SIDEBAR_MAX_WIDTH}px, ${SIDEBAR_MAX_PERCENT}vw)`;

// Build system prompt with optional context info.
const baseInstructions = `You are a helpful assistant for analyzing logs in Gram, an AI observability platform. Focus exclusively on log search and analysis.
const baseInstructions = `You are a helpful assistant for analyzing logs and security findings in Gram, an AI observability platform. Focus on log search, tool-call analysis, and risk/policy findings.

The current date is ${new Date().toISOString().split("T")[0]}.

Expand All @@ -298,7 +298,13 @@ When a user asks about logs for a specific user, tenant, customer, or entity:
2. Identify the most relevant attribute and filter on it (e.g. { path: "@user", operator: "eq", values: ["someone@example.com"] }).
3. If no relevant @-prefixed attributes exist, tell the user and fall back to text search instead.

MCP server vs. client breakdowns: \`gram.hook.source\` and \`gram.tool_call.source\` are complementary dimensions, not aliases. \`gram.hook.source\` identifies the agent/client that invoked Gram (e.g. "claude-code", "cursor") — use this for adoption / "who's using us" questions. \`gram.tool_call.source\` identifies the downstream MCP server that handled the call (e.g. "datadog-mcp", "linear") — use this for "top servers" / per-MCP usage questions. When asked about MCP server-level breakdowns, query BOTH dimensions: a server can appear in one and not the other depending on whether you're slicing by caller or callee.`;
MCP server vs. client breakdowns: \`gram.hook.source\` and \`gram.tool_call.source\` are complementary dimensions, not aliases. \`gram.hook.source\` identifies the agent/client that invoked Gram (e.g. "claude-code", "cursor") — use this for adoption / "who's using us" questions. \`gram.tool_call.source\` identifies the downstream MCP server that handled the call (e.g. "datadog-mcp", "linear") — use this for "top servers" / per-MCP usage questions. When asked about MCP server-level breakdowns, query BOTH dimensions: a server can appear in one and not the other depending on whether you're slicing by caller or callee.

Risk and policy findings:
- A risk policy scans chat messages for issues. Each \`source\` is a detector family: \`gitleaks\` (regex-based secret scanners — API keys, tokens), \`presidio\` (PII entities — emails, SSNs, credit cards), \`prompt_injection\` (heuristic + ML classifier for injection attempts), \`destructive_tool\` (tool calls matching destructive intent), \`shadow_mcp\` (calls to MCP servers not on an approved list).
- Use \`listRiskResultsForAgent\` for finding-level data — it returns the same shape as the dashboard's findings list but with the raw \`match\` field replaced by \`match_redacted\`: an opaque token of the form \`<redacted len=N sha=XXXXXXXX>\` for secret-bearing sources, or the literal server identifier for \`shadow_mcp\`. The \`sha\` prefix is deterministic, so two findings of the same secret share a fingerprint — that's how you dedupe leak counts across chats without seeing the secret.
- Use \`listRiskResultsByChat\` for chat-level rollups (findings_count, latest_detected), \`listRiskPolicies\` for the policy catalog, and \`getRiskPolicyStatus\` for analysis progress (pending vs analyzed counts, workflow state).
- HARD RULE — never quote or repeat a \`match_redacted\` value, never attempt to reconstruct a redacted secret, and never echo any string that looks like an API key, token, password, or PII. Refer to findings by their \`rule_id\` (e.g. "aws-access-key-id"), \`source\` family, and \`chat_id\`. \`shadow_mcp\` matches (server URLs / stdio commands) are safe to name verbatim.`;

const systemPrompt = contextInfo
? `${baseInstructions}
Expand Down
43 changes: 43 additions & 0 deletions client/dashboard/src/pages/security/PolicyCenter.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { InsightsConfig } from "@/components/insights-sidebar";
import { Page } from "@/components/page-layout";
import { RequireScope } from "@/components/require-scope";
import { Badge } from "@/components/ui/badge";
Expand Down Expand Up @@ -359,12 +360,54 @@ function PolicyCenterContent() {
);
}

const enabledPolicies = policies.filter((p) => p.enabled);
const insightsContext = [
"Page: Policy Center.",
`Total policies: ${policies.length}, enabled: ${enabledPolicies.length}.`,
`Policy actions: ${policies.map((p) => `${p.name} (${p.action})`).join(", ") || "none"}.`,
"Available risk tools: listRiskPolicies, getRiskPolicy, getRiskCapabilities, getRiskPolicyStatus, listRiskResultsForAgent (finding-level with match redaction), listRiskResultsByChat, listShadowMCPApprovals.",
"Never echo match_redacted values verbatim. Refer to findings by rule_id and source.",
].join(" ");

const insightsSuggestions = [
{
title: "Policy status snapshot",
label: "what's running and what's stuck",
prompt:
"For each policy returned by listRiskPolicies, call getRiskPolicyStatus and report: enabled flag, action (flag vs block), total messages, pending messages, and workflow state. Flag any policy with non-zero pending messages.",
},
{
title: "Quiet policies",
label: "policies with no recent findings",
prompt:
"Identify policies that have not produced any findings in the last 30 days. Use listRiskResultsForAgent with policy_id to check each policy. Report by name and last-seen finding date.",
},
{
title: "Coverage by source",
label: "what's each source catching",
prompt:
"Group findings by source (gitleaks, presidio, prompt_injection, shadow_mcp, destructive_tool) over the last 7 days using listRiskResultsForAgent. Report counts and the top rule_id per source family.",
},
{
title: "Capabilities check",
label: "what detectors are available",
prompt:
"Call getRiskCapabilities and tell me which detection backends are configured on this server (e.g. prompt-injection ML classifier).",
},
];

return (
<Page>
<Page.Header>
<Page.Header.Breadcrumbs />
</Page.Header>
<Page.Body>
<InsightsConfig
contextInfo={insightsContext}
suggestions={insightsSuggestions}
title="Policy insights"
subtitle="Ask about policy status, coverage, and detector capabilities. Match content is redacted before it reaches the assistant."
/>
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold">Risk Policies</h2>
Expand Down
Loading
Loading