Files
PaddedCell/plugin/tools/pcexec.ts
zhi ddaea57f2d 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
2026-03-08 21:12:27 +00:00

314 lines
8.4 KiB
TypeScript

import { spawn, SpawnOptions } from 'child_process';
export interface PcExecOptions {
/** Current working directory */
cwd?: string;
/** Environment variables */
env?: Record<string, string>;
/** Timeout in milliseconds */
timeout?: number;
/** Maximum buffer size for stdout/stderr */
maxBuffer?: number;
/** Kill signal */
killSignal?: NodeJS.Signals;
/** Shell to use */
shell?: string | boolean;
/** UID to run as */
uid?: number;
/** GID to run as */
gid?: number;
/** Window style (Windows only) */
windowsHide?: boolean;
}
export interface PcExecResult {
/** Standard output */
stdout: string;
/** Standard error */
stderr: string;
/** Exit code */
exitCode: number;
/** Command that was executed */
command: string;
}
export interface PcExecError extends Error {
/** Exit code */
code?: number;
/** Signal that terminated the process */
signal?: string;
/** Standard output */
stdout: string;
/** Standard error */
stderr: string;
/** Killed by timeout */
killed?: boolean;
}
/**
* 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; 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,
];
for (const pattern of newPatterns) {
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-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 to retrieve a secret.
* Uses the same env vars that the caller passes so pcguard checks pass.
*/
async function fetchPassword(
subcommand: string,
key: string,
env: Record<string, string>,
): Promise<string> {
return new Promise((resolve, reject) => {
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, ...env },
});
let stdout = '';
let stderr = '';
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 ${subcommand} failed: ${stderr || stdout}`));
} else {
resolve(stdout.trim());
}
});
child.on('error', reject);
});
}
/**
* Sanitize output by replacing passwords with ######
*/
function sanitizeOutput(output: string, passwords: string[]): string {
let sanitized = output;
for (const password of passwords) {
if (password) {
const escaped = password.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
sanitized = sanitized.replace(new RegExp(escaped, 'g'), '######');
}
}
return sanitized;
}
/**
* Pre-resolve pass_mgr invocations, replace them inline, and collect passwords.
*/
async function replacePassMgrGets(
command: string,
env: Record<string, string>,
): Promise<{ command: string; passwords: string[] }> {
const matches = extractPassMgrGets(command);
const passwords: string[] = [];
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: replaced, passwords };
}
/**
* Safe exec wrapper that handles pass_mgr get commands and sanitizes output.
*/
export async function pcexec(
command: string,
options: PcExecOptions = {},
): Promise<PcExecResult> {
// Build environment
const env: Record<string, string> = {};
for (const [k, v] of Object.entries(process.env)) {
if (v !== undefined) env[k] = v;
}
if (options.env) Object.assign(env, options.env);
// Pre-resolve passwords
let finalCommand = command;
let passwords: string[] = [];
const resolved = await replacePassMgrGets(command, env);
finalCommand = resolved.command;
passwords = resolved.passwords;
return new Promise((resolve, reject) => {
const spawnOptions: SpawnOptions = {
cwd: options.cwd,
env,
shell: options.shell,
windowsHide: options.windowsHide,
uid: options.uid,
gid: options.gid,
};
const child = spawn('bash', ['-c', finalCommand], spawnOptions);
let stdout = '';
let stderr = '';
let killed = false;
let timeoutId: NodeJS.Timeout | null = null;
if (options.timeout && options.timeout > 0) {
timeoutId = setTimeout(() => {
killed = true;
child.kill(options.killSignal || 'SIGTERM');
}, options.timeout);
}
child.stdout?.on('data', (data) => {
stdout += data.toString();
if (options.maxBuffer && stdout.length > options.maxBuffer) {
child.kill(options.killSignal || 'SIGTERM');
}
});
child.stderr?.on('data', (data) => {
stderr += data.toString();
if (options.maxBuffer && stderr.length > options.maxBuffer) {
child.kill(options.killSignal || 'SIGTERM');
}
});
child.on('close', (code, signal) => {
if (timeoutId) clearTimeout(timeoutId);
const sanitizedStdout = sanitizeOutput(stdout, passwords);
const sanitizedStderr = sanitizeOutput(stderr, passwords);
if (code === 0) {
resolve({
stdout: sanitizedStdout,
stderr: sanitizedStderr,
exitCode: 0,
command: finalCommand,
});
} else {
const error = new Error(`Command failed: ${command}`) as PcExecError;
error.code = code ?? undefined;
error.signal = signal ?? undefined;
error.stdout = sanitizedStdout;
error.stderr = sanitizedStderr;
error.killed = killed;
reject(error);
}
});
child.on('error', (err) => {
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);
reject(error);
});
});
}
/**
* Synchronous version — password substitution is NOT supported here
* (use async pcexec for pass_mgr integration).
*/
export function pcexecSync(
command: string,
options: PcExecOptions = {},
): PcExecResult {
const { execSync } = require('child_process');
const env: Record<string, string> = {};
for (const [k, v] of Object.entries(process.env)) {
if (v !== undefined) env[k] = v;
}
if (options.env) Object.assign(env, options.env);
try {
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 error = new Error(`Command failed: ${command}`) as PcExecError;
error.code = err.status;
error.signal = err.signal;
error.stdout = err.stdout?.toString() || '';
error.stderr = err.stderr?.toString() || '';
throw error;
}
}
export default pcexec;