diff --git a/.github/workflows/release-main-and-preview.yml b/.github/workflows/release-main-and-preview.yml index 29925d0c6..4bc01ed46 100644 --- a/.github/workflows/release-main-and-preview.yml +++ b/.github/workflows/release-main-and-preview.yml @@ -76,7 +76,7 @@ jobs: registry-url: https://registry.npmjs.org # npm trusted publishing needs npm 11.5.1 or newer, Node 20 bundles npm 10. - run: npm install -g npm@11 - - run: bun install --frozen-lockfile + - run: bun install --frozen-lockfile --os="*" --cpu="*" - name: Resolve release id: release diff --git a/bun.lock b/bun.lock index ec339c0dc..b84bfbb62 100644 --- a/bun.lock +++ b/bun.lock @@ -57,6 +57,9 @@ "secretlint": "^12.2.0", "vitest": "^4.1.0", }, + "optionalDependencies": { + "@napi-rs/keyring": "2.1.1", + }, "peerDependencies": { "typescript": "^5", }, @@ -297,6 +300,32 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@napi-rs/keyring": ["@napi-rs/keyring@2.1.1", "", { "optionalDependencies": { "@napi-rs/keyring-darwin-arm64": "2.1.1", "@napi-rs/keyring-darwin-x64": "2.1.1", "@napi-rs/keyring-freebsd-x64": "2.1.1", "@napi-rs/keyring-linux-arm-gnueabihf": "2.1.1", "@napi-rs/keyring-linux-arm64-gnu": "2.1.1", "@napi-rs/keyring-linux-arm64-musl": "2.1.1", "@napi-rs/keyring-linux-riscv64-gnu": "2.1.1", "@napi-rs/keyring-linux-x64-gnu": "2.1.1", "@napi-rs/keyring-linux-x64-musl": "2.1.1", "@napi-rs/keyring-win32-arm64-msvc": "2.1.1", "@napi-rs/keyring-win32-ia32-msvc": "2.1.1", "@napi-rs/keyring-win32-x64-msvc": "2.1.1" } }, "sha512-7HUq8FHgW/O4s8ESvkxngTC9tOR/IyktdR0ral2VD0C8HDZk86FwPAH4qZyerKwS1DhJ6AhoebIsxcyw+Lbt1Q=="], + + "@napi-rs/keyring-darwin-arm64": ["@napi-rs/keyring-darwin-arm64@2.1.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-H6uxAztj6GSDwnh/1JDHCtAqWvBTr+0+FGwOOw+rY568z7vFOqrJTTboePFuAnJSO7kNvq+yuyXJ9B5Ueco4yA=="], + + "@napi-rs/keyring-darwin-x64": ["@napi-rs/keyring-darwin-x64@2.1.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-RcHBNnOIzqliNU8Z0jc71bPs/PRn4IIO02XE6DV3HxVFAHo4CooNXDySCL+jbHhObLlSy9yynRn+wq5k+IAmRw=="], + + "@napi-rs/keyring-freebsd-x64": ["@napi-rs/keyring-freebsd-x64@2.1.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-IrM8IKBZKQzETI1BlvSpT6lRzC9eTIQ5TgUwcbQSiQTuXTEXAAHHvBYkoHicN2b2uqgAfO3dwN382CGRw0Ownw=="], + + "@napi-rs/keyring-linux-arm-gnueabihf": ["@napi-rs/keyring-linux-arm-gnueabihf@2.1.1", "", { "os": "linux", "cpu": "arm" }, "sha512-KMld1c9TRucQYV/qasA5jS/VZWCpxMOeHflrciOAsfhiVQ8VfA77grlhGD0kiWtz5e+2M6kYLH9iFmQhPROkGw=="], + + "@napi-rs/keyring-linux-arm64-gnu": ["@napi-rs/keyring-linux-arm64-gnu@2.1.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-AcMk+ZDP8H20Ml5vaTuSh/Ibh5vU4lrAavS2tjoJ7EJJBLDYssONINWrvVgy43SfzntgQVDBcU5GlMI+z5IMFA=="], + + "@napi-rs/keyring-linux-arm64-musl": ["@napi-rs/keyring-linux-arm64-musl@2.1.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-wUHecqT9SMUaQCsJO6vtFEsMcfskspXf6+J0AtKsnK8/GnAAb4hf2FHtwp2ZUOecOk1kTW3LPhU5LwTMJ1LLeA=="], + + "@napi-rs/keyring-linux-riscv64-gnu": ["@napi-rs/keyring-linux-riscv64-gnu@2.1.1", "", { "os": "linux", "cpu": "none" }, "sha512-hrfvuY0zxrpJOa4k/LzWkrB/R/nXnniz/I2V39X5pWgxKJ1iZBQI03/wT8OI7BwSv9czSh5LLeX/o5D5ywRHBg=="], + + "@napi-rs/keyring-linux-x64-gnu": ["@napi-rs/keyring-linux-x64-gnu@2.1.1", "", { "os": "linux", "cpu": "x64" }, "sha512-NHfMyYyu6laqRBzgfsB0zN12l+z5gz6xQqmLDT8ijevMMOc9ICcVo2GUJCPSX5WvRBgzui91D4R4QG0tahMurw=="], + + "@napi-rs/keyring-linux-x64-musl": ["@napi-rs/keyring-linux-x64-musl@2.1.1", "", { "os": "linux", "cpu": "x64" }, "sha512-5E2pO2zqOv+HJr2YorAUYmqItv8ts2oa+BccxZ07/I7x3XvxqwZGtRbkp4wM8psv7phhIfgy+0kZ/ItBsGiDhg=="], + + "@napi-rs/keyring-win32-arm64-msvc": ["@napi-rs/keyring-win32-arm64-msvc@2.1.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-gn+Xg3oZmyC/pMIU0HTCyrIsncn2/u47H3JRQ8WKfPgS0KDCytcf2kuCCSShtNETSE5V8Zc18ymgIW5UrOQ5OA=="], + + "@napi-rs/keyring-win32-ia32-msvc": ["@napi-rs/keyring-win32-ia32-msvc@2.1.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-DeMQnalb+ueYjskBBUjwv0b9xuN9vhs/vi2H4q/crERfDWgmqu77rGZOW4Bi9kDc7Za3t29yeZctEBZzjAtXuA=="], + + "@napi-rs/keyring-win32-x64-msvc": ["@napi-rs/keyring-win32-x64-msvc@2.1.1", "", { "os": "win32", "cpu": "x64" }, "sha512-DjRshMlsMdEyZTNpqfQYj60kDrCZLkl4y80U3w5WJQIj7ypToj02+A/hfJw/g68wzbSZ6uXhklyITQ1DABZAUg=="], + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], diff --git a/package.json b/package.json index 91216dc79..e67bb6869 100644 --- a/package.json +++ b/package.json @@ -104,6 +104,9 @@ "yaml": "^2.8.1", "zod": "^4.4.3" }, + "optionalDependencies": { + "@napi-rs/keyring": "2.1.1" + }, "overrides": { "@opentelemetry/core": "^2.10.0", "@aws-cdk/toolkit-lib": { diff --git a/scripts/build.ts b/scripts/build.ts index 359c5d64c..1d506c90f 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -5,6 +5,7 @@ import { existsSync } from "node:fs"; import { chmod } from "node:fs/promises"; import { join, resolve } from "node:path"; import { runWithExitCode } from "../src/runnable"; +import { nativeKeyringPlugin } from "./keyring"; const REPO_ROOT = resolve(import.meta.dir, ".."); const ASSETS_DIR = join(REPO_ROOT, "src", "assets"); @@ -23,7 +24,7 @@ const ASSET_NAMING = "agentcore-assets/[dir]/[name].[ext]"; // __dirname to the *build machine's* path, which resolves on the builder and // fails everywhere else. Binaries therefore have to hand the Toolkit an // explicit copy of anything it would otherwise read from its package. -const EXTERNAL = ["@aws-cdk/toolkit-lib"]; +const EXTERNAL = ["@aws-cdk/toolkit-lib", "@napi-rs/keyring"]; const BOOTSTRAP_TEMPLATE = ["lib", "api", "bootstrap", "bootstrap-template.yaml"]; @@ -202,7 +203,7 @@ async function compile(target: string): Promise { define: DEFINE, root: REPO_ROOT, naming: { asset: ASSET_NAMING }, - plugins: [assetLoaderPlugin(), runtimeShellSdkPlugin()], + plugins: [assetLoaderPlugin(), runtimeShellSdkPlugin(), nativeKeyringPlugin(target)], }); await assertTemplateIsEmbedded(outfile, template); console.log( diff --git a/scripts/keyring.test.ts b/scripts/keyring.test.ts new file mode 100644 index 000000000..b0e33f89a --- /dev/null +++ b/scripts/keyring.test.ts @@ -0,0 +1,44 @@ +import { expect, test } from "bun:test"; +import { mkdtemp, rename, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { keyringBindingPath, nativeKeyringPlugin } from "./keyring"; + +test("compiled keyring loads without an adjacent node_modules directory", async () => { + const root = await mkdtemp(join(tmpdir(), "agentcore-keyring-binary-")); + try { + const entry = join(root, "entry.ts"), + binary = join(root, process.platform === "win32" ? "probe.exe" : "probe"); + const modulePath = join(import.meta.dir, "..", "src", "io", "keyring.ts"); + await writeFile( + entry, + "import { loadKeyring } from " + + JSON.stringify(modulePath) + + ';\nif (typeof loadKeyring().Entry !== "function") throw new Error("Missing native Entry");\nconsole.log("embedded-keyring");\n', + ); + const platform = process.platform === "win32" ? "windows" : process.platform; + const target = "bun-" + platform + "-" + process.arch; + await Bun.build({ + entrypoints: [entry], + compile: { outfile: binary }, + plugins: [nativeKeyringPlugin(target)], + }); + const binding = keyringBindingPath(target); + const hidden = binding + ".probe-hidden"; + await rename(binding, hidden); + try { + const child = Bun.spawn([binary], { cwd: root, stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr, status] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + expect({ status, stderr }).toEqual({ status: 0, stderr: "" }); + expect(stdout.trim()).toBe("embedded-keyring"); + } finally { + await rename(hidden, binding); + } + } finally { + await rm(root, { recursive: true, force: true }); + } +}, 30000); diff --git a/scripts/keyring.ts b/scripts/keyring.ts new file mode 100644 index 000000000..ec41127d2 --- /dev/null +++ b/scripts/keyring.ts @@ -0,0 +1,38 @@ +import { dirname, join } from "node:path"; + +const REPO_ROOT = join(import.meta.dir, ".."); + +const TARGETS: Record = { + "bun-darwin-x64": "darwin-x64", + "bun-darwin-arm64": "darwin-arm64", + "bun-linux-x64": "linux-x64-gnu", + "bun-linux-arm64": "linux-arm64-gnu", + "bun-windows-x64": "win32-x64-msvc", + "bun-windows-arm64": "win32-arm64-msvc", +}; + +export function keyringBindingPath(target: string): string { + const platform = TARGETS[target]; + if (!platform) throw new Error("Unsupported keyring target: " + target); + try { + const manifest = Bun.resolveSync("@napi-rs/keyring-" + platform + "/package.json", REPO_ROOT); + return join(dirname(manifest), "keyring." + platform + ".node"); + } catch (cause) { + throw new Error("Missing keyring binding for " + target + ". Run bun install --os=* --cpu=*.", { + cause, + }); + } +} + +export function nativeKeyringPlugin(target: string): Bun.BunPlugin { + const path = keyringBindingPath(target); + return { + name: "embedded-keyring", + setup(build) { + build.onLoad({ filter: /[/\\]io[/\\]keyring\.ts$/ }, () => ({ + contents: "export function loadKeyring() { return require(" + JSON.stringify(path) + "); }", + loader: "js", + })); + }, + }; +} diff --git a/src/core/project/backends/cdk/credentials.test.ts b/src/core/project/backends/cdk/credentials.test.ts index 9aa9d2dcc..49aea80bc 100644 --- a/src/core/project/backends/cdk/credentials.test.ts +++ b/src/core/project/backends/cdk/credentials.test.ts @@ -340,7 +340,16 @@ describe("createCredentialProvisioner", () => { test("creates an API key provider under the target-scoped name, recorded under the spec name", async () => { const subject = account(); - const input = await project([API_KEY], "AGENTCORE_CREDENTIAL_OPENAI_KEY='sk-live'\n"); + const input = await project([API_KEY]); + const file = new EnvLocalFile(input.rootPath); + await file.insertIfNew([ + { key: "AGENTCORE_CREDENTIAL_OPENAI_KEY", value: "sk-live", comment: "API key" }, + ]); + expect(await Bun.file(file.path).text()).not.toContain("sk-live"); + await writeFile( + file.path, + (await Bun.file(file.path).text()) + "SOME_API_KEY=enc:v1:unrelated\n", + ); const { events, result } = await run(subject.provision, input); @@ -359,6 +368,13 @@ describe("createCredentialProvisioner", () => { }); }); + test("refuses an unreadable encrypted secret before calling Identity", async () => { + const subject = account(); + const input = await project([API_KEY], "AGENTCORE_CREDENTIAL_OPENAI_KEY='enc:v1:broken'\n"); + await expect(run(subject.provision, input)).rejects.toThrow(/Could not decrypt/); + expect(subject.optionsSeen).toEqual([]); + }); + test("creates an API key provider from a Secrets Manager reference", async () => { const secretRef = { secretId: "prod/openai", jsonKey: "apiKey" }; const subject = account(); @@ -391,7 +407,10 @@ describe("createCredentialProvisioner", () => { {}, { AGENTCORE_CREDENTIAL_OPENAI_KEY: "sk-from-env", HOME: "/should/not/matter" }, ); - const input = await project([API_KEY], "AGENTCORE_CREDENTIAL_OPENAI_KEY='sk-from-file'\n"); + const input = await project( + [API_KEY], + "AGENTCORE_CREDENTIAL_OPENAI_KEY=enc:v1:broken\nSOME_API_KEY=enc:v1:unrelated\n", + ); await run(subject.provision, input); diff --git a/src/core/project/backends/cdk/credentials.ts b/src/core/project/backends/cdk/credentials.ts index c76c7b42a..654b1bd77 100644 --- a/src/core/project/backends/cdk/credentials.ts +++ b/src/core/project/backends/cdk/credentials.ts @@ -181,10 +181,20 @@ export function createCredentialProvisioner( // Credential variables set in the process environment win over the file, so a // deploy can be handed its secrets without writing them to disk first. - const env = { - ...(await new EnvLocalFile(project.rootPath).read()), - ...credentialEnvironment(processEnv), - }; + const needed = new Set(declared.flatMap(credentialEnvironmentVariableNames)); + for (const credential of declared) { + if ( + credential.authorizerType === "OAuthCredentialProvider" && + credential.clientId === undefined && + credential.providerConfig === undefined + ) { + needed.add(credentialEnvVarName(credential.name, "_CLIENT_ID")); + } + } + const env = await new EnvLocalFile(project.rootPath).read({ + select: (name) => needed.has(name), + overrides: credentialEnvironment(processEnv), + }); // Every Identity call runs against the deployment target's own credentials // rather than the default chain, in the region the target deploys to. const options: CoreOptions = { region, credentials }; diff --git a/src/core/project/envLocal.test.ts b/src/core/project/envLocal.test.ts index 8fd87d96e..c8d4d1b28 100644 --- a/src/core/project/envLocal.test.ts +++ b/src/core/project/envLocal.test.ts @@ -5,6 +5,7 @@ import { dirname, join } from "node:path"; import { tmpdir } from "node:os"; import { parseEnv } from "node:util"; import { EnvLocalFile } from "./envLocal"; +import { ENC_PREFIX } from "../../secrets/cipher"; const roots: string[] = []; afterEach(async () => { @@ -176,3 +177,77 @@ testPosix("removeKeys keeps owner-only permissions on the rewritten file", async expect((await stat(file.path)).mode & 0o777).toBe(0o600); }); + +test("encrypts API, OAuth and payment secrets while leaving identifiers and placeholders readable", async () => { + const root = await tempRoot(); + const file = new EnvLocalFile(root); + const values = { + AGENTCORE_CREDENTIAL_MODEL: "model-value", + AGENTCORE_CREDENTIAL_OAUTH_CLIENT_SECRET: "oauth-value", + AGENTCORE_CREDENTIAL_PAY_API_KEY_SECRET: "payment-value", + AGENTCORE_CREDENTIAL_PAY_WALLET_SECRET: "wallet-value", + AGENTCORE_CREDENTIAL_PAY_AUTHORIZATION_PRIVATE_KEY: "private-value", + AGENTCORE_CREDENTIAL_PAY_APP_SECRET: "app-value", + AGENTCORE_CREDENTIAL_PAY_API_KEY_ID: "identifier", + EMPTY_API_KEY: "", + }; + await file.insertIfNew( + Object.entries(values).map(([key, value]) => ({ key, value, comment: key })), + ); + const raw = await Bun.file(file.path).text(); + const parsed = parseEnv(raw); + expect(await file.read()).toEqual(values); + for (const [key, value] of Object.entries(values)) { + if (key.endsWith("_ID") || value === "") expect(parsed[key]).toBe(value); + else { + expect(parsed[key]?.startsWith(ENC_PREFIX)).toBe(true); + expect(raw).not.toContain(value); + } + } +}); + +test("migrates legacy plaintext including multiline values without losing comments or unrelated content", async () => { + const root = await tempRoot(); + const file = new EnvLocalFile(root); + const before = + "# user comment\nexport LEGACY_API_KEY = 'first\nsecond' # trailing comment\n" + + "OTHER=keep # other comment\n"; + await Bun.write(file.path, before); + expect(await file.read()).toEqual({ LEGACY_API_KEY: "first\nsecond", OTHER: "keep" }); + await file.insertIfNew([{ key: "ADDED", value: "yes", comment: "new entry" }]); + const raw = await Bun.file(file.path).text(); + expect(raw).not.toContain("first\nsecond"); + expect(raw).toContain("# user comment\n"); + expect(raw).toContain("# trailing comment\nOTHER=keep # other comment\n"); + expect(await file.read()).toEqual({ + LEGACY_API_KEY: "first\nsecond", + OTHER: "keep", + ADDED: "yes", + }); + await file.rollback(); + expect(await Bun.file(file.path).text()).toBe(before); +}); + +test("preserves an existing secret and envelope when the key is skipped or a neighbor is removed", async () => { + const root = await tempRoot(); + const file = new EnvLocalFile(root); + await file.insertIfNew([ + { key: "MODEL_API_KEY", value: "old-value", comment: "secret" }, + { key: "REMOVE", value: "other", comment: "neighbor" }, + ]); + const token = parseEnv(await Bun.file(file.path).text()).MODEL_API_KEY; + await file.insertIfNew([{ key: "MODEL_API_KEY", value: "new-value", comment: "ignored" }]); + await file.removeKeys(["REMOVE"]); + expect(parseEnv(await Bun.file(file.path).text()).MODEL_API_KEY).toBe(token); + expect(await file.read()).toEqual({ MODEL_API_KEY: "old-value" }); +}); + +test("encrypts quote-bearing secrets before serializing them and migrates remaining secrets on removal", async () => { + const root = await tempRoot(); + const file = new EnvLocalFile(root); + await Bun.write(file.path, "LEGACY_API_KEY=legacy-value\nREMOVE=1\n"); + await file.removeKeys(["REMOVE"]); + expect((await Bun.file(file.path).text()).includes("legacy-value")).toBe(false); + await file.insertIfNew([{ key: "MODEL_API_KEY", value: "a'b\"c", comment: "secret" }]); + expect(await file.read()).toEqual({ LEGACY_API_KEY: "legacy-value", MODEL_API_KEY: "a'b\"c" }); +}); diff --git a/src/core/project/envLocal.ts b/src/core/project/envLocal.ts index e81c96b29..1500cb395 100644 --- a/src/core/project/envLocal.ts +++ b/src/core/project/envLocal.ts @@ -3,6 +3,8 @@ import { join } from "node:path"; import { parseEnv } from "node:util"; import { atomicWrite, readTextFile } from "../../io"; import { InputValidationError } from "../../errors"; +import { readEnvFile, type ReadEnvFileOptions } from "../../io/envFile"; +import { LocalSecrets } from "../../io/localSecrets"; import type { EnvLocalEntry } from "../../handlers/project/types"; /** The project-relative path of the local secrets file (read by `agentcore dev`). */ @@ -11,6 +13,8 @@ export const ENV_LOCAL_RELATIVE_PATH = join("agentcore", ".env.local"); const KEY_LINE = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/; const COMMENT_LINE = /^\s*#/; const SECRET_FILE_MODE = 0o600; +const ENV_ENTRY = + /^([ \t]*(?:export[ \t]+)?([A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*)(?:"[^"]*"|'[^']*'|`[^`]*`|[^\r\n#]*)/gm; /** * The project's `.env.local` secrets file, edited transactionally. `insertIfNew` @@ -24,7 +28,10 @@ export class EnvLocalFile { // string: the file's content before the write. private snapshot?: string | null; - constructor(private readonly rootPath: string) {} + constructor( + private readonly rootPath: string, + private readonly secrets = new LocalSecrets(), + ) {} /** The absolute path of the secrets file. */ get path(): string { @@ -55,12 +62,13 @@ export class EnvLocalFile { continue; } const separator = content === "" || content.endsWith("\n") ? "" : "\n"; - // Each entry is two lines: # \n= - content += `${separator}# ${entry.comment}\n${entry.key}=${formatValue(entry.value)}\n`; + const encrypted = await this.secrets.encrypt({ [entry.key]: entry.value ?? "" }); + content += `${separator}# ${entry.comment}\n${entry.key}=${formatValue(encrypted[entry.key])}\n`; written.push(entry.key); } - if (written.length > 0) { + content = await this.encryptContent(content); + if (content !== (existing ?? "")) { this.snapshot = existing; await atomicWrite(this.path, content, { mode: SECRET_FILE_MODE }); } @@ -72,10 +80,8 @@ export class EnvLocalFile { * does not exist. Values are read back with the same parser `agentcore dev` * uses, so quoting written by {@link insertIfNew} round-trips. */ - async read(): Promise> { - const content = await this.readOrNull(); - if (content === null) return {}; - return parseEnv(content); + async read(options: ReadEnvFileOptions = {}): Promise> { + return readEnvFile(this.path, { ...options, secrets: this.secrets }); } /** @@ -108,7 +114,9 @@ export class EnvLocalFile { if (removedKeys.size > 0) { // Preserve the earliest snapshot so rollback undoes this whole operation. if (this.snapshot === undefined) this.snapshot = existing; - await atomicWrite(this.path, kept.join("\n"), { mode: SECRET_FILE_MODE }); + await atomicWrite(this.path, await this.encryptContent(kept.join("\n")), { + mode: SECRET_FILE_MODE, + }); } return { removed: [...targets].filter((key) => removedKeys.has(key)), @@ -135,6 +143,16 @@ export class EnvLocalFile { private async enforcePermissions(): Promise { if (process.platform !== "win32") await chmod(this.path, SECRET_FILE_MODE); } + + private async encryptContent(content: string): Promise { + const parsed = parseEnv(content) as Record; + const encrypted = await this.secrets.encrypt(parsed); + if (encrypted === parsed) return content; + // parseEnv owns value parsing; matching assignment spans preserves comments and multiline values. + return content.replace(ENV_ENTRY, (entry, prefix: string, key: string) => + encrypted[key] !== parsed[key] ? `${prefix}${formatValue(encrypted[key])}` : entry, + ); + } } /** diff --git a/src/core/project/manager.export.test.ts b/src/core/project/manager.export.test.ts index 8d70ddfef..54e944015 100644 --- a/src/core/project/manager.export.test.ts +++ b/src/core/project/manager.export.test.ts @@ -11,6 +11,7 @@ import { createSilentLogger, TestIdentityClient } from "../../testing"; import { resolveRuntimeTemplateShortcut } from "../../handlers/project/shortcuts"; import type { ExportHarnessInput, Project, ProjectEvent } from "../../handlers/project/types"; import { HarnessSpecSchema } from "../../projectSchemas/harness"; +import { readEnvFile } from "../../io/envFile"; const originalCwd = process.cwd(); const tempDirectories: string[] = []; @@ -436,9 +437,12 @@ describe("FsProjectManager.exportHarness side effects", () => { expect(credential.authorizerType).toBe("ApiKeyCredentialProvider"); expect(credential.name).toMatch(/^ordersMcpinternalX-Api-Key-[a-f0-9]{10}$/); const envLocal = await Bun.file(join(project.rootPath, "agentcore", ".env.local")).text(); - expect(envLocal).toContain( - `AGENTCORE_CREDENTIAL_${credential.name.replace(/-/g, "_").toUpperCase()}='s3cret'`, - ); + expect(envLocal).not.toContain("s3cret"); + expect( + (await readEnvFile(join(project.rootPath, "agentcore", ".env.local")))[ + `AGENTCORE_CREDENTIAL_${credential.name.replace(/-/g, "_").toUpperCase()}` + ], + ).toBe("s3cret"); const mcpClient = await Bun.file( join(project.rootPath, "app", "assistantAgent", "mcp_client", "client.py"), ).text(); diff --git a/src/core/project/manager.test.ts b/src/core/project/manager.test.ts index 62489c496..830331f36 100644 --- a/src/core/project/manager.test.ts +++ b/src/core/project/manager.test.ts @@ -14,6 +14,7 @@ import type { AwsDeploymentTarget } from "../../projectSchemas/aws-targets"; import { credentialEnvVarName } from "../../projectSchemas/credential"; import { ProjectSpecSchema } from "../../projectSchemas/project"; import { ENV_LOCAL_RELATIVE_PATH } from "./envLocal"; +import { readEnvFile } from "../../io/envFile"; import { BMA_CN_MESSAGE, cnConnectorTargetMessage, @@ -768,8 +769,14 @@ describe("FsProjectManager.addResource", () => { const envLocal = await readFile(join(project.rootPath, ENV_LOCAL_RELATIVE_PATH), "utf8"); expect(envLocal).toContain( "# API key for the OpenAI model provider (harness assistant)\n" + - `${credentialEnvVarName("assistantOpenAIApiKey")}='sk-secret'`, + `${credentialEnvVarName("assistantOpenAIApiKey")}='enc:v1:`, ); + expect( + (await readEnvFile(join(project.rootPath, ENV_LOCAL_RELATIVE_PATH)))[ + credentialEnvVarName("assistantOpenAIApiKey") + ], + ).toBe("sk-secret"); + expect(envLocal).not.toContain("sk-secret"); }); test.each([ @@ -809,7 +816,11 @@ describe("FsProjectManager.addResource", () => { `'${credentialEnvVarName(credentialName)}' already exists in ${ENV_LOCAL_RELATIVE_PATH}; left unchanged`, ); const envLocal = await readFile(join(project.rootPath, ENV_LOCAL_RELATIVE_PATH), "utf8"); - expect(envLocal).toContain(`${credentialEnvVarName(credentialName)}='old'`); + expect( + (await readEnvFile(join(project.rootPath, ENV_LOCAL_RELATIVE_PATH)))[ + credentialEnvVarName(credentialName) + ], + ).toBe("old"); expect(envLocal).not.toContain("new"); }); @@ -877,7 +888,12 @@ describe("FsProjectManager.addResource", () => { { authorizerType: "ApiKeyCredentialProvider", name: "exampleGeminiApiKey" }, ]); const envLocal = await readFile(join(project.rootPath, ENV_LOCAL_RELATIVE_PATH), "utf8"); - expect(envLocal).toContain(`${credentialEnvVarName("exampleGeminiApiKey")}='sk-gemini'`); + expect(envLocal).not.toContain("sk-gemini"); + expect( + (await readEnvFile(join(project.rootPath, ENV_LOCAL_RELATIVE_PATH)))[ + credentialEnvVarName("exampleGeminiApiKey") + ], + ).toBe("sk-gemini"); }); }); diff --git a/src/handlers/project/add/credentials/api-key/api-key.screen.test.tsx b/src/handlers/project/add/credentials/api-key/api-key.screen.test.tsx index a6623e7b0..b5cdd41dc 100644 --- a/src/handlers/project/add/credentials/api-key/api-key.screen.test.tsx +++ b/src/handlers/project/add/credentials/api-key/api-key.screen.test.tsx @@ -17,6 +17,7 @@ import { InputValidationError, SourceResolutionError } from "../../../../../erro import type { AppIO } from "../../../../../io"; import { createRootHandler } from "../../../../index"; import { createGatewayProjectTestHarness } from "../../gateway-test-support"; +import { readEnvFile } from "../../../../../io/envFile"; const { cleanup, inProject, projectSpec, run } = createGatewayProjectTestHarness( "add-api-key-credential-wizard", @@ -65,7 +66,11 @@ describe("project add credentials api-key wizard", () => { { authorizerType: "ApiKeyCredentialProvider", name: "service-key" }, ]); const env = await Bun.file(join(projectRoot, "agentcore", ".env.local")).text(); - expect(env).toContain(`AGENTCORE_CREDENTIAL_SERVICE_KEY='${secret}'`); + expect(env).not.toContain(secret); + expect( + (await readEnvFile(join(projectRoot, "agentcore", ".env.local"))) + .AGENTCORE_CREDENTIAL_SERVICE_KEY, + ).toBe(secret); await screen.press("return"); await waitForText( diff --git a/src/handlers/project/add/credentials/oauth/oauth.screen.test.tsx b/src/handlers/project/add/credentials/oauth/oauth.screen.test.tsx index c660a4c2f..8c078d555 100644 --- a/src/handlers/project/add/credentials/oauth/oauth.screen.test.tsx +++ b/src/handlers/project/add/credentials/oauth/oauth.screen.test.tsx @@ -18,6 +18,7 @@ import type { AppIO } from "../../../../../io"; import { createRootHandler } from "../../../../index"; import { createGatewayProjectTestHarness } from "../../gateway-test-support"; import { OAUTH_VENDOR_CHOICES } from "./screen"; +import { readEnvFile } from "../../../../../io/envFile"; const DISCOVERY_URL = "https://idp.example.com/.well-known/openid-configuration"; @@ -104,7 +105,11 @@ describe("project add credentials oauth wizard", () => { }, ]); const env = await Bun.file(join(projectRoot, "agentcore", ".env.local")).text(); - expect(env).toContain(`AGENTCORE_CREDENTIAL_SERVICE_OAUTH_CLIENT_SECRET='${secret}'`); + expect(env).not.toContain(secret); + expect( + (await readEnvFile(join(projectRoot, "agentcore", ".env.local"))) + .AGENTCORE_CREDENTIAL_SERVICE_OAUTH_CLIENT_SECRET, + ).toBe(secret); screen.unmount(); }, 15000); diff --git a/src/handlers/project/add/credentials/payment/index.test.ts b/src/handlers/project/add/credentials/payment/index.test.ts index af34cede0..5201ed6cb 100644 --- a/src/handlers/project/add/credentials/payment/index.test.ts +++ b/src/handlers/project/add/credentials/payment/index.test.ts @@ -4,6 +4,7 @@ import { join } from "node:path"; import { expectError } from "../../../../../testing"; import { createPaymentProjectTestHarness } from "../../payment-test-support"; import { InputValidationError } from "../../../../../errors"; +import { readEnvFile } from "../../../../../io/envFile"; const { cleanup, inProject, projectSpec, run } = createPaymentProjectTestHarness("payment-credential"); @@ -92,8 +93,11 @@ describe("project add credentials payment", () => { ]); const env = await Bun.file(join(projectRoot, "agentcore", ".env.local")).text(); expect(env).toContain("AGENTCORE_CREDENTIAL_COINBASE_PROD_API_KEY_ID='api-key-id'"); - expect(env).toContain(`AGENTCORE_CREDENTIAL_COINBASE_PROD_API_KEY_SECRET='${apiKeySecret}'`); - expect(env).toContain(`AGENTCORE_CREDENTIAL_COINBASE_PROD_WALLET_SECRET='${walletSecret}'`); + expect(env).not.toContain(apiKeySecret); + expect(env).not.toContain(walletSecret); + const decrypted = await readEnvFile(join(projectRoot, "agentcore", ".env.local")); + expect(decrypted.AGENTCORE_CREDENTIAL_COINBASE_PROD_API_KEY_SECRET).toBe(apiKeySecret); + expect(decrypted.AGENTCORE_CREDENTIAL_COINBASE_PROD_WALLET_SECRET).toBe(walletSecret); }); test("normalizes Coinbase key whitespace before persistence", async () => { @@ -124,8 +128,9 @@ describe("project add credentials payment", () => { ]); const env = await Bun.file(join(projectRoot, "agentcore", ".env.local")).text(); - expect(env).toContain(`AGENTCORE_CREDENTIAL_COINBASE_PADDED_API_KEY_SECRET='${apiKeySecret}'`); - expect(env).toContain(`AGENTCORE_CREDENTIAL_COINBASE_PADDED_WALLET_SECRET='${walletSecret}'`); + const decrypted = await readEnvFile(join(projectRoot, "agentcore", ".env.local")); + expect(decrypted.AGENTCORE_CREDENTIAL_COINBASE_PADDED_API_KEY_SECRET).toBe(apiKeySecret); + expect(decrypted.AGENTCORE_CREDENTIAL_COINBASE_PADDED_WALLET_SECRET).toBe(walletSecret); expect(env).not.toContain(`' ${apiKeySecret} '`); }); @@ -150,9 +155,11 @@ describe("project add credentials payment", () => { ]); const env = await Bun.file(join(projectRoot, "agentcore", ".env.local")).text(); - expect(env).toContain( - `AGENTCORE_CREDENTIAL_STRIPE_PROD_AUTHORIZATION_PRIVATE_KEY='${privateKey}'`, - ); + expect( + (await readEnvFile(join(projectRoot, "agentcore", ".env.local"))) + .AGENTCORE_CREDENTIAL_STRIPE_PROD_AUTHORIZATION_PRIVATE_KEY, + ).toBe(privateKey); + expect(env).not.toContain(privateKey); expect(env).not.toContain("wallet-auth:"); }); diff --git a/src/handlers/project/add/credentials/payment/payment.screen.test.tsx b/src/handlers/project/add/credentials/payment/payment.screen.test.tsx index 5449b0209..4315bf1aa 100644 --- a/src/handlers/project/add/credentials/payment/payment.screen.test.tsx +++ b/src/handlers/project/add/credentials/payment/payment.screen.test.tsx @@ -19,6 +19,7 @@ import { InputValidationError } from "../../../../../errors"; import type { AppIO } from "../../../../../io"; import { createRootHandler } from "../../../../index"; import { createPaymentProjectTestHarness } from "../../payment-test-support"; +import { readEnvFile } from "../../../../../io/envFile"; const { cleanup, inProject, projectSpec, run } = createPaymentProjectTestHarness( "add-payment-credential-wizard", @@ -95,8 +96,11 @@ describe("project add credentials payment wizard", () => { ]); const env = await Bun.file(join(projectRoot, "agentcore", ".env.local")).text(); expect(env).toContain("AGENTCORE_CREDENTIAL_COINBASE_PROD_API_KEY_ID='coinbase-key'"); - expect(env).toContain(`AGENTCORE_CREDENTIAL_COINBASE_PROD_API_KEY_SECRET='${apiKeySecret}'`); - expect(env).toContain(`AGENTCORE_CREDENTIAL_COINBASE_PROD_WALLET_SECRET='${walletSecret}'`); + expect(env).not.toContain(apiKeySecret); + expect(env).not.toContain(walletSecret); + const decrypted = await readEnvFile(join(projectRoot, "agentcore", ".env.local")); + expect(decrypted.AGENTCORE_CREDENTIAL_COINBASE_PROD_API_KEY_SECRET).toBe(apiKeySecret); + expect(decrypted.AGENTCORE_CREDENTIAL_COINBASE_PROD_WALLET_SECRET).toBe(walletSecret); expect(screen.frames.join("\n")).not.toContain(apiKeySecret); expect(screen.frames.join("\n")).not.toContain(walletSecret); screen.unmount(); @@ -148,9 +152,12 @@ describe("project add credentials payment wizard", () => { ]); const env = await Bun.file(join(projectRoot, "agentcore", ".env.local")).text(); expect(env).toContain("AGENTCORE_CREDENTIAL_STRIPE_PROD_APP_ID='privy-app'"); - expect(env).toContain(`AGENTCORE_CREDENTIAL_STRIPE_PROD_APP_SECRET='${appSecret}'`); - expect(env).toContain( - `AGENTCORE_CREDENTIAL_STRIPE_PROD_AUTHORIZATION_PRIVATE_KEY='${authorizationKey}'`, + expect(env).not.toContain(appSecret); + expect(env).not.toContain(authorizationKey); + const decrypted = await readEnvFile(join(projectRoot, "agentcore", ".env.local")); + expect(decrypted.AGENTCORE_CREDENTIAL_STRIPE_PROD_APP_SECRET).toBe(appSecret); + expect(decrypted.AGENTCORE_CREDENTIAL_STRIPE_PROD_AUTHORIZATION_PRIVATE_KEY).toBe( + authorizationKey, ); expect(env).toContain( "AGENTCORE_CREDENTIAL_STRIPE_PROD_AUTHORIZATION_ID='stripe-authorization'", diff --git a/src/handlers/project/add/harness/harness.screen.test.tsx b/src/handlers/project/add/harness/harness.screen.test.tsx index 3a222e75b..b1ba4eacc 100644 --- a/src/handlers/project/add/harness/harness.screen.test.tsx +++ b/src/handlers/project/add/harness/harness.screen.test.tsx @@ -23,6 +23,7 @@ import type { AppIO } from "../../../../io"; import { DEFAULT_HARNESS_MODEL } from "../../../../projectSchemas/harness"; import { credentialEnvVarName } from "../../../../projectSchemas/credential"; import { createGatewayProjectTestHarness } from "../gateway-test-support"; +import { readEnvFile } from "../../../../io/envFile"; import { projectQueryKey } from "../../ProjectGate"; import type { Project } from "../../types"; @@ -260,7 +261,12 @@ describe("project add harness wizard", () => { { authorizerType: "ApiKeyCredentialProvider", name: "assistantOpenAIApiKey" }, ]); const envLocal = await Bun.file(join(projectRoot, "agentcore", ".env.local")).text(); - expect(envLocal).toContain(`${credentialEnvVarName("assistantOpenAIApiKey")}='sk-from-file'`); + expect(envLocal).not.toContain("sk-from-file"); + expect( + (await readEnvFile(join(projectRoot, "agentcore", ".env.local")))[ + credentialEnvVarName("assistantOpenAIApiKey") + ], + ).toBe("sk-from-file"); screen.unmount(); }, 15000); diff --git a/src/handlers/project/add/harness/index.test.ts b/src/handlers/project/add/harness/index.test.ts index 239e0d3b3..c73a88729 100644 --- a/src/handlers/project/add/harness/index.test.ts +++ b/src/handlers/project/add/harness/index.test.ts @@ -14,6 +14,7 @@ import { DeserializationError, InputValidationError } from "../../../../errors"; import { FsReadWriteJson, type ReadWriteJson } from "../../../../io"; import { HarnessYamlSchema } from "../../../../projectSchemas/harness"; import { credentialEnvVarName } from "../../../../projectSchemas/credential"; +import { readEnvFile } from "../../../../io/envFile"; const cleanups: Array<() => Promise> = []; afterEach(() => Promise.all(cleanups.splice(0).map((cleanup) => cleanup()))); @@ -454,7 +455,12 @@ describe("project add harness", () => { { authorizerType: "ApiKeyCredentialProvider", name: "xOpenAIApiKey" }, ]); const envLocal = await Bun.file(join(projectRoot, "agentcore", ".env.local")).text(); - expect(envLocal).toContain(`${credentialEnvVarName("xOpenAIApiKey")}='sk-file'`); + expect(envLocal).not.toContain("sk-file"); + expect( + (await readEnvFile(join(projectRoot, "agentcore", ".env.local")))[ + credentialEnvVarName("xOpenAIApiKey") + ], + ).toBe("sk-file"); }); test("--api-key reads the key from stdin", async () => { @@ -474,7 +480,12 @@ describe("project add harness", () => { { stdin: "sk-stdin" }, ); const envLocal = await Bun.file(join(projectRoot, "agentcore", ".env.local")).text(); - expect(envLocal).toContain(`${credentialEnvVarName("xLiteLLMApiKey")}='sk-stdin'`); + expect(envLocal).not.toContain("sk-stdin"); + expect( + (await readEnvFile(join(projectRoot, "agentcore", ".env.local")))[ + credentialEnvVarName("xLiteLLMApiKey") + ], + ).toBe("sk-stdin"); }); test.each<[string, string, RegExp]>([ diff --git a/src/handlers/project/add/runtime/index.test.ts b/src/handlers/project/add/runtime/index.test.ts index bb6a9b522..4bd204610 100644 --- a/src/handlers/project/add/runtime/index.test.ts +++ b/src/handlers/project/add/runtime/index.test.ts @@ -13,6 +13,7 @@ import { import type { BedrockAgentImportPlan } from "../../../../core/project/bedrockAgentImport"; import { credentialEnvVarName } from "../../../../projectSchemas/credential"; import { InputValidationError } from "../../../../errors"; +import { readEnvFile } from "../../../../io/envFile"; const cleanups: Array<() => Promise> = []; afterEach(() => Promise.all(cleanups.splice(0).map((cleanup) => cleanup()))); @@ -599,7 +600,10 @@ describe("project add runtime", () => { }); const envLocal = await Bun.file(join(projectRoot, "agentcore", ".env.local")).text(); - expect(envLocal).toContain(`${envVarName}='test-api-key'`); + expect(envLocal).not.toContain("test-api-key"); + expect((await readEnvFile(join(projectRoot, "agentcore", ".env.local")))[envVarName]).toBe( + "test-api-key", + ); }, ); diff --git a/src/handlers/project/add/runtime/runtime.screen.test.tsx b/src/handlers/project/add/runtime/runtime.screen.test.tsx index 2a41263b6..a49161d9c 100644 --- a/src/handlers/project/add/runtime/runtime.screen.test.tsx +++ b/src/handlers/project/add/runtime/runtime.screen.test.tsx @@ -23,6 +23,7 @@ import { createGatewayProjectTestHarness } from "../gateway-test-support"; import { projectQueryKey } from "../../ProjectGate"; import type { Project } from "../../types"; import { DEFAULT_MODEL_IDS } from "../../../../projectSchemas/runtime"; +import { readEnvFile } from "../../../../io/envFile"; import { RegionKey } from "../../../keys"; const { cleanup, inProject, projectSpec, run } = @@ -201,9 +202,10 @@ describe("project add runtime wizard", () => { { authorizerType: "ApiKeyCredentialProvider", name: "keyed_agentGeminiApiKey" }, ]), ); - expect(await Bun.file(join(projectRoot, "agentcore", ".env.local")).text()).toContain( - "AIza-test-key", - ); + expect( + (await readEnvFile(join(projectRoot, "agentcore", ".env.local"))) + .AGENTCORE_CREDENTIAL_KEYED_AGENTGEMINIAPIKEY, + ).toBe("AIza-test-key"); r.unmount(); }); diff --git a/src/handlers/project/create/create.screen.test.tsx b/src/handlers/project/create/create.screen.test.tsx index dc2592dc8..34fc7143c 100644 --- a/src/handlers/project/create/create.screen.test.tsx +++ b/src/handlers/project/create/create.screen.test.tsx @@ -23,6 +23,8 @@ import { resolveRuntimeTemplateShortcut } from "../shortcuts"; import type { CreateProjectInput } from "../types"; import { ProjectSpecSchema } from "../../../projectSchemas/project"; import { RegionKey } from "../../keys"; +import { readEnvFile } from "../../../io/envFile"; +import { credentialEnvVarName } from "../../../projectSchemas/credential"; import { DEFAULT_MODEL_IDS } from "../../../projectSchemas/runtime"; import { modelProviderOverridableCnMessage } from "../../../core/project/manager"; @@ -268,7 +270,11 @@ describe("project create wizard", () => { expect(harness.model).toEqual({ openAiModelConfig: { modelId: "gpt-6.1-sol", apiKeyCredentialName: "KeyAppOpenAIApiKey" }, }); - expect(await Bun.file(join(root, "agentcore", ".env.local")).text()).toContain("'sk-wizard'"); + expect( + (await readEnvFile(join(root, "agentcore", ".env.local")))[ + credentialEnvVarName("KeyAppOpenAIApiKey") + ], + ).toBe("sk-wizard"); r.unmount(); }, 10000); @@ -690,7 +696,12 @@ describe("project create wizard", () => { { authorizerType: "ApiKeyCredentialProvider", name: "agentAnthropicApiKey" }, ]); const envLocal = await Bun.file(join(directory, "KeyedApp", "agentcore", ".env.local")).text(); - expect(envLocal).toContain("sk-ant-test-key"); + expect(envLocal).not.toContain("sk-ant-test-key"); + expect( + (await readEnvFile(join(directory, "KeyedApp", "agentcore", ".env.local")))[ + credentialEnvVarName("agentAnthropicApiKey") + ], + ).toBe("sk-ant-test-key"); r.unmount(); }, 10000); diff --git a/src/handlers/project/dev/environment.test.ts b/src/handlers/project/dev/environment.test.ts index 219cf7d37..ee8c73354 100644 --- a/src/handlers/project/dev/environment.test.ts +++ b/src/handlers/project/dev/environment.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import { join } from "node:path"; import type { ProjectRuntime } from "../../../projectSchemas/runtime"; import { createDevEnvironmentLoader } from "./environment"; +import { LocalSecrets } from "../../../io/localSecrets"; const projectRoot = "/workspace/project"; @@ -15,14 +16,24 @@ const input = (envVars: { name: string; value: string }[] = []) => ({ }); describe("createDevEnvironmentLoader", () => { + test("decrypts local secrets before passing them to the agent", async () => { + const encrypted = await new LocalSecrets().encrypt({ MODEL_API_KEY: "agent-value" }); + const loader = createDevEnvironmentLoader({ + readFile: async () => `MODEL_API_KEY='${encrypted.MODEL_API_KEY}'\nSTAGE=dev\n`, + }); + await expect(loader(input())).resolves.toEqual({ + env: { MODEL_API_KEY: "agent-value", STAGE: "dev", AWS_REGION: "us-east-1" }, + }); + }); + test("merges runtime, region, and .env.local while removing runner-owned keys", async () => { const loader = createDevEnvironmentLoader({ readFile: async () => ` SHARED="local value" AWS_REGION=local-region -PORT=9999 -FASTMCP_PORT=9998 -LOCAL_DEV=0 +PORT=enc:v1:ignored +FASTMCP_PORT=enc:v1:ignored +LOCAL_DEV=enc:v1:ignored MULTILINE="first second" `, diff --git a/src/handlers/project/dev/environment.ts b/src/handlers/project/dev/environment.ts index f852d5431..27129c56b 100644 --- a/src/handlers/project/dev/environment.ts +++ b/src/handlers/project/dev/environment.ts @@ -1,8 +1,6 @@ -import { readFile } from "node:fs/promises"; import { join } from "node:path"; -import { parseEnv } from "node:util"; import type { ProjectRuntime } from "../../../projectSchemas/runtime"; -import { InputValidationError } from "../../../errors"; +import { readEnvFile } from "../../../io/envFile"; const RESERVED_ENV_KEYS = ["PORT", "FASTMCP_PORT", "LOCAL_DEV"] as const; @@ -22,40 +20,22 @@ type DevEnvironmentLoaderConfig = { readFile?: (path: string, encoding: BufferEncoding) => Promise; }; -async function localEnvironment( - projectRoot: string, - read: (path: string, encoding: BufferEncoding) => Promise, -): Promise> { - const path = join(projectRoot, "agentcore", ".env.local"); - let contents: string; - try { - contents = await read(path, "utf8"); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return {}; - throw new InputValidationError(`Unable to read local environment file at ${path}`, { - cause: error, - }); - } - - try { - return parseEnv(contents) as Record; - } catch (error) { - throw new InputValidationError(`Invalid local environment file at ${path}`, { cause: error }); - } -} - export function createDevEnvironmentLoader( config: DevEnvironmentLoaderConfig = {}, ): DevEnvironmentLoader { - const read = config.readFile ?? readFile; - return async (input) => { const env = Object.fromEntries( (input.runtime.envVars ?? []).map(({ name, value }) => [name, value]), ); if (input.region) env.AWS_REGION = input.region; - Object.assign(env, await localEnvironment(input.projectRoot, read)); + Object.assign( + env, + await readEnvFile(join(input.projectRoot, "agentcore", ".env.local"), { + ...config, + select: (name) => !RESERVED_ENV_KEYS.some((reserved) => reserved === name), + }), + ); for (const key of RESERVED_ENV_KEYS) delete env[key]; return { env }; diff --git a/src/handlers/project/project.test.ts b/src/handlers/project/project.test.ts index e1f652e3b..8444da7b2 100644 --- a/src/handlers/project/project.test.ts +++ b/src/handlers/project/project.test.ts @@ -15,6 +15,7 @@ import { import { InputValidationError, SourceResolutionError } from "../../errors"; import { DEFAULT_MODEL_IDS } from "../../projectSchemas/runtime"; import { resolveScaffoldHarnessInput } from "./create/index"; +import { readEnvFile } from "../../io/envFile"; async function run( args: string[], @@ -782,8 +783,12 @@ describe("project add credentials", () => { ]); const env = await Bun.file(join(projectRoot, "agentcore", ".env.local")).text(); - expect(env).toContain("AGENTCORE_CREDENTIAL_SVC_KEY='sk-123'\n"); - expect(env).not.toContain("AGENTCORE_CREDENTIAL_SVC_KEY='sk-123'\n\n"); + expect(env).toContain("AGENTCORE_CREDENTIAL_SVC_KEY='enc:v1:"); + expect(env).not.toContain("sk-123"); + expect( + (await readEnvFile(join(projectRoot, "agentcore", ".env.local"))) + .AGENTCORE_CREDENTIAL_SVC_KEY, + ).toBe("sk-123"); }); test("api-key without a secret writes a commented placeholder and tells the user to fill it", async () => { @@ -875,7 +880,11 @@ describe("project add credentials", () => { ]); const env = await Bun.file(join(projectRoot, "agentcore", ".env.local")).text(); - expect(env).toContain("AGENTCORE_CREDENTIAL_IDP_CLIENT_SECRET='sssh'"); + expect(env).not.toContain("sssh"); + expect( + (await readEnvFile(join(projectRoot, "agentcore", ".env.local"))) + .AGENTCORE_CREDENTIAL_IDP_CLIENT_SECRET, + ).toBe("sssh"); }); test("oauth vendored with --provider-configuration records the config and a secret placeholder", async () => { @@ -923,7 +932,8 @@ describe("project add credentials", () => { const env = await Bun.file(envPath).text(); expect(env).toStartWith(original); expect(env.match(/AGENTCORE_CREDENTIAL_SVC_KEY=/g)).toHaveLength(1); - expect(env).toContain("AGENTCORE_CREDENTIAL_SVC_KEY=user-managed"); + expect(env).not.toContain("user-managed"); + expect((await readEnvFile(envPath)).AGENTCORE_CREDENTIAL_SVC_KEY).toBe("user-managed"); expect(io.stderr()).toContain("already exists"); }); diff --git a/src/handlers/project/remove/index.test.ts b/src/handlers/project/remove/index.test.ts index 2c02f5980..d4b755f1c 100644 --- a/src/handlers/project/remove/index.test.ts +++ b/src/handlers/project/remove/index.test.ts @@ -18,6 +18,7 @@ import { import { projectSpec, writeProjectSpec } from "../add/gateway-test-support"; import { credentialEnvVarName } from "../../../projectSchemas/credential"; import { ENV_LOCAL_RELATIVE_PATH } from "../../../core/project/envLocal"; +import { readEnvFile } from "../../../io/envFile"; import { APP_CODE_RETAINED_NOTICE } from "./notice"; const cleanups: Array<() => Promise> = []; @@ -181,7 +182,8 @@ describe("project remove", () => { }); const envPath = join(projectRoot, ENV_LOCAL_RELATIVE_PATH); const envKey = credentialEnvVarName("svc-key"); - expect(await Bun.file(envPath).text()).toContain(`${envKey}='sekret'`); + expect(await Bun.file(envPath).text()).not.toContain("sekret"); + expect((await readEnvFile(envPath))[envKey]).toBe("sekret"); const { io } = await run(["remove", "credential", "--name", "svc-key"]); diff --git a/src/io/envFile.ts b/src/io/envFile.ts new file mode 100644 index 000000000..c4d63d633 --- /dev/null +++ b/src/io/envFile.ts @@ -0,0 +1,47 @@ +import { readFile } from "node:fs/promises"; +import { parseEnv } from "node:util"; +import { InputValidationError } from "../errors"; +import { LocalSecrets } from "./localSecrets"; + +export type ReadEnvFileOptions = { + select?: (name: string) => boolean; + overrides?: Readonly>; +}; + +type ReadEnvFileConfig = ReadEnvFileOptions & { + readFile?: (path: string, encoding: BufferEncoding) => Promise; + secrets?: LocalSecrets; +}; + +export async function readEnvFile( + path: string, + config: ReadEnvFileConfig = {}, +): Promise> { + const selected = config.select ?? (() => true); + const overrides = Object.fromEntries( + Object.entries(config.overrides ?? {}).filter( + ([name, value]) => selected(name) && value !== undefined, + ), + ) as Record; + let contents: string; + try { + contents = await (config.readFile ?? readFile)(path, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return overrides; + throw new InputValidationError(`Unable to read local environment file at ${path}`, { + cause: error, + }); + } + + let env: Record; + try { + env = parseEnv(contents) as Record; + } catch (error) { + throw new InputValidationError(`Invalid local environment file at ${path}`, { cause: error }); + } + + const fromFile = Object.fromEntries( + Object.entries(env).filter(([name]) => selected(name) && !Object.hasOwn(overrides, name)), + ); + return { ...(await (config.secrets ?? new LocalSecrets()).decrypt(fromFile)), ...overrides }; +} diff --git a/src/io/keyring.ts b/src/io/keyring.ts new file mode 100644 index 000000000..03ea391e9 --- /dev/null +++ b/src/io/keyring.ts @@ -0,0 +1,8 @@ +import { createRequire } from "node:module"; +import type { KeychainEntry } from "./localSecrets"; + +const require = createRequire(import.meta.url); + +export function loadKeyring(): { Entry: new (service: string, account: string) => KeychainEntry } { + return require("@napi-rs/keyring"); +} diff --git a/src/io/localSecrets.test.ts b/src/io/localSecrets.test.ts new file mode 100644 index 000000000..3674f297f --- /dev/null +++ b/src/io/localSecrets.test.ts @@ -0,0 +1,172 @@ +import { afterEach, expect, test } from "bun:test"; +import { randomBytes } from "node:crypto"; +import { existsSync } from "node:fs"; +import { chmod, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { encryptSecret } from "../secrets/cipher"; +import { LocalSecrets, type KeychainEntry } from "./localSecrets"; + +const roots: string[] = []; +afterEach(() => + Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))), +); + +async function directory(): Promise { + const root = await mkdtemp(join(tmpdir(), "agentcore-secret-key-")); + roots.push(root); + return root; +} + +test("creates and reuses the 32-byte owner-only fallback key", async () => { + const configDirectory = await directory(); + const config = { configDirectory, keychain: () => null }; + const encrypted = await new LocalSecrets(config).encrypt({ MODEL_API_KEY: "private-value" }); + const path = join(configDirectory, "secrets.key"); + const key = await readFile(path); + expect(key.length).toBe(32); + if (process.platform !== "win32") { + expect((await stat(path)).mode & 0o777).toBe(0o600); + await chmod(path, 0o644); + } + expect(await new LocalSecrets(config).decrypt(encrypted)).toEqual({ + MODEL_API_KEY: "private-value", + }); + await new LocalSecrets(config).encrypt({ MODEL_API_KEY: "next-value" }); + expect(await readFile(path)).toEqual(key); + let keychainReads = 0; + const available = new LocalSecrets({ + configDirectory, + keychain: () => { + keychainReads++; + return { getPassword: () => randomBytes(32).toString("base64"), setPassword: () => {} }; + }, + }); + const next = await available.encrypt({ MODEL_API_KEY: "file-preferred" }); + expect(await available.decrypt(next)).toEqual({ MODEL_API_KEY: "file-preferred" }); + expect(keychainReads).toBe(0); + if (process.platform !== "win32") expect((await stat(path)).mode & 0o777).toBe(0o600); +}); + +test("uses the persisted keychain key without creating a keyfile", async () => { + const configDirectory = await directory(); + let password: string | null = null; + const entry: KeychainEntry = { + getPassword: () => password, + setPassword: (value) => { + password = value; + }, + }; + const config = { configDirectory, keychain: () => entry }; + const encrypted = await new LocalSecrets(config).encrypt({ MODEL_API_KEY: "private-value" }); + expect(password).not.toBeNull(); + expect(existsSync(join(configDirectory, "secrets.key"))).toBe(false); + expect(await new LocalSecrets(config).decrypt(encrypted)).toEqual({ + MODEL_API_KEY: "private-value", + }); +}); + +test("encrypts with the persisted key rather than a generated key the store replaced", async () => { + const configDirectory = await directory(); + const persisted = randomBytes(32).toString("base64"); + let password: string | null = null; + const config = { + configDirectory, + keychain: () => ({ + getPassword: () => password, + setPassword: () => { + password = persisted; + }, + }), + }; + const encrypted = await new LocalSecrets(config).encrypt({ MODEL_API_KEY: "value" }); + expect(await new LocalSecrets(config).decrypt(encrypted)).toEqual({ MODEL_API_KEY: "value" }); +}); + +test("falls back when keychain access fails and reads values encrypted by either source", async () => { + const configDirectory = await directory(); + const keychainKey = randomBytes(32); + const fileOnly = new LocalSecrets({ configDirectory, keychain: () => null }); + const fromFile = await fileOnly.encrypt({ FILE_API_KEY: "file-value" }); + const secrets = new LocalSecrets({ + configDirectory, + keychain: () => ({ + getPassword: () => keychainKey.toString("base64"), + setPassword: () => { + throw new Error("must not overwrite an existing key"); + }, + }), + }); + expect( + await secrets.decrypt({ + ...fromFile, + KEYCHAIN_API_KEY: encryptSecret("keychain-value", keychainKey), + }), + ).toEqual({ FILE_API_KEY: "file-value", KEYCHAIN_API_KEY: "keychain-value" }); + + const unavailable = new LocalSecrets({ + configDirectory, + keychain: () => { + throw new Error("keychain unavailable"); + }, + }); + expect(await unavailable.decrypt(await unavailable.encrypt({ MODEL_API_KEY: "value" }))).toEqual({ + MODEL_API_KEY: "value", + }); +}); + +test("does not create keys for identifiers, blank placeholders, plaintext reads or encrypted writes", async () => { + const configDirectory = await directory(); + const secrets = new LocalSecrets({ configDirectory, keychain: () => null }); + const env = { AGENTCORE_CREDENTIAL_PAY_API_KEY_ID: "id", MODEL_API_KEY: "", STAGE: "dev" }; + expect(await secrets.encrypt(env)).toEqual(env); + expect(await secrets.decrypt({ MODEL_API_KEY: "legacy" })).toEqual({ MODEL_API_KEY: "legacy" }); + const token = encryptSecret("value", randomBytes(32)); + expect(await secrets.encrypt({ MODEL_API_KEY: token })).toEqual({ MODEL_API_KEY: token }); + expect(existsSync(join(configDirectory, "secrets.key"))).toBe(false); +}); + +test("missing and corrupt keys fail without replacing key material or leaking a stored value", async () => { + const configDirectory = await directory(); + const path = join(configDirectory, "secrets.key"); + const secrets = new LocalSecrets({ configDirectory, keychain: () => null }); + const token = encryptSecret("private-value", randomBytes(32)); + await expect(secrets.decrypt({ MODEL_API_KEY: token })).rejects.toThrow(/Could not decrypt/); + expect(existsSync(path)).toBe(false); + const corrupt = Buffer.alloc(10, 7); + await writeFile(path, corrupt); + await expect(secrets.encrypt({ MODEL_API_KEY: "private-value" })).rejects.toThrow(/is corrupt/); + await expect(secrets.decrypt({ MODEL_API_KEY: token })).rejects.toThrow(/Could not decrypt/); + expect(await readFile(path)).toEqual(corrupt); +}); + +test("a keychain read failure never replaces an existing key", async () => { + const configDirectory = await directory(), + original = randomBytes(32); + let password = original.toString("base64"), + denied = true, + writes = 0; + const config = { + configDirectory, + keychain: () => ({ + getPassword: () => { + if (denied) throw new Error("access denied"); + return password; + }, + setPassword: (value: string) => { + writes++; + password = value; + }, + }), + }; + const encrypted = await new LocalSecrets(config).encrypt({ NEW_API_KEY: "new-value" }); + expect(writes).toBe(0); + expect(password).toBe(original.toString("base64")); + denied = false; + expect( + await new LocalSecrets(config).decrypt({ + ...encrypted, + OLD_API_KEY: encryptSecret("old-value", original), + }), + ).toEqual({ NEW_API_KEY: "new-value", OLD_API_KEY: "old-value" }); +}); diff --git a/src/io/localSecrets.ts b/src/io/localSecrets.ts new file mode 100644 index 000000000..4f7aba497 --- /dev/null +++ b/src/io/localSecrets.ts @@ -0,0 +1,157 @@ +import { randomBytes } from "node:crypto"; +import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { FileWriteError, ProjectStateError } from "../errors"; +import { ENC_PREFIX, decryptSecret, encryptSecret } from "../secrets/cipher"; +import { isSensitiveKey } from "../secrets/sensitiveKeys"; +import { loadKeyring } from "./keyring"; + +const KEY_BYTES = 32; +const KEYCHAIN_SERVICE = "aws-agentcore"; +const KEYCHAIN_ACCOUNT = "env-local-secret-key"; + +export interface KeychainEntry { + getPassword(): string | null; + setPassword(value: string): void; +} + +type LocalSecretsConfig = { + configDirectory?: string; + keychain?: () => KeychainEntry | null; +}; + +function defaultKeychain(): KeychainEntry | null { + if (process.env.AGENTCORE_DISABLE_KEYCHAIN === "1") return null; + try { + const { Entry } = loadKeyring(); + return new Entry(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT); + } catch { + return null; + } +} + +/** Machine-local key storage shared by project environment-file reads and writes. */ +export class LocalSecrets { + private key?: Buffer; + private readonly directory: string; + private readonly keychain: () => KeychainEntry | null; + + constructor(config: LocalSecretsConfig = {}) { + this.directory = + config.configDirectory ?? process.env.AGENTCORE_CONFIG_DIR ?? join(homedir(), ".agentcore"); + this.keychain = config.keychain ?? defaultKeychain; + } + + async encrypt(env: Record): Promise> { + const pending = Object.entries(env).filter( + ([name, value]) => isSensitiveKey(name) && value !== "" && !value.startsWith(ENC_PREFIX), + ); + if (pending.length === 0) return env; + const key = this.encryptionKey(); + return { + ...env, + ...Object.fromEntries(pending.map(([name, value]) => [name, encryptSecret(value, key)])), + }; + } + + async decrypt(env: Record): Promise> { + if (!Object.values(env).some((value) => value.startsWith(ENC_PREFIX))) return env; + const keys: Buffer[] = []; + const decode = () => + Object.fromEntries( + Object.entries(env).map(([name, value]) => [ + name, + value.startsWith(ENC_PREFIX) ? decryptSecret(value, keys) : value, + ]), + ); + for (const key of this.candidateKeys()) { + keys.push(key); + try { + return decode(); + } catch (error) { + if (!(error instanceof ProjectStateError)) throw error; + } + } + return decode(); + } + + private encryptionKey(): Buffer { + if (this.key) return this.key; + const path = join(this.directory, "secrets.key"); + try { + let key = this.readKeyfile(path); + if (!key) { + const stored = this.keychainKey(true); + if (stored) return (this.key = stored); + mkdirSync(this.directory, { recursive: true, mode: 0o700 }); + key = randomBytes(KEY_BYTES); + try { + writeFileSync(path, key, { mode: 0o600, flag: "wx" }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + key = this.readKeyfile(path); + } + } + if (!key) throw new Error("The encryption key disappeared during creation."); + this.assertKeySize(key, path); + if (process.platform !== "win32") chmodSync(path, 0o600); + return (this.key = key); + } catch (error) { + if (error instanceof ProjectStateError) throw error; + throw new FileWriteError(`Could not create or read the encryption key at ${path}.`, { + cause: error, + }); + } + } + + private *candidateKeys(): Generator { + let fileKey: Buffer | undefined; + try { + fileKey = this.readKeyfile(join(this.directory, "secrets.key")); + } catch { + fileKey = undefined; + } + if (fileKey?.length === KEY_BYTES) yield fileKey; + const stored = this.keychainKey(false); + if (stored && (fileKey === undefined || !stored.equals(fileKey))) yield stored; + } + + private keychainKey(create: boolean): Buffer | null { + try { + const entry = this.keychain(); + if (!entry) return null; + const password = entry.getPassword(); + if (password !== null) { + const key = Buffer.from(password, "base64"); + return key.length === KEY_BYTES ? key : null; + } + if (!create) return null; + entry.setPassword(randomBytes(KEY_BYTES).toString("base64")); + const persisted = entry.getPassword(); + if (persisted === null) return null; + const key = Buffer.from(persisted, "base64"); + return key.length === KEY_BYTES ? key : null; + } catch { + return null; + } + } + + private readKeyfile(path: string): Buffer | undefined { + try { + return readFileSync(path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + } + } + + private assertKeySize(key: Buffer, path: string): void { + if (key.length !== KEY_BYTES) { + throw new ProjectStateError( + `Encryption key file at ${path} is corrupt (expected ${KEY_BYTES} bytes, found ${key.length}). ` + + "Restore the original key file or move it aside and re-add your credentials.", + ); + } + } +} diff --git a/src/projectSchemas/credential.ts b/src/projectSchemas/credential.ts index 7557cbb18..ac43313b2 100644 --- a/src/projectSchemas/credential.ts +++ b/src/projectSchemas/credential.ts @@ -122,27 +122,28 @@ export function credentialEnvVarName(credentialName: string, suffix = ""): strin return `${CREDENTIAL_ENV_PREFIX}${credentialName.replace(/-/g, "_").toUpperCase()}${suffix}`; } -/** - * The suffixes the CLI appends to a credential's base variable to name one of its - * fields — both the secret-bearing ones and the readable identifiers. - */ -const CREDENTIAL_FIELD_SUFFIXES = [ +export const CREDENTIAL_SECRET_SUFFIXES = [ "_CLIENT_SECRET", "_API_KEY_SECRET", "_APP_SECRET", "_WALLET_SECRET", "_AUTHORIZATION_PRIVATE_KEY", - // Identifiers rather than secrets. `_CLIENT_ID` is no longer written — an OAuth - // client id lives in agentcore.json — but deploy still reads it for projects - // created before that move, so a name that produces it is still a hazard. +] as const; + +export const CREDENTIAL_REFERENCE_SUFFIXES = [ "_CLIENT_ID", "_API_KEY_ID", "_APP_ID", "_AUTHORIZATION_ID", - // The provider name discovery variable every runtime receives. "_NAME", ] as const; +/** + * The suffixes the CLI appends to a credential's base variable to name one of its + * fields — both the secret-bearing ones and the readable identifiers. + */ +const CREDENTIAL_FIELD_SUFFIXES = [...CREDENTIAL_SECRET_SUFFIXES, ...CREDENTIAL_REFERENCE_SUFFIXES]; + /** * Reports the field suffix a credential name ends in, if any. * diff --git a/src/secrets/cipher.test.ts b/src/secrets/cipher.test.ts new file mode 100644 index 000000000..ed3339d69 --- /dev/null +++ b/src/secrets/cipher.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from "bun:test"; +import { randomBytes } from "node:crypto"; +import { ENC_PREFIX, decryptSecret, encryptSecret } from "./cipher"; + +const key = Buffer.alloc(32, 7); + +describe("secret envelopes", () => { + test("reads main's v1 IV/tag/ciphertext layout", () => { + const token = "enc:v1:AwMDAwMDAwMDAwMDm3fu1rDYozsvSS26dA+Stkifym13SzEvCiE3NYk+k2WZRc2Z1fnhFQ=="; + expect(decryptSecret(token, [key])).toBe("main-compatibility-value"); + expect(decryptSecret(encryptSecret("", key), [key])).toBe(""); + }); + + test("uses a fresh IV for each encryption", () => { + expect(encryptSecret("value", key)).not.toBe(encryptSecret("value", key)); + }); + + test("rejects a tampered envelope", () => { + const offset = 12; + const raw = Buffer.from(encryptSecret("private-value", key).slice(ENC_PREFIX.length), "base64"); + raw[offset] = raw[offset]! ^ 1; + const token = ENC_PREFIX + raw.toString("base64"); + expect(() => decryptSecret(token, [key])).toThrow(/Restore the original encryption key/); + }); + + test("tries candidate keys and fails closed when none authenticate", () => { + const token = encryptSecret("private-value", key); + expect(decryptSecret(token, [randomBytes(32), key])).toBe("private-value"); + expect(() => decryptSecret(token, [randomBytes(32)])).toThrow(/Could not decrypt/); + expect(() => decryptSecret(token, [])).toThrow(/Could not decrypt/); + expect(() => decryptSecret("enc:v1:broken", [key])).toThrow(/Could not decrypt/); + }); +}); diff --git a/src/secrets/cipher.ts b/src/secrets/cipher.ts new file mode 100644 index 000000000..d73507775 --- /dev/null +++ b/src/secrets/cipher.ts @@ -0,0 +1,34 @@ +import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; +import { ProjectStateError } from "../errors"; + +export const ENC_PREFIX = "enc:v1:"; +const ALGORITHM = "aes-256-gcm"; +const IV_BYTES = 12; +const TAG_BYTES = 16; + +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, "utf8"), cipher.final()]); + return ENC_PREFIX + Buffer.concat([iv, cipher.getAuthTag(), ciphertext]).toString("base64"); +} + +export function decryptSecret(token: string, keys: readonly Buffer[]): string { + if (token.startsWith(ENC_PREFIX)) { + const raw = Buffer.from(token.slice(ENC_PREFIX.length), "base64"); + for (const key of keys) { + try { + const decipher = createDecipheriv(ALGORITHM, key, raw.subarray(0, IV_BYTES)); + decipher.setAuthTag(raw.subarray(IV_BYTES, IV_BYTES + TAG_BYTES)); + return Buffer.concat([ + decipher.update(raw.subarray(IV_BYTES + TAG_BYTES)), + decipher.final(), + ]).toString("utf8"); + } catch {} + } + } + throw new ProjectStateError( + "Could not decrypt a stored secret in agentcore/.env.local. " + + "Restore the original encryption key or re-add the credential.", + ); +} diff --git a/src/secrets/sensitiveKeys.test.ts b/src/secrets/sensitiveKeys.test.ts new file mode 100644 index 000000000..f197cc537 --- /dev/null +++ b/src/secrets/sensitiveKeys.test.ts @@ -0,0 +1,26 @@ +import { expect, test } from "bun:test"; +import { isSensitiveKey } from "./sensitiveKeys"; + +test.each([ + "SERVICE_API_KEY", + "PAYMENT_API_KEY_SECRET", + "PAYMENT_WALLET_SECRET", + "PAYMENT_AUTHORIZATION_PRIVATE_KEY", + "PAYMENT_APP_SECRET", + "OAUTH_CLIENT_SECRET", + "AGENTCORE_CREDENTIAL_MODEL", +])("encrypts secret-bearing variable %s", (name) => { + expect(isSensitiveKey(name)).toBe(true); +}); + +test.each([ + "AGENTCORE_CREDENTIAL_PAYMENT_API_KEY_ID", + "AGENTCORE_CREDENTIAL_PAYMENT_APP_ID", + "AGENTCORE_CREDENTIAL_PAYMENT_AUTHORIZATION_ID", + "AGENTCORE_CREDENTIAL_OAUTH_CLIENT_ID", + "AGENTCORE_CREDENTIAL_MODEL_NAME", + "PORT", + "STAGE", +])("leaves identifier/configuration variable %s readable", (name) => { + expect(isSensitiveKey(name)).toBe(false); +}); diff --git a/src/secrets/sensitiveKeys.ts b/src/secrets/sensitiveKeys.ts new file mode 100644 index 000000000..565d07303 --- /dev/null +++ b/src/secrets/sensitiveKeys.ts @@ -0,0 +1,14 @@ +import { + CREDENTIAL_ENV_PREFIX, + CREDENTIAL_REFERENCE_SUFFIXES, + CREDENTIAL_SECRET_SUFFIXES, +} from "../projectSchemas/credential"; + +export function isSensitiveKey(key: string): boolean { + if (CREDENTIAL_REFERENCE_SUFFIXES.some((suffix) => key.endsWith(suffix))) return false; + return ( + key.endsWith("_API_KEY") || + CREDENTIAL_SECRET_SUFFIXES.some((suffix) => key.endsWith(suffix)) || + key.startsWith(CREDENTIAL_ENV_PREFIX) + ); +} diff --git a/src/testing/setup.ts b/src/testing/setup.ts index fccc59377..6afcddd39 100644 --- a/src/testing/setup.ts +++ b/src/testing/setup.ts @@ -1,3 +1,7 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + // Test preload: pin ANSI color OFF so rendered Ink frames are deterministic. // // chalk/supports-color decides whether Ink emits color escape codes from @@ -19,3 +23,8 @@ process.env.FORCE_COLOR = "0"; process.env.WT_SESSION ??= "bun-test"; process.env.AGENTCORE_TELEMETRY_DISABLED = "1"; + +const secretDirectory = mkdtempSync(join(tmpdir(), "agentcore-test-secrets-")); +process.env.AGENTCORE_CONFIG_DIR = secretDirectory; +process.env.AGENTCORE_DISABLE_KEYCHAIN = "1"; +process.once("exit", () => rmSync(secretDirectory, { recursive: true, force: true }));