From 352c9b6fcdba8f2fe2b5e9d842bc38376e9be10e Mon Sep 17 00:00:00 2001 From: David Nussio Date: Wed, 1 Apr 2026 06:48:16 +0200 Subject: [PATCH 1/2] refactor: replace execSync with execFileSync for GPG encryption and enhance error handling in secret key parsing --- packages/cli/src/cli/share.ts | 16 ++- packages/core/src/domain/secret-key.ts | 32 +++++ .../windows-credential-manager-access.ts | 114 ++++++++++++------ 3 files changed, 121 insertions(+), 41 deletions(-) diff --git a/packages/cli/src/cli/share.ts b/packages/cli/src/cli/share.ts index 8902703..05444cf 100644 --- a/packages/cli/src/cli/share.ts +++ b/packages/cli/src/cli/share.ts @@ -1,4 +1,4 @@ -import { execSync } from "node:child_process"; +import { execFileSync } from "node:child_process"; import { writeFileSync } from "node:fs"; import { Command, Options } from "@effect/cli"; import { @@ -33,8 +33,18 @@ const gpgEncrypt = ( ): Effect.Effect => Effect.try({ try: () => - execSync( - `gpg --batch --yes --trust-model always --encrypt --armor --recipient ${JSON.stringify(recipient)}`, + execFileSync( + "gpg", + [ + "--batch", + "--yes", + "--trust-model", + "always", + "--encrypt", + "--armor", + "--recipient", + recipient, + ], { input: plaintext, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] } ), catch: (e) => diff --git a/packages/core/src/domain/secret-key.ts b/packages/core/src/domain/secret-key.ts index 6f9ec7e..6b33efe 100644 --- a/packages/core/src/domain/secret-key.ts +++ b/packages/core/src/domain/secret-key.ts @@ -6,10 +6,33 @@ export interface ParsedKey { readonly service: string; } +/** + * Allowed characters per segment: alphanumeric, hyphens, underscores. + * Prevents shell metacharacters, path separators, and other injection vectors + * from reaching OS credential store CLIs. + */ +const segmentPattern = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/; + +const maxKeyLength = 256; + export const parse = Effect.fn("SecretKey.parse")(function* ( key: string, env: string ) { + if (key.length === 0) { + return yield* new InvalidKeyError({ + key, + message: "Key must be a non-empty string", + }); + } + + if (key.length > maxKeyLength) { + return yield* new InvalidKeyError({ + key, + message: `Key is too long (${key.length} chars, max ${maxKeyLength})`, + }); + } + const parts = key.split("."); if (parts.some((p) => p === "")) { @@ -19,6 +42,15 @@ export const parse = Effect.fn("SecretKey.parse")(function* ( }); } + for (const part of parts) { + if (!segmentPattern.test(part)) { + return yield* new InvalidKeyError({ + key, + message: `Key segment "${part}" is invalid — use only alphanumeric characters, hyphens, and underscores (must start with alphanumeric)`, + }); + } + } + const account = parts.at(-1); if (!account) { diff --git a/packages/core/src/implementations/windows-credential-manager-access.ts b/packages/core/src/implementations/windows-credential-manager-access.ts index 8bd0e88..1a7e611 100644 --- a/packages/core/src/implementations/windows-credential-manager-access.ts +++ b/packages/core/src/implementations/windows-credential-manager-access.ts @@ -6,8 +6,9 @@ import { KeychainAccess } from "../services/keychain-access.js"; /** * Windows implementation using PowerShell + Windows Credential Manager. * - * Uses cmdkey with proper double-quote escaping for set/remove, and P/Invoke - * CredReadW for reading passwords back (cmdkey cannot read passwords). + * All operations (set/get/remove) use P/Invoke to call Win32 Credential + * Manager APIs (CredWriteW, CredReadW, CredDeleteW) directly via PowerShell. + * This avoids cmdkey and its nested shell escaping issues entirely. * * No extra dependencies required — uses only built-in Windows APIs via PowerShell. * @@ -54,36 +55,77 @@ const runPowerShell = (script: string) => /** * Escape a string for use inside PowerShell single-quoted strings. - * Single quotes are doubled, and the string is safe from backtick, - * dollar sign, and other PS metacharacter interpretation. - * - * For here-string contexts or unquoted usage, additional characters - * (backtick, $, ", null byte) are also escaped to prevent injection. + * In PS single-quoted strings, the ONLY special character is the + * single quote itself, which is escaped by doubling it. + * Null bytes are stripped to prevent truncation attacks. */ const escapePS = (s: string): string => - s - .replaceAll("'", "''") - .replaceAll("`", "``") - .replaceAll("$", "`$") - .replaceAll('"', '`"') - .replaceAll("\0", ""); + s.replaceAll("\0", "").replaceAll("'", "''"); + +const targetName = (service: string, account: string) => + `envsec:${service}/${account}`; /** - * Escape a string for use inside cmdkey double-quoted parameters. - * cmdkey is a cmd.exe tool, so we escape cmd metacharacters with ^ - * and wrap the value in double quotes to handle spaces. + * P/Invoke-based PowerShell script for writing credentials. + * Uses CredWriteW directly — avoids cmdkey and its nested shell escaping issues. + * All dynamic values are injected via PS single-quoted strings (escapePS). */ -const escapeCmdkey = (s: string): string => - s - .replaceAll("^", "^^") - .replaceAll('"', '^"') - .replaceAll("&", "^&") - .replaceAll("|", "^|") - .replaceAll("<", "^<") - .replaceAll(">", "^>"); +const credWriteScript = (target: string, user: string, password: string) => + [ + `Add-Type @'`, + "using System;", + "using System.Runtime.InteropServices;", + "using System.Text;", + "public class CredWriter {", + ` [DllImport("advapi32.dll", SetLastError=true, CharSet=CharSet.Unicode)]`, + " public static extern bool CredWrite(ref CREDENTIAL cred, int flags);", + " [StructLayout(LayoutKind.Sequential, CharSet=CharSet.Unicode)]", + " public struct CREDENTIAL {", + " public int Flags; public int Type;", + " public string TargetName; public string Comment;", + " public long LastWritten; public int CredentialBlobSize;", + " public IntPtr CredentialBlob; public int Persist;", + " public int AttributeCount; public IntPtr Attributes;", + " public string TargetAlias; public string UserName;", + " }", + " public static bool Write(string target, string user, string pass) {", + " var bytes = Encoding.Unicode.GetBytes(pass);", + " var blob = Marshal.AllocHGlobal(bytes.Length);", + " Marshal.Copy(bytes, 0, blob, bytes.Length);", + " var c = new CREDENTIAL();", + " c.Type = 1; c.Persist = 2;", + " c.TargetName = target; c.UserName = user;", + " c.CredentialBlobSize = bytes.Length; c.CredentialBlob = blob;", + " var ok = CredWrite(ref c, 0);", + " Marshal.FreeHGlobal(blob);", + " return ok;", + " }", + "}", + `'@`, + `$ok = [CredWriter]::Write('${target}', '${user}', '${password}')`, + "if (-not $ok) { exit 1 }", + ].join("\n"); -const targetName = (service: string, account: string) => - `envsec:${service}/${account}`; +/** + * P/Invoke-based PowerShell script for deleting credentials. + * Uses CredDeleteW directly — avoids cmdkey and its nested shell escaping issues. + */ +const credDeleteScript = (target: string) => + [ + `Add-Type @'`, + "using System;", + "using System.Runtime.InteropServices;", + "public class CredDeleter {", + ` [DllImport("advapi32.dll", SetLastError=true, CharSet=CharSet.Unicode)]`, + " public static extern bool CredDelete(string target, int type, int flags);", + " public static bool Delete(string target) {", + " return CredDelete(target, 1, 0);", + " }", + "}", + `'@`, + `$ok = [CredDeleter]::Delete('${target}')`, + "if (-not $ok) { exit 1 }", + ].join("\n"); const make = KeychainAccess.of({ set: Effect.fn("WindowsCredentialManagerAccess.set")(function* ( @@ -91,18 +133,16 @@ const make = KeychainAccess.of({ account: string, password: string ) { - const target = escapeCmdkey(targetName(service, account)); - const user = escapeCmdkey(account); - const pass = escapeCmdkey(password); - - // Use cmdkey with double quotes to handle spaces and special characters - const script = `cmd /c 'cmdkey /generic:"${target}" /user:"${user}" /pass:"${pass}"'`; + const target = escapePS(targetName(service, account)); + const user = escapePS(account); + const pass = escapePS(password); + const script = credWriteScript(target, user, pass); const result = yield* runPowerShell(script); if (result.exitCode !== 0) { return yield* new KeychainError({ - command: "cmdkey /add", + command: "CredWriteW", stderr: result.stderr || result.stdout, message: `Failed to store credential: ${service}/${account}`, }); @@ -171,16 +211,14 @@ const make = KeychainAccess.of({ service: string, account: string ) { - const target = escapeCmdkey(targetName(service, account)); - - // Use cmdkey with double quotes for consistency - const script = `cmd /c 'cmdkey /delete:"${target}"'`; + const target = escapePS(targetName(service, account)); + const script = credDeleteScript(target); const result = yield* runPowerShell(script); if (result.exitCode !== 0) { return yield* new KeychainError({ - command: "cmdkey /delete", + command: "CredDeleteW", stderr: result.stderr || result.stdout, message: `Failed to remove credential: ${service}/${account}`, }); From b5607e54a7660fbe59c9a2db3d9d5ad25fa23b80 Mon Sep 17 00:00:00 2001 From: David Nussio Date: Wed, 1 Apr 2026 07:31:55 +0200 Subject: [PATCH 2/2] feat: migrate to Bun and enhance CLI documentation - Updated CLAUDE.md to default to Bun usage over Node.js, including commands for testing, building, and running scripts. - Added examples for Bun's APIs, testing, and frontend integration. - Consolidated CLI icon guidelines and added effect libraries reference. - Removed GEMINI.md as its content is now integrated into CLAUDE.md. - Enhanced README.md with package descriptions, SDK quick start, project structure, and common commands. - Created README.md for the @envsec/core package detailing its purpose and installation. - Updated package.json files for all packages to include repository, homepage, and bugs information. - Improved e2e-test.ps1 for better exit code handling. --- .claude/CLAUDE.md | 133 ++++++++++ .gitignore | 3 +- CLAUDE.md | 135 ++++++++++ GEMINI.md | 133 ---------- README.md | 69 ++++- packages/cli/README.md | 460 +++++++++++++++++++++++++++++++++ packages/cli/package.json | 7 +- packages/cli/test/e2e-test.ps1 | 11 +- packages/core/README.md | 42 +++ packages/core/package.json | 12 + packages/sdk/package.json | 12 + 11 files changed, 863 insertions(+), 154 deletions(-) delete mode 100644 GEMINI.md create mode 100644 packages/cli/README.md create mode 100644 packages/core/README.md diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 0a7bb42..22644c8 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -1,3 +1,120 @@ +Default to using Bun instead of Node.js. + +- Use `bun ` instead of `node ` or `ts-node ` +- Use `bun test` instead of `jest` or `vitest` +- Use `bun build ` instead of `webpack` or `esbuild` +- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install` +- Use `bun run + + +``` + +With the following `frontend.tsx`: + +```tsx#frontend.tsx +import React from "react"; +import { createRoot } from "react-dom/client"; + +// import .css files directly and it works +import './index.css'; + +const root = createRoot(document.body); + +export default function Frontend() { + return

Hello, world!

; +} + +root.render(); +``` + +Then, run index.ts + +```sh +bun --hot ./index.ts +``` + +For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`. + + +## CLI Icons + +All CLI output icons are centralized in `src/ui.ts` via the `icons` object. When adding or modifying CLI output: + +- Import icons from `src/ui.ts` — never hardcode symbols or emoji inline +- Use clean geometric Unicode glyphs (▸ ◆ ● ◎ etc.) — no emoji (🔑 📁 🔒 etc.) +- Emoji render inconsistently across terminals and break monospace alignment +- Each icon must be wrapped with its semantic color function (e.g. `green("✔")`, `red("✖")`) +- When adding a new icon, add it to the `icons` object in `src/ui.ts` with a comment noting the Unicode codepoint + # Ultracite Code Standards This project uses **Ultracite**, a zero-config preset that enforces strict code quality standards through automated formatting and linting. @@ -121,3 +238,19 @@ Biome's linter will catch most issues automatically. Focus your attention on: --- Most formatting and common issues are automatically fixed by Biome. Run `bun x ultracite fix` before committing to ensure compliance. + +## CLI Icons + +All CLI output icons are centralized in `src/ui.ts` via the `icons` object. When adding or modifying CLI output: + +- Import icons from `src/ui.ts` — never hardcode symbols or emoji inline +- Use clean geometric Unicode glyphs (▸ ◆ ● ◎ etc.) — no emoji (🔑 📁 🔒 etc.) +- Emoji render inconsistently across terminals and break monospace alignment +- Each icon must be wrapped with its semantic color function (e.g. `green("✔")`, `red("✖")`) +- When adding a new icon, add it to the `icons` object in `src/ui.ts` with a comment noting the Unicode codepoint + + +## Effect libraries + +check this out for more info: +`https://effect.website/llms-full.txt` diff --git a/.gitignore b/.gitignore index b77a4c3..21bc42e 100644 --- a/.gitignore +++ b/.gitignore @@ -145,8 +145,7 @@ vite.config.ts.timestamp-* # Claude *.local.json -.claude -skills-lock.json +.claude/settings.*.json .kiro .agents .vercel diff --git a/CLAUDE.md b/CLAUDE.md index 09f322c..7f3f0e1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -116,6 +116,141 @@ All CLI output icons are centralized in `src/ui.ts` via the `icons` object. When - Each icon must be wrapped with its semantic color function (e.g. `green("✔")`, `red("✖")`) - When adding a new icon, add it to the `icons` object in `src/ui.ts` with a comment noting the Unicode codepoint +# Ultracite Code Standards + +This project uses **Ultracite**, a zero-config preset that enforces strict code quality standards through automated formatting and linting. + +## Quick Reference + +- **Format code**: `bun x ultracite fix` +- **Check for issues**: `bun x ultracite check` +- **Diagnose setup**: `bun x ultracite doctor` + +Biome (the underlying engine) provides robust linting and formatting. Most issues are automatically fixable. + +--- + +## Core Principles + +Write code that is **accessible, performant, type-safe, and maintainable**. Focus on clarity and explicit intent over brevity. + +### Type Safety & Explicitness + +- Use explicit types for function parameters and return values when they enhance clarity +- Prefer `unknown` over `any` when the type is genuinely unknown +- Use const assertions (`as const`) for immutable values and literal types +- Leverage TypeScript's type narrowing instead of type assertions +- Use meaningful variable names instead of magic numbers - extract constants with descriptive names + +### Modern JavaScript/TypeScript + +- Use arrow functions for callbacks and short functions +- Prefer `for...of` loops over `.forEach()` and indexed `for` loops +- Use optional chaining (`?.`) and nullish coalescing (`??`) for safer property access +- Prefer template literals over string concatenation +- Use destructuring for object and array assignments +- Use `const` by default, `let` only when reassignment is needed, never `var` + +### Async & Promises + +- Always `await` promises in async functions - don't forget to use the return value +- Use `async/await` syntax instead of promise chains for better readability +- Handle errors appropriately in async code with try-catch blocks +- Don't use async functions as Promise executors + +### React & JSX + +- Use function components over class components +- Call hooks at the top level only, never conditionally +- Specify all dependencies in hook dependency arrays correctly +- Use the `key` prop for elements in iterables (prefer unique IDs over array indices) +- Nest children between opening and closing tags instead of passing as props +- Don't define components inside other components +- Use semantic HTML and ARIA attributes for accessibility: + - Provide meaningful alt text for images + - Use proper heading hierarchy + - Add labels for form inputs + - Include keyboard event handlers alongside mouse events + - Use semantic elements (`