Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
0897472
feat(risk): normalize risk_result rule_id + description across scanners
mfbx9da4 May 15, 2026
8290274
chore(sdk): regenerate SDK with normalized RiskResult docs
mfbx9da4 May 15, 2026
cbb856a
refactor(risk): collapse shadow_mcp deny reasons into single rule_id
mfbx9da4 May 15, 2026
65a5f4d
refactor(risk): category-prefix rule ids (secret./pii./destructive./pi.)
mfbx9da4 May 15, 2026
8ea62d0
refactor(risk): bake canonical cli rule id into FullName
mfbx9da4 May 15, 2026
0112c70
refactor(risk): rename pi rule id family to prompt-injection
mfbx9da4 May 15, 2026
9c21c84
refactor(risk): align policy-data.ts ids with canonical rule_ids
mfbx9da4 May 15, 2026
17cead1
feat(risk): validate canonical rule_id format in dev/test
mfbx9da4 May 15, 2026
2c32646
feat(risk): collapse prompt-injection to single rule_id, engine via f…
mfbx9da4 May 15, 2026
6865465
refactor(risk): flip prompt-injection default to regex; deberta is op…
mfbx9da4 May 15, 2026
1d19332
feat(risk): guard rule_id format at the risk_results write boundary
mfbx9da4 May 15, 2026
e85043c
refactor(dashboard): remove deberta classifier from policy form; gate…
mfbx9da4 May 15, 2026
6aba382
refactor(dashboard): drop canonicalizeRuleId + prompt_attacks category
mfbx9da4 May 15, 2026
2e7a52e
feat(dashboard): humanizeRuleId uppercases known acronyms
mfbx9da4 May 15, 2026
df74fa1
feat(dashboard): humanizeRuleId splits on underscore + slash for lega…
mfbx9da4 May 15, 2026
f05e778
refactor(risk): replace RuleContext + Normalize with per-source Descr…
mfbx9da4 May 15, 2026
7e2e9ab
Merge remote-tracking branch 'origin/main' into da/normalize-risk-res…
mfbx9da4 May 15, 2026
eb7f7aa
refactor(risk): revert verbose RiskResult docstrings; guardRuleIDs is…
mfbx9da4 May 15, 2026
68f1c0d
chore: add changeset for risk_result rule_id normalization
mfbx9da4 May 15, 2026
3a89b72
fix(risk): restore additive L0+L1 prompt-injection behavior
mfbx9da4 May 15, 2026
3f8a3e8
chore(changeset): rewrite for API-consumer audience
mfbx9da4 May 15, 2026
74e3a2b
refactor(risk): move Describe* functions to their per-source modules
mfbx9da4 May 15, 2026
243b339
feat(dashboard): show category prefix on risk-finding badge
mfbx9da4 May 15, 2026
6449649
feat(risk/presidio): filter IPv6 unspecified address from IP findings
mfbx9da4 May 15, 2026
70a5717
chore: add outbox.AppendBatch for adding multiple outbox entries (#2870)
disintegrator May 15, 2026
2e7aa9c
chore: write risk result findings to outbox
disintegrator May 15, 2026
dd52f1b
chore(hooks): render dev-test plugin via the prod generator
mfbx9da4 May 16, 2026
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
20 changes: 20 additions & 0 deletions .changeset/normalize-risk-result-rule-ids.md
Original file line number Diff line number Diff line change
@@ -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.<rule>` for credentials and secrets (e.g. `secret.anthropic-api-key`)
- `pii.<rule>` 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.<category>.<name>` 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.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
106 changes: 59 additions & 47 deletions .mise-tasks/hooks/test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,17 @@
#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 <slug>" 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

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
Expand All @@ -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
3 changes: 2 additions & 1 deletion client/dashboard/src/pages/chatLogs/ChatDetailPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -733,7 +734,7 @@ function RiskBadgePopover({ results }: { results: RiskResult[] }) {
<div key={r.id} className="py-2 first:pt-0 last:pb-0">
<div className="flex items-center gap-2">
<Badge variant="destructive" className="shrink-0 text-[10px]">
{r.source}
{ruleIdCategoryLabel(r.ruleId) || r.source.toUpperCase()}
</Badge>
{r.ruleId && (
<span className="text-muted-foreground truncate font-mono text-xs">
Expand Down
Loading
Loading