diff --git a/.changeset/normalize-risk-result-rule-ids.md b/.changeset/normalize-risk-result-rule-ids.md new file mode 100644 index 0000000000..4f6ae3de4d --- /dev/null +++ b/.changeset/normalize-risk-result-rule-ids.md @@ -0,0 +1,20 @@ +--- +"server": minor +--- + +`RiskResult.rule_id` and `RiskResult.description` now follow a consistent shape across every detection source. + +`rule_id` is lowercase, kebab-case, with an optional dot-separated category prefix: + +- `secret.` for credentials and secrets (e.g. `secret.anthropic-api-key`) +- `pii.` for personal, financial, and medical data (e.g. `pii.credit-card`, `pii.medical-license`) +- `shadow-mcp` for unverified MCP tool calls +- `destructive.tool` for MCP tool calls flagged as destructive +- `destructive..` for destructive shell, git, database, and cloud commands (e.g. `destructive.shell.rm-rf`, `destructive.git.push-force`) +- `prompt-injection` for prompt injection findings + +`(source, rule_id)` is the stable identifier downstream consumers should match on. The dotted prefix alone is enough to bucket findings by risk category. + +`description` is a short human-readable sentence describing the finding. It never echoes the matched value and is safe to display verbatim. + +Historical rows written before this release keep their original `rule_id` and `description` values; a follow-up migration will rewrite them. diff --git a/.gitignore b/.gitignore index cdcfe55c1d..eb8fd83ebe 100644 --- a/.gitignore +++ b/.gitignore @@ -53,6 +53,7 @@ gram.code-workspace /server/cmd/local /server/custom-gcl /local/cmd +/hooks/.local/ dist/ **/.claude/settings.local.json **/.claude/.gram-install-prompted diff --git a/.mise-tasks/hooks/test.sh b/.mise-tasks/hooks/test.sh index 41fd7bf661..9563dd6784 100755 --- a/.mise-tasks/hooks/test.sh +++ b/.mise-tasks/hooks/test.sh @@ -3,8 +3,8 @@ #MISE description="Test the Gram hooks Claude plugin locally" #MISE dir="{{ config_root }}" -#USAGE flag "--local" help="Always use local plugin directory instead of published plugin" #USAGE flag "--project " help="Project slug for OTEL session validation (enables blocking)" default="ecommerce-api" +#USAGE flag "--local" help="Deprecated no-op; the plugin is always rendered locally now from the current branch's generator." set -euo pipefail @@ -12,8 +12,8 @@ export GRAM_HOOKS_SERVER_URL=$GRAM_SERVER_URL # Provision a dev API key for the chosen project so Claude's OTEL exporter can # authenticate against /rpc/hooks.otel and the server can validate the -# session. Without this, the hook's getSessionMetadata lookup misses and the -# risk scanner silently bails (no project to scope policies to). +# session, and so the hook script's Gram-Key header authenticates against the +# local server (the published plugin bakes in a prod key that 401s here). # # This is a local-dev shortcut — production keys go through /rpc/keys.create # with proper auth and audit logging. Inlined here so it's obvious it's a @@ -31,50 +31,62 @@ db_query() { project_row=$(db_query -v slug="$project_slug" <<<"SELECT id, organization_id FROM projects WHERE slug = :'slug' AND deleted IS FALSE LIMIT 1" 2>/dev/null || true) if [ -z "$project_row" ]; then - echo "Warning: project '${project_slug}' not found — blocking policies will be inert." -else - project_id="${project_row%%|*}" - org_id="${project_row##*|}" - - user_id=$(db_query <<<"SELECT id FROM users LIMIT 1" 2>/dev/null || true) - if [ -z "$user_id" ]; then - echo "Warning: no users in DB — skipping API key provisioning." - else - # Soft-delete any prior dev key for this project so we can stash a - # new plaintext we know. - db_query -v project_id="$project_id" >/dev/null <<<"UPDATE api_keys SET deleted_at = NOW() WHERE project_id = :'project_id' AND name = 'dev-hooks-test' AND deleted IS FALSE" - - token_hex=$(openssl rand -hex 32) - api_key="gram_local_${token_hex}" - key_prefix="gram_local_${token_hex:0:5}" - key_hash=$(printf '%s' "$api_key" | shasum -a 256 | awk '{print $1}') - - db_query \ - -v org_id="$org_id" \ - -v project_id="$project_id" \ - -v user_id="$user_id" \ - -v key_prefix="$key_prefix" \ - -v key_hash="$key_hash" \ - >/dev/null <<<"INSERT INTO api_keys (organization_id, project_id, created_by_user_id, name, key_prefix, key_hash, scopes) VALUES (:'org_id', :'project_id', :'user_id', 'dev-hooks-test', :'key_prefix', :'key_hash', '{hooks}')" - - export CLAUDE_CODE_ENABLE_TELEMETRY=1 - export OTEL_LOGS_EXPORTER=otlp - export OTEL_METRICS_EXPORTER=otlp - export OTEL_EXPORTER_OTLP_PROTOCOL=http/json - export OTEL_EXPORTER_OTLP_LOGS_ENDPOINT="${GRAM_SERVER_URL}/rpc/hooks.otel/v1/logs" - export OTEL_EXPORTER_OTLP_METRICS_ENDPOINT="${GRAM_SERVER_URL}/rpc/hooks.otel/v1/metrics" - export OTEL_EXPORTER_OTLP_HEADERS="Gram-Key=${api_key},Gram-Project=${project_slug}" - echo "OTEL configured (key: ${api_key:0:20}...)" - fi + echo "Error: project '${project_slug}' not found — cannot provision dev key." >&2 + exit 1 fi -echo "" -if [ "${usage_local:-}" = "true" ] || ! git diff --quiet HEAD -- hooks/; then - echo "Using local plugin directory: ./hooks/plugin-claude-test" - echo "" - exec claude --plugin-dir ./hooks/plugin-claude-test --debug -else - echo "No local changes in hooks/ — using published plugin" - echo "" - exec claude --debug +project_id="${project_row%%|*}" +org_id="${project_row##*|}" + +user_id=$(db_query <<<"SELECT id FROM users LIMIT 1" 2>/dev/null || true) +if [ -z "$user_id" ]; then + echo "Error: no users in DB — cannot provision dev key." >&2 + exit 1 fi + +# Soft-delete any prior dev key for this project so we can stash a new +# plaintext we know. +db_query -v project_id="$project_id" >/dev/null <<<"UPDATE api_keys SET deleted_at = NOW() WHERE project_id = :'project_id' AND name = 'dev-hooks-test' AND deleted IS FALSE" + +token_hex=$(openssl rand -hex 32) +api_key="gram_local_${token_hex}" +key_prefix="gram_local_${token_hex:0:5}" +key_hash=$(printf '%s' "$api_key" | shasum -a 256 | awk '{print $1}') + +db_query \ + -v org_id="$org_id" \ + -v project_id="$project_id" \ + -v user_id="$user_id" \ + -v key_prefix="$key_prefix" \ + -v key_hash="$key_hash" \ + >/dev/null <<<"INSERT INTO api_keys (organization_id, project_id, created_by_user_id, name, key_prefix, key_hash, scopes) VALUES (:'org_id', :'project_id', :'user_id', 'dev-hooks-test', :'key_prefix', :'key_hash', '{hooks}')" + +export CLAUDE_CODE_ENABLE_TELEMETRY=1 +export OTEL_LOGS_EXPORTER=otlp +export OTEL_METRICS_EXPORTER=otlp +export OTEL_EXPORTER_OTLP_PROTOCOL=http/json +export OTEL_EXPORTER_OTLP_LOGS_ENDPOINT="${GRAM_SERVER_URL}/rpc/hooks.otel/v1/logs" +export OTEL_EXPORTER_OTLP_METRICS_ENDPOINT="${GRAM_SERVER_URL}/rpc/hooks.otel/v1/metrics" +export OTEL_EXPORTER_OTLP_HEADERS="Gram-Key=${api_key},Gram-Project=${project_slug}" +echo "OTEL configured (key: ${api_key:0:20}...)" +echo "" + +# Render the observability plugin using the same generator the publish flow +# uses (server/internal/plugins.GenerateObservabilityPluginPackage), so the +# test harness exercises the real templated hook.sh — no hand-maintained +# stub to drift from prod. +plugin_dir="hooks/.local/plugin-claude" +rm -rf "$plugin_dir" +mkdir -p "$plugin_dir" + +echo "Rendering plugin into ${plugin_dir}..." +go run ./server/cmd/dev-observability-plugin \ + --out "$plugin_dir" \ + --platform claude \ + --api-key "$api_key" \ + --project-slug "$project_slug" \ + --server-url "$GRAM_SERVER_URL" \ + --org-name "Gram Local" +echo "" + +exec claude --plugin-dir "$plugin_dir" --debug diff --git a/client/dashboard/src/pages/chatLogs/ChatDetailPanel.tsx b/client/dashboard/src/pages/chatLogs/ChatDetailPanel.tsx index 269f34b44d..5de810cc91 100644 --- a/client/dashboard/src/pages/chatLogs/ChatDetailPanel.tsx +++ b/client/dashboard/src/pages/chatLogs/ChatDetailPanel.tsx @@ -8,6 +8,7 @@ import { AccordionTrigger, } from "@/components/ui/accordion"; import { CodeBlock } from "@/components/ui/code-block"; +import { ruleIdCategoryLabel } from "@/pages/security/rule-ids"; import type { ChatMessage, ChatResolution, @@ -733,7 +734,7 @@ function RiskBadgePopover({ results }: { results: RiskResult[] }) {
- {r.source} + {ruleIdCategoryLabel(r.ruleId) || r.source.toUpperCase()} {r.ruleId && ( diff --git a/client/dashboard/src/pages/security/PolicyCenter.tsx b/client/dashboard/src/pages/security/PolicyCenter.tsx index 6469c3eb7a..1c39f1a400 100644 --- a/client/dashboard/src/pages/security/PolicyCenter.tsx +++ b/client/dashboard/src/pages/security/PolicyCenter.tsx @@ -49,7 +49,6 @@ import { useRiskPoliciesUpdateMutation, useRiskPoliciesDeleteMutation, useRiskPoliciesTriggerMutation, - useRiskCapabilities, invalidateAllRiskListPolicies, } from "@gram/client/react-query/index.js"; import { @@ -64,6 +63,7 @@ import { type PolicyAction, } from "./policy-data"; import { cn } from "@/lib/utils"; +import { ruleIdToPresidioEntity } from "./rule-ids"; /** Presidio-backed categories */ const PRESIDIO_CATEGORIES: RuleCategory[] = [ @@ -88,12 +88,15 @@ const ALL_CATEGORIES: RuleCategory[] = [ ...PRESIDIO_CATEGORIES, "shadow_mcp", "destructive_tool", - "prompt_attacks", "prompt_injection", "off_policy", ]; -/** Derive selected categories from a policy's sources + presidioEntities. */ +/** Derive selected categories from a policy's sources + presidioEntities. + * + * DETECTION_RULES.id is the canonical `pii.` form; the wire format + * stored on the policy is the UPPER_SNAKE entity name Presidio speaks. We + * translate at this boundary so callers never see the wire format. */ function policyToCategories( sources: string[], presidioEntities?: string[], @@ -104,8 +107,10 @@ function policyToCategories( if (sources.includes("destructive_tool")) cats.add("destructive_tool"); if (sources.includes("prompt_injection")) cats.add("prompt_injection"); for (const cat of PRESIDIO_CATEGORIES) { - const catEntityIds = DETECTION_RULES[cat].map((r) => r.id); - if (catEntityIds.some((id) => presidioEntities?.includes(id))) { + const wireEntities = DETECTION_RULES[cat].map((r) => + ruleIdToPresidioEntity(r.id), + ); + if (wireEntities.some((id) => presidioEntities?.includes(id))) { cats.add(cat); } } @@ -113,32 +118,24 @@ function policyToCategories( } /** Derive sources, presidioEntities, and promptInjectionRules from selected - * categories and per-category rule selections. promptInjectionRules is the - * subset of rule ids the user has ticked under the prompt_injection category; - * the source itself is enabled by the category-level checkbox (heuristics are - * the always-on baseline). */ -function categoriesToPayload( - cats: Set, - promptInjectionRuleSelection: Set, -) { + * categories. Prompt-injection is a single category-level toggle; the + * detection engine (deberta classifier vs L0 regex) is chosen per-org via + * a feature flag, not by the policy author. promptInjectionRules is left + * empty here for backward compatibility with the policy schema. + * + * `presidioEntities` is translated to UPPER_SNAKE for Presidio's HTTP API. */ +function categoriesToPayload(cats: Set) { const sources: string[] = []; const presidioEntities: string[] = []; const promptInjectionRules: string[] = []; if (cats.has("secrets")) sources.push("gitleaks"); if (cats.has("shadow_mcp")) sources.push("shadow_mcp"); if (cats.has("destructive_tool")) sources.push("destructive_tool"); - if (cats.has("prompt_injection")) { - sources.push("prompt_injection"); - for (const rule of DETECTION_RULES.prompt_injection) { - if (promptInjectionRuleSelection.has(rule.id)) { - promptInjectionRules.push(rule.id); - } - } - } + if (cats.has("prompt_injection")) sources.push("prompt_injection"); for (const cat of PRESIDIO_CATEGORIES) { if (cats.has(cat)) { for (const rule of DETECTION_RULES[cat]) { - presidioEntities.push(rule.id); + presidioEntities.push(ruleIdToPresidioEntity(rule.id)); } } } @@ -165,10 +162,7 @@ export default function PolicyCenter() { function PolicyCenterContent() { const queryClient = useQueryClient(); const { data, isLoading } = useRiskListPolicies(); - const { data: riskCapabilities, isLoading: isCapabilitiesLoading } = - useRiskCapabilities(); const policies = data?.policies ?? []; - const piClassifierEnabled = riskCapabilities?.piClassifierEnabled === true; const [sheetOpen, setSheetOpen] = useState(false); const [editingPolicy, setEditingPolicy] = useState(null); @@ -180,9 +174,6 @@ function PolicyCenterContent() { const [formAction, setFormAction] = useState("flag"); const [formAutoName, setFormAutoName] = useState(true); const [formUserMessage, setFormUserMessage] = useState(""); - const [formPromptInjectionRules, setFormPromptInjectionRules] = useState< - Set - >(new Set()); const [runPanelPolicy, setRunPanelPolicy] = useState(null); @@ -221,7 +212,6 @@ function PolicyCenterContent() { setFormAction("flag"); setFormAutoName(true); setFormUserMessage(""); - setFormPromptInjectionRules(new Set()); setSheetOpen(true); }; @@ -235,15 +225,12 @@ function PolicyCenterContent() { setFormAction((policy.action as PolicyAction) ?? "flag"); setFormAutoName(policy.autoName ?? true); setFormUserMessage(policy.userMessage ?? ""); - setFormPromptInjectionRules( - new Set(policy.promptInjectionRules ?? []), - ); setSheetOpen(true); }; const handleSave = () => { const { sources, presidioEntities, promptInjectionRules } = - categoriesToPayload(selectedCategories, formPromptInjectionRules); + categoriesToPayload(selectedCategories); const action = sources.includes("destructive_tool") && formAction === "block" ? "flag" @@ -304,7 +291,7 @@ function PolicyCenterContent() { }); }; - if (isLoading || isCapabilitiesLoading) { + if (isLoading) { return ( @@ -342,7 +329,6 @@ function PolicyCenterContent() { const { sources, presidioEntities, promptInjectionRules } = categoriesToPayload( new Set(["secrets", "pii"]), - new Set(), ); createMutation.mutate({ request: { @@ -511,9 +497,6 @@ function PolicyCenterContent() { setFormAutoName={setFormAutoName} formUserMessage={formUserMessage} setFormUserMessage={setFormUserMessage} - formPromptInjectionRules={formPromptInjectionRules} - setFormPromptInjectionRules={setFormPromptInjectionRules} - piClassifierEnabled={piClassifierEnabled} />
@@ -579,9 +562,6 @@ function PolicySheetBody({ setFormAutoName, formUserMessage, setFormUserMessage, - formPromptInjectionRules, - setFormPromptInjectionRules, - piClassifierEnabled, }: { formName: string; setFormName: (v: string) => void; @@ -595,9 +575,6 @@ function PolicySheetBody({ setFormAutoName: (v: boolean) => void; formUserMessage: string; setFormUserMessage: (v: string) => void; - formPromptInjectionRules: Set; - setFormPromptInjectionRules: (v: Set) => void; - piClassifierEnabled: boolean; }) { const [expandedCategory, setExpandedCategory] = useState( null, @@ -713,71 +690,29 @@ function PolicySheetBody({ />
- {/* Expanded rules list */} + {/* Expanded rules list — category-level toggle is the only + user-facing control; sub-rules ride along with it. */} {isAvailable && isExpanded && rules.length > 0 && (
- {rules.map((rule) => { - // Only the prompt_injection category supports per-rule - // selection today; heuristics are the always-on - // baseline and the listed rules are opt-in augments. - // Other categories continue to bundle all rules under - // the category-level checkbox. - const interactive = cat === "prompt_injection"; - const checked = interactive - ? selectedCategories.has(cat) && - formPromptInjectionRules.has(rule.id) - : selectedCategories.has(cat); - const isClassifierRule = - rule.id === "deberta-v3-classifier"; - const isRuleAvailable = - !isClassifierRule || piClassifierEnabled; - return ( -
( +
+ +
- ); - })} + {rule.title} + +
+ ))}
)} @@ -841,21 +776,24 @@ function PolicySheetBody({ - {/* Custom message */} -
- -

- {formAction === "block" - ? "Shown to the user when this policy blocks a tool call or prompt. Leave blank to use the default message." - : "Shown alongside flagged findings in the dashboard. Leave blank to use the default message."} -

-