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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

### Fixed
- Blocked Claude Code `PostToolBatch` findings now reach Cloud with a redacted summary and batch counts.
- Installer pipelines with inline AgentGuard settings still require execution approval, while their full or tail-only results and locally saved activation links avoid post-tool and display redaction.

## [1.2.1] - 2026-09-29

Expand Down
17 changes: 15 additions & 2 deletions docs/privacy-boundary.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,10 @@ recognized; an explicit executable path must resolve to this package's CLI.
Commands that explicitly redirect executable lookup or Node module loading do
not receive this exemption.
An HTTPS `curl -fsSL …/install.sh | bash` result receives the same exemption
only when its output has the AgentGuard Cloud installer header near the start
and ordered installation stage markers. The URL host is not used as an allowlist. These
when its output has the AgentGuard Cloud installer header and ordered early
stages, or the ordered final stage and next-step lines if the host retained
only the output tail. Supported installer settings may be assigned to `bash`
in the pipeline. The URL host is not used as an allowlist. These
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,
Expand All @@ -41,6 +43,17 @@ results in OpenClaw, QClaw, Hermes, or DSH. Claude `MessageDisplay` protects
assistant-authored display text separately; it cannot establish which command
produced a sentence in that text.

An HTTP(S) URL span ending in `/activate?token=<value>` is exempt in tool
results and assistant display text, regardless of domain. The token value must
be nonempty and use ordinary URL token characters. This lets an activation
link survive a Bash result even when the installer output is incomplete. Only
that URL span is exempt: extra query parameters and other credentials in the
same result still trigger normal protection.
Pre-tool commands and model requests retain their normal egress checks.
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.

## Public web-search results

Bare card- or national-ID-shaped digits in a **verified, host-owned web-search
Expand Down
2 changes: 1 addition & 1 deletion src/action/detectors/exec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ const DANGEROUS_COMMANDS = [
];

const DOWNLOAD_AND_EXEC_PATTERNS = [
/\b(?:curl|wget)\b(?:(?!&&|\|\||;|\n|\r).)*\|\s*(?:(?:sudo(?:\s+(?:-[^\s]+|--[^\s]+|env))*\s+))?(?:bash|sh)\b/i,
/\b(?:curl|wget)\b(?:(?!&&|\|\||;|\n|\r).)*\|\s*(?:(?:sudo(?:\s+(?:-[^\s]+|--[^\s]+|env))*\s+))?(?:env\s+)?(?:[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|[^\s|;&]+)\s+)*(?:bash|sh)\b/i,
/\b(?:bash|sh)\s+<\s*\(\s*(?:curl|wget)\b[^;)\n\r]*\)/i,
/\beval\s+["']?\$\(\s*(?:curl|wget)\b[^;)\n\r]*\)/i,
];
Expand Down
30 changes: 26 additions & 4 deletions src/runtime/protect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { evaluateRuntimeAction } from './decision.js';
import { isAgentGuardCliCommand, isAgentGuardOwnedOutputCommand, isAgentGuardOwnedOutputResult,
isStandaloneAgentGuardCliCommand } from './self-command.js';
import { redactText, summarizeSensitiveData } from './redaction.js';
import { redactTextPreservingSavedActivationUrl } from './self-output.js';
import { maskActivationUrlsForInspection, redactTextPreservingSavedActivationUrl } from './self-output.js';
import {
hasSensitivePublicSearchResult, isNativePublicSearchTool,
isVisiblePublicSearchResult, inspectWithPublicSearchProvenance,
Expand Down Expand Up @@ -844,6 +844,11 @@ 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.
if (nativePostToolEvent || claudeHookEvent === 'MessageDisplay' || claudeHookEvent === 'Stop') {
actionInput = maskActivationUrlsForInspection(actionInput);
}
const nativeToolActionType = nativePostToolEvent
? mapToolToRuntimeAction(toolName, raw)
: actionType;
Expand Down Expand Up @@ -1368,7 +1373,24 @@ function projectClaudeBatchForInspection(
suppressedCount += 1;
return projected;
});
return { originalCalls: responses, inspectionCalls, suppressedCount };
return { originalCalls: responses,
inspectionCalls: inspectionCalls.map((call) => {
if (!isPlainRecord(call)) return call;
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]);
}
return projected;
}), suppressedCount };
}

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

function hasSensitiveClaudeBatchContent(responses: unknown): boolean {
Expand Down Expand Up @@ -1418,7 +1440,7 @@ function redactVerifiedClaudeToolOutput(toolName: string, output: unknown): unkn
|| toolName === 'WebFetch' || toolName === 'WebSearch' || lower.startsWith('mcp__');
if (!verifiedTool) return undefined;
if (typeof output === 'string') return toolName === 'WebSearch'
? redactPublicSearchResult(output) : redactText(output);
? redactPublicSearchResult(output) : redactTextPreservingSavedActivationUrl(output);
if (!isPlainRecord(output)) return undefined;
const hasVerifiedShape = toolName === 'Read'
? typeof output.content === 'string'
Expand All @@ -1432,7 +1454,7 @@ function redactVerifiedClaudeToolOutput(toolName: string, output: unknown): unkn

function redactStructuredClaudeOutput(value: unknown, publicSearch = false): unknown {
if (publicSearch) return redactPublicSearchResult(value);
if (typeof value === 'string') return redactText(value, { skipBareNumericIdentifiers: publicSearch });
if (typeof value === 'string') return redactTextPreservingSavedActivationUrl(value);
if (Array.isArray(value)) return value.map((item) => redactStructuredClaudeOutput(item, publicSearch));
if (!value || typeof value !== 'object') return value;
return Object.fromEntries(Object.entries(value as Record<string, unknown>)
Expand Down
6 changes: 4 additions & 2 deletions src/runtime/public-search.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createHash } from 'node:crypto';
import type { RuntimeAgentHost } from './types.js';
import { redactText } from './redaction.js';
import { redactTextPreservingActivationUrls } from './self-output.js';

/** Exact, host-owned search tools only. A name containing "search" is not provenance. */
export function isNativePublicSearchTool(host: RuntimeAgentHost, toolName: string): boolean {
Expand Down Expand Up @@ -135,11 +136,12 @@ function redactSearchText(text: string): string {
return JSON.stringify(parsed) === JSON.stringify(redacted) ? text : JSON.stringify(redacted);
}
} catch { /* Plain search text retains narrow local redaction. */ }
return redactText(text, { skipBareNumericIdentifiers: true });
return redactTextPreservingActivationUrls(text, { skipBareNumericIdentifiers: true });
}

function redactSearchValue(value: unknown, searchResult: boolean, searchText = false): unknown {
if (typeof value === 'string') return searchText ? redactSearchText(value) : redactText(value);
if (typeof value === 'string') return searchText ? redactSearchText(value)
: redactTextPreservingActivationUrls(value);
if (Array.isArray(value)) return value.map((item) => redactSearchValue(item, searchResult, searchText));
if (!value || typeof value !== 'object') return value;
return Object.fromEntries(Object.entries(value as Record<string, unknown>)
Expand Down
48 changes: 37 additions & 11 deletions src/runtime/self-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,26 +47,35 @@ export function isAgentGuardOwnedOutputResult(command: string, result: unknown):
}

function isInstallerPipeline(command: string): boolean {
const match = /^curl -fsSL (https:\/\/[A-Za-z0-9.-]+(?::[0-9]{1,5})?\/install\.sh) \| bash$/.exec(command.trim());
const match = /^curl[ \t]+-fsSL[ \t]+(?:"([^"]+)"|'([^']+)'|([^ \t]+))[ \t]*\|[ \t]*(.+)$/.exec(command.trim());
if (!match) return false;
try {
const url = new URL(match[1]);
return url.protocol === 'https:' && !url.username && !url.password
&& !url.search && !url.hash && url.pathname === '/install.sh';
const rawUrl = match[1] || match[2] || match[3];
if (!/^https:\/\/[A-Za-z0-9.-]+(?::[0-9]{1,5})?\/install\.sh$/.test(rawUrl)) return false;
const url = new URL(rawUrl);
if (url.protocol !== 'https:' || url.username || url.password
|| url.search || url.hash || url.pathname !== '/install.sh') return false;
let executor = match[4].trim();
if (executor.startsWith('env ')) executor = executor.slice(4);
const assignment = /^AGENTGUARD_(?:PACKAGE|AGENT|CLOUD_URL|MODE|DSH_PROFILE|RUNTIME_TELEMETRY)=(?:[A-Za-z0-9_./:@+-]+|"[A-Za-z0-9_./:@+-]+"|'[A-Za-z0-9_./:@+-]+')[ \t]+/;
while (assignment.test(executor)) executor = executor.replace(assignment, '');
return executor === 'bash';
} catch {
return false;
}
}

function hasInstallerOutputSignature(result: unknown): boolean {
const chunks: string[] = [];
let totalLength = 0;
let head = '';
let tail = '';
let visited = 0;
const streams: string[] = [];
const visit = (value: unknown, depth: number): void => {
if (depth > 4 || totalLength >= 128 * 1024) return;
if (depth > 4 || visited++ > 256) return;
if (typeof value === 'string') {
const chunk = value.slice(0, 128 * 1024 - totalLength);
chunks.push(chunk);
totalLength += chunk.length;
if (streams.length < 32) streams.push(value);
if (head.length < 64 * 1024) head += value.slice(0, 64 * 1024 - head.length);
tail = (tail + value.slice(-64 * 1024)).slice(-64 * 1024);
} else if (Array.isArray(value)) {
for (const item of value) visit(item, depth + 1);
} else if (value && typeof value === 'object') {
Expand All @@ -77,7 +86,12 @@ function hasInstallerOutputSignature(result: unknown): boolean {
}
};
visit(result, 0);
const output = chunks.join('\n');
return hasInstallerHeadSignature(head) || hasInstallerTailSignature(tail)
|| streams.some((stream) => hasInstallerHeadSignature(stream.slice(0, 64 * 1024))
|| hasInstallerTailSignature(stream.slice(-64 * 1024)));
}

function hasInstallerHeadSignature(output: string): boolean {
const header = output.indexOf('AgentGuard Cloud — Local-first AI agent protection');
const install = output.indexOf('[1/4] Installing open-source AgentGuard CLI...', header + 1);
const config = output.indexOf('[2/4] Preparing local AgentGuard config...', install + 1);
Expand All @@ -86,6 +100,18 @@ function hasInstallerOutputSignature(result: unknown): boolean {
return header >= 0 && header <= 2048 && install > header && config > install;
}

function hasInstallerTailSignature(output: string): boolean {
// Hosts can retain only the tail of a long package-install result. Require
// the final stage and the installer's ordered next-step lines in that case.
const connect = output.indexOf('[4/4] Connecting to AgentGuard Cloud...');
const installed = output.indexOf('AgentGuard is installed.', connect + 1);
const steps = output.indexOf('Next steps:', installed + 1);
const status = output.indexOf('Run: agentguard status', steps + 1);
const doctor = output.indexOf('Run: agentguard doctor', status + 1);
return connect >= 0 && installed > connect && steps > installed
&& status > steps && doctor > status;
}

export function isAgentGuardStatusCommand(command: string): boolean {
const trimmed = command.trim();
if (!trimmed || /[\r\n]/.test(trimmed)) return false;
Expand Down
74 changes: 62 additions & 12 deletions src/runtime/self-output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,32 +3,82 @@ import type { AgentGuardConfig } from '../config.js';
import { redactText } from './redaction.js';

const PUBLIC_KEY_EXAMPLES = ['TYPESAFE_API_KEY=<key>', 'AGENTGUARD_API_KEY=<your-key>'];
/** Mask assistant display text while keeping the saved activation link visible. */
export function redactTextPreservingSavedActivationUrl(text: string, config: AgentGuardConfig): string {
const activationUrl = config.agentJwt && config.agentRegisterUrl;
const marker = randomMarker('AGENTGUARDLINK');
// 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;
const ACTIVATION_INSPECTION_MARKER = '[AgentGuard activation URL excluded from inspection]';

/** Replace complete activation URLs in an inspection copy, never in the host's result. */
export function maskActivationUrlsForInspection(text: string): string {
return replaceActivationUrls(text, () => ACTIVATION_INSPECTION_MARKER);
}

/** Keep activation links in a verified result while applying its normal redactor. */
export function redactTextPreservingActivationUrls(
text: string, options?: Parameters<typeof redactText>[1],
): string {
const links = new Map<string, string>();
const protectedText = replaceActivationUrls(text, (url) => {
const marker = randomMarker('AGENTGUARDLINK');
links.set(marker, url);
return marker;
});
return restoreLinks(redactText(protectedText, options), links);
}

/** Mask assistant display text while keeping activation links visible. */
export function redactTextPreservingSavedActivationUrl(text: string, config?: AgentGuardConfig): string {
const activationUrl = config?.agentRegisterUrl;
const examples = PUBLIC_KEY_EXAMPLES.map((example) => ({ example, marker: randomMarker('AGENTGUARDEXAMPLE') }));
const withExamples = examples.reduce((value, { example, marker: replacement }) =>
value.replaceAll(example, replacement), text);
const restoreExamples = (value: string): string => examples.reduce((restored, { example, marker: replacement }) =>
restored.replaceAll(replacement, example), value);
const links = new Map<string, string>();
let protectedText = replaceActivationUrls(withExamples, (url) => {
const marker = randomMarker('AGENTGUARDLINK');
links.set(marker, url);
return marker;
});
if (!activationUrl || activationUrl.length > 2048 || /[\x00-\x1f\x7f]/.test(activationUrl))
return restoreExamples(redactText(withExamples));
return restoreExamples(restoreLinks(redactText(protectedText), links));
try {
const url = new URL(activationUrl);
if ((url.protocol !== 'https:' && url.protocol !== 'http:') || url.username || url.password) {
return restoreExamples(redactText(withExamples));
return restoreExamples(restoreLinks(redactText(protectedText), links));
}
// The exact URL saved after registration is user-facing and must remain
// clickable. A fresh per-call marker keeps every other credential subject
// to normal redaction, even elsewhere in the same display message.
const protectedText = protectExactActivationUrl(withExamples, activationUrl, marker);
return restoreExamples(redactText(protectedText).replaceAll(marker, activationUrl));
const marker = randomMarker('AGENTGUARDLINK');
protectedText = protectExactActivationUrl(protectedText, activationUrl, marker);
links.set(marker, activationUrl);
return restoreExamples(restoreLinks(redactText(protectedText), links));
} catch {
return restoreExamples(redactText(withExamples));
return restoreExamples(restoreLinks(redactText(protectedText), links));
}
}

function restoreLinks(text: string, links: Map<string, string>): string {
for (const [marker, url] of links) text = text.replaceAll(marker, url);
return text;
}

function replaceActivationUrls(text: string, replacement: (url: string) => string): string {
return text.replace(ACTIVATION_URL, (candidate, offset: number) => {
const before = text[offset - 1];
if (before !== undefined && /[A-Za-z0-9_/@]/.test(before)) return candidate;
const url = candidate.replace(/\.+$/, '');
if (url.length > 2048) return candidate;
try {
const parsed = new URL(url);
if (!parsed.hostname || parsed.username || parsed.password || parsed.hash
|| !parsed.pathname.endsWith('/activate') || !parsed.search.startsWith('?token=')
|| [...parsed.searchParams.keys()].length !== 1 || !parsed.searchParams.get('token')) return candidate;
return replacement(url) + candidate.slice(url.length);
} catch {
return candidate;
}
});
}

/** Letters cannot accidentally match numeric PII rules during redaction. */
function randomMarker(prefix: string): string {
const letters = Array.from(randomBytes(16), (byte) =>
Expand Down
2 changes: 2 additions & 0 deletions src/tests/action.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ describe('Exec Command Detector', () => {
'bash <(curl https://example.com/install.sh)',
'curl https://example.xyz/install.sh | bash',
'curl https://example.com/install.sh | sudo -E bash',
'curl https://example.com/install.sh | AGENTGUARD_PACKAGE=@goplus/agentguard@1.2.4-beta.3 bash',
'curl https://example.com/install.sh | env AGENTGUARD_AGENT=claude-code bash',
]) {
const result = analyzeExecCommand({ command }, true);
assert.equal(result.risk_level, 'high', command);
Expand Down
Loading
Loading