From ddaea57f2dd59ac3e9fd4eb314131de97cfbb1f2 Mon Sep 17 00:00:00 2001 From: zhi Date: Sun, 8 Mar 2026 21:12:27 +0000 Subject: [PATCH] feat: rewrite pass_mgr with build-time AES key, update pcexec & install pass_mgr: - Complete rewrite using build-time AES key (injected via ldflags) - New command format: get-secret/get-username --key, set --key --secret - Admin commands: init, handoff, init-from (rejected when AGENT_* env set) - Inline pcguard check for agent commands - Legacy 'get ' kept for backward compat - Storage: pc-pass-store//.gpg with AES-256-GCM - Admin password stored as SHA-256 hash in .pass_mgr/admin.json pcexec.ts: - Support new 'get-secret --key' pattern alongside legacy 'get ' - Pass environment to fetchPassword for pcguard validation - Deduplicate matches, sanitize all resolved passwords from output install.mjs: - Generate random 32-byte hex build secret (.build-secret) - Reuse existing secret on rebuilds - Pass to go build via -ldflags -X main.buildSecret= README.md: - Document new pass_mgr command format - Document admin handoff/init-from workflow - Document security model limitations - Update project structure --- .gitignore | 3 + README.md | 97 ++-- install.mjs | 24 +- pass_mgr/src/main.go | 1005 +++++++++++++++++++++++----------------- plugin/tools/pcexec.ts | 327 ++++++------- 5 files changed, 798 insertions(+), 658 deletions(-) diff --git a/.gitignore b/.gitignore index 700be39..7e0a85d 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,6 @@ vendor/ # Lock files (sub-packages) plugin/package-lock.json + +# Build secret (generated by install.mjs) +.build-secret diff --git a/README.md b/README.md index e2c5695..71c8422 100644 --- a/README.md +++ b/README.md @@ -8,26 +8,42 @@ OpenClaw plugin for secure password management, safe command execution, and coordinated agent restart. +## ⚠️ Security Model + +> **pcexec + pcguard mitigate light model hallucination / misoperation / prompt forgetting.** +> They **do not** defend against malicious attacks. +> For stronger security, use **sandbox mode** instead of this plugin. + ## Features -### 1. pass_mgr — Password Manager (Go) +### 1. pass\_mgr — Password Manager (Go) -AES-256-GCM encryption, per-agent key-based encryption/decryption. +AES-256-GCM encryption with a **build-time secret** injected at compile time. +Secrets are stored per-agent under `pc-pass-store//.gpg`. + +**Agent commands** (require pcguard — must run through pcexec): ```bash -pass_mgr admin init # Initialize -pass_mgr get # Get password -pass_mgr set # Set password (human only) -pass_mgr generate # Generate password -pass_mgr unset # Delete -pass_mgr rotate # Rotate +pass_mgr list # List keys for current agent +pass_mgr get-secret --key # Output secret +pass_mgr get-username --key # Output username +pass_mgr set --key --secret [--username ] # Set entry +pass_mgr generate --key [--username ] # Generate random secret +pass_mgr unset --key # Delete entry +pass_mgr get # Legacy (maps to get-secret) +``` + +**Admin commands** (human-only — rejected if any `AGENT_*` env var is set): + +```bash +pass_mgr admin init # Set admin password (interactive or PC_ADMIN_PASS) +pass_mgr admin handoff [file] # Export build secret to file (default: pc-pass-store.secret) +pass_mgr admin init-from [file] # Re-encrypt all data from old build secret to current ``` ### 2. pcguard — Exec Guard (Go) -Validates that a process is running inside a pcexec context by checking environment sentinels (`AGENT_VERIFY`, `AGENT_ID`, `AGENT_WORKSPACE`). Returns exit code 1 with error message if any check fails. - -Scripts can call `pcguard` at the top to ensure they're executed via pcexec: +Validates that a process is running inside a pcexec context by checking environment sentinels (`AGENT_VERIFY`, `AGENT_ID`, `AGENT_WORKSPACE`). Returns exit code 1 if any check fails. ```bash #!/bin/bash @@ -38,7 +54,8 @@ pcguard || exit 1 ### 3. pcexec — Safe Execution Tool (TypeScript) Drop-in replacement for `exec` that: -- Resolves `$(pass_mgr get key)` inline and sanitizes passwords from output +- Resolves `$(pass_mgr get-secret --key )` and legacy `$(pass_mgr get )` inline +- Sanitizes all resolved passwords from stdout/stderr - Injects `AGENT_VERIFY`, `AGENT_ID`, `AGENT_WORKSPACE` environment variables - Appends `$(openclaw path)/bin` to `PATH` (making `pcguard` and `pass_mgr` available) @@ -46,19 +63,6 @@ Drop-in replacement for `exec` that: Agent state management and coordinated gateway restart. -**Agent States:** idle → busy → focus → freeze → pre-freeze - -**APIs:** -- `POST /query-restart` — Query restart readiness -- `POST /restart-result` — Report restart result -- `GET /status` — Get all statuses - -## ⚠️ Security Limitations - -> **PCEXEC + PCGUARD only mitigate light model hallucination / misoperation / prompt forgetting.** -> They **do not** defend against malicious attacks. -> For stronger security, use **sandbox mode** instead of this plugin. - ## Project Structure ``` @@ -76,8 +80,6 @@ PaddedCell/ │ └── src/main.go ├── pcguard/ # Go exec guard binary │ └── src/main.go -├── docs/ # Documentation -├── scripts/ # Utility scripts ├── dist/padded-cell/ # Build output ├── install.mjs # Installer └── README.md @@ -99,27 +101,46 @@ node install.mjs --build-only node install.mjs --uninstall ``` +The installer automatically generates a random 32-byte build secret (stored in `.build-secret`, gitignored) and injects it into `pass_mgr` at compile time. Subsequent builds reuse the same secret. + ### Install paths -The installer resolves the openclaw base path with this priority: -1. `--openclaw-profile-path` CLI argument -2. `$OPENCLAW_PATH` environment variable -3. `~/.openclaw` (default) +Priority: `--openclaw-profile-path` → `$OPENCLAW_PATH` → `~/.openclaw` -Binaries go to `$(openclaw path)/bin/`, plugin files to `$(openclaw path)/plugins/padded-cell/`. +Binaries → `$(openclaw path)/bin/`, plugin files → `$(openclaw path)/plugins/padded-cell/`. + +## Plugin Update Workflow (admin handoff) + +When you rebuild PaddedCell (which generates a new build secret), existing encrypted data needs re-encryption: + +```bash +# 1. Before updating — export current build secret +~/.openclaw/bin/pass_mgr admin handoff + +# 2. Rebuild & reinstall (generates new .build-secret) +rm .build-secret +node install.mjs + +# 3. After updating — re-encrypt data with new secret +~/.openclaw/bin/pass_mgr admin init-from + +# 4. Restart gateway +openclaw gateway restart +``` ## Usage ```bash -# Initialize pass_mgr +# Initialize admin password ~/.openclaw/bin/pass_mgr admin init -# Set and get passwords -~/.openclaw/bin/pass_mgr set mykey mypassword -~/.openclaw/bin/pass_mgr get mykey +# Agent sets and gets passwords (via pcexec) +pass_mgr set --key myservice --secret s3cret --username admin +pass_mgr get-secret --key myservice +pass_mgr get-username --key myservice -# Use pcguard in scripts -pcguard || exit 1 +# Use in shell commands (pcexec resolves and sanitizes) +curl -u "$(pass_mgr get-username --key myservice):$(pass_mgr get-secret --key myservice)" https://api.example.com ``` ## License diff --git a/install.mjs b/install.mjs index a420eb3..b2bf3a2 100755 --- a/install.mjs +++ b/install.mjs @@ -12,7 +12,8 @@ */ import { execSync } from 'child_process'; -import { existsSync, mkdirSync, copyFileSync, chmodSync, readdirSync, rmSync } from 'fs'; +import { existsSync, mkdirSync, copyFileSync, chmodSync, readdirSync, rmSync, readFileSync, writeFileSync } from 'fs'; +import { randomBytes } from 'crypto'; import { dirname, join, resolve } from 'path'; import { fileURLToPath } from 'url'; import { homedir, platform } from 'os'; @@ -117,14 +118,33 @@ function checkDeps(env) { // ── Step 3: Build ─────────────────────────────────────────────────────── +function ensureBuildSecret() { + const secretFile = join(__dirname, '.build-secret'); + if (existsSync(secretFile)) { + const existing = readFileSync(secretFile, 'utf8').trim(); + if (existing.length >= 32) { + logOk('Reusing existing build secret'); + return existing; + } + } + const secret = randomBytes(32).toString('hex'); + writeFileSync(secretFile, secret + '\n', { mode: 0o600 }); + logOk('Generated new build secret'); + return secret; +} + async function build() { logStep(3, 6, 'Building components...'); + // Generate / load build secret for pass_mgr + const buildSecret = ensureBuildSecret(); + // pass_mgr (Go) log(' Building pass_mgr...', 'blue'); const pmDir = join(__dirname, 'pass_mgr'); exec('go mod tidy', { cwd: pmDir, silent: !options.verbose }); - exec('go build -o dist/pass_mgr src/main.go', { cwd: pmDir, silent: !options.verbose }); + const ldflags = `-X main.buildSecret=${buildSecret}`; + exec(`go build -ldflags "${ldflags}" -o dist/pass_mgr src/main.go`, { cwd: pmDir, silent: !options.verbose }); chmodSync(join(pmDir, 'dist', 'pass_mgr'), 0o755); logOk('pass_mgr'); diff --git a/pass_mgr/src/main.go b/pass_mgr/src/main.go index 474c2a1..c0f0852 100644 --- a/pass_mgr/src/main.go +++ b/pass_mgr/src/main.go @@ -13,521 +13,670 @@ import ( "path/filepath" "strings" "syscall" - "time" "github.com/spf13/cobra" "golang.org/x/term" ) +// buildSecret is injected at compile time via -ldflags "-X main.buildSecret=" +var buildSecret string + const ( - DefaultAlgorithm = "AES-256-GCM" - AdminKeyDir = ".pass_mgr" - AdminKeyFile = ".priv" - SecretsDirName = ".secrets" + PassStoreDirName = "pc-pass-store" + AdminDirName = ".pass_mgr" + AdminFile = "admin.json" + + // Must match pcguard sentinel + expectedAgentVerify = "IF YOU ARE AN AGENT/MODEL, YOU SHOULD NEVER TOUCH THIS ENV VARIABLE" ) -// EncryptedData represents the structure of encrypted password file -type EncryptedData struct { - Algorithm string `json:"algorithm"` - Nonce string `json:"nonce"` - Data string `json:"data"` - User string `json:"user,omitempty"` +// ── Data types ────────────────────────────────────────────────────────── + +// EncryptedFile is the on-disk format for .gpg files +type EncryptedFile struct { + Nonce string `json:"nonce"` + Data string `json:"data"` } -// Config holds admin key configuration -type Config struct { - KeyHash string `json:"key_hash"` - Algorithm string `json:"algorithm"` +// Entry is the plaintext content inside an encrypted file +type Entry struct { + Username string `json:"username"` + Secret string `json:"secret"` } -var ( - workspaceDir string - agentID string -) +// AdminConfig stores hashed admin password +type AdminConfig struct { + PasswordHash string `json:"password_hash"` +} + +// ── Helpers ───────────────────────────────────────────────────────────── + +func resolveOpenclawPath() string { + if p := os.Getenv("OPENCLAW_PATH"); p != "" { + return p + } + home, _ := os.UserHomeDir() + return filepath.Join(home, ".openclaw") +} + +func passStoreBase() string { + return filepath.Join(resolveOpenclawPath(), PassStoreDirName) +} + +func agentStoreDir(agentID string) string { + return filepath.Join(passStoreBase(), agentID) +} + +func adminDir() string { + return filepath.Join(resolveOpenclawPath(), AdminDirName) +} + +func adminConfigPath() string { + return filepath.Join(adminDir(), AdminFile) +} + +// deriveKey returns AES-256 key from SHA-256 of the given secret string +func deriveKey(secret string) []byte { + h := sha256.Sum256([]byte(secret)) + return h[:] +} + +// currentKey returns the AES key derived from the compiled-in buildSecret +func currentKey() ([]byte, error) { + if buildSecret == "" { + return nil, fmt.Errorf("pass_mgr was built without a build secret; re-run install.mjs") + } + return deriveKey(buildSecret), nil +} + +// ── Encryption ────────────────────────────────────────────────────────── + +func encrypt(plaintext, key []byte) (*EncryptedFile, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return nil, err + } + ciphertext := gcm.Seal(nil, nonce, plaintext, nil) + return &EncryptedFile{ + Nonce: base64.StdEncoding.EncodeToString(nonce), + Data: base64.StdEncoding.EncodeToString(ciphertext), + }, nil +} + +func decrypt(ef *EncryptedFile, key []byte) ([]byte, error) { + nonce, err := base64.StdEncoding.DecodeString(ef.Nonce) + if err != nil { + return nil, fmt.Errorf("invalid nonce: %w", err) + } + ciphertext, err := base64.StdEncoding.DecodeString(ef.Data) + if err != nil { + return nil, fmt.Errorf("invalid data: %w", err) + } + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + return gcm.Open(nil, nonce, ciphertext, nil) +} + +// ── Entry I/O ─────────────────────────────────────────────────────────── + +func readEntry(filePath string, key []byte) (*Entry, error) { + raw, err := os.ReadFile(filePath) + if err != nil { + return nil, err + } + var ef EncryptedFile + if err := json.Unmarshal(raw, &ef); err != nil { + return nil, fmt.Errorf("corrupt file: %w", err) + } + plain, err := decrypt(&ef, key) + if err != nil { + return nil, fmt.Errorf("decryption failed: %w", err) + } + var entry Entry + if err := json.Unmarshal(plain, &entry); err != nil { + return nil, fmt.Errorf("invalid entry json: %w", err) + } + return &entry, nil +} + +func writeEntry(filePath string, entry *Entry, key []byte) error { + plain, _ := json.Marshal(entry) + ef, err := encrypt(plain, key) + if err != nil { + return err + } + data, _ := json.MarshalIndent(ef, "", " ") + return os.WriteFile(filePath, data, 0600) +} + +// ── pcguard inline check ──────────────────────────────────────────────── + +func requirePcguard() { + if os.Getenv("AGENT_VERIFY") != expectedAgentVerify { + fmt.Fprintln(os.Stderr, "Error: must be invoked via pcexec (AGENT_VERIFY mismatch)") + os.Exit(1) + } + if os.Getenv("AGENT_ID") == "" { + fmt.Fprintln(os.Stderr, "Error: AGENT_ID not set — must be invoked via pcexec") + os.Exit(1) + } + if os.Getenv("AGENT_WORKSPACE") == "" { + fmt.Fprintln(os.Stderr, "Error: AGENT_WORKSPACE not set — must be invoked via pcexec") + os.Exit(1) + } +} + +func currentAgentID() string { + return os.Getenv("AGENT_ID") +} + +// ── Admin helpers ─────────────────────────────────────────────────────── + +func anyAgentEnvSet() bool { + return os.Getenv("AGENT_ID") != "" || + os.Getenv("AGENT_WORKSPACE") != "" || + os.Getenv("AGENT_VERIFY") != "" +} + +func rejectIfAgent(cmdName string) { + if anyAgentEnvSet() { + fmt.Fprintf(os.Stderr, "Error: '%s' can only be run by a human (AGENT_* env vars detected)\n", cmdName) + os.Exit(1) + } +} + +func hashPassword(pw string) string { + h := sha256.Sum256([]byte(pw)) + return fmt.Sprintf("%x", h) +} + +func loadAdminConfig() (*AdminConfig, error) { + raw, err := os.ReadFile(adminConfigPath()) + if err != nil { + return nil, err + } + var cfg AdminConfig + if err := json.Unmarshal(raw, &cfg); err != nil { + return nil, err + } + return &cfg, nil +} + +func saveAdminConfig(cfg *AdminConfig) error { + if err := os.MkdirAll(adminDir(), 0700); err != nil { + return err + } + data, _ := json.MarshalIndent(cfg, "", " ") + return os.WriteFile(adminConfigPath(), data, 0600) +} + +func verifyAdminPassword() error { + cfg, err := loadAdminConfig() + if err != nil { + return fmt.Errorf("admin not initialized — run 'pass_mgr admin init' first") + } + + var password string + if envPw := os.Getenv("PC_ADMIN_PASS"); envPw != "" { + password = envPw + } else { + fmt.Fprint(os.Stderr, "Admin password: ") + raw, err := term.ReadPassword(int(syscall.Stdin)) + if err != nil { + return fmt.Errorf("failed to read password: %w", err) + } + fmt.Fprintln(os.Stderr) + password = strings.TrimSpace(string(raw)) + } + + if hashPassword(password) != cfg.PasswordHash { + return fmt.Errorf("invalid admin password") + } + return nil +} + +// ── Password generation ───────────────────────────────────────────────── + +func generatePassword(length int) (string, error) { + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()-_=+" + buf := make([]byte, length) + for i := range buf { + b := make([]byte, 1) + if _, err := rand.Read(b); err != nil { + return "", err + } + buf[i] = charset[int(b[0])%len(charset)] + } + return string(buf), nil +} + +// ── Commands ──────────────────────────────────────────────────────────── func main() { rootCmd := &cobra.Command{ Use: "pass_mgr", Short: "Password manager for OpenClaw agents", - Long: `A secure password management tool using AES-256-GCM encryption.`, } - // Get environment variables - workspaceDir = os.Getenv("AGENT_WORKSPACE") - agentID = os.Getenv("AGENT_ID") - - // Commands - rootCmd.AddCommand(getCmd()) - rootCmd.AddCommand(generateCmd()) - rootCmd.AddCommand(unsetCmd()) - rootCmd.AddCommand(rotateCmd()) - rootCmd.AddCommand(adminInitCmd()) - rootCmd.AddCommand(setCmd()) + rootCmd.AddCommand( + listCmd(), + getSecretCmd(), + getUsernameCmd(), + getLegacyCmd(), // backward compat: pass_mgr get + setCmd(), + generateCmd(), + unsetCmd(), + adminCmd(), + ) if err := rootCmd.Execute(); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } } -func getCmd() *cobra.Command { +// ── list ───────────────────────────────────────────────────────────────── + +func listCmd() *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List keys for current agent", + Run: func(cmd *cobra.Command, args []string) { + requirePcguard() + dir := agentStoreDir(currentAgentID()) + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return // no keys yet + } + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + for _, e := range entries { + name := e.Name() + if strings.HasSuffix(name, ".gpg") { + fmt.Println(strings.TrimSuffix(name, ".gpg")) + } + } + }, + } +} + +// ── get-secret ────────────────────────────────────────────────────────── + +func getSecretCmd() *cobra.Command { + var keyFlag string + cmd := &cobra.Command{ + Use: "get-secret", + Short: "Get secret for a key", + Run: func(cmd *cobra.Command, args []string) { + requirePcguard() + if keyFlag == "" { + fmt.Fprintln(os.Stderr, "Error: --key is required") + os.Exit(1) + } + key, err := currentKey() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + fp := filepath.Join(agentStoreDir(currentAgentID()), keyFlag+".gpg") + entry, err := readEntry(fp, key) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + fmt.Print(entry.Secret) + }, + } + cmd.Flags().StringVar(&keyFlag, "key", "", "Key name") + return cmd +} + +// ── get-username ──────────────────────────────────────────────────────── + +func getUsernameCmd() *cobra.Command { + var keyFlag string + cmd := &cobra.Command{ + Use: "get-username", + Short: "Get username for a key", + Run: func(cmd *cobra.Command, args []string) { + requirePcguard() + if keyFlag == "" { + fmt.Fprintln(os.Stderr, "Error: --key is required") + os.Exit(1) + } + key, err := currentKey() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + fp := filepath.Join(agentStoreDir(currentAgentID()), keyFlag+".gpg") + entry, err := readEntry(fp, key) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + fmt.Print(entry.Username) + }, + } + cmd.Flags().StringVar(&keyFlag, "key", "", "Key name") + return cmd +} + +// ── get (legacy) ──────────────────────────────────────────────────────── + +func getLegacyCmd() *cobra.Command { var showUsername bool cmd := &cobra.Command{ - Use: "get [key]", - Short: "Get password for a key", - Args: cobra.ExactArgs(1), + Use: "get [key]", + Short: "Get secret (legacy — use get-secret --key instead)", + Args: cobra.ExactArgs(1), + Hidden: true, Run: func(cmd *cobra.Command, args []string) { - key := args[0] - password, user, err := getPassword(key) + requirePcguard() + keyName := args[0] + key, err := currentKey() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + fp := filepath.Join(agentStoreDir(currentAgentID()), keyName+".gpg") + entry, err := readEntry(fp, key) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } if showUsername { - fmt.Println(user) + fmt.Print(entry.Username) } else { - fmt.Println(password) + fmt.Print(entry.Secret) } }, } - cmd.Flags().BoolVar(&showUsername, "username", false, "Show username instead of password") + cmd.Flags().BoolVar(&showUsername, "username", false, "Show username instead of secret") return cmd } +// ── set ───────────────────────────────────────────────────────────────── + +func setCmd() *cobra.Command { + var keyFlag, username, secret string + cmd := &cobra.Command{ + Use: "set", + Short: "Set a key entry", + Run: func(cmd *cobra.Command, args []string) { + requirePcguard() + if keyFlag == "" || secret == "" { + fmt.Fprintln(os.Stderr, "Error: --key and --secret are required") + os.Exit(1) + } + aesKey, err := currentKey() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + dir := agentStoreDir(currentAgentID()) + if err := os.MkdirAll(dir, 0700); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + entry := &Entry{Username: username, Secret: secret} + fp := filepath.Join(dir, keyFlag+".gpg") + if err := writeEntry(fp, entry, aesKey); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, + } + cmd.Flags().StringVar(&keyFlag, "key", "", "Key name") + cmd.Flags().StringVar(&username, "username", "", "Username") + cmd.Flags().StringVar(&secret, "secret", "", "Secret value") + return cmd +} + +// ── generate ──────────────────────────────────────────────────────────── + func generateCmd() *cobra.Command { - var user string + var keyFlag, username string cmd := &cobra.Command{ - Use: "generate [key]", - Short: "Generate a new password", - Args: cobra.ExactArgs(1), + Use: "generate", + Short: "Generate a random secret for a key", Run: func(cmd *cobra.Command, args []string) { - key := args[0] - // Check if agent is trying to set password - if os.Getenv("AGENT") != "" || os.Getenv("AGENT_WORKSPACE") != "" { - fmt.Fprintln(os.Stderr, "Error: Agents cannot set passwords. Use generate instead.") + requirePcguard() + if keyFlag == "" { + fmt.Fprintln(os.Stderr, "Error: --key is required") os.Exit(1) } - password, err := generatePassword(32) + aesKey, err := currentKey() if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } - if err := setPassword(key, user, password); err != nil { + pw, err := generatePassword(32) + if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } - fmt.Println(password) + dir := agentStoreDir(currentAgentID()) + if err := os.MkdirAll(dir, 0700); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + entry := &Entry{Username: username, Secret: pw} + fp := filepath.Join(dir, keyFlag+".gpg") + if err := writeEntry(fp, entry, aesKey); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + fmt.Print(pw) }, } - cmd.Flags().StringVar(&user, "username", "", "Username associated with the password") + cmd.Flags().StringVar(&keyFlag, "key", "", "Key name") + cmd.Flags().StringVar(&username, "username", "", "Username") return cmd } +// ── unset ─────────────────────────────────────────────────────────────── + func unsetCmd() *cobra.Command { - return &cobra.Command{ - Use: "unset [key]", - Short: "Remove a password", - Args: cobra.ExactArgs(1), + var keyFlag string + cmd := &cobra.Command{ + Use: "unset", + Short: "Remove a key entry", Run: func(cmd *cobra.Command, args []string) { - key := args[0] - if err := removePassword(key); err != nil { + requirePcguard() + if keyFlag == "" { + fmt.Fprintln(os.Stderr, "Error: --key is required") + os.Exit(1) + } + fp := filepath.Join(agentStoreDir(currentAgentID()), keyFlag+".gpg") + if err := os.Remove(fp); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } }, } + cmd.Flags().StringVar(&keyFlag, "key", "", "Key name") + return cmd } -func rotateCmd() *cobra.Command { - return &cobra.Command{ - Use: "rotate [key]", - Short: "Rotate password for a key", - Args: cobra.ExactArgs(1), - Run: func(cmd *cobra.Command, args []string) { - key := args[0] - // Check if initialized - if !isInitialized() { - fmt.Fprintln(os.Stderr, "Error: pass_mgr not initialized. Run 'pass_mgr admin init' first.") - os.Exit(1) - } - - // Get current user if exists - _, user, err := getPassword(key) - if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } - - // Generate new password - newPassword, err := generatePassword(32) - if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } - - if err := setPassword(key, user, newPassword); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } - fmt.Println(newPassword) - }, +// ── admin ─────────────────────────────────────────────────────────────── + +func adminCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "admin", + Short: "Admin commands (human only)", } + cmd.AddCommand(adminInitCmd(), adminHandoffCmd(), adminInitFromCmd()) + return cmd } func adminInitCmd() *cobra.Command { return &cobra.Command{ - Use: "admin init", - Short: "Initialize pass_mgr with admin key", + Use: "init", + Short: "Set admin password", Run: func(cmd *cobra.Command, args []string) { - if err := initAdminInteractive(); err != nil { + rejectIfAgent("admin init") + + var password string + if envPw := os.Getenv("PC_ADMIN_PASS"); envPw != "" { + password = envPw + } else { + fmt.Fprint(os.Stderr, "Enter admin password: ") + pw1, err := term.ReadPassword(int(syscall.Stdin)) + if err != nil { + fmt.Fprintf(os.Stderr, "\nError: %v\n", err) + os.Exit(1) + } + fmt.Fprintln(os.Stderr) + p1 := strings.TrimSpace(string(pw1)) + if len(p1) < 6 { + fmt.Fprintln(os.Stderr, "Error: password must be at least 6 characters") + os.Exit(1) + } + fmt.Fprint(os.Stderr, "Confirm admin password: ") + pw2, err := term.ReadPassword(int(syscall.Stdin)) + if err != nil { + fmt.Fprintf(os.Stderr, "\nError: %v\n", err) + os.Exit(1) + } + fmt.Fprintln(os.Stderr) + if p1 != strings.TrimSpace(string(pw2)) { + fmt.Fprintln(os.Stderr, "Error: passwords do not match") + os.Exit(1) + } + password = p1 + } + + cfg := &AdminConfig{PasswordHash: hashPassword(password)} + if err := saveAdminConfig(cfg); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } - fmt.Println("pass_mgr initialized successfully") + fmt.Println("Admin password set successfully") }, } } -func setCmd() *cobra.Command { - var user string - cmd := &cobra.Command{ - Use: "set [key] [password]", - Short: "Set password (admin only)", - Args: cobra.ExactArgs(2), +func adminHandoffCmd() *cobra.Command { + return &cobra.Command{ + Use: "handoff [secret_file_path]", + Short: "Export build secret to file for migration", + Args: cobra.MaximumNArgs(1), Run: func(cmd *cobra.Command, args []string) { - // Check if agent is trying to set password - if os.Getenv("AGENT") != "" || os.Getenv("AGENT_WORKSPACE") != "" { - fmt.Fprintln(os.Stderr, "Error: Agents cannot set passwords. Only humans can use 'set'.") - os.Exit(1) - } - - key := args[0] - password := args[1] - - if err := setPassword(key, user, password); err != nil { + rejectIfAgent("admin handoff") + if err := verifyAdminPassword(); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } + if buildSecret == "" { + fmt.Fprintln(os.Stderr, "Error: no build secret compiled in") + os.Exit(1) + } + + outPath := "pc-pass-store.secret" + if len(args) > 0 { + outPath = args[0] + } + if err := os.WriteFile(outPath, []byte(buildSecret), 0600); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + fmt.Fprintf(os.Stderr, "Build secret written to %s\n", outPath) }, } - cmd.Flags().StringVar(&user, "username", "", "Username associated with the password") - return cmd } -// Helper functions +func adminInitFromCmd() *cobra.Command { + return &cobra.Command{ + Use: "init-from [secret_file_path]", + Short: "Re-encrypt all data from old build secret to current", + Args: cobra.MaximumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + rejectIfAgent("admin init-from") + if err := verifyAdminPassword(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } -func getHomeDir() string { - home, err := os.UserHomeDir() - if err != nil { - return "." + inPath := "pc-pass-store.secret" + if len(args) > 0 { + inPath = args[0] + } + + oldSecretBytes, err := os.ReadFile(inPath) + if err != nil { + fmt.Fprintf(os.Stderr, "Error reading secret file: %v\n", err) + os.Exit(1) + } + oldSecret := strings.TrimSpace(string(oldSecretBytes)) + oldKey := deriveKey(oldSecret) + + newKey, err := currentKey() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + base := passStoreBase() + agentDirs, err := os.ReadDir(base) + if err != nil { + if os.IsNotExist(err) { + fmt.Fprintln(os.Stderr, "No pass store found — nothing to migrate") + os.Exit(0) + } + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + count := 0 + for _, ad := range agentDirs { + if !ad.IsDir() { + continue + } + agentDir := filepath.Join(base, ad.Name()) + files, err := os.ReadDir(agentDir) + if err != nil { + continue + } + for _, f := range files { + if !strings.HasSuffix(f.Name(), ".gpg") { + continue + } + fp := filepath.Join(agentDir, f.Name()) + entry, err := readEntry(fp, oldKey) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to decrypt %s: %v\n", fp, err) + continue + } + if err := writeEntry(fp, entry, newKey); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to re-encrypt %s: %v\n", fp, err) + continue + } + count++ + } + } + + // Delete the old secret file + if err := os.Remove(inPath); err != nil { + fmt.Fprintf(os.Stderr, "Warning: could not remove secret file: %v\n", err) + } + + fmt.Fprintf(os.Stderr, "Re-encrypted %d entries. Secret file removed.\n", count) + }, } - return home -} - -func getAdminKeyPath() string { - return filepath.Join(getHomeDir(), AdminKeyDir, AdminKeyFile) -} - -func getConfigPath() string { - return filepath.Join(getHomeDir(), AdminKeyDir, "config.json") -} - -func isInitialized() bool { - _, err := os.Stat(getConfigPath()) - return err == nil -} - -func loadAdminKey() ([]byte, error) { - keyPath := getAdminKeyPath() - key, err := os.ReadFile(keyPath) - if err != nil { - return nil, fmt.Errorf("failed to load admin key: %w", err) - } - // Hash the key to get 32 bytes for AES-256 - hash := sha256.Sum256(key) - return hash[:], nil -} - -func initAdminInteractive() error { - fmt.Print("Enter admin password: ") - password1, err := term.ReadPassword(int(syscall.Stdin)) - if err != nil { - return fmt.Errorf("failed to read password: %w", err) - } - fmt.Println() - - // Trim whitespace/newlines - password1 = []byte(strings.TrimSpace(string(password1))) - - // Validate password length - if len(password1) < 6 { - return fmt.Errorf("password must be at least 6 characters long (got %d)", len(password1)) - } - - fmt.Print("Confirm admin password: ") - password2, err := term.ReadPassword(int(syscall.Stdin)) - if err != nil { - return fmt.Errorf("failed to read password confirmation: %w", err) - } - fmt.Println() - - // Trim whitespace/newlines - password2 = []byte(strings.TrimSpace(string(password2))) - - // Check passwords match - if string(password1) != string(password2) { - return fmt.Errorf("passwords do not match") - } - - // Save the key - return saveAdminKey(password1) -} - -func saveAdminKey(key []byte) error { - homeDir := getHomeDir() - adminDir := filepath.Join(homeDir, AdminKeyDir) - - // Create admin directory - if err := os.MkdirAll(adminDir, 0700); err != nil { - return fmt.Errorf("failed to create admin directory: %w", err) - } - - // Save key - keyFile := filepath.Join(adminDir, AdminKeyFile) - if err := os.WriteFile(keyFile, key, 0600); err != nil { - return fmt.Errorf("failed to save key: %w", err) - } - - // Save config - config := Config{ - KeyHash: fmt.Sprintf("%x", sha256.Sum256(key)), - Algorithm: DefaultAlgorithm, - } - configData, _ := json.MarshalIndent(config, "", " ") - configPath := filepath.Join(adminDir, "config.json") - if err := os.WriteFile(configPath, configData, 0600); err != nil { - return fmt.Errorf("failed to save config: %w", err) - } - - return nil -} - -func initAdmin(keyPath string) error { - // Read provided key - key, err := os.ReadFile(keyPath) - if err != nil { - return fmt.Errorf("failed to read key file: %w", err) - } - - // Validate password length (must be >= 6 characters) - if len(key) < 6 { - return fmt.Errorf("password must be at least 6 characters long (got %d)", len(key)) - } - - return saveAdminKey(key) -} - -func getSecretsDir() string { - if workspaceDir != "" && agentID != "" { - return filepath.Join(workspaceDir, SecretsDirName, agentID) - } - // Fallback to home directory - return filepath.Join(getHomeDir(), SecretsDirName, "default") -} - -func getPasswordFilePath(key string) string { - return filepath.Join(getSecretsDir(), key+".gpg") -} - -func encrypt(plaintext []byte, key []byte) (*EncryptedData, error) { - block, err := aes.NewCipher(key) - if err != nil { - return nil, err - } - - gcm, err := cipher.NewGCM(block) - if err != nil { - return nil, err - } - - nonce := make([]byte, gcm.NonceSize()) - if _, err := io.ReadFull(rand.Reader, nonce); err != nil { - return nil, err - } - - ciphertext := gcm.Seal(nonce, nonce, plaintext, nil) - - return &EncryptedData{ - Algorithm: DefaultAlgorithm, - Nonce: base64.StdEncoding.EncodeToString(nonce), - Data: base64.StdEncoding.EncodeToString(ciphertext[gcm.NonceSize():]), - }, nil -} - -func decrypt(data *EncryptedData, key []byte) ([]byte, error) { - ciphertext, err := base64.StdEncoding.DecodeString(data.Data) - if err != nil { - return nil, err - } - - nonce, err := base64.StdEncoding.DecodeString(data.Nonce) - if err != nil { - return nil, err - } - - block, err := aes.NewCipher(key) - if err != nil { - return nil, err - } - - gcm, err := cipher.NewGCM(block) - if err != nil { - return nil, err - } - - plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) - if err != nil { - return nil, err - } - - return plaintext, nil -} - -func setPassword(key, user, password string) error { - if !isInitialized() { - return fmt.Errorf("pass_mgr not initialized. Run 'pass_mgr admin init' first") - } - - adminKey, err := loadAdminKey() - if err != nil { - return err - } - - // Create secrets directory - secretsDir := getSecretsDir() - if err := os.MkdirAll(secretsDir, 0700); err != nil { - return fmt.Errorf("failed to create secrets directory: %w", err) - } - - // Encrypt password - data := map[string]string{ - "password": password, - "user": user, - } - plaintext, _ := json.Marshal(data) - - encrypted, err := encrypt(plaintext, adminKey) - if err != nil { - return fmt.Errorf("failed to encrypt: %w", err) - } - encrypted.User = user - - // Save to file - filePath := getPasswordFilePath(key) - fileData, _ := json.MarshalIndent(encrypted, "", " ") - if err := os.WriteFile(filePath, fileData, 0600); err != nil { - return fmt.Errorf("failed to save password: %w", err) - } - - return nil -} - -func getPassword(key string) (string, string, error) { - if !isInitialized() { - return "", "", fmt.Errorf("pass_mgr not initialized. Run 'pass_mgr admin init' first") - } - - adminKey, err := loadAdminKey() - if err != nil { - return "", "", err - } - - filePath := getPasswordFilePath(key) - fileData, err := os.ReadFile(filePath) - if err != nil { - return "", "", fmt.Errorf("password not found: %w", err) - } - - var encrypted EncryptedData - if err := json.Unmarshal(fileData, &encrypted); err != nil { - return "", "", fmt.Errorf("failed to parse password file: %w", err) - } - - plaintext, err := decrypt(&encrypted, adminKey) - if err != nil { - return "", "", fmt.Errorf("failed to decrypt: %w", err) - } - - var data map[string]string - if err := json.Unmarshal(plaintext, &data); err != nil { - return "", "", fmt.Errorf("failed to parse decrypted data: %w", err) - } - - return data["password"], data["user"], nil -} - -func removePassword(key string) error { - if !isInitialized() { - return fmt.Errorf("pass_mgr not initialized. Run 'pass_mgr admin init' first") - } - - filePath := getPasswordFilePath(key) - if err := os.Remove(filePath); err != nil { - return fmt.Errorf("failed to remove password: %w", err) - } - return nil -} - -func generatePassword(length int) (string, error) { - const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*" - password := make([]byte, length) - for i := range password { - randomByte := make([]byte, 1) - if _, err := rand.Read(randomByte); err != nil { - return "", err - } - password[i] = charset[int(randomByte[0])%len(charset)] - } - return string(password), nil -} - -// CheckForAdminLeak checks if admin password appears in message/tool calling -func CheckForAdminLeak(content string) bool { - // This is a placeholder - actual implementation should check against actual admin password - // This function should be called by the plugin to monitor messages - configPath := getConfigPath() - if _, err := os.Stat(configPath); err != nil { - return false - } - - // TODO: Implement actual leak detection - // For now, just check if content contains common patterns - return strings.Contains(content, "admin") && strings.Contains(content, "password") -} - -// ResetOnLeak resets pass_mgr to uninitialized state and logs security breach -func ResetOnLeak() error { - configPath := getConfigPath() - - // Remove config (but keep key file for potential recovery) - if err := os.Remove(configPath); err != nil { - return err - } - - // Log security breach - logPath := filepath.Join(getHomeDir(), AdminKeyDir, "security_breach.log") - logEntry := fmt.Sprintf("[%s] CRITICAL: Admin password leaked! pass_mgr reset to uninitialized state.\n", - time.Now().Format(time.RFC3339)) - - f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) - if err != nil { - return err - } - defer f.Close() - - if _, err := f.WriteString(logEntry); err != nil { - return err - } - - return nil } diff --git a/plugin/tools/pcexec.ts b/plugin/tools/pcexec.ts index 4738bb0..d7bec3a 100644 --- a/plugin/tools/pcexec.ts +++ b/plugin/tools/pcexec.ts @@ -1,7 +1,4 @@ import { spawn, SpawnOptions } from 'child_process'; -import { promisify } from 'util'; - -const execAsync = promisify(require('child_process').exec); export interface PcExecOptions { /** Current working directory */ @@ -49,72 +46,97 @@ export interface PcExecError extends Error { } /** - * Extract pass_mgr get commands from a command string - * Supports formats like: - * - $(pass_mgr get key) - * - `pass_mgr get key` - * - pass_mgr get key (direct invocation) + * Extract pass_mgr invocations from a command string. + * + * Supports both legacy and new formats: + * Legacy: $(pass_mgr get ) / `pass_mgr get ` + * New: $(pass_mgr get-secret --key ) / `pass_mgr get-secret --key ` + * + * Returns array of { fullMatch, subcommand, key } where subcommand is + * "get" | "get-secret". */ -function extractPassMgrGets(command: string): Array<{ key: string; fullMatch: string }> { - const results: Array<{ key: string; fullMatch: string }> = []; - - // Pattern for $(pass_mgr get key) or `pass_mgr get key` - const patterns = [ +function extractPassMgrGets( + command: string, +): Array<{ key: string; fullMatch: string; subcommand: string }> { + const results: Array<{ key: string; fullMatch: string; subcommand: string }> = []; + const seen = new Set(); + + // New format: pass_mgr get-secret --key + const newPatterns = [ + /\$\(\s*pass_mgr\s+get-secret\s+--key\s+(\S+)\s*\)/g, + /`\s*pass_mgr\s+get-secret\s+--key\s+(\S+)\s*`/g, + ]; + + // Legacy format: pass_mgr get + const legacyPatterns = [ /\$\(\s*pass_mgr\s+get\s+(\S+)\s*\)/g, /`\s*pass_mgr\s+get\s+(\S+)\s*`/g, - /pass_mgr\s+get\s+(\S+)/g, ]; - - for (const pattern of patterns) { + + for (const pattern of newPatterns) { let match; while ((match = pattern.exec(command)) !== null) { - results.push({ - key: match[1], - fullMatch: match[0], - }); + if (!seen.has(match[0])) { + seen.add(match[0]); + results.push({ + key: match[1], + fullMatch: match[0], + subcommand: 'get-secret', + }); + } } } - + + for (const pattern of legacyPatterns) { + let match; + while ((match = pattern.exec(command)) !== null) { + if (!seen.has(match[0])) { + seen.add(match[0]); + results.push({ + key: match[1], + fullMatch: match[0], + subcommand: 'get', + }); + } + } + } + return results; } /** - * Execute pass_mgr get and return the password + * Execute pass_mgr to retrieve a secret. + * Uses the same env vars that the caller passes so pcguard checks pass. */ -async function getPassword(key: string): Promise { +async function fetchPassword( + subcommand: string, + key: string, + env: Record, +): Promise { return new Promise((resolve, reject) => { - const passMgrPath = process.env.PASS_MGR_PATH || 'pass_mgr'; - const child = spawn(passMgrPath, ['get', key], { + const passMgrPath = env.PASS_MGR_PATH || process.env.PASS_MGR_PATH || 'pass_mgr'; + const args = + subcommand === 'get-secret' + ? ['get-secret', '--key', key] + : ['get', key]; + + const child = spawn(passMgrPath, args, { stdio: ['ignore', 'pipe', 'pipe'], - env: { - ...process.env, - AGENT_WORKSPACE: process.env.AGENT_WORKSPACE || '', - AGENT_ID: process.env.AGENT_ID || '', - }, + env: { ...process.env, ...env }, }); - + let stdout = ''; let stderr = ''; - - child.stdout.on('data', (data) => { - stdout += data.toString(); - }); - - child.stderr.on('data', (data) => { - stderr += data.toString(); - }); - + child.stdout.on('data', (d) => (stdout += d.toString())); + child.stderr.on('data', (d) => (stderr += d.toString())); child.on('close', (code) => { if (code !== 0) { - reject(new Error(`pass_mgr get failed: ${stderr || stdout}`)); + reject(new Error(`pass_mgr ${subcommand} failed: ${stderr || stdout}`)); } else { resolve(stdout.trim()); } }); - - child.on('error', (err) => { - reject(err); - }); + child.on('error', reject); }); } @@ -125,135 +147,100 @@ function sanitizeOutput(output: string, passwords: string[]): string { let sanitized = output; for (const password of passwords) { if (password) { - // Escape special regex characters const escaped = password.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const regex = new RegExp(escaped, 'g'); - sanitized = sanitized.replace(regex, '######'); + sanitized = sanitized.replace(new RegExp(escaped, 'g'), '######'); } } return sanitized; } /** - * Replace pass_mgr get commands with actual passwords in command + * Pre-resolve pass_mgr invocations, replace them inline, and collect passwords. */ -async function replacePassMgrGets(command: string): Promise<{ command: string; passwords: string[] }> { - const passMgrGets = extractPassMgrGets(command); +async function replacePassMgrGets( + command: string, + env: Record, +): Promise<{ command: string; passwords: string[] }> { + const matches = extractPassMgrGets(command); const passwords: string[] = []; - let replacedCommand = command; - - for (const { key, fullMatch } of passMgrGets) { - try { - const password = await getPassword(key); - passwords.push(password); - replacedCommand = replacedCommand.replace(fullMatch, password); - } catch (err) { - throw new Error(`Failed to get password for key '${key}': ${err}`); - } + let replaced = command; + + for (const { key, fullMatch, subcommand } of matches) { + const pw = await fetchPassword(subcommand, key, env); + passwords.push(pw); + replaced = replaced.split(fullMatch).join(pw); } - - return { command: replacedCommand, passwords }; + + return { command: replaced, passwords }; } /** - * Safe exec wrapper that handles pass_mgr get commands and sanitizes output - * - * @param command - Command to execute - * @param options - Execution options - * @returns Promise resolving to execution result + * Safe exec wrapper that handles pass_mgr get commands and sanitizes output. */ -export async function pcexec(command: string, options: PcExecOptions = {}): Promise { - // Set up environment with workspace/agent info +export async function pcexec( + command: string, + options: PcExecOptions = {}, +): Promise { + // Build environment const env: Record = {}; - - // Copy process.env, filtering out undefined values - for (const [key, value] of Object.entries(process.env)) { - if (value !== undefined) { - env[key] = value; - } + + for (const [k, v] of Object.entries(process.env)) { + if (v !== undefined) env[k] = v; } - - // Merge options.env - if (options.env) { - Object.assign(env, options.env); - } - - if (process.env.AGENT_WORKSPACE) { - env.AGENT_WORKSPACE = process.env.AGENT_WORKSPACE; - } - if (process.env.AGENT_ID) { - env.AGENT_ID = process.env.AGENT_ID; - } - - // Extract and replace pass_mgr get commands + if (options.env) Object.assign(env, options.env); + + // Pre-resolve passwords let finalCommand = command; let passwords: string[] = []; - - try { - const result = await replacePassMgrGets(command); - finalCommand = result.command; - passwords = result.passwords; - } catch (err) { - throw err; - } - + + const resolved = await replacePassMgrGets(command, env); + finalCommand = resolved.command; + passwords = resolved.passwords; + return new Promise((resolve, reject) => { const spawnOptions: SpawnOptions = { cwd: options.cwd, env, - // Don't use shell by default - we're already using bash -c explicitly shell: options.shell, windowsHide: options.windowsHide, uid: options.uid, gid: options.gid, }; - - // Use bash for better compatibility + const child = spawn('bash', ['-c', finalCommand], spawnOptions); - + let stdout = ''; let stderr = ''; let killed = false; let timeoutId: NodeJS.Timeout | null = null; - - // Set up timeout + if (options.timeout && options.timeout > 0) { timeoutId = setTimeout(() => { killed = true; child.kill(options.killSignal || 'SIGTERM'); }, options.timeout); } - - // Handle stdout + child.stdout?.on('data', (data) => { stdout += data.toString(); - - // Check maxBuffer if (options.maxBuffer && stdout.length > options.maxBuffer) { child.kill(options.killSignal || 'SIGTERM'); } }); - - // Handle stderr + child.stderr?.on('data', (data) => { stderr += data.toString(); - - // Check maxBuffer if (options.maxBuffer && stderr.length > options.maxBuffer) { child.kill(options.killSignal || 'SIGTERM'); } }); - - // Handle process close + child.on('close', (code, signal) => { - if (timeoutId) { - clearTimeout(timeoutId); - } - - // Sanitize output + if (timeoutId) clearTimeout(timeoutId); + const sanitizedStdout = sanitizeOutput(stdout, passwords); const sanitizedStderr = sanitizeOutput(stderr, passwords); - + if (code === 0) { resolve({ stdout: sanitizedStdout, @@ -271,13 +258,9 @@ export async function pcexec(command: string, options: PcExecOptions = {}): Prom reject(error); } }); - - // Handle process error + child.on('error', (err) => { - if (timeoutId) { - clearTimeout(timeoutId); - } - + if (timeoutId) clearTimeout(timeoutId); const error = new Error(`Failed to execute command: ${err.message}`) as PcExecError; error.stdout = sanitizeOutput(stdout, passwords); error.stderr = sanitizeOutput(stderr, passwords); @@ -287,80 +270,44 @@ export async function pcexec(command: string, options: PcExecOptions = {}): Prom } /** - * Synchronous version of pcexec - * Note: Password sanitization is still applied + * Synchronous version — password substitution is NOT supported here + * (use async pcexec for pass_mgr integration). */ -export function pcexecSync(command: string, options: PcExecOptions = {}): PcExecResult { +export function pcexecSync( + command: string, + options: PcExecOptions = {}, +): PcExecResult { const { execSync } = require('child_process'); - - // Set up environment + const env: Record = {}; - - // Copy process.env, filtering out undefined values - for (const [key, value] of Object.entries(process.env)) { - if (value !== undefined) { - env[key] = value; - } + for (const [k, v] of Object.entries(process.env)) { + if (v !== undefined) env[k] = v; } - - // Merge options.env - if (options.env) { - Object.assign(env, options.env); - } - - if (process.env.AGENT_WORKSPACE) { - env.AGENT_WORKSPACE = process.env.AGENT_WORKSPACE; - } - if (process.env.AGENT_ID) { - env.AGENT_ID = process.env.AGENT_ID; - } - - // For sync version, we need to pre-resolve passwords - // This is a limitation - passwords will be in command - const passMgrGets = extractPassMgrGets(command); - let finalCommand = command; - const passwords: string[] = []; - - // Note: In sync version, we can't async fetch passwords - // So we use the original command and rely on the user to not use pass_mgr gets in sync mode - // Or they need to resolve passwords beforehand - - const execOptions: any = { - cwd: options.cwd, - env, - // Don't use shell by default - shell: options.shell, - encoding: 'utf8', - windowsHide: options.windowsHide, - uid: options.uid, - gid: options.gid, - maxBuffer: options.maxBuffer, - timeout: options.timeout, - killSignal: options.killSignal, - }; - + if (options.env) Object.assign(env, options.env); + try { - const stdout = execSync(finalCommand, execOptions); - const sanitizedStdout = sanitizeOutput(stdout.toString(), passwords); - - return { - stdout: sanitizedStdout, - stderr: '', - exitCode: 0, - command: finalCommand, - }; + const stdout = execSync(command, { + cwd: options.cwd, + env, + shell: options.shell as any, + encoding: 'utf8', + windowsHide: options.windowsHide, + uid: options.uid, + gid: options.gid, + maxBuffer: options.maxBuffer, + timeout: options.timeout, + killSignal: options.killSignal, + }); + + return { stdout: stdout.toString(), stderr: '', exitCode: 0, command }; } catch (err: any) { - const sanitizedStdout = sanitizeOutput(err.stdout?.toString() || '', passwords); - const sanitizedStderr = sanitizeOutput(err.stderr?.toString() || '', passwords); - const error = new Error(`Command failed: ${command}`) as PcExecError; error.code = err.status; error.signal = err.signal; - error.stdout = sanitizedStdout; - error.stderr = sanitizedStderr; + error.stdout = err.stdout?.toString() || ''; + error.stderr = err.stderr?.toString() || ''; throw error; } } -// Default export export default pcexec;