From 2a8babe66565dd1840817a38f0068840083a38a7 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Tue, 23 Jun 2026 16:58:56 +0000 Subject: [PATCH 01/11] feat(secrets): add sensitive-key registry for at-rest encryption --- .../secrets/__tests__/sensitive-keys.test.ts | 27 ++++++++++++++ src/lib/secrets/sensitive-keys.ts | 36 +++++++++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 src/lib/secrets/__tests__/sensitive-keys.test.ts create mode 100644 src/lib/secrets/sensitive-keys.ts diff --git a/src/lib/secrets/__tests__/sensitive-keys.test.ts b/src/lib/secrets/__tests__/sensitive-keys.test.ts new file mode 100644 index 000000000..28e167c98 --- /dev/null +++ b/src/lib/secrets/__tests__/sensitive-keys.test.ts @@ -0,0 +1,27 @@ +import { isSensitiveKey } from '../sensitive-keys'; +import { describe, expect, it } from 'vitest'; + +describe('isSensitiveKey', () => { + it.each([ + 'AGENTCORE_CREDENTIAL_MGR_CONN_API_KEY_SECRET', + 'AGENTCORE_CREDENTIAL_MGR_CONN_WALLET_SECRET', + 'AGENTCORE_CREDENTIAL_MGR_CONN_AUTHORIZATION_PRIVATE_KEY', + 'AGENTCORE_CREDENTIAL_MGR_CONN_APP_SECRET', + 'AGENTCORE_CREDENTIAL_GW_CLIENT_SECRET', + 'SOME_API_KEY', + 'AGENTCORE_CREDENTIAL_MYMODELKEY', // bare model-provider key + ])('treats %s as sensitive', key => { + expect(isSensitiveKey(key)).toBe(true); + }); + + it.each([ + 'AGENTCORE_CREDENTIAL_MGR_CONN_API_KEY_ID', + 'AGENTCORE_CREDENTIAL_MGR_CONN_APP_ID', + 'AGENTCORE_CREDENTIAL_MGR_CONN_AUTHORIZATION_ID', + 'AGENTCORE_CREDENTIAL_GW_CLIENT_ID', + 'SOME_RANDOM_CONFIG', + 'PORT', + ])('treats %s as non-sensitive', key => { + expect(isSensitiveKey(key)).toBe(false); + }); +}); diff --git a/src/lib/secrets/sensitive-keys.ts b/src/lib/secrets/sensitive-keys.ts new file mode 100644 index 000000000..530e56b44 --- /dev/null +++ b/src/lib/secrets/sensitive-keys.ts @@ -0,0 +1,36 @@ +/** + * Single source of truth for "is this env value a secret that must be encrypted + * at rest." Used by env.ts on write/read; extend with one entry when a new + * secret-bearing credential field is introduced anywhere in the CLI. + */ + +/** Suffixes whose values are secrets. */ +const SECRET_SUFFIXES = [ + '_API_KEY_SECRET', + '_WALLET_SECRET', + '_AUTHORIZATION_PRIVATE_KEY', + '_APP_SECRET', + '_CLIENT_SECRET', + '_API_KEY', +]; + +/** Reference/identifier suffixes that are NOT secrets (stay readable). */ +const REFERENCE_SUFFIXES = ['_API_KEY_ID', '_APP_ID', '_CLIENT_ID', '_AUTHORIZATION_ID']; + +export const SENSITIVE_KEY_PATTERNS: RegExp[] = SECRET_SUFFIXES.map(s => new RegExp(`${s}$`)); + +/** + * Model-provider API keys are stored as the bare `AGENTCORE_CREDENTIAL_` + * (no secret suffix). Treat such a credential var as a secret UNLESS it ends in + * a known reference suffix. + */ +function isBareModelCredential(key: string): boolean { + if (!key.startsWith('AGENTCORE_CREDENTIAL_')) return false; + return !REFERENCE_SUFFIXES.some(suffix => key.endsWith(suffix)); +} + +export function isSensitiveKey(key: string): boolean { + if (REFERENCE_SUFFIXES.some(suffix => key.endsWith(suffix))) return false; + if (SENSITIVE_KEY_PATTERNS.some(re => re.test(key))) return true; + return isBareModelCredential(key); +} From 52a95e66f7a30eb4d51065f272bfa8d8c2bdcd3b Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Tue, 23 Jun 2026 17:01:26 +0000 Subject: [PATCH 02/11] feat(errors): add SecretEncryptionError and SecretDecryptionError --- src/cli/telemetry/schemas/common-shapes.ts | 2 ++ src/lib/errors/types.ts | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/src/cli/telemetry/schemas/common-shapes.ts b/src/cli/telemetry/schemas/common-shapes.ts index 3a11954e4..0e08f3af7 100644 --- a/src/cli/telemetry/schemas/common-shapes.ts +++ b/src/cli/telemetry/schemas/common-shapes.ts @@ -125,6 +125,8 @@ export const ErrorName = z.enum([ 'PollExhaustedError', 'PollTimeoutError', 'ResourceNotFoundError', + 'SecretDecryptionError', + 'SecretEncryptionError', 'ServiceError', 'ServiceQuotaError', 'ShellKickedError', diff --git a/src/lib/errors/types.ts b/src/lib/errors/types.ts index e7e7f7c33..2993f90ea 100644 --- a/src/lib/errors/types.ts +++ b/src/lib/errors/types.ts @@ -61,6 +61,26 @@ export class AccessDeniedError extends BaseError { } } +/** + * Error thrown when a secret value cannot be encrypted before writing to disk + * (e.g. the machine encryption key could not be created/read). + */ +export class SecretEncryptionError extends BaseError { + constructor(message: string, options?: BaseErrorOptions) { + super(message, { defaultSource: 'client', ...options }); + } +} + +/** + * Error thrown when a stored secret value cannot be decrypted (missing/changed + * machine key, or corrupted ciphertext). + */ +export class SecretDecryptionError extends BaseError { + constructor(message: string, options?: BaseErrorOptions) { + super(message, { defaultSource: 'user', ...options }); + } +} + /** * Error indicating missing system dependencies required for an operation. */ From f6cb1d28ed36297448c0a01867c1e5382991c516 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Tue, 23 Jun 2026 17:03:56 +0000 Subject: [PATCH 03/11] feat(secrets): add AES-256-GCM cipher for at-rest secret envelopes --- src/lib/secrets/__tests__/cipher.test.ts | 33 ++++++++++++++++++++ src/lib/secrets/cipher.ts | 38 ++++++++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 src/lib/secrets/__tests__/cipher.test.ts create mode 100644 src/lib/secrets/cipher.ts diff --git a/src/lib/secrets/__tests__/cipher.test.ts b/src/lib/secrets/__tests__/cipher.test.ts new file mode 100644 index 000000000..e688b6e41 --- /dev/null +++ b/src/lib/secrets/__tests__/cipher.test.ts @@ -0,0 +1,33 @@ +import { ENC_PREFIX, decryptSecret, encryptSecret } from '../cipher'; +import { randomBytes } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; + +const KEY = randomBytes(32); + +describe('cipher', () => { + it('round-trips a secret', () => { + const token = encryptSecret('super-secret-value', KEY); + expect(token.startsWith(ENC_PREFIX)).toBe(true); + expect(token).not.toContain('super-secret-value'); + expect(decryptSecret(token, KEY)).toBe('super-secret-value'); + }); + + it('produces a distinct token each call (random IV)', () => { + expect(encryptSecret('x', KEY)).not.toBe(encryptSecret('x', KEY)); + }); + + it('throws SecretDecryptionError on a tampered token', () => { + const token = encryptSecret('value', KEY); + const tampered = token.slice(0, -2) + (token.endsWith('A') ? 'B' : 'A'); + expect(() => decryptSecret(tampered, KEY)).toThrow(/decrypt/i); + }); + + it('throws SecretDecryptionError on a wrong key', () => { + const token = encryptSecret('value', KEY); + expect(() => decryptSecret(token, randomBytes(32))).toThrow(/decrypt/i); + }); + + it('throws on a non-enc token', () => { + expect(() => decryptSecret('plaintext', KEY)).toThrow(); + }); +}); diff --git a/src/lib/secrets/cipher.ts b/src/lib/secrets/cipher.ts new file mode 100644 index 000000000..884c347c8 --- /dev/null +++ b/src/lib/secrets/cipher.ts @@ -0,0 +1,38 @@ +import { SecretDecryptionError } from '../errors/types'; +import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'; + +export const ENC_PREFIX = 'enc:v1:'; + +const ALGORITHM = 'aes-256-gcm'; +const IV_BYTES = 12; +const TAG_BYTES = 16; + +/** Encrypt a plaintext secret to an `enc:v1:` envelope: base64(IV || tag || ciphertext). */ +export function encryptSecret(plaintext: string, key: Buffer): string { + const iv = randomBytes(IV_BYTES); + const cipher = createCipheriv(ALGORITHM, key, iv); + const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf-8'), cipher.final()]); + const tag = cipher.getAuthTag(); + return ENC_PREFIX + Buffer.concat([iv, tag, ciphertext]).toString('base64'); +} + +/** Decrypt an `enc:v1:` envelope produced by encryptSecret. Throws SecretDecryptionError on any failure. */ +export function decryptSecret(token: string, key: Buffer): string { + if (!token.startsWith(ENC_PREFIX)) { + throw new SecretDecryptionError('Value is not an encrypted secret envelope.'); + } + try { + const raw = Buffer.from(token.slice(ENC_PREFIX.length), 'base64'); + const iv = raw.subarray(0, IV_BYTES); + const tag = raw.subarray(IV_BYTES, IV_BYTES + TAG_BYTES); + const ciphertext = raw.subarray(IV_BYTES + TAG_BYTES); + const decipher = createDecipheriv(ALGORITHM, key, iv); + decipher.setAuthTag(tag); + return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf-8'); + } catch (err) { + throw new SecretDecryptionError( + 'Could not decrypt a stored secret in agentcore/.env.local — the encryption key may be missing or changed. Re-add the credential.', + { cause: err instanceof Error ? err : undefined } + ); + } +} From dd410f003a48072a4c4921a86a46fff7c06460a7 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Tue, 23 Jun 2026 17:09:28 +0000 Subject: [PATCH 04/11] feat(secrets): add key-provider chain (OS keychain -> keyfile fallback) --- .../secrets/__tests__/key-provider.test.ts | 40 ++++++++++ src/lib/secrets/index.ts | 3 + src/lib/secrets/key-provider.ts | 80 +++++++++++++++++++ 3 files changed, 123 insertions(+) create mode 100644 src/lib/secrets/__tests__/key-provider.test.ts create mode 100644 src/lib/secrets/index.ts create mode 100644 src/lib/secrets/key-provider.ts diff --git a/src/lib/secrets/__tests__/key-provider.test.ts b/src/lib/secrets/__tests__/key-provider.test.ts new file mode 100644 index 000000000..33ea7689b --- /dev/null +++ b/src/lib/secrets/__tests__/key-provider.test.ts @@ -0,0 +1,40 @@ +import { resolveEncryptionKey } from '../key-provider'; +import { existsSync, mkdtempSync, rmSync, statSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +describe('resolveEncryptionKey (keyfile fallback)', () => { + let dir: string; + const prev = { cfg: process.env.AGENTCORE_CONFIG_DIR, noKc: process.env.AGENTCORE_DISABLE_KEYCHAIN }; + + beforeEach(async () => { + dir = mkdtempSync(join(tmpdir(), 'agentcore-key-')); + process.env.AGENTCORE_CONFIG_DIR = dir; + process.env.AGENTCORE_DISABLE_KEYCHAIN = '1'; + const { __resetKeyCacheForTests } = await import('../key-provider'); + __resetKeyCacheForTests(); + }); + afterEach(() => { + process.env.AGENTCORE_CONFIG_DIR = prev.cfg; + process.env.AGENTCORE_DISABLE_KEYCHAIN = prev.noKc; + rmSync(dir, { recursive: true, force: true }); + }); + + it('creates a 32-byte 0600 keyfile and returns the key', async () => { + const key = await resolveEncryptionKey(); + expect(key).toHaveLength(32); + const keyfile = join(dir, 'secrets.key'); + expect(existsSync(keyfile)).toBe(true); + // 0600 => mode & 0o777 === 0o600 (skip exact check on Windows) + if (process.platform !== 'win32') { + expect(statSync(keyfile).mode & 0o777).toBe(0o600); + } + }); + + it('returns a stable key across calls', async () => { + const a = await resolveEncryptionKey(); + const b = await resolveEncryptionKey(); + expect(Buffer.compare(a, b)).toBe(0); + }); +}); diff --git a/src/lib/secrets/index.ts b/src/lib/secrets/index.ts new file mode 100644 index 000000000..89161ab04 --- /dev/null +++ b/src/lib/secrets/index.ts @@ -0,0 +1,3 @@ +export { isSensitiveKey, SENSITIVE_KEY_PATTERNS } from './sensitive-keys'; +export { ENC_PREFIX, encryptSecret, decryptSecret } from './cipher'; +export { resolveEncryptionKey } from './key-provider'; diff --git a/src/lib/secrets/key-provider.ts b/src/lib/secrets/key-provider.ts new file mode 100644 index 000000000..7871e198a --- /dev/null +++ b/src/lib/secrets/key-provider.ts @@ -0,0 +1,80 @@ +import { SecretEncryptionError } from '../errors/types'; +import { randomBytes } from 'node:crypto'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +const KEY_BYTES = 32; +const KEYCHAIN_SERVICE = 'aws-agentcore'; +const KEYCHAIN_ACCOUNT = 'env-local-secret-key'; + +let cachedKey: Buffer | null = null; + +interface KeyringEntry { + getPassword(): string | null; + setPassword(password: string): void; +} + +interface KeyringModule { + Entry: new (service: string, account: string) => KeyringEntry; +} + +/** Keychain is opt-out (headless/CI) and best-effort; any failure falls through to the keyfile. */ +async function tryKeychainKey(): Promise { + if (process.env.AGENTCORE_DISABLE_KEYCHAIN === '1') return null; + try { + // Optional native dependency — dynamic import so a missing/unbuildable + // module degrades to the keyfile instead of failing the CLI. + // @ts-expect-error — @napi-rs/keyring is not declared in package.json; absent by design. + const { Entry } = (await import('@napi-rs/keyring')) as KeyringModule; + const entry = new Entry(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT); + try { + const existing = entry.getPassword(); + if (existing) return Buffer.from(existing, 'base64'); + } catch { + // no stored password yet + } + const key = randomBytes(KEY_BYTES); + entry.setPassword(key.toString('base64')); + return key; + } catch { + return null; + } +} + +function keyfilePath(): string { + const configDir = process.env.AGENTCORE_CONFIG_DIR ?? join(homedir(), '.agentcore'); + return join(configDir, 'secrets.key'); +} + +function keyfileKey(): Buffer { + const path = keyfilePath(); + const configDir = process.env.AGENTCORE_CONFIG_DIR ?? join(homedir(), '.agentcore'); + try { + if (existsSync(path)) { + const key = readFileSync(path); + if (key.length === KEY_BYTES) return key; + } + mkdirSync(configDir, { recursive: true }); + const key = randomBytes(KEY_BYTES); + writeFileSync(path, key, { mode: 0o600 }); + return key; + } catch (err) { + throw new SecretEncryptionError(`Could not create or read the machine encryption key at ${path}.`, { + cause: err instanceof Error ? err : undefined, + }); + } +} + +/** Resolve the 32-byte machine-local encryption key: keychain first, keyfile fallback. */ +export async function resolveEncryptionKey(): Promise { + if (cachedKey) return cachedKey; + const fromKeychain = await tryKeychainKey(); + cachedKey = fromKeychain ?? keyfileKey(); + return cachedKey; +} + +/** Reset the per-process key cache. Only for use in tests. */ +export function __resetKeyCacheForTests(): void { + cachedKey = null; +} From 0df71291b1c5cb3714c7adfc16d7f368acd777ee Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Tue, 23 Jun 2026 17:12:24 +0000 Subject: [PATCH 05/11] refactor(secrets): extract resolveConfigDir helper in key-provider --- src/lib/secrets/key-provider.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/lib/secrets/key-provider.ts b/src/lib/secrets/key-provider.ts index 7871e198a..099c6e94c 100644 --- a/src/lib/secrets/key-provider.ts +++ b/src/lib/secrets/key-provider.ts @@ -42,20 +42,22 @@ async function tryKeychainKey(): Promise { } } +function resolveConfigDir(): string { + return process.env.AGENTCORE_CONFIG_DIR ?? join(homedir(), '.agentcore'); +} + function keyfilePath(): string { - const configDir = process.env.AGENTCORE_CONFIG_DIR ?? join(homedir(), '.agentcore'); - return join(configDir, 'secrets.key'); + return join(resolveConfigDir(), 'secrets.key'); } function keyfileKey(): Buffer { const path = keyfilePath(); - const configDir = process.env.AGENTCORE_CONFIG_DIR ?? join(homedir(), '.agentcore'); try { if (existsSync(path)) { const key = readFileSync(path); if (key.length === KEY_BYTES) return key; } - mkdirSync(configDir, { recursive: true }); + mkdirSync(resolveConfigDir(), { recursive: true }); const key = randomBytes(KEY_BYTES); writeFileSync(path, key, { mode: 0o600 }); return key; From 49f79389cb3d69f892d5edfc87d38968a298cfe4 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Tue, 23 Jun 2026 17:15:45 +0000 Subject: [PATCH 06/11] feat(secrets): encrypt sensitive .env.local values at rest in env.ts --- src/lib/utils/__tests__/env.test.ts | 81 +++++++++++++++++++++++++- src/lib/utils/env.ts | 89 +++++++++++++++++------------ 2 files changed, 132 insertions(+), 38 deletions(-) diff --git a/src/lib/utils/__tests__/env.test.ts b/src/lib/utils/__tests__/env.test.ts index b1ec0f74c..cdf1bd122 100644 --- a/src/lib/utils/__tests__/env.test.ts +++ b/src/lib/utils/__tests__/env.test.ts @@ -1,8 +1,10 @@ -import { getEnvPath, getEnvVar, readEnvFile, setEnvVar, writeEnvFile } from '../env.js'; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'; +import { ENC_PREFIX } from '../../secrets'; +import { __resetKeyCacheForTests } from '../../secrets/key-provider'; +import { getEnvPath, getEnvVar, readEnvFile, removeEnvVars, setEnvVar, writeEnvFile } from '../env.js'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; describe('getEnvPath', () => { it('joins configRoot with .env.local', () => { @@ -186,3 +188,76 @@ describe('setEnvVar', () => { } }); }); + +describe('env.ts at-rest encryption', () => { + let root: string; // acts as configRoot (contains .env.local) + let cfgDir: string; + const prev = { cfg: process.env.AGENTCORE_CONFIG_DIR, noKc: process.env.AGENTCORE_DISABLE_KEYCHAIN }; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'agentcore-env-')); + cfgDir = mkdtempSync(join(tmpdir(), 'agentcore-cfg-')); + process.env.AGENTCORE_CONFIG_DIR = cfgDir; + process.env.AGENTCORE_DISABLE_KEYCHAIN = '1'; + __resetKeyCacheForTests(); + }); + afterEach(() => { + process.env.AGENTCORE_CONFIG_DIR = prev.cfg; + process.env.AGENTCORE_DISABLE_KEYCHAIN = prev.noKc; + __resetKeyCacheForTests(); + rmSync(root, { recursive: true, force: true }); + rmSync(cfgDir, { recursive: true, force: true }); + }); + + it('encrypts sensitive values on write, leaves reference values plaintext', async () => { + await writeEnvFile( + { + AGENTCORE_CREDENTIAL_M_C_API_KEY_SECRET: 'sek-123', + AGENTCORE_CREDENTIAL_M_C_API_KEY_ID: 'id-abc', + }, + root + ); + const onDisk = readFileSync(join(root, '.env.local'), 'utf-8'); + expect(onDisk).not.toContain('sek-123'); + expect(onDisk).toContain(`API_KEY_SECRET="${ENC_PREFIX}`); + expect(onDisk).toContain('API_KEY_ID="id-abc"'); // reference stays plaintext + }); + + it('decrypts transparently on read', async () => { + await writeEnvFile({ AGENTCORE_CREDENTIAL_M_C_API_KEY_SECRET: 'sek-123' }, root); + const env = await readEnvFile(root); + expect(env.AGENTCORE_CREDENTIAL_M_C_API_KEY_SECRET).toBe('sek-123'); + }); + + it('reads legacy plaintext secret and re-encrypts it on next write (self-migration)', async () => { + mkdirSync(root, { recursive: true }); + writeFileSync(join(root, '.env.local'), 'AGENTCORE_CREDENTIAL_M_C_API_KEY_SECRET="legacy-plain"\n'); + // read returns plaintext untouched + expect((await readEnvFile(root)).AGENTCORE_CREDENTIAL_M_C_API_KEY_SECRET).toBe('legacy-plain'); + // any write re-encrypts all sensitive values + await writeEnvFile({ OTHER: 'x' }, root); + const onDisk = readFileSync(join(root, '.env.local'), 'utf-8'); + expect(onDisk).not.toContain('legacy-plain'); + expect(onDisk).toContain(`API_KEY_SECRET="${ENC_PREFIX}`); + expect((await readEnvFile(root)).AGENTCORE_CREDENTIAL_M_C_API_KEY_SECRET).toBe('legacy-plain'); + }); + + it('does not double-encrypt an already-encrypted value', async () => { + await writeEnvFile({ AGENTCORE_CREDENTIAL_M_C_API_KEY_SECRET: 'sek-123' }, root); + await writeEnvFile({ OTHER: 'y' }, root); // merge re-writes existing values + const env = await readEnvFile(root); + expect(env.AGENTCORE_CREDENTIAL_M_C_API_KEY_SECRET).toBe('sek-123'); + }); + + it('removeEnvVars rewrites remaining secrets still encrypted', async () => { + await writeEnvFile( + { AGENTCORE_CREDENTIAL_M_C_API_KEY_SECRET: 'sek-1', AGENTCORE_CREDENTIAL_M_D_API_KEY_SECRET: 'sek-2' }, + root + ); + await removeEnvVars(['AGENTCORE_CREDENTIAL_M_C_API_KEY_SECRET'], root); + const onDisk = readFileSync(join(root, '.env.local'), 'utf-8'); + expect(onDisk).not.toContain('sek-1'); + expect(onDisk).not.toContain('sek-2'); + expect((await readEnvFile(root)).AGENTCORE_CREDENTIAL_M_D_API_KEY_SECRET).toBe('sek-2'); + }); +}); diff --git a/src/lib/utils/env.ts b/src/lib/utils/env.ts index f3884e096..bbdc3fb5a 100644 --- a/src/lib/utils/env.ts +++ b/src/lib/utils/env.ts @@ -1,5 +1,6 @@ import { ENV_FILE } from '../constants'; import { findConfigRoot } from '../schemas/io/path-resolver'; +import { ENC_PREFIX, decryptSecret, encryptSecret, isSensitiveKey, resolveEncryptionKey } from '../secrets'; import { parse } from 'dotenv'; import { existsSync } from 'fs'; import { readFile, writeFile } from 'fs/promises'; @@ -15,13 +16,35 @@ export function getEnvPath(configRoot?: string): string { return join(root, ENV_FILE); } +/** Escape and serialize an env record to dotenv `KEY="value"` lines. */ +function serializeEnv(env: Record): string { + const lines = Object.entries(env) + .map(([k, v]) => { + if (v === undefined || v === null) return ''; + if (!/^[a-z_][a-z0-9_]*$/i.test(k)) throw new Error(`Invalid env key: ${k}`); + const val = String(v).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r'); + return `${k}="${val}"`; + }) + .filter(Boolean); + return lines.length > 0 ? lines.join('\n') + '\n' : ''; +} + /** - * Read agentcore/.env.local. + * Read agentcore/.env.local. Sensitive values stored as `enc:v1:` envelopes are + * decrypted transparently; legacy plaintext values are returned as-is. */ export async function readEnvFile(configRoot?: string): Promise> { const path = getEnvPath(configRoot); if (!existsSync(path)) return {}; - return parse(await readFile(path, 'utf-8')); + const parsed = parse(await readFile(path, 'utf-8')); + const entries = Object.entries(parsed); + if (!entries.some(([, v]) => v.startsWith(ENC_PREFIX))) return parsed; + const key = await resolveEncryptionKey(); + const out: Record = {}; + for (const [k, v] of entries) { + out[k] = v.startsWith(ENC_PREFIX) ? decryptSecret(v, key) : v; + } + return out; } /** @@ -31,28 +54,34 @@ export async function getEnvVar(key: string, configRoot?: string): Promise): Promise> { + const needsEncryption = Object.entries(env).some( + ([k, v]) => isSensitiveKey(k) && v !== undefined && v !== null && !String(v).startsWith(ENC_PREFIX) + ); + if (!needsEncryption) return env; + const key = await resolveEncryptionKey(); + const out: Record = {}; + for (const [k, v] of Object.entries(env)) { + out[k] = + isSensitiveKey(k) && v !== undefined && v !== null && !String(v).startsWith(ENC_PREFIX) + ? encryptSecret(String(v), key) + : v; + } + return out; +} + /** - * Write to agentcore/.env.local. Merges with existing values by default. + * Write to agentcore/.env.local. Merges with existing values by default and + * encrypts sensitive values at rest. */ export async function writeEnvFile(updates: Record, configRoot?: string, merge = true): Promise { const path = getEnvPath(configRoot); - const current = merge ? await readEnvFile(configRoot) : {}; - const env = { ...current, ...updates }; - - const content = - Object.entries(env) - .map(([k, v]) => { - if (v === undefined || v === null) return ''; - if (!/^[a-z_][a-z0-9_]*$/i.test(k)) throw new Error(`Invalid env key: ${k}`); - - const val = String(v).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r'); - - return `${k}="${val}"`; - }) - .filter(Boolean) - .join('\n') + '\n'; - - await writeFile(path, content); + // Merge against the RAW on-disk values (still encrypted) so we never decrypt + // then re-encrypt unchanged secrets; encryptSensitive skips enc:v1: values. + const current = merge && existsSync(path) ? parse(await readFile(path, 'utf-8')) : {}; + const env = await encryptSensitive({ ...current, ...updates }); + await writeFile(path, serializeEnv(env)); } /** @@ -67,19 +96,9 @@ export async function setEnvVar(key: string, value: string, configRoot?: string) */ export async function removeEnvVars(keys: string[], configRoot?: string): Promise { const path = getEnvPath(configRoot); - const current = await readEnvFile(configRoot); - for (const key of keys) { - delete current[key]; - } - const entries = Object.entries(current); - const content = - entries.length > 0 - ? entries - .map( - ([k, v]) => - `${k}="${String(v).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r')}"` - ) - .join('\n') + '\n' - : ''; - await writeFile(path, content); + if (!existsSync(path)) return; + const current = parse(await readFile(path, 'utf-8')); // raw (encrypted) values + for (const key of keys) delete current[key]; + const env = await encryptSensitive(current); // re-encrypts any legacy plaintext among the remainder + await writeFile(path, serializeEnv(env)); } From 9a0426ee862311e21acf36af15e23c75160d55f7 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Tue, 23 Jun 2026 17:20:16 +0000 Subject: [PATCH 07/11] fix(payment-connector): warn when secrets are passed as literal CLI flags --- src/cli/primitives/PaymentConnectorPrimitive.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/cli/primitives/PaymentConnectorPrimitive.ts b/src/cli/primitives/PaymentConnectorPrimitive.ts index 0dc308b45..8bc81548e 100644 --- a/src/cli/primitives/PaymentConnectorPrimitive.ts +++ b/src/cli/primitives/PaymentConnectorPrimitive.ts @@ -2,6 +2,7 @@ import { findConfigRoot, removeEnvVars, setEnvVar, toError } from '../../lib'; import type { AgentCoreProjectSpec, PaymentProvider } from '../../schema'; import { PaymentConnectorNameSchema, PaymentConnectorSchema, PaymentProviderSchema } from '../../schema'; import type { RemoveResult } from '../commands/remove/types'; +import { ANSI } from '../constants'; import { getErrorMessage } from '../errors'; import type { RemovalPreview, SchemaChange } from '../operations/remove/types'; import { requireTTY } from '../tui/guards/tty'; @@ -447,6 +448,20 @@ export class PaymentConnectorPrimitive extends BasePrimitive v !== undefined); + if (usedLiteralSecretFlag && !cliOptions.json) { + process.stderr.write( + `${ANSI.yellow}Warning: passing secrets as CLI flags exposes them to shell history and the ` + + `process table. Prefer interactive mode (run \`agentcore add payment-connector\` with no ` + + `secret flags) for masked entry.${ANSI.reset}\n` + ); + } + let result: Awaited>; if (provider === 'StripePrivy') { result = await this.add({ From edb0307d2d32c138a49f01f8b0322fd85ab98f12 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Tue, 23 Jun 2026 17:24:12 +0000 Subject: [PATCH 08/11] chore(secrets): add optional @napi-rs/keyring + document at-rest encryption Adds @napi-rs/keyring@^1.3.0 as an optionalDependency so the OS keychain path in key-provider.ts is backed by a declared package. The native module installed successfully on this machine, so the now-redundant @ts-expect-error suppression comment is removed (it was guarding against "module not found"; with the dep declared and resolved, TypeScript no longer needs it). npm ci continues to succeed even when the native build fails on a given platform because it is optional. Updates docs/payments.md with the at-rest encryption subsection and a revised credential rotation section that recommends the re-add path. --- docs/payments.md | 20 ++- npm-shrinkwrap.json | 223 ++++++++++++++++++++++++++++++++ package.json | 3 + src/lib/secrets/key-provider.ts | 1 - 4 files changed, 243 insertions(+), 4 deletions(-) diff --git a/docs/payments.md b/docs/payments.md index 3f7e844c1..90da6f4f2 100644 --- a/docs/payments.md +++ b/docs/payments.md @@ -197,14 +197,28 @@ AGENTCORE_CREDENTIAL_{CREDENTIAL_NAME}_AUTHORIZATION_ID=... `{CREDENTIAL_NAME}` is the connector's credential name uppercased with hyphens replaced by underscores. For example, a credential named `my-cdp-creds` becomes `AGENTCORE_CREDENTIAL_MY_CDP_CREDS_API_KEY_ID`. +### Secret encryption at rest + +Connector secret values in `agentcore/.env.local` (API key secrets, wallet secrets, private keys) are encrypted at rest +with a machine-local key stored outside the project directory (your OS keychain, or `~/.agentcore/secrets.key` on +machines without a keychain). Copying or syncing the project directory does **not** expose the secrets — only the holder +of the machine key can decrypt them. Reference IDs (API key ID, app ID) remain readable. + +To rotate a credential, re-run `agentcore add payment-connector` with the new values (this re-encrypts immediately). +Editing `agentcore/.env.local` by hand still works: paste the new plaintext value and run `agentcore deploy` — the CLI +re-encrypts it on the next write. + ### Credential Rotation -To rotate credentials: +The preferred rotation path is to re-run `agentcore add payment-connector` with the new values — the CLI re-encrypts the +secrets immediately and there is no plaintext window. Hand-editing `.env.local` also works: paste the new plaintext +value and run `agentcore deploy -y` — the CLI re-encrypts on the next write. -1. Update the values in `agentcore/.env.local` +1. Re-run `agentcore add payment-connector` with the new values (preferred), **or** open `agentcore/.env.local`, paste + the new plaintext secret value, and save. 2. Run `agentcore deploy -y` -Deploy automatically updates the PaymentCredentialProvider on AWS with the new secret values. +Deploy automatically updates the PaymentCredentialProvider on AWS with the new (re-encrypted) secret values. ## Deploying with Payments diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 4b2154a23..bbca23fd8 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -95,6 +95,9 @@ "engines": { "node": ">=20" }, + "optionalDependencies": { + "@napi-rs/keyring": "^1.3.0" + }, "peerDependencies": { "aws-cdk-lib": "^2.258.0", "constructs": "^10.0.0" @@ -3925,6 +3928,226 @@ } } }, + "node_modules/@napi-rs/keyring": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring/-/keyring-1.3.0.tgz", + "integrity": "sha512-WrOw/bcXm0f9qHkumlT1QlArXSTWqaY9sunsDpOk+yCCorCKMxvWT/a3xko4EYHVdeZoh00yI2TydXn6eyICDA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/keyring-darwin-arm64": "1.3.0", + "@napi-rs/keyring-darwin-x64": "1.3.0", + "@napi-rs/keyring-freebsd-x64": "1.3.0", + "@napi-rs/keyring-linux-arm-gnueabihf": "1.3.0", + "@napi-rs/keyring-linux-arm64-gnu": "1.3.0", + "@napi-rs/keyring-linux-arm64-musl": "1.3.0", + "@napi-rs/keyring-linux-riscv64-gnu": "1.3.0", + "@napi-rs/keyring-linux-x64-gnu": "1.3.0", + "@napi-rs/keyring-linux-x64-musl": "1.3.0", + "@napi-rs/keyring-win32-arm64-msvc": "1.3.0", + "@napi-rs/keyring-win32-ia32-msvc": "1.3.0", + "@napi-rs/keyring-win32-x64-msvc": "1.3.0" + } + }, + "node_modules/@napi-rs/keyring-darwin-arm64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-arm64/-/keyring-darwin-arm64-1.3.0.tgz", + "integrity": "sha512-pl76hJvdYUBn6I24bXiOBMA9nbDapo3I5B+f3OorjDU4dUMSypXeKbOVehJe8fhgTiH24flMyTS3aAIy43xegQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-darwin-x64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-x64/-/keyring-darwin-x64-1.3.0.tgz", + "integrity": "sha512-YcJtEV5LA3cvA4z3BurgxH5IhTsW1JfIvcAAcqcecwk06Si9F9NqkxbZVIfDwQ8oRHgaBmT3zZJnLAotCrVahw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-freebsd-x64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-freebsd-x64/-/keyring-freebsd-x64-1.3.0.tgz", + "integrity": "sha512-vlLf31TGhfRAaxLDBhg8b89ss0HHD/lyNmL5F3UjSaz5CUXElsJmKYq9fqA/B+cZKUEUcLHHGhF0I/CqcFdaVw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm-gnueabihf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm-gnueabihf/-/keyring-linux-arm-gnueabihf-1.3.0.tgz", + "integrity": "sha512-KiWdMMu/Inz/bHHIAGrnF7r54FZDYXuHO6UFF/rhIrshUsxbMG1Rl9lEymNtqqsVo927G0VYcb02FzWQ3iBQRQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm64-gnu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-gnu/-/keyring-linux-arm64-gnu-1.3.0.tgz", + "integrity": "sha512-eyKGpY40lm9Jvs1aD294XRH4y7+TlJM0YVAryZeXA6TX0mb4gMkxVXwSQv7MCwgah7raeUd0dKUb4BPAYIgcMg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm64-musl": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-musl/-/keyring-linux-arm64-musl-1.3.0.tgz", + "integrity": "sha512-iIK6JWHXAJqDrEyLY3TmswwloVyt2vj+04TZnew+uSJ9gnDO8EwRbp3/iw3LpWaXiDO7VomGO6y8I0Id8uBZSw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-riscv64-gnu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-riscv64-gnu/-/keyring-linux-riscv64-gnu-1.3.0.tgz", + "integrity": "sha512-/PGqrwn6EwgtK6vccASSXJRfOSP4vN1F4ASsIQ+7MdrK6hNvAJ1FZPrIuD5gGGdxezo3F++To2Wq7DbuGIeuNQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-x64-gnu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-gnu/-/keyring-linux-x64-gnu-1.3.0.tgz", + "integrity": "sha512-2PDK1WKWTu9lBGq9VvNEkSlQD3O7YwVpmnyN2M3cy4v7NJ/8gDMd9GXv3G+FVXN13uhp4gnnPBS+ScefmEeD2A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-x64-musl": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-musl/-/keyring-linux-x64-musl-1.3.0.tgz", + "integrity": "sha512-oJ2HkX8YUo46QBkn0pG+HuIKQNqr523q6vBobCn+P95s4C4K6/kLBqHY/1bg5J4ap31DzsznhnFKcfBNBsjCnw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-arm64-msvc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-arm64-msvc/-/keyring-win32-arm64-msvc-1.3.0.tgz", + "integrity": "sha512-tOd3c/uAaeoE4ycVlmAdSvygz0Zt3zdca6Y7gokBeIbaRDWpjDIUOpU3MvML59XAaqyuKGsVVu0F/DZb1lHPmw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-ia32-msvc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-ia32-msvc/-/keyring-win32-ia32-msvc-1.3.0.tgz", + "integrity": "sha512-sPSqeAFZMGqP1R++M2JTza7GQJJ/TpCo6JU6Vcd4jnebvOaEDs9b7eipakU1PJdSvhpC2yXMCNRk9gXfrhuwHQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-x64-msvc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-x64-msvc/-/keyring-win32-x64-msvc-1.3.0.tgz", + "integrity": "sha512-4DnCWXwDc0HRKwyRlG5y0VhKZW2tNRQfKKfyj6IX/KWfDNyq9hn4n+GL1auyDcOO/v8PwnhmYo2+rOOqCkvvOg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", diff --git a/package.json b/package.json index 7247ae70e..5cd149f53 100644 --- a/package.json +++ b/package.json @@ -114,6 +114,9 @@ "yaml": "^2.8.3", "zod": "^4.3.5" }, + "optionalDependencies": { + "@napi-rs/keyring": "^1.3.0" + }, "peerDependencies": { "aws-cdk-lib": "^2.258.0", "constructs": "^10.0.0" diff --git a/src/lib/secrets/key-provider.ts b/src/lib/secrets/key-provider.ts index 099c6e94c..55fb0c25b 100644 --- a/src/lib/secrets/key-provider.ts +++ b/src/lib/secrets/key-provider.ts @@ -25,7 +25,6 @@ async function tryKeychainKey(): Promise { try { // Optional native dependency — dynamic import so a missing/unbuildable // module degrades to the keyfile instead of failing the CLI. - // @ts-expect-error — @napi-rs/keyring is not declared in package.json; absent by design. const { Entry } = (await import('@napi-rs/keyring')) as KeyringModule; const entry = new Entry(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT); try { From e9b66f318cc6c80a352bcc036efe4f227834b77f Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Tue, 23 Jun 2026 17:30:00 +0000 Subject: [PATCH 09/11] fix(build): mark @napi-rs/keyring external so esbuild does not bundle its native binary --- esbuild.config.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esbuild.config.mjs b/esbuild.config.mjs index a76f194af..7b88a3835 100644 --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -56,7 +56,7 @@ await esbuild.build({ banner: { js: `import { createRequire } from 'module'; import { fileURLToPath as __ef } from 'url'; import { dirname as __ed } from 'path'; const require = createRequire(import.meta.url); const __filename = __ef(import.meta.url); const __dirname = __ed(__filename);`, }, - external: ['fsevents', '@aws-cdk/toolkit-lib'], + external: ['fsevents', '@aws-cdk/toolkit-lib', '@napi-rs/keyring'], plugins: [optionalDepsPlugin, textLoaderPlugin], }); From 5d42e0ec08096221ec131b08fd711454a4c60bba Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Tue, 23 Jun 2026 17:37:12 +0000 Subject: [PATCH 10/11] test(secrets): assert model-provider keys are encrypted at rest in .env.local --- .../__tests__/multi-agent-credentials.test.ts | 28 +++++++++++++------ 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/src/cli/commands/add/__tests__/multi-agent-credentials.test.ts b/src/cli/commands/add/__tests__/multi-agent-credentials.test.ts index 1d4e0ddba..ac21735ef 100644 --- a/src/cli/commands/add/__tests__/multi-agent-credentials.test.ts +++ b/src/cli/commands/add/__tests__/multi-agent-credentials.test.ts @@ -1,3 +1,4 @@ +import { readEnvFile } from '../../../../lib/utils/env'; import { runCLI } from '../../../../test-utils/index.js'; import { randomUUID } from 'node:crypto'; import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; @@ -47,6 +48,10 @@ describe('multi-agent credential behavior', () => { } } + async function readEnvDecrypted() { + return readEnvFile(join(projectDir, 'agentcore')); + } + describe('credential reuse with same API key', () => { it('first agent creates project-scoped credential', async () => { const result = await runCLI( @@ -76,9 +81,11 @@ describe('multi-agent credential behavior', () => { expect(spec.credentials).toHaveLength(1); expect(spec.credentials[0].name).toBe(`${projectName}Gemini`); - const env = await readEnvLocal(); - expect(env).toContain('AGENTCORE_CREDENTIAL_MULTIAGENTPROJGEMINI='); - expect(env).toContain('KEY1'); + const rawEnv = await readEnvLocal(); + expect(rawEnv).toContain('AGENTCORE_CREDENTIAL_MULTIAGENTPROJGEMINI='); + expect(rawEnv).not.toContain('KEY1'); // encrypted at rest + const env = await readEnvDecrypted(); + expect(env.AGENTCORE_CREDENTIAL_MULTIAGENTPROJGEMINI).toBe('KEY1'); }); it('second agent with same key reuses credential (no duplicate)', async () => { @@ -151,12 +158,15 @@ describe('multi-agent credential behavior', () => { // Should have 3 agents expect(spec.runtimes).toHaveLength(3); - // .env.local should have both keys - const env = await readEnvLocal(); - expect(env).toContain('AGENTCORE_CREDENTIAL_MULTIAGENTPROJGEMINI='); - expect(env).toContain('KEY1'); - expect(env).toContain('AGENTCORE_CREDENTIAL_MULTIAGENTPROJAGENT3GEMINI='); - expect(env).toContain('KEY2'); + // .env.local should have both keys encrypted at rest, decryptable to original values + const rawEnv = await readEnvLocal(); + expect(rawEnv).toContain('AGENTCORE_CREDENTIAL_MULTIAGENTPROJGEMINI='); + expect(rawEnv).toContain('AGENTCORE_CREDENTIAL_MULTIAGENTPROJAGENT3GEMINI='); + expect(rawEnv).not.toContain('KEY1'); // encrypted at rest + expect(rawEnv).not.toContain('KEY2'); // encrypted at rest + const env = await readEnvDecrypted(); + expect(env.AGENTCORE_CREDENTIAL_MULTIAGENTPROJGEMINI).toBe('KEY1'); + expect(env.AGENTCORE_CREDENTIAL_MULTIAGENTPROJAGENT3GEMINI).toBe('KEY2'); // Generated code should reference correct credentials const agent1Code = await readFile(join(projectDir, 'app/Agent1/model/load.py'), 'utf-8'); From 2f53e08561538d67349aebd1f675f854af1b88ac Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Tue, 23 Jun 2026 17:46:37 +0000 Subject: [PATCH 11/11] test(secrets): assert OAuth client secret is encrypted at rest in add-agent-auth --- integ-tests/add-agent-auth.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/integ-tests/add-agent-auth.test.ts b/integ-tests/add-agent-auth.test.ts index c0fa734ca..67224a199 100644 --- a/integ-tests/add-agent-auth.test.ts +++ b/integ-tests/add-agent-auth.test.ts @@ -1,3 +1,4 @@ +import { readEnvFile } from '../src/lib/utils/env.js'; import { createTestProject, readProjectConfig, runCLI } from '../src/test-utils/index.js'; import type { TestProject } from '../src/test-utils/index.js'; import { readFile } from 'node:fs/promises'; @@ -119,11 +120,13 @@ describe('integration: add BYO agent with CUSTOM_JWT auth', () => { expect(oauthCred!.authorizerType).toBe('OAuthCredentialProvider'); expect((oauthCred as { managed?: boolean }).managed).toBe(true); - // Verify .env.local has client secrets (namespaced per credential) + // Client ID is a reference (plaintext); the client SECRET is encrypted at rest. const envPath = join(project.projectPath, 'agentcore', '.env.local'); const envContent = await readFile(envPath, 'utf-8'); expect(envContent).toContain('my-client-id'); - expect(envContent).toContain('my-client-secret'); + expect(envContent).not.toContain('my-client-secret'); // encrypted at rest + const env = await readEnvFile(join(project.projectPath, 'agentcore')); + expect(env.AGENTCORE_CREDENTIAL_AUTHAGENT2_OAUTH_CLIENT_SECRET).toBe('my-client-secret'); }); it('adds a BYO agent with default AWS_IAM auth (no auth flags)', async () => {