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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
## [Unreleased]

### Changed
- Clean runtime events (`allow`, zero risk, safe level, no findings or failure) now remain local across DSH, Codex, and Claude Code instead of being uploaded to Cloud.
- Reportable DSH events now use a Cloud-only semantic summary and bounded status metadata; raw request/response previews, headers, output, absolute paths, and evaluator reason text remain local.
- Reportable native-hook events now retain a safe action summary from the original tool input, including post-tool commands and file basenames; when raw values cannot be retained, Cloud receives a bounded tool-and-finding summary instead of a generic placeholder.
- Native `PreToolUse` actions remain the primary Cloud records, while routine successful `PostToolUse` observations now stay local and only failures, elevated risks, anomalous results, or material new findings are uploaded as separate redacted events.
- Native web-fetch audit events now retain a bounded sanitized destination instead of a generic placeholder, without uploading URL credentials, fragments, query values, or sensitive path segments.
- Native file, configuration-change, model-switch, and policy-reason events now retain bounded allowlisted summaries instead of generic placeholders, without uploading paths, contents, patch bodies, endpoints, credentials, or raw reason text.
Expand Down
4 changes: 2 additions & 2 deletions docs/claude-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ Hook execution is bounded to 30 seconds. Claude Code may continue the original a

`PostToolUse` scans `tool_response`. AgentGuard uses `updatedToolOutput` only for verified response shapes: Read, Bash/PowerShell, WebFetch/WebSearch, and MCP content results. Redaction preserves the verified string/object/array shape. Unknown output schemas are not guessed or destructively rewritten; sensitive unknown output is marked `partial`/`would_block`, and `PostToolBatch` remains the continuation gate.

Routine successful `PostToolUse` observations remain in the local audit and are not uploaded. Failures, elevated risks, anomalous results, and other material new findings are uploaded as separate Cloud events containing only redacted summaries and bounded status metadata.
Routine successful `PostToolUse` observations remain in the local audit and are not uploaded. Failures, elevated risks, anomalous results, and other material new findings are uploaded as separate Cloud events containing the safe original-action summary, redacted findings, and bounded status metadata.

`PostToolUseFailure` records only redacted failure type/summary and coverage. It cannot replace or block a failed tool's output. Whether a particular Claude Code version includes failed results in a following `PostToolBatch.tool_calls[].tool_response` is `unverified` until tested on that version; if absent, the failure-output path is `unsupported`.

Expand All @@ -56,7 +56,7 @@ An `@file` path without an explicit exact deny is `unsupported` and is not count

Raw prompts, command arguments, tool output, failure text, configuration content, credentials, and PII are evaluated locally. Native Claude hook audit and Cloud records keep prompts and tool output local, while shell actions may retain a redacted command preview. Protected-file actions retain only a bounded, validated explanation such as `cat .env` or `cat .ssh/id_ed25519.pub`, without the absolute path, remaining arguments, redirects, substitutions, or command tail. Hook stdout/stderr never echoes raw input.

`PreToolUse` is the primary Cloud action record. Raw stdout and stderr are never uploaded from either lifecycle stage; reportable Post events carry only the redacted risk summary and necessary status fields.
Clean `PreToolUse` events remain local; reportable `PreToolUse` events are the primary Cloud action records. Raw stdout and stderr are never uploaded from either lifecycle stage; reportable Post events carry the safe original-action summary, redacted risk summary, and necessary status fields. If the action value itself cannot be retained safely, the summary uses the tool name and a static finding title instead of a generic placeholder.

WebFetch Pre events may retain only a bounded destination preview. URL credentials, fragments, query values, and sensitive or opaque path segments are removed before Cloud sync.

Expand Down
11 changes: 7 additions & 4 deletions docs/codex.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@ from a repository subdirectory does not bypass them.
not undone.
Routine successful observations remain local; only failures, elevated risks,
anomalous results, or other material new findings are uploaded as separate
Cloud events.
Cloud events. Reportable events use the original tool action for a safe
summary instead of uploading tool output.
- `PreCompact` and `PostCompact` record only the trigger and redacted policy
metadata. AgentGuard does not open or parse `transcript_path`, and compact
metadata is not sent to AgentGuard Cloud.
Expand Down Expand Up @@ -86,9 +87,11 @@ tool output, credentials, or PII. Native shell actions may retain a redacted
command preview. Protected-file actions retain only a bounded, validated
explanation such as `cat .env` or `cat .ssh/id_ed25519.pub`, without the
absolute path, remaining arguments, redirects, substitutions, or command tail.
Raw stdout and stderr are never uploaded. `PreToolUse` remains the primary
Cloud action record, while only material `PostToolUse` findings are uploaded as
separate events.
Raw stdout and stderr are never uploaded. Clean `PreToolUse` events remain
local; reportable `PreToolUse` events are the primary Cloud action records,
while only material `PostToolUse` findings are uploaded as separate events.
When an action cannot be represented safely, Cloud receives the tool name and
static finding title instead of a generic placeholder.
Web-fetch Pre events may retain only a bounded destination preview; URL
credentials, fragments, query values, and sensitive or opaque path segments
are removed.
Expand Down
8 changes: 8 additions & 0 deletions docs/dsh-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,14 @@ Events are written to `~/.agentguard/audit.jsonl` with:
- the translated hook decision and disposition;
- `sourceAttribution: "unknown"` when no reliable owner binding exists.

Clean DSH events remain in this local audit and are not uploaded to AgentGuard
Cloud. Events with a decision, non-zero risk, finding, failure, anomaly, or PII
summary remain Cloud-eligible. Their Cloud payload uses a bounded semantic
summary (for example, a command name plus static finding title, a file basename,
or a sanitized network destination) and bounded status metadata. Raw tool
output, request/response bodies, header values, absolute paths, and evaluator
reason text remain local.

`runtime.attribution.toolOwners` is an exact, case-sensitive map from a DSH tool name to a stable plugin or package id. It is operator-authored trust metadata, not a tool-name heuristic. Owner ids are bounded and validated, duplicate/ambiguous wildcard matching is not supported, and an unmapped tool remains `unknown`. Do not bind a name when another agent scope may shadow it with a different implementation.

`runtime.ownerPolicies` applies only after a call has a matching `configured-tool-owner`. Each owner declares a `minimumDecision` of `allow`, `warn`, `require_approval`, or `block`. This is a monotonic floor: it can strengthen the shared AgentGuard decision but can never weaken it. In particular, `minimumDecision: allow` means “no additional owner restriction”; it does not bypass a warning, approval, or block produced by the shared policy. An elevation adds the bounded `DSH_OWNER_POLICY` reason code to audit and native approval text.
Expand Down
24 changes: 18 additions & 6 deletions docs/privacy-boundary.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ Unknown fields are ignored rather than treated as local enforcement facts.

Only redacted, Cloud-eligible runtime audit previews are uploaded by default:

- Clean events (`allow`, zero risk, `safe`, no findings, and no execution
failure) remain in the local audit and are not uploaded, regardless of host.

- `sessionId`, `agentHost`, `actionType`, `toolName`
- Redacted `input` preview, capped at 2,000 characters
- Native shell hooks may retain a redacted command preview so operational
Expand All @@ -48,9 +51,18 @@ Only redacted, Cloud-eligible runtime audit previews are uploaded by default:
successful `PostToolUse` observations stay in the local audit. A post-tool
event is uploaded separately only when it adds a failure, elevated risk,
anomalous result, PII summary, or another material security finding.
- Reportable native events use the original tool action for their safe summary,
including post-tool shell commands and file basenames. If no raw value can be
retained safely, the preview falls back to a bounded tool name plus static
finding title rather than an opaque placeholder.
- Reportable DSH events use the same local-first boundary through a Cloud-only
projection: command names, file basenames, sanitized network destinations,
static finding titles, and bounded status fields may be sent; raw tool output,
request/response previews, headers, absolute paths, and evaluator reason text
remain local.
- Raw stdout and stderr are never uploaded from either lifecycle stage.
Reportable post-tool events contain only the redacted risk summary and
bounded status metadata.
Reportable post-tool events contain only the safe original-action summary,
redacted risk summary, and bounded status metadata.
- Decision, risk score, risk level, reasons, and policy version
- Lifecycle stage, coverage level, enforcement status, missing-fact names, and
request correlation IDs
Expand Down Expand Up @@ -93,10 +105,10 @@ Cloud endpoints also apply server-side redaction, but clients should not rely on
## Offline behavior

If Cloud is unreachable, AgentGuard continues local enforcement and spools
Cloud-eligible redacted audit events for later retry. Routine post-tool
observations, routine model requests, and model responses are filtered again
at the Cloud client boundary, including when an older spool is drained. It
must never fail open for local `block` decisions.
Cloud-eligible redacted audit events for later retry. Clean tool events,
routine post-tool observations, routine model requests, and model responses
are filtered again at the Cloud client boundary, including when an older spool
is drained. It must never fail open for local `block` decisions.

Local policies are normalized when loaded, so caches written before the LLM
privacy fields existed inherit the bundled privacy defaults. Cloud availability
Expand Down
93 changes: 92 additions & 1 deletion src/cloud/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@ import type {
RuntimePiiSummary,
} from '../runtime/types.js';
import { normalizeEffectiveRuntimePolicy } from '../runtime/policy.js';
import { redactLlmMetadata, redactMetadata, redactPreview } from '../runtime/redaction.js';
import { redactLlmMetadata, redactMetadata, redactPreview, redactText } from '../runtime/redaction.js';
import {
buildAuditEvent,
cloudSafeReasons,
isClaudeNativeHookAction,
isCodexNativeHookAction,
nativeHookSafeMetadata,
Expand Down Expand Up @@ -323,9 +324,99 @@ export function buildCloudActionRequest(action: RuntimeAction): CloudActionReque

export function buildCloudAuditEvent(event: RuntimeAuditEvent): CloudAuditEventPayload {
const sanitized = buildAuditEvent(event);
if (event.agentHost === 'dsh') {
const reasons = cloudSafeReasons(event.reasons);
return {
schemaVersion: CLOUD_RUNTIME_WIRE_SCHEMA_VERSION,
...(event.llm?.requestId ? { requestId: redactPreview(event.llm.requestId, 160) } : {}),
...sanitized,
input: dshCloudFindingSummary(event, reasons[0]?.title),
cwd: undefined,
sourceSkill: undefined,
reasons,
metadata: nativeHookSafeMetadata(event.metadata),
};
}
return {
schemaVersion: CLOUD_RUNTIME_WIRE_SCHEMA_VERSION,
...(event.llm?.requestId ? { requestId: redactPreview(event.llm.requestId, 160) } : {}),
...sanitized,
};
}

function dshCloudFindingSummary(event: RuntimeAuditEvent, findingTitle?: string): string {
const finding = findingTitle || 'Security finding';
if (event.actionType === 'network' || event.actionType === 'browser') {
return safeDshNetworkDestination(event.input) ?? `${safeDshToolName(event)}: ${finding}`;
}
if (event.actionType === 'file_read' || event.actionType === 'file_write') {
const target = safeDshFileName(event.input);
if (target) return `${event.actionType === 'file_read' ? 'read' : 'write'} ${target}`;
}
if (event.actionType === 'shell') {
const command = safeDshCommandName(event.input) ?? safeDshToolName(event);
return `${command}: ${finding}`.slice(0, 500);
}
return `${safeDshToolName(event)}: ${finding}`.slice(0, 500);
}

function safeDshToolName(event: RuntimeAuditEvent): string {
const tool = redactPreview(event.toolName, 160).trim();
return tool && tool !== '[REDACTED]' ? tool : event.actionType;
}

function safeDshCommandName(input: string): string | undefined {
const command = input.match(
/^\s*(?:(?:sudo|env)\s+)?(?:[A-Za-z_][A-Za-z0-9_]*=[^\s]+\s+)*(?:\/[^\s/]+\/)*([A-Za-z][A-Za-z0-9_-]{0,79})\b/
)?.[1]?.toLowerCase();
return command && redactPreview(command, 80) === command ? command : undefined;
}

function safeDshFileName(input: string): string | undefined {
const normalized = input.trim().replace(/^['"]|['"]$/g, '').replace(/[\\/]+$/, '');
const name = normalized.split(/[\\/]/).at(-1);
return name && name !== '.' && name !== '..' && /^[A-Za-z0-9._-]{1,128}$/.test(name)
&& redactPreview(name, 128) === name
? name
: undefined;
}

function safeDshNetworkDestination(input: string): string | undefined {
let url: URL;
try {
url = new URL(input.trim());
} catch {
return undefined;
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') return undefined;
const path = url.pathname.split('/').map((segment) => {
if (!segment) return '';
let decoded: string;
try {
decoded = decodeURIComponent(segment);
} catch {
return '[REDACTED]';
}
const tokenLike = decoded.length >= 20 && /^[A-Za-z0-9._~+/-]+$/.test(decoded);
return decoded.length <= 128 && !tokenLike && redactPreview(decoded, 128) === decoded
? segment
: '[REDACTED]';
}).join('/');
const queryKeys = [...new Set(url.searchParams.keys())]
.slice(0, 20)
.map((key) => /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/.test(key) && redactText(key) === key
? key
: 'param');
const query = queryKeys.length > 0
? `?${queryKeys.map((key) => `${key}=[REDACTED]`).join('&')}`
: '';
return `${url.protocol}//${safeDshUrlHost(url)}${path}${query}`.slice(0, 2000);
}

function safeDshUrlHost(url: URL): string {
const hostname = url.hostname.split('.').map((label) => {
const tokenLike = label.length >= 20 && /^[A-Za-z0-9_-]+$/.test(label);
return !tokenLike && redactText(label) === label ? label : '[REDACTED]';
}).join('.');
return url.port ? `${hostname}:${url.port}` : hostname;
}
Loading
Loading