diff --git a/src/__tests__/env-handler.test.ts b/src/__tests__/env-handler.test.ts index 1524143..1a26b10 100644 --- a/src/__tests__/env-handler.test.ts +++ b/src/__tests__/env-handler.test.ts @@ -208,10 +208,26 @@ scope: 'user', ]); expect(content).toBe( - 'export API_URL="https://example.com"\nexport TOKEN="abc123"\n', + "export API_URL='https://example.com'\nexport TOKEN='abc123'\n", ); }); + it('should shell-quote values containing shell metacharacters', () => { + // Values flow from the team repo's env/env.yaml into env.sh, which every + // member sources from their shell profile. Quotes / `$` / backticks must + // be taken literally and must not break or inject into the sourced shell. + const content = handler.generateEnvFile([ + { key: 'CONN', value: 'a"b$c' }, + { key: 'GREETING', value: "it's" }, + ]); + + expect(content).toBe( + "export CONN='a\"b$c'\nexport GREETING='it'\\''s'\n", + ); + // No raw double-quote wrapping that the old code produced. + expect(content).not.toContain('="a'); + }); + it('should return just a newline for empty variables', () => { const content = handler.generateEnvFile([]); expect(content).toBe('\n'); @@ -257,8 +273,8 @@ scope: 'user', const envShPath = path.join(homeDir, '.teamai', 'env.sh'); expect(await fse.pathExists(envShPath)).toBe(true); const content = await fse.readFile(envShPath, 'utf-8'); - expect(content).toContain('export TGIT_API_BASE="https://git.woa.com/api/v3"'); - expect(content).toContain('export MODEL_ENDPOINT="https://api.example.com"'); + expect(content).toContain("export TGIT_API_BASE='https://git.woa.com/api/v3'"); + expect(content).toContain("export MODEL_ENDPOINT='https://api.example.com'"); }); it('should inject source line into shell profile (bash)', async () => { diff --git a/src/resources/env.ts b/src/resources/env.ts index 0977f0f..b268635 100644 --- a/src/resources/env.ts +++ b/src/resources/env.ts @@ -22,6 +22,16 @@ const EnvYamlSchema = z.object({ export type EnvVariable = z.infer; export type EnvYaml = z.infer; +/** + * Quote a string so it is safe to interpolate into a POSIX shell (bash/zsh/sh). + * Wraps the value in single quotes and encodes any embedded single quote as + * `'\''`, leaving all other characters (including `"`, `$`, `` ` ``, `\`) + * literal. Used when generating env.sh, which every team member sources. + */ +function shellQuoteValue(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'`; +} + // ─── Handler ───────────────────────────────────────────── export class EnvHandler extends ResourceHandler { @@ -173,9 +183,15 @@ export class EnvHandler extends ResourceHandler { /** * Generate the content of ~/.teamai/env.sh with export statements. + * + * Values are single-quoted so shell metacharacters in an env value (quotes, + * `$`, backticks, `\`, …) are taken literally and cannot break or inject into + * the sourced script. An embedded single quote is encoded with the standard + * `'\''` sequence. env.sh is sourced from every team member's shell profile, + * so values (which originate from the team repo's env/env.yaml) must be safe. */ generateEnvFile(variables: EnvVariable[]): string { - const lines = variables.map(v => `export ${v.key}="${v.value}"`); + const lines = variables.map(v => `export ${v.key}=${shellQuoteValue(v.value)}`); return lines.join('\n') + '\n'; }