chore: Merge branch 'main' into channels

This commit is contained in:
Jack Wotherspoon
2026-03-24 21:46:06 -07:00
319 changed files with 21618 additions and 9657 deletions
+4 -1
View File
@@ -89,9 +89,12 @@ export interface HookPayload {
* Payload for the 'hook-start' event.
*/
export interface HookStartPayload extends HookPayload {
/**
* The source of the hook configuration.
*/
source?: string;
/**
* The 1-based index of the current hook in the execution sequence.
* Used for progress indication (e.g. "Hook 1/3").
*/
hookIndex?: number;
/**
+3 -13
View File
@@ -35,19 +35,13 @@ describe('planUtils', () => {
const fullPath = path.join(tempRootDir, planPath);
fs.writeFileSync(fullPath, '# My Plan');
const result = await validatePlanPath(planPath, plansDir, tempRootDir);
const result = await validatePlanPath(planPath, plansDir);
expect(result).toBeNull();
});
it('should return error for path traversal', async () => {
const planPath = path.join('..', 'secret.txt');
const result = await validatePlanPath(planPath, plansDir, tempRootDir);
expect(result).toContain('Access denied');
});
it('should return error for non-existent file', async () => {
const planPath = path.join('plans', 'ghost.md');
const result = await validatePlanPath(planPath, plansDir, tempRootDir);
const result = await validatePlanPath(planPath, plansDir);
expect(result).toContain('Plan file does not exist');
});
@@ -60,11 +54,7 @@ describe('planUtils', () => {
// Create a symbolic link pointing outside the plans directory
fs.symlinkSync(outsideFile, fullMaliciousPath);
const result = await validatePlanPath(
maliciousPath,
plansDir,
tempRootDir,
);
const result = await validatePlanPath(maliciousPath, plansDir);
expect(result).toContain('Access denied');
});
});
+5 -5
View File
@@ -13,8 +13,8 @@ import { isSubpath, resolveToRealPath } from './paths.js';
* Shared between backend tools and CLI UI for consistency.
*/
export const PlanErrorMessages = {
PATH_ACCESS_DENIED:
'Access denied: plan path must be within the designated plans directory.',
PATH_ACCESS_DENIED: (planPath: string, plansDir: string) =>
`Access denied: plan path (${planPath}) must be within the designated plans directory (${plansDir}).`,
FILE_NOT_FOUND: (path: string) =>
`Plan file does not exist: ${path}. You must create the plan file before requesting approval.`,
FILE_EMPTY:
@@ -32,14 +32,14 @@ export const PlanErrorMessages = {
export async function validatePlanPath(
planPath: string,
plansDir: string,
targetDir: string,
): Promise<string | null> {
const resolvedPath = path.resolve(targetDir, planPath);
const safeFilename = path.basename(planPath);
const resolvedPath = path.join(plansDir, safeFilename);
const realPath = resolveToRealPath(resolvedPath);
const realPlansDir = resolveToRealPath(plansDir);
if (!isSubpath(realPlansDir, realPath)) {
return PlanErrorMessages.PATH_ACCESS_DENIED;
return PlanErrorMessages.PATH_ACCESS_DENIED(planPath, realPlansDir);
}
if (!(await fileExists(resolvedPath))) {
+39 -2
View File
@@ -19,6 +19,7 @@ import {
getShellConfiguration,
initializeShellParsers,
parseCommandDetails,
splitCommands,
stripShellWrapper,
hasRedirection,
resolveExecutable,
@@ -119,8 +120,10 @@ describe('getCommandRoots', () => {
expect(getCommandRoots('ls -l')).toEqual(['ls']);
});
it('should handle paths and return the binary name', () => {
expect(getCommandRoots('/usr/local/bin/node script.js')).toEqual(['node']);
it('should handle paths and return the full path', () => {
expect(getCommandRoots('/usr/local/bin/node script.js')).toEqual([
'/usr/local/bin/node',
]);
});
it('should return an empty array for an empty string', () => {
@@ -302,6 +305,40 @@ describeWindowsOnly('PowerShell integration', () => {
});
});
describe('splitCommands', () => {
it('should split chained commands', () => {
expect(splitCommands('ls -l && git status')).toEqual([
'ls -l',
'git status',
]);
});
it('should filter out redirection tokens but keep command parts', () => {
// Standard redirection
expect(splitCommands('echo "hello" > file.txt')).toEqual(['echo "hello"']);
expect(splitCommands('printf "test" >> log.txt')).toEqual([
'printf "test"',
]);
expect(splitCommands('cat < input.txt')).toEqual(['cat']);
// Heredoc/Herestring
expect(splitCommands('cat << EOF\nhello\nEOF')).toEqual(['cat']);
// Note: The Tree-sitter bash parser includes the herestring in the main
// command node's text, unlike standard redirections which are siblings.
expect(splitCommands('grep "foo" <<< "foobar"')).toEqual([
'grep "foo" <<< "foobar"',
]);
});
it('should extract nested commands from process substitution while filtering the redirection operator', () => {
// This is the key security test: we want cat to be checked, but not the > >(...) wrapper part
const parts = splitCommands('echo "foo" > >(cat)');
expect(parts).toContain('echo "foo"');
expect(parts).toContain('cat');
expect(parts.some((p) => p.includes('>'))).toBe(false);
});
});
describe('stripShellWrapper', () => {
it('should strip sh -c with quotes', () => {
expect(stripShellWrapper('sh -c "ls -l"')).toEqual('ls -l');
+6 -7
View File
@@ -264,11 +264,7 @@ function normalizeCommandName(raw: string): string {
return raw.slice(1, -1);
}
}
const trimmed = raw.trim();
if (!trimmed) {
return trimmed;
}
return trimmed.split(/[\\/]/).pop() ?? trimmed;
return raw.trim();
}
function extractNameFromNode(node: Node): string | null {
@@ -667,7 +663,10 @@ export function splitCommands(command: string): string[] {
return [];
}
return parsed.details.map((detail) => detail.text).filter(Boolean);
return parsed.details
.filter((detail) => !REDIRECTION_NAMES.has(detail.name))
.map((detail) => detail.text)
.filter(Boolean);
}
/**
@@ -705,7 +704,7 @@ export function getCommandRoots(command: string): string[] {
export function stripShellWrapper(command: string): string {
const pattern =
/^\s*(?:(?:sh|bash|zsh)\s+-c|cmd\.exe\s+\/c|powershell(?:\.exe)?\s+(?:-NoProfile\s+)?-Command|pwsh(?:\.exe)?\s+(?:-NoProfile\s+)?-Command)\s+/i;
/^\s*(?:(?:(?:\S+\/)?(?:sh|bash|zsh))\s+-c|cmd\.exe\s+\/c|powershell(?:\.exe)?\s+(?:-NoProfile\s+)?-Command|pwsh(?:\.exe)?\s+(?:-NoProfile\s+)?-Command)\s+/i;
const match = command.match(pattern);
if (match) {
let newCommand = command.substring(match[0].length).trim();