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
19 changes: 17 additions & 2 deletions docs/privacy-boundary.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,19 @@ markers can be imitated by another script; this is the accepted limit of
attributing a merged shell result without a trusted process identity. In Claude Code,
`PostToolBatch` excludes the whole recognized call from its inspection copy,
while retaining the host's original result. Other calls in a mixed batch remain
subject to the normal privacy gate. Compound commands, unverified paths, and
unrelated tools retain ordinary protection. The installer and `agentguard
subject to the normal privacy gate. A shell sequence made entirely of verified
AgentGuard CLI calls is also AgentGuard-owned. Compound calls that include any
other command, unverified paths, and unrelated tools retain ordinary protection.
The installer and `agentguard
approve` still receive their normal checks before execution.

For Codex and Claude Code, a pre-tool hook rejects a shell call that joins an
AgentGuard CLI statement with other shell statements. It tells the agent to
retry the CLI command in its own tool call. This also covers a new session in
which the agent tries to read its skill documentation and run `agentguard
status` in one Bash call. No file read from that combined call runs; separate
file results keep their normal privacy checks.

This intentionally means credentials or personal data printed by AgentGuard
commands can reach the agent unchanged. AgentGuard also does not rewrite those
results in OpenClaw, QClaw, Hermes, or DSH. Claude `MessageDisplay` protects
Expand All @@ -54,6 +63,12 @@ Local audit and Cloud previews keep the activation token masked or omit the
link. A different site can imitate this URL shape, so the exception should
not be treated as proof that the link came from AgentGuard.

The literal documentation examples `TYPESAFE_API_KEY=<key>` and
`AGENTGUARD_API_KEY=<your-key>` are also ignored by post-tool privacy checks.
This keeps `agentguard status` usable when an agent wraps the shell command and
the hook cannot attribute the result to the CLI. Real credentials elsewhere in
the same result remain subject to normal protection.

## Public web-search results

Bare card- or national-ID-shaped digits in a **verified, host-owned web-search
Expand Down
5 changes: 5 additions & 0 deletions skills/agentguard/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,11 @@ If no subcommand is given, or the first argument is a path, default to **scan**.

This skill is allowed to run `agentguard *`, so CLI commands and flags are available even when the skill has a higher-level workflow for the same area.

Run each AgentGuard CLI command in its own tool call. Read documentation or
other local files in separate calls, before or after the CLI call. If one Bash
call combines file reads with `agentguard status`, the hook receives one merged
result and must inspect the file content too.

The skill's routed subcommands take priority over similarly named CLI commands. Do not route these through the packaged CLI unless the user explicitly prefixes the request with `/agentguard cli`: `scan`, `action`, `patrol`, `trust`, `report`, `config`, `checkup`, `hermes-hooks`. The sole internal exception is the patrol workflow's canonical collector: `patrol run` and locally scheduled patrols execute the existing `agentguard checkup --json` command as specified below.

Use CLI passthrough for the CLI-only commands below, for `init` and `connect`, for explicit `/agentguard cli <args...>` requests, or for the targeted `checkup --against-advisory <id>` mode described below.
Expand Down
5 changes: 3 additions & 2 deletions src/adapters/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
getSkillTrustPolicy,
isActionAllowedByCapabilities,
} from './common.js';
import { isAgentGuardCliCommand } from '../runtime/self-command.js';
import { isAgentGuardCliCommand, isAgentGuardOwnedOutputSequence } from '../runtime/self-command.js';

/**
* Evaluate a hook event using the common AgentGuard decision engine.
Expand Down Expand Up @@ -125,5 +125,6 @@ export async function evaluateHook(
function isAgentGuardHookCommand(adapter: HookAdapter, input: HookInput): boolean {
if (adapter.mapToolToActionType(input.toolName) !== 'exec_command') return false;
const command = input.toolInput.command;
return typeof command === 'string' && isAgentGuardCliCommand(command);
return typeof command === 'string'
&& (isAgentGuardCliCommand(command) || isAgentGuardOwnedOutputSequence(command));
}
5 changes: 3 additions & 2 deletions src/adapters/openclaw-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ import {
} from '../runtime/protect.js';
import type { RuntimeActionType } from '../runtime/types.js';
import type { AgentGuardConfig } from '../config.js';
import { isStandaloneAgentGuardCliCommand } from '../runtime/self-command.js';
import { isAgentGuardOwnedOutputSequence, isStandaloneAgentGuardCliCommand } from '../runtime/self-command.js';

// ---------------------------------------------------------------------------
// OpenClaw Types (subset we use)
Expand Down Expand Up @@ -589,7 +589,8 @@ export function registerOpenClawPlugin(
const command = args && typeof args === 'object'
? (args as Record<string, unknown>).command ?? (args as Record<string, unknown>).cmd : undefined;
if (normalized.actionType === 'shell' && typeof command === 'string'
&& isStandaloneAgentGuardCliCommand(command)) return;
&& (isStandaloneAgentGuardCliCommand(command)
|| isAgentGuardOwnedOutputSequence(command))) return;
const toolName = normalized.toolName;
const pluginId = toolName ? getPluginIdFromTool(toolName) : null;
if (runtimeProtectionEnabled) {
Expand Down
5 changes: 3 additions & 2 deletions src/dsh/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
} from '../runtime/public-search.js';
import { redactText } from '../runtime/redaction.js';
import { isAgentGuardApprovalCommand } from '../runtime/protect.js';
import { isStandaloneAgentGuardCliCommand } from '../runtime/self-command.js';
import { isAgentGuardOwnedOutputSequence, isStandaloneAgentGuardCliCommand } from '../runtime/self-command.js';
import { planDshEnforcement, type DshRuntimePhase } from './enforcement-plan.js';
import {
mergeDshPostDecisions,
Expand Down Expand Up @@ -238,7 +238,8 @@ export function buildDshRuntimeAction(
}

function isDshAgentGuardCliAction(action: RuntimeAction): boolean {
return action.actionType === 'shell' && isStandaloneAgentGuardCliCommand(action.input);
return action.actionType === 'shell'
&& (isStandaloneAgentGuardCliCommand(action.input) || isAgentGuardOwnedOutputSequence(action.input));
}

export async function observeDshToolCall(
Expand Down
5 changes: 5 additions & 0 deletions src/installers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -525,6 +525,11 @@ This Skill documents the AgentGuard workflow. It is not a security boundary.
Enforcement comes from the synchronous native hooks in \`.codex/hooks.json\`
after the user reviews and trusts them with \`/hooks\`.

Run AgentGuard CLI commands such as \`agentguard status\` in their own tool
call. Read documentation or local files in separate calls before or after the
CLI call. A shell command that combines them has one merged result, so the
hook must inspect the file content as well as AgentGuard's output.

When a hook denies an action that requires approval, show the action id and
redacted reason to the user. The user may approve it from their own terminal
with \`agentguard approve --action-id <id> --once\`, then explicitly retry the
Expand Down
1 change: 1 addition & 0 deletions src/runtime/audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -621,6 +621,7 @@ const OPAQUE_URL_PATH_SEGMENT = /^(?=.{24,128}$)(?=.*[A-Za-z])(?=.*\d)[A-Za-z0-9
const SAFE_MODEL_SWITCH_LABEL = /^[A-Za-z0-9._/-]{1,160}$/;
const SAFE_REASON_TITLES: Readonly<Record<string, string>> = {
ACTION_TYPE_REQUIRES_APPROVAL: 'Tool type requires approval',
AGENTGUARD_MIXED_COMMAND: 'Separate AgentGuard CLI call required',
AGENT_SELF_APPROVAL: 'Agent approval command denied',
CUSTOM_BLOCKED_COMMAND: 'Custom blocked command',
CUSTOM_BLOCKED_DOMAIN: 'Custom blocked domain',
Expand Down
42 changes: 31 additions & 11 deletions src/runtime/protect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@ import { consumeApprovedApproval, writePendingApproval, type ApprovalRecord } fr
import { flushEventSpool, spoolEvent, writeAuditLog } from './audit.js';
import { evaluateRuntimeAction } from './decision.js';
import { isAgentGuardCliCommand, isAgentGuardOwnedOutputCommand, isAgentGuardOwnedOutputResult,
isAgentGuardOwnedOutputSequence, isMixedAgentGuardCliCommand,
isStandaloneAgentGuardCliCommand } from './self-command.js';
import { redactText, summarizeSensitiveData } from './redaction.js';
import { maskActivationUrlsForInspection, redactTextPreservingSavedActivationUrl } from './self-output.js';
import { maskAgentGuardPublicOutputForInspection, redactTextPreservingSavedActivationUrl } from './self-output.js';
import {
hasSensitivePublicSearchResult, isNativePublicSearchTool,
isVisiblePublicSearchResult, inspectWithPublicSearchProvenance,
Expand Down Expand Up @@ -90,6 +91,9 @@ export async function protectAction(options: ProtectOptions): Promise<ProtectRes
const claudeHookEvent = pickClaudeHookEventFromAction(action);
const selfApprovalAttempt = (codexHookEvent === 'PreToolUse' || claudeHookEvent === 'PreToolUse')
&& isAgentGuardApprovalCommand(action);
const mixedAgentGuardCommand = (codexHookEvent === 'PreToolUse' || claudeHookEvent === 'PreToolUse')
&& action.actionType === 'shell' && !selfApprovalAttempt
&& isMixedAgentGuardCliCommand(action.input);
const selfOutput = nativeAgentGuardSelfOutput(action, options, codexHookEvent, claudeHookEvent);
if (selfOutput !== undefined) return selfOutput;
if (claudeHookEvent === 'PostToolBatch'
Expand Down Expand Up @@ -122,6 +126,19 @@ export async function protectAction(options: ProtectOptions): Promise<ProtectRes
policySource = evaluation.policySource;
}
decision = enforceNativeHookDecision(action, decision, selfApprovalAttempt);
if (mixedAgentGuardCommand) {
const reason = {
code: 'AGENTGUARD_MIXED_COMMAND', severity: 'high' as const,
title: 'Separate AgentGuard CLI call required',
description: 'Run the AgentGuard CLI command in its own tool call, apart from other shell commands.',
};
decision = {
...decision,
decision: 'block', policyDecision: 'block', riskScore: Math.max(60, decision.riskScore),
riskLevel: decision.riskLevel === 'critical' ? 'critical' : 'high',
reasons: decision.decision === 'block' ? [...decision.reasons, reason] : [reason],
};
}
const approvedGrant = canEnforce && !postToolCall && decision.decision === 'require_approval'
? consumeApprovedApproval(approvalStorePath, action)
: null;
Expand Down Expand Up @@ -222,7 +239,8 @@ function nativeAgentGuardSelfOutput(
}

function isAgentGuardRuntimeAction(action: RuntimeAction): boolean {
return action.actionType === 'shell' && isAgentGuardCliCommand(action.input);
return action.actionType === 'shell'
&& (isAgentGuardCliCommand(action.input) || isAgentGuardOwnedOutputSequence(action.input));
}

export function isAgentGuardApprovalCommand(action: RuntimeAction): boolean {
Expand Down Expand Up @@ -727,7 +745,9 @@ function safeCodexReason(result: ProtectResult, approvalRequired = false): strin
return (
`AgentGuard ${approvalRequired ? 'approval required' : 'policy decision'}; ` +
`action=${safeCodexActionId(result.decision.actionId)}; risk=${result.decision.riskLevel}; rules=${ruleIds}; reason=[REDACTED].` +
(approvalRequired ? ' Approve this action id explicitly outside the agent, then retry.' : '')
(approvalRequired ? ' Approve this action id explicitly outside the agent, then retry.' : '') +
(result.decision.reasons.some((reason) => reason.code === 'AGENTGUARD_MIXED_COMMAND')
? ' Run AgentGuard CLI in a separate tool call; read other files separately, then retry.' : '')
);
}

Expand Down Expand Up @@ -844,10 +864,10 @@ function buildRuntimeAction(options: ProtectOptions): RuntimeAction {
const nativePostToolEvent = codexHookEvent === 'PostToolUse'
|| claudeHookEvent === 'PostToolUse'
|| claudeHookEvent === 'PostToolUseFailure';
// An activation link is safe to show after a tool has run. Do not exempt it
// from pre-tool or model-request checks, where the URL may be an egress target.
// Preserve activation links and literal CLI documentation examples in
// results. Pre-tool and model-request checks retain the original input.
if (nativePostToolEvent || claudeHookEvent === 'MessageDisplay' || claudeHookEvent === 'Stop') {
actionInput = maskActivationUrlsForInspection(actionInput);
actionInput = maskAgentGuardPublicOutputForInspection(actionInput);
}
const nativeToolActionType = nativePostToolEvent
? mapToolToRuntimeAction(toolName, raw)
Expand Down Expand Up @@ -1379,18 +1399,18 @@ function projectClaudeBatchForInspection(
const projected = { ...call };
for (const key of ['tool_response', 'toolResponse', 'result', 'error', 'error_message',
'stdout', 'stderr', 'output']) {
if (key in projected) projected[key] = maskActivationUrlsInValue(projected[key]);
if (key in projected) projected[key] = maskAgentGuardPublicOutputInValue(projected[key]);
}
return projected;
}), suppressedCount };
}

function maskActivationUrlsInValue(value: unknown): unknown {
if (typeof value === 'string') return maskActivationUrlsForInspection(value);
if (Array.isArray(value)) return value.map(maskActivationUrlsInValue);
function maskAgentGuardPublicOutputInValue(value: unknown): unknown {
if (typeof value === 'string') return maskAgentGuardPublicOutputForInspection(value);
if (Array.isArray(value)) return value.map(maskAgentGuardPublicOutputInValue);
if (!isPlainRecord(value)) return value;
return Object.fromEntries(Object.entries(value)
.map(([key, item]) => [key, maskActivationUrlsInValue(item)]));
.map(([key, item]) => [key, maskAgentGuardPublicOutputInValue(item)]));
}

function hasSensitiveClaudeBatchContent(responses: unknown): boolean {
Expand Down
86 changes: 86 additions & 0 deletions src/runtime/self-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,93 @@ export function isAgentGuardOwnedOutputCommand(command: string): boolean {
/** The installer is attributed from its finished result, never from its URL alone. */
export function isAgentGuardOwnedOutputResult(command: string, result: unknown): boolean {
return isAgentGuardOwnedOutputCommand(command)
|| isAgentGuardOwnedOutputSequence(command)
|| (isInstallerPipeline(command) && hasInstallerOutputSignature(result));
}

/** Every statement must be a verified AgentGuard invocation. */
export function isAgentGuardOwnedOutputSequence(command: string): boolean {
return isAgentGuardOwnedOutputSequenceInner(command, 0);
}

function isAgentGuardOwnedOutputSequenceInner(command: string, depth: number): boolean {
if (depth > 2) return false;
const statements = splitShellStatements(command);
if (!statements) return false;
if (statements.length > 1) {
return statements.every((statement) => isAgentGuardOwnedOutputCommand(statement)
|| isAgentGuardOwnedOutputSequenceInner(statement, depth + 1));
}
const wrapped = shellWrappedCommand(statements[0] || '');
return wrapped ? isAgentGuardOwnedOutputSequenceInner(wrapped, depth + 1) : false;
}

/** A separate CLI statement inside one shell call has no separate result provenance. */
export function isMixedAgentGuardCliCommand(command: string): boolean {
return isMixedAgentGuardCliCommandInner(command, 0);
}

function isMixedAgentGuardCliCommandInner(command: string, depth: number): boolean {
if (depth > 2) return false;
const statements = splitShellStatements(command);
if (!statements) return false;
const isOwnStatement = (statement: string): boolean => isAgentGuardOwnedOutputCommand(statement)
|| isAgentGuardOwnedOutputSequenceInner(statement, depth + 1);
if (statements.length > 1
&& statements.some(isOwnStatement)
&& statements.some((statement) => !isOwnStatement(statement))) return true;
return statements.some((statement) => {
const wrapped = shellWrappedCommand(statement);
return wrapped ? isMixedAgentGuardCliCommandInner(wrapped, depth + 1) : false;
});
}

function shellWrappedCommand(statement: string): string | null {
const tokens = shellTokens(statement);
let index = skipSafeAssignments(tokens, 0);
if (index === null) return null;
if (basename(tokens[index] || '') === 'env') {
index += 1;
while (tokens[index]?.startsWith('-')) index += 1;
index = skipSafeAssignments(tokens, index);
if (index === null) return null;
}
while (['command', 'builtin'].includes(basename(tokens[index] || ''))) index += 1;
return shellWrapperCommand(tokens, index);
}

function splitShellStatements(command: string): string[] | null {
const input = command.replace(/\\\r?\n/g, '');
const statements: string[] = [];
let start = 0;
let quote: '"' | "'" | null = null;
let escaped = false;
for (let index = 0; index < input.length; index += 1) {
const char = input[index]!;
if (escaped) { escaped = false; continue; }
if (char === '\\' && quote !== "'") { escaped = true; continue; }
if (char === '"' || char === "'") {
if (!quote) quote = char;
else if (quote === char) quote = null;
continue;
}
// Here-doc bodies, comments, and nested shell expressions are not
// statements we can attribute safely from a flat command string.
if (!quote && ((char === '<' && input[index + 1] === '<')
|| (char === '#' && (index === 0 || /[\s;&|()]/.test(input[index - 1]!)))
|| (char === '$' && input[index + 1] === '(')
|| char === '`' || char === '(' || char === ')')) return null;
if (quote || ![';', '\n', '&', '|'].includes(char)) continue;
if ((char === '&' || char === '|') && input[index + 1] !== char) continue;
statements.push(input.slice(start, index).trim());
if (char === '&' || char === '|') index += 1;
start = index + 1;
}
if (quote || escaped) return null;
statements.push(input.slice(start).trim());
return statements.filter(Boolean);
}

function isInstallerPipeline(command: string): boolean {
const match = /^curl[ \t]+-fsSL[ \t]+(?:"([^"]+)"|'([^']+)'|([^ \t]+))[ \t]*\|[ \t]*(.+)$/.exec(command.trim());
if (!match) return false;
Expand Down Expand Up @@ -128,6 +212,7 @@ function isAgentGuardCliCommandInner(command: string, depth: number): boolean {

let index = skipSafeAssignments(tokens, 0);
if (index === null) return false;
if (!tokens[index]) return false;
if (basename(tokens[index]) === 'env') {
index += 1;
while (tokens[index]?.startsWith('-')) index += 1;
Expand Down Expand Up @@ -157,6 +242,7 @@ function isStandaloneAgentGuardCliCommandInner(command: string, depth: number, o

let index = skipSafeAssignments(tokens, 0);
if (index === null) return false;
if (!tokens[index]) return false;
if (basename(tokens[index]) === 'env') {
index += 1;
while (tokens[index]?.startsWith('-')) index += 1;
Expand Down
8 changes: 8 additions & 0 deletions src/runtime/self-output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { AgentGuardConfig } from '../config.js';
import { redactText } from './redaction.js';

const PUBLIC_KEY_EXAMPLES = ['TYPESAFE_API_KEY=<key>', 'AGENTGUARD_API_KEY=<your-key>'];
const PUBLIC_KEY_EXAMPLE_INSPECTION_MARKER = '[AgentGuard public key example]';
// The token charset deliberately stops at separators, so adjacent query
// parameters or prose cannot become part of the exempt span.
const ACTIVATION_URL = /https?:\/\/[^\s"'`<>()\[\]{}\\?,;!]+\/activate\?token=[A-Za-z0-9._~%+=-]+/g;
Expand All @@ -13,6 +14,13 @@ export function maskActivationUrlsForInspection(text: string): string {
return replaceActivationUrls(text, () => ACTIVATION_INSPECTION_MARKER);
}

/** Static documentation placeholders are not credentials, even in wrapped tool results. */
export function maskAgentGuardPublicOutputForInspection(text: string): string {
const withoutExamples = PUBLIC_KEY_EXAMPLES.reduce((value, example) =>
value.replaceAll(example, PUBLIC_KEY_EXAMPLE_INSPECTION_MARKER), text);
return maskActivationUrlsForInspection(withoutExamples);
}

/** Keep activation links in a verified result while applying its normal redactor. */
export function redactTextPreservingActivationUrls(
text: string, options?: Parameters<typeof redactText>[1],
Expand Down
Loading
Loading