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 <key>' kept for backward compat - Storage: pc-pass-store/<agent-id>/<key>.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 <key>' - 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=<secret> README.md: - Document new pass_mgr command format - Document admin handoff/init-from workflow - Document security model limitations - Update project structure
This commit is contained in:
@@ -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 <key>) / `pass_mgr get <key>`
|
||||
* New: $(pass_mgr get-secret --key <key>) / `pass_mgr get-secret --key <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<string>();
|
||||
|
||||
// New format: pass_mgr get-secret --key <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 <key>
|
||||
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<string> {
|
||||
async function fetchPassword(
|
||||
subcommand: string,
|
||||
key: string,
|
||||
env: Record<string, string>,
|
||||
): Promise<string> {
|
||||
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<string, string>,
|
||||
): 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<PcExecResult> {
|
||||
// Set up environment with workspace/agent info
|
||||
export async function pcexec(
|
||||
command: string,
|
||||
options: PcExecOptions = {},
|
||||
): Promise<PcExecResult> {
|
||||
// Build environment
|
||||
const env: Record<string, string> = {};
|
||||
|
||||
// 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<string, string> = {};
|
||||
|
||||
// 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;
|
||||
|
||||
Reference in New Issue
Block a user