diff --git a/CHANGELOG.md b/CHANGELOG.md index 48340ee..1648878 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/privacy-boundary.md b/docs/privacy-boundary.md index cb9eebb..5919718 100644 --- a/docs/privacy-boundary.md +++ b/docs/privacy-boundary.md @@ -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, @@ -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=` 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 diff --git a/src/action/detectors/exec.ts b/src/action/detectors/exec.ts index 6c6bb05..8450d92 100644 --- a/src/action/detectors/exec.ts +++ b/src/action/detectors/exec.ts @@ -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, ]; diff --git a/src/runtime/protect.ts b/src/runtime/protect.ts index 46413a9..9c6160a 100644 --- a/src/runtime/protect.ts +++ b/src/runtime/protect.ts @@ -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, @@ -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; @@ -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 { @@ -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' @@ -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) diff --git a/src/runtime/public-search.ts b/src/runtime/public-search.ts index 79e07b8..d813610 100644 --- a/src/runtime/public-search.ts +++ b/src/runtime/public-search.ts @@ -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 { @@ -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) diff --git a/src/runtime/self-command.ts b/src/runtime/self-command.ts index 524fbf8..a560101 100644 --- a/src/runtime/self-command.ts +++ b/src/runtime/self-command.ts @@ -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') { @@ -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); @@ -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; diff --git a/src/runtime/self-output.ts b/src/runtime/self-output.ts index 6e213e5..2cfc5b5 100644 --- a/src/runtime/self-output.ts +++ b/src/runtime/self-output.ts @@ -3,32 +3,82 @@ import type { AgentGuardConfig } from '../config.js'; import { redactText } from './redaction.js'; const PUBLIC_KEY_EXAMPLES = ['TYPESAFE_API_KEY=', 'AGENTGUARD_API_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[1], +): string { + const links = new Map(); + 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(); + 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 { + 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) => diff --git a/src/tests/action.test.ts b/src/tests/action.test.ts index 26eed50..1abf4a6 100644 --- a/src/tests/action.test.ts +++ b/src/tests/action.test.ts @@ -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); diff --git a/src/tests/claude-code-lifecycle.test.ts b/src/tests/claude-code-lifecycle.test.ts index a30d6a8..1f3e8ce 100644 --- a/src/tests/claude-code-lifecycle.test.ts +++ b/src/tests/claude-code-lifecycle.test.ts @@ -400,15 +400,109 @@ describe('Claude Code native lifecycle hooks', () => { }); assert.ok(display.output.hookSpecificOutput.displayContent.includes(activationUrl)); assert.doesNotMatch(display.text, /display-secret/); + const pendingConfig = { ...fixture.config, agentJwt: undefined }; + const pendingDisplay = await runHook(pendingConfig, { + hook_event_name: 'MessageDisplay', session_id: 'sess-display-pending', cwd: fixture.root, + delta: `Open ${activationUrl} api_key=sk-pending-other-secret-1234567890`, + }); + assert.ok(pendingDisplay.output.hookSpecificOutput.displayContent.includes(activationUrl)); + assert.doesNotMatch(pendingDisplay.text, /pending-other-secret/); + }); + + it('passes activation URLs from unknown output without exempting other secrets', async () => { + const fixture = lifecycleFixture(); + const url = `https://${['staging', 'example.invalid'].join('.')}/activate?token=fixture-link-1234567890`; + const call = (stdout: string) => ({ tool_name: 'Bash', + tool_input: { command: 'printf result' }, tool_response: { stdout, exit_code: 0 } }); + const post = await runHook(fixture.config, { + hook_event_name: 'PostToolUse', session_id: 'sess-activation-url', cwd: fixture.root, + ...call(`Open ${url}\n`), + }); + assert.notEqual(post.result.decision.decision, 'block'); + assert.ok(post.output.hookSpecificOutput.updatedToolOutput.stdout.includes(url)); + const search = await runHook(fixture.config, { + hook_event_name: 'PostToolUse', session_id: 'sess-activation-search', cwd: fixture.root, + tool_name: 'WebSearch', tool_input: { query: 'activation help' }, + tool_response: { content: `Open ${url}` }, + }); + assert.notEqual(search.result.decision.decision, 'block'); + assert.ok(search.output.hookSpecificOutput.updatedToolOutput.content.includes(url)); + const searchMixed = await runHook(fixture.config, { + hook_event_name: 'PostToolUse', session_id: 'sess-activation-search-mixed', cwd: fixture.root, + tool_name: 'WebSearch', tool_input: { query: 'activation help' }, + tool_response: { content: `Open ${url}\napi_key=sk-search-other-secret-1234567890` }, + }); + assert.equal(searchMixed.result.decision.decision, 'block'); + assert.ok(searchMixed.output.hookSpecificOutput.updatedToolOutput.content.includes(url)); + assert.doesNotMatch(searchMixed.text, /search-other-secret/); + const batch = await runHook(fixture.config, { + hook_event_name: 'PostToolBatch', session_id: 'sess-activation-batch', cwd: fixture.root, + tool_calls: [call(`Open ${url}\n`)], + }); + assert.notEqual(batch.result.decision.decision, 'block'); + const display = await runHook(fixture.config, { + hook_event_name: 'MessageDisplay', session_id: 'sess-activation-display', cwd: fixture.root, + delta: `Open ${url}`, + }); + assert.notEqual(display.result.decision.decision, 'block'); + assert.equal(display.text, ''); + const mixedDisplay = await runHook(fixture.config, { + hook_event_name: 'MessageDisplay', session_id: 'sess-activation-display-mixed', cwd: fixture.root, + delta: `Open ${url} api_key=sk-display-other-secret-1234567890`, + }); + assert.ok(mixedDisplay.output.hookSpecificOutput.displayContent.includes(url)); + assert.doesNotMatch(mixedDisplay.text, /display-other-secret/); + const mixed = await runHook(fixture.config, { + hook_event_name: 'PostToolBatch', session_id: 'sess-activation-mixed', cwd: fixture.root, + tool_calls: [call(`Open ${url}\napi_key=sk-unrelated-secret-1234567890`)], + }); + assert.equal(mixed.result.decision.decision, 'block'); + const mixedPost = await runHook(fixture.config, { + hook_event_name: 'PostToolUse', session_id: 'sess-activation-mixed-post', cwd: fixture.root, + ...call(`Open ${url}\napi_key=sk-unrelated-secret-1234567890`), + }); + assert.equal(mixedPost.result.decision.decision, 'block'); + assert.ok(mixedPost.output.hookSpecificOutput.updatedToolOutput.stdout.includes(url)); + assert.doesNotMatch(mixedPost.text, /unrelated-secret/); + const unrelated = await runHook(fixture.config, { + hook_event_name: 'PostToolBatch', session_id: 'sess-unrelated-token', cwd: fixture.root, + tool_calls: [call('https://example.invalid/reset?token=ordinary-secret-1234567890')], + }); + assert.equal(unrelated.result.decision.decision, 'block'); + const extraQuery = await runHook(fixture.config, { + hook_event_name: 'PostToolBatch', session_id: 'sess-extra-query-token', cwd: fixture.root, + tool_calls: [call(`${url}&api_key=sk-query-secret-1234567890`)], + }); + assert.equal(extraQuery.result.decision.decision, 'block'); + const adjacentSecret = await runHook(fixture.config, { + hook_event_name: 'PostToolBatch', session_id: 'sess-adjacent-secret', cwd: fixture.root, + tool_calls: [call(`${url},api_key=sk-adjacent-secret-1234567890`)], + }); + assert.equal(adjacentSecret.result.decision.decision, 'block'); + const pre = await runHook(fixture.config, { + hook_event_name: 'PreToolUse', session_id: 'sess-activation-egress', cwd: fixture.root, + tool_name: 'Bash', tool_input: { command: `curl -fsS '${url}'` }, + }); + assert.ok(pre.result.event.input.includes(url), 'execution checks retain the URL'); + const inputOnlyBatch = await runHook(fixture.config, { + hook_event_name: 'PostToolBatch', session_id: 'sess-activation-input-only', cwd: fixture.root, + tool_calls: [{ tool_name: 'Bash', tool_input: { command: `curl -fsS '${url}'` }, + tool_response: { stdout: 'done' } }], + }); + assert.ok(inputOnlyBatch.result.event.input.includes(url), 'batch inputs remain inspectable'); + const audit = readFileSync(fixture.config.auditPath, 'utf8'); + assert.doesNotMatch(audit, /fixture-link-1234567890|unrelated-secret-1234567890/); }); it('retains installer approval and passes its post results unchanged', async () => { const fixture = lifecycleFixture(); - const command = `curl -fsSL https://${['staging', 'example', 'invalid'].join('.')}/install.sh | bash`; - const output = 'AgentGuard Cloud — Local-first AI agent protection\n' - + '[1/4] Installing open-source AgentGuard CLI...\n' - + '[2/4] Preparing local AgentGuard config...\n' - + 'api_key=sk-installer-secret-1234567890'; + const command = `curl -fsSL https://${['example', 'invalid'].join('.')}/install.sh` + + ' | AGENTGUARD_PACKAGE=@goplus/agentguard@1.2.4-beta.2 bash'; + const activationUrl = `https://${['example', 'invalid'].join('.')}/activate?token=fixture-activation-token-1234567890`; + const output = '[4/4] Connecting to AgentGuard Cloud...\n' + + `AgentGuard Cloud activation is ready:\n${activationUrl}\n` + + 'AgentGuard is installed.\nNext steps:\n' + + '1. Run: agentguard status\n2. Run: agentguard doctor'; const pre = await runHook(fixture.config, { hook_event_name: 'PreToolUse', session_id: 'sess-installer', cwd: fixture.root, tool_name: 'Bash', tool_input: { command } }); @@ -422,6 +516,13 @@ describe('Claude Code native lifecycle hooks', () => { ? { tool_calls: [ownCall] } : ownCall) } }); assert.equal(result, null, event); } + fixture.config.agentJwt = 'fixture-jwt'; + fixture.config.agentRegisterUrl = activationUrl; + const display = await runHook(fixture.config, { hook_event_name: 'MessageDisplay', + session_id: 'sess-installer', cwd: fixture.root, + delta: `Open ${activationUrl} and then run agentguard status.`, + }); + assert.equal(display.text, '', 'the saved activation link does not need display rewriting'); const priorNative = process.env.AGENTGUARD_CLAUDE_HOOK; process.env.AGENTGUARD_CLAUDE_HOOK = '1'; try { @@ -572,9 +673,10 @@ describe('Claude Code native lifecycle hooks', () => { hook_event_name: 'PostToolBatch', session_id: 'sess-installed-installer', cwd: nested, tool_calls: [{ tool_name: 'Bash', - tool_input: { command: `curl -fsSL https://${['staging', 'example', 'invalid'].join('.')}/install.sh | bash` }, + tool_input: { command: `curl -fsSL https://${['example', 'invalid'].join('.')}/install.sh` + + ' | AGENTGUARD_PACKAGE=@goplus/agentguard@1.2.4-beta.2 bash' }, tool_response: [{ type: 'text', text: - `AgentGuard Cloud — Local-first AI agent protection\n[1/4] Installing open-source AgentGuard CLI...\n[2/4] Preparing local AgentGuard config...\napi_key=${secret}\nAgentGuard is installed.` }], + `[4/4] Connecting to AgentGuard Cloud...\nAgentGuard Cloud activation is ready:\napi_key=${secret}\nAgentGuard is installed.\nNext steps:\n1. Run: agentguard status\n2. Run: agentguard doctor` }], }], }), encoding: 'utf8', diff --git a/src/tests/codex-hooks.test.ts b/src/tests/codex-hooks.test.ts index 1048d50..8576fba 100644 --- a/src/tests/codex-hooks.test.ts +++ b/src/tests/codex-hooks.test.ts @@ -12,6 +12,11 @@ const installerOutput = 'AgentGuard Cloud — Local-first AI agent protection\n' + '[1/4] Installing open-source AgentGuard CLI...\n' + '[2/4] Preparing local AgentGuard config...\n' + 'api_key=sk-installer-secret-1234567890'; +const installerTail = '[4/4] Connecting to AgentGuard Cloud...\n' + + 'AgentGuard Cloud activation is ready:\n' + + 'AgentGuard is installed.\nNext steps:\n' + + '1. Run: agentguard status\n2. Run: agentguard doctor\n' + + 'api_key=sk-installer-secret-1234567890'; interface HookRun { status: number | null; @@ -51,8 +56,20 @@ describe('installed Codex native hooks', () => { it('attributes installer results by output signature without trusting a URL', () => { assert.equal(isAgentGuardOwnedOutputResult(installerCommand, { output: installerOutput }), true); + assert.equal(isAgentGuardOwnedOutputResult(installerCommand, { output: `${'package log\n'.repeat(18000)}${installerTail}` }), true); + assert.equal(isAgentGuardOwnedOutputResult(installerCommand, { + stdout: installerTail, stderr: 'package warning\n'.repeat(10000), + }), true); + assert.equal(isAgentGuardOwnedOutputResult(installerCommand.replace(' | bash', + ' | AGENTGUARD_PACKAGE=@goplus/agentguard@1.2.4-beta.2 bash'), { output: installerTail }), true); + assert.equal(isAgentGuardOwnedOutputResult(installerCommand.replace(' | bash', + ' | env AGENTGUARD_AGENT=claude-code AGENTGUARD_CLOUD_URL=https://example.invalid bash'), + { output: installerTail }), true); assert.equal(isAgentGuardOwnedOutputResult(installerCommand, { output: `${'other output '.repeat(200)}${installerOutput}` }), false); + assert.equal(isAgentGuardOwnedOutputResult(installerCommand, { output: 'AgentGuard is installed.\napi_key=sk-unrelated-1234567890' }), false); assert.equal(isAgentGuardOwnedOutputResult(installerCommand, { output: 'api_key=sk-unrelated-1234567890' }), false); + assert.equal(isAgentGuardOwnedOutputResult(installerCommand.replace(' | bash', + ' | AGENTGUARD_API_KEY=sk-unrelated-1234567890 bash'), { output: installerTail }), false); assert.equal(isAgentGuardOwnedOutputResult(`${installerCommand}; echo extra`, { output: installerOutput }), false); assert.equal(isAgentGuardOwnedOutputResult(installerCommand.replace('https:', 'http:'), { output: installerOutput }), false); assert.equal(isAgentGuardOwnedOutputResult(installerCommand.replace('/install.sh', '/other.sh'), { output: installerOutput }), false); @@ -61,7 +78,8 @@ describe('installed Codex native hooks', () => { it('keeps installer approval and passes attributed output through', () => { const fixture = installFixture(); - const command = installerCommand; + const command = installerCommand.replace(' | bash', + ' | AGENTGUARD_PACKAGE=@goplus/agentguard@1.2.4-beta.2 bash'); const base = { session_id: 'sess-site-install', turn_id: 'turn-site-install', cwd: fixture.project, tool_name: 'Bash', tool_use_id: 'tool-site-install', tool_input: { command } }; @@ -69,7 +87,7 @@ describe('installed Codex native hooks', () => { assert.equal(pre.status, 0); assert.equal(JSON.parse(pre.stdout).hookSpecificOutput.permissionDecision, 'deny'); const post = fixture.run('agentguard-post-tool.sh', { ...base, hook_event_name: 'PostToolUse', - tool_response: { output: installerOutput, exit_code: 0 } }); + tool_response: { output: installerTail, exit_code: 0 } }); assert.equal(post.status, 0); assert.equal(post.stdout.trim(), ''); const unrelated = fixture.run('agentguard-post-tool.sh', { ...base, hook_event_name: 'PostToolUse', @@ -391,6 +409,26 @@ describe('installed Codex native hooks', () => { } }); + it('allows an activation URL from an unrelated command while keeping other output protected', () => { + const fixture = installFixture(); + const url = `https://${['staging', 'example.invalid'].join('.')}/activate?token=codex-link-1234567890`; + const runPost = (output: string) => fixture.run('agentguard-post-tool.sh', { + hook_event_name: 'PostToolUse', session_id: 'sess-activation', turn_id: 'turn-activation', + cwd: fixture.project, tool_name: 'functions.exec_command', tool_use_id: 'tool-activation', + tool_input: { cmd: 'printf result' }, tool_response: { output, exit_code: 0 }, + }); + const allowed = runPost(`Open ${url}\n`); + assert.equal(allowed.status, 0); + assert.equal(allowed.stdout.trim(), ''); + const mixed = runPost(`Open ${url}\napi_key=sk-codex-other-secret-1234567890`); + assert.equal(mixed.status, 0); + assert.doesNotMatch(mixed.stdout + mixed.stderr, /codex-other-secret/); + const audit = readFileSync(join(fixture.home, 'audit.jsonl'), 'utf8'); + assert.doesNotMatch(audit, /codex-link-1234567890|codex-other-secret-1234567890/); + assert.ok(audit.split('\n').filter(Boolean) + .some((line) => (JSON.parse(line) as { decision: string }).decision === 'block')); + }); + it('keeps real init and privacy status working with the public key example', () => { const fixture = installFixture(); const post = (cmd: string, output: string) => fixture.run('agentguard-post-tool.sh', {