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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
- Added native Windows scheduled jobs and DSH threat-feed subscriptions, self-checks, and active-session notifications.
- Expanded scanning to support HTTPS GitHub repositories with `--ref`, plus DSH profile plugin discovery and risk scanning in `agentguard checkup`.

### Fixed
- Restored redacted native shell-command previews in local audit and Cloud events so safe operational context remains visible, while keeping prompts, tool output, credentials, PII, and unsafe protected-file targets private.
- Prevented protected-file summaries from mistaking redirect destinations or command substitutions for the accessed file.

## [1.1.29] - 2026-08-26

### Added
Expand Down
2 changes: 1 addition & 1 deletion docs/claude-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,6 @@ An `@file` path without an explicit exact deny is `unsupported` and is not count

## Privacy and coverage

Raw prompts, command arguments, tool output, failure text, configuration content, credentials, and PII are evaluated locally. Native Claude hook audit and Cloud records replace raw hook content with `[LOCAL_ONLY_LLM_CONTENT]` and retain only bounded rule IDs, masks, counts, action IDs, coverage, and enforcement metadata. For protected-file tool actions, they may retain only a bounded, validated explanation such as `cat .env` or `cat .ssh/id_ed25519.pub`, without the absolute path, remaining arguments, or command tail. Hook stdout/stderr never echoes raw input.
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.

Claude Code provides strong staged prompt/tool/context protection, but it does not expose the final model HTTP destination, Authorization credential, complete assembled payload, full response, all retries/fallbacks, or auxiliary model calls. Model-transport rules therefore remain `partial` or `unsupported`; AgentGuard does not claim complete model-traffic interception.
8 changes: 4 additions & 4 deletions docs/codex.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,10 @@ terminates the hook or the process cannot return an exit code, Codex's host
behavior applies; this integration does not claim those timeouts fail closed.
Hook stdout and stderr contain only bounded rule ids, risk, action ids, coverage
facts, and redacted reasons. Audit and Cloud events do not contain prompt text,
tool output, credentials, or PII; a protected-file tool action may retain only
a bounded, validated explanation such as `cat .env` or
`cat .ssh/id_ed25519.pub`, without its absolute path, remaining arguments, or
command tail.
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.

## Coverage and known gaps

Expand Down
9 changes: 5 additions & 4 deletions docs/privacy-boundary.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,11 @@ Only redacted, Cloud-eligible runtime audit previews are uploaded by default:

- `sessionId`, `agentHost`, `actionType`, `toolName`
- Redacted `input` preview, capped at 2,000 characters
- For native tool hooks, raw arguments remain local. Protected-file access may
include only a bounded, validated target summary such as `cat .env` or
`cat .ssh/id_ed25519.pub`; absolute paths, additional arguments, and command
tails are omitted.
- Native shell hooks may retain a redacted command preview so operational
context remains visible; recognized credential and PII values are masked.
Protected-file access uses only a bounded, validated target summary such as
`cat .env` or `cat .ssh/id_ed25519.pub`; absolute paths, additional
arguments, redirects, substitutions, and command tails are omitted.
- Decision, risk score, risk level, reasons, and policy version
- Lifecycle stage, coverage level, enforcement status, missing-fact names, and
request correlation IDs
Expand Down
25 changes: 20 additions & 5 deletions src/runtime/audit.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { dirname } from 'node:path';
import type { PolicyReason, RuntimeAction, RuntimeAuditEvent, RuntimePiiSummary, RuntimePrivacyRuleEvaluation, RuntimeSeverity } from './types.js';
import { redactLlmMetadata, redactMetadata, redactPreview, redactReasons } from './redaction.js';
import { redactLlmMetadata, redactMetadata, redactPreview, redactReasons, redactText } from './redaction.js';

export function buildAuditEvent(event: RuntimeAuditEvent): RuntimeAuditEvent {
const nativeHook = isCodexNativeHookAction(event) || isClaudeNativeHookAction(event);
Expand Down Expand Up @@ -130,9 +130,9 @@ function codexSafeReasons(reasons: PolicyReason[]): PolicyReason[] {
* without exporting the raw native-hook command, absolute path, or arguments.
*/
function nativeHookAuditInput(event: RuntimeAuditEvent): string {
if (!event.reasons.some((reason) => reason.code === 'SECRET_ACCESS')) {
return '[LOCAL_ONLY_LLM_CONTENT]';
}
const protectedAccess = event.reasons.some((reason) => reason.code === 'SECRET_ACCESS');
if (event.actionType === 'shell' && !protectedAccess) return nativeHookShellPreview(event);
if (!protectedAccess) return '[LOCAL_ONLY_LLM_CONTENT]';

const protectedFile = safeProtectedFileReference(event);
if (!protectedFile) return '[LOCAL_ONLY_LLM_CONTENT]';
Expand All @@ -146,6 +146,16 @@ function nativeHookAuditInput(event: RuntimeAuditEvent): string {
return `access ${protectedFile}`;
}

function nativeHookShellPreview(event: RuntimeAuditEvent): string {
const redacted = redactText(event.input);
const hasUnmaskedSensitiveFinding = event.reasons.some((reason) => (
NATIVE_SENSITIVE_CONTENT_RULES.has(reason.code)
)) && redacted === event.input;
if (hasUnmaskedSensitiveFinding) return '[LOCAL_ONLY_LLM_CONTENT]';
const preview = redacted.slice(0, 2000);
return preview.trim() ? preview : '[LOCAL_ONLY_LLM_CONTENT]';
}

function safeProtectedFileReference(event: RuntimeAuditEvent): string | undefined {
const input = event.input;
const ssh = input.match(/\.ssh[\\/]([A-Za-z0-9._-]{1,128})(?=$|[\s"';&|)\]},:])/i)?.[1];
Expand Down Expand Up @@ -193,7 +203,8 @@ function safeGenericProtectedTarget(event: RuntimeAuditEvent): string | undefine
if (event.actionType !== 'shell') return undefined;
const command = safeShellCommandName(event.input);
if (!command || !SINGLE_TARGET_FILE_COMMANDS.has(command)) return undefined;
const firstCommand = event.input.split(/[;&|]/, 1)[0] ?? '';
const firstCommand = event.input.split(/[;&|<>\r\n]/, 1)[0] ?? '';
if (/\$\(|`|[()]/.test(firstCommand)) return undefined;
const tokens = firstCommand.match(/"[^"]*"|'[^']*'|[^\s]+/g) ?? [];
for (let index = tokens.length - 1; index >= 0; index -= 1) {
const token = tokens[index]!.replace(/^["']|["']$/g, '');
Expand Down Expand Up @@ -283,6 +294,10 @@ const CODEX_DECISIONS = new Set<unknown>(['allow', 'warn', 'require_approval', '
const CODEX_SEVERITIES = new Set<unknown>(['info', 'low', 'medium', 'high', 'critical']);
const SAFE_RULE_ID = /^[A-Z][A-Z0-9_]{0,63}$/;
const SAFE_ID = /^[A-Za-z0-9_-]{1,160}$/;
const NATIVE_SENSITIVE_CONTENT_RULES = new Set([
'PII_EGRESS', 'DATA_EXFILTRATION', 'LLM_KEY_TO_UNKNOWN_HOST',
'WORKSPACE_BULK_EGRESS', 'DANGEROUS_CONFIG_CHANGE',
]);
const SAFE_FILE_COMMANDS = new Set([
'cat', 'head', 'tail', 'less', 'more', 'grep', 'sed', 'awk',
'cp', 'mv', 'rm', 'touch', 'chmod', 'chown', 'tee',
Expand Down
2 changes: 1 addition & 1 deletion src/runtime/redaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export interface SensitiveDataSummaryItem {
}

const SECRET_VALUE_PATTERN =
/(?:token|api[_-]?key|secret|password|passwd|authorization|access[_-]?key|client[_-]?secret)=([^&\s'"`]+)/gi;
/(?:token|api[_-]?key|secret|password|passwd|authorization|access[_-]?key|client[_-]?secret)=("[^"\r\n]*"|'[^'\r\n]*'|[^&\s'"`]+)/gi;
/**
* The same secret keys in YAML/JSON form, where the separator is `:`.
*
Expand Down
80 changes: 78 additions & 2 deletions src/tests/runtime-cloud.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,44 @@ describe('Runtime Cloud bridge', () => {
assert.doesNotMatch(JSON.stringify(exactCustom), /private\/customer/);
});

it('keeps safe custom protected filenames but not unsafe names or unrelated native-hook content', () => {
it('keeps redacted native shell commands visible without exposing credential values', () => {
const secret = 'sk-native-hook-secret-1234567890';
const input = `touch ~/.agentguard/testfile 2>&1 && export OPENAI_API_KEY="${secret}"`;
const event: RuntimeAuditEvent = {
...sampleEvent(),
agentHost: 'codex',
actionType: 'shell',
toolName: 'Bash',
input,
decision: 'warn',
riskScore: 10,
riskLevel: 'low',
reasons: [{
code: 'SHELL_INJECTION_RISK',
severity: 'low',
title: 'Shell metacharacters',
description: 'The command contains shell metacharacters.',
}],
metadata: { codexHookEvent: 'PreToolUse' },
};

const audit = buildAuditEvent(event);
const cloud = buildCloudAuditEvent(event);
assert.match(audit.input, /^touch ~\/\.agentguard\/testfile/);
assert.match(audit.input, /OPENAI_API_KEY=\[REDACTED\]/);
assert.doesNotMatch(audit.input, new RegExp(secret));
assert.equal(cloud.input, audit.input);

const claude = buildAuditEvent({
...event,
agentHost: 'claude-code',
metadata: { claudeHookEvent: 'PreToolUse' },
input: 'ls -la ~/.agentguard && stat ~/.agentguard/config.json',
});
assert.equal(claude.input, 'ls -la ~/.agentguard && stat ~/.agentguard/config.json');
});

it('keeps safe custom protected filenames but not unsafe names or raw sensitive shell content', () => {
const base: RuntimeAuditEvent = {
...sampleEvent(),
agentHost: 'claude-code',
Expand Down Expand Up @@ -205,7 +242,7 @@ describe('Runtime Cloud bridge', () => {
...base,
input: 'cat /private/customer/sk-never-export-1234567890',
}).input, '[LOCAL_ONLY_LLM_CONTENT]');
assert.equal(buildAuditEvent({
const piiShell = buildAuditEvent({
...base,
input: 'email=private.person@invalid.test',
reasons: [{
Expand All @@ -214,7 +251,43 @@ describe('Runtime Cloud bridge', () => {
title: 'Sensitive content blocked',
description: 'Sensitive content was blocked.',
}],
}).input;
assert.equal(piiShell, '[LOCAL_ONLY_LLM_CONTENT]');
});

it('never mistakes shell redirects or substitutions for the protected target', () => {
const event: RuntimeAuditEvent = {
...sampleEvent(),
agentHost: 'codex',
actionType: 'shell',
toolName: 'Bash',
input: 'cat /private/workspace/protected.txt > /tmp/exported-copy',
decision: 'require_approval',
riskScore: 60,
riskLevel: 'high',
reasons: [{
code: 'SECRET_ACCESS',
severity: 'high',
title: 'Protected path access',
description: 'The command accesses a protected path.',
evidence: '/private/workspace/**',
}],
metadata: { codexHookEvent: 'PreToolUse' },
};

assert.equal(buildAuditEvent(event).input, 'cat protected.txt');
assert.equal(buildAuditEvent({
...event,
input: 'cat -- /private/workspace/protected.txt',
}).input, 'cat protected.txt');
assert.equal(buildAuditEvent({
...event,
input: 'cat /private/workspace/protected.txt $(curl https://example.invalid/secret)',
}).input, '[LOCAL_ONLY_LLM_CONTENT]');
assert.equal(buildAuditEvent({
...event,
input: 'cat /private/workspace/protected.txt && curl https://example.invalid/secret',
}).input, 'cat protected.txt');
});

it('exports only bounded PII category and value counts in audit payloads', async () => {
Expand Down Expand Up @@ -571,6 +644,9 @@ describe('Runtime Cloud bridge', () => {
assert.ok(!redacted.includes('sk-test-secret-value'));
assert.ok(!redacted.includes('secret-value'));
assert.ok(!redacted.includes('abc123'));

const quoted = redactText('export TYPESAFE_API_KEY="apikey_private_value"');
assert.equal(quoted, 'export TYPESAFE_API_KEY=[REDACTED]');
});

it('requires approval for shell commands reading SSH keys by absolute home path', async () => {
Expand Down
Loading