From 7d2349c5f8ad8b186e6b3dbaef70a75a27f9da51 Mon Sep 17 00:00:00 2001 From: Greg Anderson Date: Wed, 12 Aug 2026 16:52:44 -0600 Subject: [PATCH] chore(hardening): widen sensitive-file coverage and apply it to Grep/Glob Treat credential directories as sensitive whatever the file inside is called (.ssh, .gnupg, .aws, .azure, .kube, .config/gcloud, and the CLI's own credentials dir). Private keys and cloud credential files are routinely given local names like deploy_key or work-cluster.json, which a basename list cannot predict. Public keys, known_hosts, .ssh/config and .aws/config carry no secret and stay readable; the `config` exemption is per-directory because .kube/config IS the credential. Add the common credential files by name: .git-credentials, .netrc, .npmrc, .pypirc, .docker/config.json, kubeconfig, and the CLI's own config.toml, which holds the provider API key. Apply the check to Grep and Glob, which previously passed checkSensitive: false and so could search the contents of files Read, Write and Edit refuse to open. Applied to both engine copies of the policy. Co-Authored-By: Claude Opus 4.8 --- .../hardening-sensitive-file-coverage.md | 17 +++++++ .../src/agent/tools/os/glob/globTool.ts | 2 +- .../src/agent/tools/os/grep/grepTool.ts | 2 +- .../agent-core-v2/src/tool/path-access.ts | 47 +++++++++++++++++++ .../test/tool/path-access.test.ts | 40 ++++++++++++++++ .../src/tools/policies/sensitive.ts | 47 +++++++++++++++++++ .../test/tools/policies/sensitive.test.ts | 40 ++++++++++++++++ 7 files changed, 193 insertions(+), 2 deletions(-) create mode 100644 .changeset/hardening-sensitive-file-coverage.md diff --git a/.changeset/hardening-sensitive-file-coverage.md b/.changeset/hardening-sensitive-file-coverage.md new file mode 100644 index 00000000..8e8c63d6 --- /dev/null +++ b/.changeset/hardening-sensitive-file-coverage.md @@ -0,0 +1,17 @@ +--- +'@moonshot-ai/agent-core-v2': minor +'@moonshot-ai/agent-core': minor +--- + +Widen sensitive-file coverage for the file tools. Credential directories +(`.ssh`, `.gnupg`, `.aws`, `.azure`, `.kube`, `.config/gcloud`, the CLI's own +`credentials` dir) are now treated as sensitive whatever the file inside is +called, since private keys and cloud credential files routinely carry local +names a basename list cannot predict. Adds the common credential files +(`.git-credentials`, `.netrc`, `.npmrc`, `.pypirc`, `.docker/config.json`, +`kubeconfig`, the CLI's own `config.toml`). Public keys, `known_hosts`, +`.ssh/config` and `.aws/config` stay readable. + +Grep and Glob now apply the sensitive-file check that Read, Write and Edit +already applied, so searching cannot return the contents of files those tools +refuse to open. diff --git a/packages/agent-core-v2/src/agent/tools/os/glob/globTool.ts b/packages/agent-core-v2/src/agent/tools/os/glob/globTool.ts index 591165af..b27480c7 100644 --- a/packages/agent-core-v2/src/agent/tools/os/glob/globTool.ts +++ b/packages/agent-core-v2/src/agent/tools/os/glob/globTool.ts @@ -152,7 +152,7 @@ export class GlobTool implements IGlobTool { env: this.env, workspace: this.workspaceConfig, operation: 'search', - policy: { guardMode: 'absolute-outside-allowed', checkSensitive: false }, + policy: { guardMode: 'absolute-outside-allowed', checkSensitive: true }, }); } const searchRoots = [path ?? this.workspaceConfig.workspaceDir]; diff --git a/packages/agent-core-v2/src/agent/tools/os/grep/grepTool.ts b/packages/agent-core-v2/src/agent/tools/os/grep/grepTool.ts index c709326c..2fe33aae 100644 --- a/packages/agent-core-v2/src/agent/tools/os/grep/grepTool.ts +++ b/packages/agent-core-v2/src/agent/tools/os/grep/grepTool.ts @@ -127,7 +127,7 @@ export class GrepTool implements IGrepTool { env: this.env, workspace: this.workspace, operation: 'search', - policy: { guardMode: 'absolute-outside-allowed', checkSensitive: false }, + policy: { guardMode: 'absolute-outside-allowed', checkSensitive: true }, }); } const searchPaths = [path ?? this.workspace.workspaceDir]; diff --git a/packages/agent-core-v2/src/tool/path-access.ts b/packages/agent-core-v2/src/tool/path-access.ts index d5362efc..c1f688f2 100644 --- a/packages/agent-core-v2/src/tool/path-access.ts +++ b/packages/agent-core-v2/src/tool/path-access.ts @@ -33,13 +33,49 @@ const SENSITIVE_BASENAMES = new Set([ 'id_ed25519', 'id_ecdsa', 'credentials', + '.git-credentials', + '.netrc', + '_netrc', + '.npmrc', + '.pypirc', + '.dockercfg', + 'kubeconfig', ]); const SENSITIVE_PATH_SUFFIXES = [ ['.aws', 'credentials'], ['.gcp', 'credentials'], + ['.docker', 'config.json'], + ['.kimi-code', 'config.toml'], ]; +/** + * Directories whose contents are credentials whatever the file is called. + * Private keys and cloud credential files are routinely given local names + * (`deploy_key`, `work-cluster.json`), so a basename list cannot cover them. + * Public keys and the host-key caches carry no secret and stay readable. + */ +const SENSITIVE_DIRECTORY_SEGMENTS: readonly (readonly string[])[] = [ + ['.ssh'], + ['.gnupg'], + ['.aws'], + ['.azure'], + ['.kube'], + ['.config', 'gcloud'], + ['.kimi-code', 'credentials'], +]; + +const SENSITIVE_DIRECTORY_EXEMPT_BASENAMES = new Set([ + 'known_hosts', + 'known_hosts.old', +]); + +/** + * `config` is a secret in `.kube` but not in `.ssh` (host aliases) or `.aws` + * (region settings), so the exemption is per-directory rather than by name. + */ +const SENSITIVE_DIRECTORY_EXEMPT_SUFFIXES = ['.ssh/config', '.aws/config']; + const ENV_PREFIX = '.env.'; const ENV_EXEMPTIONS = new Set(['.env.example', '.env.sample', '.env.template']); @@ -94,6 +130,17 @@ export function isSensitiveFile(path: string): boolean { } } + if ( + !comparableName.endsWith('.pub') && + !SENSITIVE_DIRECTORY_EXEMPT_BASENAMES.has(comparableName) && + !SENSITIVE_DIRECTORY_EXEMPT_SUFFIXES.some((suffix) => comparablePath.endsWith(`/${suffix}`)) + ) { + for (const segments of SENSITIVE_DIRECTORY_SEGMENTS) { + const dir = comparable(segments.join('/')); + if (comparablePath.includes(`/${dir}/`)) return true; + } + } + return false; } diff --git a/packages/agent-core-v2/test/tool/path-access.test.ts b/packages/agent-core-v2/test/tool/path-access.test.ts index 9bc6ed5d..ebaa0d15 100644 --- a/packages/agent-core-v2/test/tool/path-access.test.ts +++ b/packages/agent-core-v2/test/tool/path-access.test.ts @@ -64,6 +64,46 @@ describe('isSensitiveFile', () => { expect(isSensitiveFile(path), path).toBe(false); } }); + + it('treats credential directories as sensitive whatever the file is called', () => { + for (const path of [ + '/home/u/.ssh/deploy_key', + '/home/u/.ssh/work-key', + '/home/u/.gnupg/secring.gpg', + '/home/u/.aws/sso-cache.json', + '/home/u/.azure/accessTokens.json', + '/home/u/.kube/config', + '/home/u/.config/gcloud/application_default_credentials.json', + '/home/u/.kimi-code/credentials/kimi.json', + ]) { + expect(isSensitiveFile(path), path).toBe(true); + } + }); + + it('treats well-known credential files as sensitive', () => { + for (const path of [ + '/home/u/.git-credentials', + '/home/u/.netrc', + '/home/u/.npmrc', + '/home/u/.pypirc', + '/home/u/.docker/config.json', + '/home/u/.kimi-code/config.toml', + '/home/u/kubeconfig', + ]) { + expect(isSensitiveFile(path), path).toBe(true); + } + }); + + it('leaves non-secret files inside credential directories readable', () => { + for (const path of [ + '/home/u/.ssh/known_hosts', + '/home/u/.ssh/config', + '/home/u/.ssh/deploy_key.pub', + '/home/u/.aws/config', + ]) { + expect(isSensitiveFile(path), path).toBe(false); + } + }); }); describe('extendWorkspaceWithSkillRoots', () => { diff --git a/packages/agent-core/src/tools/policies/sensitive.ts b/packages/agent-core/src/tools/policies/sensitive.ts index 0cfbc839..a8fa1857 100644 --- a/packages/agent-core/src/tools/policies/sensitive.ts +++ b/packages/agent-core/src/tools/policies/sensitive.ts @@ -15,13 +15,49 @@ const SENSITIVE_BASENAMES = new Set([ 'id_ed25519', 'id_ecdsa', 'credentials', + '.git-credentials', + '.netrc', + '_netrc', + '.npmrc', + '.pypirc', + '.dockercfg', + 'kubeconfig', ]); const SENSITIVE_PATH_SUFFIXES = [ ['.aws', 'credentials'], ['.gcp', 'credentials'], + ['.docker', 'config.json'], + ['.kimi-code', 'config.toml'], ]; +/** + * Directories whose contents are credentials whatever the file is called. + * Private keys and cloud credential files are routinely given local names + * (`deploy_key`, `work-cluster.json`), so a basename list cannot cover them. + * Public keys and the host-key caches carry no secret and stay readable. + */ +const SENSITIVE_DIRECTORY_SEGMENTS: readonly (readonly string[])[] = [ + ['.ssh'], + ['.gnupg'], + ['.aws'], + ['.azure'], + ['.kube'], + ['.config', 'gcloud'], + ['.kimi-code', 'credentials'], +]; + +const SENSITIVE_DIRECTORY_EXEMPT_BASENAMES = new Set([ + 'known_hosts', + 'known_hosts.old', +]); + +/** + * `config` is a secret in `.kube` but not in `.ssh` (host aliases) or `.aws` + * (region settings), so the exemption is per-directory rather than by name. + */ +const SENSITIVE_DIRECTORY_EXEMPT_SUFFIXES = ['.ssh/config', '.aws/config']; + const ENV_PREFIX = '.env.'; const ENV_EXEMPTIONS = new Set(['.env.example', '.env.sample', '.env.template']); @@ -78,5 +114,16 @@ export function isSensitiveFile(path: string): boolean { } } + if ( + !comparableName.endsWith('.pub') && + !SENSITIVE_DIRECTORY_EXEMPT_BASENAMES.has(comparableName) && + !SENSITIVE_DIRECTORY_EXEMPT_SUFFIXES.some((suffix) => comparablePath.endsWith(`/${suffix}`)) + ) { + for (const segments of SENSITIVE_DIRECTORY_SEGMENTS) { + const dir = comparable(segments.join('/')); + if (comparablePath.includes(`/${dir}/`)) return true; + } + } + return false; } diff --git a/packages/agent-core/test/tools/policies/sensitive.test.ts b/packages/agent-core/test/tools/policies/sensitive.test.ts index 1a6958ed..32730726 100644 --- a/packages/agent-core/test/tools/policies/sensitive.test.ts +++ b/packages/agent-core/test/tools/policies/sensitive.test.ts @@ -68,4 +68,44 @@ describe('isSensitiveFile', () => { expect(isSensitiveFile(path), path).toBe(false); } }); + + it('treats credential directories as sensitive whatever the file is called', () => { + for (const path of [ + '/home/u/.ssh/deploy_key', + '/home/u/.ssh/work-key', + '/home/u/.gnupg/secring.gpg', + '/home/u/.aws/sso-cache.json', + '/home/u/.azure/accessTokens.json', + '/home/u/.kube/config', + '/home/u/.config/gcloud/application_default_credentials.json', + '/home/u/.kimi-code/credentials/kimi.json', + ]) { + expect(isSensitiveFile(path), path).toBe(true); + } + }); + + it('treats well-known credential files as sensitive', () => { + for (const path of [ + '/home/u/.git-credentials', + '/home/u/.netrc', + '/home/u/.npmrc', + '/home/u/.pypirc', + '/home/u/.docker/config.json', + '/home/u/.kimi-code/config.toml', + '/home/u/kubeconfig', + ]) { + expect(isSensitiveFile(path), path).toBe(true); + } + }); + + it('leaves non-secret files inside credential directories readable', () => { + for (const path of [ + '/home/u/.ssh/known_hosts', + '/home/u/.ssh/config', + '/home/u/.ssh/deploy_key.pub', + '/home/u/.aws/config', + ]) { + expect(isSensitiveFile(path), path).toBe(false); + } + }); });